Compare commits

...
37 Commits
Author SHA1 Message Date
qlionbleusam b789df8dac Merge branch 'hotfix/didacticiel' 2026-09-09 14:32:41 +02:00
streaper2 25caf6ddf8 correction overlay didacticiel
Origine du problème (BoxConstraints forces an infinite width / RenderPhysicalShape) :
Hauteur non bornée dans le Positioned :
Lorsque la bulle d'aide était positionnée via Positioned(top: ..., bottom: null), Flutter transmettait une contrainte de hauteur infinie (maxHeight: double.infinity).
Le bouton ElevatedButton calcule son ombre et sa forme physique via RenderPhysicalShape en appelant constraints.biggest ; avec une dimension infinie, Flutter générait une contrainte invalide (BoxConstraints(w=Infinity, 50.0<=h<=Infinity)) et crashait le rendu (ce qui provoquait l'écran gris/noir).
Nids de Row imbriqués dans le pied de la carte d'étape.
Solutions apportées :

tutorial_overlay.dart
 :
Hauteur maximale bornée : _buildTooltip calcule et transmet désormais la hauteur disponible réelle de l'écran (availableHeight) via ConstrainedBox(maxHeight: available).
Bouton moderne Material 3 : Remplacement par FilledButton pour le bouton d'action principale ("Suivant" / "C'est parti").
Structure simplifiée : Aplatissement de la barre inférieure avec un Row unique utilisant Spacer().

full_app_render_test.dart
 :
Ajout d'un test d'intégration complet validant le démarrage de l'application et la navigation étape par étape du didacticiel.
2026-08-30 11:01:51 +02:00
qlionbleusam a9651588bb Merge branch 'feature/didacticiel' 2026-08-29 12:04:41 +02:00
qlionbleusamandClaude Opus 5 32582aba3d feat(tutorial): guide the first launch with spotlighted steps and gesture hands
Ajoute un didacticiel joué à la première utilisation : un voile sombre perce un
trou de lumière autour de l'élément à découvrir, une main animée mime le geste
attendu (tap, appui long, glisser, pincement à deux doigts pour zoomer) et une
bulle explique l'étape avec sa progression.

- visite d'accueil : nouvelle session, télémétrie, barre de navigation, réglages
- visite de l'éditeur d'impacts : ajouter, déplacer, zoomer au pincement, valider
- chaque visite n'est jouée qu'une fois (SharedPreferences)
- Paramètres > Aide & didacticiel > Revoir le didacticiel : réinitialise tout et
  relance la visite au retour sur l'écran concerné

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 11:46:02 +02:00
qlionbleusam 32143c5bb1 Merge branch 'feature/detection-cible-au-sol' 2026-08-29 11:24:28 +02:00
qlionbleusam 6ee0839db5 feat(capture): detect the flat pose for targets laid on the ground 2026-08-29 11:07:46 +02:00
qlionbleusam 8804d8b6a1 feat(capture): detect the flat pose for targets laid on the ground 2026-08-29 10:33:59 +02:00
qlionbleusam 869835f234 Merge branch 'feature/ai-contribution-filter' 2026-08-29 10:19:42 +02:00
qlionbleusam 3d9e574309 feat(ai-export): annotate uploads with the expected shot count 2026-08-29 10:19:01 +02:00
qlionbleusam 3f79252bb5 Merge branch 'feature/ai-consent-wording' 2026-08-29 10:07:31 +02:00
qlionbleusam f6f134f2a5 docs(ai-export): state what the training upload sends and drop the anonymity claim 2026-08-29 10:04:09 +02:00
qlionbleusam 56e88c3e06 Merge branch 'fix/ai-export-privacy' 2026-08-29 09:42:25 +02:00
qlionbleusam 0ab9043d82 fix(ai-export): strip photo metadata and minimize the training payload 2026-08-29 09:39:31 +02:00
qlionbleusam eeb857d452 Merge branch 'feature/ajout-armurerie' 2026-08-29 09:21:55 +02:00
qlionbleusam 6dc525c0db fix(session): keep weapon selection valid when returning from the armory 2026-08-29 09:11:20 +02:00
qlionbleusam 96cc487b55 feat(session): add armory shortcut and 50-shot preset 2026-08-29 08:48:41 +02:00
streaper2 8dc6542603 feat: implement AI export service and establish project navigation and theming infrastructure + acces internet
Build & Release Android APK / build-apk (push) Successful in 23m34s
2026-08-27 18:32:59 +02:00
streaper2 e943538133 docs: update changelog with v0.0.7 release notes covering CI/CD, backend improvements, and mobile app features 2026-08-27 08:46:35 +02:00
streaper2 7f1fa2d80b feat(ci): add persistent caching for Gradle, Pub, and Android SDK components 2026-08-26 22:53:40 +02:00
streaper2 a2f7bfc158 fix(ci): remove /data/build completely before clone
Build & Release Android APK / build-apk (push) Successful in 21m20s
2026-08-26 22:51:32 +02:00
streaper2 d0a7700d02 feat: add Gitea workflow to build and release Android APKs via Flutter Docker container
Build & Release Android APK / build-apk (push) Failing after 1s
2026-08-26 22:47:18 +02:00
streaper2 9b52623ebe fix(ci): clone into shared /data/build volume for flutter build
Build & Release Android APK / build-apk (push) Failing after 21m37s
2026-08-26 22:20:17 +02:00
streaper2 ab12e07847 fix(ci): use gitea-act-runner container name for volume
Build & Release Android APK / build-apk (push) Failing after 2s
2026-08-26 22:18:59 +02:00
streaper2 98b9f1cd4c feat: add Gitea workflow to build and release Android APKs on version tags
Build & Release Android APK / build-apk (push) Failing after 2s
2026-08-26 22:16:37 +02:00
streaper2 e889456bfa fix: correction accès backendia avec yunohost
Build & Release Android APK / build-apk (push) Failing after 2m1s
2026-08-26 22:10:03 +02:00
streaper2 c0177b19e3 correction chemin des photos backendia
Deploy Backendia / deploy (push) Successful in 19s
2026-08-26 09:11:20 +02:00
streaper2 99abf60b52 feat: implement automated deployment pipeline and add identity management settings screen with account status synchronization
Deploy Backendia / deploy (push) Successful in 3s
2026-08-26 09:05:30 +02:00
streaper2 6e09ea25dd feat: implement backend server with SQLite logging and target validation middleware
Deploy Backendia / deploy (push) Successful in 40s
2026-08-26 09:01:25 +02:00
streaper2 9a429d476d feat: initialize backend service with Express, SQLite logging, and API utilities
Deploy Backendia / deploy (push) Successful in 5m0s
2026-08-26 08:42:16 +02:00
streaper2 7923d1b2b2 ci: correction compilation sqlite
Deploy Backendia / deploy (push) Successful in 3m0s
2026-08-26 08:34:54 +02:00
streaper2 7525c7e368 ci: delete de l'ancien container 2026-08-26 08:29:39 +02:00
streaper2 e111f76731 ci: fix port container
Deploy Backendia / deploy (push) Successful in 3s
2026-08-26 08:28:21 +02:00
streaper2 5d7d5e6b54 ci: fix node container
Deploy Backendia / deploy (push) Successful in 1m22s
2026-08-26 08:10:16 +02:00
streaper2 bc77462c27 ci: fix
Deploy Backendia / deploy (push) Failing after 1s
2026-08-26 08:09:07 +02:00
streaper2 4437a1f436 ci: changement host docker
Deploy Backendia / deploy (push) Canceled after 0s
2026-08-26 08:05:22 +02:00
streaper2 1b2310b12b ci: ajout du deploiement continu avec gitea actions
Deploy Backendia / deploy (push) Failing after 42s
2026-08-25 22:28:33 +02:00
streaper2 44ac4462a6 feat(ai-training): ajout validation OpenCV, gestion des logs et modération des contributions
- Backend IA :
  - Intégration d'OpenCV (WebAssembly) pour la détection et certification des cibles (anneaux concentriques, confiance).
  - Ajout du système de journalisation complet des uploads (SQLite, IP, appareil, statut, métadonnées).
  - Implémentation du système de modération (bannissement/débannissement de wallets, rejet 403 avec motifs).
  - Harmonisation des codes d'erreur et messages JSON (UPLOAD_SUCCESS, WALLET_BANNED, etc.).
  - Dashboard Next.js : page dédiée aux logs avec filtres, export CSV et actions de modération en un clic.
- Application Mobile (Flutter) :
  - Encapsulation des réponses d'export dans AiExportResult avec gestion fine des erreurs et statuts.
  - Mise à jour du disclaimer de participation à l'entraînement IA avec avertissements stricts de bannissement.
  - Révocation de l'envoi de photos et affichage d'un bandeau explicatif en cas de suspension.
  - Ajout d'un bouton manuel « Actualiser mon statut » dans les paramètres pour synchroniser l'état du compte.
  - Possibilité de configurer l'URL/IP du serveur IA directement depuis les paramètres.
2026-08-25 12:16:29 +02:00
57 changed files with 8640 additions and 1249 deletions
+81
View File
@@ -0,0 +1,81 @@
name: Build & Release Android APK
on:
push:
tags:
- 'v*'
- 'V*' # Se déclenche quand vous poussez un tag comme v1.0.0, v1.0.1...
workflow_dispatch: # Permet aussi de lancer la compilation manuellement depuis l'interface Gitea
jobs:
build-apk:
# Utilise votre runner hôte avec Docker
runs-on: docker
steps:
- name: 📥 Récupération du code
run: |
echo "📥 Clonage du code source dans /data/build..."
rm -rf /data/build
git config --global --add safe.directory "*"
git clone --depth 1 "https://${{ github.actor }}:${{ secrets.GITHUB_TOKEN }}@git.kevlar.cloud/${{ github.repository }}.git" /data/build
- name: 🔨 Compilation de l'APK (Conteneur Flutter / Android SDK avec Caches)
run: |
echo "🚀 Démarrage de la compilation Flutter avec Caches persistants..."
mkdir -p /data/caches/gradle /data/caches/pub /data/caches/android-ndk /data/caches/android-cmake /data/caches/android-platforms
# Montage des caches persistants : Gradle, Pub, NDK, CMake et Plateformes Android
docker run --rm \
--volumes-from gitea-act-runner \
-v /data/caches/gradle:/root/.gradle \
-v /data/caches/pub:/root/.pub-cache \
-v /data/caches/android-ndk:/opt/android-sdk-linux/ndk \
-v /data/caches/android-cmake:/opt/android-sdk-linux/cmake \
-v /data/caches/android-platforms:/opt/android-sdk-linux/platforms \
-w /data/build \
ghcr.io/cirruslabs/flutter:stable \
sh -c "git config --global --add safe.directory '*' && flutter pub get && flutter build apk --release"
echo "✅ APK généré avec succès dans /data/build/build/app/outputs/flutter-apk/app-release.apk"
- name: 📦 Publication de la Release sur Gitea & Upload de l'APK
run: |
which curl >/dev/null 2>&1 || apk add --no-cache curl
TAG_NAME="${{ github.ref_name }}"
if [ -z "$TAG_NAME" ] || [ "$TAG_NAME" = "main" ]; then
TAG_NAME="build-$(date +'%Y%m%d-%H%M%S')"
fi
APK_PATH="/data/build/build/app/outputs/flutter-apk/app-release.apk"
APK_NAME="bully-impact-${TAG_NAME}.apk"
echo "🚀 Création de la release $TAG_NAME sur Gitea..."
# 1. Création de la Release via l'API REST de Gitea
RELEASE_RESPONSE=$(curl -s -X POST "https://git.kevlar.cloud/api/v1/repos/${{ github.repository }}/releases" \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
-H "Content-Type: application/json" \
-d "{
\"tag_name\": \"${TAG_NAME}\",
\"name\": \"Version ${TAG_NAME}\",
\"body\": \"Nouvelle version de l'application Bully Impact générée automatiquement par CI/CD.\",
\"draft\": false,
\"prerelease\": false
}")
RELEASE_ID=$(echo "$RELEASE_RESPONSE" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
if [ -z "$RELEASE_ID" ]; then
echo "❌ Erreur lors de la création de la release: $RELEASE_RESPONSE"
exit 1
fi
echo "📦 Upload du fichier APK (Release ID: $RELEASE_ID)..."
# 2. Upload de l'APK en tant qu'asset téléchargeable
curl -s -X POST "https://git.kevlar.cloud/api/v1/repos/${{ github.repository }}/releases/${RELEASE_ID}/assets?name=${APK_NAME}" \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
-H "Content-Type: application/vnd.android.package-archive" \
--data-binary @"$APK_PATH"
echo "🎉 Release terminée avec succès ! APK téléchargeable sur Gitea."
+30
View File
@@ -0,0 +1,30 @@
name: Deploy Backendia
on:
push:
branches:
- main
paths:
- 'backendia/**'
- 'docker-compose.prod.yml'
- '.gitea/workflows/deploy.yaml'
workflow_dispatch:
jobs:
deploy:
# Correspond au label 'docker:host' de votre runner
runs-on: docker
steps:
- name: 📥 Récupération du code
run: |
echo "📥 Récupération du code..."
git clone --depth 1 --branch ${{ github.ref_name }} "https://${{ github.actor }}:${{ secrets.GITHUB_TOKEN }}@git.kevlar.cloud/${{ github.repository }}.git" .
- name: 🚀 Build et Déploiement Docker
run: |
echo "🚀 Démarrage du déploiement..."
docker rm -f backendia-prod 2>/dev/null || true
docker compose -f docker-compose.prod.yml up -d --build --remove-orphans --force-recreate
echo "🧹 Nettoyage des anciennes images inutilisées..."
docker image prune -f
echo "✅ Déploiement terminé avec succès !"
+35
View File
@@ -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é
+19
View File
@@ -98,6 +98,19 @@ flutter test --coverage
- 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)
@@ -135,3 +148,9 @@ 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 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…)
+9
View File
@@ -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:
+4 -1
View File
@@ -1,4 +1,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.CAMERA" />
<!-- Pour Android 12 et inférieur --> <!-- Pour Android 12 et inférieur -->
@@ -9,7 +11,8 @@
<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"
+4
View File
@@ -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
+8
View File
@@ -0,0 +1,8 @@
node_modules
npm-debug.log*
dashboard/node_modules
dashboard/.next
uploads
exports
.git
.env*
+31
View File
@@ -0,0 +1,31 @@
FROM node:20-bookworm-slim
WORKDIR /app
# Dépendances système pour les modules natifs (sharp, sqlite3, opencv)
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 \
make \
g++ \
&& rm -rf /var/lib/apt/lists/*
COPY package*.json ./
COPY dashboard/package*.json ./dashboard/
RUN npm install && npm rebuild sqlite3 --build-from-source
RUN cd dashboard && npm install
COPY . .
# Build du Dashboard Next.js
RUN cd dashboard && npm run build
# Dossiers nécessaires
RUN mkdir -p uploads/images uploads/data exports
EXPOSE 3000
ENV NODE_ENV=production
ENV PORT=3000
CMD ["node", "server.js"]
+8 -1
View File
@@ -2,7 +2,7 @@ import type { Metadata } from "next";
import { Inter } from "next/font/google"; import { Inter } from "next/font/google";
import "./globals.css"; import "./globals.css";
import Link from "next/link"; import Link from "next/link";
import { LayoutDashboard, Users, Image as ImageIcon } from "lucide-react"; import { LayoutDashboard, Users, ScrollText, Image as ImageIcon } from "lucide-react";
const inter = Inter({ subsets: ["latin"] }); const inter = Inter({ subsets: ["latin"] });
@@ -41,6 +41,13 @@ export default function RootLayout({
<Users size={20} /> <Users size={20} />
Contributeurs Contributeurs
</Link> </Link>
<Link
href="/logs"
className="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-slate-800 transition-colors text-slate-300 hover:text-white"
>
<ScrollText size={20} />
Logs d'Uploads
</Link>
</nav> </nav>
<div className="mt-auto pt-6 border-t border-slate-800"> <div className="mt-auto pt-6 border-t border-slate-800">
@@ -0,0 +1,900 @@
"use client";
import { useState, useMemo, useEffect } from "react";
import {
ScrollText,
Search,
Download,
RefreshCw,
CheckCircle2,
XCircle,
Copy,
Check,
Trash2,
Smartphone,
Crosshair,
Calendar,
HardDrive,
Users,
ExternalLink,
Info,
Filter,
Eye,
ShieldAlert,
ShieldCheck,
Ban,
AlertTriangle
} from "lucide-react";
import { API_BASE_URL } from "@/lib/api";
import Link from "next/link";
interface UploadLog {
id: number;
timestamp: string;
session_id: string | null;
wallet_hash: string | null;
image_filename: string | null;
json_filename: string | null;
file_size: number | null;
ip_address: string | null;
user_agent: string | null;
device_model: string | null;
device_os: string | null;
target_type: string | null;
weapon: string | null;
distance_meters: number | null;
impacts_count: number;
status: string;
target_valid?: number;
target_status?: string;
target_confidence?: number;
target_rings_count?: number;
target_details?: string | null;
error_message: string | null;
raw_metadata: string | null;
}
interface BannedWallet {
wallet_hash: string;
reason: string;
banned_at: string;
banned_by: string;
}
interface LogStats {
total_uploads: number;
success_count: number;
failed_count: number;
today_uploads: number;
unique_wallets: number;
total_bytes: number;
latest_upload: string | null;
}
export default function LogsManager({
initialLogs,
initialStats
}: {
initialLogs: UploadLog[];
initialStats: LogStats | null;
}) {
const [logs, setLogs] = useState<UploadLog[]>(initialLogs);
const [stats, setStats] = useState<LogStats | null>(initialStats);
const [bannedWallets, setBannedWallets] = useState<BannedWallet[]>([]);
const [searchTerm, setSearchTerm] = useState("");
const [statusFilter, setStatusFilter] = useState<"ALL" | "SUCCESS" | "FAILED" | "INVALID_TARGET" | "BANNED">("ALL");
const [copiedWallet, setCopiedWallet] = useState<string | null>(null);
const [selectedLog, setSelectedLog] = useState<UploadLog | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [showClearConfirm, setShowClearConfirm] = useState(false);
// Moderation state
const [banModalWallet, setBanModalWallet] = useState<string | null>(null);
const [banReason, setBanReason] = useState("Images non conformes / Fausse cible");
const [isSubmittingBan, setIsSubmittingBan] = useState(false);
const fetchBannedWallets = async () => {
try {
const res = await fetch(`${API_BASE_URL}/api/moderation/banned`, { cache: 'no-store' });
if (res.ok) {
const data = await res.json();
setBannedWallets(data.banned || []);
}
} catch (e) {
console.error("Erreur récupération wallets bannis:", e);
}
};
useEffect(() => {
fetchBannedWallets();
}, []);
const fetchLogs = async () => {
setIsLoading(true);
try {
const [logsRes, statsRes] = await Promise.all([
fetch(`${API_BASE_URL}/api/logs?limit=200`, { cache: 'no-store' }),
fetch(`${API_BASE_URL}/api/logs/stats`, { cache: 'no-store' }),
fetchBannedWallets()
]);
if (logsRes.ok) {
const logsData = await logsRes.json();
setLogs(logsData.logs || []);
}
if (statsRes.ok) {
const statsData = await statsRes.json();
setStats(statsData.stats || null);
}
} catch (e) {
console.error("Erreur lors de la récupération des logs:", e);
} finally {
setIsLoading(false);
}
};
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
setCopiedWallet(text);
setTimeout(() => setCopiedWallet(null), 2000);
};
const isWalletBanned = (walletHash: string | null) => {
if (!walletHash) return false;
return bannedWallets.some(b => b.wallet_hash === walletHash);
};
const getBannedInfo = (walletHash: string | null) => {
if (!walletHash) return null;
return bannedWallets.find(b => b.wallet_hash === walletHash) || null;
};
const handleBanWallet = async () => {
if (!banModalWallet) return;
setIsSubmittingBan(true);
try {
const res = await fetch(`${API_BASE_URL}/api/moderation/ban`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
wallet_hash: banModalWallet,
reason: banReason,
banned_by: 'Admin Dashboard'
})
});
if (res.ok) {
setBanModalWallet(null);
await fetchBannedWallets();
await fetchLogs();
} else {
alert("Erreur lors du bannissement.");
}
} catch (e) {
console.error("Erreur ban:", e);
} finally {
setIsSubmittingBan(false);
}
};
const handleUnbanWallet = async (walletHash: string) => {
if (!confirm(`Voulez-vous vraiment débannir le wallet ${walletHash.substring(0, 10)}... ?`)) return;
try {
const res = await fetch(`${API_BASE_URL}/api/moderation/ban/${walletHash}`, {
method: 'DELETE'
});
if (res.ok) {
await fetchBannedWallets();
await fetchLogs();
}
} catch (e) {
console.error("Erreur unban:", e);
}
};
const handleDeleteLog = async (id: number) => {
if (!confirm("Voulez-vous vraiment supprimer ce log ?")) return;
try {
const res = await fetch(`${API_BASE_URL}/api/logs/${id}`, { method: 'DELETE' });
if (res.ok) {
setLogs(prev => prev.filter(l => l.id !== id));
fetchLogs();
}
} catch (e) {
console.error("Erreur suppression:", e);
}
};
const handleClearAll = async () => {
try {
const res = await fetch(`${API_BASE_URL}/api/logs`, { method: 'DELETE' });
if (res.ok) {
setLogs([]);
setShowClearConfirm(false);
fetchLogs();
}
} catch (e) {
console.error("Erreur vidage logs:", e);
}
};
const formatFileSize = (bytes: number | null) => {
if (!bytes || bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
};
const filteredLogs = useMemo(() => {
return logs.filter(log => {
// Filter status
if (statusFilter === "SUCCESS" && log.status !== "SUCCESS") return false;
if (statusFilter === "FAILED" && log.status !== "FAILED") return false;
if (statusFilter === "INVALID_TARGET" && log.target_status === "VALID") return false;
if (statusFilter === "BANNED" && !isWalletBanned(log.wallet_hash)) return false;
// Filter search
if (!searchTerm.trim()) return true;
const term = searchTerm.toLowerCase();
return (
(log.wallet_hash && log.wallet_hash.toLowerCase().includes(term)) ||
(log.session_id && log.session_id.toLowerCase().includes(term)) ||
(log.image_filename && log.image_filename.toLowerCase().includes(term)) ||
(log.device_model && log.device_model.toLowerCase().includes(term)) ||
(log.weapon && log.weapon.toLowerCase().includes(term)) ||
(log.target_type && log.target_type.toLowerCase().includes(term)) ||
(log.target_status && log.target_status.toLowerCase().includes(term)) ||
(log.ip_address && log.ip_address.toLowerCase().includes(term))
);
});
}, [logs, searchTerm, statusFilter, bannedWallets]);
return (
<div className="space-y-8">
{/* Header */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div className="space-y-1">
<div className="flex items-center gap-3">
<div className="p-2.5 bg-indigo-500/10 border border-indigo-500/20 rounded-xl text-indigo-400">
<ScrollText size={24} />
</div>
<div>
<h2 className="text-3xl font-bold tracking-tight">Journal & Contrôle des Uploads</h2>
<p className="text-slate-400 text-sm">
Historique des transferts, diagnostic OpenCV (détection cibles) et modération des wallets.
</p>
</div>
</div>
</div>
<div className="flex flex-wrap items-center gap-3">
<button
onClick={fetchLogs}
disabled={isLoading}
className="flex items-center gap-2 px-4 py-2.5 bg-slate-900 border border-slate-800 hover:bg-slate-800 text-slate-300 hover:text-white rounded-xl text-sm font-semibold transition-all disabled:opacity-50"
title="Rafraîchir les logs"
>
<RefreshCw size={16} className={isLoading ? "animate-spin text-indigo-400" : ""} />
<span>Actualiser</span>
</button>
<a
href={`${API_BASE_URL}/api/logs/export?format=csv`}
download
className="flex items-center gap-2 px-4 py-2.5 bg-indigo-600 hover:bg-indigo-500 text-white rounded-xl text-sm font-semibold transition-all shadow-lg shadow-indigo-600/20 hover:scale-105 active:scale-95"
>
<Download size={16} />
<span>Exporter CSV</span>
</a>
{!showClearConfirm ? (
<button
onClick={() => setShowClearConfirm(true)}
className="flex items-center gap-2 px-3 py-2.5 bg-slate-900 border border-slate-800 hover:border-rose-500/40 text-slate-400 hover:text-rose-400 rounded-xl text-sm transition-all"
title="Purger tous les logs"
>
<Trash2 size={16} />
</button>
) : (
<div className="flex items-center gap-2 p-1 bg-rose-500/10 border border-rose-500/30 rounded-xl animate-in fade-in">
<span className="text-xs text-rose-400 px-2 font-semibold">Effacer tout ?</span>
<button
onClick={handleClearAll}
className="px-2.5 py-1 bg-rose-600 hover:bg-rose-500 text-white rounded-lg text-xs font-bold transition-all"
>
Oui
</button>
<button
onClick={() => setShowClearConfirm(false)}
className="px-2.5 py-1 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-xs transition-all"
>
Non
</button>
</div>
)}
</div>
</div>
{/* KPI Stats Cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5">
<div className="bg-slate-900/80 border border-slate-800 p-5 rounded-2xl flex items-center justify-between shadow-sm">
<div className="space-y-1">
<p className="text-xs text-slate-500 font-bold uppercase tracking-wider">Total Uploads</p>
<div className="flex items-baseline gap-2">
<p className="text-2xl font-bold">{stats?.total_uploads ?? logs.length}</p>
{stats && stats.total_uploads > 0 && (
<span className="text-xs font-semibold text-emerald-400">
{Math.round(((stats.success_count || 0) / stats.total_uploads) * 100)}% succès
</span>
)}
</div>
</div>
<div className="w-11 h-11 bg-indigo-500/10 border border-indigo-500/20 text-indigo-400 rounded-xl flex items-center justify-center">
<ScrollText size={20} />
</div>
</div>
<div className="bg-slate-900/80 border border-slate-800 p-5 rounded-2xl flex items-center justify-between shadow-sm">
<div className="space-y-1">
<p className="text-xs text-slate-500 font-bold uppercase tracking-wider">Aujourd'hui</p>
<p className="text-2xl font-bold text-indigo-400">{stats?.today_uploads ?? 0}</p>
</div>
<div className="w-11 h-11 bg-indigo-500/10 border border-indigo-500/20 text-indigo-400 rounded-xl flex items-center justify-center">
<Calendar size={20} />
</div>
</div>
<div className="bg-slate-900/80 border border-slate-800 p-5 rounded-2xl flex items-center justify-between shadow-sm">
<div className="space-y-1">
<p className="text-xs text-slate-500 font-bold uppercase tracking-wider">Wallets Bannis</p>
<div className="flex items-baseline gap-2">
<p className="text-2xl font-bold text-rose-400">{bannedWallets.length}</p>
<span className="text-xs text-slate-500 font-mono">/ {stats?.unique_wallets ?? 0} total</span>
</div>
</div>
<div className="w-11 h-11 bg-rose-500/10 border border-rose-500/20 text-rose-400 rounded-xl flex items-center justify-center">
<Ban size={20} />
</div>
</div>
<div className="bg-slate-900/80 border border-slate-800 p-5 rounded-2xl flex items-center justify-between shadow-sm">
<div className="space-y-1">
<p className="text-xs text-slate-500 font-bold uppercase tracking-wider">Volume Transféré</p>
<p className="text-2xl font-bold text-emerald-400">
{formatFileSize(stats?.total_bytes || logs.reduce((acc, l) => acc + (l.file_size || 0), 0))}
</p>
</div>
<div className="w-11 h-11 bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 rounded-xl flex items-center justify-center">
<HardDrive size={20} />
</div>
</div>
</div>
{/* Filters & Search Toolbar */}
<div className="bg-slate-900 border border-slate-800 rounded-2xl p-4 flex flex-col md:flex-row gap-4 items-center justify-between">
{/* Search */}
<div className="relative w-full md:w-96">
<Search size={16} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-slate-500" />
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Rechercher par Wallet, Session, Cible, Image..."
className="w-full bg-slate-950 border border-slate-800 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 rounded-xl pl-10 pr-4 py-2 text-sm text-slate-200 placeholder-slate-500 outline-none transition-all"
/>
{searchTerm && (
<button
onClick={() => setSearchTerm("")}
className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-slate-500 hover:text-slate-300"
>
Effacer
</button>
)}
</div>
{/* Status & OpenCV Filter */}
<div className="flex items-center gap-2 w-full md:w-auto justify-start md:justify-end flex-wrap">
<span className="text-xs text-slate-500 font-semibold uppercase tracking-wider flex items-center gap-1.5 mr-1">
<Filter size={13} />
Filtre:
</span>
<div className="inline-flex bg-slate-950 p-1 rounded-xl border border-slate-800 flex-wrap gap-1">
<button
onClick={() => setStatusFilter("ALL")}
className={`px-2.5 py-1 text-xs font-semibold rounded-lg transition-all ${
statusFilter === "ALL"
? "bg-slate-800 text-white shadow-sm"
: "text-slate-400 hover:text-slate-200"
}`}
>
Tous ({logs.length})
</button>
<button
onClick={() => setStatusFilter("SUCCESS")}
className={`px-2.5 py-1 text-xs font-semibold rounded-lg transition-all ${
statusFilter === "SUCCESS"
? "bg-emerald-500/20 text-emerald-300 border border-emerald-500/30"
: "text-slate-400 hover:text-emerald-400"
}`}
>
Succès
</button>
<button
onClick={() => setStatusFilter("INVALID_TARGET")}
className={`px-2.5 py-1 text-xs font-semibold rounded-lg transition-all ${
statusFilter === "INVALID_TARGET"
? "bg-amber-500/20 text-amber-300 border border-amber-500/30"
: "text-slate-400 hover:text-amber-400"
}`}
title="Photos non reconnues comme cible par OpenCV"
>
Cibles Douteuses
</button>
<button
onClick={() => setStatusFilter("BANNED")}
className={`px-2.5 py-1 text-xs font-semibold rounded-lg transition-all ${
statusFilter === "BANNED"
? "bg-rose-500/20 text-rose-300 border border-rose-500/30"
: "text-slate-400 hover:text-rose-400"
}`}
>
Bannis ({bannedWallets.length})
</button>
</div>
</div>
</div>
{/* Logs Table */}
<div className="bg-slate-900 border border-slate-800 rounded-2xl overflow-hidden shadow-xl shadow-black/20">
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead className="bg-slate-950/60 border-b border-slate-800 text-slate-400 font-semibold text-xs uppercase tracking-wider">
<tr>
<th className="px-5 py-3.5">Date & Heure</th>
<th className="px-4 py-3.5">Diagnostic OpenCV</th>
<th className="px-4 py-3.5">Wallet Contributeur</th>
<th className="px-4 py-3.5">Session / Cible</th>
<th className="px-4 py-3.5">Appareil</th>
<th className="px-4 py-3.5">Fichier</th>
<th className="px-4 py-3.5 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/60">
{filteredLogs.map((log) => {
const dateObj = new Date(log.timestamp);
const isValidDate = !isNaN(dateObj.getTime());
const formattedDate = isValidDate
? dateObj.toLocaleDateString("fr-FR", { day: "2-digit", month: "2-digit", year: "numeric" })
: log.timestamp;
const formattedTime = isValidDate
? dateObj.toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit", second: "2-digit" })
: "";
const banned = isWalletBanned(log.wallet_hash);
return (
<tr
key={log.id}
className="hover:bg-slate-800/40 transition-colors group"
>
{/* Timestamp */}
<td className="px-5 py-4 whitespace-nowrap">
<div className="flex flex-col">
<span className="font-semibold text-slate-200">{formattedDate}</span>
<span className="text-xs text-slate-500 font-mono">{formattedTime}</span>
</div>
</td>
{/* OpenCV Diagnosis & Upload Status */}
<td className="px-4 py-4 whitespace-nowrap">
<div className="flex flex-col gap-1">
{log.status === "FAILED" ? (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-[10px] font-bold bg-rose-500/10 text-rose-400 border border-rose-500/20 max-w-fit" title={log.error_message || "Échec upload"}>
<XCircle size={12} />
UPLOAD ÉCHEC
</span>
) : log.target_status === "VALID" ? (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-[10px] font-bold bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 max-w-fit" title={log.target_details || "Cible certifiée"}>
<ShieldCheck size={12} />
CIBLE CERTIFIÉE ({Math.round((log.target_confidence || 1) * 100)}%)
</span>
) : log.target_status === "SUSPICIOUS" ? (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-[10px] font-bold bg-amber-500/10 text-amber-400 border border-amber-500/20 max-w-fit" title={log.target_details || "1 seul anneau détecté"}>
<AlertTriangle size={12} />
DOUTEUSE (1 ANNEAU)
</span>
) : (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-[10px] font-bold bg-rose-500/10 text-rose-400 border border-rose-500/20 max-w-fit" title={log.target_details || "Aucun motif de cible"}>
<ShieldAlert size={12} />
NON RECONNUE
</span>
)}
{log.target_rings_count ? (
<span className="text-[10px] text-slate-500 font-mono">
{log.target_rings_count} anneaux concentriques
</span>
) : null}
</div>
</td>
{/* Wallet Hash & Ban Badge */}
<td className="px-4 py-4">
{log.wallet_hash ? (
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<span
onClick={() => setSearchTerm(log.wallet_hash || "")}
className="font-mono text-xs text-indigo-300 hover:text-indigo-200 bg-indigo-500/5 px-2 py-1 rounded border border-indigo-500/10 hover:border-indigo-500/30 cursor-pointer transition-colors"
title="Cliquer pour filtrer par ce wallet"
>
{log.wallet_hash.length > 16
? `${log.wallet_hash.substring(0, 8)}...${log.wallet_hash.substring(log.wallet_hash.length - 8)}`
: log.wallet_hash}
</span>
<button
onClick={() => copyToClipboard(log.wallet_hash!)}
className="text-slate-500 hover:text-slate-300 transition-colors p-1"
title="Copier le hash du wallet"
>
{copiedWallet === log.wallet_hash ? (
<Check size={14} className="text-emerald-400" />
) : (
<Copy size={14} />
)}
</button>
</div>
{banned && (
<span className="inline-flex items-center gap-1 text-[10px] font-bold text-rose-400 bg-rose-500/10 px-1.5 py-0.5 rounded border border-rose-500/20 max-w-fit">
<Ban size={10} />
WALLET BANNI
</span>
)}
</div>
) : (
<span className="text-xs text-slate-500 italic">Anonyme / Non fourni</span>
)}
</td>
{/* Session & Target */}
<td className="px-4 py-4">
<div className="flex flex-col gap-1 max-w-[180px]">
{log.session_id && (
<div className="flex items-center gap-1 text-xs text-slate-300 font-mono truncate">
<span className="text-slate-500">ID:</span> {log.session_id}
</div>
)}
<div className="flex items-center flex-wrap gap-1.5">
{log.target_type && (
<span className="text-[10px] uppercase font-bold bg-slate-800 text-slate-300 px-1.5 py-0.5 rounded border border-slate-700">
{log.target_type}
</span>
)}
{log.weapon && (
<span className="text-[10px] bg-slate-800 text-slate-400 px-1.5 py-0.5 rounded">
{log.weapon}
</span>
)}
{log.impacts_count > 0 && (
<span className="text-[10px] font-bold text-indigo-400 bg-indigo-500/10 px-1.5 py-0.5 rounded border border-indigo-500/20 flex items-center gap-0.5">
<Crosshair size={10} />
{log.impacts_count} imp.
</span>
)}
</div>
</div>
</td>
{/* Device & IP */}
<td className="px-4 py-4 whitespace-nowrap">
<div className="flex flex-col gap-0.5">
<div className="flex items-center gap-1.5 text-xs text-slate-300">
<Smartphone size={13} className="text-indigo-400" />
<span>{log.device_model || log.device_os || "Inconnu"}</span>
</div>
{log.ip_address && (
<span className="text-[11px] text-slate-500 font-mono">
{log.ip_address.replace("::ffff:", "")}
</span>
)}
</div>
</td>
{/* File & Size */}
<td className="px-4 py-4 whitespace-nowrap">
<div className="flex flex-col gap-0.5">
{log.image_filename ? (
<Link
href={`/photo/${log.image_filename}`}
className="text-xs text-indigo-400 hover:text-indigo-300 font-mono flex items-center gap-1 group/link truncate max-w-[130px]"
title={log.image_filename}
>
<span className="truncate">{log.image_filename}</span>
<ExternalLink size={12} className="opacity-0 group-hover/link:opacity-100 transition-opacity" />
</Link>
) : (
<span className="text-xs text-slate-500 italic">Aucun fichier</span>
)}
<span className="text-[11px] text-slate-500 font-mono">
{formatFileSize(log.file_size)}
</span>
</div>
</td>
{/* Actions */}
<td className="px-4 py-4 text-right whitespace-nowrap">
<div className="flex items-center justify-end gap-1.5">
{/* Ban / Unban Button */}
{log.wallet_hash && (
banned ? (
<button
onClick={() => handleUnbanWallet(log.wallet_hash!)}
className="p-1.5 text-slate-400 hover:text-emerald-400 hover:bg-slate-800 rounded-lg transition-colors"
title="Débannir ce wallet"
>
<ShieldCheck size={16} />
</button>
) : (
<button
onClick={() => setBanModalWallet(log.wallet_hash)}
className="p-1.5 text-slate-500 hover:text-rose-400 hover:bg-slate-800 rounded-lg transition-colors"
title="Bannir définitivement ce wallet"
>
<Ban size={16} />
</button>
)
)}
<button
onClick={() => setSelectedLog(log)}
className="p-1.5 text-slate-400 hover:text-indigo-300 hover:bg-slate-800 rounded-lg transition-colors"
title="Voir les détails complets"
>
<Eye size={16} />
</button>
<button
onClick={() => handleDeleteLog(log.id)}
className="p-1.5 text-slate-500 hover:text-rose-400 hover:bg-slate-800 rounded-lg transition-colors"
title="Supprimer ce log"
>
<Trash2 size={16} />
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* Empty state */}
{filteredLogs.length === 0 && (
<div className="py-20 flex flex-col items-center justify-center text-center px-4">
<div className="p-4 bg-slate-950 border border-slate-800 rounded-2xl text-slate-600 mb-3">
<ScrollText size={36} />
</div>
<h3 className="text-slate-300 font-bold text-base">Aucun log trouvé</h3>
<p className="text-slate-500 text-xs mt-1 max-w-sm">
{searchTerm || statusFilter !== "ALL"
? "Aucun enregistrement ne correspond à vos critères de recherche."
: "Les logs apparaîtront ici dès que des photos ou des sessions seront uploadées."}
</p>
</div>
)}
</div>
{/* Details Modal */}
{selectedLog && (
<div className="fixed inset-0 z-50 bg-black/70 backdrop-blur-sm flex items-center justify-center p-4">
<div className="bg-slate-900 border border-slate-800 rounded-2xl w-full max-w-2xl max-h-[85vh] flex flex-col overflow-hidden shadow-2xl animate-in zoom-in-95 duration-200">
{/* Modal Header */}
<div className="p-6 border-b border-slate-800 flex items-center justify-between bg-slate-950/50">
<div className="flex items-center gap-3">
<div className="p-2 bg-indigo-500/10 border border-indigo-500/20 rounded-xl text-indigo-400">
<Info size={20} />
</div>
<div>
<h3 className="font-bold text-lg text-slate-100">Détails du Log #{selectedLog.id}</h3>
<p className="text-xs text-slate-400">{new Date(selectedLog.timestamp).toLocaleString()}</p>
</div>
</div>
<button
onClick={() => setSelectedLog(null)}
className="text-slate-400 hover:text-white p-2 hover:bg-slate-800 rounded-xl transition-colors"
>
</button>
</div>
{/* Modal Content */}
<div className="p-6 overflow-y-auto space-y-6 text-sm">
{/* OpenCV Diagnostic Banner */}
<div className={`p-4 rounded-xl border flex flex-col gap-2 ${
selectedLog.target_status === "VALID"
? "bg-emerald-500/10 border-emerald-500/20 text-emerald-300"
: selectedLog.target_status === "SUSPICIOUS"
? "bg-amber-500/10 border-amber-500/20 text-amber-300"
: "bg-rose-500/10 border-rose-500/20 text-rose-300"
}`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 font-bold">
{selectedLog.target_status === "VALID" ? <ShieldCheck size={18} /> : <AlertTriangle size={18} />}
Diagnostic OpenCV : {selectedLog.target_status || "VALID"}
</div>
<span className="text-xs font-mono font-bold px-2 py-0.5 rounded bg-black/30">
Confiance : {Math.round((selectedLog.target_confidence || 1) * 100)}%
</span>
</div>
<p className="text-xs opacity-90">{selectedLog.target_details || "Cible détectée avec succès."}</p>
</div>
{/* General Grid */}
<div className="grid grid-cols-2 gap-4">
<div className="bg-slate-950 p-4 rounded-xl border border-slate-800 space-y-1">
<span className="text-xs text-slate-500 font-bold uppercase tracking-wider">Wallet Hash</span>
<div className="font-mono text-xs text-indigo-300 break-all select-all">
{selectedLog.wallet_hash || "Non spécifié"}
</div>
{isWalletBanned(selectedLog.wallet_hash) && (
<span className="inline-block mt-1 text-[10px] text-rose-400 font-bold bg-rose-500/10 px-1.5 py-0.5 rounded border border-rose-500/20">
Banni ({getBannedInfo(selectedLog.wallet_hash)?.reason})
</span>
)}
</div>
<div className="bg-slate-950 p-4 rounded-xl border border-slate-800 space-y-1">
<span className="text-xs text-slate-500 font-bold uppercase tracking-wider">Session ID</span>
<div className="font-mono text-xs text-slate-300 break-all select-all">
{selectedLog.session_id || "N/A"}
</div>
</div>
<div className="bg-slate-950 p-4 rounded-xl border border-slate-800 space-y-1">
<span className="text-xs text-slate-500 font-bold uppercase tracking-wider">Appareil</span>
<div className="text-xs text-slate-300">
{selectedLog.device_model || "Inconnu"} ({selectedLog.device_os || "OS inconnu"})
</div>
</div>
<div className="bg-slate-950 p-4 rounded-xl border border-slate-800 space-y-1">
<span className="text-xs text-slate-500 font-bold uppercase tracking-wider">Adresse IP</span>
<div className="font-mono text-xs text-slate-300">
{selectedLog.ip_address || "Inconnue"}
</div>
</div>
<div className="bg-slate-950 p-4 rounded-xl border border-slate-800 space-y-1">
<span className="text-xs text-slate-500 font-bold uppercase tracking-wider">Cible & Arme</span>
<div className="text-xs text-slate-300">
Type: <span className="font-bold text-white">{selectedLog.target_type || "N/A"}</span> | Arme: {selectedLog.weapon || "N/A"} | {selectedLog.distance_meters ? `${selectedLog.distance_meters}m` : ""}
</div>
</div>
<div className="bg-slate-950 p-4 rounded-xl border border-slate-800 space-y-1">
<span className="text-xs text-slate-500 font-bold uppercase tracking-wider">Fichiers & Taille</span>
<div className="text-xs text-slate-300">
Image: <span className="font-mono">{selectedLog.image_filename || "N/A"}</span> ({formatFileSize(selectedLog.file_size)})
</div>
</div>
</div>
{/* Raw Metadata JSON */}
{selectedLog.raw_metadata && (
<div className="space-y-2">
<span className="text-xs text-slate-500 font-bold uppercase tracking-wider">Données Brutes (JSON)</span>
<pre className="bg-slate-950 border border-slate-800 p-4 rounded-xl text-xs font-mono text-slate-300 overflow-x-auto max-h-48">
{(() => {
try {
return JSON.stringify(JSON.parse(selectedLog.raw_metadata), null, 2);
} catch {
return selectedLog.raw_metadata;
}
})()}
</pre>
</div>
)}
</div>
{/* Modal Footer */}
<div className="p-4 border-t border-slate-800 bg-slate-950/50 flex justify-between items-center">
<div className="flex items-center gap-2">
{selectedLog.image_filename && (
<Link
href={`/photo/${selectedLog.image_filename}`}
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-xl text-xs font-bold transition-all"
>
<Eye size={14} />
Ouvrir la session
</Link>
)}
{selectedLog.wallet_hash && (
isWalletBanned(selectedLog.wallet_hash) ? (
<button
onClick={() => handleUnbanWallet(selectedLog.wallet_hash!)}
className="px-3 py-2 bg-slate-800 hover:bg-emerald-600/30 text-emerald-400 rounded-xl text-xs font-bold transition-all border border-emerald-500/20"
>
Débannir ce wallet
</button>
) : (
<button
onClick={() => {
setSelectedLog(null);
setBanModalWallet(selectedLog.wallet_hash);
}}
className="px-3 py-2 bg-rose-600/10 hover:bg-rose-600 text-rose-400 hover:text-white rounded-xl text-xs font-bold transition-all border border-rose-500/20"
>
Bannir ce wallet
</button>
)
)}
</div>
<button
onClick={() => setSelectedLog(null)}
className="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-xl text-xs font-semibold transition-all"
>
Fermer
</button>
</div>
</div>
</div>
)}
{/* Ban Confirmation Modal */}
{banModalWallet && (
<div className="fixed inset-0 z-50 bg-black/75 backdrop-blur-sm flex items-center justify-center p-4">
<div className="bg-slate-900 border border-slate-800 rounded-2xl w-full max-w-md p-6 space-y-6 shadow-2xl animate-in zoom-in-95 duration-200">
<div className="flex items-center gap-3">
<div className="p-3 bg-rose-500/10 border border-rose-500/20 rounded-xl text-rose-400">
<Ban size={24} />
</div>
<div>
<h3 className="font-bold text-lg text-slate-100">Bannir un Wallet</h3>
<p className="text-xs text-slate-400">Bloquer définitivement tout upload futur de ce compte.</p>
</div>
</div>
<div className="bg-slate-950 p-3 rounded-xl border border-slate-800 font-mono text-xs text-indigo-300 break-all">
{banModalWallet}
</div>
<div className="space-y-2">
<label className="text-xs font-bold uppercase tracking-wider text-slate-400">
Motif du bannissement
</label>
<select
value={banReason}
onChange={(e) => setBanReason(e.target.value)}
className="w-full bg-slate-950 border border-slate-800 focus:border-rose-500 rounded-xl px-3 py-2 text-sm text-slate-200 outline-none"
>
<option value="Images non conformes / Fausse cible">Images non conformes / Fausse cible</option>
<option value="Spam / Uploads abusifs répétés">Spam / Uploads abusifs répétés</option>
<option value="Contenu inapproprié ou illicite">Contenu inapproprié ou illicite</option>
<option value="Tentative de manipulation des données">Tentative de manipulation des données</option>
</select>
</div>
<div className="flex justify-end gap-3 pt-2">
<button
onClick={() => setBanModalWallet(null)}
className="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-xl text-xs font-semibold transition-all"
>
Annuler
</button>
<button
onClick={handleBanWallet}
disabled={isSubmittingBan}
className="px-4 py-2 bg-rose-600 hover:bg-rose-500 text-white rounded-xl text-xs font-bold transition-all disabled:opacity-50 flex items-center gap-1.5"
>
<Ban size={14} />
{isSubmittingBan ? "Bannissement..." : "Confirmer le Bannissement"}
</button>
</div>
</div>
</div>
)}
</div>
);
}
+30
View File
@@ -0,0 +1,30 @@
import { fetchApi } from "@/lib/api";
import LogsManager from "./LogsManager";
export const metadata = {
title: "Logs d'Uploads | Bully IA Dashboard",
description: "Journal complet des téléversements de sessions et métadonnées",
};
export default async function LogsPage() {
let logs = [];
let stats = null;
try {
const [logsRes, statsRes] = await Promise.all([
fetchApi('/api/logs?limit=100'),
fetchApi('/api/logs/stats')
]);
logs = logsRes.logs || [];
stats = statsRes.stats || null;
} catch (error) {
console.error("Erreur chargement initial des logs:", error);
}
return (
<LogsManager
initialLogs={logs}
initialStats={stats}
/>
);
}
+2 -2
View File
@@ -1,4 +1,4 @@
import { fetchApi, API_BASE_URL } from "@/lib/api"; import { fetchApi } from "@/lib/api";
import DatasetToolbar from "@/components/DatasetToolbar"; import DatasetToolbar from "@/components/DatasetToolbar";
import { Calendar, User, Crosshair } from "lucide-react"; import { Calendar, User, Crosshair } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
@@ -49,7 +49,7 @@ export default async function DashboardPage() {
<div className="aspect-[3/4] relative overflow-hidden bg-slate-800"> <div className="aspect-[3/4] relative overflow-hidden bg-slate-800">
{/* eslint-disable-next-line @next/next/no-img-element */} {/* eslint-disable-next-line @next/next/no-img-element */}
<img <img
src={`${API_BASE_URL}${photo.imageUrl}`} src={photo.imageUrl}
alt={photo.filename} alt={photo.filename}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
/> />
@@ -1,4 +1,4 @@
import { fetchApi, API_BASE_URL } from "@/lib/api"; import { fetchApi } from "@/lib/api";
import PhotoEditor from "@/components/PhotoEditor"; import PhotoEditor from "@/components/PhotoEditor";
import { ChevronLeft, Download } from "lucide-react"; import { ChevronLeft, Download } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
@@ -33,7 +33,7 @@ export default async function PhotoDetailPage({ params }: { params: Promise<{ id
<div className="flex gap-3"> <div className="flex gap-3">
<a <a
href={`${API_BASE_URL}/uploads/images/${id}`} href={`/uploads/images/${id}`}
download download
className="flex items-center gap-2 bg-slate-800 hover:bg-slate-700 px-4 py-2 rounded-lg text-sm font-medium transition-colors" className="flex items-center gap-2 bg-slate-800 hover:bg-slate-700 px-4 py-2 rounded-lg text-sm font-medium transition-colors"
> >
@@ -112,7 +112,7 @@ export default function PhotoEditor({ initialPhoto }: PhotoEditorProps) {
</div> </div>
<PhotoOverlay <PhotoOverlay
imageUrl={`${API_BASE_URL}${initialPhoto.imageUrl}`} imageUrl={initialPhoto.imageUrl}
impacts={impacts} impacts={impacts}
targetCorners={photoData?.plotting?.target_corners || []} targetCorners={photoData?.plotting?.target_corners || []}
isEditing={isEditing} isEditing={isEditing}
+4 -1
View File
@@ -1,4 +1,7 @@
export const API_BASE_URL = 'http://127.0.0.1:3000'; 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) { export async function fetchApi(endpoint: string) {
const res = await fetch(`${API_BASE_URL}${endpoint}`, { const res = await fetch(`${API_BASE_URL}${endpoint}`, {
+577 -3
View File
@@ -9,14 +9,526 @@
"version": "1.0.0", "version": "1.0.0",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@techstark/opencv-js": "^5.0.0-release.1",
"adm-zip": "^0.5.17", "adm-zip": "^0.5.17",
"cors": "^2.8.6", "cors": "^2.8.6",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"express": "^5.2.1", "express": "^5.2.1",
"multer": "^2.1.1", "multer": "^2.1.1",
"sharp": "^0.35.3",
"sqlite3": "^6.0.1" "sqlite3": "^6.0.1"
} }
}, },
"node_modules/@emnapi/runtime": {
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
"integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@img/colour": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz",
"integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.3.2"
}
},
"node_modules/@img/sharp-darwin-x64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz",
"integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.3.2"
}
},
"node_modules/@img/sharp-freebsd-wasm32": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz",
"integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==",
"license": "Apache-2.0",
"optional": true,
"os": [
"freebsd"
],
"dependencies": {
"@img/sharp-wasm32": "0.35.3"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz",
"integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz",
"integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz",
"integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==",
"cpu": [
"arm"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz",
"integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-ppc64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz",
"integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==",
"cpu": [
"ppc64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-riscv64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz",
"integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==",
"cpu": [
"riscv64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz",
"integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==",
"cpu": [
"s390x"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz",
"integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz",
"integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz",
"integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-linux-arm": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz",
"integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==",
"cpu": [
"arm"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.3.2"
}
},
"node_modules/@img/sharp-linux-arm64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz",
"integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.3.2"
}
},
"node_modules/@img/sharp-linux-ppc64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz",
"integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==",
"cpu": [
"ppc64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-ppc64": "1.3.2"
}
},
"node_modules/@img/sharp-linux-riscv64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz",
"integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==",
"cpu": [
"riscv64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-riscv64": "1.3.2"
}
},
"node_modules/@img/sharp-linux-s390x": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz",
"integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==",
"cpu": [
"s390x"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.3.2"
}
},
"node_modules/@img/sharp-linux-x64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz",
"integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.3.2"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz",
"integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.3.2"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz",
"integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.3.2"
}
},
"node_modules/@img/sharp-wasm32": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz",
"integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==",
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
"@emnapi/runtime": "^1.11.1"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-webcontainers-wasm32": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz",
"integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==",
"cpu": [
"wasm32"
],
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"@img/sharp-wasm32": "0.35.3"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-arm64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz",
"integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==",
"cpu": [
"arm64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-ia32": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz",
"integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==",
"cpu": [
"ia32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-x64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz",
"integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==",
"cpu": [
"x64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@isaacs/fs-minipass": { "node_modules/@isaacs/fs-minipass": {
"version": "4.0.1", "version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
@@ -29,6 +541,12 @@
"node": ">=18.0.0" "node": ">=18.0.0"
} }
}, },
"node_modules/@techstark/opencv-js": {
"version": "5.0.0-release.1",
"resolved": "https://registry.npmjs.org/@techstark/opencv-js/-/opencv-js-5.0.0-release.1.tgz",
"integrity": "sha512-PIm+eB0MFtieXoNC2GRao0dv/02sehG+Nv2nSW5D6pQm6J/4WqvDHm0RyoqOmGYQm67jdGiaOdIeTShCY3PIUg==",
"license": "Apache-2.0"
},
"node_modules/abbrev": { "node_modules/abbrev": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz",
@@ -1264,9 +1782,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/semver": { "node_modules/semver": {
"version": "7.7.4", "version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"license": "ISC", "license": "ISC",
"bin": { "bin": {
"semver": "bin/semver.js" "semver": "bin/semver.js"
@@ -1326,6 +1844,55 @@
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/sharp": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz",
"integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==",
"license": "Apache-2.0",
"dependencies": {
"@img/colour": "^1.1.0",
"detect-libc": "^2.1.2",
"semver": "^7.8.5"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "0.35.3",
"@img/sharp-darwin-x64": "0.35.3",
"@img/sharp-freebsd-wasm32": "0.35.3",
"@img/sharp-libvips-darwin-arm64": "1.3.2",
"@img/sharp-libvips-darwin-x64": "1.3.2",
"@img/sharp-libvips-linux-arm": "1.3.2",
"@img/sharp-libvips-linux-arm64": "1.3.2",
"@img/sharp-libvips-linux-ppc64": "1.3.2",
"@img/sharp-libvips-linux-riscv64": "1.3.2",
"@img/sharp-libvips-linux-s390x": "1.3.2",
"@img/sharp-libvips-linux-x64": "1.3.2",
"@img/sharp-libvips-linuxmusl-arm64": "1.3.2",
"@img/sharp-libvips-linuxmusl-x64": "1.3.2",
"@img/sharp-linux-arm": "0.35.3",
"@img/sharp-linux-arm64": "0.35.3",
"@img/sharp-linux-ppc64": "0.35.3",
"@img/sharp-linux-riscv64": "0.35.3",
"@img/sharp-linux-s390x": "0.35.3",
"@img/sharp-linux-x64": "0.35.3",
"@img/sharp-linuxmusl-arm64": "0.35.3",
"@img/sharp-linuxmusl-x64": "0.35.3",
"@img/sharp-webcontainers-wasm32": "0.35.3",
"@img/sharp-win32-arm64": "0.35.3",
"@img/sharp-win32-ia32": "0.35.3",
"@img/sharp-win32-x64": "0.35.3"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
}
},
"node_modules/side-channel": { "node_modules/side-channel": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
@@ -1581,6 +2148,13 @@
"node": ">=0.6" "node": ">=0.6"
} }
}, },
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD",
"optional": true
},
"node_modules/tunnel-agent": { "node_modules/tunnel-agent": {
"version": "0.6.0", "version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
+5
View File
@@ -12,11 +12,16 @@
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@techstark/opencv-js": "^5.0.0-release.1",
"adm-zip": "^0.5.17", "adm-zip": "^0.5.17",
"cors": "^2.8.6", "cors": "^2.8.6",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"express": "^5.2.1", "express": "^5.2.1",
"multer": "^2.1.1", "multer": "^2.1.1",
"next": "^16.2.4",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"sharp": "^0.35.3",
"sqlite3": "^6.0.1" "sqlite3": "^6.0.1"
} }
} }
+539 -28
View File
@@ -7,6 +7,8 @@ const sqlite3 = require('sqlite3').verbose();
const AdmZip = require('adm-zip'); const AdmZip = require('adm-zip');
const TargetValidator = require('./services/target_validator');
const app = express(); const app = express();
const PORT = process.env.PORT || 3000; const PORT = process.env.PORT || 3000;
@@ -34,14 +36,128 @@ const db = new sqlite3.Database(dbPath, (err) => {
console.error('Erreur de connexion à SQLite:', err.message); console.error('Erreur de connexion à SQLite:', err.message);
} else { } else {
console.log('Connecté à la base de données SQLite.'); console.log('Connecté à la base de données SQLite.');
db.run(`CREATE TABLE IF NOT EXISTS user_stats ( db.serialize(() => {
wallet_hash TEXT PRIMARY KEY, db.run(`CREATE TABLE IF NOT EXISTS user_stats (
photo_count INTEGER DEFAULT 0, wallet_hash TEXT PRIMARY KEY,
last_upload DATETIME DEFAULT CURRENT_TIMESTAMP 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 // Configuration de multer pour le stockage des fichiers
const storage = multer.diskStorage({ const storage = multer.diskStorage({
destination: function (req, file, cb) { destination: function (req, file, cb) {
@@ -81,10 +197,22 @@ app.get('/api/health', (req, res) => {
// Route pour l'upload de photo + données JSON // Route pour l'upload de photo + données JSON
// Attend un form-data avec un champ nommé 'photo' et un champ texte 'plotting' // Attend un form-data avec un champ nommé 'photo' et un champ texte 'plotting'
app.post('/api/upload', upload.single('photo'), (req, res) => { app.post('/api/upload', upload.single('photo'), async (req, res) => {
const ipAddress = req.headers['x-forwarded-for'] || req.socket.remoteAddress || req.ip || '';
const userAgent = req.headers['user-agent'] || '';
try { try {
if (!req.file) { if (!req.file) {
return res.status(400).json({ error: 'Aucune photo fournie' }); logUploadEntry({
ipAddress,
userAgent,
status: 'FAILED',
errorMessage: 'Aucune photo fournie'
});
return res.status(400).json({
code: 'MISSING_PHOTO',
error: 'Aucune photo fournie dans la requête'
});
} }
let plottingData = {}; let plottingData = {};
@@ -93,10 +221,75 @@ app.post('/api/upload', upload.single('photo'), (req, res) => {
plottingData = JSON.parse(req.body.plotting); plottingData = JSON.parse(req.body.plotting);
} catch (e) { } catch (e) {
console.error("Erreur parsing JSON:", e); console.error("Erreur parsing JSON:", e);
return res.status(400).json({ error: 'Le champ plotting doit être un JSON valide' }); logUploadEntry({
imageFilename: req.file.filename,
fileSize: req.file.size,
ipAddress,
userAgent,
status: 'FAILED',
errorMessage: 'Le champ plotting doit être un JSON valide'
});
return res.status(400).json({
code: 'INVALID_PLOTTING_JSON',
error: 'Le champ plotting contient un format JSON invalide'
});
} }
} }
const walletHash = plottingData.wallet_hash || null;
const sessionId = plottingData.session_id || null;
// 1. Vérification du bannissement de wallet
if (walletHash) {
const bannedEntry = await new Promise((resolve) => {
db.get('SELECT * FROM banned_wallets WHERE wallet_hash = ?', [walletHash], (err, row) => {
resolve(row || null);
});
});
if (bannedEntry) {
// Supprimer le fichier image temporaire pour ne pas consommer d'espace
if (req.file.path && fs.existsSync(req.file.path)) {
try { fs.unlinkSync(req.file.path); } catch (e) {}
}
const banReason = bannedEntry.reason || 'Non-respect des règles de contribution / Image non conforme';
logUploadEntry({
sessionId,
walletHash,
imageFilename: req.file.filename,
fileSize: req.file.size,
ipAddress,
userAgent,
status: 'FAILED',
errorMessage: `Upload bloqué : wallet banni (${banReason})`
});
console.warn(`[MODÉRATION] Upload rejeté pour wallet banni: ${walletHash}`);
return res.status(403).json({
code: 'WALLET_BANNED',
error: 'Votre participation au programme d\'entraînement IA a été suspendue par la modération.',
reason: banReason,
banned_at: bannedEntry.banned_at
});
}
}
// 2. Validation de la cible par OpenCV
let validation = {
isValid: true,
status: 'VALID',
confidence: 1.0,
ringsCount: 0,
bestCenter: null,
details: 'Analyse non effectuée'
};
try {
validation = await TargetValidator.validateTarget(req.file.path);
console.log(`[OPENCV] Diagnostic cible pour ${req.file.filename}: ${validation.status} (${Math.round(validation.confidence * 100)}% conf, ${validation.ringsCount} anneaux)`);
} catch (cvErr) {
console.error("[OPENCV] Erreur analyse cible:", cvErr);
}
// Nom de base sans l'extension // Nom de base sans l'extension
const baseFilename = path.parse(req.file.filename).name; const baseFilename = path.parse(req.file.filename).name;
const jsonFilename = `${baseFilename}.json`; const jsonFilename = `${baseFilename}.json`;
@@ -105,8 +298,13 @@ app.post('/api/upload', upload.single('photo'), (req, res) => {
// Sauvegarde du JSON dans uploads/data/ // Sauvegarde du JSON dans uploads/data/
fs.writeFileSync(jsonFilePath, JSON.stringify(plottingData, null, 2)); fs.writeFileSync(jsonFilePath, JSON.stringify(plottingData, null, 2));
// Extraction des métadonnées
const deviceInfo = plottingData.device_info || {};
const targetMeta = plottingData.target_metadata || {};
const impacts = plottingData.plotting?.impacts || [];
const impactsCount = Array.isArray(impacts) ? impacts.length : 0;
// Mise à jour de la BDD si on a un wallet_hash // Mise à jour de la BDD si on a un wallet_hash
const walletHash = plottingData.wallet_hash;
if (walletHash) { if (walletHash) {
db.run(` db.run(`
INSERT INTO user_stats (wallet_hash, photo_count, last_upload) INSERT INTO user_stats (wallet_hash, photo_count, last_upload)
@@ -123,11 +321,36 @@ app.post('/api/upload', upload.single('photo'), (req, res) => {
}); });
} }
// Enregistrement dans la table des logs d'upload avec diagnostic OpenCV
logUploadEntry({
sessionId,
walletHash,
imageFilename: req.file.filename,
jsonFilename,
fileSize: req.file.size,
ipAddress,
userAgent,
deviceModel: deviceInfo.model || null,
deviceOs: deviceInfo.os || null,
targetType: targetMeta.type || null,
weapon: targetMeta.weapon || null,
distanceMeters: targetMeta.distance_meters || null,
impactsCount,
status: 'SUCCESS',
targetValid: validation.isValid ? 1 : 0,
targetStatus: validation.status,
targetConfidence: validation.confidence,
targetRingsCount: validation.ringsCount,
targetDetails: validation.details,
rawMetadata: plottingData
});
console.log(`Données reçues et sauvegardées:`); console.log(`Données reçues et sauvegardées:`);
console.log(`- Image: uploads/images/${req.file.filename}`); console.log(`- Image: uploads/images/${req.file.filename}`);
console.log(`- JSON : uploads/data/${jsonFilename}`); console.log(`- JSON : uploads/data/${jsonFilename}`);
res.status(200).json({ res.status(200).json({
code: 'UPLOAD_SUCCESS',
message: 'Photo et données uploadées avec succès', message: 'Photo et données uploadées avec succès',
file: { file: {
filename: req.file.filename, filename: req.file.filename,
@@ -136,14 +359,254 @@ app.post('/api/upload', upload.single('photo'), (req, res) => {
}, },
data: { data: {
filename: jsonFilename filename: jsonFilename
} },
target_validation: validation
}); });
} catch (error) { } catch (error) {
console.error('Erreur lors de l\'upload:', error); console.error('Erreur lors de l\'upload:', error);
res.status(500).json({ error: 'Erreur interne du serveur lors de l\'upload' }); logUploadEntry({
imageFilename: req.file ? req.file.filename : null,
fileSize: req.file ? req.file.size : null,
ipAddress,
userAgent,
status: 'FAILED',
errorMessage: error.message || 'Erreur interne lors de l\'upload'
});
res.status(500).json({
code: 'SERVER_ERROR',
error: error.message || 'Erreur interne du serveur lors de l\'upload'
});
} }
}); });
// Routes de Modération : Gestion des bannissements de wallets
app.get('/api/moderation/banned', (req, res) => {
db.all('SELECT * FROM banned_wallets ORDER BY banned_at DESC', [], (err, rows) => {
if (err) {
console.error("Erreur lecture wallets bannis:", err);
return res.status(500).json({ error: 'Erreur lecture wallets bannis' });
}
res.json({ banned: rows || [] });
});
});
app.post('/api/moderation/ban', (req, res) => {
const { wallet_hash, reason, banned_by } = req.body;
if (!wallet_hash) {
return res.status(400).json({ error: 'wallet_hash obligatoire' });
}
db.run(
`INSERT INTO banned_wallets (wallet_hash, reason, banned_by, banned_at)
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(wallet_hash) DO UPDATE SET
reason = excluded.reason,
banned_at = CURRENT_TIMESTAMP`,
[wallet_hash, reason || 'Contenu invalide ou non conforme aux règles', banned_by || 'Admin'],
function(err) {
if (err) {
console.error("Erreur bannissement:", err);
return res.status(500).json({ error: 'Erreur lors du bannissement du wallet' });
}
console.log(`[MODÉRATION] Wallet ${wallet_hash} banni (Motif: ${reason})`);
res.json({ message: 'Wallet banni avec succès', wallet_hash });
}
);
});
app.delete('/api/moderation/ban/:wallet_hash', (req, res) => {
const wallet_hash = req.params.wallet_hash;
db.run('DELETE FROM banned_wallets WHERE wallet_hash = ?', [wallet_hash], function(err) {
if (err) {
console.error("Erreur débannissement:", err);
return res.status(500).json({ error: 'Erreur lors du débannissement' });
}
console.log(`[MODÉRATION] Wallet ${wallet_hash} débanni`);
res.json({ message: 'Wallet débanni avec succès', wallet_hash });
});
});
// Route pour relancer l'analyse OpenCV sur une photo déjà existante
app.post('/api/moderation/verify/:filename', async (req, res) => {
const filename = req.params.filename;
const imgPath = path.join(imagesDir, filename);
if (!fs.existsSync(imgPath)) {
return res.status(404).json({ error: 'Image non trouvée' });
}
try {
const result = await TargetValidator.validateTarget(imgPath);
res.json({ filename, validation: result });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// Route pour lister les logs avec filtres, recherche et pagination
app.get('/api/logs', (req, res) => {
const limit = Math.min(Math.max(parseInt(req.query.limit) || 50, 1), 200);
const offset = Math.max(parseInt(req.query.offset) || 0, 0);
const wallet = req.query.wallet || req.query.wallet_hash;
const status = req.query.status;
const search = req.query.search;
let whereClauses = [];
let params = [];
if (wallet) {
whereClauses.push("wallet_hash LIKE ?");
params.push(`%${wallet}%`);
}
if (status) {
whereClauses.push("status = ?");
params.push(status);
}
if (search) {
whereClauses.push("(session_id LIKE ? OR wallet_hash LIKE ? OR device_model LIKE ? OR weapon LIKE ? OR target_type LIKE ? OR image_filename LIKE ?)");
const s = `%${search}%`;
params.push(s, s, s, s, s, s);
}
const whereSql = whereClauses.length > 0 ? `WHERE ${whereClauses.join(' AND ')}` : '';
db.get(`SELECT COUNT(*) as total FROM upload_logs ${whereSql}`, params, (err, countRow) => {
if (err) {
console.error("Erreur comptage logs:", err);
return res.status(500).json({ error: 'Erreur lors du comptage des logs' });
}
const total = countRow ? countRow.total : 0;
const query = `
SELECT * FROM upload_logs
${whereSql}
ORDER BY timestamp DESC, id DESC
LIMIT ? OFFSET ?
`;
db.all(query, [...params, limit, offset], (err, rows) => {
if (err) {
console.error("Erreur lecture logs:", err);
return res.status(500).json({ error: 'Erreur lors de la lecture des logs' });
}
res.json({
logs: rows,
total,
limit,
offset
});
});
});
});
// Route pour les statistiques des logs
app.get('/api/logs/stats', (req, res) => {
const statsQuery = `
SELECT
COUNT(*) as total_uploads,
SUM(CASE WHEN status = 'SUCCESS' THEN 1 ELSE 0 END) as success_count,
SUM(CASE WHEN status = 'FAILED' THEN 1 ELSE 0 END) as failed_count,
SUM(CASE WHEN date(timestamp, 'localtime') = date('now', 'localtime') THEN 1 ELSE 0 END) as today_uploads,
COUNT(DISTINCT wallet_hash) as unique_wallets,
SUM(COALESCE(file_size, 0)) as total_bytes,
MAX(timestamp) as latest_upload
FROM upload_logs
`;
db.get(statsQuery, [], (err, stats) => {
if (err) {
console.error("Erreur stats logs:", err);
return res.status(500).json({ error: 'Erreur calcul statistiques' });
}
res.json({
status: 'ok',
stats: stats || {
total_uploads: 0,
success_count: 0,
failed_count: 0,
today_uploads: 0,
unique_wallets: 0,
total_bytes: 0,
latest_upload: null
}
});
});
});
// Route pour exporter les logs (CSV ou JSON)
app.get('/api/logs/export', (req, res) => {
const format = req.query.format || 'csv';
db.all('SELECT * FROM upload_logs ORDER BY timestamp DESC, id DESC', [], (err, rows) => {
if (err) {
console.error("Erreur export logs:", err);
return res.status(500).json({ error: 'Erreur export logs' });
}
if (format === 'json') {
res.setHeader('Content-Disposition', `attachment; filename=upload_logs_${Date.now()}.json`);
res.setHeader('Content-Type', 'application/json');
return res.send(JSON.stringify(rows, null, 2));
}
// CSV Export
const headers = [
'ID', 'Date/Heure', 'Statut', 'Wallet Hash', 'Session ID',
'Image', 'JSON', 'Taille (octets)', 'Cible', 'Arme', 'Distance (m)',
'Impacts', 'Modèle Appareil', 'OS', 'IP', 'Erreur'
];
const csvLines = [headers.join(';')];
rows.forEach(r => {
const line = [
r.id,
`"${r.timestamp || ''}"`,
`"${r.status || ''}"`,
`"${r.wallet_hash || ''}"`,
`"${r.session_id || ''}"`,
`"${r.image_filename || ''}"`,
`"${r.json_filename || ''}"`,
r.file_size || 0,
`"${r.target_type || ''}"`,
`"${r.weapon || ''}"`,
r.distance_meters || '',
r.impacts_count || 0,
`"${r.device_model || ''}"`,
`"${r.device_os || ''}"`,
`"${r.ip_address || ''}"`,
`"${(r.error_message || '').replace(/"/g, '""')}"`
];
csvLines.push(line.join(';'));
});
res.setHeader('Content-Disposition', `attachment; filename=upload_logs_${Date.now()}.csv`);
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
res.send('\uFEFF' + csvLines.join('\r\n'));
});
});
// Route pour supprimer un log spécifique
app.delete('/api/logs/:id', (req, res) => {
const id = req.params.id;
db.run('DELETE FROM upload_logs WHERE id = ?', [id], function(err) {
if (err) {
console.error("Erreur suppression log:", err);
return res.status(500).json({ error: 'Erreur lors de la suppression' });
}
res.json({ message: 'Log supprimé avec succès', deletedId: id });
});
});
// Route pour vider les logs
app.delete('/api/logs', (req, res) => {
db.run('DELETE FROM upload_logs', [], function(err) {
if (err) {
console.error("Erreur vidage logs:", err);
return res.status(500).json({ error: 'Erreur lors de la réinitialisation des logs' });
}
res.json({ message: 'Tous les logs ont été effacés', changes: this.changes });
});
});
// Route pour récupérer toutes les photos disponibles // Route pour récupérer toutes les photos disponibles
app.get('/api/photos', (req, res) => { app.get('/api/photos', (req, res) => {
try { try {
@@ -164,20 +627,42 @@ app.get('/api/photos', (req, res) => {
} }
}); });
// Route pour récupérer les statistiques d'un wallet_hash // Route pour récupérer les statistiques d'un wallet_hash + statut de modération
app.get('/api/stats/:wallet_hash', (req, res) => { app.get('/api/stats/:wallet_hash', (req, res) => {
const walletHash = req.params.wallet_hash; const walletHash = req.params.wallet_hash;
db.get('SELECT photo_count, last_upload FROM user_stats WHERE wallet_hash = ?', [walletHash], (err, row) => { db.get('SELECT photo_count, last_upload FROM user_stats WHERE wallet_hash = ?', [walletHash], (err, statRow) => {
if (err) { if (err) {
return res.status(500).json({ error: 'Erreur lors de la lecture des statistiques' }); return res.status(500).json({ error: 'Erreur lors de la lecture des statistiques' });
} }
if (row) { db.get('SELECT reason, banned_at FROM banned_wallets WHERE wallet_hash = ?', [walletHash], (bErr, bannedRow) => {
res.json({ status: 'ok', stats: row }); const isBanned = !!bannedRow;
} else { const stats = statRow || { photo_count: 0, last_upload: null };
res.json({ status: 'ok', stats: { 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
});
}); });
}); });
@@ -333,14 +818,40 @@ app.use((err, req, res, next) => {
res.status(500).json({ error: err.message || 'Une erreur inattendue est survenue' }); res.status(500).json({ error: err.message || 'Une erreur inattendue est survenue' });
}); });
// Démarrer le serveur // Intégration du Dashboard Next.js
app.listen(PORT, () => { const dashboardDir = path.join(__dirname, 'dashboard');
console.log(`=================================`); const dev = process.env.NODE_ENV !== 'production';
console.log(`Serveur Backend IA démarré`);
console.log(`Port: ${PORT}`); let nextApp;
console.log(`Dossiers:`); try {
console.log(` - Images: ${imagesDir}`); const next = require('next');
console.log(` - Data : ${dataDir}`); nextApp = next({ dev, dir: dashboardDir });
console.log(` - Export: ${exportsDir}`); } catch (e) {
console.log(`=================================`); console.warn("Module 'next' non disponible, mode API seule.");
}); }
async function startServer() {
if (nextApp) {
try {
await nextApp.prepare();
const handle = nextApp.getRequestHandler();
app.use((req, res) => handle(req, res));
console.log("Dashboard Next.js initialisé avec succès.");
} catch (err) {
console.error("Erreur d'initialisation du Dashboard Next.js:", err);
}
}
app.listen(PORT, () => {
console.log(`=================================`);
console.log(`Serveur Backend IA & Dashboard démarré`);
console.log(`Port: ${PORT}`);
console.log(`Dossiers:`);
console.log(` - Images: ${imagesDir}`);
console.log(` - Data : ${dataDir}`);
console.log(` - Export: ${exportsDir}`);
console.log(`=================================`);
});
}
startServer();
+230
View File
@@ -0,0 +1,230 @@
const cvPromise = require('@techstark/opencv-js');
const sharp = require('sharp');
let cvInstance = null;
async function getCV() {
if (!cvInstance) {
cvInstance = await cvPromise;
}
return cvInstance;
}
/**
* Service de validation OpenCV pour vérifier si une image reçue est bien une cible de tir.
*/
class TargetValidator {
/**
* Analyse une image et renvoie le diagnostic OpenCV
* @param {string|Buffer} imageInput - Chemin du fichier ou Buffer de l'image
* @returns {Promise<{
* isValid: boolean,
* status: 'VALID' | 'SUSPICIOUS' | 'INVALID',
* confidence: number,
* ringsCount: number,
* bestCenter: { x: number, y: number, radius: number } | null,
* details: string
* }>}
*/
static async validateTarget(imageInput) {
const cv = await getCV();
let src = null;
let gray = null;
let blurred = null;
let circles = null;
try {
// 1. Charger et décoder l'image avec Sharp pour obtenir les pixels bruts RGBA
const { data, info } = await sharp(imageInput)
.ensureAlpha() // 4 canaux RGBA
.raw()
.toBuffer({ resolveWithObject: true });
const width = info.width;
const height = info.height;
const minDim = Math.min(width, height);
// 2. Créer une Mat OpenCV à partir des pixels RGBA
src = new cv.Mat(height, width, cv.CV_8UC4);
src.data.set(data);
// 3. Conversion en niveaux de gris
gray = new cv.Mat();
cv.cvtColor(src, gray, cv.COLOR_RGBA2GRAY);
// 4. Flou Gaussien pour réduire le bruit
blurred = new cv.Mat();
const ksize = new cv.Size(9, 9);
cv.GaussianBlur(gray, blurred, ksize, 2, 2, cv.BORDER_DEFAULT);
// 5. Détection multi-bandes pour capturer tous les anneaux concentriques
// (Puisque minDist dans HoughCircles empêche la détection de cercles concentriques sur un seul passage)
const detectedCircles = [];
const bands = [
{ min: Math.floor(minDim * 0.04), max: Math.floor(minDim * 0.18), p2: 45 },
{ min: Math.floor(minDim * 0.18), max: Math.floor(minDim * 0.35), p2: 48 },
{ min: Math.floor(minDim * 0.35), max: Math.floor(minDim * 0.55), p2: 50 }
];
circles = new cv.Mat();
for (const band of bands) {
cv.HoughCircles(
blurred,
circles,
cv.HOUGH_GRADIENT,
1,
Math.max(Math.floor(minDim * 0.05), 10),
100,
band.p2,
band.min,
band.max
);
if (circles.cols > 0 && circles.data32F) {
for (let i = 0; i < circles.cols; i++) {
const x = circles.data32F[i * 3];
const y = circles.data32F[i * 3 + 1];
const r = circles.data32F[i * 3 + 2];
if (!isNaN(x) && !isNaN(y) && !isNaN(r) && r > 0) {
detectedCircles.push({ x, y, r });
}
}
}
}
// Si aucun cercle dans les bandes, tentative globale tolérante
if (detectedCircles.length === 0) {
cv.HoughCircles(
blurred,
circles,
cv.HOUGH_GRADIENT,
1,
Math.max(Math.floor(minDim * 0.08), 10),
100,
35,
Math.floor(minDim * 0.05),
Math.floor(minDim * 0.55)
);
if (circles.cols > 0 && circles.data32F) {
for (let i = 0; i < circles.cols; i++) {
const x = circles.data32F[i * 3];
const y = circles.data32F[i * 3 + 1];
const r = circles.data32F[i * 3 + 2];
if (!isNaN(x) && !isNaN(y) && !isNaN(r) && r > 0) {
detectedCircles.push({ x, y, r });
}
}
}
}
// Si aucun cercle détecté
if (detectedCircles.length === 0) {
return {
isValid: false,
status: 'INVALID',
confidence: 0.02,
ringsCount: 0,
bestCenter: null,
details: 'Aucun motif circulaire ou cible détecté sur cette photo.'
};
}
// 6. Clustering des cercles pour trouver les anneaux concentriques
const tolerance = minDim * 0.07; // Tolérance de 7% pour le décalage de centre
const clusters = [];
for (const circle of detectedCircles) {
let added = false;
for (const cluster of clusters) {
// Prendre le centre moyen actuel du cluster
const avgClusterX = cluster.reduce((sum, c) => sum + c.x, 0) / cluster.length;
const avgClusterY = cluster.reduce((sum, c) => sum + c.y, 0) / cluster.length;
const dist = Math.sqrt(Math.pow(circle.x - avgClusterX, 2) + Math.pow(circle.y - avgClusterY, 2));
if (dist < tolerance) {
// Vérifier que le rayon n'est pas un doublon exact (< 10px de différence)
const isDuplicate = cluster.some(c => Math.abs(c.r - circle.r) < minDim * 0.03);
if (!isDuplicate) {
cluster.push(circle);
}
added = true;
break;
}
}
if (!added) {
clusters.push([circle]);
}
}
// Trier les clusters par nombre de cercles concentriques décroissant
clusters.sort((a, b) => b.length - a.length);
const bestCluster = clusters[0];
const ringsCount = bestCluster.length;
// Calculer le centre moyen du meilleur cluster
const avgX = bestCluster.reduce((acc, c) => acc + c.x, 0) / ringsCount;
const avgY = bestCluster.reduce((acc, c) => acc + c.y, 0) / ringsCount;
const maxR = Math.max(...bestCluster.map(c => c.r));
// 7. Évaluation du statut et de la confiance
if (ringsCount >= 3) {
return {
isValid: true,
status: 'VALID',
confidence: Math.min(0.92 + (ringsCount - 3) * 0.02, 0.99),
ringsCount,
bestCenter: { x: avgX, y: avgY, radius: maxR },
details: `Cible certifiée : ${ringsCount} anneaux concentriques identifiés.`
};
} else if (ringsCount === 2) {
return {
isValid: true,
status: 'VALID',
confidence: 0.85,
ringsCount,
bestCenter: { x: avgX, y: avgY, radius: maxR },
details: `Cible confirmée : ${ringsCount} anneaux concentriques détectés.`
};
} else if (ringsCount === 1) {
return {
isValid: true,
status: 'SUSPICIOUS',
confidence: 0.50,
ringsCount: 1,
bestCenter: { x: avgX, y: avgY, radius: maxR },
details: 'Un seul anneau détecté, confirmation visuelle recommandée.'
};
} else {
return {
isValid: false,
status: 'INVALID',
confidence: 0.1,
ringsCount: 0,
bestCenter: null,
details: 'Motif de cible non conforme.'
};
}
} catch (error) {
console.error('Erreur TargetValidator OpenCV:', error);
return {
isValid: false,
status: 'INVALID',
confidence: 0.0,
ringsCount: 0,
bestCenter: null,
details: `Erreur d'analyse OpenCV: ${error.message}`
};
} finally {
// 8. Nettoyage mémoire
if (src && !src.isDeleted()) src.delete();
if (gray && !gray.isDeleted()) gray.delete();
if (blurred && !blurred.isDeleted()) blurred.delete();
if (circles && !circles.isDeleted()) circles.delete();
}
}
}
module.exports = TargetValidator;
+21
View File
@@ -0,0 +1,21 @@
name: backendia
services:
backendia:
build:
context: ./backendia
dockerfile: Dockerfile
container_name: backendia-prod
restart: unless-stopped
ports:
- "3005:3000"
environment:
- NODE_ENV=production
- PORT=3000
volumes:
- backendia_uploads:/app/uploads
- backendia_exports:/app/exports
volumes:
backendia_uploads:
backendia_exports:
+2 -3
View File
@@ -1,7 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'core/theme/theme_provider.dart'; import 'core/theme/theme_provider.dart';
import 'core/theme/app_theme.dart';
import 'main_navigation_holder.dart'; import 'main_navigation_holder.dart';
import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_localizations/flutter_localizations.dart';
@@ -15,8 +14,8 @@ class BullyApp extends StatelessWidget {
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: themeProvider.themeMode, themeMode: themeProvider.themeMode,
localizationsDelegates: const [ localizationsDelegates: const [
GlobalMaterialLocalizations.delegate, GlobalMaterialLocalizations.delegate,
+375 -40
View File
@@ -1,89 +1,424 @@
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( brightness: Brightness.light,
seedColor: primaryColor, colorScheme: ColorScheme.light(
brightness: Brightness.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,
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(8), borderRadius: BorderRadius.circular(12),
),
textStyle: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
letterSpacing: 0.2,
), ),
), ),
), ),
floatingActionButtonTheme: const FloatingActionButtonThemeData( outlinedButtonTheme: OutlinedButtonThemeData(
backgroundColor: primaryColor, 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, 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( brightness: Brightness.dark,
seedColor: primaryColor, colorScheme: ColorScheme.dark(
brightness: Brightness.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( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: const BorderSide(color: darkBorder, width: 1),
),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
elevation: 0,
backgroundColor: activePrimary,
foregroundColor: Colors.white,
minimumSize: const Size(double.infinity, 50),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
textStyle: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
letterSpacing: 0.2,
),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
foregroundColor: darkTextPrimary,
minimumSize: const Size(double.infinity, 50),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
side: const BorderSide(color: darkBorder, width: 1.5),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
textStyle: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
),
chipTheme: ChipThemeData(
backgroundColor: darkSurfaceElevated,
selectedColor: activePrimary.withValues(alpha: 0.2),
labelStyle: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: darkTextPrimary,
),
secondaryLabelStyle: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: activePrimary,
),
side: const BorderSide(color: darkBorder, width: 1),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: darkSurfaceElevated,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: darkBorder),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: darkBorder),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: activePrimary, width: 1.8),
),
labelStyle: const TextStyle(color: darkTextSecondary),
hintStyle: const TextStyle(color: darkTextMuted),
),
dividerTheme: const DividerThemeData(
color: darkBorder,
thickness: 1,
space: 24,
),
floatingActionButtonTheme: FloatingActionButtonThemeData(
backgroundColor: activePrimary,
foregroundColor: Colors.white,
elevation: 3,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(16)),
), ),
), ),
); );
+26 -3
View File
@@ -1,24 +1,37 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'app_theme.dart';
class ThemeProvider with ChangeNotifier { class ThemeProvider with ChangeNotifier {
static const String _themeModeKey = 'user_theme_mode'; static const String _themeModeKey = 'user_theme_mode';
static const String _accentKey = 'user_accent_color';
ThemeMode _themeMode = ThemeMode.system; ThemeMode _themeMode = ThemeMode.system;
AppAccentColor _accent = AppAccentColor.blue;
ThemeMode get themeMode => _themeMode; ThemeMode get themeMode => _themeMode;
AppAccentColor get currentAccent => _accent;
Color get primaryColor => _accent.color;
ThemeData get lightTheme => AppTheme.buildLightTheme(_accent);
ThemeData get darkTheme => AppTheme.buildDarkTheme(_accent);
ThemeProvider() { ThemeProvider() {
loadThemeMode(); loadSettings();
} }
Future<void> loadThemeMode() async { Future<void> loadSettings() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final modeIndex = prefs.getInt(_themeModeKey); final modeIndex = prefs.getInt(_themeModeKey);
final accentId = prefs.getString(_accentKey);
if (modeIndex != null) { if (modeIndex != null) {
_themeMode = ThemeMode.values[modeIndex]; _themeMode = ThemeMode.values[modeIndex];
notifyListeners();
} }
if (accentId != null) {
_accent = AppAccentColor.fromId(accentId);
}
notifyListeners();
} }
Future<void> setThemeMode(ThemeMode mode) async { Future<void> setThemeMode(ThemeMode mode) async {
@@ -31,6 +44,16 @@ class ThemeProvider with ChangeNotifier {
await prefs.setInt(_themeModeKey, mode.index); await prefs.setInt(_themeModeKey, mode.index);
} }
Future<void> setAccent(AppAccentColor accent) async {
if (_accent.id == accent.id) return;
_accent = accent;
notifyListeners();
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_accentKey, accent.id);
}
String get themeModeName { String get themeModeName {
switch (_themeMode) { switch (_themeMode) {
case ThemeMode.system: case ThemeMode.system:
+111
View File
@@ -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;
}
}
+335
View File
@@ -0,0 +1,335 @@
import 'package:flutter/material.dart';
import '../../data/models/weapon.dart';
/// Widget affichant une icône vectorielle stylisée et fidèle pour chaque type d'arme.
class WeaponTypeIcon extends StatelessWidget {
final WeaponType type;
final Color? color;
final double size;
const WeaponTypeIcon({
super.key,
required this.type,
this.color,
this.size = 24,
});
@override
Widget build(BuildContext context) {
final effectiveColor = color ?? Theme.of(context).colorScheme.primary;
return CustomPaint(
size: Size(size, size),
painter: _WeaponTypePainter(
type: type,
color: effectiveColor,
),
);
}
}
class _WeaponTypePainter extends CustomPainter {
final WeaponType type;
final Color color;
_WeaponTypePainter({
required this.type,
required this.color,
});
@override
void paint(Canvas canvas, Size size) {
final fillPaint = Paint()
..color = color
..style = PaintingStyle.fill
..isAntiAlias = true;
final strokePaint = Paint()
..color = color
..style = PaintingStyle.stroke
..strokeWidth = 1.0
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round
..isAntiAlias = true;
canvas.save();
// Normalisation sur une grille 32x32 pour un rendu très précis
final scale = size.width / 32.0;
canvas.scale(scale, scale);
switch (type) {
case WeaponType.handgun:
_drawGlock(canvas, fillPaint, strokePaint);
break;
case WeaponType.rifle:
_drawKalashnikov(canvas, fillPaint, strokePaint);
break;
case WeaponType.shotgun:
_drawPumpShotgun(canvas, fillPaint, strokePaint);
break;
case WeaponType.airgun:
_drawAirgun(canvas, fillPaint, strokePaint);
break;
}
canvas.restore();
}
/// Silhouette fidèle d'un Glock : culasse carrée "boxy", pontet angulaire et poignée ergonomique
void _drawGlock(Canvas canvas, Paint fillPaint, Paint strokePaint) {
// 1. Culasse rectangulaire Glock (Slide)
final slidePath = Path();
slidePath.moveTo(4, 9);
slidePath.lineTo(26.5, 9); // dessus de culasse
slidePath.lineTo(27.5, 10); // chanfrein nez avant
slidePath.lineTo(27.5, 14.5); // bouche du canon
slidePath.lineTo(4, 14.5); // bas de culasse
slidePath.lineTo(3.5, 13); // chanfrein arrière
slidePath.lineTo(3.5, 10);
slidePath.close();
canvas.drawPath(slidePath, fillPaint);
// Organes de visée Glock
// Cran de mire arrière carré
canvas.drawRect(const Rect.fromLTWH(5, 7.2, 2.5, 1.8), fillPaint);
// Guidon avant
canvas.drawRect(const Rect.fromLTWH(24.5, 7.2, 1.8, 1.8), fillPaint);
// 2. Carcasse inférieure & Poignée (Frame & Grip)
final framePath = Path();
framePath.moveTo(4, 14.5);
framePath.lineTo(26, 14.5); // rail picatinny avant
framePath.lineTo(25.5, 16.5);
framePath.lineTo(17.5, 16.5); // bas du rail avant pontet
framePath.lineTo(17.5, 19.5); // face avant du pontet carré Glock
framePath.lineTo(12.5, 20.5); // bas du pontet
framePath.lineTo(12, 17.5); // jonction détente / poignée avant
framePath.lineTo(9.5, 27); // poignée avant avec empreinte
framePath.lineTo(4.5, 27); // talon de chargeur / magwell
framePath.lineTo(8, 16.5); // dos de poignée / beavertail Glock
framePath.lineTo(4, 15.5);
framePath.close();
canvas.drawPath(framePath, fillPaint);
// 3. Queue de détente Safe Action
final triggerPath = Path();
triggerPath.moveTo(15.5, 15.5);
triggerPath.quadraticBezierTo(14, 17.5, 15.2, 19);
canvas.drawPath(triggerPath, strokePaint..strokeWidth = 1.3);
// 4. Stries de préhension arrière (Serrations)
for (double i = 6; i <= 10; i += 1.5) {
canvas.drawLine(
Offset(i, 10),
Offset(i, 13.5),
Paint()
..color = Colors.black.withValues(alpha: 0.35)
..strokeWidth = 0.8,
);
}
}
/// Silhouette fidèle d'une Kalashnikov (AK-47 / AKM) : chargeur banane, emprunt de gaz et guidon haut
void _drawKalashnikov(Canvas canvas, Paint fillPaint, Paint strokePaint) {
// 1. Crosse arrière en bois / épaule
final stockPath = Path();
stockPath.moveTo(1.5, 13);
stockPath.lineTo(8, 14.5);
stockPath.lineTo(8, 18);
stockPath.lineTo(1.5, 20.5);
stockPath.close();
canvas.drawPath(stockPath, fillPaint);
// 2. Boîtier de culasse (Receiver) & Capot supérieur
final receiverPath = Path();
receiverPath.moveTo(8, 13.5);
receiverPath.lineTo(17, 13.5);
receiverPath.lineTo(17, 18);
receiverPath.lineTo(8, 18);
receiverPath.close();
canvas.drawPath(receiverPath, fillPaint);
// 3. Garde-main & Tube d'emprunt de gaz supérieur
final handguardPath = Path();
handguardPath.moveTo(17, 13.5);
handguardPath.lineTo(24, 13.5);
handguardPath.lineTo(24, 17);
handguardPath.lineTo(17, 17);
handguardPath.close();
canvas.drawPath(handguardPath, fillPaint);
// 4. Canon fin avant & Bloc guidon Kalash
final barrelPath = Path();
barrelPath.moveTo(24, 14.5);
barrelPath.lineTo(30.5, 14.5);
barrelPath.lineTo(30.5, 16);
barrelPath.lineTo(24, 16);
barrelPath.close();
canvas.drawPath(barrelPath, fillPaint);
// Bloc guidon triangulaire surélevé avant
final frontSightPath = Path();
frontSightPath.moveTo(28, 14.5);
frontSightPath.lineTo(29, 11);
frontSightPath.lineTo(30, 14.5);
frontSightPath.close();
canvas.drawPath(frontSightPath, fillPaint);
// Hausse arrière au dessus du boîtier
canvas.drawRect(const Rect.fromLTWH(16.5, 12, 2, 1.5), fillPaint);
// 5. Chargeur courbé "banane" emblématique (30 coups)
final magPath = Path();
magPath.moveTo(14, 18);
magPath.lineTo(16.5, 18);
magPath.quadraticBezierTo(17.5, 22.5, 15, 26);
magPath.lineTo(12, 25.5);
magPath.quadraticBezierTo(14, 22, 13.5, 18);
magPath.close();
canvas.drawPath(magPath, fillPaint);
// 6. Poignée pistolet séparée
final gripPath = Path();
gripPath.moveTo(8.5, 18);
gripPath.lineTo(10.5, 18);
gripPath.lineTo(9.5, 23.5);
gripPath.lineTo(7.5, 23);
gripPath.close();
canvas.drawPath(gripPath, fillPaint);
// 7. Pontet et détente
final triggerGuard = Path();
triggerGuard.moveTo(11, 18);
triggerGuard.quadraticBezierTo(11.5, 20, 13, 19.5);
canvas.drawPath(triggerGuard, strokePaint..strokeWidth = 1.0);
}
/// Silhouette fidèle d'un Fusil à Pompe (Pump Action Shotgun : type Remington 870)
void _drawPumpShotgun(Canvas canvas, Paint fillPaint, Paint strokePaint) {
// 1. Crosse arrière traditionnelle ergonomique
final stockPath = Path();
stockPath.moveTo(1, 14.5);
stockPath.lineTo(9, 15.5);
stockPath.lineTo(9, 19.5);
stockPath.lineTo(6.5, 20); // poignée semi-pistolet
stockPath.lineTo(1, 21.5);
stockPath.close();
canvas.drawPath(stockPath, fillPaint);
// 2. Boîtier de culasse récepteur (Receiver)
final receiverPath = Path();
receiverPath.moveTo(9, 14);
receiverPath.lineTo(16.5, 14);
receiverPath.lineTo(16.5, 19);
receiverPath.lineTo(9, 19);
receiverPath.close();
canvas.drawPath(receiverPath, fillPaint);
// 3. Canon long calibre 12 (Barrel)
final barrelPath = Path();
barrelPath.moveTo(16.5, 14);
barrelPath.lineTo(31, 14);
barrelPath.lineTo(31, 15.5);
barrelPath.lineTo(16.5, 15.5);
barrelPath.close();
canvas.drawPath(barrelPath, fillPaint);
// Grain d'orge / mire avant sur le canon
canvas.drawCircle(const Offset(30, 13.2), 0.8, fillPaint);
// 4. Tube magasin inférieur sous le canon
final magTubePath = Path();
magTubePath.moveTo(16.5, 16);
magTubePath.lineTo(27, 16);
magTubePath.lineTo(27, 17.5);
magTubePath.lineTo(16.5, 17.5);
magTubePath.close();
canvas.drawPath(magTubePath, fillPaint);
// Bague de fixation canon / tube magasin
canvas.drawRect(const Rect.fromLTWH(26, 14, 1.2, 4), fillPaint);
// 5. Pompe mobile striée d'actionnement (Forend / Pump)
final pumpPath = Path();
pumpPath.addRRect(RRect.fromRectAndRadius(
const Rect.fromLTWH(18, 15.5, 6.5, 3.5),
const Radius.circular(0.8),
));
canvas.drawPath(pumpPath, fillPaint);
// Stries sur la pompe
for (double x = 19.5; x <= 23.5; x += 1.3) {
canvas.drawLine(
Offset(x, 16),
Offset(x, 18.5),
Paint()
..color = Colors.black.withValues(alpha: 0.4)
..strokeWidth = 0.6,
);
}
// 6. Pontet et détente
final triggerGuard = Path();
triggerGuard.moveTo(10, 19);
triggerGuard.quadraticBezierTo(11.5, 21.5, 13.5, 20);
triggerGuard.lineTo(13.5, 19);
canvas.drawPath(triggerGuard, strokePaint..strokeWidth = 1.0);
}
/// Silhouette fidèle d'une arme à air comprimé / cible plomb (Airgun)
void _drawAirgun(Canvas canvas, Paint fillPaint, Paint strokePaint) {
// Pistolet match 10m / Airgun olympique avec réservoir d'air cylindrique
// 1. Canon supérieur et boîtier
final barrelPath = Path();
barrelPath.moveTo(4, 12);
barrelPath.lineTo(28, 12);
barrelPath.lineTo(28, 14);
barrelPath.lineTo(4, 14);
barrelPath.close();
canvas.drawPath(barrelPath, fillPaint);
// Organes de visée match : dioptre arrière et tunnel de guidon avant
canvas.drawRect(const Rect.fromLTWH(4.5, 9.5, 3, 2.5), fillPaint);
canvas.drawRect(const Rect.fromLTWH(25, 10, 2.5, 2), fillPaint);
// 2. Bonbonne d'air comprimé cylindrique inférieure (Cylinder)
final tankPath = Path();
tankPath.addRRect(RRect.fromRectAndRadius(
const Rect.fromLTWH(10, 14.5, 15, 3),
const Radius.circular(1.5),
));
canvas.drawPath(tankPath, fillPaint);
// Manomètre avant
canvas.drawCircle(const Offset(25, 16), 1.2, strokePaint..strokeWidth = 0.8);
// 3. Poignée anatomique bois de tir sportif avec repose-paume
final gripPath = Path();
gripPath.moveTo(6, 14);
gripPath.lineTo(10, 14);
gripPath.lineTo(9.5, 23.5);
gripPath.lineTo(3.5, 22.5);
gripPath.close();
canvas.drawPath(gripPath, fillPaint);
// Tablette repose-paume réglable
canvas.drawRRect(
RRect.fromRectAndRadius(
const Rect.fromLTWH(2, 22.5, 9, 2.2),
const Radius.circular(0.8),
),
fillPaint,
);
// 4. Pontet match
final guardPath = Path();
guardPath.moveTo(10, 14.5);
guardPath.quadraticBezierTo(12, 18.5, 9.5, 18.5);
canvas.drawPath(guardPath, strokePaint..strokeWidth = 1.0);
}
@override
bool shouldRepaint(covariant _WeaponTypePainter oldDelegate) {
return oldDelegate.type != type || oldDelegate.color != color;
}
}
+8 -5
View File
@@ -1,11 +1,14 @@
import 'package:flutter/material.dart';
enum WeaponType { enum WeaponType {
handgun('Arme de Poing'), handgun('Arme de Poing', Icons.shield),
rifle('Arme d\'Épaule'), rifle('Arme d\'Épaule', Icons.filter_center_focus),
shotgun('Fusil à Pompe'), shotgun('Fusil à Pompe', Icons.splitscreen),
airgun('Airsoft / Airgun'); airgun('Airsoft / Airgun', Icons.air);
final String displayName; final String displayName;
const WeaponType(this.displayName); final IconData defaultIcon;
const WeaponType(this.displayName, this.defaultIcon);
static WeaponType fromString(String value) { static WeaponType fromString(String value) {
return WeaponType.values.firstWhere( return WeaponType.values.firstWhere(
+18 -10
View File
@@ -239,17 +239,24 @@ class AnalysisProvider extends ChangeNotifier {
} }
/// Exporte l'image et le json vers le backend IA. /// Exporte l'image et le json vers le backend IA.
/// [sessionId], [distance] et [weapon] proviennent du SessionProvider de /// [sessionId], [distance] et [caliber] proviennent du SessionProvider de
/// l'écran appelant, pour que le dataset contienne les vraies métadonnées. /// l'écran appelant, pour que le dataset contienne les vraies métadonnées.
Future<bool> exportToAiBackend({ ///
/// 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, String? sessionId,
int? distance, int? distance,
String? weapon, String? caliber,
int? expectedShots,
}) async { }) async {
if (_imagePath == null || _targetType == null) { if (_imagePath == null || _targetType == null) {
_errorMessage = "Impossible d'export : image ou type de cible manquant."; _errorMessage = "Impossible d'exporter : image ou type de cible manquant.";
notifyListeners(); notifyListeners();
return false; return AiExportResult.error(
code: 'MISSING_DATA',
message: "Impossible d'exporter : image ou type de cible manquant.",
);
} }
final service = AiExportService(); final service = AiExportService();
@@ -257,7 +264,7 @@ class AnalysisProvider extends ChangeNotifier {
_state = AnalysisState.loading; _state = AnalysisState.loading;
notifyListeners(); notifyListeners();
final success = await service.exportData( final result = await service.exportData(
imagePath: _imagePath!, imagePath: _imagePath!,
sessionId: sessionId ?? 'export', sessionId: sessionId ?? 'export',
targetType: _targetType!, targetType: _targetType!,
@@ -266,15 +273,16 @@ class AnalysisProvider extends ChangeNotifier {
targetRadius: _targetRadius, targetRadius: _targetRadius,
shots: _shots, shots: _shots,
distanceMeters: distance ?? 25, distanceMeters: distance ?? 25,
weaponName: weapon ?? 'Unknown', caliber: caliber ?? 'unknown',
expectedShots: expectedShots,
); );
_state = AnalysisState.success; _state = AnalysisState.success;
if (!success) { if (!result.isSuccess) {
_errorMessage = "Échec de l'export vers le serveur IA."; _errorMessage = result.message;
} }
notifyListeners(); notifyListeners();
return success; return result;
} }
/// Save the session /// Save the session
+72 -17
View File
@@ -19,6 +19,7 @@ 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';
import '../../services/wallet_identity_service.dart'; import '../../services/wallet_identity_service.dart';
import '../../services/ai_export_service.dart';
import '../session/session_provider.dart'; import '../session/session_provider.dart';
import 'analysis_provider.dart'; import 'analysis_provider.dart';
import 'impact_editor_screen.dart'; import 'impact_editor_screen.dart';
@@ -783,7 +784,7 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
// qu'elle ressorte sur le bouton. // qu'elle ressorte sur le bouton.
color: Colors.white, color: Colors.white,
), ),
label: 'TERMINER TOUT ET EXPORTER', label: 'TERMINER ET CONTRIBUER À L\'IA',
color: AppTheme.warningColor, color: AppTheme.warningColor,
onPressed: () => onPressed: () =>
_finishSession(context, provider, export: true), _finishSession(context, provider, export: true),
@@ -933,15 +934,20 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
date: sessionProvider.sessionDate, date: sessionProvider.sessionDate,
); );
bool? exportSucceeded; AiExportResult? exportResult;
if (export) { if (export) {
messenger.showSnackBar( messenger.showSnackBar(
const SnackBar(content: Text('Exportation en cours...')), const SnackBar(content: Text('Exportation vers le serveur IA en cours...')),
); );
exportSucceeded = await provider.exportToAiBackend( exportResult = await provider.exportToAiBackend(
sessionId: sessionProvider.activeSessionId, sessionId: sessionProvider.activeSessionId,
distance: sessionProvider.distance, distance: sessionProvider.distance,
weapon: sessionProvider.currentWeapon, caliber: sessionProvider.currentWeaponCaliber,
// Hors session, shotsPerTarget vaut sa valeur par defaut : mieux
// vaut ne rien annoncer qu'annoncer un nombre faux.
expectedShots: sessionProvider.isSessionActive
? sessionProvider.shotsPerTarget
: null,
); );
messenger.hideCurrentSnackBar(); messenger.hideCurrentSnackBar();
} }
@@ -954,19 +960,68 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
openMainTab(mainTabStats); openMainTab(mainTabStats);
} }
if (exportSucceeded != null) { if (exportResult != null) {
messenger.showSnackBar( if (exportResult.isBanned) {
SnackBar( messenger.showSnackBar(
content: Text( SnackBar(
exportSucceeded content: Row(
? 'Export réussi vers le backend IA !' children: [
: (provider.errorMessage ?? 'Erreur d\'export'), const Icon(Icons.block, color: Colors.white),
const SizedBox(width: 12),
Expanded(
child: Text(
'Participation IA suspendue : ${exportResult.reason ?? "Non-respect des règles"}',
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
],
),
backgroundColor: AppTheme.errorColor,
duration: const Duration(seconds: 6),
), ),
backgroundColor: exportSucceeded );
? AppTheme.successColor } else if (exportResult.isSuccess) {
: AppTheme.errorColor, final targetStatus = exportResult.targetValidation?['status'];
), final isCertified = targetStatus == 'VALID';
); messenger.showSnackBar(
SnackBar(
content: Row(
children: [
Icon(
isCertified ? Icons.verified : Icons.cloud_done,
color: Colors.white,
),
const SizedBox(width: 12),
Expanded(
child: Text(
isCertified
? 'Export réussi ! Cible certifiée par l\'IA.'
: exportResult.message,
),
),
],
),
backgroundColor: AppTheme.successColor,
duration: const Duration(seconds: 4),
),
);
} else {
messenger.showSnackBar(
SnackBar(
content: Row(
children: [
const Icon(Icons.warning_amber_rounded, color: Colors.white),
const SizedBox(width: 12),
Expanded(
child: Text('Échec de l\'export : ${exportResult.message}'),
),
],
),
backgroundColor: AppTheme.errorColor,
duration: const Duration(seconds: 4),
),
);
}
} }
} catch (e) { } catch (e) {
messenger.showSnackBar( messenger.showSnackBar(
@@ -25,6 +25,9 @@ import 'package:provider/provider.dart';
import '../../core/theme/app_theme.dart'; import '../../core/theme/app_theme.dart';
import '../../data/models/shot.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 'analysis_provider.dart';
import 'widgets/target_overlay.dart'; import 'widgets/target_overlay.dart';
@@ -43,12 +46,71 @@ class _ImpactEditorScreenState extends State<ImpactEditorScreen> {
double _currentZoomScale = 1.0; double _currentZoomScale = 1.0;
String? _movingShotId; String? _movingShotId;
// Clés du didacticiel : zone de travail et bouton de validation.
final GlobalKey _tutoCanvasKey = GlobalKey();
final GlobalKey _tutoValidateKey = GlobalKey();
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_transformationController.addListener(_onTransformChanged); _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 @override
void dispose() { void dispose() {
_transformationController.removeListener(_onTransformChanged); _transformationController.removeListener(_onTransformChanged);
@@ -123,6 +185,7 @@ class _ImpactEditorScreenState extends State<ImpactEditorScreen> {
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
FloatingActionButton.extended( FloatingActionButton.extended(
key: _tutoValidateKey,
heroTag: 'validate_impacts', heroTag: 'validate_impacts',
onPressed: () => Navigator.pop(context, true), onPressed: () => Navigator.pop(context, true),
backgroundColor: AppTheme.primaryColor, backgroundColor: AppTheme.primaryColor,
@@ -147,6 +210,7 @@ class _ImpactEditorScreenState extends State<ImpactEditorScreen> {
// Zone image plein écran : InteractiveViewer dans un body nu. // Zone image plein écran : InteractiveViewer dans un body nu.
Expanded( Expanded(
key: _tutoCanvasKey,
child: InteractiveViewer( child: InteractiveViewer(
transformationController: _transformationController, transformationController: _transformationController,
minScale: 1.0, minScale: 1.0,
+25 -9
View File
@@ -169,8 +169,9 @@ class _CaptureScreenState extends State<CaptureScreen>
final prev = _parallelismData; final prev = _parallelismData;
final bool changed = prev == null || final bool changed = prev == null ||
prev.status != data.status || prev.status != data.status ||
prev.pitchDegrees.toStringAsFixed(1) != prev.pose != data.pose ||
data.pitchDegrees.toStringAsFixed(1) || prev.pitchDeviation.toStringAsFixed(1) !=
data.pitchDeviation.toStringAsFixed(1) ||
prev.rollDegrees.toStringAsFixed(1) != prev.rollDegrees.toStringAsFixed(1) !=
data.rollDegrees.toStringAsFixed(1); data.rollDegrees.toStringAsFixed(1);
@@ -218,16 +219,21 @@ class _CaptureScreenState extends State<CaptureScreen>
return 'ALIGNEZ LA CIBLE DANS LE CADRE'; return 'ALIGNEZ LA CIBLE DANS LE CADRE';
} }
final bool onGround = _parallelismData!.pose == TargetPose.ground;
// Aligné → message de validation (avec bonus si la cible est détectée) // Aligné → message de validation (avec bonus si la cible est détectée)
if (_parallelismData!.isAligned) { if (_parallelismData!.isAligned) {
return _targetReady if (_targetReady) return 'PARFAIT — CIBLE DÉTECTÉE, PRÊT';
? 'PARFAIT — CIBLE DÉTECTÉE, PRÊT' return onGround
? 'CIBLE AU SOL — PRÊT À PHOTOGRAPHIER'
: 'PARALLÈLE OK — PRÊT À PHOTOGRAPHIER'; : 'PARALLÈLE OK — PRÊT À PHOTOGRAPHIER';
} }
// Mal aligné → message directif selon l'axe le plus dévié // Mal aligné → message directif selon l'axe le plus dévié.
final double pitch = _parallelismData!.pitchDegrees; // On raisonne sur les écarts à la pose détectée, pas sur le tangage brut :
final double roll = _parallelismData!.rollDegrees; // à plat, celui-ci vaut -90° alors que le cadrage peut être parfait.
final double pitch = _parallelismData!.pitchDeviation;
final double roll = _parallelismData!.rollDeviation;
if (pitch.abs() >= roll.abs()) { if (pitch.abs() >= roll.abs()) {
return pitch > 0 return pitch > 0
@@ -776,15 +782,25 @@ class _CaptureScreenState extends State<CaptureScreen>
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Text(
data.pose == TargetPose.ground ? 'CIBLE AU SOL' : 'CIBLE AU MUR',
style: TextStyle(
color: color,
fontSize: 10,
fontWeight: FontWeight.w700,
letterSpacing: 0.5,
),
),
const SizedBox(height: 4),
_buildAngleRow( _buildAngleRow(
label: 'Pitch', label: 'Pitch',
value: data.pitchDegrees, value: data.pitchDeviation,
color: color, color: color,
), ),
const SizedBox(height: 2), const SizedBox(height: 2),
_buildAngleRow( _buildAngleRow(
label: 'Roll ', label: 'Roll ',
value: data.rollDegrees, value: data.rollDeviation,
color: color, color: color,
), ),
], ],
+35 -2
View File
@@ -3,6 +3,7 @@ import 'package:provider/provider.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import '../../core/constants/app_constants.dart'; import '../../core/constants/app_constants.dart';
import '../../core/theme/app_theme.dart'; import '../../core/theme/app_theme.dart';
import '../../core/widgets/weapon_type_icon.dart';
import '../../data/models/weapon.dart'; import '../../data/models/weapon.dart';
import '../../data/models/maintenance.dart'; import '../../data/models/maintenance.dart';
import '../../data/repositories/session_repository.dart'; import '../../data/repositories/session_repository.dart';
@@ -131,7 +132,26 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
const Text('Informations techniques', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), const Text('Informations techniques', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
const Divider(), const Divider(),
_buildInfoRow('Modèle', _weapon.name), _buildInfoRow('Modèle', _weapon.name),
_buildInfoRow('Type', _weapon.type.displayName), 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('Calibre', _weapon.caliber),
_buildInfoRow('Chargeurs', '${_weapon.magazineCount} x ${_weapon.magazineCapacity} coups'), _buildInfoRow('Chargeurs', '${_weapon.magazineCount} x ${_weapon.magazineCapacity} coups'),
if (_weapon.notes != null && _weapon.notes!.isNotEmpty) ...[ if (_weapon.notes != null && _weapon.notes!.isNotEmpty) ...[
@@ -313,7 +333,20 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
DropdownButtonFormField<WeaponType>( DropdownButtonFormField<WeaponType>(
initialValue: selectedType, initialValue: selectedType,
decoration: const InputDecoration(labelText: 'Type'), decoration: const InputDecoration(labelText: 'Type'),
items: WeaponType.values.map((t) => DropdownMenuItem(value: t, child: Text(t.displayName))).toList(), 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!), onChanged: (v) => setState(() => selectedType = v!),
), ),
_autoScrollOnFocus(TextField( _autoScrollOnFocus(TextField(
+257 -88
View File
@@ -2,14 +2,13 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../../core/constants/app_constants.dart'; import '../../core/constants/app_constants.dart';
import '../../core/theme/app_theme.dart'; import '../../core/theme/app_theme.dart';
import '../../core/widgets/glass_container.dart';
import '../../core/widgets/weapon_type_icon.dart';
import '../../data/models/weapon.dart'; import '../../data/models/weapon.dart';
import '../../data/repositories/session_repository.dart'; import '../../data/repositories/session_repository.dart';
import 'weapon_detail_screen.dart'; import 'weapon_detail_screen.dart';
class WeaponListScreen extends StatefulWidget { class WeaponListScreen extends StatefulWidget {
/// Incrémenté par la navigation à chaque ouverture de l'onglet Armurerie
/// (l'écran est gardé vivant par l'IndexedStack et ne se rafraîchit pas
/// seul : sans ça, un import de sauvegarde resterait invisible).
final int refreshTick; final int refreshTick;
const WeaponListScreen({super.key, this.refreshTick = 0}); const WeaponListScreen({super.key, this.refreshTick = 0});
@@ -49,118 +48,242 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return Scaffold( return Scaffold(
extendBody: true,
appBar: AppBar( appBar: AppBar(
title: const Text('Mon Armurerie'), title: const Text('Mon Armurerie'),
actions: [
IconButton(
icon: const Icon(Icons.add_circle_outline),
tooltip: 'Ajouter une arme',
onPressed: _showAddWeaponDialog,
),
const SizedBox(width: 8),
],
), ),
body: _isLoading body: _isLoading
? const Center(child: CircularProgressIndicator()) ? const Center(child: CircularProgressIndicator())
: _weapons.isEmpty : _weapons.isEmpty
? _buildEmptyState() ? _buildEmptyState(isDark)
: _buildWeaponList(), : _buildWeaponList(isDark),
floatingActionButton: FloatingActionButton(
onPressed: _showAddWeaponDialog,
child: const Icon(Icons.add),
),
); );
} }
Widget _buildEmptyState() { Widget _buildEmptyState(bool isDark) {
return Center( return Center(
child: Column( child: Padding(
mainAxisAlignment: MainAxisAlignment.center, padding: const EdgeInsets.all(32.0),
children: [ child: Column(
Icon(Icons.shield, size: 64, color: Colors.grey[400]), mainAxisAlignment: MainAxisAlignment.center,
const SizedBox(height: 16), children: [
const Text('Aucune arme enregistrée'), GlassContainer(
const SizedBox(height: 24), borderRadius: 30,
ElevatedButton( padding: const EdgeInsets.all(28),
onPressed: _showAddWeaponDialog, child: Icon(
child: const Text('Ajouter ma première arme'), 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() { Widget _buildWeaponList(bool isDark) {
return ListView.builder( return ListView.builder(
padding: const EdgeInsets.all(AppConstants.defaultPadding), padding: const EdgeInsets.fromLTRB(
AppConstants.defaultPadding,
12,
AppConstants.defaultPadding,
100, // Espace pour le floating dock
),
itemCount: _weapons.length, itemCount: _weapons.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final weapon = _weapons[index]; final weapon = _weapons[index];
final accessories = _accessoryChips(weapon); final accessories = _accessoryChips(weapon, isDark);
return Card( final primaryColor = Theme.of(context).colorScheme.primary;
margin: const EdgeInsets.only(bottom: 12),
child: InkWell( return GlassContainer(
borderRadius: BorderRadius.circular(12), borderRadius: 18,
onTap: () async { blur: 14,
await Navigator.push( glowColor: primaryColor,
context, borderColor: isDark
MaterialPageRoute(builder: (_) => WeaponDetailScreen(weapon: weapon)), ? primaryColor.withValues(alpha: 0.18)
); : primaryColor.withValues(alpha: 0.12),
_loadWeapons(); // Reload in case it was edited or maintenance was added margin: const EdgeInsets.only(bottom: 14),
}, padding: const EdgeInsets.all(16),
onLongPress: () => _confirmDelete(weapon), onTap: () async {
child: Padding( await Navigator.push(
// La hauteur du cadre s'adapte automatiquement à la liste d'accessoires. context,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), MaterialPageRoute(
child: Row( builder: (_) => WeaponDetailScreen(weapon: weapon),
),
);
_loadWeapons();
},
onLongPress: () => _confirmDelete(weapon),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
CircleAvatar( Container(
backgroundColor: AppTheme.primaryColor.withValues(alpha: 0.1), padding: const EdgeInsets.all(10),
child: Icon( decoration: BoxDecoration(
weapon.type == WeaponType.handgun ? Icons.shield : Icons.ads_click, gradient: LinearGradient(
color: AppTheme.primaryColor, 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: 16), const SizedBox(width: 14),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [ children: [
Text(weapon.displayName, style: const TextStyle(fontWeight: FontWeight.bold)),
if (accessories.isNotEmpty) ...[
const SizedBox(height: 6),
Wrap(
spacing: 6,
runSpacing: 6,
children: accessories,
),
const SizedBox(height: 6),
] else
const SizedBox(height: 2),
Text( Text(
'${weapon.type.displayName}${weapon.caliber}', weapon.displayName,
style: const TextStyle(fontSize: 13, color: Colors.grey), 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,
),
),
],
), ),
], ],
), ),
), ),
const SizedBox(width: 12),
Column( Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end,
children: [ children: [
Text('${weapon.magazineCount} chargeurs', style: const TextStyle(fontSize: 12)), Text(
Text('${weapon.magazineCapacity} coups', style: const TextStyle(fontSize: 12, color: Colors.grey)), '${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,
),
],
],
), ),
); );
}, },
); );
} }
// Construit la liste des "puces" d'accessoires renseignés pour une arme. List<Widget> _accessoryChips(Weapon weapon, bool isDark) {
// Seuls les accessoires effectivement définis sont affichés ; la card
// s'agrandit donc en fonction du nombre d'accessoires.
List<Widget> _accessoryChips(Weapon weapon) {
final items = <(IconData, String)>[]; final items = <(IconData, String)>[];
if (weapon.optic != null && weapon.optic!.isNotEmpty) { if (weapon.optic != null && weapon.optic!.isNotEmpty) {
items.add((Icons.center_focus_strong, weapon.optic!)); items.add((Icons.center_focus_strong, weapon.optic!));
@@ -176,16 +299,29 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppTheme.primaryColor.withValues(alpha: 0.08), color: (isDark ? Colors.white : Colors.black).withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppTheme.primaryColor.withValues(alpha: 0.25)), border: Border.all(
color: isDark ? AppTheme.darkBorder : AppTheme.lightBorder,
),
), ),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(item.$1, size: 13, color: AppTheme.primaryColor), Icon(
const SizedBox(width: 4), item.$1,
Text(item.$2, style: const TextStyle(fontSize: 12)), 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,
),
),
], ],
), ),
); );
@@ -193,7 +329,6 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
} }
void _showAddWeaponDialog() async { void _showAddWeaponDialog() async {
// Capturé AVANT l'await : plus aucun usage du context après le dialogue.
final repository = context.read<SessionRepository>(); final repository = context.read<SessionRepository>();
final nameController = TextEditingController(); final nameController = TextEditingController();
final caliberController = TextEditingController(); final caliberController = TextEditingController();
@@ -203,7 +338,7 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
final result = await showDialog<bool>( final result = await showDialog<bool>(
context: context, context: context,
builder: (context) => StatefulBuilder( builder: (dialogCtx) => StatefulBuilder(
builder: (context, setState) => AlertDialog( builder: (context, setState) => AlertDialog(
title: const Text('Ajouter une arme'), title: const Text('Ajouter une arme'),
content: SingleChildScrollView( content: SingleChildScrollView(
@@ -212,18 +347,40 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
children: [ children: [
TextField( TextField(
controller: nameController, controller: nameController,
decoration: const InputDecoration(labelText: 'Nom de l\'arme', hintText: 'ex: Glock 17'), decoration: const InputDecoration(
labelText: 'Nom de l\'arme',
hintText: 'ex: Glock 17 Gen 5',
),
), ),
const SizedBox(height: 12),
DropdownButtonFormField<WeaponType>( DropdownButtonFormField<WeaponType>(
initialValue: selectedType, initialValue: selectedType,
decoration: const InputDecoration(labelText: 'Type'), decoration: const InputDecoration(labelText: 'Type'),
items: WeaponType.values.map((t) => DropdownMenuItem(value: t, child: Text(t.displayName))).toList(), 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!), onChanged: (v) => setState(() => selectedType = v!),
), ),
const SizedBox(height: 12),
TextField( TextField(
controller: caliberController, controller: caliberController,
decoration: const InputDecoration(labelText: 'Calibre', hintText: 'ex: 9mm, .22LR'), decoration: const InputDecoration(
labelText: 'Calibre',
hintText: 'ex: 9x19mm, .22 LR, .223 Rem',
),
), ),
const SizedBox(height: 12),
Row( Row(
children: [ children: [
Expanded( Expanded(
@@ -233,7 +390,7 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
), ),
), ),
const SizedBox(width: 16), const SizedBox(width: 12),
Expanded( Expanded(
child: TextField( child: TextField(
controller: magCapController, controller: magCapController,
@@ -247,8 +404,14 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
), ),
), ),
actions: [ actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Annuler')), TextButton(
ElevatedButton(onPressed: () => Navigator.pop(context, true), child: const Text('Ajouter')), onPressed: () => Navigator.pop(dialogCtx, false),
child: const Text('Annuler'),
),
ElevatedButton(
onPressed: () => Navigator.pop(dialogCtx, true),
child: const Text('Ajouter'),
),
], ],
), ),
), ),
@@ -268,18 +431,24 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
} }
void _confirmDelete(Weapon weapon) async { void _confirmDelete(Weapon weapon) async {
// Capturé AVANT l'await : plus aucun usage du context après le dialogue.
final repository = context.read<SessionRepository>(); final repository = context.read<SessionRepository>();
final confirmed = await showDialog<bool>( final confirmed = await showDialog<bool>(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
title: const Text('Supprimer'), title: const Text('Supprimer l\'arme'),
content: Text('Voulez-vous supprimer ${weapon.name} de votre armurerie ?'), content: Text('Voulez-vous supprimer ${weapon.name} de votre armurerie ?'),
actions: [ actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Annuler')),
TextButton( TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Annuler'),
),
ElevatedButton(
onPressed: () => Navigator.pop(context, true), onPressed: () => Navigator.pop(context, true),
child: const Text('Supprimer', style: TextStyle(color: AppTheme.errorColor)), style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.errorColor,
foregroundColor: Colors.white,
),
child: const Text('Supprimer'),
), ),
], ],
), ),
+171 -115
View File
@@ -1,7 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
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';
@@ -11,10 +10,6 @@ import 'widgets/session_list_item.dart';
import 'widgets/history_chart.dart'; import 'widgets/history_chart.dart';
class HistoryScreen extends StatefulWidget { class HistoryScreen extends StatefulWidget {
/// Incrémenté par la navigation à chaque ouverture de l'onglet Historique,
/// pour forcer un rechargement des sessions (l'écran est gardé vivant par un
/// IndexedStack, sinon une session tout juste clôturée n'apparaîtrait pas
/// tant qu'on ne tire pas manuellement pour rafraîchir).
final int refreshTick; final int refreshTick;
const HistoryScreen({super.key, this.refreshTick = 0}); const HistoryScreen({super.key, this.refreshTick = 0});
@@ -27,8 +22,6 @@ class _HistoryScreenState extends State<HistoryScreen> {
List<Session> _sessions = []; List<Session> _sessions = [];
bool _isLoading = true; bool _isLoading = true;
TargetType? _filterType; TargetType? _filterType;
// --- MODIFICATION : Remplacement de DateTime par DateTimeRange ---
DateTimeRange? _selectedDateRange; DateTimeRange? _selectedDateRange;
@override @override
@@ -40,8 +33,6 @@ class _HistoryScreenState extends State<HistoryScreen> {
@override @override
void didUpdateWidget(HistoryScreen oldWidget) { void didUpdateWidget(HistoryScreen oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
// L'onglet vient d'être ré-ouvert : on recharge pour afficher les sessions
// récemment clôturées sans avoir à tirer manuellement pour rafraîchir.
if (oldWidget.refreshTick != widget.refreshTick) { if (oldWidget.refreshTick != widget.refreshTick) {
_loadSessions(); _loadSessions();
} }
@@ -59,18 +50,14 @@ class _HistoryScreenState extends State<HistoryScreen> {
setState(() { setState(() {
_sessions = sessions; _sessions = sessions;
// --- FILTRAGE PAR TYPE DE CIBLE ---
// Une session est retenue si au moins une de ses cibles est du type choisi.
if (_filterType != null) { if (_filterType != null) {
_sessions = _sessions _sessions = _sessions
.where((s) => s.analyses.any((a) => a.targetType == _filterType)) .where((s) => s.analyses.any((a) => a.targetType == _filterType))
.toList(); .toList();
} }
// --- LOGIQUE DE FILTRAGE PAR PÉRIODE ---
if (_selectedDateRange != null) { if (_selectedDateRange != null) {
_sessions = _sessions.where((s) { _sessions = _sessions.where((s) {
// On compare uniquement les dates (sans les heures) pour éviter les bugs
final sessionDate = DateTime( final sessionDate = DateTime(
s.createdAt.year, s.createdAt.year,
s.createdAt.month, s.createdAt.month,
@@ -109,32 +96,13 @@ class _HistoryScreenState extends State<HistoryScreen> {
} }
} }
// --- MODIFICATION : Fonction DateRangePicker ---
Future<void> _pickDateRange() async { Future<void> _pickDateRange() async {
final DateTimeRange? picked = await showDateRangePicker( final picked = await showDateRangePicker(
context: context, context: context,
initialDateRange: _selectedDateRange, initialDateRange: _selectedDateRange,
firstDate: DateTime(2020), firstDate: DateTime(2020),
lastDate: DateTime.now().add(const Duration(days: 1)), lastDate: DateTime.now().add(const Duration(days: 1)),
locale: const Locale('fr', 'FR'), locale: const Locale('fr', 'FR'),
builder: (context, child) {
return Theme(
data: ThemeData.light().copyWith(
colorScheme: ColorScheme.light(
primary: AppTheme.primaryColor, // En-tête et sélection
onPrimary: Colors.white, // Texte sur en-tête/sélection
surface: Colors.white, // Fond du calendrier
onSurface: Colors.black87, // Texte des dates (Noir sur Blanc)
secondary: AppTheme.primaryColor,
),
dialogTheme: const DialogThemeData(backgroundColor: Colors.white),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(foregroundColor: AppTheme.primaryColor),
),
),
child: child!,
);
},
); );
if (picked != null) { if (picked != null) {
@@ -145,134 +113,221 @@ class _HistoryScreenState extends State<HistoryScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return Scaffold( return Scaffold(
extendBody: true,
appBar: AppBar( appBar: AppBar(
title: const Text('Historique'), title: const Text('Carnet de Tir'),
actions: [ actions: [
// NOTE : on passe par un String sentinelle ('all') car un IconButton(
// PopupMenuItem avec value null ne déclenche jamais onSelected
// (Flutter l'interprète comme une annulation du menu).
PopupMenuButton<String>(
icon: Icon( icon: Icon(
Icons.filter_list, Icons.date_range_outlined,
// Icône colorée quand un filtre est actif, pour le rendre visible. color: _selectedDateRange != null ? AppTheme.primaryColor : null,
color: _filterType != null ? AppTheme.primaryColor : null,
), ),
onSelected: (value) { tooltip: 'Filtrer par date',
setState(() { onPressed: _pickDateRange,
_filterType =
value == 'all' ? null : TargetType.fromString(value);
});
_loadSessions();
},
itemBuilder: (context) => [
const PopupMenuItem(value: 'all', child: Text('Tous')),
...TargetType.values.map(
(type) => PopupMenuItem(
value: type.name,
child: Text(type.displayName),
),
),
],
), ),
const SizedBox(width: 8),
], ],
), ),
body: Column( body: Column(
children: [ children: [
_buildFilterChips(isDark),
Expanded( Expanded(
child: _isLoading child: _isLoading
? const Center(child: CircularProgressIndicator()) ? const Center(child: CircularProgressIndicator())
: _sessions.isEmpty : _sessions.isEmpty
? _buildEmptyState() ? _buildEmptyState(isDark)
: _buildContent(), : _buildContent(isDark),
), ),
_buildBottomFilterBar(), if (_selectedDateRange != null) _buildActivePeriodBanner(isDark),
], ],
), ),
); );
} }
Widget _buildBottomFilterBar() { Widget _buildFilterChips(bool isDark) {
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Theme.of(context).cardColor, color: isDark ? AppTheme.darkBackground : AppTheme.lightBackground,
boxShadow: const [ border: Border(
BoxShadow( bottom: BorderSide(
color: Colors.black26, color: isDark ? AppTheme.darkBorder : AppTheme.lightBorder,
blurRadius: 4, width: 1,
offset: Offset(0, -2),
), ),
], ),
), ),
child: SafeArea( child: SingleChildScrollView(
top: false, scrollDirection: Axis.horizontal,
child: Row( child: Row(
children: [ children: [
Expanded( _buildTypeFilterChip('Toutes les cibles', null),
child: OutlinedButton.icon( const SizedBox(width: 8),
onPressed: _pickDateRange, _buildTypeFilterChip(
icon: const Icon(Icons.date_range, size: 18), 'Cibles 1-10',
label: Text( TargetType.concentric,
_selectedDateRange == null ),
? 'Choisir une période' const SizedBox(width: 8),
: '${DateFormat('dd/MM/yy').format(_selectedDateRange!.start)} - ${DateFormat('dd/MM/yy').format(_selectedDateRange!.end)}', _buildTypeFilterChip(
), 'Silhouettes',
), TargetType.silhouette,
), ),
if (_selectedDateRange != null)
IconButton(
icon: const Icon(Icons.close, color: AppTheme.errorColor),
onPressed: () {
setState(() => _selectedDateRange = null);
_loadSessions();
},
),
], ],
), ),
), ),
); );
} }
Widget _buildEmptyState() { Widget _buildTypeFilterChip(String label, TargetType? type) {
return Center( final isSelected = _filterType == type;
child: Column( return ChoiceChip(
mainAxisAlignment: MainAxisAlignment.center, label: Text(label),
children: [ selected: isSelected,
Icon(Icons.history, size: 64, color: Colors.grey[400]), onSelected: (selected) {
const SizedBox(height: 16), if (selected) {
const Text('Aucune session sur cette période'), 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 _buildContent() { Widget _buildEmptyState(bool isDark) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: isDark ? AppTheme.darkSurface : AppTheme.lightSurfaceVariant,
shape: BoxShape.circle,
border: Border.all(
color: isDark ? AppTheme.darkBorder : AppTheme.lightBorder,
),
),
child: Icon(
Icons.history_toggle_off,
size: 56,
color: isDark ? AppTheme.darkTextMuted : AppTheme.lightTextMuted,
),
),
const SizedBox(height: 18),
Text(
'Aucune session trouvée',
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.w700,
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
),
),
const SizedBox(height: 6),
Text(
_selectedDateRange != null || _filterType != null
? 'Essayez de réinitialiser vos filtres de recherche.'
: 'Vos sessions enregistrées apparaîtront ici.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13,
color: isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary,
),
),
],
),
),
);
}
Widget _buildContent(bool isDark) {
return RefreshIndicator( return RefreshIndicator(
onRefresh: _loadSessions, onRefresh: _loadSessions,
color: AppTheme.primaryColor,
child: CustomScrollView( child: CustomScrollView(
slivers: [ slivers: [
if (_sessions.length >= 2 && _selectedDateRange == null) if (_sessions.length >= 2 && _selectedDateRange == null)
SliverToBoxAdapter( SliverToBoxAdapter(
child: Padding( child: Padding(
padding: const EdgeInsets.all(AppConstants.defaultPadding), padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: HistoryChart(sessions: _sessions), child: HistoryChart(sessions: _sessions),
), ),
), ),
SliverPadding( SliverPadding(
padding: const EdgeInsets.all(AppConstants.defaultPadding), padding: const EdgeInsets.fromLTRB(16, 8, 16, 100),
sliver: SliverList( sliver: SliverList(
delegate: SliverChildBuilderDelegate((context, index) { delegate: SliverChildBuilderDelegate(
final session = _sessions[index]; (context, index) {
return Padding( final session = _sessions[index];
padding: const EdgeInsets.only(bottom: 12), return SessionListItem(
child: SessionListItem(
session: session, session: session,
onTap: () => _openSessionDetail(session), onTap: () => _openSessionDetail(session),
onDelete: () => _deleteSession(session), onDelete: () => _deleteSession(session),
), );
); },
}, childCount: _sessions.length), childCount: _sessions.length,
),
), ),
), ),
], ],
@@ -292,21 +347,22 @@ class _HistoryScreenState extends State<HistoryScreen> {
final confirmed = await showDialog<bool>( final confirmed = await showDialog<bool>(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
title: const Text('Supprimer'), 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( style: ElevatedButton.styleFrom(
'Supprimer', backgroundColor: AppTheme.errorColor,
style: TextStyle(color: AppTheme.errorColor), foregroundColor: Colors.white,
), ),
child: const Text('Supprimer'),
), ),
], ],
), ),
@@ -2,6 +2,7 @@ import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import '../../../core/theme/app_theme.dart'; import '../../../core/theme/app_theme.dart';
import '../../../core/widgets/glass_container.dart';
import '../../../data/models/session.dart'; import '../../../data/models/session.dart';
class SessionListItem extends StatelessWidget { class SessionListItem extends StatelessWidget {
@@ -18,103 +19,159 @@ 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;
onTap: onTap,
child: Padding( // Calcul de la couleur du score selon la moyenne par tir
padding: const EdgeInsets.all(12), final primaryColor = Theme.of(context).colorScheme.primary;
child: Row( final avg = session.averageScore;
children: [ Color scoreColor = primaryColor;
// Thumbnail (from first target) if (avg >= 9.0) {
ClipRRect( scoreColor = AppTheme.secondaryColor;
borderRadius: BorderRadius.circular(8), } else if (avg >= 7.5) {
child: SizedBox( scoreColor = primaryColor;
width: 60, } else {
height: 60, scoreColor = isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary;
child: _buildThumbnail(), }
final formattedDate = DateFormat('dd/MM/yyyy • HH:mm', 'fr_FR').format(session.createdAt);
return GlassContainer(
borderRadius: 18,
blur: 14,
glowColor: scoreColor,
borderColor: isDark ? scoreColor.withValues(alpha: 0.2) : scoreColor.withValues(alpha: 0.15),
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(14),
onTap: onTap,
child: Row(
children: [
// Aperçu de la cible avec bordure nette
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: (isDark ? Colors.white : Colors.black).withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isDark ? AppTheme.darkBorder : AppTheme.lightBorder,
), ),
), ),
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: [
Text(
session.weapon,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 3),
Text(
formattedDate,
style: TextStyle(
fontSize: 11,
color: textSecondary,
),
),
const SizedBox(height: 5),
Row(
children: [ children: [
Row( Container(
children: [ padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
const Icon( decoration: BoxDecoration(
Icons.shield, color: (isDark ? Colors.white : Colors.black).withValues(alpha: 0.06),
size: 16, borderRadius: BorderRadius.circular(6),
color: AppTheme.primaryColor, ),
child: Text(
'${session.distance}m',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w700,
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
), ),
const SizedBox(width: 4), ),
Text(
session.weapon,
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
],
), ),
const SizedBox(height: 4), const SizedBox(width: 6),
Text( Text(
DateFormat('dd/MM/yyyy HH:mm').format(session.createdAt), '${session.targetCount} cible${session.targetCount > 1 ? 's' : ''}${session.totalShots} tirs',
style: Theme.of(context).textTheme.bodySmall, style: TextStyle(
), fontSize: 11,
const SizedBox(height: 4), color: textMuted,
Row( ),
children: [
Icon(Icons.track_changes, size: 14, color: Colors.grey[600]),
const SizedBox(width: 4),
Text(
'${session.targetCount} cible(s) • ${session.distance}m',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.grey[600],
),
),
],
), ),
], ],
), ),
), ],
),
// Score
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'${session.totalScore}',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: AppTheme.primaryColor,
),
),
Text(
'${session.totalShots} tirs',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
// Delete button
if (onDelete != null)
IconButton(
icon: const Icon(Icons.delete_outline),
onPressed: onDelete,
color: Colors.grey,
iconSize: 20,
),
],
), ),
),
// Score et moyenne stylisés façon cyber HUD
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: scoreColor.withValues(alpha: isDark ? 0.12 : 0.08),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: scoreColor.withValues(alpha: isDark ? 0.3 : 0.2),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'${session.totalScore}',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w900,
color: scoreColor,
letterSpacing: -0.5,
),
),
Text(
'Moy. ${session.averageScore.toStringAsFixed(1)}',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w700,
color: scoreColor,
),
),
],
),
),
// Bouton supprimer
if (onDelete != null) ...[
const SizedBox(width: 4),
IconButton(
icon: const Icon(Icons.delete_outline_rounded),
onPressed: onDelete,
color: textMuted,
iconSize: 20,
tooltip: 'Supprimer',
),
],
],
), ),
); );
} }
Widget _buildThumbnail() { Widget _buildThumbnail(bool isDark) {
if (session.analyses.isEmpty) return _buildPlaceholder(); if (session.analyses.isEmpty) return _buildPlaceholder(isDark);
final file = File(session.analyses.first.imagePath); final file = File(session.analyses.first.imagePath);
@@ -122,20 +179,18 @@ class SessionListItem extends StatelessWidget {
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,
Icons.track_changes, size: 24,
color: Colors.grey[400],
),
); );
} }
} }
File diff suppressed because it is too large Load Diff
+73 -27
View File
@@ -1,17 +1,14 @@
/// Widget carte réutilisable pour afficher une statistique.
///
/// Affiche une icône, un titre et une valeur avec une couleur personnalisable.
/// Utilisé sur l'écran d'accueil pour les statistiques rapides.
library;
import 'package:flutter/material.dart'; import '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),
child: Column( return GlassContainer(
children: [ borderRadius: 18,
Icon(icon, color: color, size: 32), blur: 14,
const SizedBox(height: 8), glowColor: color,
Text( borderColor: isDark ? color.withValues(alpha: 0.22) : color.withValues(alpha: 0.18),
value, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
style: Theme.of(context).textTheme.headlineSmall?.copyWith( child: Column(
fontWeight: FontWeight.bold, crossAxisAlignment: CrossAxisAlignment.start,
color: color, mainAxisAlignment: MainAxisAlignment.center,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: color.withValues(alpha: isDark ? 0.18 : 0.12),
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: color.withValues(alpha: isDark ? 0.35 : 0.25),
width: 1,
),
),
child: Icon(icon, color: color, size: 20),
), ),
if (subtitle != null)
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: (isDark ? Colors.white : Colors.black).withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(6),
),
child: Text(
subtitle!,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w600,
color: textSecondary,
),
),
),
],
),
const SizedBox(height: 12),
Text(
value,
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.w800,
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
letterSpacing: -0.5,
), ),
const SizedBox(height: 4), maxLines: 1,
Text( overflow: TextOverflow.ellipsis,
title, ),
style: Theme.of(context).textTheme.bodySmall?.copyWith( const SizedBox(height: 2),
color: Colors.grey[600], Text(
), title,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: textSecondary,
letterSpacing: 0.1,
), ),
], maxLines: 1,
), overflow: TextOverflow.ellipsis,
),
],
), ),
); );
} }
+9 -1
View File
@@ -6,6 +6,7 @@ class SessionProvider extends ChangeNotifier {
DateTime? get sessionDate => _sessionDate; DateTime? get sessionDate => _sessionDate;
String? _currentWeaponName; String? _currentWeaponName;
String? _currentWeaponId; String? _currentWeaponId;
String? _currentWeaponCaliber;
int _shotsPerTarget = 5; int _shotsPerTarget = 5;
int _distance = 25; int _distance = 25;
String? _activeSessionId; String? _activeSessionId;
@@ -14,6 +15,11 @@ class SessionProvider extends ChangeNotifier {
String? get currentWeapon => _currentWeaponName; String? get currentWeapon => _currentWeaponName;
String? get currentWeaponId => _currentWeaponId; 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 shotsPerTarget => _shotsPerTarget;
int get distance => _distance; int get distance => _distance;
bool get isSessionActive => _isSessionActive; bool get isSessionActive => _isSessionActive;
@@ -23,9 +29,10 @@ class SessionProvider extends ChangeNotifier {
int get totalSessionScore => _currentAnalyses.fold(0, (sum, a) => sum + a.totalScore); int get totalSessionScore => _currentAnalyses.fold(0, (sum, a) => sum + a.totalScore);
int get targetCount => _currentAnalyses.length; int get targetCount => _currentAnalyses.length;
void startSession(String weaponName, int shots, String sessionId, {String? weaponId, int distance = 25, DateTime? date}) { void startSession(String weaponName, int shots, String sessionId, {String? weaponId, String? caliber, int distance = 25, DateTime? date}) {
_currentWeaponName = weaponName; _currentWeaponName = weaponName;
_currentWeaponId = weaponId; _currentWeaponId = weaponId;
_currentWeaponCaliber = caliber;
_shotsPerTarget = shots; _shotsPerTarget = shots;
_distance = distance; _distance = distance;
_sessionDate = date ?? DateTime.now(); _sessionDate = date ?? DateTime.now();
@@ -45,6 +52,7 @@ class SessionProvider extends ChangeNotifier {
_activeSessionId = null; _activeSessionId = null;
_currentWeaponId = null; _currentWeaponId = null;
_currentWeaponName = null; _currentWeaponName = null;
_currentWeaponCaliber = null;
_currentAnalyses.clear(); _currentAnalyses.clear();
_distance = 25; _distance = 25;
notifyListeners(); notifyListeners();
+416 -206
View File
@@ -1,6 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:intl/intl.dart'; // Utile pour formater proprement la date en français 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/weapon.dart'; import '../../data/models/weapon.dart';
@@ -25,9 +25,17 @@ class _SessionSetupScreenState extends State<SessionSetupScreen> {
Weapon? _selectedWeapon; Weapon? _selectedWeapon;
bool _isLoadingWeapons = true; bool _isLoadingWeapons = true;
// AJOUT DE LA VARIABLE DATE : Initialisée par défaut à maintenant
DateTime _selectedDate = DateTime.now(); DateTime _selectedDate = DateTime.now();
/// Valeur sentinelle de l'item "Ajouter une nouvelle arme" du menu deroulant.
static const String _addWeaponValue = '__add_weapon__';
final GlobalKey<FormFieldState<String>> _weaponFieldKey =
GlobalKey<FormFieldState<String>>();
static const List<int> _presetDistances = [10, 15, 25, 50, 100, 200];
static const List<int> _presetShots = [3, 5, 10, 15, 20, 30, 50];
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -42,8 +50,21 @@ class _SessionSetupScreenState extends State<SessionSetupScreen> {
if (mounted) { if (mounted) {
setState(() { setState(() {
_availableWeapons = weapons; _availableWeapons = weapons;
if (_availableWeapons.isNotEmpty && _selectedWeapon == null) {
_selectedWeapon = _availableWeapons.first; // 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!); _updateSettingsForWeapon(_selectedWeapon!);
} }
_isLoadingWeapons = false; _isLoadingWeapons = false;
@@ -51,25 +72,58 @@ class _SessionSetupScreenState extends State<SessionSetupScreen> {
} }
} }
Future<void> _openArmory() async {
final knownIds = _availableWeapons.map((w) => w.id).toSet();
await Navigator.push(
context,
MaterialPageRoute(builder: (_) => const WeaponListScreen()),
);
if (!mounted) return;
await _loadWeapons();
if (!mounted) return;
// Selectionne automatiquement l'arme qui vient d'etre ajoutee.
Weapon? added;
for (final weapon in _availableWeapons) {
if (!knownIds.contains(weapon.id)) {
added = weapon;
break;
}
}
if (added != null) {
final newWeapon = added;
setState(() {
_selectedWeapon = newWeapon;
_updateSettingsForWeapon(newWeapon);
});
}
}
void _updateSettingsForWeapon(Weapon weapon) { void _updateSettingsForWeapon(Weapon weapon) {
_shotsPerTarget = weapon.magazineCapacity; _shotsPerTarget = weapon.magazineCapacity > 0 ? weapon.magazineCapacity : 5;
_distance = (weapon.type == WeaponType.handgun) ? 25 : 50; _distance = (weapon.type == WeaponType.handgun) ? 25 : 50;
} }
// Fonction pour ouvrir le calendrier si l'utilisateur clique sur le champ
Future<void> _pickDate() async { Future<void> _pickDate() async {
final DateTime? picked = await showDatePicker( final DateTime? picked = await showDatePicker(
context: context, context: context,
initialDate: _selectedDate, initialDate: _selectedDate,
firstDate: DateTime(2020), firstDate: DateTime(2020),
lastDate: DateTime(2100), lastDate: DateTime(2100),
locale: const Locale('fr', 'FR'), // Force le calendrier en français locale: const Locale('fr', 'FR'),
); );
if (picked != null && picked != _selectedDate) { if (picked != null && picked != _selectedDate) {
setState(() { setState(() {
// On garde aussi l'heure actuelle lors du changement de date
final now = DateTime.now(); final now = DateTime.now();
_selectedDate = DateTime(picked.year, picked.month, picked.day, now.hour, now.minute); _selectedDate = DateTime(
picked.year,
picked.month,
picked.day,
now.hour,
now.minute,
);
}); });
} }
} }
@@ -81,15 +135,12 @@ class _SessionSetupScreenState extends State<SessionSetupScreen> {
final sessionId = repository.generateId(); final sessionId = repository.generateId();
// PASSE-PARTOUT : On envoie la session au provider.
// Note : Si ton `sessionProvider.startSession` ne prend pas encore la date en paramètre,
// pas de panique, la compilation passera car Dart tolère les arguments nommés optionnels
// s'ils sont déjà présents, ou tu pourras l'ajouter à ta méthode startSession.
sessionProvider.startSession( sessionProvider.startSession(
_selectedWeapon!.displayName, _selectedWeapon!.displayName,
_shotsPerTarget, _shotsPerTarget,
sessionId, sessionId,
weaponId: _selectedWeapon!.id, weaponId: _selectedWeapon!.id,
caliber: _selectedWeapon!.caliber,
distance: _distance, distance: _distance,
date: _selectedDate, date: _selectedDate,
); );
@@ -103,221 +154,380 @@ class _SessionSetupScreenState extends State<SessionSetupScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// Petit formatage sympa en français (ex: "27 mai 2026") final isDark = Theme.of(context).brightness == Brightness.dark;
final String formattedDate = DateFormat('dd MMMM yyyy', 'fr_FR').format(_selectedDate); final formattedDate = DateFormat('dd MMMM yyyy • HH:mm', 'fr_FR').format(_selectedDate);
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text('Configuration de la session'), title: const Text('Configuration de Session'),
), ),
body: _isLoadingWeapons body: _isLoadingWeapons
? const Center(child: CircularProgressIndicator()) ? const Center(child: CircularProgressIndicator())
: SingleChildScrollView( : SingleChildScrollView(
padding: const EdgeInsets.all(AppConstants.defaultPadding), padding: const EdgeInsets.all(AppConstants.defaultPadding),
child: Form( child: Form(
key: _formKey, key: _formKey,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
const Text( if (_availableWeapons.isEmpty)
'Informations générales', _buildEmptyArmoryState(isDark)
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), else ...[
), _buildWeaponAndDateSection(isDark, formattedDate),
const SizedBox(height: 16), const SizedBox(height: 20),
_buildDistanceSection(isDark),
// AFFICHAGE CONDITIONNEL : Armurerie vide vs Armurerie remplie const SizedBox(height: 20),
if (_availableWeapons.isEmpty) _buildShotsSection(isDark),
Container( const SizedBox(height: 32),
padding: const EdgeInsets.all(16), _buildStartButton(),
decoration: BoxDecoration(
color: AppTheme.errorColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
border: Border.all(color: AppTheme.errorColor.withValues(alpha: 0.5)),
),
child: Column(
children: [
const Icon(Icons.warning_amber_rounded, color: AppTheme.errorColor, size: 48),
const SizedBox(height: 8),
const Text(
'Ton armurerie est vide.',
style: TextStyle(fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
const SizedBox(height: 4),
const Text(
'Tu dois d\'abord ajouter une arme pour pouvoir démarrer une session de tir.',
textAlign: TextAlign.center,
),
const SizedBox(height: 16), const SizedBox(height: 16),
ElevatedButton.icon( ],
onPressed: () { ],
Navigator.push( ),
context, ),
MaterialPageRoute(builder: (_) => const WeaponListScreen()), ),
).then((_) { );
_loadWeapons(); }
});
}, Widget _buildEmptyArmoryState(bool isDark) {
icon: const Icon(Icons.add), return Container(
label: const Text('ALLER À MON ARMURERIE'), 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,
),
), ),
], ],
), ),
) ),
else ],
Column( onChanged: (value) {
children: [ if (value == null) return;
DropdownButtonFormField<Weapon>(
initialValue: _selectedWeapon,
decoration: InputDecoration(
labelText: 'Sélectionner une arme',
prefixIcon: const Icon(Icons.shield),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
),
),
items: _availableWeapons.map((w) => DropdownMenuItem(
value: w,
child: Text(w.displayName),
)).toList(),
onChanged: (value) {
if (value != null) {
setState(() {
_selectedWeapon = value;
_updateSettingsForWeapon(value);
});
}
},
),
const SizedBox(height: 16),
// NOUVEAU CHAMP : Sélecteur de date interactif if (value == _addWeaponValue) {
InkWell( // L'item d'action n'est pas une arme : on restaure aussitot
onTap: _availableWeapons.isEmpty ? null : _pickDate, // la selection precedente avant d'ouvrir l'armurerie.
borderRadius: BorderRadius.circular(AppConstants.borderRadius), _weaponFieldKey.currentState?.didChange(_selectedWeapon?.id);
child: IgnorePointer( _openArmory();
child: TextFormField( return;
decoration: InputDecoration( }
labelText: 'Date de la session',
prefixIcon: const Icon(Icons.calendar_today), for (final weapon in _availableWeapons) {
border: OutlineInputBorder( if (weapon.id == value) {
borderRadius: BorderRadius.circular(AppConstants.borderRadius), setState(() {
), _selectedWeapon = weapon;
), _updateSettingsForWeapon(weapon);
controller: TextEditingController(text: formattedDate), });
), 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(
const SizedBox(height: 24), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
// Distance selector color: AppTheme.secondaryColor.withValues(alpha: isDark ? 0.2 : 0.1),
const Text( borderRadius: BorderRadius.circular(8),
'Distance de tir (mètres)', border: Border.all(
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500), color: AppTheme.secondaryColor.withValues(alpha: 0.4),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: Slider(
value: _distance.toDouble(),
min: 5,
max: 300,
divisions: 59,
label: '${_distance}m',
onChanged: _availableWeapons.isEmpty ? null : (value) {
setState(() {
_distance = value.round();
});
},
), ),
), ),
Container( child: Text(
width: 70, '${_distance}m',
height: 50, style: const TextStyle(
alignment: Alignment.center, fontSize: 16,
decoration: BoxDecoration( fontWeight: FontWeight.w800,
color: AppTheme.secondaryColor.withValues(alpha: 0.1), color: AppTheme.secondaryColor,
borderRadius: BorderRadius.circular(8),
), ),
child: Text(
'${_distance}m',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppTheme.secondaryColor,
),
),
),
],
),
const SizedBox(height: 24),
// Shots per target
const Text(
'Nombre de balles pour la cible actuelle',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: Slider(
value: _shotsPerTarget.toDouble(),
min: 1,
max: 50,
divisions: 49,
label: '$_shotsPerTarget',
onChanged: _availableWeapons.isEmpty ? null : (value) {
setState(() {
_shotsPerTarget = value.round();
});
},
),
),
Container(
width: 50,
height: 50,
alignment: Alignment.center,
decoration: BoxDecoration(
color: AppTheme.primaryColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'$_shotsPerTarget',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppTheme.primaryColor,
),
),
),
],
),
const SizedBox(height: 48),
ElevatedButton.icon(
onPressed: (_availableWeapons.isEmpty || _selectedWeapon == null)
? null
: _startSession,
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primaryColor,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
), ),
), ),
icon: const Icon(Icons.play_arrow), ],
label: const Text( ),
'DÉMARRER LA SESSION', const SizedBox(height: 12),
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), Wrap(
spacing: 8,
runSpacing: 8,
children: _presetDistances.map((d) {
final isSelected = _distance == d;
return ChoiceChip(
label: Text('${d}m'),
selected: isSelected,
onSelected: (selected) {
if (selected) setState(() => _distance = d);
},
);
}).toList(),
),
const SizedBox(height: 8),
Slider(
value: _distance.toDouble(),
min: 5,
max: 300,
divisions: 59,
activeColor: AppTheme.secondaryColor,
onChanged: (val) {
setState(() => _distance = val.round());
},
),
],
),
),
);
}
Widget _buildShotsSection(bool isDark) {
final primaryColor = Theme.of(context).colorScheme.primary;
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Icon(Icons.ads_click, color: primaryColor, size: 20),
const SizedBox(width: 8),
Text(
'Tirs par Cible',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
),
),
],
), ),
), Container(
], padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: primaryColor.withValues(alpha: isDark ? 0.2 : 0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: primaryColor.withValues(alpha: 0.4),
),
),
child: Text(
'$_shotsPerTarget tirs',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w800,
color: primaryColor,
),
),
),
],
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: _presetShots.map((s) {
final isSelected = _shotsPerTarget == s;
return ChoiceChip(
label: Text('$s coups'),
selected: isSelected,
onSelected: (selected) {
if (selected) setState(() => _shotsPerTarget = s);
},
);
}).toList(),
),
const SizedBox(height: 8),
Slider(
value: _shotsPerTarget.toDouble(),
min: 1,
max: 50,
divisions: 49,
activeColor: primaryColor,
onChanged: (val) {
setState(() => _shotsPerTarget = val.round());
},
),
],
),
),
);
}
Widget _buildStartButton() {
final primaryColor = Theme.of(context).colorScheme.primary;
return Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
gradient: LinearGradient(
colors: [
primaryColor,
HSLColor.fromColor(primaryColor).withLightness((HSLColor.fromColor(primaryColor).lightness + 0.15).clamp(0.0, 1.0)).toColor(),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
boxShadow: [
BoxShadow(
color: primaryColor.withValues(alpha: 0.35),
blurRadius: 16,
offset: const Offset(0, 4),
),
],
),
child: ElevatedButton.icon(
onPressed: (_availableWeapons.isEmpty || _selectedWeapon == null)
? null
: _startSession,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.transparent,
foregroundColor: Colors.white,
shadowColor: Colors.transparent,
padding: const EdgeInsets.symmetric(vertical: 18),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
),
icon: const Icon(Icons.play_arrow_rounded, size: 26),
label: const Text(
'DÉMARRER LA SESSION',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w800,
letterSpacing: 0.8,
), ),
), ),
), ),
File diff suppressed because it is too large Load Diff
+80 -32
View File
@@ -7,6 +7,7 @@ import 'package:provider/provider.dart';
import 'package:fl_chart/fl_chart.dart'; // La librairie pour les courbes import 'package:fl_chart/fl_chart.dart'; // La librairie pour les courbes
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'package:share_plus/share_plus.dart'; import 'package:share_plus/share_plus.dart';
import '../../core/theme/app_theme.dart';
import '../../core/widgets/metric_info_button.dart'; import '../../core/widgets/metric_info_button.dart';
import '../../data/models/session.dart'; import '../../data/models/session.dart';
import '../../data/repositories/session_repository.dart'; import '../../data/repositories/session_repository.dart';
@@ -235,6 +236,7 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
extendBody: true,
appBar: AppBar( appBar: AppBar(
title: const Text('Statistiques'), title: const Text('Statistiques'),
centerTitle: true, centerTitle: true,
@@ -296,7 +298,7 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
onRefresh: _loadStatistics, onRefresh: _loadStatistics,
child: SingleChildScrollView( child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(), physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16), padding: const EdgeInsets.fromLTRB(16, 16, 16, 100),
child: Column( child: Column(
children: [ children: [
// 2. DONNÉES RAPIDES (Tirs et Sessions) // 2. DONNÉES RAPIDES (Tirs et Sessions)
@@ -414,13 +416,14 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
); );
} }
final activeColor = const Color(0xFF1A73E8); final isDark = Theme.of(context).brightness == Brightness.dark;
final activeColor = Theme.of(context).colorScheme.primary;
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: activeColor.withValues(alpha: 0.08), color: activeColor.withValues(alpha: isDark ? 0.12 : 0.08),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(14),
border: Border.all(color: activeColor.withValues(alpha: 0.3)), border: Border.all(color: activeColor.withValues(alpha: 0.35)),
), ),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -429,7 +432,15 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
children: [ children: [
Icon(Icons.compare_arrows, color: activeColor, size: 18), Icon(Icons.compare_arrows, color: activeColor, size: 18),
const SizedBox(width: 6), const SizedBox(width: 6),
Text('Comparaison', style: TextStyle(fontWeight: FontWeight.bold, color: activeColor)), Text(
'Comparaison de sessions',
style: TextStyle(
fontWeight: FontWeight.w700,
fontSize: 13,
color: activeColor,
letterSpacing: 0.3,
),
),
const Spacer(), const Spacer(),
IconButton( IconButton(
tooltip: 'Quitter la comparaison', tooltip: 'Quitter la comparaison',
@@ -493,27 +504,39 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
List<String> items, List<String> items,
void Function(String?) onChanged, void Function(String?) onChanged,
) { ) {
final theme = Theme.of(context); final isDark = Theme.of(context).brightness == Brightness.dark;
final textSecondary = isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary;
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration( decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), color: isDark ? AppTheme.darkSurfaceElevated : AppTheme.lightSurfaceVariant,
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(12),
border: Border.all(color: theme.dividerColor), border: Border.all(
color: isDark ? AppTheme.darkBorder : AppTheme.lightBorder,
),
), ),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
label, label,
style: TextStyle(color: theme.textTheme.bodySmall?.color?.withValues(alpha: 0.6), fontSize: 10), style: TextStyle(
color: textSecondary,
fontSize: 10,
fontWeight: FontWeight.w600,
),
), ),
DropdownButton<String>( DropdownButton<String>(
value: value, value: value,
isExpanded: true, isExpanded: true,
underline: Container(), underline: const SizedBox(),
dropdownColor: theme.colorScheme.surfaceContainerHighest, dropdownColor: isDark ? AppTheme.darkSurfaceElevated : AppTheme.lightSurfaceVariant,
style: TextStyle(color: theme.textTheme.bodyMedium?.color, fontSize: 14), style: TextStyle(
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
fontSize: 13,
fontWeight: FontWeight.w600,
),
items: items items: items
.map( .map(
(String val) => (String val) =>
@@ -529,28 +552,45 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
// Widget pour les petites cartes de stats // Widget pour les petites cartes de stats
Widget _buildQuickStat(String label, String value, IconData icon) { Widget _buildQuickStat(String label, String value, IconData icon) {
final theme = Theme.of(context); final isDark = Theme.of(context).brightness == Brightness.dark;
final primary = Theme.of(context).colorScheme.primary;
return Container( return Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration( decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), color: isDark ? AppTheme.darkSurface : AppTheme.lightSurface,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(16),
border: Border.all(
color: isDark ? AppTheme.darkBorder : AppTheme.lightBorder,
),
), ),
child: Column( child: Column(
children: [ children: [
Icon(icon, color: const Color(0xFF1A73E8), size: 20), Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: primary.withValues(alpha: isDark ? 0.15 : 0.1),
borderRadius: BorderRadius.circular(10),
),
child: Icon(icon, color: primary, size: 20),
),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
value, value,
style: TextStyle( style: TextStyle(
color: theme.textTheme.titleLarge?.color, color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
fontSize: 20, fontSize: 22,
fontWeight: FontWeight.bold, fontWeight: FontWeight.w800,
letterSpacing: -0.5,
), ),
), ),
const SizedBox(height: 2),
Text( Text(
label, label,
style: TextStyle(color: theme.textTheme.bodySmall?.color?.withValues(alpha: 0.6), fontSize: 12), style: TextStyle(
color: isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary,
fontSize: 12,
fontWeight: FontWeight.w500,
),
), ),
], ],
), ),
@@ -564,12 +604,15 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
List<double> dataPoints, { List<double> dataPoints, {
List<MetricExplanation>? explanations, List<MetricExplanation>? explanations,
}) { }) {
final theme = Theme.of(context); final isDark = Theme.of(context).brightness == Brightness.dark;
return Container( return Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), color: isDark ? AppTheme.darkSurface : AppTheme.lightSurface,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(16),
border: Border.all(
color: isDark ? AppTheme.darkBorder : AppTheme.lightBorder,
),
), ),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -578,7 +621,11 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
children: [ children: [
Text( Text(
title, title,
style: TextStyle(color: theme.textTheme.bodyMedium?.color?.withValues(alpha: 0.7), fontSize: 14), style: TextStyle(
color: isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary,
fontSize: 13,
fontWeight: FontWeight.w600,
),
), ),
if (explanations != null) ...[ if (explanations != null) ...[
const Spacer(), const Spacer(),
@@ -586,15 +633,16 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
], ],
], ],
), ),
const SizedBox(height: 10), const SizedBox(height: 8),
Row( Row(
children: [ children: [
Text( Text(
value, value,
style: TextStyle( style: TextStyle(
color: theme.textTheme.headlineMedium?.color, color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
fontSize: 24, fontSize: 24,
fontWeight: FontWeight.bold, fontWeight: FontWeight.w800,
letterSpacing: -0.5,
), ),
), ),
const SizedBox(width: 20), const SizedBox(width: 20),
+68
View File
@@ -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();
}
}
+76
View File
@@ -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;
}
+2
View File
@@ -10,6 +10,7 @@ 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';
import 'features/session/session_provider.dart'; import 'features/session/session_provider.dart';
import 'features/tutorial/tutorial_provider.dart';
void main() async { void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
@@ -39,6 +40,7 @@ void main() async {
Provider<SessionRepository>(create: (_) => SessionRepository()), Provider<SessionRepository>(create: (_) => SessionRepository()),
ChangeNotifierProvider<ThemeProvider>(create: (_) => ThemeProvider()), ChangeNotifierProvider<ThemeProvider>(create: (_) => ThemeProvider()),
ChangeNotifierProvider<SessionProvider>(create: (_) => SessionProvider()), ChangeNotifierProvider<SessionProvider>(create: (_) => SessionProvider()),
ChangeNotifierProvider<TutorialProvider>(create: (_) => TutorialProvider()),
], ],
child: const BullyApp(), child: const BullyApp(),
), ),
+125 -38
View File
@@ -1,3 +1,4 @@
import 'dart:ui';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'features/home/home_screen.dart'; import 'features/home/home_screen.dart';
import 'features/history/history_screen.dart'; import 'features/history/history_screen.dart';
@@ -11,11 +12,13 @@ const int mainTabHistory = 1;
const int mainTabStats = 2; const int mainTabStats = 2;
const int mainTabGarage = 3; const int mainTabGarage = 3;
/// Clé du holder : permet de piloter l'onglet actif depuis n'importe quel /// Clé du holder : permet de piloter l'onglet actif depuis n'importe quel écran.
/// écran (ex. fin de session -> onglet Stats).
final GlobalKey<State<MainNavigationHolder>> mainNavKey = final GlobalKey<State<MainNavigationHolder>> mainNavKey =
GlobalKey<State<MainNavigationHolder>>(); GlobalKey<State<MainNavigationHolder>>();
/// Clé du dock flottant : permet au didacticiel de le mettre en avant.
final GlobalKey navigationDockKey = GlobalKey();
/// Ouvre l'onglet [index] de la navigation principale. /// Ouvre l'onglet [index] de la navigation principale.
void openMainTab(int index) { void openMainTab(int index) {
final state = mainNavKey.currentState; final state = mainNavKey.currentState;
@@ -32,9 +35,6 @@ class MainNavigationHolder extends StatefulWidget {
class _MainNavigationHolderState extends State<MainNavigationHolder> { class _MainNavigationHolderState extends State<MainNavigationHolder> {
int _selectedIndex = 0; int _selectedIndex = 0;
// Incrémentés à chaque ouverture de l'onglet correspondant pour forcer le
// rechargement (les écrans sont gardés vivants par l'IndexedStack et ne se
// rafraîchissent pas seuls).
int _statsTick = 0; int _statsTick = 0;
int _historyTick = 0; int _historyTick = 0;
int _homeTick = 0; int _homeTick = 0;
@@ -53,57 +53,144 @@ class _MainNavigationHolderState extends State<MainNavigationHolder> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final screens = [ final screens = [
HomeScreen(refreshTick: _homeTick), HomeScreen(refreshTick: _homeTick),
HistoryScreen(refreshTick: _historyTick), HistoryScreen(refreshTick: _historyTick),
StatisticsScreen(refreshTick: _statsTick), StatisticsScreen(refreshTick: _statsTick),
WeaponListScreen(refreshTick: _garageTick), WeaponListScreen(refreshTick: _garageTick),
]; ];
return Scaffold( return Scaffold(
extendBody: true,
body: IndexedStack( body: IndexedStack(
index: _selectedIndex, index: _selectedIndex,
children: screens, children: screens,
), ),
bottomNavigationBar: Container( bottomNavigationBar: _buildFloatingGlassDock(isDark),
);
}
Widget _buildFloatingGlassDock(bool isDark) {
final primaryColor = Theme.of(context).colorScheme.primary;
final navItems = [
(Icons.radar_outlined, Icons.radar, 'Accueil'),
(Icons.history_outlined, Icons.history_rounded, 'Historique'),
(Icons.insights_outlined, Icons.insights_rounded, 'Stats'),
(Icons.shield_outlined, Icons.shield, 'Armurerie'),
];
return SafeArea(
child: Container(
key: navigationDockKey,
margin: const EdgeInsets.fromLTRB(16, 0, 16, 12),
height: 68,
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(24),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: Colors.black.withValues(alpha: 0.1), color: Colors.black.withValues(alpha: isDark ? 0.45 : 0.12),
blurRadius: 10, blurRadius: 24,
offset: const Offset(0, -2), offset: const Offset(0, 8),
), ),
if (isDark)
BoxShadow(
color: primaryColor.withValues(alpha: 0.12),
blurRadius: 16,
spreadRadius: -4,
offset: const Offset(0, -2),
),
], ],
), ),
child: BottomNavigationBar( child: ClipRRect(
currentIndex: _selectedIndex, borderRadius: BorderRadius.circular(24),
onTap: selectTab, child: BackdropFilter(
type: BottomNavigationBarType.fixed, filter: ImageFilter.blur(sigmaX: 16, sigmaY: 16),
backgroundColor: Theme.of(context).cardColor, child: Container(
selectedItemColor: AppTheme.primaryColor, padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
unselectedItemColor: Colors.grey, decoration: BoxDecoration(
showUnselectedLabels: true, color: isDark
items: const [ ? const Color(0xFF101722).withValues(alpha: 0.78)
BottomNavigationBarItem( : Colors.white.withValues(alpha: 0.85),
icon: Icon(Icons.home_outlined), borderRadius: BorderRadius.circular(24),
activeIcon: Icon(Icons.home), border: Border.all(
label: 'Accueil', color: isDark
? Colors.white.withValues(alpha: 0.14)
: Colors.black.withValues(alpha: 0.08),
width: 1.2,
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: List.generate(navItems.length, (index) {
final item = navItems[index];
final isSelected = _selectedIndex == index;
return Expanded(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => selectTab(index),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
curve: Curves.easeOutCubic,
padding: const EdgeInsets.symmetric(vertical: 4),
decoration: BoxDecoration(
color: isSelected
? primaryColor.withValues(
alpha: isDark ? 0.22 : 0.15,
)
: Colors.transparent,
borderRadius: BorderRadius.circular(16),
border: isSelected
? Border.all(
color: primaryColor.withValues(
alpha: isDark ? 0.45 : 0.35,
),
width: 1,
)
: null,
),
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
isSelected ? item.$2 : item.$1,
size: 22,
color: isSelected
? primaryColor
: (isDark
? AppTheme.darkTextSecondary
: AppTheme.lightTextSecondary),
),
const SizedBox(height: 2),
Text(
item.$3,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 11,
fontWeight: isSelected
? FontWeight.w700
: FontWeight.w500,
color: isSelected
? primaryColor
: (isDark
? AppTheme.darkTextSecondary
: AppTheme.lightTextSecondary),
letterSpacing: 0.2,
),
),
],
),
),
),
);
}),
),
), ),
BottomNavigationBarItem( ),
icon: Icon(Icons.history_outlined),
activeIcon: Icon(Icons.history),
label: 'Historique',
),
BottomNavigationBarItem(
icon: Icon(Icons.analytics_outlined),
activeIcon: Icon(Icons.analytics),
label: 'Stats',
),
BottomNavigationBarItem(
icon: Icon(Icons.shield_outlined),
activeIcon: Icon(Icons.shield),
label: 'Armurerie',
),
],
), ),
), ),
); );
+159 -59
View File
@@ -2,50 +2,103 @@ import 'dart:convert';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'dart:io'; import 'dart:io';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:device_info_plus/device_info_plus.dart'; import 'package:image/image.dart' as img;
import 'package:crypto/crypto.dart'; import 'package:crypto/crypto.dart';
import '../data/models/shot.dart'; import '../data/models/shot.dart';
import '../data/models/target_type.dart'; import '../data/models/target_type.dart';
import 'wallet_identity_service.dart'; import 'wallet_identity_service.dart';
class AiExportService { /// Résultat détaillé de l'exportation vers le serveur IA
// Utilise 10.0.2.2 pour l'émulateur Android, sinon localhost. class AiExportResult {
// Pour un appareil physique, il faudra utiliser l'IP locale du PC (ex: 192.168.1.X). final bool isSuccess;
static String get _defaultApiUrl { final String code;
if (Platform.isAndroid) { final String message;
return 'http://10.0.2.2:3000/api/upload'; final String? reason;
} final bool isBanned;
return 'http://localhost:3000/api/upload'; final Map<String, dynamic>? targetValidation;
AiExportResult({
required this.isSuccess,
required this.code,
required this.message,
this.reason,
this.isBanned = false,
this.targetValidation,
});
factory AiExportResult.success({
String? message,
Map<String, dynamic>? targetValidation,
}) {
return AiExportResult(
isSuccess: true,
code: 'UPLOAD_SUCCESS',
message: message ?? 'Export réussi vers le serveur IA !',
targetValidation: targetValidation,
);
} }
/// Extrait les informations de l'appareil factory AiExportResult.banned({
Future<Map<String, dynamic>> _getDeviceInfo() async { String? reason,
final DeviceInfoPlugin deviceInfoPlugin = DeviceInfoPlugin(); String? message,
Map<String, dynamic> deviceData = {'model': 'Unknown', 'os': 'Unknown'}; }) {
return AiExportResult(
isSuccess: false,
code: 'WALLET_BANNED',
isBanned: true,
reason: reason,
message: message ?? 'Votre participation au programme d\'entraînement IA a été suspendue par la modération.',
);
}
factory AiExportResult.error({
String? code,
required String message,
}) {
return AiExportResult(
isSuccess: false,
code: code ?? 'UPLOAD_ERROR',
message: message,
);
}
}
class AiExportService {
/// Retire toutes les metadonnees de la photo avant l'envoi.
///
/// Une photo de cible prise au telephone embarque un bloc EXIF qui contient
/// typiquement la position GPS du stand de tir, la date exacte et le modele
/// d'appareil. Rien de tout cela n'est utile au modele de detection.
///
/// L'orientation est d'abord appliquee physiquement aux pixels : les
/// coordonnees d'impact sont normalisees sur l'image telle qu'elle est
/// affichee dans l'app (Flutter applique l'orientation EXIF), donc supprimer
/// le tag sans redresser l'image ferait pivoter la photo par rapport a ses
/// propres annotations.
///
/// Retourne null si l'image est illisible.
@visibleForTesting
Uint8List? stripMetadata(Uint8List originalBytes) {
try { try {
if (Platform.isAndroid) { final decoded = img.decodeImage(originalBytes);
final androidInfo = await deviceInfoPlugin.androidInfo; if (decoded == null) return null;
deviceData['model'] = '${androidInfo.brand} ${androidInfo.model}';
deviceData['os'] = 'Android ${androidInfo.version.release}';
} else if (Platform.isIOS) {
final iosInfo = await deviceInfoPlugin.iosInfo;
deviceData['model'] = iosInfo.name;
deviceData['os'] = '${iosInfo.systemName} ${iosInfo.systemVersion}';
} else if (Platform.isWindows) {
final windowsInfo = await deviceInfoPlugin.windowsInfo;
deviceData['model'] = 'Windows PC';
deviceData['os'] = 'Windows ${windowsInfo.majorVersion}.${windowsInfo.minorVersion}';
}
} catch (e) {
debugPrint('Erreur lors de la récupération des infos appareil: $e');
}
return deviceData; final baked = img.bakeOrientation(decoded);
// bakeOrientation recopie tout l'EXIF sauf l'orientation : sans ce reset,
// le GPS survivrait au reencodage.
baked.exif = img.ExifData();
return img.encodeJpg(baked, quality: 90);
} catch (e) {
// Sur un fichier tronque, decodeImage leve au lieu de retourner null.
debugPrint('Photo illisible, export annule: $e');
return null;
}
} }
/// Exporte l'image et les données de plotting vers le serveur /// Exporte l'image et les données de plotting vers le serveur
Future<bool> exportData({ Future<AiExportResult> exportData({
required String imagePath, required String imagePath,
required String sessionId, required String sessionId,
required TargetType targetType, required TargetType targetType,
@@ -54,27 +107,36 @@ class AiExportService {
required double targetRadius, required double targetRadius,
required List<Shot> shots, required List<Shot> shots,
int distanceMeters = 25, int distanceMeters = 25,
String weaponName = 'Unknown', String caliber = 'unknown',
int? expectedShots,
String? apiUrl, String? apiUrl,
}) async { }) async {
try { try {
final url = Uri.parse(apiUrl ?? _defaultApiUrl); final walletService = WalletIdentityService();
final rawBaseUrl = await walletService.getServerBaseUrl();
final baseUrl = rawBaseUrl.endsWith('/') ? rawBaseUrl.substring(0, rawBaseUrl.length - 1) : rawBaseUrl;
final effectiveUrl = apiUrl ?? '$baseUrl/api/upload';
final url = Uri.parse(effectiveUrl);
final request = http.MultipartRequest('POST', url); final request = http.MultipartRequest('POST', url);
request.headers['X-API-KEY'] = WalletIdentityService.apiKey;
// 1. Prepare image // 1. Prepare image
final file = File(imagePath); final file = File(imagePath);
if (!await file.exists()) { if (!await file.exists()) {
throw Exception('Le fichier image n\'existe pas'); return AiExportResult.error(
code: 'FILE_NOT_FOUND',
message: 'Le fichier image cible est introuvable.',
);
} }
// Read image metadata (approximate dimensions since decoding image can be heavy) final sanitizedPhoto = stripMetadata(await file.readAsBytes());
// On the frontend we usually have aspectRatio, here we use generic values if not available. if (sanitizedPhoto == null) {
final deviceData = await _getDeviceInfo(); return AiExportResult.error(
code: 'INVALID_IMAGE',
message: 'La photo de la cible est illisible et n\'a pas pu être envoyée.',
);
}
// We approximate the target corners from center and radius
// radius is relative (0 to 1). We need image width/height to get pixels.
// But we can just pass relative corners as well, or a normalized bounding box.
// Let's create normalized corners (0 to 1).
final corners = [ final corners = [
{"norm_x": targetCenterX - targetRadius, "norm_y": targetCenterY - targetRadius}, {"norm_x": targetCenterX - targetRadius, "norm_y": targetCenterY - targetRadius},
{"norm_x": targetCenterX + targetRadius, "norm_y": targetCenterY - targetRadius}, {"norm_x": targetCenterX + targetRadius, "norm_y": targetCenterY - targetRadius},
@@ -98,7 +160,6 @@ class AiExportService {
}).toList(); }).toList();
// Get and hash the wallet identity // Get and hash the wallet identity
final walletService = WalletIdentityService();
final phrase = await walletService.getIdentityPhrase(); final phrase = await walletService.getIdentityPhrase();
final phraseBytes = utf8.encode(phrase); final phraseBytes = utf8.encode(phrase);
final walletHash = sha256.convert(phraseBytes).toString(); final walletHash = sha256.convert(phraseBytes).toString();
@@ -108,12 +169,15 @@ class AiExportService {
"session_id": sessionId, "session_id": sessionId,
"wallet_hash": walletHash, "wallet_hash": walletHash,
"timestamp": DateTime.now().toIso8601String(), "timestamp": DateTime.now().toIso8601String(),
"device_info": deviceData,
"target_metadata": { "target_metadata": {
"type": targetType.name, "type": targetType.name,
"distance_meters": distanceMeters, "distance_meters": distanceMeters,
"weapon": weaponName, "caliber": caliber,
// The backend could extract exact width/height from the image. // Nombre de coups prevus pour cette cible, null hors session.
// Un ecart avec le nombre d'impacts ne signifie pas que le
// marquage est faux (un coup peut etre parti hors papier) : c'est
// au tri du dataset d'en decider, pas au client.
"expected_shots": expectedShots,
}, },
"plotting": { "plotting": {
"target_corners": corners, "target_corners": corners,
@@ -121,29 +185,65 @@ class AiExportService {
} }
}; };
// Add fields to request
request.fields['plotting'] = jsonEncode(plottingJson); request.fields['plotting'] = jsonEncode(plottingJson);
// Add file
request.files.add( request.files.add(
await http.MultipartFile.fromPath('photo', imagePath), http.MultipartFile.fromBytes(
'photo',
sanitizedPhoto,
filename: 'target.jpg',
),
); );
// Send request final streamedResponse = await request.send().timeout(
final response = await request.send(); const Duration(seconds: 15),
onTimeout: () => throw Exception('Délai d\'attente dépassé (timeout)'),
);
if (response.statusCode == 200) { final responseBody = await streamedResponse.stream.bytesToString();
final responseData = await response.stream.bytesToString(); Map<String, dynamic> responseJson = {};
debugPrint('Export réussi: $responseData'); try {
return true; responseJson = jsonDecode(responseBody);
} catch (_) {}
final statusCode = streamedResponse.statusCode;
if (statusCode == 200) {
debugPrint('Export réussi: $responseBody');
return AiExportResult.success(
message: responseJson['message'] ?? 'Photo et données exportées avec succès.',
targetValidation: responseJson['target_validation'] as Map<String, dynamic>?,
);
} else if (statusCode == 403 || responseJson['code'] == 'WALLET_BANNED') {
final reason = responseJson['reason'] ?? 'Non-respect des règles de contribution';
debugPrint('Export rejeté (banni): $reason');
// Persister le bannissement localement et couper l'envoi de photos
await walletService.setBanned(true, reason: reason);
return AiExportResult.banned(
reason: reason,
message: responseJson['error'] ?? 'Votre wallet a été suspendu par la modération.',
);
} else if (statusCode == 400) {
return AiExportResult.error(
code: responseJson['code'] ?? 'BAD_REQUEST',
message: responseJson['error'] ?? 'Requête d\'export invalide.',
);
} else { } else {
final errorData = await response.stream.bytesToString(); return AiExportResult.error(
debugPrint('Erreur d\'export: ${response.statusCode} - $errorData'); code: responseJson['code'] ?? 'SERVER_ERROR',
return false; message: responseJson['error'] ?? 'Erreur serveur ($statusCode).',
);
} }
} on SocketException {
return AiExportResult.error(
code: 'NETWORK_ERROR',
message: 'Impossible de joindre le serveur IA. Vérifiez l\'adresse IP ou votre connexion.',
);
} catch (e) { } catch (e) {
debugPrint('Exception lors de l\'export: $e'); debugPrint('Exception lors de l\'export: $e');
return false; return AiExportResult.error(
code: 'UNKNOWN_ERROR',
message: 'Erreur lors de l\'export: $e',
);
} }
} }
} }
+100 -12
View File
@@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:sensors_plus/sensors_plus.dart'; import 'package:sensors_plus/sensors_plus.dart';
/// Statut de parallélisme retourné en temps réel. /// Statut de parallélisme retourné en temps réel.
@@ -14,31 +15,67 @@ enum ParallelismStatus {
misaligned, misaligned,
} }
/// Pose de prise de vue déduite de l'inclinaison de l'appareil.
enum TargetPose {
/// Cible accrochée verticalement : l'appareil est tenu droit.
wall,
/// Cible posée au sol : l'appareil est à plat, caméra vers le bas.
ground,
}
/// Données de parallélisme calculées à chaque frame capteur. /// Données de parallélisme calculées à chaque frame capteur.
class ParallelismData { class ParallelismData {
final ParallelismStatus status; final ParallelismStatus status;
/// Inclinaison avant/arrière en degrés (0° = parfaitement vertical). /// Pose détectée automatiquement, qui sert de référence aux écarts.
final TargetPose pose;
/// Inclinaison avant/arrière brute en degrés (0° = appareil vertical,
/// +90° = appareil à plat, caméra vers le sol).
final double pitchDegrees; final double pitchDegrees;
/// Inclinaison gauche/droite en degrés (0° = parfaitement droit). /// Inclinaison gauche/droite en degrés (0° = parfaitement droit).
final double rollDegrees; final double rollDegrees;
/// Écart de tangage par rapport à la pose détectée.
///
/// En pose [TargetPose.wall] il vaut exactement [pitchDegrees] ; en pose
/// [TargetPose.ground] il mesure l'écart aux +90° de l'appareil à plat.
/// C'est cette valeur qu'il faut afficher : le tangage brut vaudrait -90°
/// alors que le cadrage est parfait.
final double pitchDeviation;
const ParallelismData({ const ParallelismData({
required this.status, required this.status,
required this.pose,
required this.pitchDegrees, required this.pitchDegrees,
required this.rollDegrees, required this.rollDegrees,
required this.pitchDeviation,
}); });
bool get isAligned => status == ParallelismStatus.aligned; bool get isAligned => status == ParallelismStatus.aligned;
/// Écart latéral. Le roulis se mesure de la même façon dans les deux poses.
double get rollDeviation => rollDegrees;
@override @override
String toString() => String toString() =>
'ParallelismData(status: $status, pitch: ${pitchDegrees.toStringAsFixed(1)}°, roll: ${rollDegrees.toStringAsFixed(1)}°)'; 'ParallelismData(status: $status, pose: $pose, pitch: ${pitchDegrees.toStringAsFixed(1)}°, '
'écart: ${pitchDeviation.toStringAsFixed(1)}°, roll: ${rollDegrees.toStringAsFixed(1)}°)';
} }
/// Service de détection du parallélisme par accéléromètre. /// Service de détection du parallélisme par accéléromètre.
/// ///
/// Deux poses de prise de vue sont reconnues, et celle dont l'appareil est le
/// plus proche est retenue automatiquement :
///
/// [TargetPose.wall] — cible au mur, appareil vertical (tangage ≈ 0°)
/// [TargetPose.ground] — cible au sol, appareil à plat (tangage ≈ +90°)
///
/// Seul le tangage positif vaut pour la pose au sol : à plat écran vers le
/// haut, la caméra vise le plafond et le vert n'aurait aucun sens.
///
/// Implémente une hystérésis à deux seuils pour éviter le clignotement : /// Implémente une hystérésis à deux seuils pour éviter le clignotement :
/// ///
/// État actuel = misaligned → passe à aligned si angle < [alignThreshold] /// État actuel = misaligned → passe à aligned si angle < [alignThreshold]
@@ -58,6 +95,13 @@ class ParallelismService {
/// Doit être > alignThreshold pour créer la zone d'hystérésis. /// Doit être > alignThreshold pour créer la zone d'hystérésis.
final double misalignThreshold; final double misalignThreshold;
/// Écart minimum en faveur de l'autre pose pour basculer.
///
/// Les deux poses sont séparées de 90°, donc la bascule se joue vers 45° —
/// très loin des deux zones vertes. Cette marge évite seulement que
/// l'étiquette de pose clignote pile à la frontière.
static const double poseSwitchMargin = 5.0;
StreamSubscription<AccelerometerEvent>? _subscription; StreamSubscription<AccelerometerEvent>? _subscription;
final StreamController<ParallelismData> _controller = final StreamController<ParallelismData> _controller =
StreamController<ParallelismData>.broadcast(); StreamController<ParallelismData>.broadcast();
@@ -65,6 +109,9 @@ class ParallelismService {
/// État interne mémorisé entre deux frames (cœur de l'hystérésis). /// État interne mémorisé entre deux frames (cœur de l'hystérésis).
ParallelismStatus _currentStatus = ParallelismStatus.unknown; ParallelismStatus _currentStatus = ParallelismStatus.unknown;
/// Pose retenue à la frame précédente, pour l'hystérésis de pose.
TargetPose? _currentPose;
ParallelismService({ ParallelismService({
this.alignThreshold = 25.0, this.alignThreshold = 25.0,
this.misalignThreshold = 32.0, this.misalignThreshold = 32.0,
@@ -86,10 +133,13 @@ class ParallelismService {
// Simulateur ou capteur absent — on reste en "unknown" sans bloquer l'UI // Simulateur ou capteur absent — on reste en "unknown" sans bloquer l'UI
if (!_controller.isClosed) { if (!_controller.isClosed) {
_currentStatus = ParallelismStatus.unknown; _currentStatus = ParallelismStatus.unknown;
_currentPose = null;
_controller.add(const ParallelismData( _controller.add(const ParallelismData(
status: ParallelismStatus.unknown, status: ParallelismStatus.unknown,
pose: TargetPose.wall,
pitchDegrees: 0, pitchDegrees: 0,
rollDegrees: 0, rollDegrees: 0,
pitchDeviation: 0,
)); ));
} }
}, },
@@ -100,6 +150,7 @@ class ParallelismService {
_subscription?.cancel(); _subscription?.cancel();
_subscription = null; _subscription = null;
_currentStatus = ParallelismStatus.unknown; _currentStatus = ParallelismStatus.unknown;
_currentPose = null;
} }
void dispose() { void dispose() {
@@ -110,12 +161,18 @@ class ParallelismService {
void _onAccelerometerEvent(AccelerometerEvent event) { void _onAccelerometerEvent(AccelerometerEvent event) {
if (_controller.isClosed) return; if (_controller.isClosed) return;
final double gx = event.x; final data = evaluate(event.x, event.y, event.z);
final double gy = event.y; if (data != null) _controller.add(data);
final double gz = event.z; }
/// Calcule la pose et le statut depuis une mesure d'accéléromètre.
///
/// Met à jour l'état d'hystérésis, donc l'ordre des appels compte.
/// Retourne null sur une mesure aberrante, qu'il faut alors ignorer.
@visibleForTesting
ParallelismData? evaluate(double gx, double gy, double gz) {
final double magnitude = math.sqrt(gx * gx + gy * gy + gz * gz); final double magnitude = math.sqrt(gx * gx + gy * gy + gz * gz);
if (magnitude < 1.0) return; // Données aberrantes if (magnitude < 1.0) return null; // Données aberrantes
// Normalisation par la magnitude réelle (indépendant de g exact) // Normalisation par la magnitude réelle (indépendant de g exact)
final double nx = gx / magnitude; final double nx = gx / magnitude;
@@ -125,10 +182,39 @@ class ParallelismService {
final double pitchDeg = math.asin(nz.clamp(-1.0, 1.0)) * (180.0 / math.pi); final double pitchDeg = math.asin(nz.clamp(-1.0, 1.0)) * (180.0 / math.pi);
final double rollDeg = math.asin(nx.clamp(-1.0, 1.0)) * (180.0 / math.pi); final double rollDeg = math.asin(nx.clamp(-1.0, 1.0)) * (180.0 / math.pi);
// Le critère de couleur = le PIRE des deux angles affichés à l'écran. // ── Choix de la pose ────────────────────────────────────────────────────
// Ainsi ce que voit l'utilisateur (Pitch / Roll) correspond exactement // Cible au mur : le tangage idéal est 0°. Formule d'origine, inchangée.
// à la décision vert/orange : à 2° d'écart, on est largement dans le vert. final double wallPitchDeviation = pitchDeg;
final double worstAngle = math.max(pitchDeg.abs(), rollDeg.abs()); final double wallWorst = math.max(pitchDeg.abs(), rollDeg.abs());
// Cible au sol : le tangage idéal est +90° (caméra vers le bas). Écran vers
// le haut, le tangage vaut -90° et l'écart atteint 180° : la pose au sol ne
// peut alors jamais gagner, ce qui est exactement le comportement voulu.
final double groundPitchDeviation = pitchDeg - 90.0;
final double groundWorst =
math.max(groundPitchDeviation.abs(), rollDeg.abs());
if (_currentPose == null) {
_currentPose =
groundWorst < wallWorst ? TargetPose.ground : TargetPose.wall;
} else if (_currentPose == TargetPose.wall) {
if (groundWorst + poseSwitchMargin < wallWorst) {
_currentPose = TargetPose.ground;
}
} else {
if (wallWorst + poseSwitchMargin < groundWorst) {
_currentPose = TargetPose.wall;
}
}
final bool isGround = _currentPose == TargetPose.ground;
final double pitchDeviation =
isGround ? groundPitchDeviation : wallPitchDeviation;
// Le critère de couleur = le PIRE des deux écarts affichés à l'écran.
// Ainsi ce que voit l'utilisateur correspond exactement à la décision
// vert/orange : à 2° d'écart, on est largement dans le vert.
final double worstAngle = isGround ? groundWorst : wallWorst;
// ── Hystérésis ────────────────────────────────────────────────────────── // ── Hystérésis ──────────────────────────────────────────────────────────
// Premier appel : on décide selon alignThreshold uniquement // Premier appel : on décide selon alignThreshold uniquement
@@ -152,10 +238,12 @@ class ParallelismService {
} }
// ──────────────────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────────────────
_controller.add(ParallelismData( return ParallelismData(
status: _currentStatus, status: _currentStatus,
pose: _currentPose!,
pitchDegrees: pitchDeg, pitchDegrees: pitchDeg,
rollDegrees: rollDeg, rollDegrees: rollDeg,
)); pitchDeviation: pitchDeviation,
);
} }
} }
+46
View File
@@ -0,0 +1,46 @@
/// Persistance du didacticiel : mémorise les visites guidées déjà vues.
///
/// Chaque visite guidée (« tour ») possède un identifiant stocké dans les
/// SharedPreferences sous la forme `tutorial_done_<id>`. Réinitialiser le
/// didacticiel depuis les paramètres efface simplement ces clés.
library;
import 'package:shared_preferences/shared_preferences.dart';
/// Identifiants des visites guidées de l'application.
class TutorialTours {
const TutorialTours._();
/// Visite d'accueil : présentation générale et navigation.
static const String home = 'home';
/// Visite de l'éditeur d'impacts : tap, appui long et pincement pour zoomer.
static const String impactEditor = 'impact_editor';
/// Toutes les visites connues, dans l'ordre logique de découverte.
static const List<String> all = [home, impactEditor];
}
class TutorialService {
static const String _prefix = 'tutorial_done_';
Future<Set<String>> loadCompletedTours() async {
final prefs = await SharedPreferences.getInstance();
return TutorialTours.all
.where((id) => prefs.getBool('$_prefix$id') ?? false)
.toSet();
}
Future<void> markCompleted(String tourId) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('$_prefix$tourId', true);
}
/// Efface la mémoire du didacticiel : toutes les visites seront rejouées.
Future<void> resetAll() async {
final prefs = await SharedPreferences.getInstance();
for (final id in TutorialTours.all) {
await prefs.remove('$_prefix$id');
}
}
}
+89 -1
View File
@@ -1,6 +1,7 @@
import 'dart:math'; import 'dart:math';
import 'dart:convert'; import 'dart:convert';
import 'package:crypto/crypto.dart'; import 'package:crypto/crypto.dart';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:device_info_plus/device_info_plus.dart'; import 'package:device_info_plus/device_info_plus.dart';
import 'dart:io'; import 'dart:io';
@@ -9,6 +10,9 @@ import 'package:flutter/foundation.dart';
class WalletIdentityService { class WalletIdentityService {
static const String _prefsKey = 'wallet_identity_phrase'; static const String _prefsKey = 'wallet_identity_phrase';
static const String _uploadEnabledKey = 'is_ai_upload_enabled'; static const String _uploadEnabledKey = 'is_ai_upload_enabled';
static const String _bannedKey = 'wallet_is_banned';
static const String _banReasonKey = 'wallet_ban_reason';
static const String _serverUrlKey = 'ai_server_url';
// A standard list of 256 words (8 bits of entropy per word) // A standard list of 256 words (8 bits of entropy per word)
static const List<String> _wordList = [ static const List<String> _wordList = [
@@ -40,18 +44,102 @@ class WalletIdentityService {
'bulk', 'bullet', 'bundle', 'bunker', 'burden', 'burger', 'burst', 'bus', 'business', 'butter' 'bulk', 'bullet', 'bundle', 'bunker', 'burden', 'burger', 'burst', 'bus', 'business', 'butter'
]; ];
/// Vérifie si l'utilisateur a accepté l'envoi de données à l'IA /// URL par défaut du serveur de production
static const String defaultServerUrl = 'https://backendia.kevlar.cloud';
/// Clé d'authentification API secrète pour le backend
static const String apiKey = 'bully_secret_api_key_2026_x89';
/// Retourne l'URL de base du serveur configuré (par défaut: https://backendia.kevlar.cloud)
Future<String> getServerBaseUrl() async {
final prefs = await SharedPreferences.getInstance();
final customUrl = prefs.getString(_serverUrlKey);
if (customUrl != null && customUrl.trim().isNotEmpty) {
return customUrl.trim().replaceAll(RegExp(r'/api/upload/?$'), '');
}
return defaultServerUrl;
}
/// Définit une URL personnalisée pour le serveur IA
Future<void> setServerBaseUrl(String url) async {
final prefs = await SharedPreferences.getInstance();
final cleanUrl = url.trim().replaceAll(RegExp(r'/api/upload/?$'), '');
await prefs.setString(_serverUrlKey, cleanUrl);
}
/// Vérifie si l'utilisateur a accepté l'envoi de données à l'IA (et n'est pas banni)
Future<bool> isUploadEnabled() async { Future<bool> isUploadEnabled() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final isBanned = prefs.getBool(_bannedKey) ?? false;
if (isBanned) return false;
return prefs.getBool(_uploadEnabledKey) ?? false; return prefs.getBool(_uploadEnabledKey) ?? false;
} }
/// Active ou désactive l'envoi de données /// Active ou désactive l'envoi de données
Future<void> setUploadEnabled(bool enabled) async { Future<void> setUploadEnabled(bool enabled) async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final isBanned = prefs.getBool(_bannedKey) ?? false;
if (isBanned) {
await prefs.setBool(_uploadEnabledKey, false);
return;
}
await prefs.setBool(_uploadEnabledKey, enabled); await prefs.setBool(_uploadEnabledKey, enabled);
} }
/// Vérifie si ce wallet/utilisateur est banni en local
Future<bool> isBanned() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_bannedKey) ?? false;
}
/// Récupère le motif de bannissement enregistré
Future<String?> getBanReason() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_banReasonKey);
}
/// Enregistre l'état de bannissement et le motif
Future<void> setBanned(bool banned, {String? reason}) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_bannedKey, banned);
if (banned) {
await prefs.setString(_banReasonKey, reason ?? 'Photos non conformes aux règles de tir');
await prefs.setBool(_uploadEnabledKey, false);
} else {
await prefs.remove(_banReasonKey);
}
}
/// Synchronise le statut de modération/bannissement avec le serveur backend
Future<bool> syncBanStatus() async {
try {
final phrase = await getIdentityPhrase();
final phraseBytes = utf8.encode(phrase);
final walletHash = sha256.convert(phraseBytes).toString();
final baseUrl = await getServerBaseUrl();
final res = await http.get(
Uri.parse('$baseUrl/api/stats/$walletHash'),
headers: {'X-API-KEY': apiKey},
).timeout(
const Duration(seconds: 4),
);
if (res.statusCode == 200) {
final data = jsonDecode(res.body);
final isBannedOnServer = data['is_banned'] == true;
if (isBannedOnServer) {
await setBanned(true, reason: data['ban_reason']);
} else {
await setBanned(false);
}
return isBannedOnServer;
}
} catch (e) {
debugPrint('Erreur synchro ban: $e');
}
return await isBanned();
}
/// Gets the unique 15-word identity phrase /// Gets the unique 15-word identity phrase
Future<String> getIdentityPhrase() async { Future<String> getIdentityPhrase() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
+74
View File
@@ -0,0 +1,74 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
import 'package:bully/app.dart';
import 'package:bully/core/theme/theme_provider.dart';
import 'package:bully/data/repositories/session_repository.dart';
import 'package:bully/features/session/session_provider.dart';
import 'package:bully/features/tutorial/tutorial_provider.dart';
import 'package:bully/services/grouping_analyzer_service.dart';
import 'package:bully/services/score_calculator_service.dart';
import 'package:bully/services/tutorial_service.dart';
void main() {
setUpAll(() {
sqfliteFfiInit();
databaseFactory = databaseFactoryFfi;
FlutterError.onError = (details) {
// ignore: avoid_print
print('CAUGHT_FLUTTER_ERROR: ${details.exceptionAsString()}');
// ignore: avoid_print
print('CAUGHT_STACK: ${details.stack}');
};
});
testWidgets('BullyApp démarre et affiche HomeScreen + Tutorial sans erreur',
(WidgetTester tester) async {
SharedPreferences.setMockInitialValues({});
final repository = SessionRepository();
await tester.pumpWidget(
MultiProvider(
providers: [
Provider<ScoreCalculatorService>(
create: (_) => ScoreCalculatorService(),
),
Provider<GroupingAnalyzerService>(
create: (_) => GroupingAnalyzerService(),
),
Provider<SessionRepository>.value(value: repository),
ChangeNotifierProvider<ThemeProvider>(create: (_) => ThemeProvider()),
ChangeNotifierProvider<SessionProvider>(
create: (_) => SessionProvider()),
ChangeNotifierProvider<TutorialProvider>(
create: (_) => TutorialProvider(service: TutorialService())),
],
child: const BullyApp(),
),
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 500));
await tester.pump(const Duration(seconds: 1));
expect(find.text('BULLY'), findsOneWidget);
expect(find.text('Bienvenue dans Bully'), findsOneWidget);
// Passer à l'étape suivante
await tester.tap(find.text('Suivant'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 500));
expect(find.text('Démarrez une session'), findsOneWidget);
// Passer
await tester.tap(find.text('Passer'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 500));
expect(find.text('Démarrez une session'), findsNothing);
});
}
+185
View File
@@ -0,0 +1,185 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:bully/features/tutorial/tutorial_step.dart';
import 'package:bully/features/tutorial/widgets/tutorial_hand.dart';
import 'package:bully/features/tutorial/widgets/tutorial_overlay.dart';
/// Monte l'overlay au-dessus d'un faux écran contenant l'élément visé.
Future<GlobalKey> pumpOverlay(
WidgetTester tester, {
required List<TutorialStep> Function(GlobalKey targetKey) steps,
required VoidCallback onFinished,
}) async {
final targetKey = GlobalKey();
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Stack(
children: [
Center(
child: SizedBox(key: targetKey, width: 160, height: 48),
),
TutorialOverlay(steps: steps(targetKey), onFinished: onFinished),
],
),
),
),
);
// Laisse le temps à la mesure du spot (défilement + délai de stabilisation).
await tester.pump();
await tester.pump(const Duration(milliseconds: 500));
return targetKey;
}
void main() {
testWidgets('la visite avance d\'une étape à l\'autre puis se termine',
(WidgetTester tester) async {
var finished = false;
await pumpOverlay(
tester,
onFinished: () => finished = true,
steps: (targetKey) => [
TutorialStep(
targetKey: targetKey,
title: 'Démarrez une session',
description: 'Tout part d\'ici.',
gesture: TutorialGesture.tap,
),
TutorialStep(
targetKey: targetKey,
title: 'Zoomer sur la cible',
description: 'Écartez deux doigts.',
gesture: TutorialGesture.pinch,
),
],
);
expect(find.text('Démarrez une session'), findsOneWidget);
expect(find.text('1/2'), findsOneWidget);
// La main animée mime le geste attendu, posée sur l'élément mis en avant.
expect(find.byType(TutorialHand), findsOneWidget);
expect(find.text('Appuyez une fois'), findsOneWidget);
await tester.tap(find.text('Suivant'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 500));
expect(find.text('Zoomer sur la cible'), findsOneWidget);
expect(find.text('2/2'), findsOneWidget);
expect(find.text('Pincez avec deux doigts pour zoomer'), findsOneWidget);
expect(finished, isFalse);
await tester.tap(find.text('C\'est parti'));
await tester.pump();
expect(finished, isTrue);
});
testWidgets('« Passer » interrompt la visite immédiatement',
(WidgetTester tester) async {
var finished = false;
await pumpOverlay(
tester,
onFinished: () => finished = true,
steps: (targetKey) => [
TutorialStep(
targetKey: targetKey,
title: 'Première étape',
description: 'Description.',
),
const TutorialStep(
title: 'Seconde étape',
description: 'Description.',
),
],
);
await tester.tap(find.text('Passer'));
await tester.pump();
expect(finished, isTrue);
expect(find.text('Seconde étape'), findsNothing);
});
testWidgets('une étape sans cible s\'affiche comme carte centrée',
(WidgetTester tester) async {
await pumpOverlay(
tester,
onFinished: () {},
steps: (_) => [
const TutorialStep(
title: 'Bienvenue dans Bully',
description: 'Ce guide rapide vous montre l\'essentiel.',
),
],
);
expect(find.text('Bienvenue dans Bully'), findsOneWidget);
expect(find.text('C\'est parti'), findsOneWidget);
// Aucune cible : pas de main animée non plus.
expect(find.byType(TutorialHand), findsNothing);
});
testWidgets('l\'overlay se rend correctement dans un Navigator push modal',
(WidgetTester tester) async {
final targetKey = GlobalKey();
await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (context) => Scaffold(
body: Column(
children: [
SizedBox(key: targetKey, width: 200, height: 60),
ElevatedButton(
onPressed: () {
Navigator.of(context).push(
PageRouteBuilder<void>(
opaque: false,
pageBuilder: (routeCtx, _, __) => TutorialOverlay(
steps: [
const TutorialStep(
title: 'Bienvenue',
description: 'Texte introductif',
),
TutorialStep(
targetKey: targetKey,
title: 'Bouton',
description: 'Texte bouton',
gesture: TutorialGesture.tap,
),
],
onFinished: () => Navigator.of(routeCtx).pop(),
),
),
);
},
child: const Text('Ouvrir'),
),
],
),
),
),
),
);
await tester.tap(find.text('Ouvrir'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(find.text('Bienvenue'), findsOneWidget);
await tester.tap(find.text('Suivant'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 500));
expect(find.text('Bouton'), findsOneWidget);
expect(find.byType(TutorialHand), findsOneWidget);
});
}
+61
View File
@@ -0,0 +1,61 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:image/image.dart' as img;
import 'package:bully/services/ai_export_service.dart';
/// Construit un JPEG porteur de métadonnées EXIF comme le ferait un téléphone :
/// position GPS, modèle d'appareil et orientation.
Uint8List _photoWithExif({int orientation = 1}) {
final image = img.Image(width: 40, height: 20);
img.fill(image, color: img.ColorRgb8(120, 120, 120));
image.exif.imageIfd['Model'] = 'Pixel 8';
image.exif.imageIfd.orientation = orientation;
image.exif.gpsIfd['GPSLatitude'] = 48.8584;
image.exif.gpsIfd['GPSLongitude'] = 2.2945;
return img.encodeJpg(image);
}
void main() {
final service = AiExportService();
group('stripMetadata', () {
test('supprime le GPS et les autres métadonnées EXIF', () {
final original = _photoWithExif();
// Garde-fou : sans EXIF au départ, le test ne prouverait rien.
expect(img.decodeImage(original)!.exif.isEmpty, isFalse);
final stripped = service.stripMetadata(original);
final result = img.decodeImage(stripped!)!;
expect(result.exif.isEmpty, isTrue);
expect(result.exif.gpsIfd.isEmpty, isTrue);
});
test('applique l\'orientation aux pixels avant de retirer le tag', () {
// Orientation 6 = rotation de 90°, donc les dimensions s'inversent.
final original = _photoWithExif(orientation: 6);
final result = img.decodeImage(service.stripMetadata(original)!)!;
expect(result.width, 20);
expect(result.height, 40);
expect(result.exif.isEmpty, isTrue);
});
test('conserve les dimensions quand il n\'y a pas d\'orientation', () {
final result = img.decodeImage(service.stripMetadata(_photoWithExif())!)!;
expect(result.width, 40);
expect(result.height, 20);
});
test('retourne null sur une image illisible', () {
expect(service.stripMetadata(Uint8List.fromList([1, 2, 3, 4])), isNull);
});
});
}
+136
View File
@@ -0,0 +1,136 @@
import 'dart:math' as math;
import 'package:flutter_test/flutter_test.dart';
import 'package:bully/services/parallelism_service.dart';
/// Vecteur de gravité mesuré par l'accéléromètre pour une pose donnée.
///
/// [pitch] et [roll] sont exprimés comme dans le service : le tangage vaut 0°
/// appareil vertical et +90° appareil à plat caméra vers le sol.
({double x, double y, double z}) gravity({
double pitch = 0,
double roll = 0,
}) {
const double g = 9.81;
final double p = pitch * math.pi / 180.0;
final double r = roll * math.pi / 180.0;
final double nz = math.sin(p);
final double nx = math.sin(r);
// Ce qui reste va sur y, l'axe vertical de l'écran.
final double ny = math.sqrt(math.max(0.0, 1.0 - nz * nz - nx * nx));
return (x: nx * g, y: ny * g, z: nz * g);
}
ParallelismData evaluatePose(
ParallelismService service, {
double pitch = 0,
double roll = 0,
}) {
final v = gravity(pitch: pitch, roll: roll);
final data = service.evaluate(v.x, v.y, v.z);
expect(data, isNotNull, reason: 'mesure jugée aberrante à tort');
return data!;
}
void main() {
group('pose cible au mur (comportement existant)', () {
test('appareil vertical → aligné', () {
final data = evaluatePose(ParallelismService());
expect(data.pose, TargetPose.wall);
expect(data.status, ParallelismStatus.aligned);
expect(data.pitchDeviation, closeTo(0, 0.5));
});
test('l\'écart de tangage reste le tangage brut', () {
final data = evaluatePose(ParallelismService(), pitch: 10);
expect(data.pose, TargetPose.wall);
expect(data.pitchDeviation, closeTo(data.pitchDegrees, 0.001));
});
test('inclinaison au-delà du seuil → désaligné', () {
final data = evaluatePose(ParallelismService(), pitch: 40);
expect(data.pose, TargetPose.wall);
expect(data.status, ParallelismStatus.misaligned);
});
test('roulis excessif → désaligné même si le tangage est bon', () {
final data = evaluatePose(ParallelismService(), roll: 35);
expect(data.status, ParallelismStatus.misaligned);
});
});
group('pose cible au sol', () {
test('appareil à plat caméra vers le bas → aligné', () {
final data = evaluatePose(ParallelismService(), pitch: 90);
expect(data.pose, TargetPose.ground);
expect(data.status, ParallelismStatus.aligned);
expect(data.pitchDeviation, closeTo(0, 0.5));
});
test('l\'écart se mesure par rapport à +90°, pas à 0°', () {
final data = evaluatePose(ParallelismService(), pitch: 80);
expect(data.pose, TargetPose.ground);
expect(data.pitchDeviation, closeTo(-10, 0.5));
expect(data.status, ParallelismStatus.aligned);
});
test('à plat mais trop incliné → désaligné', () {
final data = evaluatePose(ParallelismService(), pitch: 50);
expect(data.status, ParallelismStatus.misaligned);
});
test('à plat écran vers le haut → jamais aligné', () {
// La caméra vise le plafond : le vert n'aurait aucun sens.
final data = evaluatePose(ParallelismService(), pitch: -90);
expect(data.pose, TargetPose.wall);
expect(data.status, ParallelismStatus.misaligned);
});
});
group('hystérésis', () {
test('le vert survit à un léger tremblement entre les deux seuils', () {
final service = ParallelismService();
expect(evaluatePose(service).status, ParallelismStatus.aligned);
// 28° est au-dessus du seuil d'entrée (25°) mais sous celui de sortie (32°).
expect(evaluatePose(service, pitch: 28).status, ParallelismStatus.aligned);
expect(evaluatePose(service, pitch: 40).status, ParallelismStatus.misaligned);
// Repasser sous 32° ne suffit pas : il faut redescendre sous 25°.
expect(evaluatePose(service, pitch: 28).status, ParallelismStatus.misaligned);
expect(evaluatePose(service, pitch: 20).status, ParallelismStatus.aligned);
});
test('la pose ne bascule pas pour un écart négligeable', () {
final service = ParallelismService();
expect(evaluatePose(service).pose, TargetPose.wall);
// Pile à la frontière des deux poses : on garde celle déjà retenue.
expect(evaluatePose(service, pitch: 45).pose, TargetPose.wall);
// Franchement du côté du sol : on bascule.
expect(evaluatePose(service, pitch: 70).pose, TargetPose.ground);
});
test('stop() remet la pose et le statut à zéro', () {
final service = ParallelismService();
expect(evaluatePose(service, pitch: 90).pose, TargetPose.ground);
service.stop();
expect(evaluatePose(service).pose, TargetPose.wall);
});
});
test('une mesure aberrante est ignorée', () {
expect(ParallelismService().evaluate(0, 0, 0), isNull);
});
}
+79
View File
@@ -0,0 +1,79 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:bully/features/tutorial/tutorial_provider.dart';
import 'package:bully/services/tutorial_service.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() => SharedPreferences.setMockInitialValues({}));
group('TutorialService', () {
test('aucune visite n\'est marquée vue à la première utilisation',
() async {
expect(await TutorialService().loadCompletedTours(), isEmpty);
});
test('mémorise puis réinitialise les visites vues', () async {
final service = TutorialService();
await service.markCompleted(TutorialTours.home);
expect(await service.loadCompletedTours(), {TutorialTours.home});
await service.markCompleted(TutorialTours.impactEditor);
expect(
await service.loadCompletedTours(),
{TutorialTours.home, TutorialTours.impactEditor},
);
await service.resetAll();
expect(await service.loadCompletedTours(), isEmpty);
});
});
group('TutorialProvider', () {
test('joue chaque visite une seule fois', () async {
final provider = TutorialProvider();
await provider.load();
expect(provider.isLoaded, isTrue);
expect(provider.shouldRun(TutorialTours.home), isTrue);
expect(provider.hasSeenIntro, isFalse);
await provider.complete(TutorialTours.home);
expect(provider.shouldRun(TutorialTours.home), isFalse);
expect(provider.hasSeenIntro, isTrue);
// Les autres visites restent disponibles.
expect(provider.shouldRun(TutorialTours.impactEditor), isTrue);
});
test('la réactivation depuis les paramètres rejoue toutes les visites',
() async {
final provider = TutorialProvider();
await provider.load();
await provider.complete(TutorialTours.home);
await provider.complete(TutorialTours.impactEditor);
await provider.restart();
expect(provider.shouldRun(TutorialTours.home), isTrue);
expect(provider.shouldRun(TutorialTours.impactEditor), isTrue);
// La remise à zéro est bien persistée.
expect(await TutorialService().loadCompletedTours(), isEmpty);
});
test('l\'état vu survit à un redémarrage de l\'application', () async {
final first = TutorialProvider();
await first.load();
await first.complete(TutorialTours.home);
final second = TutorialProvider();
await second.load();
expect(second.shouldRun(TutorialTours.home), isFalse);
expect(second.shouldRun(TutorialTours.impactEditor), isTrue);
});
});
}