Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e111f76731 | ||
|
|
5d7d5e6b54 | ||
|
|
bc77462c27 | ||
|
|
4437a1f436 | ||
|
|
1b2310b12b | ||
|
|
44ac4462a6 | ||
|
|
0374a7611e | ||
|
|
543f54dc4f | ||
|
|
3812cd740b | ||
|
|
86abb645ce | ||
|
|
3a9e393a20 | ||
|
|
058bd5ff71 | ||
|
|
a4dc26fda8 | ||
|
|
6130e63cfb | ||
|
|
b641e80d62 | ||
|
|
df5f84c87c | ||
|
|
bbf10d7eed | ||
|
|
2b904205a0 | ||
|
|
cec9838e9b | ||
|
|
8d5641c0a3 | ||
|
|
a3167fc2ba | ||
|
|
972bfbe0e9 | ||
|
|
f65f65112c | ||
|
|
beb20074f2 |
@@ -0,0 +1,24 @@
|
||||
name: Deploy Backendia
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
# Correspond au label 'docker:host' de votre runner
|
||||
runs-on: docker
|
||||
steps:
|
||||
- name: 📥 Récupération du code
|
||||
run: |
|
||||
echo "📥 Récupération du code..."
|
||||
git clone --depth 1 --branch ${{ github.ref_name }} "https://${{ github.actor }}:${{ secrets.GITHUB_TOKEN }}@git.kevlar.cloud/${{ github.repository }}.git" .
|
||||
|
||||
- name: 🚀 Build et Déploiement Docker
|
||||
run: |
|
||||
echo "🚀 Démarrage du déploiement..."
|
||||
docker compose -f docker-compose.prod.yml up -d --build --remove-orphans
|
||||
echo "🧹 Nettoyage des anciennes images inutilisées..."
|
||||
docker image prune -f
|
||||
echo "✅ Déploiement terminé avec succès !"
|
||||
@@ -41,10 +41,12 @@ flutter test --coverage
|
||||
|
||||
## Features
|
||||
|
||||
### Analyse de cibles de tir
|
||||
### Capture de cibles de tir
|
||||
- Support de cibles concentriques (anneaux) et silhouettes
|
||||
- Chargement d'images de cibles depuis la galerie ou la caméra
|
||||
- Détection automatique du centre et du rayon de la cible
|
||||
- Aperçu caméra avec aide au cadrage : détection OpenCV de la cible (mire)
|
||||
et indicateur de parallélisme par accéléromètre (pitch/roll)
|
||||
- Écran de centrage/recadrage : rotation fine, déplacement pixel par pixel
|
||||
|
||||
### Calibration des cibles
|
||||
- Ajustement manuel du centre, du rayon et du nombre d'anneaux (1-10)
|
||||
@@ -52,14 +54,13 @@ flutter test --coverage
|
||||
- Slider global pour redimensionner tous les anneaux proportionnellement
|
||||
- Visualisation en temps réel des zones de score
|
||||
|
||||
### Détection d'impacts
|
||||
- **Ajout manuel** : cliquer sur l'image pour placer un impact
|
||||
- **Détection automatique** : algorithme de détection de blobs avec paramètres ajustables
|
||||
- Seuil de luminosité
|
||||
- Taille min/max des impacts
|
||||
- Circularité minimale
|
||||
- Ratio de remplissage (distingue les trous pleins des cercles vides)
|
||||
- **Détection par références** : sélectionner 2-4 impacts manuellement, l'algorithme apprend leurs caractéristiques et détecte les impacts similaires
|
||||
### Placement des impacts
|
||||
- **Éditeur d'impacts plein écran** : tap pour ajouter (y compris juste à côté
|
||||
ou par-dessus un impact existant), appui long pour déplacer, pincer pour zoomer
|
||||
- Un tap n'ouvre jamais d'édition de score : le score reste calculé
|
||||
automatiquement d'après la position de l'impact
|
||||
- Le placement est entièrement manuel ; le bouton ↻ de l'écran de synthèse
|
||||
efface tous les impacts sans toucher à la calibration
|
||||
|
||||
### Calcul des scores
|
||||
- Score automatique basé sur la position de l'impact dans les zones
|
||||
@@ -79,6 +80,19 @@ flutter test --coverage
|
||||
- **Distribution régionale** : répartition des tirs par quadrant
|
||||
- Filtrage par période : session, semaine, mois, toutes les sessions
|
||||
|
||||
### Sauvegarde (export / import JSON)
|
||||
- Bouton **Exporter** en bas de l'écran Statistiques : génère un fichier JSON
|
||||
(sessions + cibles + impacts + calibration, armurerie + entretien, et un
|
||||
instantané des statistiques calculées) puis ouvre la feuille de partage du
|
||||
système (`share_plus`) pour l'envoyer où l'on veut
|
||||
- Option « Inclure les photos des cibles » : photos encodées en base64 dans le
|
||||
JSON (sauvegarde complète mais fichier lourd) ; sans elles le fichier reste léger
|
||||
- Bouton **Importer** (`file_selector`) : aperçu du contenu avant confirmation,
|
||||
puis fusion avec les données existantes — même identifiant = mise à jour,
|
||||
donc réimporter deux fois ne crée pas de doublon et rien n'est effacé
|
||||
- Les statistiques ne sont pas réimportées : elles sont recalculées à partir des
|
||||
sessions. Elles figurent dans le fichier pour être exploitables telles quelles
|
||||
|
||||
### Historique des sessions
|
||||
- Sauvegarde des sessions avec date, score, notes
|
||||
- Visualisation des sessions passées
|
||||
@@ -120,3 +134,4 @@ session_list_item.dart Item de liste représentant une session
|
||||
history_chart.dart Graphique d'évolution des 10 dernières sessions
|
||||
statistics_screen.dart Écran statistiques avec filtrage par période
|
||||
heat_map_widget.dart Heat map avec gradient bleu→rouge
|
||||
backup_service.dart Export/import JSON des sessions, stats et armurerie
|
||||
|
||||
@@ -7,6 +7,15 @@
|
||||
|
||||
# The following line activates a set of recommended lints for Flutter apps,
|
||||
# packages, and plugins designed to encourage good coding practices.
|
||||
analyzer:
|
||||
exclude:
|
||||
- build/**
|
||||
- android/**
|
||||
- ios/**
|
||||
- web/**
|
||||
- windows/**
|
||||
- macos/**
|
||||
- linux/**
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
linter:
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
Analyzing bully...
|
||||
|
||||
info - Statements in an if should be enclosed in a block - lib\features\analysis\analysis_screen.dart:122:17 - curly_braces_in_flow_control_structures
|
||||
info - 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss - lib\features\analysis\analysis_screen.dart:650:51 - deprecated_member_use
|
||||
warning - The declaration '_showAddShotHint' isn't referenced - lib\features\analysis\analysis_screen.dart:1083:8 - unused_element
|
||||
warning - The declaration '_showAutoDetectDialog' isn't referenced - lib\features\analysis\analysis_screen.dart:1120:8 - unused_element
|
||||
warning - Unused import: 'widgets/target_type_selector.dart' - lib\features\capture\capture_screen.dart:16:8 - unused_import
|
||||
info - The private field _selectedType could be 'final' - lib\features\capture\capture_screen.dart:28:14 - prefer_final_fields
|
||||
info - 'scale' is deprecated and shouldn't be used. Use scaleByVector3, scaleByVector4, or scaleByDouble instead - lib\features\crop\crop_screen.dart:141:25 - deprecated_member_use
|
||||
info - The import of 'package:flutter/foundation.dart' is unnecessary because all of the used elements are also provided by the import of 'package:flutter/material.dart' - lib\features\statistics\statistics_screen.dart:8:8 - unnecessary_import
|
||||
warning - The declaration '_buildLegendItem' isn't referenced - lib\features\statistics\statistics_screen.dart:309:10 - unused_element
|
||||
info - Unnecessary use of string interpolation - lib\features\statistics\statistics_screen.dart:408:15 - unnecessary_string_interpolations
|
||||
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:192:7 - avoid_print
|
||||
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:239:7 - avoid_print
|
||||
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:246:9 - avoid_print
|
||||
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:278:9 - avoid_print
|
||||
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:289:11 - avoid_print
|
||||
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:292:11 - avoid_print
|
||||
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:297:9 - avoid_print
|
||||
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:332:7 - avoid_print
|
||||
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:336:7 - avoid_print
|
||||
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:683:7 - avoid_print
|
||||
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:725:7 - avoid_print
|
||||
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:736:7 - avoid_print
|
||||
warning - The declaration '_detectDarkSpotsAdaptive' isn't referenced - lib\services\image_processing_service.dart:780:15 - unused_element
|
||||
info - Don't invoke 'print' in production code - lib\services\opencv_impact_detection_service.dart:104:5 - avoid_print
|
||||
info - Don't invoke 'print' in production code - lib\services\opencv_impact_detection_service.dart:116:5 - avoid_print
|
||||
info - Don't invoke 'print' in production code - lib\services\target_detection_service.dart:297:7 - avoid_print
|
||||
info - Don't invoke 'print' in production code - lib\services\target_detection_service.dart:342:7 - avoid_print
|
||||
|
||||
27 issues found. (ran in 1.9s)
|
||||
@@ -1,37 +0,0 @@
|
||||
Analyzing bully...
|
||||
|
||||
info - Don't invoke 'print' in production code - lib\features\analysis\analysis_provider.dart:553:7 - avoid_print
|
||||
info - The private field _selectedType could be 'final' - lib\features\capture\capture_screen.dart:27:14 - prefer_final_fields
|
||||
info - 'scale' is deprecated and shouldn't be used. Use scaleByVector3, scaleByVector4, or scaleByDouble instead - lib\features\crop\crop_screen.dart:142:25 - deprecated_member_use
|
||||
info - Statements in an if should be enclosed in a block - lib\services\distortion_correction_service.dart:566:11 - curly_braces_in_flow_control_structures
|
||||
info - Don't invoke 'print' in production code - lib\services\distortion_correction_service.dart:639:7 - avoid_print
|
||||
info - Don't invoke 'print' in production code - lib\services\distortion_correction_service.dart:764:7 - avoid_print
|
||||
info - Don't invoke 'print' in production code - lib\services\distortion_correction_service.dart:825:9 - avoid_print
|
||||
info - Don't invoke 'print' in production code - lib\services\distortion_correction_service.dart:953:7 - avoid_print
|
||||
info - Don't invoke 'print' in production code - lib\services\distortion_correction_service.dart:1015:9 - avoid_print
|
||||
info - Don't invoke 'print' in production code - lib\services\distortion_correction_service.dart:1063:7 - avoid_print
|
||||
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:192:7 - avoid_print
|
||||
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:239:7 - avoid_print
|
||||
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:246:9 - avoid_print
|
||||
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:278:9 - avoid_print
|
||||
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:289:11 - avoid_print
|
||||
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:292:11 - avoid_print
|
||||
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:297:9 - avoid_print
|
||||
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:332:7 - avoid_print
|
||||
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:336:7 - avoid_print
|
||||
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:683:7 - avoid_print
|
||||
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:725:7 - avoid_print
|
||||
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:736:7 - avoid_print
|
||||
warning - The declaration '_detectDarkSpotsAdaptive' isn't referenced - lib\services\image_processing_service.dart:780:15 - unused_element
|
||||
info - Don't invoke 'print' in production code - lib\services\target_detection_service.dart:328:7 - avoid_print
|
||||
info - Don't invoke 'print' in production code - lib\services\target_detection_service.dart:377:7 - avoid_print
|
||||
info - Don't invoke 'print' in production code - lib\services\target_detection_service.dart:414:7 - avoid_print
|
||||
info - Don't invoke 'print' in production code - lib\services\yolo_impact_detection_service.dart:23:9 - avoid_print
|
||||
info - Don't invoke 'print' in production code - lib\services\yolo_impact_detection_service.dart:29:7 - avoid_print
|
||||
info - Don't invoke 'print' in production code - lib\services\yolo_impact_detection_service.dart:31:7 - avoid_print
|
||||
info - Don't invoke 'print' in production code - lib\services\yolo_impact_detection_service.dart:67:7 - avoid_print
|
||||
info - Don't invoke 'print' in production code - tests\opencv_quad_test.dart:4:3 - avoid_print
|
||||
info - Don't invoke 'print' in production code - tests\opencv_quad_test.dart:5:3 - avoid_print
|
||||
info - Don't invoke 'print' in production code - tests\opencv_quad_test.dart:6:3 - avoid_print
|
||||
info - Don't invoke 'print' in production code - tests\test_homography.dart:4:3 - avoid_print
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
Analyzing bully...
|
||||
|
||||
info - Don't invoke 'print' in production code - lib\features\analysis\analysis_provider.dart:392:7 - avoid_print
|
||||
info - Don't invoke 'print' in production code - lib\features\analysis\analysis_provider.dart:596:7 - avoid_print
|
||||
info - The private field _selectedType could be 'final' - lib\features\capture\capture_screen.dart:27:14 - prefer_final_fields
|
||||
info - 'scale' is deprecated and shouldn't be used. Use scaleByVector3, scaleByVector4, or scaleByDouble instead - lib\features\crop\crop_screen.dart:142:25 - deprecated_member_use
|
||||
info - Statements in an if should be enclosed in a block - lib\services\distortion_correction_service.dart:566:11 - curly_braces_in_flow_control_structures
|
||||
info - Don't invoke 'print' in production code - lib\services\distortion_correction_service.dart:639:7 - avoid_print
|
||||
info - Don't invoke 'print' in production code - lib\services\distortion_correction_service.dart:764:7 - avoid_print
|
||||
info - Don't invoke 'print' in production code - lib\services\distortion_correction_service.dart:825:9 - avoid_print
|
||||
info - Don't invoke 'print' in production code - lib\services\distortion_correction_service.dart:953:7 - avoid_print
|
||||
info - Don't invoke 'print' in production code - lib\services\distortion_correction_service.dart:1015:9 - avoid_print
|
||||
info - Don't invoke 'print' in production code - lib\services\distortion_correction_service.dart:1063:7 - avoid_print
|
||||
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:192:7 - avoid_print
|
||||
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:239:7 - avoid_print
|
||||
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:246:9 - avoid_print
|
||||
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:278:9 - avoid_print
|
||||
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:289:11 - avoid_print
|
||||
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:292:11 - avoid_print
|
||||
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:297:9 - avoid_print
|
||||
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:332:7 - avoid_print
|
||||
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:336:7 - avoid_print
|
||||
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:683:7 - avoid_print
|
||||
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:725:7 - avoid_print
|
||||
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:736:7 - avoid_print
|
||||
warning - The declaration '_detectDarkSpotsAdaptive' isn't referenced - lib\services\image_processing_service.dart:780:15 - unused_element
|
||||
info - Don't invoke 'print' in production code - lib\services\target_detection_service.dart:328:7 - avoid_print
|
||||
info - Don't invoke 'print' in production code - lib\services\target_detection_service.dart:377:7 - avoid_print
|
||||
info - Don't invoke 'print' in production code - lib\services\target_detection_service.dart:414:7 - avoid_print
|
||||
info - Don't invoke 'print' in production code - lib\services\yolo_impact_detection_service.dart:23:9 - avoid_print
|
||||
info - Don't invoke 'print' in production code - lib\services\yolo_impact_detection_service.dart:29:7 - avoid_print
|
||||
info - Don't invoke 'print' in production code - lib\services\yolo_impact_detection_service.dart:31:7 - avoid_print
|
||||
info - Don't invoke 'print' in production code - lib\services\yolo_impact_detection_service.dart:67:7 - avoid_print
|
||||
info - Don't invoke 'print' in production code - tests\opencv_quad_test.dart:4:3 - avoid_print
|
||||
info - Don't invoke 'print' in production code - tests\opencv_quad_test.dart:5:3 - avoid_print
|
||||
info - Don't invoke 'print' in production code - tests\opencv_quad_test.dart:6:3 - avoid_print
|
||||
info - Don't invoke 'print' in production code - tests\test_homography.dart:4:3 - avoid_print
|
||||
|
||||
@@ -1,2 +1,6 @@
|
||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||
android.useAndroidX=true
|
||||
# This builtInKotlin flag was added automatically by Flutter migrator
|
||||
android.builtInKotlin=false
|
||||
# This newDsl flag was added automatically by Flutter migrator
|
||||
android.newDsl=false
|
||||
|
||||
|
After Width: | Height: | Size: 29 KiB |
@@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
npm-debug.log*
|
||||
dashboard/node_modules
|
||||
dashboard/.next
|
||||
uploads
|
||||
exports
|
||||
.git
|
||||
.env*
|
||||
@@ -13,8 +13,7 @@ dashboard/out/
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# Database
|
||||
backendia/uploads/data/database.sqlite
|
||||
# Database (contient des données utilisateurs — jamais dans git)
|
||||
*.sqlite
|
||||
*.sqlite-journal
|
||||
|
||||
@@ -23,7 +22,6 @@ uploads/images/*
|
||||
!uploads/images/.gitkeep
|
||||
uploads/data/*
|
||||
!uploads/data/.gitkeep
|
||||
!uploads/data/database.sqlite
|
||||
exports/*
|
||||
!exports/.gitkeep
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
FROM node:20-bookworm-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Dépendances système pour les modules natifs (sharp, sqlite3, opencv)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
python3 \
|
||||
make \
|
||||
g++ \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
COPY . .
|
||||
|
||||
# Dossiers nécessaires
|
||||
RUN mkdir -p uploads/images uploads/data exports
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
@@ -2,7 +2,7 @@ import type { Metadata } from "next";
|
||||
import { Inter } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import Link from "next/link";
|
||||
import { LayoutDashboard, Users, Image as ImageIcon } from "lucide-react";
|
||||
import { LayoutDashboard, Users, ScrollText, Image as ImageIcon } from "lucide-react";
|
||||
|
||||
const inter = Inter({ subsets: ["latin"] });
|
||||
|
||||
@@ -41,6 +41,13 @@ export default function RootLayout({
|
||||
<Users size={20} />
|
||||
Contributeurs
|
||||
</Link>
|
||||
<Link
|
||||
href="/logs"
|
||||
className="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-slate-800 transition-colors text-slate-300 hover:text-white"
|
||||
>
|
||||
<ScrollText size={20} />
|
||||
Logs d'Uploads
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
<div className="mt-auto pt-6 border-t border-slate-800">
|
||||
|
||||
@@ -0,0 +1,900 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo, useEffect } from "react";
|
||||
import {
|
||||
ScrollText,
|
||||
Search,
|
||||
Download,
|
||||
RefreshCw,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Copy,
|
||||
Check,
|
||||
Trash2,
|
||||
Smartphone,
|
||||
Crosshair,
|
||||
Calendar,
|
||||
HardDrive,
|
||||
Users,
|
||||
ExternalLink,
|
||||
Info,
|
||||
Filter,
|
||||
Eye,
|
||||
ShieldAlert,
|
||||
ShieldCheck,
|
||||
Ban,
|
||||
AlertTriangle
|
||||
} from "lucide-react";
|
||||
import { API_BASE_URL } from "@/lib/api";
|
||||
import Link from "next/link";
|
||||
|
||||
interface UploadLog {
|
||||
id: number;
|
||||
timestamp: string;
|
||||
session_id: string | null;
|
||||
wallet_hash: string | null;
|
||||
image_filename: string | null;
|
||||
json_filename: string | null;
|
||||
file_size: number | null;
|
||||
ip_address: string | null;
|
||||
user_agent: string | null;
|
||||
device_model: string | null;
|
||||
device_os: string | null;
|
||||
target_type: string | null;
|
||||
weapon: string | null;
|
||||
distance_meters: number | null;
|
||||
impacts_count: number;
|
||||
status: string;
|
||||
target_valid?: number;
|
||||
target_status?: string;
|
||||
target_confidence?: number;
|
||||
target_rings_count?: number;
|
||||
target_details?: string | null;
|
||||
error_message: string | null;
|
||||
raw_metadata: string | null;
|
||||
}
|
||||
|
||||
interface BannedWallet {
|
||||
wallet_hash: string;
|
||||
reason: string;
|
||||
banned_at: string;
|
||||
banned_by: string;
|
||||
}
|
||||
|
||||
interface LogStats {
|
||||
total_uploads: number;
|
||||
success_count: number;
|
||||
failed_count: number;
|
||||
today_uploads: number;
|
||||
unique_wallets: number;
|
||||
total_bytes: number;
|
||||
latest_upload: string | null;
|
||||
}
|
||||
|
||||
export default function LogsManager({
|
||||
initialLogs,
|
||||
initialStats
|
||||
}: {
|
||||
initialLogs: UploadLog[];
|
||||
initialStats: LogStats | null;
|
||||
}) {
|
||||
const [logs, setLogs] = useState<UploadLog[]>(initialLogs);
|
||||
const [stats, setStats] = useState<LogStats | null>(initialStats);
|
||||
const [bannedWallets, setBannedWallets] = useState<BannedWallet[]>([]);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<"ALL" | "SUCCESS" | "FAILED" | "INVALID_TARGET" | "BANNED">("ALL");
|
||||
const [copiedWallet, setCopiedWallet] = useState<string | null>(null);
|
||||
const [selectedLog, setSelectedLog] = useState<UploadLog | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [showClearConfirm, setShowClearConfirm] = useState(false);
|
||||
|
||||
// Moderation state
|
||||
const [banModalWallet, setBanModalWallet] = useState<string | null>(null);
|
||||
const [banReason, setBanReason] = useState("Images non conformes / Fausse cible");
|
||||
const [isSubmittingBan, setIsSubmittingBan] = useState(false);
|
||||
|
||||
const fetchBannedWallets = async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}/api/moderation/banned`, { cache: 'no-store' });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setBannedWallets(data.banned || []);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Erreur récupération wallets bannis:", e);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchBannedWallets();
|
||||
}, []);
|
||||
|
||||
const fetchLogs = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const [logsRes, statsRes] = await Promise.all([
|
||||
fetch(`${API_BASE_URL}/api/logs?limit=200`, { cache: 'no-store' }),
|
||||
fetch(`${API_BASE_URL}/api/logs/stats`, { cache: 'no-store' }),
|
||||
fetchBannedWallets()
|
||||
]);
|
||||
|
||||
if (logsRes.ok) {
|
||||
const logsData = await logsRes.json();
|
||||
setLogs(logsData.logs || []);
|
||||
}
|
||||
if (statsRes.ok) {
|
||||
const statsData = await statsRes.json();
|
||||
setStats(statsData.stats || null);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Erreur lors de la récupération des logs:", e);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = (text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
setCopiedWallet(text);
|
||||
setTimeout(() => setCopiedWallet(null), 2000);
|
||||
};
|
||||
|
||||
const isWalletBanned = (walletHash: string | null) => {
|
||||
if (!walletHash) return false;
|
||||
return bannedWallets.some(b => b.wallet_hash === walletHash);
|
||||
};
|
||||
|
||||
const getBannedInfo = (walletHash: string | null) => {
|
||||
if (!walletHash) return null;
|
||||
return bannedWallets.find(b => b.wallet_hash === walletHash) || null;
|
||||
};
|
||||
|
||||
const handleBanWallet = async () => {
|
||||
if (!banModalWallet) return;
|
||||
setIsSubmittingBan(true);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}/api/moderation/ban`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
wallet_hash: banModalWallet,
|
||||
reason: banReason,
|
||||
banned_by: 'Admin Dashboard'
|
||||
})
|
||||
});
|
||||
if (res.ok) {
|
||||
setBanModalWallet(null);
|
||||
await fetchBannedWallets();
|
||||
await fetchLogs();
|
||||
} else {
|
||||
alert("Erreur lors du bannissement.");
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Erreur ban:", e);
|
||||
} finally {
|
||||
setIsSubmittingBan(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnbanWallet = async (walletHash: string) => {
|
||||
if (!confirm(`Voulez-vous vraiment débannir le wallet ${walletHash.substring(0, 10)}... ?`)) return;
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}/api/moderation/ban/${walletHash}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
if (res.ok) {
|
||||
await fetchBannedWallets();
|
||||
await fetchLogs();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Erreur unban:", e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteLog = async (id: number) => {
|
||||
if (!confirm("Voulez-vous vraiment supprimer ce log ?")) return;
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}/api/logs/${id}`, { method: 'DELETE' });
|
||||
if (res.ok) {
|
||||
setLogs(prev => prev.filter(l => l.id !== id));
|
||||
fetchLogs();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Erreur suppression:", e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearAll = async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}/api/logs`, { method: 'DELETE' });
|
||||
if (res.ok) {
|
||||
setLogs([]);
|
||||
setShowClearConfirm(false);
|
||||
fetchLogs();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Erreur vidage logs:", e);
|
||||
}
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes: number | null) => {
|
||||
if (!bytes || bytes === 0) return "0 B";
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
|
||||
};
|
||||
|
||||
const filteredLogs = useMemo(() => {
|
||||
return logs.filter(log => {
|
||||
// Filter status
|
||||
if (statusFilter === "SUCCESS" && log.status !== "SUCCESS") return false;
|
||||
if (statusFilter === "FAILED" && log.status !== "FAILED") return false;
|
||||
if (statusFilter === "INVALID_TARGET" && log.target_status === "VALID") return false;
|
||||
if (statusFilter === "BANNED" && !isWalletBanned(log.wallet_hash)) return false;
|
||||
|
||||
// Filter search
|
||||
if (!searchTerm.trim()) return true;
|
||||
const term = searchTerm.toLowerCase();
|
||||
return (
|
||||
(log.wallet_hash && log.wallet_hash.toLowerCase().includes(term)) ||
|
||||
(log.session_id && log.session_id.toLowerCase().includes(term)) ||
|
||||
(log.image_filename && log.image_filename.toLowerCase().includes(term)) ||
|
||||
(log.device_model && log.device_model.toLowerCase().includes(term)) ||
|
||||
(log.weapon && log.weapon.toLowerCase().includes(term)) ||
|
||||
(log.target_type && log.target_type.toLowerCase().includes(term)) ||
|
||||
(log.target_status && log.target_status.toLowerCase().includes(term)) ||
|
||||
(log.ip_address && log.ip_address.toLowerCase().includes(term))
|
||||
);
|
||||
});
|
||||
}, [logs, searchTerm, statusFilter, bannedWallets]);
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2.5 bg-indigo-500/10 border border-indigo-500/20 rounded-xl text-indigo-400">
|
||||
<ScrollText size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold tracking-tight">Journal & Contrôle des Uploads</h2>
|
||||
<p className="text-slate-400 text-sm">
|
||||
Historique des transferts, diagnostic OpenCV (détection cibles) et modération des wallets.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
onClick={fetchLogs}
|
||||
disabled={isLoading}
|
||||
className="flex items-center gap-2 px-4 py-2.5 bg-slate-900 border border-slate-800 hover:bg-slate-800 text-slate-300 hover:text-white rounded-xl text-sm font-semibold transition-all disabled:opacity-50"
|
||||
title="Rafraîchir les logs"
|
||||
>
|
||||
<RefreshCw size={16} className={isLoading ? "animate-spin text-indigo-400" : ""} />
|
||||
<span>Actualiser</span>
|
||||
</button>
|
||||
|
||||
<a
|
||||
href={`${API_BASE_URL}/api/logs/export?format=csv`}
|
||||
download
|
||||
className="flex items-center gap-2 px-4 py-2.5 bg-indigo-600 hover:bg-indigo-500 text-white rounded-xl text-sm font-semibold transition-all shadow-lg shadow-indigo-600/20 hover:scale-105 active:scale-95"
|
||||
>
|
||||
<Download size={16} />
|
||||
<span>Exporter CSV</span>
|
||||
</a>
|
||||
|
||||
{!showClearConfirm ? (
|
||||
<button
|
||||
onClick={() => setShowClearConfirm(true)}
|
||||
className="flex items-center gap-2 px-3 py-2.5 bg-slate-900 border border-slate-800 hover:border-rose-500/40 text-slate-400 hover:text-rose-400 rounded-xl text-sm transition-all"
|
||||
title="Purger tous les logs"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 p-1 bg-rose-500/10 border border-rose-500/30 rounded-xl animate-in fade-in">
|
||||
<span className="text-xs text-rose-400 px-2 font-semibold">Effacer tout ?</span>
|
||||
<button
|
||||
onClick={handleClearAll}
|
||||
className="px-2.5 py-1 bg-rose-600 hover:bg-rose-500 text-white rounded-lg text-xs font-bold transition-all"
|
||||
>
|
||||
Oui
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowClearConfirm(false)}
|
||||
className="px-2.5 py-1 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-xs transition-all"
|
||||
>
|
||||
Non
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* KPI Stats Cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5">
|
||||
<div className="bg-slate-900/80 border border-slate-800 p-5 rounded-2xl flex items-center justify-between shadow-sm">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-slate-500 font-bold uppercase tracking-wider">Total Uploads</p>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<p className="text-2xl font-bold">{stats?.total_uploads ?? logs.length}</p>
|
||||
{stats && stats.total_uploads > 0 && (
|
||||
<span className="text-xs font-semibold text-emerald-400">
|
||||
{Math.round(((stats.success_count || 0) / stats.total_uploads) * 100)}% succès
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-11 h-11 bg-indigo-500/10 border border-indigo-500/20 text-indigo-400 rounded-xl flex items-center justify-center">
|
||||
<ScrollText size={20} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-900/80 border border-slate-800 p-5 rounded-2xl flex items-center justify-between shadow-sm">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-slate-500 font-bold uppercase tracking-wider">Aujourd'hui</p>
|
||||
<p className="text-2xl font-bold text-indigo-400">{stats?.today_uploads ?? 0}</p>
|
||||
</div>
|
||||
<div className="w-11 h-11 bg-indigo-500/10 border border-indigo-500/20 text-indigo-400 rounded-xl flex items-center justify-center">
|
||||
<Calendar size={20} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-900/80 border border-slate-800 p-5 rounded-2xl flex items-center justify-between shadow-sm">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-slate-500 font-bold uppercase tracking-wider">Wallets Bannis</p>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<p className="text-2xl font-bold text-rose-400">{bannedWallets.length}</p>
|
||||
<span className="text-xs text-slate-500 font-mono">/ {stats?.unique_wallets ?? 0} total</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-11 h-11 bg-rose-500/10 border border-rose-500/20 text-rose-400 rounded-xl flex items-center justify-center">
|
||||
<Ban size={20} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-900/80 border border-slate-800 p-5 rounded-2xl flex items-center justify-between shadow-sm">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-slate-500 font-bold uppercase tracking-wider">Volume Transféré</p>
|
||||
<p className="text-2xl font-bold text-emerald-400">
|
||||
{formatFileSize(stats?.total_bytes || logs.reduce((acc, l) => acc + (l.file_size || 0), 0))}
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-11 h-11 bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 rounded-xl flex items-center justify-center">
|
||||
<HardDrive size={20} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters & Search Toolbar */}
|
||||
<div className="bg-slate-900 border border-slate-800 rounded-2xl p-4 flex flex-col md:flex-row gap-4 items-center justify-between">
|
||||
{/* Search */}
|
||||
<div className="relative w-full md:w-96">
|
||||
<Search size={16} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-slate-500" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder="Rechercher par Wallet, Session, Cible, Image..."
|
||||
className="w-full bg-slate-950 border border-slate-800 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 rounded-xl pl-10 pr-4 py-2 text-sm text-slate-200 placeholder-slate-500 outline-none transition-all"
|
||||
/>
|
||||
{searchTerm && (
|
||||
<button
|
||||
onClick={() => setSearchTerm("")}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-slate-500 hover:text-slate-300"
|
||||
>
|
||||
Effacer
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status & OpenCV Filter */}
|
||||
<div className="flex items-center gap-2 w-full md:w-auto justify-start md:justify-end flex-wrap">
|
||||
<span className="text-xs text-slate-500 font-semibold uppercase tracking-wider flex items-center gap-1.5 mr-1">
|
||||
<Filter size={13} />
|
||||
Filtre:
|
||||
</span>
|
||||
<div className="inline-flex bg-slate-950 p-1 rounded-xl border border-slate-800 flex-wrap gap-1">
|
||||
<button
|
||||
onClick={() => setStatusFilter("ALL")}
|
||||
className={`px-2.5 py-1 text-xs font-semibold rounded-lg transition-all ${
|
||||
statusFilter === "ALL"
|
||||
? "bg-slate-800 text-white shadow-sm"
|
||||
: "text-slate-400 hover:text-slate-200"
|
||||
}`}
|
||||
>
|
||||
Tous ({logs.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStatusFilter("SUCCESS")}
|
||||
className={`px-2.5 py-1 text-xs font-semibold rounded-lg transition-all ${
|
||||
statusFilter === "SUCCESS"
|
||||
? "bg-emerald-500/20 text-emerald-300 border border-emerald-500/30"
|
||||
: "text-slate-400 hover:text-emerald-400"
|
||||
}`}
|
||||
>
|
||||
Succès
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStatusFilter("INVALID_TARGET")}
|
||||
className={`px-2.5 py-1 text-xs font-semibold rounded-lg transition-all ${
|
||||
statusFilter === "INVALID_TARGET"
|
||||
? "bg-amber-500/20 text-amber-300 border border-amber-500/30"
|
||||
: "text-slate-400 hover:text-amber-400"
|
||||
}`}
|
||||
title="Photos non reconnues comme cible par OpenCV"
|
||||
>
|
||||
⚠ Cibles Douteuses
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStatusFilter("BANNED")}
|
||||
className={`px-2.5 py-1 text-xs font-semibold rounded-lg transition-all ${
|
||||
statusFilter === "BANNED"
|
||||
? "bg-rose-500/20 text-rose-300 border border-rose-500/30"
|
||||
: "text-slate-400 hover:text-rose-400"
|
||||
}`}
|
||||
>
|
||||
Bannis ({bannedWallets.length})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Logs Table */}
|
||||
<div className="bg-slate-900 border border-slate-800 rounded-2xl overflow-hidden shadow-xl shadow-black/20">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="bg-slate-950/60 border-b border-slate-800 text-slate-400 font-semibold text-xs uppercase tracking-wider">
|
||||
<tr>
|
||||
<th className="px-5 py-3.5">Date & Heure</th>
|
||||
<th className="px-4 py-3.5">Diagnostic OpenCV</th>
|
||||
<th className="px-4 py-3.5">Wallet Contributeur</th>
|
||||
<th className="px-4 py-3.5">Session / Cible</th>
|
||||
<th className="px-4 py-3.5">Appareil</th>
|
||||
<th className="px-4 py-3.5">Fichier</th>
|
||||
<th className="px-4 py-3.5 text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800/60">
|
||||
{filteredLogs.map((log) => {
|
||||
const dateObj = new Date(log.timestamp);
|
||||
const isValidDate = !isNaN(dateObj.getTime());
|
||||
const formattedDate = isValidDate
|
||||
? dateObj.toLocaleDateString("fr-FR", { day: "2-digit", month: "2-digit", year: "numeric" })
|
||||
: log.timestamp;
|
||||
const formattedTime = isValidDate
|
||||
? dateObj.toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit", second: "2-digit" })
|
||||
: "";
|
||||
|
||||
const banned = isWalletBanned(log.wallet_hash);
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={log.id}
|
||||
className="hover:bg-slate-800/40 transition-colors group"
|
||||
>
|
||||
{/* Timestamp */}
|
||||
<td className="px-5 py-4 whitespace-nowrap">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-semibold text-slate-200">{formattedDate}</span>
|
||||
<span className="text-xs text-slate-500 font-mono">{formattedTime}</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* OpenCV Diagnosis & Upload Status */}
|
||||
<td className="px-4 py-4 whitespace-nowrap">
|
||||
<div className="flex flex-col gap-1">
|
||||
{log.status === "FAILED" ? (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-[10px] font-bold bg-rose-500/10 text-rose-400 border border-rose-500/20 max-w-fit" title={log.error_message || "Échec upload"}>
|
||||
<XCircle size={12} />
|
||||
UPLOAD ÉCHEC
|
||||
</span>
|
||||
) : log.target_status === "VALID" ? (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-[10px] font-bold bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 max-w-fit" title={log.target_details || "Cible certifiée"}>
|
||||
<ShieldCheck size={12} />
|
||||
CIBLE CERTIFIÉE ({Math.round((log.target_confidence || 1) * 100)}%)
|
||||
</span>
|
||||
) : log.target_status === "SUSPICIOUS" ? (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-[10px] font-bold bg-amber-500/10 text-amber-400 border border-amber-500/20 max-w-fit" title={log.target_details || "1 seul anneau détecté"}>
|
||||
<AlertTriangle size={12} />
|
||||
DOUTEUSE (1 ANNEAU)
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-[10px] font-bold bg-rose-500/10 text-rose-400 border border-rose-500/20 max-w-fit" title={log.target_details || "Aucun motif de cible"}>
|
||||
<ShieldAlert size={12} />
|
||||
NON RECONNUE
|
||||
</span>
|
||||
)}
|
||||
|
||||
{log.target_rings_count ? (
|
||||
<span className="text-[10px] text-slate-500 font-mono">
|
||||
{log.target_rings_count} anneaux concentriques
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Wallet Hash & Ban Badge */}
|
||||
<td className="px-4 py-4">
|
||||
{log.wallet_hash ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
onClick={() => setSearchTerm(log.wallet_hash || "")}
|
||||
className="font-mono text-xs text-indigo-300 hover:text-indigo-200 bg-indigo-500/5 px-2 py-1 rounded border border-indigo-500/10 hover:border-indigo-500/30 cursor-pointer transition-colors"
|
||||
title="Cliquer pour filtrer par ce wallet"
|
||||
>
|
||||
{log.wallet_hash.length > 16
|
||||
? `${log.wallet_hash.substring(0, 8)}...${log.wallet_hash.substring(log.wallet_hash.length - 8)}`
|
||||
: log.wallet_hash}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => copyToClipboard(log.wallet_hash!)}
|
||||
className="text-slate-500 hover:text-slate-300 transition-colors p-1"
|
||||
title="Copier le hash du wallet"
|
||||
>
|
||||
{copiedWallet === log.wallet_hash ? (
|
||||
<Check size={14} className="text-emerald-400" />
|
||||
) : (
|
||||
<Copy size={14} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{banned && (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-bold text-rose-400 bg-rose-500/10 px-1.5 py-0.5 rounded border border-rose-500/20 max-w-fit">
|
||||
<Ban size={10} />
|
||||
WALLET BANNI
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-slate-500 italic">Anonyme / Non fourni</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* Session & Target */}
|
||||
<td className="px-4 py-4">
|
||||
<div className="flex flex-col gap-1 max-w-[180px]">
|
||||
{log.session_id && (
|
||||
<div className="flex items-center gap-1 text-xs text-slate-300 font-mono truncate">
|
||||
<span className="text-slate-500">ID:</span> {log.session_id}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center flex-wrap gap-1.5">
|
||||
{log.target_type && (
|
||||
<span className="text-[10px] uppercase font-bold bg-slate-800 text-slate-300 px-1.5 py-0.5 rounded border border-slate-700">
|
||||
{log.target_type}
|
||||
</span>
|
||||
)}
|
||||
{log.weapon && (
|
||||
<span className="text-[10px] bg-slate-800 text-slate-400 px-1.5 py-0.5 rounded">
|
||||
{log.weapon}
|
||||
</span>
|
||||
)}
|
||||
{log.impacts_count > 0 && (
|
||||
<span className="text-[10px] font-bold text-indigo-400 bg-indigo-500/10 px-1.5 py-0.5 rounded border border-indigo-500/20 flex items-center gap-0.5">
|
||||
<Crosshair size={10} />
|
||||
{log.impacts_count} imp.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Device & IP */}
|
||||
<td className="px-4 py-4 whitespace-nowrap">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="flex items-center gap-1.5 text-xs text-slate-300">
|
||||
<Smartphone size={13} className="text-indigo-400" />
|
||||
<span>{log.device_model || log.device_os || "Inconnu"}</span>
|
||||
</div>
|
||||
{log.ip_address && (
|
||||
<span className="text-[11px] text-slate-500 font-mono">
|
||||
{log.ip_address.replace("::ffff:", "")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* File & Size */}
|
||||
<td className="px-4 py-4 whitespace-nowrap">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{log.image_filename ? (
|
||||
<Link
|
||||
href={`/photo/${log.image_filename}`}
|
||||
className="text-xs text-indigo-400 hover:text-indigo-300 font-mono flex items-center gap-1 group/link truncate max-w-[130px]"
|
||||
title={log.image_filename}
|
||||
>
|
||||
<span className="truncate">{log.image_filename}</span>
|
||||
<ExternalLink size={12} className="opacity-0 group-hover/link:opacity-100 transition-opacity" />
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-xs text-slate-500 italic">Aucun fichier</span>
|
||||
)}
|
||||
<span className="text-[11px] text-slate-500 font-mono">
|
||||
{formatFileSize(log.file_size)}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Actions */}
|
||||
<td className="px-4 py-4 text-right whitespace-nowrap">
|
||||
<div className="flex items-center justify-end gap-1.5">
|
||||
{/* Ban / Unban Button */}
|
||||
{log.wallet_hash && (
|
||||
banned ? (
|
||||
<button
|
||||
onClick={() => handleUnbanWallet(log.wallet_hash!)}
|
||||
className="p-1.5 text-slate-400 hover:text-emerald-400 hover:bg-slate-800 rounded-lg transition-colors"
|
||||
title="Débannir ce wallet"
|
||||
>
|
||||
<ShieldCheck size={16} />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setBanModalWallet(log.wallet_hash)}
|
||||
className="p-1.5 text-slate-500 hover:text-rose-400 hover:bg-slate-800 rounded-lg transition-colors"
|
||||
title="Bannir définitivement ce wallet"
|
||||
>
|
||||
<Ban size={16} />
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => setSelectedLog(log)}
|
||||
className="p-1.5 text-slate-400 hover:text-indigo-300 hover:bg-slate-800 rounded-lg transition-colors"
|
||||
title="Voir les détails complets"
|
||||
>
|
||||
<Eye size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteLog(log.id)}
|
||||
className="p-1.5 text-slate-500 hover:text-rose-400 hover:bg-slate-800 rounded-lg transition-colors"
|
||||
title="Supprimer ce log"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Empty state */}
|
||||
{filteredLogs.length === 0 && (
|
||||
<div className="py-20 flex flex-col items-center justify-center text-center px-4">
|
||||
<div className="p-4 bg-slate-950 border border-slate-800 rounded-2xl text-slate-600 mb-3">
|
||||
<ScrollText size={36} />
|
||||
</div>
|
||||
<h3 className="text-slate-300 font-bold text-base">Aucun log trouvé</h3>
|
||||
<p className="text-slate-500 text-xs mt-1 max-w-sm">
|
||||
{searchTerm || statusFilter !== "ALL"
|
||||
? "Aucun enregistrement ne correspond à vos critères de recherche."
|
||||
: "Les logs apparaîtront ici dès que des photos ou des sessions seront uploadées."}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Details Modal */}
|
||||
{selectedLog && (
|
||||
<div className="fixed inset-0 z-50 bg-black/70 backdrop-blur-sm flex items-center justify-center p-4">
|
||||
<div className="bg-slate-900 border border-slate-800 rounded-2xl w-full max-w-2xl max-h-[85vh] flex flex-col overflow-hidden shadow-2xl animate-in zoom-in-95 duration-200">
|
||||
{/* Modal Header */}
|
||||
<div className="p-6 border-b border-slate-800 flex items-center justify-between bg-slate-950/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-indigo-500/10 border border-indigo-500/20 rounded-xl text-indigo-400">
|
||||
<Info size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-lg text-slate-100">Détails du Log #{selectedLog.id}</h3>
|
||||
<p className="text-xs text-slate-400">{new Date(selectedLog.timestamp).toLocaleString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSelectedLog(null)}
|
||||
className="text-slate-400 hover:text-white p-2 hover:bg-slate-800 rounded-xl transition-colors"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Modal Content */}
|
||||
<div className="p-6 overflow-y-auto space-y-6 text-sm">
|
||||
{/* OpenCV Diagnostic Banner */}
|
||||
<div className={`p-4 rounded-xl border flex flex-col gap-2 ${
|
||||
selectedLog.target_status === "VALID"
|
||||
? "bg-emerald-500/10 border-emerald-500/20 text-emerald-300"
|
||||
: selectedLog.target_status === "SUSPICIOUS"
|
||||
? "bg-amber-500/10 border-amber-500/20 text-amber-300"
|
||||
: "bg-rose-500/10 border-rose-500/20 text-rose-300"
|
||||
}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 font-bold">
|
||||
{selectedLog.target_status === "VALID" ? <ShieldCheck size={18} /> : <AlertTriangle size={18} />}
|
||||
Diagnostic OpenCV : {selectedLog.target_status || "VALID"}
|
||||
</div>
|
||||
<span className="text-xs font-mono font-bold px-2 py-0.5 rounded bg-black/30">
|
||||
Confiance : {Math.round((selectedLog.target_confidence || 1) * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs opacity-90">{selectedLog.target_details || "Cible détectée avec succès."}</p>
|
||||
</div>
|
||||
|
||||
{/* General Grid */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="bg-slate-950 p-4 rounded-xl border border-slate-800 space-y-1">
|
||||
<span className="text-xs text-slate-500 font-bold uppercase tracking-wider">Wallet Hash</span>
|
||||
<div className="font-mono text-xs text-indigo-300 break-all select-all">
|
||||
{selectedLog.wallet_hash || "Non spécifié"}
|
||||
</div>
|
||||
{isWalletBanned(selectedLog.wallet_hash) && (
|
||||
<span className="inline-block mt-1 text-[10px] text-rose-400 font-bold bg-rose-500/10 px-1.5 py-0.5 rounded border border-rose-500/20">
|
||||
Banni ({getBannedInfo(selectedLog.wallet_hash)?.reason})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-950 p-4 rounded-xl border border-slate-800 space-y-1">
|
||||
<span className="text-xs text-slate-500 font-bold uppercase tracking-wider">Session ID</span>
|
||||
<div className="font-mono text-xs text-slate-300 break-all select-all">
|
||||
{selectedLog.session_id || "N/A"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-950 p-4 rounded-xl border border-slate-800 space-y-1">
|
||||
<span className="text-xs text-slate-500 font-bold uppercase tracking-wider">Appareil</span>
|
||||
<div className="text-xs text-slate-300">
|
||||
{selectedLog.device_model || "Inconnu"} ({selectedLog.device_os || "OS inconnu"})
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-950 p-4 rounded-xl border border-slate-800 space-y-1">
|
||||
<span className="text-xs text-slate-500 font-bold uppercase tracking-wider">Adresse IP</span>
|
||||
<div className="font-mono text-xs text-slate-300">
|
||||
{selectedLog.ip_address || "Inconnue"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-950 p-4 rounded-xl border border-slate-800 space-y-1">
|
||||
<span className="text-xs text-slate-500 font-bold uppercase tracking-wider">Cible & Arme</span>
|
||||
<div className="text-xs text-slate-300">
|
||||
Type: <span className="font-bold text-white">{selectedLog.target_type || "N/A"}</span> | Arme: {selectedLog.weapon || "N/A"} | {selectedLog.distance_meters ? `${selectedLog.distance_meters}m` : ""}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-950 p-4 rounded-xl border border-slate-800 space-y-1">
|
||||
<span className="text-xs text-slate-500 font-bold uppercase tracking-wider">Fichiers & Taille</span>
|
||||
<div className="text-xs text-slate-300">
|
||||
Image: <span className="font-mono">{selectedLog.image_filename || "N/A"}</span> ({formatFileSize(selectedLog.file_size)})
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Raw Metadata JSON */}
|
||||
{selectedLog.raw_metadata && (
|
||||
<div className="space-y-2">
|
||||
<span className="text-xs text-slate-500 font-bold uppercase tracking-wider">Données Brutes (JSON)</span>
|
||||
<pre className="bg-slate-950 border border-slate-800 p-4 rounded-xl text-xs font-mono text-slate-300 overflow-x-auto max-h-48">
|
||||
{(() => {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(selectedLog.raw_metadata), null, 2);
|
||||
} catch {
|
||||
return selectedLog.raw_metadata;
|
||||
}
|
||||
})()}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modal Footer */}
|
||||
<div className="p-4 border-t border-slate-800 bg-slate-950/50 flex justify-between items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedLog.image_filename && (
|
||||
<Link
|
||||
href={`/photo/${selectedLog.image_filename}`}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-xl text-xs font-bold transition-all"
|
||||
>
|
||||
<Eye size={14} />
|
||||
Ouvrir la session
|
||||
</Link>
|
||||
)}
|
||||
{selectedLog.wallet_hash && (
|
||||
isWalletBanned(selectedLog.wallet_hash) ? (
|
||||
<button
|
||||
onClick={() => handleUnbanWallet(selectedLog.wallet_hash!)}
|
||||
className="px-3 py-2 bg-slate-800 hover:bg-emerald-600/30 text-emerald-400 rounded-xl text-xs font-bold transition-all border border-emerald-500/20"
|
||||
>
|
||||
Débannir ce wallet
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedLog(null);
|
||||
setBanModalWallet(selectedLog.wallet_hash);
|
||||
}}
|
||||
className="px-3 py-2 bg-rose-600/10 hover:bg-rose-600 text-rose-400 hover:text-white rounded-xl text-xs font-bold transition-all border border-rose-500/20"
|
||||
>
|
||||
Bannir ce wallet
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setSelectedLog(null)}
|
||||
className="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-xl text-xs font-semibold transition-all"
|
||||
>
|
||||
Fermer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Ban Confirmation Modal */}
|
||||
{banModalWallet && (
|
||||
<div className="fixed inset-0 z-50 bg-black/75 backdrop-blur-sm flex items-center justify-center p-4">
|
||||
<div className="bg-slate-900 border border-slate-800 rounded-2xl w-full max-w-md p-6 space-y-6 shadow-2xl animate-in zoom-in-95 duration-200">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-3 bg-rose-500/10 border border-rose-500/20 rounded-xl text-rose-400">
|
||||
<Ban size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-lg text-slate-100">Bannir un Wallet</h3>
|
||||
<p className="text-xs text-slate-400">Bloquer définitivement tout upload futur de ce compte.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-950 p-3 rounded-xl border border-slate-800 font-mono text-xs text-indigo-300 break-all">
|
||||
{banModalWallet}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold uppercase tracking-wider text-slate-400">
|
||||
Motif du bannissement
|
||||
</label>
|
||||
<select
|
||||
value={banReason}
|
||||
onChange={(e) => setBanReason(e.target.value)}
|
||||
className="w-full bg-slate-950 border border-slate-800 focus:border-rose-500 rounded-xl px-3 py-2 text-sm text-slate-200 outline-none"
|
||||
>
|
||||
<option value="Images non conformes / Fausse cible">Images non conformes / Fausse cible</option>
|
||||
<option value="Spam / Uploads abusifs répétés">Spam / Uploads abusifs répétés</option>
|
||||
<option value="Contenu inapproprié ou illicite">Contenu inapproprié ou illicite</option>
|
||||
<option value="Tentative de manipulation des données">Tentative de manipulation des données</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<button
|
||||
onClick={() => setBanModalWallet(null)}
|
||||
className="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-xl text-xs font-semibold transition-all"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
onClick={handleBanWallet}
|
||||
disabled={isSubmittingBan}
|
||||
className="px-4 py-2 bg-rose-600 hover:bg-rose-500 text-white rounded-xl text-xs font-bold transition-all disabled:opacity-50 flex items-center gap-1.5"
|
||||
>
|
||||
<Ban size={14} />
|
||||
{isSubmittingBan ? "Bannissement..." : "Confirmer le Bannissement"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { fetchApi } from "@/lib/api";
|
||||
import LogsManager from "./LogsManager";
|
||||
|
||||
export const metadata = {
|
||||
title: "Logs d'Uploads | Bully IA Dashboard",
|
||||
description: "Journal complet des téléversements de sessions et métadonnées",
|
||||
};
|
||||
|
||||
export default async function LogsPage() {
|
||||
let logs = [];
|
||||
let stats = null;
|
||||
|
||||
try {
|
||||
const [logsRes, statsRes] = await Promise.all([
|
||||
fetchApi('/api/logs?limit=100'),
|
||||
fetchApi('/api/logs/stats')
|
||||
]);
|
||||
logs = logsRes.logs || [];
|
||||
stats = statsRes.stats || null;
|
||||
} catch (error) {
|
||||
console.error("Erreur chargement initial des logs:", error);
|
||||
}
|
||||
|
||||
return (
|
||||
<LogsManager
|
||||
initialLogs={logs}
|
||||
initialStats={stats}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
{
|
||||
"session_id": "session_1777452202935",
|
||||
"timestamp": "2026-04-29T08:43:22.943303",
|
||||
"device_info": {
|
||||
"model": "google sdk_gphone64_x86_64",
|
||||
"os": "Android 16"
|
||||
},
|
||||
"target_metadata": {
|
||||
"type": "concentric",
|
||||
"distance_meters": 25,
|
||||
"weapon": "Unknown"
|
||||
},
|
||||
"plotting": {
|
||||
"target_corners": [
|
||||
{
|
||||
"norm_x": 0.09999999999999998,
|
||||
"norm_y": 0.09999999999999998
|
||||
},
|
||||
{
|
||||
"norm_x": 0.9,
|
||||
"norm_y": 0.09999999999999998
|
||||
},
|
||||
{
|
||||
"norm_x": 0.9,
|
||||
"norm_y": 0.9
|
||||
},
|
||||
{
|
||||
"norm_x": 0.09999999999999998,
|
||||
"norm_y": 0.9
|
||||
}
|
||||
],
|
||||
"impacts": [
|
||||
{
|
||||
"id": 1,
|
||||
"label": "bullet_hole",
|
||||
"score": 6,
|
||||
"coords": {
|
||||
"norm_x": 0.3934941044560185,
|
||||
"norm_y": 0.665676540798611
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"label": "bullet_hole",
|
||||
"score": 5,
|
||||
"coords": {
|
||||
"norm_x": 0.6055302372685185,
|
||||
"norm_y": 0.6953124999999999
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"label": "bullet_hole",
|
||||
"score": 8,
|
||||
"coords": {
|
||||
"norm_x": 0.5981137876157407,
|
||||
"norm_y": 0.5249565972222221
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"label": "bullet_hole",
|
||||
"score": 6,
|
||||
"coords": {
|
||||
"norm_x": 0.6759033203125,
|
||||
"norm_y": 0.4786376953125
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"label": "bullet_hole",
|
||||
"score": 9,
|
||||
"coords": {
|
||||
"norm_x": 0.48608398437499994,
|
||||
"norm_y": 0.5490315755208333
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"label": "bullet_hole",
|
||||
"score": 9,
|
||||
"coords": {
|
||||
"norm_x": 0.5101625795717593,
|
||||
"norm_y": 0.43238661024305547
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"label": "bullet_hole",
|
||||
"score": 7,
|
||||
"coords": {
|
||||
"norm_x": 0.6296079282407407,
|
||||
"norm_y": 0.4203152126736111
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"label": "bullet_hole",
|
||||
"score": 9,
|
||||
"coords": {
|
||||
"norm_x": 0.46386718749999994,
|
||||
"norm_y": 0.48046875
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"label": "bullet_hole",
|
||||
"score": 6,
|
||||
"coords": {
|
||||
"norm_x": 0.3379521122685185,
|
||||
"norm_y": 0.4397786458333333
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
{
|
||||
"session_id": "session_1777452481252",
|
||||
"timestamp": "2026-04-29T08:48:01.259705",
|
||||
"device_info": {
|
||||
"model": "google sdk_gphone64_x86_64",
|
||||
"os": "Android 16"
|
||||
},
|
||||
"target_metadata": {
|
||||
"type": "concentric",
|
||||
"distance_meters": 25,
|
||||
"weapon": "Unknown"
|
||||
},
|
||||
"plotting": {
|
||||
"target_corners": [
|
||||
{
|
||||
"norm_x": 0.09999999999999998,
|
||||
"norm_y": 0.09999999999999998
|
||||
},
|
||||
{
|
||||
"norm_x": 0.9,
|
||||
"norm_y": 0.09999999999999998
|
||||
},
|
||||
{
|
||||
"norm_x": 0.9,
|
||||
"norm_y": 0.9
|
||||
},
|
||||
{
|
||||
"norm_x": 0.09999999999999998,
|
||||
"norm_y": 0.9
|
||||
}
|
||||
],
|
||||
"impacts": [
|
||||
{
|
||||
"id": 1,
|
||||
"label": "bullet_hole",
|
||||
"score": 6,
|
||||
"coords": {
|
||||
"norm_x": 0.3934941044560185,
|
||||
"norm_y": 0.665676540798611
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"label": "bullet_hole",
|
||||
"score": 5,
|
||||
"coords": {
|
||||
"norm_x": 0.6055302372685185,
|
||||
"norm_y": 0.6953124999999999
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"label": "bullet_hole",
|
||||
"score": 8,
|
||||
"coords": {
|
||||
"norm_x": 0.5981137876157407,
|
||||
"norm_y": 0.5249565972222221
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"label": "bullet_hole",
|
||||
"score": 6,
|
||||
"coords": {
|
||||
"norm_x": 0.6759033203125,
|
||||
"norm_y": 0.4786376953125
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"label": "bullet_hole",
|
||||
"score": 9,
|
||||
"coords": {
|
||||
"norm_x": 0.48608398437499994,
|
||||
"norm_y": 0.5490315755208333
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"label": "bullet_hole",
|
||||
"score": 9,
|
||||
"coords": {
|
||||
"norm_x": 0.5101625795717593,
|
||||
"norm_y": 0.43238661024305547
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"label": "bullet_hole",
|
||||
"score": 7,
|
||||
"coords": {
|
||||
"norm_x": 0.6296079282407407,
|
||||
"norm_y": 0.4203152126736111
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"label": "bullet_hole",
|
||||
"score": 9,
|
||||
"coords": {
|
||||
"norm_x": 0.46386718749999994,
|
||||
"norm_y": 0.48046875
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"label": "bullet_hole",
|
||||
"score": 6,
|
||||
"coords": {
|
||||
"norm_x": 0.3379521122685185,
|
||||
"norm_y": 0.4397786458333333
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
{
|
||||
"session_id": "session_1777452613625",
|
||||
"timestamp": "2026-04-29T08:50:13.631206",
|
||||
"device_info": {
|
||||
"model": "google sdk_gphone64_x86_64",
|
||||
"os": "Android 16"
|
||||
},
|
||||
"target_metadata": {
|
||||
"type": "concentric",
|
||||
"distance_meters": 25,
|
||||
"weapon": "Unknown"
|
||||
},
|
||||
"plotting": {
|
||||
"target_corners": [
|
||||
{
|
||||
"norm_x": 0.09999999999999998,
|
||||
"norm_y": 0.09999999999999998
|
||||
},
|
||||
{
|
||||
"norm_x": 0.9,
|
||||
"norm_y": 0.09999999999999998
|
||||
},
|
||||
{
|
||||
"norm_x": 0.9,
|
||||
"norm_y": 0.9
|
||||
},
|
||||
{
|
||||
"norm_x": 0.09999999999999998,
|
||||
"norm_y": 0.9
|
||||
}
|
||||
],
|
||||
"impacts": [
|
||||
{
|
||||
"id": 1,
|
||||
"label": "bullet_hole",
|
||||
"score": 10,
|
||||
"coords": {
|
||||
"norm_x": 0.49810836226851846,
|
||||
"norm_y": 0.4981011284722222
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
{
|
||||
"session_id": "session_1777463308809",
|
||||
"wallet_hash": "cc60fcaa5ef7fdc9be3dd1adba4157fa6dfae2845da45b830b5ef2b7f473d887",
|
||||
"timestamp": "2026-04-29T11:48:28.834236",
|
||||
"device_info": {
|
||||
"model": "google sdk_gphone64_x86_64",
|
||||
"os": "Android 16"
|
||||
},
|
||||
"target_metadata": {
|
||||
"type": "concentric",
|
||||
"distance_meters": 25,
|
||||
"weapon": "Unknown"
|
||||
},
|
||||
"plotting": {
|
||||
"target_corners": [
|
||||
{
|
||||
"norm_x": 0.09999999999999998,
|
||||
"norm_y": 0.09999999999999998
|
||||
},
|
||||
{
|
||||
"norm_x": 0.9,
|
||||
"norm_y": 0.09999999999999998
|
||||
},
|
||||
{
|
||||
"norm_x": 0.9,
|
||||
"norm_y": 0.9
|
||||
},
|
||||
{
|
||||
"norm_x": 0.09999999999999998,
|
||||
"norm_y": 0.9
|
||||
}
|
||||
],
|
||||
"impacts": [
|
||||
{
|
||||
"id": 1,
|
||||
"label": "bullet_hole",
|
||||
"score": 6,
|
||||
"coords": {
|
||||
"norm_x": 0.38424660011574074,
|
||||
"norm_y": 0.3666042751736111
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"label": "bullet_hole",
|
||||
"score": 7,
|
||||
"coords": {
|
||||
"norm_x": 0.6055302372685185,
|
||||
"norm_y": 0.396240234375
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"label": "bullet_hole",
|
||||
"score": 7,
|
||||
"coords": {
|
||||
"norm_x": 0.5860903139467593,
|
||||
"norm_y": 0.5999620225694443
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"label": "bullet_hole",
|
||||
"score": 6,
|
||||
"coords": {
|
||||
"norm_x": 0.33517433449074074,
|
||||
"norm_y": 0.5592041015625
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"label": "bullet_hole",
|
||||
"score": 8,
|
||||
"coords": {
|
||||
"norm_x": 0.6073612919560185,
|
||||
"norm_y": 0.5101047092013888
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"label": "bullet_hole",
|
||||
"score": 9,
|
||||
"coords": {
|
||||
"norm_x": 0.432373046875,
|
||||
"norm_y": 0.46663411458333337
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"label": "bullet_hole",
|
||||
"score": 9,
|
||||
"coords": {
|
||||
"norm_x": 0.45181297019675926,
|
||||
"norm_y": 0.5564236111111112
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"label": "bullet_hole",
|
||||
"score": 10,
|
||||
"coords": {
|
||||
"norm_x": 0.5221860532407407,
|
||||
"norm_y": 0.48792860243055547
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"label": "bullet_hole",
|
||||
"score": 3,
|
||||
"coords": {
|
||||
"norm_x": 0.6564335575810184,
|
||||
"norm_y": 0.7536349826388888
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"label": "bullet_hole",
|
||||
"score": 5,
|
||||
"coords": {
|
||||
"norm_x": 0.4712528935185185,
|
||||
"norm_y": 0.7018229166666666
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"label": "bullet_hole",
|
||||
"score": 5,
|
||||
"coords": {
|
||||
"norm_x": 0.3981327763310185,
|
||||
"norm_y": 0.7018229166666666
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"label": "bullet_hole",
|
||||
"score": 2,
|
||||
"coords": {
|
||||
"norm_x": 0.48144531249999994,
|
||||
"norm_y": 0.8212483723958334
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
{
|
||||
"session_id": "session_1777463703756",
|
||||
"wallet_hash": "cc60fcaa5ef7fdc9be3dd1adba4157fa6dfae2845da45b830b5ef2b7f473d887",
|
||||
"timestamp": "2026-04-29T11:55:03.765285",
|
||||
"device_info": {
|
||||
"model": "google sdk_gphone64_x86_64",
|
||||
"os": "Android 16"
|
||||
},
|
||||
"target_metadata": {
|
||||
"type": "concentric",
|
||||
"distance_meters": 25,
|
||||
"weapon": "Unknown"
|
||||
},
|
||||
"plotting": {
|
||||
"target_corners": [
|
||||
{
|
||||
"norm_x": 0.09999999999999998,
|
||||
"norm_y": 0.09999999999999998
|
||||
},
|
||||
{
|
||||
"norm_x": 0.9,
|
||||
"norm_y": 0.09999999999999998
|
||||
},
|
||||
{
|
||||
"norm_x": 0.9,
|
||||
"norm_y": 0.9
|
||||
},
|
||||
{
|
||||
"norm_x": 0.09999999999999998,
|
||||
"norm_y": 0.9
|
||||
}
|
||||
],
|
||||
"impacts": [
|
||||
{
|
||||
"id": 1,
|
||||
"label": "bullet_hole",
|
||||
"score": 7,
|
||||
"coords": {
|
||||
"norm_x": 0.38424660011574074,
|
||||
"norm_y": 0.45178222656249994
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"label": "bullet_hole",
|
||||
"score": 5,
|
||||
"coords": {
|
||||
"norm_x": 0.6518247251157407,
|
||||
"norm_y": 0.3638237847222222
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"label": "bullet_hole",
|
||||
"score": 6,
|
||||
"coords": {
|
||||
"norm_x": 0.5490415219907407,
|
||||
"norm_y": 0.66845703125
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"label": "bullet_hole",
|
||||
"score": 6,
|
||||
"coords": {
|
||||
"norm_x": 0.405517578125,
|
||||
"norm_y": 0.6758490668402777
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"label": "bullet_hole",
|
||||
"score": 3,
|
||||
"coords": {
|
||||
"norm_x": 0.7268066406249999,
|
||||
"norm_y": 0.6758490668402777
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"label": "bullet_hole",
|
||||
"score": 9,
|
||||
"coords": {
|
||||
"norm_x": 0.5471806278935185,
|
||||
"norm_y": 0.529568142361111
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"label": "bullet_hole",
|
||||
"score": 8,
|
||||
"coords": {
|
||||
"norm_x": 0.44720504195601846,
|
||||
"norm_y": 0.5980631510416666
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"label": "bullet_hole",
|
||||
"score": 8,
|
||||
"coords": {
|
||||
"norm_x": 0.45458984375,
|
||||
"norm_y": 0.4221462673611111
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"label": "bullet_hole",
|
||||
"score": 7,
|
||||
"coords": {
|
||||
"norm_x": 0.5203251591435185,
|
||||
"norm_y": 0.3638237847222222
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
{
|
||||
"session_id": "session_1777464156234",
|
||||
"wallet_hash": "cc60fcaa5ef7fdc9be3dd1adba4157fa6dfae2845da45b830b5ef2b7f473d887",
|
||||
"timestamp": "2026-04-29T12:02:36.255849",
|
||||
"device_info": {
|
||||
"model": "google sdk_gphone64_x86_64",
|
||||
"os": "Android 16"
|
||||
},
|
||||
"target_metadata": {
|
||||
"type": "concentric",
|
||||
"distance_meters": 25,
|
||||
"weapon": "Unknown"
|
||||
},
|
||||
"plotting": {
|
||||
"target_corners": [
|
||||
{
|
||||
"norm_x": 0.09999999999999998,
|
||||
"norm_y": 0.09999999999999998
|
||||
},
|
||||
{
|
||||
"norm_x": 0.9,
|
||||
"norm_y": 0.09999999999999998
|
||||
},
|
||||
{
|
||||
"norm_x": 0.9,
|
||||
"norm_y": 0.9
|
||||
},
|
||||
{
|
||||
"norm_x": 0.09999999999999998,
|
||||
"norm_y": 0.9
|
||||
}
|
||||
],
|
||||
"impacts": [
|
||||
{
|
||||
"id": 1,
|
||||
"label": "bullet_hole",
|
||||
"score": 7,
|
||||
"coords": {
|
||||
"norm_x": 0.38424660011574074,
|
||||
"norm_y": 0.45178222656249994
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"label": "bullet_hole",
|
||||
"score": 5,
|
||||
"coords": {
|
||||
"norm_x": 0.6518247251157407,
|
||||
"norm_y": 0.3638237847222222
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"label": "bullet_hole",
|
||||
"score": 6,
|
||||
"coords": {
|
||||
"norm_x": 0.5490415219907407,
|
||||
"norm_y": 0.66845703125
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"label": "bullet_hole",
|
||||
"score": 6,
|
||||
"coords": {
|
||||
"norm_x": 0.405517578125,
|
||||
"norm_y": 0.6758490668402777
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"label": "bullet_hole",
|
||||
"score": 3,
|
||||
"coords": {
|
||||
"norm_x": 0.7268066406249999,
|
||||
"norm_y": 0.6758490668402777
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"label": "bullet_hole",
|
||||
"score": 9,
|
||||
"coords": {
|
||||
"norm_x": 0.5471806278935185,
|
||||
"norm_y": 0.529568142361111
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"label": "bullet_hole",
|
||||
"score": 8,
|
||||
"coords": {
|
||||
"norm_x": 0.44720504195601846,
|
||||
"norm_y": 0.5980631510416666
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"label": "bullet_hole",
|
||||
"score": 8,
|
||||
"coords": {
|
||||
"norm_x": 0.45458984375,
|
||||
"norm_y": 0.4221462673611111
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"label": "bullet_hole",
|
||||
"score": 7,
|
||||
"coords": {
|
||||
"norm_x": 0.5203251591435185,
|
||||
"norm_y": 0.3638237847222222
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
{
|
||||
"session_id": "session_1777464607256",
|
||||
"wallet_hash": "cc60fcaa5ef7fdc9be3dd1adba4157fa6dfae2845da45b830b5ef2b7f473d887",
|
||||
"timestamp": "2026-04-29T12:10:07.260954",
|
||||
"device_info": {
|
||||
"model": "google sdk_gphone64_x86_64",
|
||||
"os": "Android 16"
|
||||
},
|
||||
"target_metadata": {
|
||||
"type": "concentric",
|
||||
"distance_meters": 25,
|
||||
"weapon": "Unknown"
|
||||
},
|
||||
"plotting": {
|
||||
"target_corners": [
|
||||
{
|
||||
"norm_x": 0.09999999999999998,
|
||||
"norm_y": 0.09999999999999998
|
||||
},
|
||||
{
|
||||
"norm_x": 0.9,
|
||||
"norm_y": 0.09999999999999998
|
||||
},
|
||||
{
|
||||
"norm_x": 0.9,
|
||||
"norm_y": 0.9
|
||||
},
|
||||
{
|
||||
"norm_x": 0.09999999999999998,
|
||||
"norm_y": 0.9
|
||||
}
|
||||
],
|
||||
"impacts": [
|
||||
{
|
||||
"id": 1,
|
||||
"label": "bullet_hole",
|
||||
"score": 3,
|
||||
"coords": {
|
||||
"norm_x": 0.31570457175925926,
|
||||
"norm_y": 0.7388509114583333
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"label": "bullet_hole",
|
||||
"score": 5,
|
||||
"coords": {
|
||||
"norm_x": 0.3184814453125,
|
||||
"norm_y": 0.6489935980902778
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"label": "bullet_hole",
|
||||
"score": 6,
|
||||
"coords": {
|
||||
"norm_x": 0.3110966435185185,
|
||||
"norm_y": 0.529568142361111
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"label": "bullet_hole",
|
||||
"score": 6,
|
||||
"coords": {
|
||||
"norm_x": 0.31570457175925926,
|
||||
"norm_y": 0.4416097005208333
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"label": "bullet_hole",
|
||||
"score": 4,
|
||||
"coords": {
|
||||
"norm_x": 0.31570457175925926,
|
||||
"norm_y": 0.34252929687499994
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"label": "bullet_hole",
|
||||
"score": 3,
|
||||
"coords": {
|
||||
"norm_x": 0.32589699074074074,
|
||||
"norm_y": 0.2332763671875
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"label": "bullet_hole",
|
||||
"score": 3,
|
||||
"coords": {
|
||||
"norm_x": 0.45458984375,
|
||||
"norm_y": 0.19624837239583331
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"label": "bullet_hole",
|
||||
"score": 3,
|
||||
"coords": {
|
||||
"norm_x": 0.5981137876157407,
|
||||
"norm_y": 0.23049587673611108
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"label": "bullet_hole",
|
||||
"score": 5,
|
||||
"coords": {
|
||||
"norm_x": 0.6907045717592593,
|
||||
"norm_y": 0.3546006944444444
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"label": "bullet_hole",
|
||||
"score": 5,
|
||||
"coords": {
|
||||
"norm_x": 0.699951171875,
|
||||
"norm_y": 0.48046875
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"label": "bullet_hole",
|
||||
"score": 7,
|
||||
"coords": {
|
||||
"norm_x": 0.6268310546874999,
|
||||
"norm_y": 0.5416395399305556
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"label": "bullet_hole",
|
||||
"score": 9,
|
||||
"coords": {
|
||||
"norm_x": 0.49072265624999994,
|
||||
"norm_y": 0.5610351562499999
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"label": "bullet_hole",
|
||||
"score": 8,
|
||||
"coords": {
|
||||
"norm_x": 0.4203495732060185,
|
||||
"norm_y": 0.536960177951389
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 14,
|
||||
"label": "bullet_hole",
|
||||
"score": 6,
|
||||
"coords": {
|
||||
"norm_x": 0.5370180483217593,
|
||||
"norm_y": 0.6879204644097222
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"label": "bullet_hole",
|
||||
"score": 3,
|
||||
"coords": {
|
||||
"norm_x": 0.6175537109375,
|
||||
"norm_y": 0.7601453993055556
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 16,
|
||||
"label": "bullet_hole",
|
||||
"score": 1,
|
||||
"coords": {
|
||||
"norm_x": 0.7027280454282406,
|
||||
"norm_y": 0.8406439887152777
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 110 KiB |
|
Before Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 107 KiB |
@@ -1 +0,0 @@
|
||||
# Keep directory structure
|
||||
@@ -1,270 +0,0 @@
|
||||
{
|
||||
"session_id": "session_1777553535029",
|
||||
"wallet_hash": "cc60fcaa5ef7fdc9be3dd1adba4157fa6dfae2845da45b830b5ef2b7f473d887",
|
||||
"timestamp": "2026-04-30T12:52:15.048830",
|
||||
"device_info": {
|
||||
"model": "google sdk_gphone64_x86_64",
|
||||
"os": "Android 16"
|
||||
},
|
||||
"target_metadata": {
|
||||
"type": "concentric",
|
||||
"distance_meters": 25,
|
||||
"weapon": "Unknown"
|
||||
},
|
||||
"plotting": {
|
||||
"target_corners": [
|
||||
{
|
||||
"norm_x": 0.09999999999999998,
|
||||
"norm_y": 0.09999999999999998
|
||||
},
|
||||
{
|
||||
"norm_x": 0.9,
|
||||
"norm_y": 0.09999999999999998
|
||||
},
|
||||
{
|
||||
"norm_x": 0.9,
|
||||
"norm_y": 0.9
|
||||
},
|
||||
{
|
||||
"norm_x": 0.09999999999999998,
|
||||
"norm_y": 0.9
|
||||
}
|
||||
],
|
||||
"impacts": [
|
||||
{
|
||||
"id": 1,
|
||||
"label": "bullet_hole",
|
||||
"score": 0,
|
||||
"coords": {
|
||||
"norm_x": 0.17681929976851848,
|
||||
"norm_y": 0.8851318359375
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"label": "bullet_hole",
|
||||
"score": 0,
|
||||
"coords": {
|
||||
"norm_x": 0.17681929976851848,
|
||||
"norm_y": 0.7721489800347222
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"label": "bullet_hole",
|
||||
"score": 1,
|
||||
"coords": {
|
||||
"norm_x": 0.17959617332175923,
|
||||
"norm_y": 0.6888020833333334
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"label": "bullet_hole",
|
||||
"score": 2,
|
||||
"coords": {
|
||||
"norm_x": 0.17959617332175923,
|
||||
"norm_y": 0.6342095269097222
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"label": "bullet_hole",
|
||||
"score": 2,
|
||||
"coords": {
|
||||
"norm_x": 0.17959617332175923,
|
||||
"norm_y": 0.560153537326389
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"label": "bullet_hole",
|
||||
"score": 2,
|
||||
"coords": {
|
||||
"norm_x": 0.17959617332175923,
|
||||
"norm_y": 0.5138346354166666
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"label": "bullet_hole",
|
||||
"score": 2,
|
||||
"coords": {
|
||||
"norm_x": 0.1814579716435185,
|
||||
"norm_y": 0.45273166232638884
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"label": "bullet_hole",
|
||||
"score": 2,
|
||||
"coords": {
|
||||
"norm_x": 0.18701171875,
|
||||
"norm_y": 0.3878987630208333
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"label": "bullet_hole",
|
||||
"score": 1,
|
||||
"coords": {
|
||||
"norm_x": 0.18887351707175926,
|
||||
"norm_y": 0.2925482855902778
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"label": "bullet_hole",
|
||||
"score": 0,
|
||||
"coords": {
|
||||
"norm_x": 0.2036747685185185,
|
||||
"norm_y": 0.20085991753472218
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"label": "bullet_hole",
|
||||
"score": 0,
|
||||
"coords": {
|
||||
"norm_x": 0.27035499855324074,
|
||||
"norm_y": 0.16200086805555558
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"label": "bullet_hole",
|
||||
"score": 1,
|
||||
"coords": {
|
||||
"norm_x": 0.37588523582175926,
|
||||
"norm_y": 0.1546088324652778
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"label": "bullet_hole",
|
||||
"score": 2,
|
||||
"coords": {
|
||||
"norm_x": 0.46292136863425926,
|
||||
"norm_y": 0.1518283420138889
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 14,
|
||||
"label": "bullet_hole",
|
||||
"score": 1,
|
||||
"coords": {
|
||||
"norm_x": 0.6814579716435185,
|
||||
"norm_y": 0.1499294704861111
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"label": "bullet_hole",
|
||||
"score": 2,
|
||||
"coords": {
|
||||
"norm_x": 0.5509033203125,
|
||||
"norm_y": 0.1546088324652778
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 16,
|
||||
"label": "bullet_hole",
|
||||
"score": 1,
|
||||
"coords": {
|
||||
"norm_x": 0.6156919126157407,
|
||||
"norm_y": 0.1546088324652778
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 17,
|
||||
"label": "bullet_hole",
|
||||
"score": 1,
|
||||
"coords": {
|
||||
"norm_x": 0.7703251591435185,
|
||||
"norm_y": 0.2360568576388889
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 18,
|
||||
"label": "bullet_hole",
|
||||
"score": 4,
|
||||
"coords": {
|
||||
"norm_x": 0.7110595703125,
|
||||
"norm_y": 0.3536512586805555
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 19,
|
||||
"label": "bullet_hole",
|
||||
"score": 7,
|
||||
"coords": {
|
||||
"norm_x": 0.6027524594907407,
|
||||
"norm_y": 0.4129231770833333
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 20,
|
||||
"label": "bullet_hole",
|
||||
"score": 9,
|
||||
"coords": {
|
||||
"norm_x": 0.4823911313657407,
|
||||
"norm_y": 0.4304877387152777
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 21,
|
||||
"label": "bullet_hole",
|
||||
"score": 7,
|
||||
"coords": {
|
||||
"norm_x": 0.39627097800925926,
|
||||
"norm_y": 0.43509928385416663
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 22,
|
||||
"label": "bullet_hole",
|
||||
"score": 5,
|
||||
"coords": {
|
||||
"norm_x": 0.289794921875,
|
||||
"norm_y": 0.43238661024305547
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 23,
|
||||
"label": "bullet_hole",
|
||||
"score": 2,
|
||||
"coords": {
|
||||
"norm_x": 0.7166440610532407,
|
||||
"norm_y": 0.7693684895833333
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 24,
|
||||
"label": "bullet_hole",
|
||||
"score": 3,
|
||||
"coords": {
|
||||
"norm_x": 0.7314453125,
|
||||
"norm_y": 0.7008734809027778
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 25,
|
||||
"label": "bullet_hole",
|
||||
"score": 1,
|
||||
"coords": {
|
||||
"norm_x": 0.7879331235532407,
|
||||
"norm_y": 0.7601453993055556
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 26,
|
||||
"label": "bullet_hole",
|
||||
"score": 0,
|
||||
"coords": {
|
||||
"norm_x": 0.736083984375,
|
||||
"norm_y": 0.828640407986111
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
# Keep directory structure
|
||||
|
Before Width: | Height: | Size: 105 KiB |
@@ -9,14 +9,526 @@
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@techstark/opencv-js": "^5.0.0-release.1",
|
||||
"adm-zip": "^0.5.17",
|
||||
"cors": "^2.8.6",
|
||||
"dotenv": "^17.4.2",
|
||||
"express": "^5.2.1",
|
||||
"multer": "^2.1.1",
|
||||
"sharp": "^0.35.3",
|
||||
"sqlite3": "^6.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.11.3",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
|
||||
"integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/colour": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
||||
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-darwin-arm64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz",
|
||||
"integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-darwin-arm64": "1.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-darwin-x64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz",
|
||||
"integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-darwin-x64": "1.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-freebsd-wasm32": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz",
|
||||
"integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"dependencies": {
|
||||
"@img/sharp-wasm32": "0.35.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-darwin-arm64": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz",
|
||||
"integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-darwin-x64": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz",
|
||||
"integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-arm": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz",
|
||||
"integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-arm64": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz",
|
||||
"integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-ppc64": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz",
|
||||
"integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-riscv64": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz",
|
||||
"integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-s390x": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz",
|
||||
"integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-x64": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz",
|
||||
"integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz",
|
||||
"integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz",
|
||||
"integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-arm": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz",
|
||||
"integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-arm": "1.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-arm64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz",
|
||||
"integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-arm64": "1.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-ppc64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz",
|
||||
"integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-ppc64": "1.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-riscv64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz",
|
||||
"integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-riscv64": "1.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-s390x": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz",
|
||||
"integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-s390x": "1.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-x64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz",
|
||||
"integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-x64": "1.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linuxmusl-arm64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz",
|
||||
"integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linuxmusl-x64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz",
|
||||
"integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-wasm32": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz",
|
||||
"integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==",
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/runtime": "^1.11.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-webcontainers-wasm32": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz",
|
||||
"integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@img/sharp-wasm32": "0.35.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-arm64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz",
|
||||
"integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-ia32": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz",
|
||||
"integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-x64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz",
|
||||
"integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/fs-minipass": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
|
||||
@@ -29,6 +541,12 @@
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@techstark/opencv-js": {
|
||||
"version": "5.0.0-release.1",
|
||||
"resolved": "https://registry.npmjs.org/@techstark/opencv-js/-/opencv-js-5.0.0-release.1.tgz",
|
||||
"integrity": "sha512-PIm+eB0MFtieXoNC2GRao0dv/02sehG+Nv2nSW5D6pQm6J/4WqvDHm0RyoqOmGYQm67jdGiaOdIeTShCY3PIUg==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/abbrev": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz",
|
||||
@@ -1264,9 +1782,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.7.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
||||
"version": "7.8.5",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
@@ -1326,6 +1844,55 @@
|
||||
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/sharp": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz",
|
||||
"integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@img/colour": "^1.1.0",
|
||||
"detect-libc": "^2.1.2",
|
||||
"semver": "^7.8.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-darwin-arm64": "0.35.3",
|
||||
"@img/sharp-darwin-x64": "0.35.3",
|
||||
"@img/sharp-freebsd-wasm32": "0.35.3",
|
||||
"@img/sharp-libvips-darwin-arm64": "1.3.2",
|
||||
"@img/sharp-libvips-darwin-x64": "1.3.2",
|
||||
"@img/sharp-libvips-linux-arm": "1.3.2",
|
||||
"@img/sharp-libvips-linux-arm64": "1.3.2",
|
||||
"@img/sharp-libvips-linux-ppc64": "1.3.2",
|
||||
"@img/sharp-libvips-linux-riscv64": "1.3.2",
|
||||
"@img/sharp-libvips-linux-s390x": "1.3.2",
|
||||
"@img/sharp-libvips-linux-x64": "1.3.2",
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.3.2",
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.3.2",
|
||||
"@img/sharp-linux-arm": "0.35.3",
|
||||
"@img/sharp-linux-arm64": "0.35.3",
|
||||
"@img/sharp-linux-ppc64": "0.35.3",
|
||||
"@img/sharp-linux-riscv64": "0.35.3",
|
||||
"@img/sharp-linux-s390x": "0.35.3",
|
||||
"@img/sharp-linux-x64": "0.35.3",
|
||||
"@img/sharp-linuxmusl-arm64": "0.35.3",
|
||||
"@img/sharp-linuxmusl-x64": "0.35.3",
|
||||
"@img/sharp-webcontainers-wasm32": "0.35.3",
|
||||
"@img/sharp-win32-arm64": "0.35.3",
|
||||
"@img/sharp-win32-ia32": "0.35.3",
|
||||
"@img/sharp-win32-x64": "0.35.3"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
||||
@@ -1581,6 +2148,13 @@
|
||||
"node": ">=0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/tunnel-agent": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
|
||||
|
||||
@@ -12,11 +12,13 @@
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@techstark/opencv-js": "^5.0.0-release.1",
|
||||
"adm-zip": "^0.5.17",
|
||||
"cors": "^2.8.6",
|
||||
"dotenv": "^17.4.2",
|
||||
"express": "^5.2.1",
|
||||
"multer": "^2.1.1",
|
||||
"sharp": "^0.35.3",
|
||||
"sqlite3": "^6.0.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ const sqlite3 = require('sqlite3').verbose();
|
||||
const AdmZip = require('adm-zip');
|
||||
|
||||
|
||||
const TargetValidator = require('./services/target_validator');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
@@ -39,9 +41,119 @@ const db = new sqlite3.Database(dbPath, (err) => {
|
||||
photo_count INTEGER DEFAULT 0,
|
||||
last_upload DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)`);
|
||||
|
||||
db.run(`CREATE TABLE IF NOT EXISTS banned_wallets (
|
||||
wallet_hash TEXT PRIMARY KEY,
|
||||
reason TEXT,
|
||||
banned_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
banned_by TEXT DEFAULT 'Admin'
|
||||
)`);
|
||||
|
||||
db.run(`CREATE TABLE IF NOT EXISTS upload_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
session_id TEXT,
|
||||
wallet_hash TEXT,
|
||||
image_filename TEXT,
|
||||
json_filename TEXT,
|
||||
file_size INTEGER,
|
||||
ip_address TEXT,
|
||||
user_agent TEXT,
|
||||
device_model TEXT,
|
||||
device_os TEXT,
|
||||
target_type TEXT,
|
||||
weapon TEXT,
|
||||
distance_meters INTEGER,
|
||||
impacts_count INTEGER DEFAULT 0,
|
||||
status TEXT DEFAULT 'SUCCESS',
|
||||
target_valid INTEGER DEFAULT 1,
|
||||
target_status TEXT DEFAULT 'VALID',
|
||||
target_confidence REAL DEFAULT 1.0,
|
||||
target_rings_count INTEGER DEFAULT 0,
|
||||
target_details TEXT,
|
||||
error_message TEXT,
|
||||
raw_metadata TEXT
|
||||
)`);
|
||||
|
||||
// Migration safe des colonnes OpenCV si la table existait déjà
|
||||
const migrations = [
|
||||
"ALTER TABLE upload_logs ADD COLUMN target_valid INTEGER DEFAULT 1",
|
||||
"ALTER TABLE upload_logs ADD COLUMN target_status TEXT DEFAULT 'VALID'",
|
||||
"ALTER TABLE upload_logs ADD COLUMN target_confidence REAL DEFAULT 1.0",
|
||||
"ALTER TABLE upload_logs ADD COLUMN target_rings_count INTEGER DEFAULT 0",
|
||||
"ALTER TABLE upload_logs ADD COLUMN target_details TEXT"
|
||||
];
|
||||
migrations.forEach(sql => db.run(sql, () => {}));
|
||||
|
||||
db.run(`CREATE INDEX IF NOT EXISTS idx_upload_logs_wallet ON upload_logs(wallet_hash)`);
|
||||
db.run(`CREATE INDEX IF NOT EXISTS idx_upload_logs_timestamp ON upload_logs(timestamp)`);
|
||||
}
|
||||
});
|
||||
|
||||
// Helper pour insérer un log d'upload
|
||||
function logUploadEntry({
|
||||
sessionId = null,
|
||||
walletHash = null,
|
||||
imageFilename = null,
|
||||
jsonFilename = null,
|
||||
fileSize = null,
|
||||
ipAddress = null,
|
||||
userAgent = null,
|
||||
deviceModel = null,
|
||||
deviceOs = null,
|
||||
targetType = null,
|
||||
weapon = null,
|
||||
distanceMeters = null,
|
||||
impactsCount = 0,
|
||||
status = 'SUCCESS',
|
||||
targetValid = 1,
|
||||
targetStatus = 'VALID',
|
||||
targetConfidence = 1.0,
|
||||
targetRingsCount = 0,
|
||||
targetDetails = null,
|
||||
errorMessage = null,
|
||||
rawMetadata = null
|
||||
}) {
|
||||
const query = `
|
||||
INSERT INTO upload_logs (
|
||||
session_id, wallet_hash, image_filename, json_filename, file_size,
|
||||
ip_address, user_agent, device_model, device_os, target_type,
|
||||
weapon, distance_meters, impacts_count, status,
|
||||
target_valid, target_status, target_confidence, target_rings_count, target_details,
|
||||
error_message, raw_metadata
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`;
|
||||
db.run(query, [
|
||||
sessionId,
|
||||
walletHash,
|
||||
imageFilename,
|
||||
jsonFilename,
|
||||
fileSize,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
deviceModel,
|
||||
deviceOs,
|
||||
targetType,
|
||||
weapon,
|
||||
distanceMeters,
|
||||
impactsCount,
|
||||
status,
|
||||
targetValid ? 1 : 0,
|
||||
targetStatus,
|
||||
targetConfidence,
|
||||
targetRingsCount,
|
||||
targetDetails,
|
||||
errorMessage,
|
||||
rawMetadata ? JSON.stringify(rawMetadata) : null
|
||||
], function(err) {
|
||||
if (err) {
|
||||
console.error("Erreur lors de l'insertion dans upload_logs:", err.message);
|
||||
} else {
|
||||
console.log(`[LOG] Upload enregistré (ID: ${this.lastID}) - Statut: ${status} - OpenCV: ${targetStatus}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Configuration de multer pour le stockage des fichiers
|
||||
const storage = multer.diskStorage({
|
||||
destination: function (req, file, cb) {
|
||||
@@ -81,10 +193,22 @@ app.get('/api/health', (req, res) => {
|
||||
|
||||
// Route pour l'upload de photo + données JSON
|
||||
// Attend un form-data avec un champ nommé 'photo' et un champ texte 'plotting'
|
||||
app.post('/api/upload', upload.single('photo'), (req, res) => {
|
||||
app.post('/api/upload', upload.single('photo'), async (req, res) => {
|
||||
const ipAddress = req.headers['x-forwarded-for'] || req.socket.remoteAddress || req.ip || '';
|
||||
const userAgent = req.headers['user-agent'] || '';
|
||||
|
||||
try {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ error: 'Aucune photo fournie' });
|
||||
logUploadEntry({
|
||||
ipAddress,
|
||||
userAgent,
|
||||
status: 'FAILED',
|
||||
errorMessage: 'Aucune photo fournie'
|
||||
});
|
||||
return res.status(400).json({
|
||||
code: 'MISSING_PHOTO',
|
||||
error: 'Aucune photo fournie dans la requête'
|
||||
});
|
||||
}
|
||||
|
||||
let plottingData = {};
|
||||
@@ -93,10 +217,75 @@ app.post('/api/upload', upload.single('photo'), (req, res) => {
|
||||
plottingData = JSON.parse(req.body.plotting);
|
||||
} catch (e) {
|
||||
console.error("Erreur parsing JSON:", e);
|
||||
return res.status(400).json({ error: 'Le champ plotting doit être un JSON valide' });
|
||||
logUploadEntry({
|
||||
imageFilename: req.file.filename,
|
||||
fileSize: req.file.size,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
status: 'FAILED',
|
||||
errorMessage: 'Le champ plotting doit être un JSON valide'
|
||||
});
|
||||
return res.status(400).json({
|
||||
code: 'INVALID_PLOTTING_JSON',
|
||||
error: 'Le champ plotting contient un format JSON invalide'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const walletHash = plottingData.wallet_hash || null;
|
||||
const sessionId = plottingData.session_id || null;
|
||||
|
||||
// 1. Vérification du bannissement de wallet
|
||||
if (walletHash) {
|
||||
const bannedEntry = await new Promise((resolve) => {
|
||||
db.get('SELECT * FROM banned_wallets WHERE wallet_hash = ?', [walletHash], (err, row) => {
|
||||
resolve(row || null);
|
||||
});
|
||||
});
|
||||
|
||||
if (bannedEntry) {
|
||||
// Supprimer le fichier image temporaire pour ne pas consommer d'espace
|
||||
if (req.file.path && fs.existsSync(req.file.path)) {
|
||||
try { fs.unlinkSync(req.file.path); } catch (e) {}
|
||||
}
|
||||
const banReason = bannedEntry.reason || 'Non-respect des règles de contribution / Image non conforme';
|
||||
logUploadEntry({
|
||||
sessionId,
|
||||
walletHash,
|
||||
imageFilename: req.file.filename,
|
||||
fileSize: req.file.size,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
status: 'FAILED',
|
||||
errorMessage: `Upload bloqué : wallet banni (${banReason})`
|
||||
});
|
||||
console.warn(`[MODÉRATION] Upload rejeté pour wallet banni: ${walletHash}`);
|
||||
return res.status(403).json({
|
||||
code: 'WALLET_BANNED',
|
||||
error: 'Votre participation au programme d\'entraînement IA a été suspendue par la modération.',
|
||||
reason: banReason,
|
||||
banned_at: bannedEntry.banned_at
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Validation de la cible par OpenCV
|
||||
let validation = {
|
||||
isValid: true,
|
||||
status: 'VALID',
|
||||
confidence: 1.0,
|
||||
ringsCount: 0,
|
||||
bestCenter: null,
|
||||
details: 'Analyse non effectuée'
|
||||
};
|
||||
|
||||
try {
|
||||
validation = await TargetValidator.validateTarget(req.file.path);
|
||||
console.log(`[OPENCV] Diagnostic cible pour ${req.file.filename}: ${validation.status} (${Math.round(validation.confidence * 100)}% conf, ${validation.ringsCount} anneaux)`);
|
||||
} catch (cvErr) {
|
||||
console.error("[OPENCV] Erreur analyse cible:", cvErr);
|
||||
}
|
||||
|
||||
// Nom de base sans l'extension
|
||||
const baseFilename = path.parse(req.file.filename).name;
|
||||
const jsonFilename = `${baseFilename}.json`;
|
||||
@@ -105,8 +294,13 @@ app.post('/api/upload', upload.single('photo'), (req, res) => {
|
||||
// Sauvegarde du JSON dans uploads/data/
|
||||
fs.writeFileSync(jsonFilePath, JSON.stringify(plottingData, null, 2));
|
||||
|
||||
// Extraction des métadonnées
|
||||
const deviceInfo = plottingData.device_info || {};
|
||||
const targetMeta = plottingData.target_metadata || {};
|
||||
const impacts = plottingData.plotting?.impacts || [];
|
||||
const impactsCount = Array.isArray(impacts) ? impacts.length : 0;
|
||||
|
||||
// Mise à jour de la BDD si on a un wallet_hash
|
||||
const walletHash = plottingData.wallet_hash;
|
||||
if (walletHash) {
|
||||
db.run(`
|
||||
INSERT INTO user_stats (wallet_hash, photo_count, last_upload)
|
||||
@@ -123,11 +317,36 @@ app.post('/api/upload', upload.single('photo'), (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
// Enregistrement dans la table des logs d'upload avec diagnostic OpenCV
|
||||
logUploadEntry({
|
||||
sessionId,
|
||||
walletHash,
|
||||
imageFilename: req.file.filename,
|
||||
jsonFilename,
|
||||
fileSize: req.file.size,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
deviceModel: deviceInfo.model || null,
|
||||
deviceOs: deviceInfo.os || null,
|
||||
targetType: targetMeta.type || null,
|
||||
weapon: targetMeta.weapon || null,
|
||||
distanceMeters: targetMeta.distance_meters || null,
|
||||
impactsCount,
|
||||
status: 'SUCCESS',
|
||||
targetValid: validation.isValid ? 1 : 0,
|
||||
targetStatus: validation.status,
|
||||
targetConfidence: validation.confidence,
|
||||
targetRingsCount: validation.ringsCount,
|
||||
targetDetails: validation.details,
|
||||
rawMetadata: plottingData
|
||||
});
|
||||
|
||||
console.log(`Données reçues et sauvegardées:`);
|
||||
console.log(`- Image: uploads/images/${req.file.filename}`);
|
||||
console.log(`- JSON : uploads/data/${jsonFilename}`);
|
||||
|
||||
res.status(200).json({
|
||||
code: 'UPLOAD_SUCCESS',
|
||||
message: 'Photo et données uploadées avec succès',
|
||||
file: {
|
||||
filename: req.file.filename,
|
||||
@@ -136,14 +355,254 @@ app.post('/api/upload', upload.single('photo'), (req, res) => {
|
||||
},
|
||||
data: {
|
||||
filename: jsonFilename
|
||||
}
|
||||
},
|
||||
target_validation: validation
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de l\'upload:', error);
|
||||
res.status(500).json({ error: 'Erreur interne du serveur lors de l\'upload' });
|
||||
logUploadEntry({
|
||||
imageFilename: req.file ? req.file.filename : null,
|
||||
fileSize: req.file ? req.file.size : null,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
status: 'FAILED',
|
||||
errorMessage: error.message || 'Erreur interne lors de l\'upload'
|
||||
});
|
||||
res.status(500).json({
|
||||
code: 'SERVER_ERROR',
|
||||
error: error.message || 'Erreur interne du serveur lors de l\'upload'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Routes de Modération : Gestion des bannissements de wallets
|
||||
app.get('/api/moderation/banned', (req, res) => {
|
||||
db.all('SELECT * FROM banned_wallets ORDER BY banned_at DESC', [], (err, rows) => {
|
||||
if (err) {
|
||||
console.error("Erreur lecture wallets bannis:", err);
|
||||
return res.status(500).json({ error: 'Erreur lecture wallets bannis' });
|
||||
}
|
||||
res.json({ banned: rows || [] });
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/moderation/ban', (req, res) => {
|
||||
const { wallet_hash, reason, banned_by } = req.body;
|
||||
if (!wallet_hash) {
|
||||
return res.status(400).json({ error: 'wallet_hash obligatoire' });
|
||||
}
|
||||
|
||||
db.run(
|
||||
`INSERT INTO banned_wallets (wallet_hash, reason, banned_by, banned_at)
|
||||
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(wallet_hash) DO UPDATE SET
|
||||
reason = excluded.reason,
|
||||
banned_at = CURRENT_TIMESTAMP`,
|
||||
[wallet_hash, reason || 'Contenu invalide ou non conforme aux règles', banned_by || 'Admin'],
|
||||
function(err) {
|
||||
if (err) {
|
||||
console.error("Erreur bannissement:", err);
|
||||
return res.status(500).json({ error: 'Erreur lors du bannissement du wallet' });
|
||||
}
|
||||
console.log(`[MODÉRATION] Wallet ${wallet_hash} banni (Motif: ${reason})`);
|
||||
res.json({ message: 'Wallet banni avec succès', wallet_hash });
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
app.delete('/api/moderation/ban/:wallet_hash', (req, res) => {
|
||||
const wallet_hash = req.params.wallet_hash;
|
||||
db.run('DELETE FROM banned_wallets WHERE wallet_hash = ?', [wallet_hash], function(err) {
|
||||
if (err) {
|
||||
console.error("Erreur débannissement:", err);
|
||||
return res.status(500).json({ error: 'Erreur lors du débannissement' });
|
||||
}
|
||||
console.log(`[MODÉRATION] Wallet ${wallet_hash} débanni`);
|
||||
res.json({ message: 'Wallet débanni avec succès', wallet_hash });
|
||||
});
|
||||
});
|
||||
|
||||
// Route pour relancer l'analyse OpenCV sur une photo déjà existante
|
||||
app.post('/api/moderation/verify/:filename', async (req, res) => {
|
||||
const filename = req.params.filename;
|
||||
const imgPath = path.join(imagesDir, filename);
|
||||
if (!fs.existsSync(imgPath)) {
|
||||
return res.status(404).json({ error: 'Image non trouvée' });
|
||||
}
|
||||
try {
|
||||
const result = await TargetValidator.validateTarget(imgPath);
|
||||
res.json({ filename, validation: result });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Route pour lister les logs avec filtres, recherche et pagination
|
||||
app.get('/api/logs', (req, res) => {
|
||||
const limit = Math.min(Math.max(parseInt(req.query.limit) || 50, 1), 200);
|
||||
const offset = Math.max(parseInt(req.query.offset) || 0, 0);
|
||||
const wallet = req.query.wallet || req.query.wallet_hash;
|
||||
const status = req.query.status;
|
||||
const search = req.query.search;
|
||||
|
||||
let whereClauses = [];
|
||||
let params = [];
|
||||
|
||||
if (wallet) {
|
||||
whereClauses.push("wallet_hash LIKE ?");
|
||||
params.push(`%${wallet}%`);
|
||||
}
|
||||
if (status) {
|
||||
whereClauses.push("status = ?");
|
||||
params.push(status);
|
||||
}
|
||||
if (search) {
|
||||
whereClauses.push("(session_id LIKE ? OR wallet_hash LIKE ? OR device_model LIKE ? OR weapon LIKE ? OR target_type LIKE ? OR image_filename LIKE ?)");
|
||||
const s = `%${search}%`;
|
||||
params.push(s, s, s, s, s, s);
|
||||
}
|
||||
|
||||
const whereSql = whereClauses.length > 0 ? `WHERE ${whereClauses.join(' AND ')}` : '';
|
||||
|
||||
db.get(`SELECT COUNT(*) as total FROM upload_logs ${whereSql}`, params, (err, countRow) => {
|
||||
if (err) {
|
||||
console.error("Erreur comptage logs:", err);
|
||||
return res.status(500).json({ error: 'Erreur lors du comptage des logs' });
|
||||
}
|
||||
|
||||
const total = countRow ? countRow.total : 0;
|
||||
const query = `
|
||||
SELECT * FROM upload_logs
|
||||
${whereSql}
|
||||
ORDER BY timestamp DESC, id DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`;
|
||||
|
||||
db.all(query, [...params, limit, offset], (err, rows) => {
|
||||
if (err) {
|
||||
console.error("Erreur lecture logs:", err);
|
||||
return res.status(500).json({ error: 'Erreur lors de la lecture des logs' });
|
||||
}
|
||||
res.json({
|
||||
logs: rows,
|
||||
total,
|
||||
limit,
|
||||
offset
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Route pour les statistiques des logs
|
||||
app.get('/api/logs/stats', (req, res) => {
|
||||
const statsQuery = `
|
||||
SELECT
|
||||
COUNT(*) as total_uploads,
|
||||
SUM(CASE WHEN status = 'SUCCESS' THEN 1 ELSE 0 END) as success_count,
|
||||
SUM(CASE WHEN status = 'FAILED' THEN 1 ELSE 0 END) as failed_count,
|
||||
SUM(CASE WHEN date(timestamp, 'localtime') = date('now', 'localtime') THEN 1 ELSE 0 END) as today_uploads,
|
||||
COUNT(DISTINCT wallet_hash) as unique_wallets,
|
||||
SUM(COALESCE(file_size, 0)) as total_bytes,
|
||||
MAX(timestamp) as latest_upload
|
||||
FROM upload_logs
|
||||
`;
|
||||
|
||||
db.get(statsQuery, [], (err, stats) => {
|
||||
if (err) {
|
||||
console.error("Erreur stats logs:", err);
|
||||
return res.status(500).json({ error: 'Erreur calcul statistiques' });
|
||||
}
|
||||
res.json({
|
||||
status: 'ok',
|
||||
stats: stats || {
|
||||
total_uploads: 0,
|
||||
success_count: 0,
|
||||
failed_count: 0,
|
||||
today_uploads: 0,
|
||||
unique_wallets: 0,
|
||||
total_bytes: 0,
|
||||
latest_upload: null
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Route pour exporter les logs (CSV ou JSON)
|
||||
app.get('/api/logs/export', (req, res) => {
|
||||
const format = req.query.format || 'csv';
|
||||
|
||||
db.all('SELECT * FROM upload_logs ORDER BY timestamp DESC, id DESC', [], (err, rows) => {
|
||||
if (err) {
|
||||
console.error("Erreur export logs:", err);
|
||||
return res.status(500).json({ error: 'Erreur export logs' });
|
||||
}
|
||||
|
||||
if (format === 'json') {
|
||||
res.setHeader('Content-Disposition', `attachment; filename=upload_logs_${Date.now()}.json`);
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
return res.send(JSON.stringify(rows, null, 2));
|
||||
}
|
||||
|
||||
// CSV Export
|
||||
const headers = [
|
||||
'ID', 'Date/Heure', 'Statut', 'Wallet Hash', 'Session ID',
|
||||
'Image', 'JSON', 'Taille (octets)', 'Cible', 'Arme', 'Distance (m)',
|
||||
'Impacts', 'Modèle Appareil', 'OS', 'IP', 'Erreur'
|
||||
];
|
||||
|
||||
const csvLines = [headers.join(';')];
|
||||
|
||||
rows.forEach(r => {
|
||||
const line = [
|
||||
r.id,
|
||||
`"${r.timestamp || ''}"`,
|
||||
`"${r.status || ''}"`,
|
||||
`"${r.wallet_hash || ''}"`,
|
||||
`"${r.session_id || ''}"`,
|
||||
`"${r.image_filename || ''}"`,
|
||||
`"${r.json_filename || ''}"`,
|
||||
r.file_size || 0,
|
||||
`"${r.target_type || ''}"`,
|
||||
`"${r.weapon || ''}"`,
|
||||
r.distance_meters || '',
|
||||
r.impacts_count || 0,
|
||||
`"${r.device_model || ''}"`,
|
||||
`"${r.device_os || ''}"`,
|
||||
`"${r.ip_address || ''}"`,
|
||||
`"${(r.error_message || '').replace(/"/g, '""')}"`
|
||||
];
|
||||
csvLines.push(line.join(';'));
|
||||
});
|
||||
|
||||
res.setHeader('Content-Disposition', `attachment; filename=upload_logs_${Date.now()}.csv`);
|
||||
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
||||
res.send('\uFEFF' + csvLines.join('\r\n'));
|
||||
});
|
||||
});
|
||||
|
||||
// Route pour supprimer un log spécifique
|
||||
app.delete('/api/logs/:id', (req, res) => {
|
||||
const id = req.params.id;
|
||||
db.run('DELETE FROM upload_logs WHERE id = ?', [id], function(err) {
|
||||
if (err) {
|
||||
console.error("Erreur suppression log:", err);
|
||||
return res.status(500).json({ error: 'Erreur lors de la suppression' });
|
||||
}
|
||||
res.json({ message: 'Log supprimé avec succès', deletedId: id });
|
||||
});
|
||||
});
|
||||
|
||||
// Route pour vider les logs
|
||||
app.delete('/api/logs', (req, res) => {
|
||||
db.run('DELETE FROM upload_logs', [], function(err) {
|
||||
if (err) {
|
||||
console.error("Erreur vidage logs:", err);
|
||||
return res.status(500).json({ error: 'Erreur lors de la réinitialisation des logs' });
|
||||
}
|
||||
res.json({ message: 'Tous les logs ont été effacés', changes: this.changes });
|
||||
});
|
||||
});
|
||||
|
||||
// Route pour récupérer toutes les photos disponibles
|
||||
app.get('/api/photos', (req, res) => {
|
||||
try {
|
||||
@@ -164,20 +623,42 @@ app.get('/api/photos', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Route pour récupérer les statistiques d'un wallet_hash
|
||||
// Route pour récupérer les statistiques d'un wallet_hash + statut de modération
|
||||
app.get('/api/stats/:wallet_hash', (req, res) => {
|
||||
const walletHash = req.params.wallet_hash;
|
||||
|
||||
db.get('SELECT photo_count, last_upload FROM user_stats WHERE wallet_hash = ?', [walletHash], (err, row) => {
|
||||
db.get('SELECT photo_count, last_upload FROM user_stats WHERE wallet_hash = ?', [walletHash], (err, statRow) => {
|
||||
if (err) {
|
||||
return res.status(500).json({ error: 'Erreur lors de la lecture des statistiques' });
|
||||
}
|
||||
|
||||
if (row) {
|
||||
res.json({ status: 'ok', stats: row });
|
||||
} else {
|
||||
res.json({ status: 'ok', stats: { photo_count: 0, last_upload: null } });
|
||||
db.get('SELECT reason, banned_at FROM banned_wallets WHERE wallet_hash = ?', [walletHash], (bErr, bannedRow) => {
|
||||
const isBanned = !!bannedRow;
|
||||
const stats = statRow || { photo_count: 0, last_upload: null };
|
||||
res.json({
|
||||
status: 'ok',
|
||||
stats,
|
||||
is_banned: isBanned,
|
||||
ban_reason: bannedRow ? bannedRow.reason : null,
|
||||
banned_at: bannedRow ? bannedRow.banned_at : null
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Route directe pour vérifier l'état de modération d'un wallet
|
||||
app.get('/api/moderation/status/:wallet_hash', (req, res) => {
|
||||
const walletHash = req.params.wallet_hash;
|
||||
db.get('SELECT reason, banned_at FROM banned_wallets WHERE wallet_hash = ?', [walletHash], (err, bannedRow) => {
|
||||
if (err) {
|
||||
return res.status(500).json({ error: 'Erreur lecture statut modération' });
|
||||
}
|
||||
res.json({
|
||||
wallet_hash: walletHash,
|
||||
is_banned: !!bannedRow,
|
||||
ban_reason: bannedRow ? bannedRow.reason : null,
|
||||
banned_at: bannedRow ? bannedRow.banned_at : null
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -1,90 +0,0 @@
|
||||
{
|
||||
"session_id": "session_1777554006515",
|
||||
"wallet_hash": "cc60fcaa5ef7fdc9be3dd1adba4157fa6dfae2845da45b830b5ef2b7f473d887",
|
||||
"timestamp": "2026-04-30T13:00:06.519693",
|
||||
"device_info": {
|
||||
"model": "google sdk_gphone64_x86_64",
|
||||
"os": "Android 16"
|
||||
},
|
||||
"target_metadata": {
|
||||
"type": "concentric",
|
||||
"distance_meters": 25,
|
||||
"weapon": "Unknown"
|
||||
},
|
||||
"plotting": {
|
||||
"target_corners": [
|
||||
{
|
||||
"norm_x": 0.09999999999999998,
|
||||
"norm_y": 0.09999999999999998
|
||||
},
|
||||
{
|
||||
"norm_x": 0.9,
|
||||
"norm_y": 0.09999999999999998
|
||||
},
|
||||
{
|
||||
"norm_x": 0.9,
|
||||
"norm_y": 0.9
|
||||
},
|
||||
{
|
||||
"norm_x": 0.09999999999999998,
|
||||
"norm_y": 0.9
|
||||
}
|
||||
],
|
||||
"impacts": [
|
||||
{
|
||||
"id": 1,
|
||||
"label": "bullet_hole",
|
||||
"score": 5,
|
||||
"coords": {
|
||||
"norm_x": 0.267578125,
|
||||
"norm_y": 0.5554741753472222
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"label": "bullet_hole",
|
||||
"score": 7,
|
||||
"coords": {
|
||||
"norm_x": 0.5712583188657407,
|
||||
"norm_y": 0.3592800564236111
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"label": "bullet_hole",
|
||||
"score": 4,
|
||||
"coords": {
|
||||
"norm_x": 0.7555239076967593,
|
||||
"norm_y": 0.5703938802083333
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"label": "bullet_hole",
|
||||
"score": 4,
|
||||
"coords": {
|
||||
"norm_x": 0.6666259765625,
|
||||
"norm_y": 0.6759168836805556
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"label": "bullet_hole",
|
||||
"score": 9,
|
||||
"coords": {
|
||||
"norm_x": 0.5490415219907407,
|
||||
"norm_y": 0.5212944878472222
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"label": "bullet_hole",
|
||||
"score": 8,
|
||||
"coords": {
|
||||
"norm_x": 0.4897768373842592,
|
||||
"norm_y": 0.39352756076388895
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 107 KiB |
@@ -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:
|
||||
@@ -27,7 +27,7 @@ class BullyApp extends StatelessWidget {
|
||||
Locale('fr', 'FR'), // Français
|
||||
],
|
||||
locale: const Locale('fr', 'FR'), // Force l'interface en français
|
||||
home: const MainNavigationHolder(),
|
||||
home: MainNavigationHolder(key: mainNavKey),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -380,17 +380,71 @@ class DatabaseHelper {
|
||||
limit: limit,
|
||||
offset: offset,
|
||||
);
|
||||
if (sessionMaps.isEmpty) return [];
|
||||
|
||||
final sessions = <Session>[];
|
||||
for (final sessionMap in sessionMaps) {
|
||||
final sessionId = sessionMap['id'] as String;
|
||||
final session = await getSession(sessionId);
|
||||
if (session != null) {
|
||||
sessions.add(session);
|
||||
}
|
||||
// 3 requêtes groupées au lieu d'une cascade par session puis par analyse
|
||||
// (N+1) : l'ancien code refaisait un getSession complet pour chaque ligne,
|
||||
// ce qui ralentissait l'accueil/historique/stats au fil des mois.
|
||||
final sessionIds = sessionMaps.map((m) => m['id'] as String).toList();
|
||||
final analysisMaps = await _queryIn(
|
||||
db,
|
||||
AppConstants.targetAnalysesTable,
|
||||
'session_id',
|
||||
sessionIds,
|
||||
);
|
||||
final analysisIds = analysisMaps.map((m) => m['id'] as String).toList();
|
||||
final shotMaps = await _queryIn(
|
||||
db,
|
||||
AppConstants.shotsTable,
|
||||
'analysis_id',
|
||||
analysisIds,
|
||||
);
|
||||
|
||||
final shotsByAnalysis = <String, List<Shot>>{};
|
||||
for (final map in shotMaps) {
|
||||
(shotsByAnalysis[map['analysis_id'] as String] ??= [])
|
||||
.add(Shot.fromMap(map));
|
||||
}
|
||||
|
||||
return sessions;
|
||||
final analysesBySession = <String, List<TargetAnalysis>>{};
|
||||
for (final map in analysisMaps) {
|
||||
final analysis = TargetAnalysis.fromMap(
|
||||
map,
|
||||
shotsByAnalysis[map['id'] as String] ?? [],
|
||||
);
|
||||
(analysesBySession[map['session_id'] as String] ??= []).add(analysis);
|
||||
}
|
||||
|
||||
return sessionMaps
|
||||
.map((m) =>
|
||||
Session.fromMap(m, analysesBySession[m['id'] as String] ?? []))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// SELECT * FROM [table] WHERE [column] IN (values), découpé par paquets
|
||||
/// de 500 pour rester sous la limite de variables d'une requête SQLite (999).
|
||||
Future<List<Map<String, Object?>>> _queryIn(
|
||||
Database db,
|
||||
String table,
|
||||
String column,
|
||||
List<String> values,
|
||||
) async {
|
||||
if (values.isEmpty) return [];
|
||||
const chunkSize = 500;
|
||||
final results = <Map<String, Object?>>[];
|
||||
for (var i = 0; i < values.length; i += chunkSize) {
|
||||
final chunk = values.sublist(
|
||||
i,
|
||||
i + chunkSize > values.length ? values.length : i + chunkSize,
|
||||
);
|
||||
final placeholders = List.filled(chunk.length, '?').join(',');
|
||||
results.addAll(await db.query(
|
||||
table,
|
||||
where: '$column IN ($placeholders)',
|
||||
whereArgs: chunk,
|
||||
));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
Future<int> deleteSession(String id) async {
|
||||
@@ -536,6 +590,16 @@ class DatabaseHelper {
|
||||
);
|
||||
}
|
||||
|
||||
/// Toutes les entrées de maintenance, armes confondues (export/sauvegarde).
|
||||
Future<List<MaintenanceEntry>> getAllMaintenance() async {
|
||||
final db = await database;
|
||||
final maps = await db.query(
|
||||
AppConstants.maintenanceTable,
|
||||
orderBy: 'date DESC',
|
||||
);
|
||||
return List.generate(maps.length, (i) => MaintenanceEntry.fromMap(maps[i]));
|
||||
}
|
||||
|
||||
Future<List<MaintenanceEntry>> getMaintenanceForWeapon(String weaponId) async {
|
||||
final db = await database;
|
||||
final maps = await db.query(
|
||||
|
||||
@@ -131,6 +131,29 @@ class SessionRepository {
|
||||
return await _databaseHelper.getStatistics();
|
||||
}
|
||||
|
||||
/// Enregistre une session déjà construite (import de sauvegarde).
|
||||
/// Les identifiants existants sont écrasés : réimporter deux fois la même
|
||||
/// sauvegarde ne crée pas de doublons.
|
||||
Future<void> saveSession(Session session) async {
|
||||
await _databaseHelper.insertSession(session);
|
||||
}
|
||||
|
||||
/// Copie une image dans le dossier des cibles de l'app (import de sauvegarde).
|
||||
Future<String> saveImageBytes(List<int> bytes, String extension) async {
|
||||
final appDir = await getApplicationDocumentsDirectory();
|
||||
final imagesDir = Directory(path.join(appDir.path, 'target_images'));
|
||||
|
||||
if (!await imagesDir.exists()) {
|
||||
await imagesDir.create(recursive: true);
|
||||
}
|
||||
|
||||
final fileName = '${_uuid.v4()}$extension';
|
||||
final destPath = path.join(imagesDir.path, fileName);
|
||||
await File(destPath).writeAsBytes(bytes);
|
||||
|
||||
return destPath;
|
||||
}
|
||||
|
||||
String generateId() {
|
||||
return _uuid.v4();
|
||||
}
|
||||
@@ -170,6 +193,11 @@ class SessionRepository {
|
||||
return await _databaseHelper.getAllWeapons();
|
||||
}
|
||||
|
||||
/// Enregistre une arme déjà construite (import de sauvegarde).
|
||||
Future<void> saveWeapon(Weapon weapon) async {
|
||||
await _databaseHelper.insertWeapon(weapon);
|
||||
}
|
||||
|
||||
Future<void> updateWeapon(Weapon weapon) async {
|
||||
await _databaseHelper.updateWeapon(weapon);
|
||||
}
|
||||
@@ -208,6 +236,16 @@ class SessionRepository {
|
||||
return await _databaseHelper.getMaintenanceForWeapon(weaponId);
|
||||
}
|
||||
|
||||
/// Tout l'historique de maintenance de l'armurerie (export de sauvegarde).
|
||||
Future<List<MaintenanceEntry>> getAllMaintenance() async {
|
||||
return await _databaseHelper.getAllMaintenance();
|
||||
}
|
||||
|
||||
/// Enregistre une entrée de maintenance déjà construite (import).
|
||||
Future<void> saveMaintenanceEntry(MaintenanceEntry entry) async {
|
||||
await _databaseHelper.insertMaintenance(entry);
|
||||
}
|
||||
|
||||
Future<void> deleteMaintenanceEntry(String id) async {
|
||||
await _databaseHelper.deleteMaintenance(id);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/// Gestionnaire d'état pour l'analyse des cibles (ChangeNotifier).
|
||||
///
|
||||
/// Gère le workflow complet d'analyse : chargement d'image, détection de cible,
|
||||
/// gestion des impacts (manuels et automatiques), calcul des scores,
|
||||
/// analyse de groupement et sauvegarde des sessions.
|
||||
/// Gère le workflow complet d'analyse : chargement d'image, gestion des
|
||||
/// impacts placés manuellement, calcul des scores, analyse de groupement
|
||||
/// et sauvegarde des sessions.
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
@@ -13,37 +13,25 @@ import '../../data/models/target_analysis.dart';
|
||||
import '../../data/models/shot.dart';
|
||||
import '../../data/models/target_type.dart';
|
||||
import '../../data/repositories/session_repository.dart';
|
||||
import '../../services/target_detection_service.dart';
|
||||
import '../../services/score_calculator_service.dart';
|
||||
import '../../services/grouping_analyzer_service.dart';
|
||||
import '../../services/distortion_correction_service.dart';
|
||||
import '../../services/opencv_target_service.dart';
|
||||
import '../../services/ai_export_service.dart';
|
||||
|
||||
enum AnalysisState { initial, loading, success, error }
|
||||
|
||||
class AnalysisProvider extends ChangeNotifier {
|
||||
final TargetDetectionService _detectionService;
|
||||
final ScoreCalculatorService _scoreCalculatorService;
|
||||
final GroupingAnalyzerService _groupingAnalyzerService;
|
||||
final SessionRepository _sessionRepository;
|
||||
final DistortionCorrectionService _distortionService;
|
||||
final OpenCVTargetService _opencvTargetService;
|
||||
final Uuid _uuid = const Uuid();
|
||||
|
||||
AnalysisProvider({
|
||||
required TargetDetectionService detectionService,
|
||||
required ScoreCalculatorService scoreCalculatorService,
|
||||
required GroupingAnalyzerService groupingAnalyzerService,
|
||||
required SessionRepository sessionRepository,
|
||||
DistortionCorrectionService? distortionService,
|
||||
OpenCVTargetService? opencvTargetService,
|
||||
}) : _detectionService = detectionService,
|
||||
_scoreCalculatorService = scoreCalculatorService,
|
||||
}) : _scoreCalculatorService = scoreCalculatorService,
|
||||
_groupingAnalyzerService = groupingAnalyzerService,
|
||||
_sessionRepository = sessionRepository,
|
||||
_distortionService = distortionService ?? DistortionCorrectionService(),
|
||||
_opencvTargetService = opencvTargetService ?? OpenCVTargetService();
|
||||
_sessionRepository = sessionRepository;
|
||||
|
||||
AnalysisState _state = AnalysisState.initial;
|
||||
String? _errorMessage;
|
||||
@@ -53,7 +41,7 @@ class AnalysisProvider extends ChangeNotifier {
|
||||
// AJOUT PROTECTION DU PLOTTING : Stockage permanent de la rotation du Crop
|
||||
double _cropRotation = 0.0;
|
||||
|
||||
// Target detection results
|
||||
// Target calibration
|
||||
double _targetCenterX = 0.5;
|
||||
double _targetCenterY = 0.5;
|
||||
double _targetRadius = 0.4;
|
||||
@@ -71,15 +59,6 @@ class AnalysisProvider extends ChangeNotifier {
|
||||
// Grouping results
|
||||
GroupingResult? _groupingResult;
|
||||
|
||||
// Reference-based detection
|
||||
List<Shot> _referenceImpacts = [];
|
||||
ImpactCharacteristics? _learnedCharacteristics;
|
||||
|
||||
// Distortion correction
|
||||
bool _distortionCorrectionEnabled = false;
|
||||
DistortionParameters? _distortionParams;
|
||||
String? _correctedImagePath;
|
||||
|
||||
// Getters
|
||||
AnalysisState get state => _state;
|
||||
String? get errorMessage => _errorMessage;
|
||||
@@ -100,21 +79,6 @@ class AnalysisProvider extends ChangeNotifier {
|
||||
|
||||
int get totalScore => _scoreResult?.totalScore ?? 0;
|
||||
int get shotCount => _shots.length;
|
||||
List<Shot> get referenceImpacts => List.unmodifiable(_referenceImpacts);
|
||||
ImpactCharacteristics? get learnedCharacteristics => _learnedCharacteristics;
|
||||
bool get hasLearnedCharacteristics => _learnedCharacteristics != null;
|
||||
|
||||
// Distortion correction getters
|
||||
bool get distortionCorrectionEnabled => _distortionCorrectionEnabled;
|
||||
DistortionParameters? get distortionParams => _distortionParams;
|
||||
String? get correctedImagePath => _correctedImagePath;
|
||||
bool get hasDistortion => _distortionParams?.needsCorrection ?? false;
|
||||
|
||||
/// Retourne le chemin de l'image à afficher (corrigée si activée, originale sinon)
|
||||
String? get displayImagePath =>
|
||||
_distortionCorrectionEnabled && _correctedImagePath != null
|
||||
? _correctedImagePath
|
||||
: _imagePath;
|
||||
|
||||
/// Modifie et mémorise la rotation de l'image pour le Plotting
|
||||
void setCropRotation(double rotation) {
|
||||
@@ -122,14 +86,11 @@ class AnalysisProvider extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Analyze an image
|
||||
///
|
||||
/// [autoAnalyze] determines if we should run automatic detection immediately.
|
||||
/// If false, only the image is loaded and default target parameters are set.
|
||||
/// Charge l'image et initialise les paramètres de cible par défaut.
|
||||
/// Le placement des impacts et la calibration se font ensuite manuellement.
|
||||
Future<void> analyzeImage(
|
||||
String imagePath,
|
||||
TargetType targetType, {
|
||||
bool autoAnalyze = true,
|
||||
Offset? manualCenter,
|
||||
}) async {
|
||||
_state = AnalysisState.loading;
|
||||
@@ -147,55 +108,13 @@ class AnalysisProvider extends ChangeNotifier {
|
||||
_imageAspectRatio = frame.image.width / frame.image.height;
|
||||
frame.image.dispose();
|
||||
|
||||
if (!autoAnalyze) {
|
||||
// Just setup default values without running detection
|
||||
_targetCenterX = manualCenter?.dx ?? 0.5;
|
||||
_targetCenterY = manualCenter?.dy ?? 0.5;
|
||||
_targetRadius = 0.4;
|
||||
_targetInnerRadius = 0.04;
|
||||
|
||||
// Initialize empty shots list
|
||||
_shots = [];
|
||||
|
||||
_state = AnalysisState.success;
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
final result = await _detectionService.detectTargetAsync(
|
||||
imagePath,
|
||||
targetType,
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
_state = AnalysisState.error;
|
||||
_errorMessage = result.errorMessage;
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
_targetCenterX = result.centerX;
|
||||
_targetCenterY = result.centerY;
|
||||
_targetRadius = result.radius;
|
||||
_targetInnerRadius = result.radius * 0.1;
|
||||
|
||||
// Create shots from detected impacts
|
||||
_shots = result.impacts.map((impact) {
|
||||
return Shot(
|
||||
id: _uuid.v4(),
|
||||
x: impact.x,
|
||||
y: impact.y,
|
||||
score: impact.suggestedScore,
|
||||
analysisId: '',
|
||||
);
|
||||
}).toList();
|
||||
|
||||
// Calculate scores
|
||||
_recalculateScores();
|
||||
|
||||
// Calculate grouping
|
||||
_recalculateGrouping();
|
||||
|
||||
_state = AnalysisState.success;
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
@@ -216,22 +135,18 @@ class AnalysisProvider extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Remove a shot
|
||||
void removeShot(String shotId) {
|
||||
_shots.removeWhere((shot) => shot.id == shotId);
|
||||
/// Efface tous les impacts en un clic (bouton ↻ de l'écran Synthèse).
|
||||
/// La calibration (centre, rayon, anneaux) n'est pas touchée.
|
||||
void clearShots() {
|
||||
_shots.clear();
|
||||
_recalculateScores();
|
||||
_recalculateGrouping();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Move a shot to a new position
|
||||
void moveShot(String shotId, double newX, double newY) {
|
||||
final index = _shots.indexWhere((shot) => shot.id == shotId);
|
||||
if (index == -1) return;
|
||||
|
||||
final newScore = _calculateShotScore(newX, newY);
|
||||
_shots[index] = _shots[index].copyWith(x: newX, y: newY, score: newScore);
|
||||
|
||||
/// Remove a shot
|
||||
void removeShot(String shotId) {
|
||||
_shots.removeWhere((shot) => shot.id == shotId);
|
||||
_recalculateScores();
|
||||
_recalculateGrouping();
|
||||
notifyListeners();
|
||||
@@ -247,265 +162,6 @@ class AnalysisProvider extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Auto-detect impacts using image processing
|
||||
Future<int> autoDetectImpacts({
|
||||
int darkThreshold = 80,
|
||||
int minImpactSize = 20,
|
||||
int maxImpactSize = 500,
|
||||
double minCircularity = 0.6,
|
||||
double minFillRatio = 0.5,
|
||||
bool clearExisting = false,
|
||||
}) async {
|
||||
if (_imagePath == null || _targetType == null) return 0;
|
||||
|
||||
final settings = ImpactDetectionSettings(
|
||||
darkThreshold: darkThreshold,
|
||||
minImpactSize: minImpactSize,
|
||||
maxImpactSize: maxImpactSize,
|
||||
minCircularity: minCircularity,
|
||||
minFillRatio: minFillRatio,
|
||||
);
|
||||
|
||||
final detectedImpacts = _detectionService.detectImpactsOnly(
|
||||
_imagePath!,
|
||||
_targetType!,
|
||||
_targetCenterX,
|
||||
_targetCenterY,
|
||||
_targetRadius,
|
||||
_ringCount,
|
||||
settings,
|
||||
);
|
||||
|
||||
if (clearExisting) {
|
||||
_shots.clear();
|
||||
}
|
||||
|
||||
// Add detected impacts as shots
|
||||
for (final impact in detectedImpacts) {
|
||||
final score = _calculateShotScore(impact.x, impact.y);
|
||||
final shot = Shot(
|
||||
id: _uuid.v4(),
|
||||
x: impact.x,
|
||||
y: impact.y,
|
||||
score: score,
|
||||
analysisId: '',
|
||||
);
|
||||
_shots.add(shot);
|
||||
}
|
||||
|
||||
_recalculateScores();
|
||||
_recalculateGrouping();
|
||||
notifyListeners();
|
||||
|
||||
return detectedImpacts.length;
|
||||
}
|
||||
|
||||
/// Auto-detect impacts using OpenCV (Hough Circles + Contours)
|
||||
Future<int> autoDetectImpactsWithOpenCV({
|
||||
double cannyThreshold1 = 50,
|
||||
double cannyThreshold2 = 150,
|
||||
double minDist = 20,
|
||||
double param1 = 100,
|
||||
double param2 = 30,
|
||||
int minRadius = 5,
|
||||
int maxRadius = 50,
|
||||
int minSize = 5,
|
||||
int maxSize = 1000,
|
||||
int blurSize = 5,
|
||||
bool useContourDetection = true,
|
||||
double minCircularity = 0.6,
|
||||
double minContourArea = 50,
|
||||
double maxContourArea = 5000,
|
||||
bool clearExisting = false,
|
||||
}) async {
|
||||
if (_imagePath == null || _targetType == null) return 0;
|
||||
|
||||
final settings = OpenCVDetectionSettings(
|
||||
cannyThreshold1: cannyThreshold1,
|
||||
cannyThreshold2: cannyThreshold2,
|
||||
minDist: minDist,
|
||||
param1: param1,
|
||||
param2: param2,
|
||||
minRadius: minRadius,
|
||||
maxRadius: maxRadius,
|
||||
blurSize: blurSize,
|
||||
useContourDetection: useContourDetection,
|
||||
minCircularity: minCircularity,
|
||||
minContourArea: minContourArea,
|
||||
maxContourArea: maxContourArea,
|
||||
);
|
||||
|
||||
final detectedImpacts = _detectionService.detectImpactsWithOpenCV(
|
||||
_imagePath!,
|
||||
_targetType!,
|
||||
_targetCenterX,
|
||||
_targetCenterY,
|
||||
_targetRadius,
|
||||
_ringCount,
|
||||
settings: settings,
|
||||
);
|
||||
|
||||
if (clearExisting) {
|
||||
_shots.clear();
|
||||
}
|
||||
|
||||
// Add detected impacts as shots
|
||||
for (final impact in detectedImpacts) {
|
||||
final score = _calculateShotScore(impact.x, impact.y);
|
||||
final shot = Shot(
|
||||
id: _uuid.v4(),
|
||||
x: impact.x,
|
||||
y: impact.y,
|
||||
score: score,
|
||||
analysisId: '',
|
||||
);
|
||||
_shots.add(shot);
|
||||
}
|
||||
|
||||
_recalculateScores();
|
||||
_recalculateGrouping();
|
||||
notifyListeners();
|
||||
|
||||
return detectedImpacts.length;
|
||||
}
|
||||
|
||||
/// Detect impacts with OpenCV using reference points
|
||||
Future<int> detectFromReferencesWithOpenCV({
|
||||
double tolerance = 2.0,
|
||||
bool clearExisting = false,
|
||||
}) async {
|
||||
if (_imagePath == null ||
|
||||
_targetType == null ||
|
||||
_referenceImpacts.length < 2) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Convertir les références
|
||||
final references = _referenceImpacts
|
||||
.map((shot) => ReferenceImpact(x: shot.x, y: shot.y))
|
||||
.toList();
|
||||
|
||||
final detectedImpacts = _detectionService
|
||||
.detectImpactsWithOpenCVFromReferences(
|
||||
_imagePath!,
|
||||
_targetType!,
|
||||
_targetCenterX,
|
||||
_targetCenterY,
|
||||
_targetRadius,
|
||||
_ringCount,
|
||||
references,
|
||||
tolerance: tolerance,
|
||||
);
|
||||
|
||||
if (clearExisting) {
|
||||
_shots.clear();
|
||||
}
|
||||
|
||||
// Add detected impacts as shots
|
||||
for (final impact in detectedImpacts) {
|
||||
final score = _calculateShotScore(impact.x, impact.y);
|
||||
final shot = Shot(
|
||||
id: _uuid.v4(),
|
||||
x: impact.x,
|
||||
y: impact.y,
|
||||
score: score,
|
||||
analysisId: '',
|
||||
);
|
||||
_shots.add(shot);
|
||||
}
|
||||
|
||||
_recalculateScores();
|
||||
_recalculateGrouping();
|
||||
notifyListeners();
|
||||
|
||||
return detectedImpacts.length;
|
||||
}
|
||||
|
||||
/// Add a reference impact for calibrated detection
|
||||
void addReferenceImpact(double x, double y) {
|
||||
final score = _calculateShotScore(x, y);
|
||||
final shot = Shot(id: _uuid.v4(), x: x, y: y, score: score, analysisId: '');
|
||||
_referenceImpacts.add(shot);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Remove a reference impact
|
||||
void removeReferenceImpact(String shotId) {
|
||||
_referenceImpacts.removeWhere((shot) => shot.id == shotId);
|
||||
_learnedCharacteristics = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Clear all reference impacts
|
||||
void clearReferenceImpacts() {
|
||||
_referenceImpacts.clear();
|
||||
_learnedCharacteristics = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Learn characteristics from reference impacts
|
||||
bool learnFromReferences() {
|
||||
if (_imagePath == null || _referenceImpacts.length < 2) return false;
|
||||
|
||||
final references = _referenceImpacts
|
||||
.map((shot) => ReferenceImpact(x: shot.x, y: shot.y))
|
||||
.toList();
|
||||
|
||||
_learnedCharacteristics = _detectionService.analyzeReferenceImpacts(
|
||||
_imagePath!,
|
||||
references,
|
||||
);
|
||||
|
||||
notifyListeners();
|
||||
return _learnedCharacteristics != null;
|
||||
}
|
||||
|
||||
/// Auto-detect impacts using learned reference characteristics
|
||||
Future<int> detectFromReferences({
|
||||
double tolerance = 2.0,
|
||||
bool clearExisting = false,
|
||||
}) async {
|
||||
if (_imagePath == null ||
|
||||
_targetType == null ||
|
||||
_learnedCharacteristics == null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
final detectedImpacts = _detectionService.detectImpactsFromReferences(
|
||||
_imagePath!,
|
||||
_targetType!,
|
||||
_targetCenterX,
|
||||
_targetCenterY,
|
||||
_targetRadius,
|
||||
_ringCount,
|
||||
_learnedCharacteristics!,
|
||||
tolerance: tolerance,
|
||||
);
|
||||
|
||||
if (clearExisting) {
|
||||
_shots.clear();
|
||||
}
|
||||
|
||||
// Add detected impacts as shots
|
||||
for (final impact in detectedImpacts) {
|
||||
final score = _calculateShotScore(impact.x, impact.y);
|
||||
final shot = Shot(
|
||||
id: _uuid.v4(),
|
||||
x: impact.x,
|
||||
y: impact.y,
|
||||
score: score,
|
||||
analysisId: '',
|
||||
);
|
||||
_shots.add(shot);
|
||||
}
|
||||
|
||||
_recalculateScores();
|
||||
_recalculateGrouping();
|
||||
notifyListeners();
|
||||
|
||||
return detectedImpacts.length;
|
||||
}
|
||||
|
||||
/// Adjust target position
|
||||
void adjustTargetPosition(
|
||||
double centerX,
|
||||
@@ -539,118 +195,6 @@ class AnalysisProvider extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Auto-calibrate target using OpenCV
|
||||
Future<bool> autoCalibrateTarget() async {
|
||||
if (_imagePath == null) return false;
|
||||
|
||||
try {
|
||||
// 1. Attempt to correct perspective/distortion first
|
||||
final correctedPath = await _distortionService
|
||||
.correctPerspectiveWithConcentricMesh(_imagePath!);
|
||||
|
||||
if (correctedPath != _imagePath) {
|
||||
_imagePath = correctedPath;
|
||||
_correctedImagePath = correctedPath;
|
||||
_distortionCorrectionEnabled = true;
|
||||
_imageAspectRatio = 1.0;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// 2. Detect the target on the straight/corrected image
|
||||
final result = await _opencvTargetService.detectTarget(_imagePath!);
|
||||
|
||||
if (result.success) {
|
||||
adjustTargetPosition(
|
||||
result.centerX,
|
||||
result.centerY,
|
||||
result.radius * 0.1,
|
||||
result.radius,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (e) {
|
||||
debugPrint('Auto-calibration error: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Calcule les paramètres de distorsion basés sur la calibration actuelle
|
||||
void calculateDistortion() {
|
||||
_distortionParams = _distortionService.calculateDistortionFromCalibration(
|
||||
targetCenterX: _targetCenterX,
|
||||
targetCenterY: _targetCenterY,
|
||||
targetRadius: _targetRadius,
|
||||
imageAspectRatio: _imageAspectRatio,
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Applique la correction de distorsion à l'image
|
||||
/// Crée une nouvelle image corrigée et la sauvegarde
|
||||
Future<void> applyDistortionCorrection() async {
|
||||
if (_imagePath == null || _distortionParams == null) return;
|
||||
|
||||
try {
|
||||
_correctedImagePath = await _distortionService.applyCorrection(
|
||||
_imagePath!,
|
||||
_distortionParams!,
|
||||
);
|
||||
_distortionCorrectionEnabled = true;
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
_errorMessage = 'Erreur lors de la correction: $e';
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Active ou désactive l'affichage de l'image corrigée
|
||||
void setDistortionCorrectionEnabled(bool enabled) {
|
||||
if (enabled && _correctedImagePath == null && _distortionParams != null) {
|
||||
// Si on active mais pas encore d'image corrigée, la créer
|
||||
applyDistortionCorrection();
|
||||
} else {
|
||||
_distortionCorrectionEnabled = enabled;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Calcule ET applique la correction pour un feedback immédiat
|
||||
Future<void> calculateAndApplyDistortion() async {
|
||||
// 1. Calcul des paramètres (votre code actuel)
|
||||
_distortionParams = _distortionService.calculateDistortionFromCalibration(
|
||||
targetCenterX: _targetCenterX,
|
||||
targetCenterY: _targetCenterY,
|
||||
targetRadius: _targetRadius,
|
||||
imageAspectRatio: _imageAspectRatio,
|
||||
);
|
||||
|
||||
// 2. Vérification si une correction est réellement nécessaire
|
||||
if (_distortionParams != null && _distortionParams!.needsCorrection) {
|
||||
// 3. Application immédiate de la transformation (méthode asynchrone)
|
||||
await applyDistortionCorrection();
|
||||
} else {
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> runFullDistortionWorkflow() async {
|
||||
_state = AnalysisState.loading;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
calculateDistortion();
|
||||
await applyDistortionCorrection();
|
||||
_distortionCorrectionEnabled = true;
|
||||
_state = AnalysisState.success;
|
||||
} catch (e) {
|
||||
_errorMessage = "Erreur de rendu : $e";
|
||||
_state = AnalysisState.error;
|
||||
} finally {
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
int _calculateShotScore(double x, double y) {
|
||||
if (_targetType == TargetType.concentric) {
|
||||
return _scoreCalculatorService.calculateConcentricScore(
|
||||
@@ -694,12 +238,21 @@ class AnalysisProvider extends ChangeNotifier {
|
||||
_groupingResult = _groupingAnalyzerService.analyzeGrouping(_shots);
|
||||
}
|
||||
|
||||
/// Exporte l'image et le json vers le backend IA
|
||||
Future<bool> exportToAiBackend() async {
|
||||
/// Exporte l'image et le json vers le backend IA.
|
||||
/// [sessionId], [distance] et [weapon] proviennent du SessionProvider de
|
||||
/// l'écran appelant, pour que le dataset contienne les vraies métadonnées.
|
||||
Future<AiExportResult> exportToAiBackend({
|
||||
String? sessionId,
|
||||
int? distance,
|
||||
String? weapon,
|
||||
}) async {
|
||||
if (_imagePath == null || _targetType == null) {
|
||||
_errorMessage = "Impossible d'export : image ou type de cible manquant.";
|
||||
_errorMessage = "Impossible d'exporter : image ou type de cible manquant.";
|
||||
notifyListeners();
|
||||
return false;
|
||||
return AiExportResult.error(
|
||||
code: 'MISSING_DATA',
|
||||
message: "Impossible d'exporter : image ou type de cible manquant.",
|
||||
);
|
||||
}
|
||||
|
||||
final service = AiExportService();
|
||||
@@ -707,22 +260,24 @@ class AnalysisProvider extends ChangeNotifier {
|
||||
_state = AnalysisState.loading;
|
||||
notifyListeners();
|
||||
|
||||
final success = await service.exportData(
|
||||
final result = await service.exportData(
|
||||
imagePath: _imagePath!,
|
||||
sessionId: 'export',
|
||||
sessionId: sessionId ?? 'export',
|
||||
targetType: _targetType!,
|
||||
targetCenterX: _targetCenterX,
|
||||
targetCenterY: _targetCenterY,
|
||||
targetRadius: _targetRadius,
|
||||
shots: _shots,
|
||||
distanceMeters: distance ?? 25,
|
||||
weaponName: weapon ?? 'Unknown',
|
||||
);
|
||||
|
||||
_state = AnalysisState.success;
|
||||
if (!success) {
|
||||
_errorMessage = "Échec de l'export vers le serveur IA.";
|
||||
if (!result.isSuccess) {
|
||||
_errorMessage = result.message;
|
||||
}
|
||||
notifyListeners();
|
||||
return success;
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Save the session
|
||||
@@ -807,11 +362,6 @@ class AnalysisProvider extends ChangeNotifier {
|
||||
_shots = [];
|
||||
_scoreResult = null;
|
||||
_groupingResult = null;
|
||||
_referenceImpacts = [];
|
||||
_learnedCharacteristics = null;
|
||||
_distortionCorrectionEnabled = false;
|
||||
_distortionParams = null;
|
||||
_correctedImagePath = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
/// Écran principal de Plotting et d'analyse - Interface centrale de traitement des cibles.
|
||||
/// Écran principal de Synthèse et d'analyse - Interface centrale de traitement des cibles.
|
||||
///
|
||||
/// Affiche d'abord la calibration de la cible, puis l'overlay des anneaux et impacts détectés.
|
||||
/// Permet le calcul des scores et statistiques de groupement (Plotting).
|
||||
/// Permet le calcul des scores et statistiques de groupement (Synthèse), et
|
||||
/// c'est de là que l'on termine la session. L'ajout des impacts, lui, se fait
|
||||
/// dans l'éditeur d'impacts plein écran.
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
@@ -9,14 +11,15 @@ import 'dart:math' as math;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../main_navigation_holder.dart';
|
||||
import '../../core/constants/app_constants.dart';
|
||||
import '../../core/theme/app_theme.dart';
|
||||
import '../../data/models/target_type.dart';
|
||||
import '../../data/models/shot.dart';
|
||||
import '../../data/repositories/session_repository.dart';
|
||||
import '../../services/target_detection_service.dart';
|
||||
import '../../services/score_calculator_service.dart';
|
||||
import '../../services/grouping_analyzer_service.dart';
|
||||
import '../../services/wallet_identity_service.dart';
|
||||
import '../../services/ai_export_service.dart';
|
||||
import '../session/session_provider.dart';
|
||||
import 'analysis_provider.dart';
|
||||
import 'impact_editor_screen.dart';
|
||||
@@ -60,7 +63,6 @@ class AnalysisScreen extends StatelessWidget {
|
||||
return ChangeNotifierProvider(
|
||||
create: (context) {
|
||||
final p = AnalysisProvider(
|
||||
detectionService: context.read<TargetDetectionService>(),
|
||||
scoreCalculatorService: context.read<ScoreCalculatorService>(),
|
||||
groupingAnalyzerService: context.read<GroupingAnalyzerService>(),
|
||||
sessionRepository: context.read<SessionRepository>(),
|
||||
@@ -72,7 +74,6 @@ class AnalysisScreen extends StatelessWidget {
|
||||
p.analyzeImage(
|
||||
imagePath,
|
||||
targetType,
|
||||
autoAnalyze: false,
|
||||
manualCenter: manualCenterOffset,
|
||||
);
|
||||
return p;
|
||||
@@ -110,19 +111,17 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
|
||||
|
||||
// Forcé à TRUE pour démarrer sur l'ajustement des cercles
|
||||
bool _isCalibrating = true;
|
||||
bool _isSelectingReferences = false;
|
||||
bool _isAtBottom = false;
|
||||
|
||||
// Affichage du réglage manuel de l'espacement des anneaux.
|
||||
bool _showSpacing = false;
|
||||
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
final TransformationController _transformationController =
|
||||
TransformationController();
|
||||
final GlobalKey _imageKey = GlobalKey();
|
||||
double _currentZoomScale = 1.0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_transformationController.addListener(_onTransformChanged);
|
||||
_scrollController.addListener(_onScroll);
|
||||
}
|
||||
|
||||
@@ -140,32 +139,22 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_transformationController.removeListener(_onTransformChanged);
|
||||
_transformationController.dispose();
|
||||
_scrollController.removeListener(_onScroll);
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onTransformChanged() {
|
||||
final scale = _transformationController.value.getMaxScaleOnAxis();
|
||||
if (scale != _currentZoomScale) {
|
||||
setState(() {
|
||||
_currentZoomScale = scale;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Repasse en mode calibration en réinitialisant le zoom de l'InteractiveViewer.
|
||||
/// Repasse en mode calibration.
|
||||
///
|
||||
/// Sans cette remise à zéro, le facteur de zoom accumulé en mode Plotting
|
||||
/// persiste dans le TransformationController et se réapplique au retour,
|
||||
/// ce qui faisait "zoomer" légèrement la photo. On repart donc toujours
|
||||
/// d'une transformation identité (zoom 1.0).
|
||||
/// La cible du mode Synthèse est désormais un élément fixe (aucun zoom à
|
||||
/// réinitialiser) : on se contente donc de rebasculer l'état.
|
||||
void _enterCalibration() {
|
||||
_transformationController.value = Matrix4.identity();
|
||||
_currentZoomScale = 1.0;
|
||||
setState(() => _isCalibrating = true);
|
||||
setState(() {
|
||||
_isCalibrating = true;
|
||||
// La calibration est reconstruite à neuf (espacement manuel désactivé) :
|
||||
// on aligne l'état du panneau pour ne pas afficher un mode inactif.
|
||||
_showSpacing = false;
|
||||
});
|
||||
}
|
||||
|
||||
/// Ouvre l'éditeur d'impacts (plein écran) en PARTAGEANT le provider courant.
|
||||
@@ -174,10 +163,11 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
|
||||
/// exactement le même AnalysisProvider que cet écran : les impacts ajoutés,
|
||||
/// déplacés ou supprimés sont donc immédiatement répercutés ici.
|
||||
///
|
||||
/// Au retour : si l'utilisateur a validé (résultat true) on bascule en mode
|
||||
/// Plotting (lecture seule) ; sinon on repasse en calibration.
|
||||
/// Au retour, quel que soit le résultat (validation OU retour arrière), on
|
||||
/// revient TOUJOURS sur la Synthèse. L'éditeur d'impacts n'est ouvert que
|
||||
/// depuis la Synthèse : il doit donc y ramener, jamais sur la calibration.
|
||||
Future<void> _openImpactEditor(AnalysisProvider provider) async {
|
||||
final validated = await Navigator.of(context).push<bool>(
|
||||
await Navigator.of(context).push<bool>(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ChangeNotifierProvider<AnalysisProvider>.value(
|
||||
value: provider,
|
||||
@@ -188,14 +178,9 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
if (validated == true) {
|
||||
setState(() {
|
||||
_isCalibrating = false;
|
||||
_isSelectingReferences = false;
|
||||
});
|
||||
} else {
|
||||
_enterCalibration();
|
||||
}
|
||||
}
|
||||
|
||||
/// Chemin à utiliser pour repartir dans le CropScreen lors d'un retour arrière.
|
||||
@@ -208,6 +193,181 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
|
||||
return widget.originalImagePath ?? provider.imagePath!;
|
||||
}
|
||||
|
||||
/// Panneau de réglages de la calibration (taille + espacement).
|
||||
///
|
||||
/// Rendu AU-DESSUS de l'image (et non plus en surimpression) pour ne pas
|
||||
/// masquer la cible. Les valeurs affichées viennent du provider ; les
|
||||
/// modifications sont poussées dans l'état de [TargetCalibration] via sa clé.
|
||||
Widget _buildCalibrationSettings(AnalysisProvider provider) {
|
||||
final radius = provider.targetRadius.clamp(
|
||||
TargetCalibrationState.minRadius,
|
||||
TargetCalibrationState.maxRadius,
|
||||
);
|
||||
final spacing =
|
||||
(provider.targetRadius > 0
|
||||
? provider.targetInnerRadius / provider.targetRadius
|
||||
: 0.1)
|
||||
.clamp(
|
||||
TargetCalibrationState.minSpacing,
|
||||
TargetCalibrationState.maxSpacing,
|
||||
);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 0, 12, 4),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 36,
|
||||
child: Row(
|
||||
children: [
|
||||
const Text(
|
||||
'Taille',
|
||||
style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
const Icon(Icons.zoom_out, size: 16),
|
||||
Expanded(
|
||||
child: Slider(
|
||||
value: radius,
|
||||
min: TargetCalibrationState.minRadius,
|
||||
max: TargetCalibrationState.maxRadius,
|
||||
activeColor: AppTheme.primaryColor,
|
||||
onChanged: (value) =>
|
||||
_calibrationKey.currentState?.setRadius(value),
|
||||
),
|
||||
),
|
||||
const Icon(Icons.zoom_in, size: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: 32,
|
||||
child: Row(
|
||||
children: [
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'Options d\'espacement avancées',
|
||||
style: TextStyle(fontSize: 11, fontWeight: FontWeight.w500),
|
||||
),
|
||||
),
|
||||
Switch(
|
||||
value: _showSpacing,
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
onChanged: (value) {
|
||||
setState(() => _showSpacing = value);
|
||||
_calibrationKey.currentState?.setSpacingMode(value);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_showSpacing)
|
||||
SizedBox(
|
||||
height: 36,
|
||||
child: Row(
|
||||
children: [
|
||||
const Text(
|
||||
'Espacement',
|
||||
style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
const Icon(Icons.compress, size: 16),
|
||||
Expanded(
|
||||
child: Slider(
|
||||
value: spacing,
|
||||
min: TargetCalibrationState.minSpacing,
|
||||
max: TargetCalibrationState.maxSpacing,
|
||||
activeColor: Colors.orange,
|
||||
onChanged: (value) =>
|
||||
_calibrationKey.currentState?.setSpacingRatio(value),
|
||||
),
|
||||
),
|
||||
const Icon(Icons.expand, size: 16),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh, size: 20),
|
||||
tooltip: 'Réinitialiser l\'espacement',
|
||||
constraints: const BoxConstraints(),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
onPressed: () =>
|
||||
_calibrationKey.currentState?.resetSpacing(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Indication affichée sous le titre en mode Synthèse.
|
||||
///
|
||||
/// Rien n'indiquait comment ajouter un impact une fois la calibration
|
||||
/// validée : ce rappel pointe vers le geste (toucher la cible).
|
||||
Widget _buildSyntheseHint(AnalysisProvider provider) {
|
||||
final hasShots = provider.shotCount > 0;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
hasShots ? Icons.touch_app : Icons.add_location_alt,
|
||||
size: 16,
|
||||
color: AppTheme.primaryColor,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Flexible(
|
||||
child: Text(
|
||||
hasShots
|
||||
? 'Touchez la cible pour modifier vos impacts'
|
||||
: 'Touchez la cible pour placer vos impacts',
|
||||
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Bouton flottant du bas de l'écran.
|
||||
///
|
||||
/// En calibration : VALIDER. En synthèse : tant qu'aucun impact n'est placé,
|
||||
/// on ne propose pas de terminer la session mais de placer un impact (le
|
||||
/// bouton ouvre l'éditeur, comme un tap sur la cible).
|
||||
Widget _buildBottomAction(BuildContext context, AnalysisProvider provider) {
|
||||
if (_isCalibrating) {
|
||||
return FloatingActionButton.extended(
|
||||
// Même bouton bleu flottant que « TERMINER LA SESSION » :
|
||||
// on fige la calibration puis on bascule sur la Synthèse.
|
||||
onPressed: () {
|
||||
_calibrationKey.currentState?.commitCalibration();
|
||||
setState(() => _isCalibrating = false);
|
||||
},
|
||||
backgroundColor: AppTheme.primaryColor,
|
||||
icon: const Icon(Icons.check),
|
||||
label: const Text('VALIDER'),
|
||||
);
|
||||
}
|
||||
|
||||
if (provider.shotCount == 0) {
|
||||
return FloatingActionButton.extended(
|
||||
onPressed: () => _openImpactEditor(provider),
|
||||
backgroundColor: AppTheme.primaryColor,
|
||||
icon: const Icon(Icons.add_location_alt),
|
||||
label: const Text('PLACER UN IMPACT'),
|
||||
);
|
||||
}
|
||||
|
||||
return FloatingActionButton.extended(
|
||||
onPressed: () => _showSaveSessionDialog(context, provider),
|
||||
backgroundColor: AppTheme.primaryColor,
|
||||
icon: const Icon(Icons.save),
|
||||
label: const Text('TERMINER LA SESSION'),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final provider = context.watch<AnalysisProvider>();
|
||||
@@ -216,10 +376,10 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
|
||||
? sessionProvider.targetCount + 1
|
||||
: null;
|
||||
|
||||
final titlePrefix = _isCalibrating ? 'Calibration' : 'Plotting';
|
||||
final titlePrefix = _isCalibrating ? 'Calibration' : 'Synthèse';
|
||||
final title = targetNumber != null
|
||||
? '$titlePrefix - Cible $targetNumber'
|
||||
: (_isCalibrating ? 'Calibration' : 'Plotting du Tir');
|
||||
: (_isCalibrating ? 'Calibration' : 'Synthèse du Tir');
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
@@ -243,39 +403,12 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// Retour Plotting -> Calibration : on réinitialise le zoom.
|
||||
// Retour Synthèse -> Calibration : on réinitialise le zoom.
|
||||
_enterCalibration();
|
||||
}
|
||||
},
|
||||
),
|
||||
actions: [
|
||||
if (!_isCalibrating && !_isSelectingReferences)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: () => provider.analyzeImage(
|
||||
context.read<AnalysisProvider>().imagePath!,
|
||||
context.read<AnalysisProvider>().targetType!,
|
||||
),
|
||||
),
|
||||
if (_isCalibrating)
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
// On fige la calibration courante AVANT d'ouvrir l'éditeur,
|
||||
// puis on passe sur l'écran d'édition d'impacts plein écran
|
||||
// (zoom fiable + placement). Le mode Plotting (lecture seule)
|
||||
// s'affichera au retour si l'utilisateur valide.
|
||||
_calibrationKey.currentState?.commitCalibration();
|
||||
_openImpactEditor(context.read<AnalysisProvider>());
|
||||
},
|
||||
child: const Text(
|
||||
'VALIDER',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
actions: const [],
|
||||
),
|
||||
body: Stack(
|
||||
children: [
|
||||
@@ -283,22 +416,21 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
|
||||
controller: _scrollController,
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(AppConstants.defaultPadding),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Plus de bloc vide au-dessus de l'image : l'indicateur n'occupe
|
||||
// de la place que pendant le chargement.
|
||||
if (provider.state == AnalysisState.loading)
|
||||
const Center(
|
||||
child: Padding(
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
|
||||
// Réglages de calibration : au-dessus de la photo pour ne rien
|
||||
// masquer de la cible.
|
||||
if (_isCalibrating)
|
||||
_buildCalibrationSettings(provider)
|
||||
else
|
||||
_buildSyntheseHint(provider),
|
||||
|
||||
AspectRatio(
|
||||
aspectRatio: provider.imageAspectRatio,
|
||||
child: _isCalibrating
|
||||
@@ -315,7 +447,6 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
|
||||
widget.cropOffset?.dy ?? 0.0,
|
||||
0.0,
|
||||
)
|
||||
..scale(1.0, 1.0)
|
||||
..rotateZ(
|
||||
(provider.cropRotation) *
|
||||
(math.pi / 180),
|
||||
@@ -368,27 +499,6 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
|
||||
padding: const EdgeInsets.all(AppConstants.defaultPadding),
|
||||
child: Column(
|
||||
children: [
|
||||
Card(
|
||||
color: AppTheme.primaryColor.withValues(alpha: 0.18),
|
||||
child: ListTile(
|
||||
leading: const Icon(
|
||||
Icons.edit_location_alt,
|
||||
color: AppTheme.primaryColor,
|
||||
),
|
||||
title: const Text('Modifier les impacts'),
|
||||
subtitle: const Text(
|
||||
'Ajouter, déplacer ou supprimer des impacts (plein écran)',
|
||||
),
|
||||
trailing: const Icon(
|
||||
Icons.open_in_full,
|
||||
size: 16,
|
||||
),
|
||||
// Rouvre l'éditeur plein écran en partageant le provider.
|
||||
onTap: () =>
|
||||
_openImpactEditor(context.read<AnalysisProvider>()),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Card(
|
||||
color: AppTheme.primaryColor.withValues(alpha: 0.1),
|
||||
child: ListTile(
|
||||
@@ -414,6 +524,14 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
|
||||
shotCount: provider.shotCount,
|
||||
scoreResult: provider.scoreResult,
|
||||
targetType: provider.targetType!,
|
||||
// Cumul de la session : cibles déjà validées + cible
|
||||
// en cours (absent hors session).
|
||||
sessionTotalScore: sessionProvider.isSessionActive
|
||||
? sessionProvider.totalSessionScore +
|
||||
provider.totalScore
|
||||
: null,
|
||||
sessionTargetCount:
|
||||
sessionProvider.targetCount + 1,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (provider.groupingResult != null &&
|
||||
@@ -423,8 +541,6 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
|
||||
targetCenterX: provider.targetCenterX,
|
||||
targetCenterY: provider.targetCenterY,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildActionButtons(context, provider),
|
||||
const SizedBox(height: 50),
|
||||
],
|
||||
),
|
||||
@@ -528,15 +644,7 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
|
||||
padding: _isAtBottom
|
||||
? EdgeInsets.zero
|
||||
: const EdgeInsets.all(16.0),
|
||||
child: _isCalibrating
|
||||
? const SizedBox.shrink()
|
||||
: FloatingActionButton.extended(
|
||||
onPressed: () =>
|
||||
_showSaveSessionDialog(context, provider),
|
||||
backgroundColor: AppTheme.primaryColor,
|
||||
icon: const Icon(Icons.save),
|
||||
label: const Text('TERMINER LA SESSION'),
|
||||
),
|
||||
child: _buildBottomAction(context, provider),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -558,22 +666,21 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
|
||||
);
|
||||
}
|
||||
|
||||
/// Affichage du plotting en LECTURE SEULE.
|
||||
/// Affichage de la synthèse en LECTURE SEULE.
|
||||
///
|
||||
/// L'édition (ajout / déplacement / suppression) se fait désormais
|
||||
/// exclusivement dans l'éditeur plein écran (ImpactEditorScreen). Ici on se
|
||||
/// contente d'afficher l'image + l'overlay, avec un zoom de consultation.
|
||||
/// Le tap sur un impact ouvre simplement ses détails.
|
||||
/// La cible est un élément d'écran FIXE : elle ne se déplace pas et ne se
|
||||
/// zoome pas (plus d'InteractiveViewer). Un tap n'importe où sur la cible
|
||||
/// ouvre directement l'éditeur d'impacts plein écran (ImpactEditorScreen),
|
||||
/// exactement comme le faisait l'ancien bouton « Modifier les impacts ».
|
||||
/// C'est là que se fait toute l'édition (ajout / déplacement / suppression),
|
||||
/// avec un zoom fiable.
|
||||
Widget _buildReadOnlyPlotImage(
|
||||
BuildContext context,
|
||||
AnalysisProvider provider,
|
||||
) {
|
||||
return InteractiveViewer(
|
||||
transformationController: _transformationController,
|
||||
minScale: 1.0,
|
||||
maxScale: 10.0,
|
||||
boundaryMargin: const EdgeInsets.all(80),
|
||||
panEnabled: true,
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => _openImpactEditor(context.read<AnalysisProvider>()),
|
||||
child: Stack(
|
||||
children: [
|
||||
Image.file(
|
||||
@@ -589,10 +696,7 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
|
||||
targetType: provider.targetType!,
|
||||
shots: provider.shots,
|
||||
showRings: true,
|
||||
zoomScale: _currentZoomScale,
|
||||
// Lecture seule : tap sur impact -> détails (consultation).
|
||||
onShotTapped: (shot) =>
|
||||
_showShotDetails(context, provider, shot),
|
||||
zoomScale: 1.0,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -600,100 +704,93 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActionButtons(BuildContext context, AnalysisProvider provider) {
|
||||
return const Column(children: [Row(children: [])]);
|
||||
}
|
||||
|
||||
void _showShotDetails(
|
||||
Future<void> _showSaveSessionDialog(
|
||||
BuildContext context,
|
||||
AnalysisProvider provider,
|
||||
Shot shot,
|
||||
) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (context) => Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'Impact #${provider.shots.indexOf(shot) + 1}',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18),
|
||||
),
|
||||
Text(
|
||||
'ID: ${shot.id}',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(color: Colors.grey, fontSize: 10),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.score),
|
||||
title: const Text('Modifier le score'),
|
||||
trailing: DropdownButton<int>(
|
||||
value: shot.score.clamp(0, 10),
|
||||
items: List.generate(11, (index) => index)
|
||||
.map(
|
||||
(s) => DropdownMenuItem(
|
||||
value: s,
|
||||
child: Text(
|
||||
'$s',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 18,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
onChanged: (newScore) {
|
||||
if (newScore != null) {
|
||||
provider.updateShotScore(shot.id, newScore);
|
||||
Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () {
|
||||
provider.removeShot(shot.id);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
icon: const Icon(Icons.delete, color: Colors.red),
|
||||
label: const Text(
|
||||
'SUPPRIMER',
|
||||
style: TextStyle(color: Colors.red),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
) async {
|
||||
// L'option « Participer à l'entraînement IA » (Paramètres) est lue AVANT
|
||||
// d'ouvrir la popup : elle décide de la présence du bouton d'export.
|
||||
final canExport =
|
||||
await WalletIdentityService().isUploadEnabled() &&
|
||||
provider.state == AnalysisState.success;
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
void _showSaveSessionDialog(BuildContext context, AnalysisProvider provider) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Session Terminee'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
// Protège des petits écrans / grandes polices : la popup défile au
|
||||
// lieu de déborder.
|
||||
scrollable: true,
|
||||
// En-tête bleu pleine largeur : le titre occupe toute la bande, d'où
|
||||
// les paddings mis à zéro.
|
||||
titlePadding: EdgeInsets.zero,
|
||||
contentPadding: const EdgeInsets.fromLTRB(20, 16, 20, 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
title: Container(
|
||||
width: double.infinity,
|
||||
color: AppTheme.primaryColor,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
||||
child: const Row(
|
||||
children: [
|
||||
Text('Nombre de tirs: ${provider.shotCount}'),
|
||||
Text('Score total: ${provider.totalScore}'),
|
||||
const SizedBox(height: 16),
|
||||
const Text('Voulez-vous enregistrer cette session ?'),
|
||||
Icon(Icons.flag, color: Colors.white),
|
||||
SizedBox(width: 10),
|
||||
Text(
|
||||
'Session terminée',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
),
|
||||
// Les boutons sont dans le contenu (et non dans `actions`) pour être
|
||||
// tous à la même largeur, alignés les uns sous les autres.
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_buildDialogRecap(context, provider),
|
||||
const SizedBox(height: 16),
|
||||
const Text('Voulez-vous enregistrer cette session ?'),
|
||||
const SizedBox(height: 16),
|
||||
_buildDialogButton(
|
||||
icon: const Icon(Icons.add_a_photo, color: Colors.white),
|
||||
label: 'AJOUTER UNE CIBLE',
|
||||
color: AppTheme.secondaryColor,
|
||||
onPressed: () => _saveAndAddTarget(context, provider),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_buildDialogButton(
|
||||
icon: const Icon(Icons.save, color: Colors.white),
|
||||
label: 'TERMINER TOUT',
|
||||
color: AppTheme.primaryColor,
|
||||
onPressed: () => _finishSession(context, provider),
|
||||
),
|
||||
// Bouton d'export : uniquement si l'entraînement IA est autorisé.
|
||||
if (canExport) ...[
|
||||
const SizedBox(height: 8),
|
||||
_buildDialogButton(
|
||||
icon: Image.asset(
|
||||
'assets/icons/cloud_save.png',
|
||||
width: 24,
|
||||
height: 24,
|
||||
// L'icône est un trait noir : on la recolore en blanc pour
|
||||
// qu'elle ressorte sur le bouton.
|
||||
color: Colors.white,
|
||||
),
|
||||
label: 'TERMINER TOUT ET EXPORTER',
|
||||
color: AppTheme.warningColor,
|
||||
onPressed: () =>
|
||||
_finishSession(context, provider, export: true),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 4),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
@@ -715,8 +812,72 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
|
||||
},
|
||||
child: const Text('ANNULER'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Rappel chiffré (tirs / score) en tête de la popup de fin de session.
|
||||
Widget _buildDialogRecap(BuildContext context, AnalysisProvider provider) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primaryColor.withValues(alpha: 0.10),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
_buildRecapValue(context, '${provider.shotCount}', 'Tirs'),
|
||||
_buildRecapValue(context, '${provider.totalScore}', 'Score total'),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRecapValue(BuildContext context, String value, String label) {
|
||||
return Column(
|
||||
children: [
|
||||
Text(
|
||||
value,
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppTheme.primaryColor,
|
||||
),
|
||||
),
|
||||
Text(label, style: Theme.of(context).textTheme.bodySmall),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Bouton pleine largeur de la popup, aux couleurs du thème.
|
||||
Widget _buildDialogButton({
|
||||
required Widget icon,
|
||||
required String label,
|
||||
required Color color,
|
||||
required VoidCallback onPressed,
|
||||
}) {
|
||||
return ElevatedButton.icon(
|
||||
icon: icon,
|
||||
label: Text(label, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: color,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
onPressed: onPressed,
|
||||
);
|
||||
}
|
||||
|
||||
/// Enregistre la cible courante et enchaîne sur une nouvelle capture.
|
||||
Future<void> _saveAndAddTarget(
|
||||
BuildContext context,
|
||||
AnalysisProvider provider,
|
||||
) async {
|
||||
try {
|
||||
final sessionProvider = context.read<SessionProvider>();
|
||||
final analysis = await provider.saveSession(
|
||||
@@ -733,9 +894,7 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
|
||||
Navigator.pop(context);
|
||||
Navigator.pushAndRemoveUntil(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const CaptureScreen(),
|
||||
),
|
||||
MaterialPageRoute(builder: (context) => const CaptureScreen()),
|
||||
(route) => route.isFirst,
|
||||
);
|
||||
}
|
||||
@@ -749,13 +908,24 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
child: const Text('AJOUTER UNE CIBLE'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
try {
|
||||
}
|
||||
|
||||
/// Clôture la session et revient sur l'onglet Statistiques.
|
||||
///
|
||||
/// Avec [export], la cible est en plus envoyée au backend d'entraînement IA.
|
||||
/// L'enregistrement local reste prioritaire : un échec d'export n'empêche
|
||||
/// jamais la session d'être sauvegardée.
|
||||
Future<void> _finishSession(
|
||||
BuildContext context,
|
||||
AnalysisProvider provider, {
|
||||
bool export = false,
|
||||
}) async {
|
||||
// Messenger et session capturés AVANT les await : le contexte de la popup
|
||||
// ne sera plus valide ensuite.
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final sessionProvider = context.read<SessionProvider>();
|
||||
|
||||
try {
|
||||
await provider.saveSession(
|
||||
sessionId: sessionProvider.activeSessionId,
|
||||
weaponName: sessionProvider.currentWeapon,
|
||||
@@ -764,14 +934,92 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
|
||||
date: sessionProvider.sessionDate,
|
||||
);
|
||||
|
||||
AiExportResult? exportResult;
|
||||
if (export) {
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(content: Text('Exportation vers le serveur IA en cours...')),
|
||||
);
|
||||
exportResult = await provider.exportToAiBackend(
|
||||
sessionId: sessionProvider.activeSessionId,
|
||||
distance: sessionProvider.distance,
|
||||
weapon: sessionProvider.currentWeapon,
|
||||
);
|
||||
messenger.hideCurrentSnackBar();
|
||||
}
|
||||
|
||||
if (context.mounted) {
|
||||
sessionProvider.endSession();
|
||||
Navigator.pop(context);
|
||||
Navigator.of(context).popUntil((route) => route.isFirst);
|
||||
// Fin de session : on atterrit sur les statistiques.
|
||||
openMainTab(mainTabStats);
|
||||
}
|
||||
|
||||
if (exportResult != null) {
|
||||
if (exportResult.isBanned) {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Row(
|
||||
children: [
|
||||
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),
|
||||
),
|
||||
);
|
||||
} else if (exportResult.isSuccess) {
|
||||
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) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Erreur: $e'),
|
||||
backgroundColor: AppTheme.errorColor,
|
||||
@@ -779,11 +1027,4 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
child: const Text('TERMINER TOUT'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,11 @@
|
||||
/// scroll vertical ou une transformation parente.
|
||||
///
|
||||
/// Interactions :
|
||||
/// - Tap sur zone vide -> ajoute un impact
|
||||
/// - Tap sur un impact -> ouvre l'édition (score / suppression)
|
||||
/// - Tap -> ajoute TOUJOURS un impact, même juste à côté
|
||||
/// (ou par-dessus) un impact existant. Aucun tap
|
||||
/// n'ouvre d'édition de score : on peut donc
|
||||
/// placer un impact au pouce près sans être
|
||||
/// interrompu par une popup.
|
||||
/// - Appui long + glisser -> déplace l'impact
|
||||
///
|
||||
/// L'état des impacts est partagé avec l'écran d'analyse via le MÊME
|
||||
@@ -20,6 +23,7 @@ import 'dart:math' as math;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../core/theme/app_theme.dart';
|
||||
import '../../data/models/shot.dart';
|
||||
import 'analysis_provider.dart';
|
||||
import 'widgets/target_overlay.dart';
|
||||
@@ -71,8 +75,11 @@ class _ImpactEditorScreenState extends State<ImpactEditorScreen> {
|
||||
}
|
||||
|
||||
/// Renvoie l'impact le plus proche de [rel] dans la tolérance, sinon null.
|
||||
///
|
||||
/// Utilisé uniquement par l'appui long (déplacement) : le tap simple, lui,
|
||||
/// ajoute toujours un impact sans chercher à en sélectionner un.
|
||||
Shot? _hitTestShot(AnalysisProvider provider, Offset rel,
|
||||
{double tolerance = 0.04}) {
|
||||
{double tolerance = 0.06}) {
|
||||
Shot? closest;
|
||||
double minDistance = double.infinity;
|
||||
for (final shot in provider.shots) {
|
||||
@@ -98,16 +105,29 @@ class _ImpactEditorScreenState extends State<ImpactEditorScreen> {
|
||||
title: Text('Placement des impacts (${provider.shotCount})'),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
tooltip: 'Retour à la calibration',
|
||||
tooltip: 'Retour à la synthèse',
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text(
|
||||
'VALIDER',
|
||||
style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
|
||||
),
|
||||
// Corbeille (efface tous les impacts) + bouton bleu flottant VALIDER.
|
||||
// heroTag distinct sur chaque FAB pour éviter le conflit de Hero.
|
||||
floatingActionButton: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
FloatingActionButton(
|
||||
heroTag: 'reset_impacts',
|
||||
onPressed: () => provider.clearShots(),
|
||||
backgroundColor: Colors.grey.shade800,
|
||||
tooltip: 'Effacer tous les impacts',
|
||||
child: const Icon(Icons.delete),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
FloatingActionButton.extended(
|
||||
heroTag: 'validate_impacts',
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
backgroundColor: AppTheme.primaryColor,
|
||||
icon: const Icon(Icons.check),
|
||||
label: const Text('VALIDER'),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -119,7 +139,7 @@ class _ImpactEditorScreenState extends State<ImpactEditorScreen> {
|
||||
color: Colors.white10,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: const Text(
|
||||
'Tap : ajouter • Tap sur impact : éditer • Appui long : déplacer • Pincer : zoomer',
|
||||
'Tap : ajouter un impact • Appui long : déplacer • Pincer : zoomer',
|
||||
style: TextStyle(color: Colors.white70, fontSize: 12),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
@@ -136,24 +156,20 @@ class _ImpactEditorScreenState extends State<ImpactEditorScreen> {
|
||||
child: Center(
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
// TAP : éditer si on touche un impact, sinon ajouter.
|
||||
// TAP : ajoute un impact, sans exception. Même collé à un
|
||||
// impact existant, le tap crée le nouvel impact au lieu
|
||||
// d'ouvrir l'édition du score.
|
||||
onTapUp: (details) {
|
||||
if (_movingShotId != null) return;
|
||||
final rel = _toImageRelative(details.globalPosition);
|
||||
if (rel == null) return;
|
||||
|
||||
final hit = _hitTestShot(provider, rel);
|
||||
if (hit != null) {
|
||||
_showShotDetails(context, provider, hit);
|
||||
} else {
|
||||
provider.addShot(rel.dx, rel.dy);
|
||||
}
|
||||
},
|
||||
// APPUI LONG : on saisit l'impact le plus proche pour le déplacer.
|
||||
onLongPressStart: (details) {
|
||||
final rel = _toImageRelative(details.globalPosition);
|
||||
if (rel == null) return;
|
||||
final hit = _hitTestShot(provider, rel, tolerance: 0.06);
|
||||
final hit = _hitTestShot(provider, rel);
|
||||
if (hit != null) {
|
||||
setState(() => _movingShotId = hit.id);
|
||||
}
|
||||
@@ -189,10 +205,10 @@ class _ImpactEditorScreenState extends State<ImpactEditorScreen> {
|
||||
shots: provider.shots,
|
||||
showRings: true,
|
||||
zoomScale: _currentZoomScale,
|
||||
// L'ajout et la sélection sont gérés par le
|
||||
// GestureDetector parent ci-dessus.
|
||||
onShotTapped: (shot) =>
|
||||
_showShotDetails(context, provider, shot),
|
||||
// Aucun onShotTapped : les impacts ne captent plus le
|
||||
// toucher, tout va au GestureDetector parent qui
|
||||
// ajoute un impact (y compris pile sur un impact
|
||||
// existant).
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -205,79 +221,4 @@ class _ImpactEditorScreenState extends State<ImpactEditorScreen> {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showShotDetails(
|
||||
BuildContext context,
|
||||
AnalysisProvider provider,
|
||||
Shot shot,
|
||||
) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (context) => Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'Impact #${provider.shots.indexOf(shot) + 1}',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18),
|
||||
),
|
||||
Text(
|
||||
'ID: ${shot.id}',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodySmall
|
||||
?.copyWith(color: Colors.grey, fontSize: 10),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.score),
|
||||
title: const Text('Modifier le score'),
|
||||
trailing: DropdownButton<int>(
|
||||
value: shot.score.clamp(0, 10),
|
||||
items: List.generate(11, (index) => index)
|
||||
.map(
|
||||
(s) => DropdownMenuItem(
|
||||
value: s,
|
||||
child: Text(
|
||||
'$s',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 18,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
onChanged: (newScore) {
|
||||
if (newScore != null) {
|
||||
provider.updateShotScore(shot.id, newScore);
|
||||
Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () {
|
||||
provider.removeShot(shot.id);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
icon: const Icon(Icons.delete, color: Colors.red),
|
||||
label: const Text(
|
||||
'SUPPRIMER',
|
||||
style: TextStyle(color: Colors.red),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -17,12 +17,22 @@ class ScoreCard extends StatelessWidget {
|
||||
final ScoreResult? scoreResult;
|
||||
final TargetType targetType;
|
||||
|
||||
/// Score cumulé de la session (cibles déjà validées + cible en cours).
|
||||
///
|
||||
/// null hors session : le bandeau de session n'est alors pas affiché.
|
||||
final int? sessionTotalScore;
|
||||
|
||||
/// Nombre de cibles comptabilisées dans [sessionTotalScore].
|
||||
final int sessionTargetCount;
|
||||
|
||||
const ScoreCard({
|
||||
super.key,
|
||||
required this.totalScore,
|
||||
required this.shotCount,
|
||||
this.scoreResult,
|
||||
required this.targetType,
|
||||
this.sessionTotalScore,
|
||||
this.sessionTargetCount = 0,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -46,13 +56,23 @@ class ScoreCard extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (sessionTotalScore != null) ...[
|
||||
Flexible(child: _buildSessionBadge(context)),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
MetricInfoButton(
|
||||
title: 'Score',
|
||||
explanations: [
|
||||
MetricExplanation(
|
||||
'Total',
|
||||
'Somme des points de tous vos impacts, sur le maximum '
|
||||
'possible (nombre d\'impacts × $maxScore points).',
|
||||
'Somme des points de tous vos impacts sur CETTE cible, '
|
||||
'sur le maximum possible (nombre d\'impacts × '
|
||||
'$maxScore points).',
|
||||
),
|
||||
const MetricExplanation(
|
||||
'Session',
|
||||
'Score cumulé de toutes les cibles de la session en '
|
||||
'cours, cible affichée comprise.',
|
||||
),
|
||||
const MetricExplanation(
|
||||
'Impacts',
|
||||
@@ -122,6 +142,30 @@ class ScoreCard extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
/// Bandeau compact rappelant le score total de la session en cours.
|
||||
Widget _buildSessionBadge(BuildContext context) {
|
||||
// Le nombre de cibles n'est rappelé qu'à partir de la deuxième : sur la
|
||||
// première il n'apporte rien et allonge le bandeau pour rien.
|
||||
final cibles = sessionTargetCount > 1 ? ' ($sessionTargetCount cibles)' : '';
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primaryColor.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
'Session : $sessionTotalScore pts$cibles',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppTheme.primaryColor,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildScoreStat(
|
||||
BuildContext context,
|
||||
String label,
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
/// Les anneaux sont répartis proportionnellement.
|
||||
library;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../../data/models/target_type.dart';
|
||||
|
||||
class TargetCalibration extends StatefulWidget {
|
||||
@@ -43,6 +43,15 @@ class TargetCalibration extends StatefulWidget {
|
||||
}
|
||||
|
||||
class TargetCalibrationState extends State<TargetCalibration> {
|
||||
/// Bornes du rayon global (mesurées pour laisser de la liberté sans
|
||||
/// débordement incontrôlé).
|
||||
static const double minRadius = 0.3;
|
||||
static const double maxRadius = 0.95;
|
||||
|
||||
/// Bornes de l'espacement : max bridé à 0.70 pour confiner le dernier cercle.
|
||||
static const double minSpacing = 0.01;
|
||||
static const double maxSpacing = 0.70;
|
||||
|
||||
late double _centerX;
|
||||
late double _centerY;
|
||||
late double _radius;
|
||||
@@ -130,7 +139,14 @@ class TargetCalibrationState extends State<TargetCalibration> {
|
||||
_currentEspacementRatio = (_radius > 0) ? (_innerRadius / _radius).clamp(0.01, 0.70) : 0.1;
|
||||
shouldReinit = true;
|
||||
}
|
||||
if (widget.initialRingRadii != oldWidget.initialRingRadii && widget.initialRingRadii != null) {
|
||||
// On ne rafraîchit le profil d'usine que si les rayons entrants proviennent
|
||||
// réellement d'une nouvelle détection (ils diffèrent de notre état courant).
|
||||
// Sinon il s'agit de l'écho de notre propre _notifyChange (aller-retour via
|
||||
// le provider) : le clobber effacerait le profil d'origine et casserait le
|
||||
// bouton de réinitialisation de l'espacement.
|
||||
if (widget.initialRingRadii != oldWidget.initialRingRadii &&
|
||||
widget.initialRingRadii != null &&
|
||||
!listEquals(widget.initialRingRadii, _ringRadii)) {
|
||||
_originalRingRadii = List.from(widget.initialRingRadii!);
|
||||
shouldReinit = true;
|
||||
}
|
||||
@@ -146,9 +162,9 @@ class TargetCalibrationState extends State<TargetCalibration> {
|
||||
builder: (context, constraints) {
|
||||
final size = constraints.biggest;
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
GestureDetector(
|
||||
// Les réglages (taille / espacement) sont rendus par l'écran hôte
|
||||
// AU-DESSUS de l'image : rien ne vient masquer la cible ici.
|
||||
return GestureDetector(
|
||||
onScaleStart: (details) {
|
||||
_baseRadiusBeforeScale = _radius;
|
||||
final tapX = details.localFocalPoint.dx / size.width;
|
||||
@@ -178,125 +194,68 @@ class TargetCalibrationState extends State<TargetCalibration> {
|
||||
isDraggingInnerRadius: false,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Positioned(
|
||||
top: 10,
|
||||
left: 40,
|
||||
right: 40,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black54,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'Options d\'espacement avancées',
|
||||
style: TextStyle(color: Colors.white70, fontSize: 11, fontWeight: FontWeight.w500),
|
||||
),
|
||||
SizedBox(
|
||||
height: 28,
|
||||
child: Switch(
|
||||
value: _showEspacement,
|
||||
activeThumbColor: const Color(0xFF00FF00),
|
||||
onChanged: (bool value) {
|
||||
// ---------------------------------------------------------------------------
|
||||
// API publique pilotée par l'écran hôte (panneau de réglages hors de l'image)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Espacement courant (rayon du premier anneau, en fraction du rayon global).
|
||||
double get spacingRatio => _currentEspacementRatio;
|
||||
|
||||
/// Mode d'espacement manuel actif ou non.
|
||||
bool get isSpacingModeEnabled => _showEspacement;
|
||||
|
||||
/// Applique une nouvelle taille globale (rayon normalisé).
|
||||
void setRadius(double value) {
|
||||
setState(() {
|
||||
_showEspacement = value;
|
||||
// Quand on désactive l'espacement manuel, on restaure la configuration d'usine !
|
||||
if (!value) {
|
||||
_radius = value.clamp(minRadius, maxRadius);
|
||||
_innerRadius = _radius * _currentEspacementRatio;
|
||||
// Si le mode avancé n'est pas coché, on applique la taille pure sans
|
||||
// détruire le ratio d'origine.
|
||||
_initRingRadii(forceRecalculate: _showEspacement);
|
||||
});
|
||||
_notifyChange();
|
||||
}
|
||||
|
||||
/// Agrandit / réduit la cible de [deltaPixels] pixels.
|
||||
///
|
||||
/// La conversion utilise la plus petite dimension de [size], exactement comme
|
||||
/// le painter, pour que le pas corresponde bien à un pixel à l'écran.
|
||||
void adjustRadiusByPixels(double deltaPixels, Size size) {
|
||||
final minDim = size.width < size.height ? size.width : size.height;
|
||||
if (minDim <= 0) return;
|
||||
setRadius(_radius + deltaPixels / minDim);
|
||||
}
|
||||
|
||||
/// Active ou non le réglage manuel de l'espacement.
|
||||
///
|
||||
/// À la désactivation, on restaure la configuration d'usine.
|
||||
void setSpacingMode(bool enabled) {
|
||||
setState(() {
|
||||
_showEspacement = enabled;
|
||||
if (!enabled) {
|
||||
_initRingRadii(forceRecalculate: false);
|
||||
}
|
||||
});
|
||||
_notifyChange();
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
}
|
||||
|
||||
// Slider pour la taille (toujours visible)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black54,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Text('Taille ', style: TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.bold)),
|
||||
const Icon(Icons.zoom_out, color: Colors.white, size: 16),
|
||||
Expanded(
|
||||
child: Slider(
|
||||
// SÉCURITÉ : Ouverture mesurée des bornes pour plus de liberté sans débordement incontrôlé
|
||||
value: _radius.clamp(0.3, 0.95),
|
||||
min: 0.3,
|
||||
max: 0.95,
|
||||
activeColor: AppTheme.primaryColor,
|
||||
onChanged: (value) {
|
||||
/// Applique un nouvel espacement entre les anneaux.
|
||||
void setSpacingRatio(double value) {
|
||||
setState(() {
|
||||
_radius = value;
|
||||
_innerRadius = _radius * _currentEspacementRatio;
|
||||
// CORRECTION : Si le mode avancé n'est pas coché, on applique la taille pure sans détruire le ratio d'origine
|
||||
_initRingRadii(forceRecalculate: _showEspacement);
|
||||
});
|
||||
_notifyChange();
|
||||
},
|
||||
),
|
||||
),
|
||||
const Icon(Icons.zoom_in, color: Colors.white, size: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Affichage conditionnel du slider d'espacement orange
|
||||
if (_showEspacement) ...[
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 4, 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black54,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Text('Espacement ', style: TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.bold)),
|
||||
const Icon(Icons.compress, color: Colors.white, size: 16),
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Slider(
|
||||
value: _currentEspacementRatio,
|
||||
min: 0.01,
|
||||
// SÉCURITÉ : Écartement max bridé à 0.70 pour confiner le dernier cercle
|
||||
max: 0.70,
|
||||
activeColor: Colors.orange,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_currentEspacementRatio = value;
|
||||
_currentEspacementRatio = value.clamp(minSpacing, maxSpacing);
|
||||
_innerRadius = _radius * _currentEspacementRatio;
|
||||
_initRingRadii(forceRecalculate: true);
|
||||
});
|
||||
_notifyChange();
|
||||
},
|
||||
),
|
||||
),
|
||||
const Icon(Icons.expand, color: Colors.white, size: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
// CORRECTION DU BOUTON RESET : Restaure désormais le vrai profil d'usine de l'IA
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh, color: Colors.white70, size: 20),
|
||||
onPressed: () {
|
||||
}
|
||||
|
||||
/// Restaure le vrai profil d'usine détecté sur l'image.
|
||||
void resetSpacing() {
|
||||
setState(() {
|
||||
if (_originalRingRadii != null) {
|
||||
_initRingRadii(forceRecalculate: false);
|
||||
@@ -311,22 +270,6 @@ class TargetCalibrationState extends State<TargetCalibration> {
|
||||
}
|
||||
});
|
||||
_notifyChange();
|
||||
},
|
||||
tooltip: 'Réinitialiser l\'espacement',
|
||||
constraints: const BoxConstraints(),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildDirectionalControls(BuildContext context, Size size) {
|
||||
@@ -340,7 +283,8 @@ class TargetCalibrationState extends State<TargetCalibration> {
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildSignLabel('−'),
|
||||
// Réduit la cible d'un pixel.
|
||||
_buildSizeButton('−', () => adjustRadiusByPixels(-1, size)),
|
||||
const SizedBox(width: 12),
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -358,17 +302,27 @@ class TargetCalibrationState extends State<TargetCalibration> {
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
_buildSignLabel('+'),
|
||||
// Agrandit la cible d'un pixel.
|
||||
_buildSizeButton('+', () => adjustRadiusByPixels(1, size)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Symbole purement décoratif affiché de part et d'autre de la croix.
|
||||
Widget _buildSignLabel(String text) {
|
||||
return Text(
|
||||
text,
|
||||
style: const TextStyle(color: Colors.white54, fontSize: 28, fontWeight: FontWeight.bold),
|
||||
/// Bouton − / + de part et d'autre de la croix : ajuste la TAILLE de la cible.
|
||||
Widget _buildSizeButton(String sign, VoidCallback onPressed) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(color: Colors.black87, borderRadius: BorderRadius.circular(8)),
|
||||
child: IconButton(
|
||||
icon: Text(
|
||||
sign,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 24, fontWeight: FontWeight.bold),
|
||||
),
|
||||
onPressed: onPressed,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: 44, minHeight: 44),
|
||||
tooltip: sign == '+' ? 'Agrandir la cible' : 'Réduire la cible',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -407,7 +361,7 @@ class TargetCalibrationState extends State<TargetCalibration> {
|
||||
void _onScaleUpdate(ScaleUpdateDetails details, Size size) {
|
||||
setState(() {
|
||||
if (details.pointerCount == 2) {
|
||||
_radius = (_baseRadiusBeforeScale * details.scale).clamp(0.3, 0.95);
|
||||
_radius = (_baseRadiusBeforeScale * details.scale).clamp(minRadius, maxRadius);
|
||||
_innerRadius = _radius * _currentEspacementRatio;
|
||||
_initRingRadii(forceRecalculate: _showEspacement);
|
||||
} else if (_isDraggingCenter) {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/// Overlay visuel de la cible.
|
||||
///
|
||||
/// Dessine les anneaux de la cible, les impacts détectés, le cercle de groupement
|
||||
/// et les impacts de référence. Gère uniquement la SÉLECTION d'impacts existants
|
||||
/// (tap sur un impact). L'AJOUT d'un impact est délégué à l'écran parent pour
|
||||
/// éviter tout conflit de gestes avec le zoom/pan de l'InteractiveViewer.
|
||||
/// Dessine les anneaux de la cible, les impacts et le cercle de groupement.
|
||||
/// Gère uniquement la SÉLECTION d'impacts existants (tap sur un impact).
|
||||
/// L'AJOUT d'un impact est délégué à l'écran parent pour éviter tout conflit
|
||||
/// de gestes avec le zoom/pan de l'InteractiveViewer.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -20,11 +20,9 @@ class TargetOverlay extends StatelessWidget {
|
||||
final int ringCount;
|
||||
final List<double>? ringRadii;
|
||||
final void Function(Shot shot)? onShotTapped;
|
||||
final void Function(double x, double y)? onAddShot;
|
||||
final double? groupingCenterX;
|
||||
final double? groupingCenterY;
|
||||
final double? groupingDiameter;
|
||||
final List<Shot>? referenceImpacts;
|
||||
final double zoomScale;
|
||||
final bool showRings;
|
||||
|
||||
@@ -38,11 +36,9 @@ class TargetOverlay extends StatelessWidget {
|
||||
this.ringCount = 10,
|
||||
this.ringRadii,
|
||||
this.onShotTapped,
|
||||
this.onAddShot,
|
||||
this.groupingCenterX,
|
||||
this.groupingCenterY,
|
||||
this.groupingDiameter,
|
||||
this.referenceImpacts,
|
||||
this.zoomScale = 1.0,
|
||||
this.showRings = false,
|
||||
});
|
||||
@@ -57,7 +53,10 @@ class TargetOverlay extends StatelessWidget {
|
||||
// Désormais :
|
||||
// - L'AJOUT d'impact est géré par le GestureDetector parent (analysis_screen).
|
||||
// - Seule la SÉLECTION d'un impact existant est gérée ici, via des petites
|
||||
// zones de tap localisées (deferToChild) placées sur chaque impact.
|
||||
// zones de tap localisées (deferToChild) placées sur chaque impact —
|
||||
// et UNIQUEMENT si [onShotTapped] est fourni. Sans callback, aucune zone
|
||||
// de tap n'est créée : un tap pile sur un impact traverse jusqu'au parent
|
||||
// au lieu d'être absorbé dans le vide.
|
||||
return IgnorePointer(
|
||||
ignoring: false,
|
||||
child: CustomPaint(
|
||||
@@ -72,12 +71,13 @@ class TargetOverlay extends StatelessWidget {
|
||||
groupingCenterX: groupingCenterX,
|
||||
groupingCenterY: groupingCenterY,
|
||||
groupingDiameter: groupingDiameter,
|
||||
referenceImpacts: referenceImpacts,
|
||||
zoomScale: zoomScale,
|
||||
showRings: showRings,
|
||||
),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final onTapped = onShotTapped;
|
||||
if (onTapped == null) return const SizedBox.expand();
|
||||
return Stack(
|
||||
children: shots.map((shot) {
|
||||
final x = shot.x * constraints.maxWidth;
|
||||
@@ -94,7 +94,7 @@ class TargetOverlay extends StatelessWidget {
|
||||
// Le reste de la surface reste donc disponible pour le
|
||||
// pinch/pan de l'InteractiveViewer.
|
||||
behavior: HitTestBehavior.deferToChild,
|
||||
onTap: () => onShotTapped?.call(shot),
|
||||
onTap: () => onTapped(shot),
|
||||
child: Container(
|
||||
width: tapSize,
|
||||
height: tapSize,
|
||||
@@ -127,7 +127,6 @@ class _TargetOverlayPainter extends CustomPainter {
|
||||
final double? groupingCenterX;
|
||||
final double? groupingCenterY;
|
||||
final double? groupingDiameter;
|
||||
final List<Shot>? referenceImpacts;
|
||||
final double zoomScale;
|
||||
final bool showRings;
|
||||
|
||||
@@ -142,7 +141,6 @@ class _TargetOverlayPainter extends CustomPainter {
|
||||
this.groupingCenterX,
|
||||
this.groupingCenterY,
|
||||
this.groupingDiameter,
|
||||
this.referenceImpacts,
|
||||
this.zoomScale = 1.0,
|
||||
this.showRings = false,
|
||||
});
|
||||
@@ -163,13 +161,6 @@ class _TargetOverlayPainter extends CustomPainter {
|
||||
for (final shot in shots) {
|
||||
_drawImpact(canvas, size, shot);
|
||||
}
|
||||
|
||||
// Draw reference impacts (with different color)
|
||||
if (referenceImpacts != null) {
|
||||
for (final ref in referenceImpacts!) {
|
||||
_drawReferenceImpact(canvas, size, ref);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _drawTargetCenter(Canvas canvas, Size size) {
|
||||
@@ -319,48 +310,6 @@ class _TargetOverlayPainter extends CustomPainter {
|
||||
);
|
||||
}
|
||||
|
||||
void _drawReferenceImpact(Canvas canvas, Size size, Shot ref) {
|
||||
final x = ref.x * size.width;
|
||||
final y = ref.y * size.height;
|
||||
|
||||
// Tailles fixes divisées par le zoom pour rester constantes à l'écran
|
||||
final outerRadius = 12 / zoomScale;
|
||||
final innerRadius = 10 / zoomScale;
|
||||
final strokeWidth = 3 / zoomScale;
|
||||
final fontSize = 12 / zoomScale;
|
||||
|
||||
// Draw outer circle (white outline for visibility)
|
||||
final outlinePaint = Paint()
|
||||
..color = Colors.white
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = strokeWidth;
|
||||
canvas.drawCircle(Offset(x, y), outerRadius, outlinePaint);
|
||||
|
||||
// Draw reference marker (purple)
|
||||
final refPaint = Paint()
|
||||
..color = Colors.deepPurple
|
||||
..style = PaintingStyle.fill;
|
||||
canvas.drawCircle(Offset(x, y), innerRadius, refPaint);
|
||||
|
||||
// Draw "R" to indicate reference
|
||||
final textPainter = TextPainter(
|
||||
text: TextSpan(
|
||||
text: 'R',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: fontSize,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
textDirection: TextDirection.ltr,
|
||||
);
|
||||
textPainter.layout();
|
||||
textPainter.paint(
|
||||
canvas,
|
||||
Offset(x - textPainter.width / 2, y - textPainter.height / 2),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _TargetOverlayPainter oldDelegate) {
|
||||
return shots != oldDelegate.shots ||
|
||||
@@ -372,7 +321,6 @@ class _TargetOverlayPainter extends CustomPainter {
|
||||
groupingCenterX != oldDelegate.groupingCenterX ||
|
||||
groupingCenterY != oldDelegate.groupingCenterY ||
|
||||
groupingDiameter != oldDelegate.groupingDiameter ||
|
||||
referenceImpacts != oldDelegate.referenceImpacts ||
|
||||
zoomScale != oldDelegate.zoomScale ||
|
||||
showRings != oldDelegate.showRings;
|
||||
}
|
||||
|
||||
@@ -128,7 +128,6 @@ class _CaptureScreenState extends State<CaptureScreen>
|
||||
|
||||
// Détection OpenCV (cible circulaire) — on garde le résultat COMPLET
|
||||
TargetDetectionResult? _targetResult; // NOUVEAU : centre + rayon de la cible
|
||||
Timer? _detectionTimer;
|
||||
bool _isAnalyzingFrame = false;
|
||||
|
||||
// NOUVEAU : Données IMU en temps réel
|
||||
@@ -148,7 +147,6 @@ class _CaptureScreenState extends State<CaptureScreen>
|
||||
void dispose() {
|
||||
_cameraController?.dispose();
|
||||
_scanAnimationController.dispose();
|
||||
_detectionTimer?.cancel();
|
||||
_parallelismSubscription?.cancel(); // NOUVEAU
|
||||
_parallelismService.dispose(); // NOUVEAU
|
||||
super.dispose();
|
||||
@@ -300,9 +298,6 @@ class _CaptureScreenState extends State<CaptureScreen>
|
||||
// Détection OpenCV périodique (inchangée)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
void _startAlignmentDetection() {
|
||||
_detectionTimer?.cancel();
|
||||
_detectionTimer = null;
|
||||
|
||||
DateTime? lastAnalysis;
|
||||
|
||||
_cameraController!.startImageStream((CameraImage cameraImage) async {
|
||||
@@ -310,7 +305,9 @@ class _CaptureScreenState extends State<CaptureScreen>
|
||||
final now = DateTime.now();
|
||||
// Cadence ~1 s : assez réactif pour suivre la cible sans saturer le CPU.
|
||||
if (lastAnalysis != null &&
|
||||
now.difference(lastAnalysis!).inMilliseconds < 1000) return;
|
||||
now.difference(lastAnalysis!).inMilliseconds < 1000) {
|
||||
return;
|
||||
}
|
||||
lastAnalysis = now;
|
||||
_isAnalyzingFrame = true;
|
||||
|
||||
@@ -362,8 +359,6 @@ class _CaptureScreenState extends State<CaptureScreen>
|
||||
}
|
||||
|
||||
void _stopAlignmentDetection() {
|
||||
_detectionTimer?.cancel();
|
||||
_detectionTimer = null;
|
||||
try {
|
||||
if (_cameraController != null &&
|
||||
_cameraController!.value.isStreamingImages) {
|
||||
@@ -521,10 +516,10 @@ class _CaptureScreenState extends State<CaptureScreen>
|
||||
height: MediaQuery.of(context).size.width * 0.85,
|
||||
child: Stack(
|
||||
children: [
|
||||
_buildCameraCorner(TopLeft: true, color: frameColor),
|
||||
_buildCameraCorner(TopRight: true, color: frameColor),
|
||||
_buildCameraCorner(BottomLeft: true, color: frameColor),
|
||||
_buildCameraCorner(BottomRight: true, color: frameColor),
|
||||
_buildCameraCorner(topLeft: true, color: frameColor),
|
||||
_buildCameraCorner(topRight: true, color: frameColor),
|
||||
_buildCameraCorner(bottomLeft: true, color: frameColor),
|
||||
_buildCameraCorner(bottomRight: true, color: frameColor),
|
||||
Center(
|
||||
child: Container(
|
||||
width: 24,
|
||||
@@ -831,32 +826,32 @@ class _CaptureScreenState extends State<CaptureScreen>
|
||||
// Coins du cadre (inchangés)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
Widget _buildCameraCorner({
|
||||
bool TopLeft = false,
|
||||
bool TopRight = false,
|
||||
bool BottomLeft = false,
|
||||
bool BottomRight = false,
|
||||
bool topLeft = false,
|
||||
bool topRight = false,
|
||||
bool bottomLeft = false,
|
||||
bool bottomRight = false,
|
||||
Color color = const Color(0xFF00FF00),
|
||||
}) {
|
||||
return Positioned(
|
||||
top: (TopLeft || TopRight) ? 10 : null,
|
||||
bottom: (BottomLeft || BottomRight) ? 10 : null,
|
||||
left: (TopLeft || BottomLeft) ? 10 : null,
|
||||
right: (TopRight || BottomRight) ? 10 : null,
|
||||
top: (topLeft || topRight) ? 10 : null,
|
||||
bottom: (bottomLeft || bottomRight) ? 10 : null,
|
||||
left: (topLeft || bottomLeft) ? 10 : null,
|
||||
right: (topRight || bottomRight) ? 10 : null,
|
||||
child: Container(
|
||||
width: 20,
|
||||
height: 20,
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
top: (TopLeft || TopRight)
|
||||
top: (topLeft || topRight)
|
||||
? BorderSide(color: color, width: 4)
|
||||
: BorderSide.none,
|
||||
bottom: (BottomLeft || BottomRight)
|
||||
bottom: (bottomLeft || bottomRight)
|
||||
? BorderSide(color: color, width: 4)
|
||||
: BorderSide.none,
|
||||
left: (TopLeft || BottomLeft)
|
||||
left: (topLeft || bottomLeft)
|
||||
? BorderSide(color: color, width: 4)
|
||||
: BorderSide.none,
|
||||
right: (TopRight || BottomRight)
|
||||
right: (topRight || bottomRight)
|
||||
? BorderSide(color: color, width: 4)
|
||||
: BorderSide.none,
|
||||
),
|
||||
|
||||
@@ -27,6 +27,11 @@ class CropScreen extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _CropScreenState extends State<CropScreen> {
|
||||
// Bornes et pas de la rotation, partagés par la jauge et les boutons − / +.
|
||||
static const double _minRotation = -15.0;
|
||||
static const double _maxRotation = 15.0;
|
||||
static const double _rotationStep = 0.1;
|
||||
|
||||
final ImageCropService _cropService = ImageCropService();
|
||||
|
||||
// États de transformation
|
||||
@@ -113,8 +118,8 @@ class _CropScreenState extends State<CropScreen> {
|
||||
const SizedBox(width: 8),
|
||||
Flexible(
|
||||
child: Text(
|
||||
'Alignez et pivotez la cible sur la croix',
|
||||
style: TextStyle(color: Colors.white.withOpacity(0.8), fontSize: 14, fontWeight: FontWeight.w500),
|
||||
'Zoomer au maximum puis aligner votre cible',
|
||||
style: TextStyle(color: Colors.white.withValues(alpha: 0.8), fontSize: 14, fontWeight: FontWeight.w500),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -122,7 +127,7 @@ class _CropScreenState extends State<CropScreen> {
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Glissez à un doigt pour déplacer, pincez pour zoomer',
|
||||
style: TextStyle(color: Colors.white.withOpacity(0.5), fontSize: 12),
|
||||
style: TextStyle(color: Colors.white.withValues(alpha: 0.5), fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -155,7 +160,8 @@ class _CropScreenState extends State<CropScreen> {
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// CROIX DIRECTIONNELLE — déplace la photo pixel par pixel.
|
||||
// CROIX DIRECTIONNELLE — déplace la photo pixel par pixel,
|
||||
// encadrée par les deux boutons de rotation fine.
|
||||
_buildDirectionalPad(),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
@@ -180,12 +186,13 @@ class _CropScreenState extends State<CropScreen> {
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.rotate_left, color: Colors.white38, size: 20),
|
||||
// Les icônes de sens de rotation ont rejoint les boutons − / +
|
||||
// de la croix directionnelle.
|
||||
Expanded(
|
||||
child: Slider(
|
||||
value: _rotation,
|
||||
min: -15.0,
|
||||
max: 15.0,
|
||||
min: _minRotation,
|
||||
max: _maxRotation,
|
||||
divisions: 300,
|
||||
label: '${_rotation.toStringAsFixed(1)}°',
|
||||
activeColor: const Color(0xFF1A73E8),
|
||||
@@ -197,7 +204,6 @@ class _CropScreenState extends State<CropScreen> {
|
||||
},
|
||||
),
|
||||
),
|
||||
const Icon(Icons.rotate_right, color: Colors.white38, size: 20),
|
||||
const SizedBox(width: 4),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.restart_alt, color: Colors.white54, size: 20),
|
||||
@@ -274,7 +280,7 @@ class _CropScreenState extends State<CropScreen> {
|
||||
child: Transform(
|
||||
transform: Matrix4.identity()
|
||||
..setTranslationRaw(_offset.dx, _offset.dy, 0)
|
||||
..scale(_scale, _scale)
|
||||
..scaleByDouble(_scale, _scale, _scale, 1.0)
|
||||
..rotateZ(_rotation * (math.pi / 180)),
|
||||
alignment: Alignment.center,
|
||||
child: Image.file(
|
||||
@@ -296,14 +302,14 @@ class _CropScreenState extends State<CropScreen> {
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
height: 1.5,
|
||||
color: const Color(0xFF00FF00).withOpacity(0.6),
|
||||
color: const Color(0xFF00FF00).withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
Center(
|
||||
child: Container(
|
||||
width: 1.5,
|
||||
height: double.infinity,
|
||||
color: const Color(0xFF00FF00).withOpacity(0.6),
|
||||
color: const Color(0xFF00FF00).withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -363,12 +369,18 @@ class _CropScreenState extends State<CropScreen> {
|
||||
}
|
||||
|
||||
// Croix directionnelle compacte pour déplacer la photo pixel par pixel.
|
||||
// Les symboles « − » et « + » de part et d'autre sont purement décoratifs.
|
||||
// Les boutons « − » et « + » de part et d'autre pivotent l'image de 0,1°.
|
||||
Widget _buildDirectionalPad() {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_buildCropSignLabel('−'),
|
||||
_buildRotationButton(
|
||||
icon: Icons.rotate_left,
|
||||
sign: '−',
|
||||
iconFirst: true,
|
||||
tooltip: 'Pivoter vers la gauche',
|
||||
onPressed: () => _rotateBy(-_rotationStep),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -386,7 +398,13 @@ class _CropScreenState extends State<CropScreen> {
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
_buildCropSignLabel('+'),
|
||||
_buildRotationButton(
|
||||
icon: Icons.rotate_right,
|
||||
sign: '+',
|
||||
iconFirst: false,
|
||||
tooltip: 'Pivoter vers la droite',
|
||||
onPressed: () => _rotateBy(_rotationStep),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -404,10 +422,40 @@ class _CropScreenState extends State<CropScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCropSignLabel(String text) {
|
||||
return Text(
|
||||
text,
|
||||
style: const TextStyle(color: Colors.white54, fontSize: 24, fontWeight: FontWeight.bold),
|
||||
// Bouton de rotation : l'icône de sens est accolée au signe − / +.
|
||||
Widget _buildRotationButton({
|
||||
required IconData icon,
|
||||
required String sign,
|
||||
required bool iconFirst,
|
||||
required String tooltip,
|
||||
required VoidCallback onPressed,
|
||||
}) {
|
||||
final content = [
|
||||
Icon(icon, color: Colors.white70, size: 20),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
sign,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 22, fontWeight: FontWeight.bold),
|
||||
),
|
||||
];
|
||||
|
||||
return Tooltip(
|
||||
message: tooltip,
|
||||
child: Material(
|
||||
color: Colors.black54,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: InkWell(
|
||||
onTap: onPressed,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: iconFirst ? content : content.reversed.toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -417,6 +465,17 @@ class _CropScreenState extends State<CropScreen> {
|
||||
});
|
||||
}
|
||||
|
||||
/// Pivote l'image de [delta] degrés, dans les mêmes bornes que la jauge.
|
||||
///
|
||||
/// La valeur est arrondie au dixième pour rester calée sur les crans de la
|
||||
/// jauge (300 divisions sur 30°) et sur l'affichage.
|
||||
void _rotateBy(double delta) {
|
||||
setState(() {
|
||||
final value = (_rotation + delta).clamp(_minRotation, _maxRotation);
|
||||
_rotation = (value * 10).roundToDouble() / 10;
|
||||
});
|
||||
}
|
||||
|
||||
void _onScaleStart(ScaleStartDetails details) {
|
||||
_baseScale = _scale;
|
||||
_startFocalPoint = details.focalPoint;
|
||||
|
||||
@@ -52,7 +52,7 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit),
|
||||
onPressed: () => _showEditWeaponDialog(context),
|
||||
onPressed: _showEditWeaponDialog,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -74,7 +74,7 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
|
||||
),
|
||||
),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: () => _showAddMaintenanceDialog(context),
|
||||
onPressed: _showAddMaintenanceDialog,
|
||||
label: const Text('Entretien'),
|
||||
icon: const Icon(Icons.build),
|
||||
),
|
||||
@@ -166,7 +166,7 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
|
||||
padding: const EdgeInsets.all(8),
|
||||
minimumSize: const Size(36, 36),
|
||||
),
|
||||
onPressed: () => _showEditAccessoriesDialog(context),
|
||||
onPressed: _showEditAccessoriesDialog,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -223,7 +223,7 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
|
||||
title: Text(entry.type.displayName),
|
||||
subtitle: Text(entry.description),
|
||||
trailing: Text(DateFormat('dd/MM/yy').format(entry.date), style: const TextStyle(fontSize: 12)),
|
||||
onLongPress: () => _confirmDeleteMaintenance(context, entry),
|
||||
onLongPress: () => _confirmDeleteMaintenance(entry),
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -279,7 +279,9 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
void _showEditWeaponDialog(BuildContext context) async {
|
||||
void _showEditWeaponDialog() async {
|
||||
// Capturé AVANT l'await : plus aucun usage du context après le dialogue.
|
||||
final repository = context.read<SessionRepository>();
|
||||
final nameController = TextEditingController(text: _weapon.name);
|
||||
final caliberController = TextEditingController(text: _weapon.caliber);
|
||||
final magCountController = TextEditingController(text: _weapon.magazineCount.toString());
|
||||
@@ -309,7 +311,7 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
|
||||
decoration: const InputDecoration(labelText: 'Surnom / Custom Name', hintText: 'ex: Mon Glock de Compète'),
|
||||
)),
|
||||
DropdownButtonFormField<WeaponType>(
|
||||
value: selectedType,
|
||||
initialValue: selectedType,
|
||||
decoration: const InputDecoration(labelText: 'Type'),
|
||||
items: WeaponType.values.map((t) => DropdownMenuItem(value: t, child: Text(t.displayName))).toList(),
|
||||
onChanged: (v) => setState(() => selectedType = v!),
|
||||
@@ -383,8 +385,8 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
|
||||
customName: customNameController.text.isEmpty ? null : customNameController.text,
|
||||
);
|
||||
|
||||
final repository = context.read<SessionRepository>();
|
||||
await repository.updateWeapon(updatedWeapon);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_weapon = updatedWeapon;
|
||||
});
|
||||
@@ -395,7 +397,9 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
|
||||
// fortement des accessoires, on ne modifie PAS l'arme existante : on crée une
|
||||
// nouvelle arme (même modèle, accessoires différents) dans l'armurerie afin de
|
||||
// pouvoir comparer les scores selon la configuration utilisée.
|
||||
void _showEditAccessoriesDialog(BuildContext context) async {
|
||||
void _showEditAccessoriesDialog() async {
|
||||
// Capturé AVANT l'await : plus aucun usage du context après le dialogue.
|
||||
final repository = context.read<SessionRepository>();
|
||||
final opticController = TextEditingController(text: _weapon.optic);
|
||||
final silencerController = TextEditingController(text: _weapon.silencer);
|
||||
final triggerController = TextEditingController(text: _weapon.trigger);
|
||||
@@ -444,7 +448,7 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
|
||||
),
|
||||
);
|
||||
|
||||
if (result != true) return;
|
||||
if (result != true || !mounted) return;
|
||||
|
||||
final newOptic = opticController.text.isEmpty ? null : opticController.text;
|
||||
final newSilencer = silencerController.text.isEmpty ? null : silencerController.text;
|
||||
@@ -456,11 +460,9 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
|
||||
newSilencer == _weapon.silencer &&
|
||||
newTrigger == _weapon.trigger;
|
||||
if (unchanged) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Aucun accessoire modifié, aucune configuration créée.')),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -472,7 +474,6 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
|
||||
newCustomName = accessories.isEmpty ? null : '${_weapon.name} ($accessories)';
|
||||
}
|
||||
|
||||
final repository = context.read<SessionRepository>();
|
||||
final newWeapon = await repository.addWeapon(
|
||||
name: _weapon.name,
|
||||
type: _weapon.type,
|
||||
@@ -497,7 +498,9 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
void _showAddMaintenanceDialog(BuildContext context) async {
|
||||
void _showAddMaintenanceDialog() async {
|
||||
// Capturé AVANT l'await : plus aucun usage du context après le dialogue.
|
||||
final repository = context.read<SessionRepository>();
|
||||
final descController = TextEditingController();
|
||||
MaintenanceType selectedType = MaintenanceType.cleaning;
|
||||
DateTime selectedDate = DateTime.now();
|
||||
@@ -511,7 +514,7 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
DropdownButtonFormField<MaintenanceType>(
|
||||
value: selectedType,
|
||||
initialValue: selectedType,
|
||||
decoration: const InputDecoration(labelText: 'Type d\'intervention'),
|
||||
items: MaintenanceType.values.map((t) => DropdownMenuItem(value: t, child: Text(t.displayName))).toList(),
|
||||
onChanged: (v) => setState(() => selectedType = v!),
|
||||
@@ -553,7 +556,6 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
|
||||
);
|
||||
|
||||
if (result == true && descController.text.isNotEmpty) {
|
||||
final repository = context.read<SessionRepository>();
|
||||
await repository.addMaintenanceEntry(
|
||||
weaponId: _weapon.id,
|
||||
type: selectedType,
|
||||
@@ -561,11 +563,14 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
|
||||
roundsSinceLast: _totalRounds,
|
||||
date: selectedDate,
|
||||
);
|
||||
if (!mounted) return;
|
||||
_loadData();
|
||||
}
|
||||
}
|
||||
|
||||
void _confirmDeleteMaintenance(BuildContext context, MaintenanceEntry entry) async {
|
||||
void _confirmDeleteMaintenance(MaintenanceEntry entry) async {
|
||||
// Capturé AVANT l'await : plus aucun usage du context après le dialogue.
|
||||
final repository = context.read<SessionRepository>();
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
@@ -582,8 +587,8 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
|
||||
);
|
||||
|
||||
if (confirmed == true) {
|
||||
final repository = context.read<SessionRepository>();
|
||||
await repository.deleteMaintenanceEntry(entry.id);
|
||||
if (!mounted) return;
|
||||
_loadData();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,12 @@ import '../../data/repositories/session_repository.dart';
|
||||
import 'weapon_detail_screen.dart';
|
||||
|
||||
class WeaponListScreen extends StatefulWidget {
|
||||
const WeaponListScreen({super.key});
|
||||
/// Incrémenté par la navigation à chaque ouverture de l'onglet Armurerie
|
||||
/// (l'écran est gardé vivant par l'IndexedStack et ne se rafraîchit pas
|
||||
/// seul : sans ça, un import de sauvegarde resterait invisible).
|
||||
final int refreshTick;
|
||||
|
||||
const WeaponListScreen({super.key, this.refreshTick = 0});
|
||||
|
||||
@override
|
||||
State<WeaponListScreen> createState() => _WeaponListScreenState();
|
||||
@@ -23,6 +28,14 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
|
||||
_loadWeapons();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(WeaponListScreen oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.refreshTick != widget.refreshTick) {
|
||||
_loadWeapons();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadWeapons() async {
|
||||
final repository = context.read<SessionRepository>();
|
||||
final weapons = await repository.getWeapons();
|
||||
@@ -46,7 +59,7 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
|
||||
? _buildEmptyState()
|
||||
: _buildWeaponList(),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: () => _showAddWeaponDialog(context),
|
||||
onPressed: _showAddWeaponDialog,
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
);
|
||||
@@ -62,7 +75,7 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
|
||||
const Text('Aucune arme enregistrée'),
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton(
|
||||
onPressed: () => _showAddWeaponDialog(context),
|
||||
onPressed: _showAddWeaponDialog,
|
||||
child: const Text('Ajouter ma première arme'),
|
||||
),
|
||||
],
|
||||
@@ -88,7 +101,7 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
|
||||
);
|
||||
_loadWeapons(); // Reload in case it was edited or maintenance was added
|
||||
},
|
||||
onLongPress: () => _confirmDelete(context, weapon),
|
||||
onLongPress: () => _confirmDelete(weapon),
|
||||
child: Padding(
|
||||
// La hauteur du cadre s'adapte automatiquement à la liste d'accessoires.
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
@@ -179,7 +192,9 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
|
||||
}).toList();
|
||||
}
|
||||
|
||||
void _showAddWeaponDialog(BuildContext context) async {
|
||||
void _showAddWeaponDialog() async {
|
||||
// Capturé AVANT l'await : plus aucun usage du context après le dialogue.
|
||||
final repository = context.read<SessionRepository>();
|
||||
final nameController = TextEditingController();
|
||||
final caliberController = TextEditingController();
|
||||
final magCountController = TextEditingController(text: '2');
|
||||
@@ -200,7 +215,7 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
|
||||
decoration: const InputDecoration(labelText: 'Nom de l\'arme', hintText: 'ex: Glock 17'),
|
||||
),
|
||||
DropdownButtonFormField<WeaponType>(
|
||||
value: selectedType,
|
||||
initialValue: selectedType,
|
||||
decoration: const InputDecoration(labelText: 'Type'),
|
||||
items: WeaponType.values.map((t) => DropdownMenuItem(value: t, child: Text(t.displayName))).toList(),
|
||||
onChanged: (v) => setState(() => selectedType = v!),
|
||||
@@ -240,7 +255,6 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
|
||||
);
|
||||
|
||||
if (result == true && nameController.text.isNotEmpty) {
|
||||
final repository = context.read<SessionRepository>();
|
||||
await repository.addWeapon(
|
||||
name: nameController.text,
|
||||
type: selectedType,
|
||||
@@ -248,11 +262,14 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
|
||||
magazineCount: int.tryParse(magCountController.text) ?? 1,
|
||||
magazineCapacity: int.tryParse(magCapController.text) ?? 10,
|
||||
);
|
||||
if (!mounted) return;
|
||||
_loadWeapons();
|
||||
}
|
||||
}
|
||||
|
||||
void _confirmDelete(BuildContext context, Weapon weapon) async {
|
||||
void _confirmDelete(Weapon weapon) async {
|
||||
// Capturé AVANT l'await : plus aucun usage du context après le dialogue.
|
||||
final repository = context.read<SessionRepository>();
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
@@ -269,8 +286,8 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
|
||||
);
|
||||
|
||||
if (confirmed == true) {
|
||||
final repository = context.read<SessionRepository>();
|
||||
await repository.deleteWeapon(weapon.id);
|
||||
if (!mounted) return;
|
||||
_loadWeapons();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,13 @@ import 'widgets/session_list_item.dart';
|
||||
import 'widgets/history_chart.dart';
|
||||
|
||||
class HistoryScreen extends StatefulWidget {
|
||||
const HistoryScreen({super.key});
|
||||
/// Incrémenté par la navigation à chaque ouverture de l'onglet Historique,
|
||||
/// pour forcer un rechargement des sessions (l'écran est gardé vivant par un
|
||||
/// IndexedStack, sinon une session tout juste clôturée n'apparaîtrait pas
|
||||
/// tant qu'on ne tire pas manuellement pour rafraîchir).
|
||||
final int refreshTick;
|
||||
|
||||
const HistoryScreen({super.key, this.refreshTick = 0});
|
||||
|
||||
@override
|
||||
State<HistoryScreen> createState() => _HistoryScreenState();
|
||||
@@ -31,6 +37,16 @@ class _HistoryScreenState extends State<HistoryScreen> {
|
||||
_loadSessions();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(HistoryScreen oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
// L'onglet vient d'être ré-ouvert : on recharge pour afficher les sessions
|
||||
// récemment clôturées sans avoir à tirer manuellement pour rafraîchir.
|
||||
if (oldWidget.refreshTick != widget.refreshTick) {
|
||||
_loadSessions();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadSessions() async {
|
||||
if (!mounted) return;
|
||||
setState(() => _isLoading = true);
|
||||
@@ -43,6 +59,14 @@ class _HistoryScreenState extends State<HistoryScreen> {
|
||||
setState(() {
|
||||
_sessions = sessions;
|
||||
|
||||
// --- FILTRAGE PAR TYPE DE CIBLE ---
|
||||
// Une session est retenue si au moins une de ses cibles est du type choisi.
|
||||
if (_filterType != null) {
|
||||
_sessions = _sessions
|
||||
.where((s) => s.analyses.any((a) => a.targetType == _filterType))
|
||||
.toList();
|
||||
}
|
||||
|
||||
// --- LOGIQUE DE FILTRAGE PAR PÉRIODE ---
|
||||
if (_selectedDateRange != null) {
|
||||
_sessions = _sessions.where((s) {
|
||||
@@ -103,7 +127,7 @@ class _HistoryScreenState extends State<HistoryScreen> {
|
||||
onSurface: Colors.black87, // Texte des dates (Noir sur Blanc)
|
||||
secondary: AppTheme.primaryColor,
|
||||
),
|
||||
dialogBackgroundColor: Colors.white,
|
||||
dialogTheme: const DialogThemeData(backgroundColor: Colors.white),
|
||||
textButtonTheme: TextButtonThemeData(
|
||||
style: TextButton.styleFrom(foregroundColor: AppTheme.primaryColor),
|
||||
),
|
||||
@@ -125,17 +149,29 @@ class _HistoryScreenState extends State<HistoryScreen> {
|
||||
appBar: AppBar(
|
||||
title: const Text('Historique'),
|
||||
actions: [
|
||||
PopupMenuButton<TargetType?>(
|
||||
icon: const Icon(Icons.filter_list),
|
||||
onSelected: (type) {
|
||||
setState(() => _filterType = type);
|
||||
// NOTE : on passe par un String sentinelle ('all') car un
|
||||
// PopupMenuItem avec value null ne déclenche jamais onSelected
|
||||
// (Flutter l'interprète comme une annulation du menu).
|
||||
PopupMenuButton<String>(
|
||||
icon: Icon(
|
||||
Icons.filter_list,
|
||||
// Icône colorée quand un filtre est actif, pour le rendre visible.
|
||||
color: _filterType != null ? AppTheme.primaryColor : null,
|
||||
),
|
||||
onSelected: (value) {
|
||||
setState(() {
|
||||
_filterType =
|
||||
value == 'all' ? null : TargetType.fromString(value);
|
||||
});
|
||||
_loadSessions();
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
const PopupMenuItem(value: null, child: Text('Tous')),
|
||||
const PopupMenuItem(value: 'all', child: Text('Tous')),
|
||||
...TargetType.values.map(
|
||||
(type) =>
|
||||
PopupMenuItem(value: type, child: Text(type.displayName)),
|
||||
(type) => PopupMenuItem(
|
||||
value: type.name,
|
||||
child: Text(type.displayName),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../main_navigation_holder.dart';
|
||||
import '../../core/constants/app_constants.dart';
|
||||
import '../../core/theme/app_theme.dart';
|
||||
import '../../data/repositories/session_repository.dart';
|
||||
@@ -14,7 +15,12 @@ import '../session/session_provider.dart';
|
||||
import 'widgets/stats_card.dart';
|
||||
|
||||
class HomeScreen extends StatefulWidget {
|
||||
const HomeScreen({super.key});
|
||||
/// Incrémenté par la navigation à chaque ouverture de l'onglet Accueil
|
||||
/// (l'écran est gardé vivant par l'IndexedStack : sans ça, un import de
|
||||
/// sauvegarde resterait invisible sur le dashboard).
|
||||
final int refreshTick;
|
||||
|
||||
const HomeScreen({super.key, this.refreshTick = 0});
|
||||
|
||||
@override
|
||||
State<HomeScreen> createState() => _HomeScreenState();
|
||||
@@ -34,6 +40,14 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
_loadStats();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(HomeScreen oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.refreshTick != widget.refreshTick) {
|
||||
_loadStats();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
@@ -230,6 +244,8 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
backgroundColor: AppTheme.successColor,
|
||||
),
|
||||
);
|
||||
// Fin de session : on bascule sur les statistiques.
|
||||
openMainTab(mainTabStats);
|
||||
},
|
||||
child: const Text('TERMINER', style: TextStyle(color: Colors.redAccent)),
|
||||
),
|
||||
@@ -339,7 +355,7 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
Expanded(
|
||||
child: StatsCard(
|
||||
icon: Icons.emoji_events,
|
||||
title: 'Meilleur',
|
||||
title: 'Meilleur score',
|
||||
value: '${_stats!['bestScore']}',
|
||||
color: AppTheme.successColor,
|
||||
),
|
||||
|
||||
@@ -168,7 +168,7 @@ class _SessionSetupScreenState extends State<SessionSetupScreen> {
|
||||
Column(
|
||||
children: [
|
||||
DropdownButtonFormField<Weapon>(
|
||||
value: _selectedWeapon,
|
||||
initialValue: _selectedWeapon,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Sélectionner une arme',
|
||||
prefixIcon: const Icon(Icons.shield),
|
||||
|
||||
@@ -22,7 +22,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
String? _identityPhrase;
|
||||
int? _photoCount;
|
||||
bool _isLoadingStats = false;
|
||||
bool _isCheckingStatus = false;
|
||||
bool _isUploadEnabled = false;
|
||||
bool _isBanned = false;
|
||||
String? _banReason;
|
||||
String _serverUrl = 'http://localhost:3000';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -32,11 +36,17 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
|
||||
Future<void> _loadIdentity() async {
|
||||
final phrase = await _walletService.getIdentityPhrase();
|
||||
final serverUrl = await _walletService.getServerBaseUrl();
|
||||
final isBanned = await _walletService.isBanned();
|
||||
final banReason = await _walletService.getBanReason();
|
||||
final isEnabled = await _walletService.isUploadEnabled();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_identityPhrase = phrase;
|
||||
_isUploadEnabled = isEnabled;
|
||||
_serverUrl = serverUrl;
|
||||
_isBanned = isBanned;
|
||||
_banReason = banReason;
|
||||
_isUploadEnabled = isEnabled && !isBanned;
|
||||
});
|
||||
_fetchStats(phrase);
|
||||
}
|
||||
@@ -47,19 +57,18 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
try {
|
||||
final phraseBytes = utf8.encode(phrase);
|
||||
final walletHash = sha256.convert(phraseBytes).toString();
|
||||
final baseUrl = await _walletService.getServerBaseUrl();
|
||||
|
||||
// Utilise 10.0.2.2 pour l'émulateur Android, sinon localhost
|
||||
final baseUrl = Theme.of(context).platform == TargetPlatform.android
|
||||
? 'http://10.0.2.2:3000'
|
||||
: 'http://localhost:3000';
|
||||
|
||||
final response = await http.get(Uri.parse('$baseUrl/api/stats/$walletHash'));
|
||||
final response = await http.get(Uri.parse('$baseUrl/api/stats/$walletHash')).timeout(
|
||||
const Duration(seconds: 4),
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(response.body);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_photoCount = data['stats']['photo_count'] ?? 0;
|
||||
_serverUrl = baseUrl;
|
||||
_isLoadingStats = false;
|
||||
});
|
||||
}
|
||||
@@ -67,11 +76,77 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
if (mounted) setState(() => _isLoadingStats = false);
|
||||
}
|
||||
} catch (e) {
|
||||
print('Erreur lors du chargement des statistiques: $e');
|
||||
debugPrint('Erreur lors du chargement des statistiques: $e');
|
||||
if (mounted) setState(() => _isLoadingStats = false);
|
||||
}
|
||||
}
|
||||
|
||||
/// Action manuelle de l'utilisateur pour vérifier et synchroniser son statut auprès du serveur
|
||||
Future<void> _refreshAccountStatus() async {
|
||||
setState(() => _isCheckingStatus = true);
|
||||
try {
|
||||
final isBanned = await _walletService.syncBanStatus();
|
||||
final banReason = await _walletService.getBanReason();
|
||||
final isEnabled = await _walletService.isUploadEnabled();
|
||||
|
||||
if (_identityPhrase != null) {
|
||||
await _fetchStats(_identityPhrase!);
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() {
|
||||
_isBanned = isBanned;
|
||||
_banReason = banReason;
|
||||
_isUploadEnabled = isEnabled && !isBanned;
|
||||
_isCheckingStatus = false;
|
||||
});
|
||||
|
||||
if (isBanned) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Row(
|
||||
children: [
|
||||
const Icon(Icons.block, color: Colors.white),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text('Compte toujours suspendu : ${_banReason ?? "Non-respect des règles"}'),
|
||||
),
|
||||
],
|
||||
),
|
||||
backgroundColor: AppTheme.errorColor,
|
||||
duration: const Duration(seconds: 5),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Row(
|
||||
children: [
|
||||
Icon(Icons.check_circle, color: Colors.white),
|
||||
SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text('Statut actualisé : votre compte est actif et autorisé !'),
|
||||
),
|
||||
],
|
||||
),
|
||||
backgroundColor: AppTheme.successColor,
|
||||
duration: Duration(seconds: 4),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _isCheckingStatus = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Impossible de joindre le serveur : $e'),
|
||||
backgroundColor: AppTheme.errorColor,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _copyToClipboard() {
|
||||
if (_identityPhrase != null) {
|
||||
Clipboard.setData(ClipboardData(text: _identityPhrase!));
|
||||
@@ -182,8 +257,34 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
}
|
||||
|
||||
void _showOptInDisclaimer(bool value) {
|
||||
if (_isBanned) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Row(
|
||||
children: [
|
||||
Icon(Icons.block, color: AppTheme.errorColor),
|
||||
SizedBox(width: 10),
|
||||
Expanded(child: Text('Programme IA Suspendu')),
|
||||
],
|
||||
),
|
||||
content: Text(
|
||||
'Votre participation au programme d\'entraînement a été suspendue par la modération pour le motif suivant :\n\n'
|
||||
'« ${_banReason ?? "Envois non conformes ou inappropriés"} »\n\n'
|
||||
'L\'envoi de photos pour l\'entraînement est définitivement désactivé sur cet appareil.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Compris'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
// Si on désactive, pas besoin de disclaimer, on le fait direct.
|
||||
_walletService.setUploadEnabled(false);
|
||||
setState(() {
|
||||
_isUploadEnabled = false;
|
||||
@@ -196,28 +297,69 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
barrierDismissible: false,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Participer à l\'entraînement IA', textAlign: TextAlign.center),
|
||||
content: const Column(
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(Icons.psychology, size: 48, color: AppTheme.primaryColor),
|
||||
SizedBox(height: 16),
|
||||
Text(
|
||||
'En activant cette option, vous acceptez d\'envoyer vos photos de cibles à notre serveur sécurisé.',
|
||||
const Center(
|
||||
child: Icon(Icons.psychology, size: 48, color: AppTheme.primaryColor),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'En activant cette option, vous acceptez d\'envoyer vos photos de cibles au serveur d\'entraînement IA.',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
SizedBox(height: 12),
|
||||
Text(
|
||||
'🔒 Anonymat Garanti : L\'envoi est totalement anonymisé. Votre identité est remplacée par un hash cryptographique incassable.',
|
||||
const SizedBox(height: 14),
|
||||
const Text(
|
||||
'🔒 Anonymat Garanti : L\'envoi est totalement anonymisé. Votre identité est remplacée par un hash cryptographique.',
|
||||
style: TextStyle(fontSize: 13),
|
||||
),
|
||||
SizedBox(height: 12),
|
||||
Text(
|
||||
'🎁 Avantages Futurs : Votre contribution (Hash de Wallet) sera comptabilisée. Lors de la sortie publique de notre IA, les participants actifs recevront des fonctionnalités premium ou des badges exclusifs en récompense !',
|
||||
const SizedBox(height: 10),
|
||||
const Text(
|
||||
'🎁 Récompenses : Vos contributions sont enregistrées pour vous donner accès à des fonctionnalités exclusives.',
|
||||
style: TextStyle(fontSize: 13),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.errorColor.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: AppTheme.errorColor.withValues(alpha: 0.4)),
|
||||
),
|
||||
child: const Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.warning_amber_rounded, color: AppTheme.errorColor, size: 20),
|
||||
SizedBox(width: 8),
|
||||
Text(
|
||||
'Règles strictes & Bannissement',
|
||||
style: TextStyle(
|
||||
color: AppTheme.errorColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 6),
|
||||
Text(
|
||||
'Vous vous engagez à n\'envoyer que de réelles cibles de tir conformes. '
|
||||
'Tout envoi de photos non conformes, fausses cibles, images floues ou contenu inapproprié '
|
||||
'entraînera le bannissement immédiat et définitif de votre compte. '
|
||||
'L\'application perdra définitivement la possibilité d\'envoyer des photos.',
|
||||
style: TextStyle(fontSize: 12, height: 1.4),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
@@ -238,7 +380,70 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
),
|
||||
);
|
||||
},
|
||||
child: const Text('J\'accepte', style: TextStyle(color: Colors.white)),
|
||||
child: const Text('J\'accepte les règles', style: TextStyle(color: Colors.white)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showEditServerUrlDialog() {
|
||||
final urlController = TextEditingController(text: _serverUrl);
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Adresse du Serveur IA'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Indiquez l\'adresse IP ou l\'URL du serveur backend IA (port 3000) :',
|
||||
style: TextStyle(fontSize: 13, color: Colors.grey),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: urlController,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Ex: http://192.168.1.50:3000',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'💡 Sur émulateur : http://10.0.2.2:3000\n💡 Sur smartphone réel : IP locale de votre PC (ex: http://192.168.1.X:3000)',
|
||||
style: TextStyle(fontSize: 11, color: Colors.grey),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Annuler'),
|
||||
),
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(backgroundColor: AppTheme.primaryColor),
|
||||
onPressed: () async {
|
||||
final newUrl = urlController.text.trim();
|
||||
if (newUrl.isNotEmpty) {
|
||||
await _walletService.setServerBaseUrl(newUrl);
|
||||
setState(() {
|
||||
_serverUrl = newUrl;
|
||||
});
|
||||
if (_identityPhrase != null) {
|
||||
_fetchStats(_identityPhrase!);
|
||||
}
|
||||
}
|
||||
if (!context.mounted) return;
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Adresse du serveur IA mise à jour'),
|
||||
backgroundColor: AppTheme.successColor,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: const Text('Enregistrer', style: TextStyle(color: Colors.white)),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -363,7 +568,77 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
_buildSectionHeader('Configuration Serveur IA'),
|
||||
_buildSectionHeader('Programme d\'Entraînement IA'),
|
||||
if (_isBanned) ...[
|
||||
Card(
|
||||
elevation: 0,
|
||||
color: AppTheme.errorColor.withValues(alpha: 0.12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12.0),
|
||||
side: BorderSide(color: AppTheme.errorColor.withValues(alpha: 0.6), width: 1.5),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Row(
|
||||
children: [
|
||||
Icon(Icons.block, color: AppTheme.errorColor, size: 22),
|
||||
SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Participation au programme IA suspendue',
|
||||
style: TextStyle(
|
||||
color: AppTheme.errorColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Motif : ${_banReason ?? "Envois d'images non appropriées ou fausses cibles."}',
|
||||
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
const Text(
|
||||
'Suite à des envois non conformes, la possibilité de participer au programme d\'entraînement et d\'envoyer des photos a été révoquée pour cet identifiant.',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey, height: 1.3),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton.icon(
|
||||
icon: _isCheckingStatus
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
|
||||
)
|
||||
: const Icon(Icons.refresh, size: 18),
|
||||
label: Text(
|
||||
_isCheckingStatus ? 'Vérification...' : 'ACTUALISER MON STATUT',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppTheme.errorColor,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
onPressed: _isCheckingStatus ? null : _refreshAccountStatus,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
] else ...[
|
||||
Card(
|
||||
elevation: 0,
|
||||
color: Colors.transparent,
|
||||
@@ -371,10 +646,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
child: SwitchListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 4.0),
|
||||
title: const Text('Participer à l\'entraînement IA', style: TextStyle(fontWeight: FontWeight.w500)),
|
||||
subtitle: const Text('Aidez-nous à améliorer la détection tout en gagnant des avantages', style: TextStyle(fontSize: 12)),
|
||||
subtitle: const Text('Aidez-nous à améliorer la détection (soumis aux règles strictes)', style: TextStyle(fontSize: 12)),
|
||||
secondary: const Icon(Icons.psychology, color: AppTheme.textPrimary),
|
||||
value: _isUploadEnabled,
|
||||
activeColor: AppTheme.primaryColor,
|
||||
activeThumbColor: AppTheme.primaryColor,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12.0),
|
||||
side: BorderSide(color: Colors.grey.withAlpha(50), width: 1),
|
||||
@@ -387,12 +662,16 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
context: context,
|
||||
icon: Icons.cloud_outlined,
|
||||
title: 'Adresse du Serveur IA',
|
||||
subtitle: 'http://localhost:3000/api/upload',
|
||||
onTap: () {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Changement d\'adresse à venir')),
|
||||
);
|
||||
},
|
||||
subtitle: _serverUrl,
|
||||
onTap: _showEditServerUrlDialog,
|
||||
),
|
||||
if (_isUploadEnabled)
|
||||
_buildSettingsTile(
|
||||
context: context,
|
||||
icon: Icons.sync,
|
||||
title: 'Actualiser mon statut',
|
||||
subtitle: _isCheckingStatus ? 'Vérification en cours...' : 'Vérifier l\'état du compte auprès du serveur',
|
||||
onTap: _isCheckingStatus ? () {} : _refreshAccountStatus,
|
||||
),
|
||||
if (_isUploadEnabled)
|
||||
_buildSettingsTile(
|
||||
@@ -408,6 +687,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 24),
|
||||
_buildSectionHeader('À propos'),
|
||||
|
||||
@@ -1,17 +1,27 @@
|
||||
import 'dart:io';
|
||||
import 'dart:math' as math;
|
||||
import 'dart:ui' as ui;
|
||||
import 'package:file_selector/file_selector.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:fl_chart/fl_chart.dart'; // La librairie pour les courbes
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import '../../core/widgets/metric_info_button.dart';
|
||||
import '../../data/models/session.dart';
|
||||
import '../../data/repositories/session_repository.dart';
|
||||
import '../../services/backup_service.dart';
|
||||
import '../../services/statistics_service.dart';
|
||||
|
||||
class StatisticsScreen extends StatefulWidget {
|
||||
final Session? singleSession;
|
||||
|
||||
const StatisticsScreen({super.key, this.singleSession});
|
||||
/// Incrémenté par la navigation à chaque fois que l'onglet Stats est ouvert,
|
||||
/// pour forcer un rechargement des données (l'écran est gardé vivant par un
|
||||
/// IndexedStack, sinon les filtres arme/distance resteraient périmés).
|
||||
final int refreshTick;
|
||||
|
||||
const StatisticsScreen({super.key, this.singleSession, this.refreshTick = 0});
|
||||
|
||||
@override
|
||||
State<StatisticsScreen> createState() => _StatisticsScreenState();
|
||||
@@ -19,7 +29,7 @@ class StatisticsScreen extends StatefulWidget {
|
||||
|
||||
class _StatisticsScreenState extends State<StatisticsScreen> {
|
||||
final StatisticsService _statisticsService = StatisticsService();
|
||||
StatsPeriod _selectedPeriod = StatsPeriod.all;
|
||||
final StatsPeriod _selectedPeriod = StatsPeriod.all;
|
||||
SessionStatistics? _statistics;
|
||||
bool _isLoading = true;
|
||||
List<Session> _allSessions = [];
|
||||
@@ -38,12 +48,26 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
|
||||
bool _showingB = false; // false = on affiche A, true = on affiche B
|
||||
bool get _compareMode => _compareA != null && _compareB != null;
|
||||
|
||||
// --- Sauvegarde (export/import JSON) ---
|
||||
bool _isBackupBusy = false;
|
||||
final GlobalKey _exportButtonKey = GlobalKey();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _loadStatistics());
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(StatisticsScreen oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
// L'onglet vient d'être ré-ouvert : on recharge pour rafraîchir les listes
|
||||
// de filtres (armes/distances) et les stats avec les nouvelles sessions.
|
||||
if (oldWidget.refreshTick != widget.refreshTick) {
|
||||
_loadStatistics();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadStatistics() async {
|
||||
if (!mounted) return;
|
||||
setState(() => _isLoading = true);
|
||||
@@ -217,11 +241,14 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
|
||||
),
|
||||
body: _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: RefreshIndicator(
|
||||
onRefresh: _loadStatistics,
|
||||
child: SingleChildScrollView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.all(16),
|
||||
: Column(
|
||||
children: [
|
||||
// HEADER COLLANT : filtres + comparateur toujours visibles
|
||||
Material(
|
||||
elevation: 2,
|
||||
color: Theme.of(context).scaffoldBackgroundColor,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 12),
|
||||
child: Column(
|
||||
children: [
|
||||
// 1. FILTRES (Arme et Distance)
|
||||
@@ -256,12 +283,22 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// 1bis. COMPARATEUR DE SESSIONS
|
||||
_buildComparator(),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: RefreshIndicator(
|
||||
onRefresh: _loadStatistics,
|
||||
child: SingleChildScrollView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
children: [
|
||||
// 2. DONNÉES RAPIDES (Tirs et Sessions)
|
||||
Row(
|
||||
children: [
|
||||
@@ -346,11 +383,19 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
|
||||
_buildBiasWarning(),
|
||||
],
|
||||
|
||||
const SizedBox(height: 25),
|
||||
|
||||
// 5. SAUVEGARDE : export/import de toutes les données
|
||||
_buildBackupSection(),
|
||||
|
||||
const SizedBox(height: 30),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -851,6 +896,263 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// --- SAUVEGARDE : EXPORT / IMPORT JSON ---
|
||||
|
||||
Widget _buildBackupSection() {
|
||||
final theme = Theme.of(context);
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.save_alt, color: theme.textTheme.titleMedium?.color, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Sauvegarde',
|
||||
style: TextStyle(
|
||||
color: theme.textTheme.titleMedium?.color,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Exporte toutes tes sessions, tes stats et ton armurerie dans un '
|
||||
'fichier JSON, à envoyer où tu veux. L\'import fusionne le fichier '
|
||||
'avec tes données actuelles (rien n\'est effacé).',
|
||||
style: TextStyle(
|
||||
color: theme.textTheme.bodySmall?.color?.withValues(alpha: 0.6),
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ElevatedButton.icon(
|
||||
key: _exportButtonKey,
|
||||
onPressed: _isBackupBusy ? null : _exportBackup,
|
||||
icon: const Icon(Icons.ios_share, size: 18),
|
||||
label: const Text('Exporter'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _isBackupBusy ? null : _importBackup,
|
||||
icon: const Icon(Icons.file_download_outlined, size: 18),
|
||||
label: const Text('Importer'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_isBackupBusy) ...[
|
||||
const SizedBox(height: 12),
|
||||
const LinearProgressIndicator(minHeight: 2),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
BackupService _backupService() =>
|
||||
BackupService(repository: context.read<SessionRepository>());
|
||||
|
||||
Future<void> _exportBackup() async {
|
||||
final includeImages = await _askIncludeImages();
|
||||
if (includeImages == null || !mounted) return;
|
||||
|
||||
setState(() => _isBackupBusy = true);
|
||||
try {
|
||||
final file = await _backupService().exportToFile(
|
||||
includeImages: includeImages,
|
||||
);
|
||||
if (!mounted) return;
|
||||
|
||||
// sharePositionOrigin : obligatoire pour l'iPad, ignoré ailleurs.
|
||||
final box = _exportButtonKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
final origin = box != null && box.hasSize
|
||||
? box.localToGlobal(Offset.zero) & box.size
|
||||
: null;
|
||||
|
||||
await SharePlus.instance.share(
|
||||
ShareParams(
|
||||
files: [XFile(file.path, mimeType: 'application/json')],
|
||||
fileNameOverrides: [p.basename(file.path)],
|
||||
subject: 'Sauvegarde IMPACT',
|
||||
text: 'Sauvegarde de mes sessions de tir (${p.basename(file.path)})',
|
||||
sharePositionOrigin: origin,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
_showMessage('Export impossible : $e', isError: true);
|
||||
} finally {
|
||||
if (mounted) setState(() => _isBackupBusy = false);
|
||||
}
|
||||
}
|
||||
|
||||
/// Les photos de cibles alourdissent énormément le fichier : on laisse le
|
||||
/// choix entre une sauvegarde légère (données seules) et une sauvegarde
|
||||
/// complète (photos encodées dans le JSON).
|
||||
Future<bool?> _askIncludeImages() {
|
||||
var includeImages = false;
|
||||
return showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => StatefulBuilder(
|
||||
builder: (context, setDialogState) => AlertDialog(
|
||||
title: const Text('Exporter mes données'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Le fichier contiendra toutes tes sessions (cibles, impacts, '
|
||||
'scores), tes statistiques et ton armurerie.',
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
value: includeImages,
|
||||
onChanged: (v) => setDialogState(() => includeImages = v),
|
||||
title: const Text('Inclure les photos des cibles'),
|
||||
subtitle: const Text(
|
||||
'Sauvegarde complète, mais fichier beaucoup plus lourd.',
|
||||
style: TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Annuler'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.pop(context, includeImages),
|
||||
child: const Text('Exporter'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _importBackup() async {
|
||||
const jsonGroup = XTypeGroup(
|
||||
label: 'Sauvegarde IMPACT (.json)',
|
||||
extensions: ['json'],
|
||||
// Android renvoie parfois un type générique pour un .json : on accepte
|
||||
// large, le contenu est validé à la lecture de toute façon.
|
||||
mimeTypes: ['application/json', 'text/plain', 'application/octet-stream'],
|
||||
uniformTypeIdentifiers: ['public.json', 'public.text'],
|
||||
);
|
||||
|
||||
final picked = await openFile(acceptedTypeGroups: const [jsonGroup]);
|
||||
if (picked == null || !mounted) return;
|
||||
|
||||
setState(() => _isBackupBusy = true);
|
||||
try {
|
||||
final service = _backupService();
|
||||
final preview = await service.readBackup(File(picked.path));
|
||||
if (!mounted) return;
|
||||
|
||||
final confirmed = await _confirmImport(preview);
|
||||
if (confirmed != true || !mounted) return;
|
||||
|
||||
final result = await service.applyBackup(preview);
|
||||
await _loadStatistics();
|
||||
if (!mounted) return;
|
||||
|
||||
final details = [
|
||||
'${result.sessions} session(s)',
|
||||
'${result.weapons} arme(s)',
|
||||
if (result.maintenance > 0) '${result.maintenance} entretien(s)',
|
||||
if (result.images > 0) '${result.images} photo(s)',
|
||||
].join(' · ');
|
||||
_showMessage(
|
||||
result.errors.isEmpty
|
||||
? 'Import terminé : $details'
|
||||
: 'Import terminé : $details — ${result.errors.length} entrée(s) ignorée(s)',
|
||||
);
|
||||
} on BackupFormatException catch (e) {
|
||||
_showMessage(e.message, isError: true);
|
||||
} catch (e) {
|
||||
_showMessage('Import impossible : $e', isError: true);
|
||||
} finally {
|
||||
if (mounted) setState(() => _isBackupBusy = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool?> _confirmImport(BackupPreview preview) {
|
||||
final date = preview.exportedAt;
|
||||
final dateLabel = date == null
|
||||
? null
|
||||
: '${date.day.toString().padLeft(2, '0')}/'
|
||||
'${date.month.toString().padLeft(2, '0')}/${date.year}';
|
||||
|
||||
return showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Importer cette sauvegarde ?'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (dateLabel != null) Text('Exportée le $dateLabel'),
|
||||
if (dateLabel != null) const SizedBox(height: 8),
|
||||
Text('• ${preview.sessionCount} session(s)'),
|
||||
Text('• ${preview.targetCount} cible(s), ${preview.shotCount} impact(s)'),
|
||||
Text('• ${preview.weaponCount} arme(s), ${preview.maintenanceCount} entretien(s)'),
|
||||
Text(preview.hasImages
|
||||
? '• Photos des cibles incluses'
|
||||
: '• Sans photos de cibles'),
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
'Les données actuelles sont conservées. Une session déjà '
|
||||
'présente est simplement mise à jour.',
|
||||
style: TextStyle(fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Annuler'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text('Importer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showMessage(String message, {bool isError = false}) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
backgroundColor: isError ? Colors.red.shade700 : null,
|
||||
duration: const Duration(seconds: 4),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HeatMapPainter extends CustomPainter {
|
||||
|
||||
@@ -7,10 +7,8 @@ import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||
import 'app.dart';
|
||||
import 'core/theme/theme_provider.dart';
|
||||
import 'data/repositories/session_repository.dart';
|
||||
import 'services/target_detection_service.dart';
|
||||
import 'services/score_calculator_service.dart';
|
||||
import 'services/grouping_analyzer_service.dart';
|
||||
import 'services/image_processing_service.dart';
|
||||
import 'features/session/session_provider.dart';
|
||||
|
||||
void main() async {
|
||||
@@ -32,14 +30,6 @@ void main() async {
|
||||
runApp(
|
||||
MultiProvider(
|
||||
providers: [
|
||||
Provider<ImageProcessingService>(
|
||||
create: (_) => ImageProcessingService(),
|
||||
),
|
||||
Provider<TargetDetectionService>(
|
||||
create: (context) => TargetDetectionService(
|
||||
imageProcessingService: context.read<ImageProcessingService>(),
|
||||
),
|
||||
),
|
||||
Provider<ScoreCalculatorService>(
|
||||
create: (_) => ScoreCalculatorService(),
|
||||
),
|
||||
|
||||
@@ -5,6 +5,23 @@ import 'features/statistics/statistics_screen.dart';
|
||||
import 'features/garage/weapon_list_screen.dart';
|
||||
import 'core/theme/app_theme.dart';
|
||||
|
||||
/// Index des onglets de la barre de navigation principale.
|
||||
const int mainTabHome = 0;
|
||||
const int mainTabHistory = 1;
|
||||
const int mainTabStats = 2;
|
||||
const int mainTabGarage = 3;
|
||||
|
||||
/// Clé du holder : permet de piloter l'onglet actif depuis n'importe quel
|
||||
/// écran (ex. fin de session -> onglet Stats).
|
||||
final GlobalKey<State<MainNavigationHolder>> mainNavKey =
|
||||
GlobalKey<State<MainNavigationHolder>>();
|
||||
|
||||
/// Ouvre l'onglet [index] de la navigation principale.
|
||||
void openMainTab(int index) {
|
||||
final state = mainNavKey.currentState;
|
||||
if (state is _MainNavigationHolderState) state.selectTab(index);
|
||||
}
|
||||
|
||||
class MainNavigationHolder extends StatefulWidget {
|
||||
const MainNavigationHolder({super.key});
|
||||
|
||||
@@ -15,25 +32,37 @@ class MainNavigationHolder extends StatefulWidget {
|
||||
class _MainNavigationHolderState extends State<MainNavigationHolder> {
|
||||
int _selectedIndex = 0;
|
||||
|
||||
final List<Widget> _screens = [
|
||||
const HomeScreen(),
|
||||
const HistoryScreen(),
|
||||
const StatisticsScreen(),
|
||||
const WeaponListScreen(),
|
||||
];
|
||||
// Incrémentés à chaque ouverture de l'onglet correspondant pour forcer le
|
||||
// rechargement (les écrans sont gardés vivants par l'IndexedStack et ne se
|
||||
// rafraîchissent pas seuls).
|
||||
int _statsTick = 0;
|
||||
int _historyTick = 0;
|
||||
int _homeTick = 0;
|
||||
int _garageTick = 0;
|
||||
|
||||
void _onItemTapped(int index) {
|
||||
void selectTab(int index) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_selectedIndex = index;
|
||||
if (index == mainTabHome) _homeTick++;
|
||||
if (index == mainTabHistory) _historyTick++;
|
||||
if (index == mainTabStats) _statsTick++;
|
||||
if (index == mainTabGarage) _garageTick++;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final screens = [
|
||||
HomeScreen(refreshTick: _homeTick),
|
||||
HistoryScreen(refreshTick: _historyTick),
|
||||
StatisticsScreen(refreshTick: _statsTick),
|
||||
WeaponListScreen(refreshTick: _garageTick),
|
||||
];
|
||||
return Scaffold(
|
||||
body: IndexedStack(
|
||||
index: _selectedIndex,
|
||||
children: _screens,
|
||||
children: screens,
|
||||
),
|
||||
bottomNavigationBar: Container(
|
||||
decoration: BoxDecoration(
|
||||
@@ -47,7 +76,7 @@ class _MainNavigationHolderState extends State<MainNavigationHolder> {
|
||||
),
|
||||
child: BottomNavigationBar(
|
||||
currentIndex: _selectedIndex,
|
||||
onTap: _onItemTapped,
|
||||
onTap: selectTab,
|
||||
type: BottomNavigationBarType.fixed,
|
||||
backgroundColor: Theme.of(context).cardColor,
|
||||
selectedItemColor: AppTheme.primaryColor,
|
||||
|
||||
@@ -8,16 +8,62 @@ import '../data/models/shot.dart';
|
||||
import '../data/models/target_type.dart';
|
||||
import 'wallet_identity_service.dart';
|
||||
|
||||
class AiExportService {
|
||||
// Utilise 10.0.2.2 pour l'émulateur Android, sinon localhost.
|
||||
// Pour un appareil physique, il faudra utiliser l'IP locale du PC (ex: 192.168.1.X).
|
||||
static String get _defaultApiUrl {
|
||||
if (Platform.isAndroid) {
|
||||
return 'http://10.0.2.2:3000/api/upload';
|
||||
}
|
||||
return 'http://localhost:3000/api/upload';
|
||||
/// Résultat détaillé de l'exportation vers le serveur IA
|
||||
class AiExportResult {
|
||||
final bool isSuccess;
|
||||
final String code;
|
||||
final String message;
|
||||
final String? reason;
|
||||
final bool isBanned;
|
||||
final Map<String, dynamic>? targetValidation;
|
||||
|
||||
AiExportResult({
|
||||
required this.isSuccess,
|
||||
required this.code,
|
||||
required this.message,
|
||||
this.reason,
|
||||
this.isBanned = false,
|
||||
this.targetValidation,
|
||||
});
|
||||
|
||||
factory AiExportResult.success({
|
||||
String? message,
|
||||
Map<String, dynamic>? targetValidation,
|
||||
}) {
|
||||
return AiExportResult(
|
||||
isSuccess: true,
|
||||
code: 'UPLOAD_SUCCESS',
|
||||
message: message ?? 'Export réussi vers le serveur IA !',
|
||||
targetValidation: targetValidation,
|
||||
);
|
||||
}
|
||||
|
||||
factory AiExportResult.banned({
|
||||
String? reason,
|
||||
String? message,
|
||||
}) {
|
||||
return AiExportResult(
|
||||
isSuccess: false,
|
||||
code: 'WALLET_BANNED',
|
||||
isBanned: true,
|
||||
reason: reason,
|
||||
message: message ?? 'Votre participation au programme d\'entraînement IA a été suspendue par la modération.',
|
||||
);
|
||||
}
|
||||
|
||||
factory AiExportResult.error({
|
||||
String? code,
|
||||
required String message,
|
||||
}) {
|
||||
return AiExportResult(
|
||||
isSuccess: false,
|
||||
code: code ?? 'UPLOAD_ERROR',
|
||||
message: message,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AiExportService {
|
||||
/// Extrait les informations de l'appareil
|
||||
Future<Map<String, dynamic>> _getDeviceInfo() async {
|
||||
final DeviceInfoPlugin deviceInfoPlugin = DeviceInfoPlugin();
|
||||
@@ -45,7 +91,7 @@ class AiExportService {
|
||||
}
|
||||
|
||||
/// Exporte l'image et les données de plotting vers le serveur
|
||||
Future<bool> exportData({
|
||||
Future<AiExportResult> exportData({
|
||||
required String imagePath,
|
||||
required String sessionId,
|
||||
required TargetType targetType,
|
||||
@@ -53,26 +99,28 @@ class AiExportService {
|
||||
required double targetCenterY,
|
||||
required double targetRadius,
|
||||
required List<Shot> shots,
|
||||
int distanceMeters = 25,
|
||||
String weaponName = 'Unknown',
|
||||
String? apiUrl,
|
||||
}) async {
|
||||
try {
|
||||
final url = Uri.parse(apiUrl ?? _defaultApiUrl);
|
||||
final walletService = WalletIdentityService();
|
||||
final baseUrl = await walletService.getServerBaseUrl();
|
||||
final effectiveUrl = apiUrl ?? '$baseUrl/api/upload';
|
||||
final url = Uri.parse(effectiveUrl);
|
||||
final request = http.MultipartRequest('POST', url);
|
||||
|
||||
// 1. Prepare image
|
||||
final file = File(imagePath);
|
||||
if (!await file.exists()) {
|
||||
throw Exception('Le fichier image n\'existe pas');
|
||||
return AiExportResult.error(
|
||||
code: 'FILE_NOT_FOUND',
|
||||
message: 'Le fichier image cible est introuvable.',
|
||||
);
|
||||
}
|
||||
|
||||
// Read image metadata (approximate dimensions since decoding image can be heavy)
|
||||
// On the frontend we usually have aspectRatio, here we use generic values if not available.
|
||||
final deviceData = await _getDeviceInfo();
|
||||
|
||||
// We approximate the target corners from center and radius
|
||||
// radius is relative (0 to 1). We need image width/height to get pixels.
|
||||
// But we can just pass relative corners as well, or a normalized bounding box.
|
||||
// Let's create normalized corners (0 to 1).
|
||||
final corners = [
|
||||
{"norm_x": targetCenterX - targetRadius, "norm_y": targetCenterY - targetRadius},
|
||||
{"norm_x": targetCenterX + targetRadius, "norm_y": targetCenterY - targetRadius},
|
||||
@@ -96,7 +144,6 @@ class AiExportService {
|
||||
}).toList();
|
||||
|
||||
// Get and hash the wallet identity
|
||||
final walletService = WalletIdentityService();
|
||||
final phrase = await walletService.getIdentityPhrase();
|
||||
final phraseBytes = utf8.encode(phrase);
|
||||
final walletHash = sha256.convert(phraseBytes).toString();
|
||||
@@ -109,9 +156,8 @@ class AiExportService {
|
||||
"device_info": deviceData,
|
||||
"target_metadata": {
|
||||
"type": targetType.name,
|
||||
"distance_meters": 25, // Default/placeholder
|
||||
"weapon": "Unknown", // Default/placeholder
|
||||
// The backend could extract exact width/height from the image.
|
||||
"distance_meters": distanceMeters,
|
||||
"weapon": weaponName,
|
||||
},
|
||||
"plotting": {
|
||||
"target_corners": corners,
|
||||
@@ -119,29 +165,61 @@ class AiExportService {
|
||||
}
|
||||
};
|
||||
|
||||
// Add fields to request
|
||||
request.fields['plotting'] = jsonEncode(plottingJson);
|
||||
|
||||
// Add file
|
||||
request.files.add(
|
||||
await http.MultipartFile.fromPath('photo', imagePath),
|
||||
);
|
||||
|
||||
// Send request
|
||||
final response = await request.send();
|
||||
final streamedResponse = await request.send().timeout(
|
||||
const Duration(seconds: 15),
|
||||
onTimeout: () => throw Exception('Délai d\'attente dépassé (timeout)'),
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final responseData = await response.stream.bytesToString();
|
||||
debugPrint('Export réussi: $responseData');
|
||||
return true;
|
||||
final responseBody = await streamedResponse.stream.bytesToString();
|
||||
Map<String, dynamic> responseJson = {};
|
||||
try {
|
||||
responseJson = jsonDecode(responseBody);
|
||||
} catch (_) {}
|
||||
|
||||
final statusCode = streamedResponse.statusCode;
|
||||
|
||||
if (statusCode == 200) {
|
||||
debugPrint('Export réussi: $responseBody');
|
||||
return AiExportResult.success(
|
||||
message: responseJson['message'] ?? 'Photo et données exportées avec succès.',
|
||||
targetValidation: responseJson['target_validation'] as Map<String, dynamic>?,
|
||||
);
|
||||
} else if (statusCode == 403 || responseJson['code'] == 'WALLET_BANNED') {
|
||||
final reason = responseJson['reason'] ?? 'Non-respect des règles de contribution';
|
||||
debugPrint('Export rejeté (banni): $reason');
|
||||
// Persister le bannissement localement et couper l'envoi de photos
|
||||
await walletService.setBanned(true, reason: reason);
|
||||
return AiExportResult.banned(
|
||||
reason: reason,
|
||||
message: responseJson['error'] ?? 'Votre wallet a été suspendu par la modération.',
|
||||
);
|
||||
} else if (statusCode == 400) {
|
||||
return AiExportResult.error(
|
||||
code: responseJson['code'] ?? 'BAD_REQUEST',
|
||||
message: responseJson['error'] ?? 'Requête d\'export invalide.',
|
||||
);
|
||||
} else {
|
||||
final errorData = await response.stream.bytesToString();
|
||||
debugPrint('Erreur d\'export: ${response.statusCode} - $errorData');
|
||||
return false;
|
||||
return AiExportResult.error(
|
||||
code: responseJson['code'] ?? 'SERVER_ERROR',
|
||||
message: responseJson['error'] ?? 'Erreur serveur ($statusCode).',
|
||||
);
|
||||
}
|
||||
} on SocketException {
|
||||
return AiExportResult.error(
|
||||
code: 'NETWORK_ERROR',
|
||||
message: 'Impossible de joindre le serveur IA. Vérifiez l\'adresse IP ou votre connexion.',
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('Exception lors de l\'export: $e');
|
||||
return false;
|
||||
return AiExportResult.error(
|
||||
code: 'UNKNOWN_ERROR',
|
||||
message: 'Erreur lors de l\'export: $e',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,426 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import '../data/models/maintenance.dart';
|
||||
import '../data/models/session.dart';
|
||||
import '../data/models/shot.dart';
|
||||
import '../data/models/target_analysis.dart';
|
||||
import '../data/models/weapon.dart';
|
||||
import '../data/repositories/session_repository.dart';
|
||||
import 'statistics_service.dart';
|
||||
|
||||
/// Résumé d'un fichier de sauvegarde, affiché avant de confirmer un import.
|
||||
class BackupPreview {
|
||||
final int sessionCount;
|
||||
final int targetCount;
|
||||
final int shotCount;
|
||||
final int weaponCount;
|
||||
final int maintenanceCount;
|
||||
final bool hasImages;
|
||||
final DateTime? exportedAt;
|
||||
final Map<String, dynamic> raw;
|
||||
|
||||
const BackupPreview({
|
||||
required this.sessionCount,
|
||||
required this.targetCount,
|
||||
required this.shotCount,
|
||||
required this.weaponCount,
|
||||
required this.maintenanceCount,
|
||||
required this.hasImages,
|
||||
required this.exportedAt,
|
||||
required this.raw,
|
||||
});
|
||||
}
|
||||
|
||||
/// Résultat d'un import : ce qui a réellement été écrit en base.
|
||||
class ImportResult {
|
||||
final int sessions;
|
||||
final int weapons;
|
||||
final int maintenance;
|
||||
final int images;
|
||||
final List<String> errors;
|
||||
|
||||
const ImportResult({
|
||||
required this.sessions,
|
||||
required this.weapons,
|
||||
required this.maintenance,
|
||||
required this.images,
|
||||
required this.errors,
|
||||
});
|
||||
}
|
||||
|
||||
/// Erreur « propre » d'import : message directement affichable à l'utilisateur.
|
||||
class BackupFormatException implements Exception {
|
||||
final String message;
|
||||
BackupFormatException(this.message);
|
||||
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
|
||||
/// Import / export de l'intégralité des données de l'app dans un fichier JSON :
|
||||
/// sessions (avec cibles, impacts et calibration), armurerie (armes +
|
||||
/// maintenance) et un instantané des statistiques calculées.
|
||||
///
|
||||
/// Les statistiques ne sont pas réimportées : elles sont recalculées à partir
|
||||
/// des sessions. Elles figurent dans le fichier pour pouvoir être lues telles
|
||||
/// quelles (analyse externe, IA, tableur).
|
||||
class BackupService {
|
||||
static const String formatId = 'impact.backup';
|
||||
static const int formatVersion = 1;
|
||||
|
||||
final SessionRepository _repository;
|
||||
final StatisticsService _statisticsService;
|
||||
|
||||
BackupService({
|
||||
required SessionRepository repository,
|
||||
StatisticsService? statisticsService,
|
||||
}) : _repository = repository,
|
||||
_statisticsService = statisticsService ?? StatisticsService();
|
||||
|
||||
// ---------------------------------------------------------------- EXPORT
|
||||
|
||||
/// Construit le fichier de sauvegarde et renvoie le fichier écrit dans le
|
||||
/// dossier temporaire, prêt à être passé à la feuille de partage du système.
|
||||
Future<File> exportToFile({bool includeImages = false}) async {
|
||||
final json = await buildBackupJson(includeImages: includeImages);
|
||||
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final file = File(p.join(tempDir.path, _buildFileName()));
|
||||
await file.writeAsString(
|
||||
const JsonEncoder.withIndent(' ').convert(json),
|
||||
flush: true,
|
||||
);
|
||||
|
||||
return file;
|
||||
}
|
||||
|
||||
String _buildFileName() {
|
||||
final now = DateTime.now();
|
||||
String two(int v) => v.toString().padLeft(2, '0');
|
||||
return 'impact_sauvegarde_${now.year}-${two(now.month)}-${two(now.day)}'
|
||||
'_${two(now.hour)}${two(now.minute)}.json';
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
Future<Map<String, dynamic>> buildBackupJson({
|
||||
bool includeImages = false,
|
||||
}) async {
|
||||
final sessions = await _repository.getAllSessions();
|
||||
final weapons = await _repository.getWeapons();
|
||||
final maintenance = await _repository.getAllMaintenance();
|
||||
|
||||
// Maintenance regroupée par arme : une arme reste autonome dans le fichier.
|
||||
final maintenanceByWeapon = <String, List<MaintenanceEntry>>{};
|
||||
for (final entry in maintenance) {
|
||||
(maintenanceByWeapon[entry.weaponId] ??= []).add(entry);
|
||||
}
|
||||
|
||||
var totalTargets = 0;
|
||||
var totalShots = 0;
|
||||
final sessionsJson = <Map<String, dynamic>>[];
|
||||
for (final session in sessions) {
|
||||
final analysesJson = <Map<String, dynamic>>[];
|
||||
for (final analysis in session.analyses) {
|
||||
totalTargets++;
|
||||
totalShots += analysis.shots.length;
|
||||
analysesJson.add(await _analysisToJson(analysis, includeImages));
|
||||
}
|
||||
sessionsJson.add({
|
||||
...session.toMap(),
|
||||
'analyses': analysesJson,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
'format': formatId,
|
||||
'version': formatVersion,
|
||||
'app': 'bully',
|
||||
'exported_at': DateTime.now().toIso8601String(),
|
||||
'includes_images': includeImages,
|
||||
'counts': {
|
||||
'sessions': sessions.length,
|
||||
'targets': totalTargets,
|
||||
'shots': totalShots,
|
||||
'weapons': weapons.length,
|
||||
'maintenance': maintenance.length,
|
||||
},
|
||||
'statistics': _statisticsToJson(sessions),
|
||||
'weapons': weapons
|
||||
.map((w) => {
|
||||
...w.toMap(),
|
||||
'maintenance': (maintenanceByWeapon[w.id] ?? [])
|
||||
.map((e) => e.toMap())
|
||||
.toList(),
|
||||
})
|
||||
.toList(),
|
||||
'sessions': sessionsJson,
|
||||
// Maintenance orpheline (arme supprimée) : conservée pour ne rien perdre.
|
||||
'orphan_maintenance': maintenance
|
||||
.where((e) => !weapons.any((w) => w.id == e.weaponId))
|
||||
.map((e) => e.toMap())
|
||||
.toList(),
|
||||
};
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> _analysisToJson(
|
||||
TargetAnalysis analysis,
|
||||
bool includeImages,
|
||||
) async {
|
||||
final json = <String, dynamic>{
|
||||
...analysis.toMap(),
|
||||
'shots': analysis.shots.map((s) => s.toMap()).toList(),
|
||||
};
|
||||
|
||||
if (includeImages) {
|
||||
try {
|
||||
final file = File(analysis.imagePath);
|
||||
if (await file.exists()) {
|
||||
json['image_extension'] = p.extension(analysis.imagePath);
|
||||
json['image_base64'] = base64Encode(await file.readAsBytes());
|
||||
}
|
||||
} catch (e) {
|
||||
// Une photo illisible ne doit pas faire échouer toute la sauvegarde.
|
||||
debugPrint('Sauvegarde : image ignorée (${analysis.id}) : $e');
|
||||
}
|
||||
}
|
||||
|
||||
return json;
|
||||
}
|
||||
|
||||
Map<String, dynamic> _statisticsToJson(List<Session> sessions) {
|
||||
final stats = _statisticsService.calculateStatistics(
|
||||
sessions,
|
||||
period: StatsPeriod.all,
|
||||
);
|
||||
|
||||
return {
|
||||
'total_shots': stats.totalShots,
|
||||
'total_score': stats.totalScore,
|
||||
'average_score': stats.avgScore,
|
||||
'max_score': stats.maxScore,
|
||||
'min_score': stats.minScore,
|
||||
'precision': {
|
||||
'avg_distance_from_center': stats.precision.avgDistanceFromCenter,
|
||||
'grouping_diameter': stats.precision.groupingDiameter,
|
||||
'precision_score': stats.precision.precisionScore,
|
||||
'consistency_score': stats.precision.consistencyScore,
|
||||
},
|
||||
'std_dev': {
|
||||
'x': stats.stdDev.stdDevX,
|
||||
'y': stats.stdDev.stdDevY,
|
||||
'radial': stats.stdDev.stdDevRadial,
|
||||
'score': stats.stdDev.stdDevScore,
|
||||
'mean_x': stats.stdDev.meanX,
|
||||
'mean_y': stats.stdDev.meanY,
|
||||
'mean_score': stats.stdDev.meanScore,
|
||||
},
|
||||
'regional': {
|
||||
'quadrants': stats.regional.quadrantDistribution,
|
||||
'sectors': stats.regional.sectorDistribution,
|
||||
'dominant_direction': stats.regional.dominantDirection,
|
||||
'bias_x': stats.regional.biasX,
|
||||
'bias_y': stats.regional.biasY,
|
||||
},
|
||||
'heat_map': {
|
||||
'grid_size': stats.heatMap.gridSize,
|
||||
'max_shots_in_zone': stats.heatMap.maxShotsInZone,
|
||||
'zones': [
|
||||
for (final row in stats.heatMap.zones)
|
||||
for (final zone in row)
|
||||
{
|
||||
'row': zone.row,
|
||||
'col': zone.col,
|
||||
'shot_count': zone.shotCount,
|
||||
'intensity': zone.intensity,
|
||||
'avg_score': zone.avgScore,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- IMPORT
|
||||
|
||||
/// Lit et valide un fichier de sauvegarde sans rien écrire en base.
|
||||
Future<BackupPreview> readBackup(File file) async {
|
||||
late final dynamic decoded;
|
||||
try {
|
||||
decoded = jsonDecode(await file.readAsString());
|
||||
} catch (e) {
|
||||
throw BackupFormatException(
|
||||
'Fichier illisible : ce n\'est pas un JSON valide.',
|
||||
);
|
||||
}
|
||||
|
||||
if (decoded is! Map<String, dynamic>) {
|
||||
throw BackupFormatException('Fichier illisible : format inattendu.');
|
||||
}
|
||||
if (decoded['format'] != formatId) {
|
||||
throw BackupFormatException(
|
||||
'Ce fichier n\'est pas une sauvegarde IMPACT.',
|
||||
);
|
||||
}
|
||||
final version = (decoded['version'] as num?)?.toInt() ?? 0;
|
||||
if (version > formatVersion) {
|
||||
throw BackupFormatException(
|
||||
'Sauvegarde créée par une version plus récente de l\'application '
|
||||
'(format $version). Mettez l\'app à jour pour l\'importer.',
|
||||
);
|
||||
}
|
||||
|
||||
final sessions = _asList(decoded['sessions']);
|
||||
final weapons = _asList(decoded['weapons']);
|
||||
|
||||
var targets = 0;
|
||||
var shots = 0;
|
||||
var hasImages = false;
|
||||
for (final session in sessions) {
|
||||
for (final analysis in _asList(session['analyses'])) {
|
||||
targets++;
|
||||
shots += _asList(analysis['shots']).length;
|
||||
if (analysis['image_base64'] != null) hasImages = true;
|
||||
}
|
||||
}
|
||||
|
||||
var maintenance = _asList(decoded['orphan_maintenance']).length;
|
||||
for (final weapon in weapons) {
|
||||
maintenance += _asList(weapon['maintenance']).length;
|
||||
}
|
||||
|
||||
return BackupPreview(
|
||||
sessionCount: sessions.length,
|
||||
targetCount: targets,
|
||||
shotCount: shots,
|
||||
weaponCount: weapons.length,
|
||||
maintenanceCount: maintenance,
|
||||
hasImages: hasImages,
|
||||
exportedAt: DateTime.tryParse(decoded['exported_at'] as String? ?? ''),
|
||||
raw: decoded,
|
||||
);
|
||||
}
|
||||
|
||||
/// Écrit en base le contenu d'une sauvegarde déjà lue par [readBackup].
|
||||
///
|
||||
/// Fusion : les entrées existantes portant le même identifiant sont
|
||||
/// remplacées, les autres sont conservées. Réimporter deux fois la même
|
||||
/// sauvegarde ne crée donc pas de doublons.
|
||||
Future<ImportResult> applyBackup(BackupPreview preview) async {
|
||||
final errors = <String>[];
|
||||
var importedSessions = 0;
|
||||
var importedWeapons = 0;
|
||||
var importedMaintenance = 0;
|
||||
var importedImages = 0;
|
||||
|
||||
// 1. Armurerie d'abord : les sessions y font référence par weapon_id.
|
||||
for (final weaponJson in _asList(preview.raw['weapons'])) {
|
||||
try {
|
||||
await _repository.saveWeapon(Weapon.fromMap(weaponJson));
|
||||
importedWeapons++;
|
||||
} catch (e) {
|
||||
errors.add('Arme ignorée : $e');
|
||||
continue;
|
||||
}
|
||||
for (final entryJson in _asList(weaponJson['maintenance'])) {
|
||||
try {
|
||||
await _repository.saveMaintenanceEntry(
|
||||
MaintenanceEntry.fromMap(entryJson),
|
||||
);
|
||||
importedMaintenance++;
|
||||
} catch (e) {
|
||||
errors.add('Entretien ignoré : $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Sessions, cibles et impacts.
|
||||
for (final sessionJson in _asList(preview.raw['sessions'])) {
|
||||
try {
|
||||
final analyses = <TargetAnalysis>[];
|
||||
for (final analysisJson in _asList(sessionJson['analyses'])) {
|
||||
final shots = _asList(analysisJson['shots'])
|
||||
.map((s) => Shot.fromMap(s))
|
||||
.toList();
|
||||
|
||||
var map = _normalizeAnalysis(analysisJson);
|
||||
final imagePath = await _restoreImage(analysisJson);
|
||||
if (imagePath != null) {
|
||||
map = {...map, 'image_path': imagePath};
|
||||
importedImages++;
|
||||
}
|
||||
|
||||
analyses.add(TargetAnalysis.fromMap(map, shots));
|
||||
}
|
||||
await _repository.saveSession(Session.fromMap(sessionJson, analyses));
|
||||
importedSessions++;
|
||||
} catch (e) {
|
||||
errors.add('Session ignorée : $e');
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Maintenance dont l'arme a été supprimée avant l'export.
|
||||
for (final entryJson in _asList(preview.raw['orphan_maintenance'])) {
|
||||
try {
|
||||
await _repository.saveMaintenanceEntry(
|
||||
MaintenanceEntry.fromMap(entryJson),
|
||||
);
|
||||
importedMaintenance++;
|
||||
} catch (e) {
|
||||
errors.add('Entretien ignoré : $e');
|
||||
}
|
||||
}
|
||||
|
||||
return ImportResult(
|
||||
sessions: importedSessions,
|
||||
weapons: importedWeapons,
|
||||
maintenance: importedMaintenance,
|
||||
images: importedImages,
|
||||
errors: errors,
|
||||
);
|
||||
}
|
||||
|
||||
/// Recrée la photo de cible si la sauvegarde l'embarque, et renvoie son
|
||||
/// nouveau chemin local. `null` si la sauvegarde est sans photos : le chemin
|
||||
/// d'origine est alors conservé (l'app affiche un placeholder s'il est mort).
|
||||
Future<String?> _restoreImage(Map<String, dynamic> analysisJson) async {
|
||||
final encoded = analysisJson['image_base64'] as String?;
|
||||
if (encoded == null || encoded.isEmpty) return null;
|
||||
|
||||
try {
|
||||
final bytes = base64Decode(encoded);
|
||||
final extension = analysisJson['image_extension'] as String? ?? '.jpg';
|
||||
return await _repository.saveImageBytes(bytes, extension);
|
||||
} catch (e) {
|
||||
debugPrint('Import : image ignorée : $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// JSON ne distingue pas 1 de 1.0 : une valeur ronde relue devient un `int`
|
||||
/// et casse les `as double?` des modèles. On reforce donc les doubles.
|
||||
Map<String, dynamic> _normalizeAnalysis(Map<String, dynamic> json) {
|
||||
const doubleKeys = [
|
||||
'grouping_diameter',
|
||||
'grouping_center_x',
|
||||
'grouping_center_y',
|
||||
'target_center_x',
|
||||
'target_center_y',
|
||||
'target_radius',
|
||||
];
|
||||
|
||||
final map = Map<String, dynamic>.from(json);
|
||||
for (final key in doubleKeys) {
|
||||
map[key] = (map[key] as num?)?.toDouble();
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> _asList(dynamic value) {
|
||||
if (value is! List) return const [];
|
||||
return value.whereType<Map<String, dynamic>>().toList();
|
||||
}
|
||||
}
|
||||
@@ -1,228 +0,0 @@
|
||||
/// Service de détection d'impacts utilisant OpenCV.
|
||||
library;
|
||||
|
||||
import 'dart:math' as math;
|
||||
import 'package:opencv_dart/opencv_dart.dart' as cv;
|
||||
|
||||
/// Paramètres de détection d'impacts OpenCV
|
||||
class OpenCVDetectionSettings {
|
||||
/// Seuil Canny bas pour la détection de contours
|
||||
final double cannyThreshold1;
|
||||
|
||||
/// Seuil Canny haut pour la détection de contours
|
||||
final double cannyThreshold2;
|
||||
|
||||
/// Distance minimale entre les centres des cercles détectés
|
||||
final double minDist;
|
||||
|
||||
/// Paramètre 1 de HoughCircles (seuil Canny interne)
|
||||
final double param1;
|
||||
|
||||
/// Paramètre 2 de HoughCircles (seuil d'accumulation)
|
||||
final double param2;
|
||||
|
||||
/// Rayon minimum des cercles en pixels
|
||||
final int minRadius;
|
||||
|
||||
/// Rayon maximum des cercles en pixels
|
||||
final int maxRadius;
|
||||
|
||||
/// Taille du flou gaussien (doit être impair)
|
||||
final int blurSize;
|
||||
|
||||
/// Utiliser la détection de contours en plus de Hough
|
||||
final bool useContourDetection;
|
||||
|
||||
/// Circularité minimale pour la détection par contours (0-1)
|
||||
final double minCircularity;
|
||||
|
||||
/// Surface minimale des contours
|
||||
final double minContourArea;
|
||||
|
||||
/// Surface maximale des contours
|
||||
final double maxContourArea;
|
||||
|
||||
const OpenCVDetectionSettings({
|
||||
this.cannyThreshold1 = 50,
|
||||
this.cannyThreshold2 = 150,
|
||||
this.minDist = 20,
|
||||
this.param1 = 100,
|
||||
this.param2 = 30,
|
||||
this.minRadius = 5,
|
||||
this.maxRadius = 50,
|
||||
this.blurSize = 5,
|
||||
this.useContourDetection = true,
|
||||
this.minCircularity = 0.6,
|
||||
this.minContourArea = 50,
|
||||
this.maxContourArea = 5000,
|
||||
});
|
||||
}
|
||||
|
||||
/// Résultat de détection d'impact
|
||||
class OpenCVDetectedImpact {
|
||||
/// Position X normalisée (0-1)
|
||||
final double x;
|
||||
|
||||
/// Position Y normalisée (0-1)
|
||||
final double y;
|
||||
|
||||
/// Rayon en pixels
|
||||
final double radius;
|
||||
|
||||
/// Score de confiance (0-1)
|
||||
final double confidence;
|
||||
|
||||
/// Méthode de détection utilisée
|
||||
final String method;
|
||||
|
||||
const OpenCVDetectedImpact({
|
||||
required this.x,
|
||||
required this.y,
|
||||
required this.radius,
|
||||
this.confidence = 1.0,
|
||||
this.method = 'unknown',
|
||||
});
|
||||
}
|
||||
|
||||
/// Service de détection d'impacts utilisant OpenCV
|
||||
class OpenCVImpactDetectionService {
|
||||
/// Détecte les impacts dans une image en utilisant OpenCV
|
||||
List<OpenCVDetectedImpact> detectImpacts(
|
||||
String imagePath, {
|
||||
OpenCVDetectionSettings settings = const OpenCVDetectionSettings(),
|
||||
}) {
|
||||
try {
|
||||
final img = cv.imread(imagePath, flags: cv.IMREAD_COLOR);
|
||||
if (img.isEmpty) return [];
|
||||
|
||||
final gray = cv.cvtColor(img, cv.COLOR_BGR2GRAY);
|
||||
|
||||
// Apply blur to reduce noise
|
||||
final blurKSize = (settings.blurSize, settings.blurSize);
|
||||
final blurred = cv.gaussianBlur(gray, blurKSize, 2, sigmaY: 2);
|
||||
|
||||
final List<OpenCVDetectedImpact> detectedImpacts = [];
|
||||
|
||||
final circles = cv.HoughCircles(
|
||||
blurred,
|
||||
cv.HOUGH_GRADIENT,
|
||||
1,
|
||||
settings.minDist,
|
||||
param1: settings.param1,
|
||||
param2: settings.param2,
|
||||
minRadius: settings.minRadius,
|
||||
maxRadius: settings.maxRadius,
|
||||
);
|
||||
|
||||
if (circles.rows > 0 && circles.cols > 0) {
|
||||
// Mat shape: (1, N, 3) usually for HoughCircles (CV_32FC3)
|
||||
// We use at<Vec3f> directly.
|
||||
|
||||
for (int i = 0; i < circles.cols; i++) {
|
||||
final vec = circles.at<cv.Vec3f>(0, i);
|
||||
final x = vec.val1;
|
||||
final y = vec.val2;
|
||||
final r = vec.val3;
|
||||
|
||||
detectedImpacts.add(
|
||||
OpenCVDetectedImpact(
|
||||
x: x / img.cols,
|
||||
y: y / img.rows,
|
||||
radius: r,
|
||||
confidence: 0.8,
|
||||
method: 'hough',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Contour Detection (if enabled)
|
||||
if (settings.useContourDetection) {
|
||||
// Canny edge detection
|
||||
final edges = cv.canny(
|
||||
blurred,
|
||||
settings.cannyThreshold1,
|
||||
settings.cannyThreshold2,
|
||||
);
|
||||
|
||||
// Find contours
|
||||
final contoursResult = cv.findContours(
|
||||
edges,
|
||||
cv.RETR_EXTERNAL,
|
||||
cv.CHAIN_APPROX_SIMPLE,
|
||||
);
|
||||
|
||||
final contours = contoursResult.$1;
|
||||
// hierarchy is $2
|
||||
|
||||
for (int i = 0; i < contours.length; i++) {
|
||||
final contour = contours[i];
|
||||
|
||||
// Filter by area
|
||||
final area = cv.contourArea(contour);
|
||||
if (area < settings.minContourArea ||
|
||||
area > settings.maxContourArea) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Filter by circularity
|
||||
final perimeter = cv.arcLength(contour, true);
|
||||
if (perimeter == 0) continue;
|
||||
final circularity = 4 * math.pi * area / (perimeter * perimeter);
|
||||
|
||||
if (circularity < settings.minCircularity) continue;
|
||||
|
||||
// Get bounding circle
|
||||
final enclosingCircle = cv.minEnclosingCircle(contour);
|
||||
final center = enclosingCircle.$1;
|
||||
final radius = enclosingCircle.$2;
|
||||
|
||||
// Avoid duplicates (simple distance check against Hough results)
|
||||
bool isDuplicate = false;
|
||||
for (final existing in detectedImpacts) {
|
||||
final dx = existing.x * img.cols - center.x;
|
||||
final dy = existing.y * img.rows - center.y;
|
||||
final dist = math.sqrt(dx * dx + dy * dy);
|
||||
if (dist < radius) {
|
||||
isDuplicate = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isDuplicate) {
|
||||
detectedImpacts.add(
|
||||
OpenCVDetectedImpact(
|
||||
x: center.x / img.cols,
|
||||
y: center.y / img.rows,
|
||||
radius: radius,
|
||||
confidence: circularity, // Use circularity as confidence
|
||||
method: 'contour',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return detectedImpacts;
|
||||
} catch (e) {
|
||||
// print('OpenCV Error: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Détecte les impacts en utilisant une image de référence
|
||||
List<OpenCVDetectedImpact> detectFromReferences(
|
||||
String imagePath,
|
||||
List<({double x, double y})> referencePoints, {
|
||||
double tolerance = 2.0,
|
||||
}) {
|
||||
// Basic implementation: use average color/brightness of reference points
|
||||
// This is a placeholder for a more complex template matching or feature matching
|
||||
|
||||
// For now, we can just run the standard detection but filter results
|
||||
// based on properties of the reference points (e.g. size/radius if we had it).
|
||||
|
||||
// Returning standard detection for now to enable the feature.
|
||||
return detectImpacts(imagePath);
|
||||
}
|
||||
}
|
||||
@@ -26,23 +26,34 @@ class TargetDetectionResult {
|
||||
|
||||
class OpenCVTargetService {
|
||||
/// Detect the main target (center and radius) from an image file
|
||||
///
|
||||
/// IMPORTANT : les Mat OpenCV sont de la mémoire NATIVE, invisible pour le
|
||||
/// garbage collector Dart. Cette méthode est appelée en boucle (~1 s)
|
||||
/// pendant l'aperçu caméra : sans dispose() explicite dans le finally, la
|
||||
/// mémoire native grimpe en continu tant que l'utilisateur vise.
|
||||
Future<TargetDetectionResult> detectTarget(String imagePath) async {
|
||||
cv.Mat? img;
|
||||
cv.Mat? gray;
|
||||
cv.Mat? blurred;
|
||||
cv.Mat? circles;
|
||||
cv.Mat? looseCircles;
|
||||
|
||||
try {
|
||||
// Read image
|
||||
final img = cv.imread(imagePath, flags: cv.IMREAD_COLOR);
|
||||
img = cv.imread(imagePath, flags: cv.IMREAD_COLOR);
|
||||
if (img.isEmpty) {
|
||||
return TargetDetectionResult.failure();
|
||||
}
|
||||
|
||||
// Convert to grayscale
|
||||
final gray = cv.cvtColor(img, cv.COLOR_BGR2GRAY);
|
||||
gray = cv.cvtColor(img, cv.COLOR_BGR2GRAY);
|
||||
|
||||
// Apply Gaussian blur to reduce noise
|
||||
final blurred = cv.gaussianBlur(gray, (9, 9), 2, sigmaY: 2);
|
||||
blurred = cv.gaussianBlur(gray, (9, 9), 2, sigmaY: 2);
|
||||
|
||||
// Detect circles using Hough Transform
|
||||
// Parameters need to be tuned for the specific target type
|
||||
final circles = cv.HoughCircles(
|
||||
// Detect circles using Hough Transform.
|
||||
// HoughCircles returns a Mat of shape (1, N) of Vec3f (x, y, r).
|
||||
circles = cv.HoughCircles(
|
||||
blurred,
|
||||
cv.HOUGH_GRADIENT,
|
||||
1, // dp
|
||||
@@ -55,26 +66,9 @@ class OpenCVTargetService {
|
||||
maxRadius: img.cols ~/ 2,
|
||||
);
|
||||
|
||||
// HoughCircles returns a Mat of shape (1, N, 3) where N is number of circles.
|
||||
// In opencv_dart, we cannot iterate easily.
|
||||
// However, we can access data via pointer if needed, or check if Vec3f is supported.
|
||||
// Given the user report, `at<Vec3f>` likely failed compilation or runtime.
|
||||
// Let's use a safer approach: assume standard memory layout (x, y, r, x, y, r...).
|
||||
// Or use `at<double>` carefully.
|
||||
|
||||
// Better yet: try to use `circles.data` if available, but it returns a Pointer.
|
||||
// Let's stick to `at` but use `double` and manual offset if Vec3f fails.
|
||||
// actually, let's try to trust `at<double>` for flattened access OR `at<Vec3f>`.
|
||||
// NOTE: `at<Vec3f>` was reported as "method at not defined for VecPoint2f" earlier, NOT for Mat.
|
||||
// The user error was for `VecPoint2f`. `Mat` definitely has `at`.
|
||||
// BUT `VecPoint2f` is a List-like structure in Dart wrapper.
|
||||
// usage of `at` on `VecPoint2f` was the error.
|
||||
// Here `circles` IS A MAT. So `at` IS defined.
|
||||
// However, to be safe and robust, and to implement clustering...
|
||||
|
||||
if (circles.isEmpty) {
|
||||
// Try with different parameters if first attempt fails (more lenient)
|
||||
final looseCircles = cv.HoughCircles(
|
||||
looseCircles = cv.HoughCircles(
|
||||
blurred,
|
||||
cv.HOUGH_GRADIENT,
|
||||
1,
|
||||
@@ -93,8 +87,15 @@ class OpenCVTargetService {
|
||||
|
||||
return _findBestConcentricCircles(circles, img.cols, img.rows);
|
||||
} catch (e) {
|
||||
// print('Error detecting target with OpenCV: $e');
|
||||
return TargetDetectionResult.failure();
|
||||
} finally {
|
||||
// _findBestConcentricCircles a déjà extrait les données dans des listes
|
||||
// Dart avant qu'on arrive ici : libérer les Mat est donc toujours sûr.
|
||||
img?.dispose();
|
||||
gray?.dispose();
|
||||
blurred?.dispose();
|
||||
circles?.dispose();
|
||||
looseCircles?.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,461 +0,0 @@
|
||||
import 'dart:math' as math;
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../data/models/target_type.dart';
|
||||
import 'image_processing_service.dart';
|
||||
import 'opencv_impact_detection_service.dart';
|
||||
export 'image_processing_service.dart'
|
||||
show ImpactDetectionSettings, ReferenceImpact, ImpactCharacteristics;
|
||||
export 'opencv_impact_detection_service.dart'
|
||||
show OpenCVDetectionSettings, OpenCVDetectedImpact;
|
||||
|
||||
// ============================================================================
|
||||
// STRUCT ET FONCTION GLOBALE POUR LE PARALLÉLISME (THREAD SECONDAIRE)
|
||||
// ============================================================================
|
||||
|
||||
/// Conteneur de données pour envoyer les paramètres à l'Isolate d'arrière-plan.
|
||||
class DetectionPayload {
|
||||
final String imagePath;
|
||||
final TargetType targetType;
|
||||
|
||||
// On recrée les instances à l'intérieur du thread isolé car les objets ne se partagent pas entre threads.
|
||||
DetectionPayload({
|
||||
required this.imagePath,
|
||||
required this.targetType,
|
||||
});
|
||||
}
|
||||
|
||||
/// FONCTION EXÉCUTÉE EN PARALLÈLE : Tourne sur un autre cœur du processeur.
|
||||
/// L'interface graphique reste à 120 FPS et totalement fluide.
|
||||
TargetDetectionResult runParallelTargetDetection(DetectionPayload payload) {
|
||||
// 1. Initialisation locale des services dans le sous-thread
|
||||
final imageProcessingService = ImageProcessingService();
|
||||
|
||||
try {
|
||||
// 2. Détection de la cible principale (Calcul lourd)
|
||||
final mainTarget = imageProcessingService.detectMainTarget(payload.imagePath);
|
||||
|
||||
double centerX = 0.5;
|
||||
double centerY = 0.5;
|
||||
double radius = 0.4;
|
||||
|
||||
if (mainTarget != null) {
|
||||
centerX = mainTarget.centerX;
|
||||
centerY = mainTarget.centerY;
|
||||
radius = mainTarget.radius;
|
||||
}
|
||||
|
||||
// 3. Détection des impacts (Calcul lourd)
|
||||
final impacts = imageProcessingService.detectImpacts(payload.imagePath);
|
||||
|
||||
// 4. Calcul mathématique des scores relatifs
|
||||
final detectedImpacts = impacts.map((impact) {
|
||||
final score = payload.targetType == TargetType.concentric
|
||||
? _staticCalculateConcentricScore(
|
||||
impact.x,
|
||||
impact.y,
|
||||
centerX,
|
||||
centerY,
|
||||
radius,
|
||||
)
|
||||
: _staticCalculateSilhouetteScore(impact.x, impact.y, centerX, centerY);
|
||||
|
||||
return DetectedImpactResult(
|
||||
x: impact.x,
|
||||
y: impact.y,
|
||||
radius: impact.radius,
|
||||
suggestedScore: score,
|
||||
);
|
||||
}).toList();
|
||||
|
||||
return TargetDetectionResult(
|
||||
centerX: centerX,
|
||||
centerY: centerY,
|
||||
radius: radius,
|
||||
impacts: detectedImpacts,
|
||||
);
|
||||
} catch (e) {
|
||||
return TargetDetectionResult.error('Erreur de detection parallèle: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Fonctions mathématiques pures nécessaires à l'Isolate (statiques)
|
||||
int _staticCalculateConcentricScore(double impactX, double impactY, double centerX, double centerY, double targetRadius) {
|
||||
final dx = impactX - centerX;
|
||||
final dy = impactY - centerY;
|
||||
final distance = math.sqrt(dx * dx + dy * dy) / targetRadius;
|
||||
if (distance <= 0.1) return 10;
|
||||
if (distance <= 0.2) return 9;
|
||||
if (distance <= 0.3) return 8;
|
||||
if (distance <= 0.4) return 7;
|
||||
if (distance <= 0.5) return 6;
|
||||
if (distance <= 0.6) return 5;
|
||||
if (distance <= 0.7) return 4;
|
||||
if (distance <= 0.8) return 3;
|
||||
if (distance <= 0.9) return 2;
|
||||
if (distance <= 1.0) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int _staticCalculateSilhouetteScore(double impactX, double impactY, double centerX, double centerY) {
|
||||
final dx = (impactX - centerX).abs();
|
||||
final dy = impactY - centerY;
|
||||
if (dx > 0.15) return 0;
|
||||
if (dy < -0.25) return 5;
|
||||
if (dy < 0.0) return 5;
|
||||
if (dy < 0.15) return 4;
|
||||
if (dy < 0.35) return 3;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// FIN DU BLOC DE PARALLÉLISME
|
||||
// ============================================================================
|
||||
|
||||
|
||||
class TargetDetectionResult {
|
||||
final double centerX; // Relative (0-1)
|
||||
final double centerY; // Relative (0-1)
|
||||
final double radius; // Relative (0-1)
|
||||
final List<DetectedImpactResult> impacts;
|
||||
final bool success;
|
||||
final String? errorMessage;
|
||||
|
||||
TargetDetectionResult({
|
||||
required this.centerX,
|
||||
required this.centerY,
|
||||
required this.radius,
|
||||
required this.impacts,
|
||||
this.success = true,
|
||||
this.errorMessage,
|
||||
});
|
||||
|
||||
factory TargetDetectionResult.error(String message) {
|
||||
return TargetDetectionResult(
|
||||
centerX: 0.5,
|
||||
centerY: 0.5,
|
||||
radius: 0.4,
|
||||
impacts: [],
|
||||
success: false,
|
||||
errorMessage: message,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DetectedImpactResult {
|
||||
final double x; // Relative (0-1)
|
||||
final double y; // Relative (0-1)
|
||||
final double radius; // Absolute pixels
|
||||
final int suggestedScore;
|
||||
|
||||
DetectedImpactResult({
|
||||
required this.x,
|
||||
required this.y,
|
||||
required this.radius,
|
||||
required this.suggestedScore,
|
||||
});
|
||||
}
|
||||
|
||||
class TargetDetectionService {
|
||||
final ImageProcessingService _imageProcessingService;
|
||||
final OpenCVImpactDetectionService _opencvService;
|
||||
|
||||
TargetDetectionService({
|
||||
ImageProcessingService? imageProcessingService,
|
||||
OpenCVImpactDetectionService? opencvService,
|
||||
}) : _imageProcessingService =
|
||||
imageProcessingService ?? ImageProcessingService(),
|
||||
_opencvService = opencvService ?? OpenCVImpactDetectionService();
|
||||
|
||||
/// Detect target and impacts from an image file ASYNCHRONOUSLY in a separate Thread.
|
||||
/// CORRECTION : Utilise désormais 'compute' pour basculer en arrière-plan immédiat.
|
||||
Future<TargetDetectionResult> detectTargetAsync(String imagePath, TargetType targetType) async {
|
||||
final payload = DetectionPayload(imagePath: imagePath, targetType: targetType);
|
||||
// Déclenche l'exécution isolée en tâche de fond
|
||||
return await compute(runParallelTargetDetection, payload);
|
||||
}
|
||||
|
||||
/// Gardée pour rétrocompatibilité synchrone si nécessaire
|
||||
TargetDetectionResult detectTarget(String imagePath, TargetType targetType) {
|
||||
try {
|
||||
final mainTarget = _imageProcessingService.detectMainTarget(imagePath);
|
||||
|
||||
double centerX = 0.5;
|
||||
double centerY = 0.5;
|
||||
double radius = 0.4;
|
||||
|
||||
if (mainTarget != null) {
|
||||
centerX = mainTarget.centerX;
|
||||
centerY = mainTarget.centerY;
|
||||
radius = mainTarget.radius;
|
||||
}
|
||||
|
||||
final impacts = _imageProcessingService.detectImpacts(imagePath);
|
||||
|
||||
final detectedImpacts = impacts.map((impact) {
|
||||
final score = targetType == TargetType.concentric
|
||||
? _calculateConcentricScore(
|
||||
impact.x,
|
||||
impact.y,
|
||||
centerX,
|
||||
centerY,
|
||||
radius,
|
||||
)
|
||||
: _calculateSilhouetteScore(impact.x, impact.y, centerX, centerY);
|
||||
|
||||
return DetectedImpactResult(
|
||||
x: impact.x,
|
||||
y: impact.y,
|
||||
radius: impact.radius,
|
||||
suggestedScore: score,
|
||||
);
|
||||
}).toList();
|
||||
|
||||
return TargetDetectionResult(
|
||||
centerX: centerX,
|
||||
centerY: centerY,
|
||||
radius: radius,
|
||||
impacts: detectedImpacts,
|
||||
);
|
||||
} catch (e) {
|
||||
return TargetDetectionResult.error('Erreur de detection: $e');
|
||||
}
|
||||
}
|
||||
|
||||
int _calculateConcentricScore(
|
||||
double impactX,
|
||||
double impactY,
|
||||
double centerX,
|
||||
double centerY,
|
||||
double targetRadius,
|
||||
) {
|
||||
final dx = impactX - centerX;
|
||||
final dy = impactY - centerY;
|
||||
final distance = math.sqrt(dx * dx + dy * dy) / targetRadius;
|
||||
|
||||
if (distance <= 0.1) return 10;
|
||||
if (distance <= 0.2) return 9;
|
||||
if (distance <= 0.3) return 8;
|
||||
if (distance <= 0.4) return 7;
|
||||
if (distance <= 0.5) return 6;
|
||||
if (distance <= 0.6) return 5;
|
||||
if (distance <= 0.7) return 4;
|
||||
if (distance <= 0.8) return 3;
|
||||
if (distance <= 0.9) return 2;
|
||||
if (distance <= 1.0) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int _calculateSilhouetteScore(
|
||||
double impactX,
|
||||
double impactY,
|
||||
double centerX,
|
||||
double centerY,
|
||||
) {
|
||||
final dx = (impactX - centerX).abs();
|
||||
final dy = impactY - centerY;
|
||||
|
||||
if (dx > 0.15) return 0;
|
||||
if (dy < -0.25) return 5;
|
||||
if (dy < 0.0) return 5;
|
||||
if (dy < 0.15) return 4;
|
||||
if (dy < 0.35) return 3;
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
List<DetectedImpactResult> detectImpactsOnly(
|
||||
String imagePath,
|
||||
TargetType targetType,
|
||||
double centerX,
|
||||
double centerY,
|
||||
double radius,
|
||||
int ringCount,
|
||||
ImpactDetectionSettings settings,
|
||||
) {
|
||||
try {
|
||||
final impacts = _imageProcessingService.detectImpactsWithSettings(
|
||||
imagePath,
|
||||
settings,
|
||||
);
|
||||
|
||||
return impacts.map((impact) {
|
||||
final score = targetType == TargetType.concentric
|
||||
? _calculateConcentricScoreWithRings(
|
||||
impact.x,
|
||||
impact.y,
|
||||
centerX,
|
||||
centerY,
|
||||
radius,
|
||||
ringCount,
|
||||
)
|
||||
: _calculateSilhouetteScore(impact.x, impact.y, centerX, centerY);
|
||||
|
||||
return DetectedImpactResult(
|
||||
x: impact.x,
|
||||
y: impact.y,
|
||||
radius: impact.radius,
|
||||
suggestedScore: score,
|
||||
);
|
||||
}).toList();
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
int _calculateConcentricScoreWithRings(
|
||||
double impactX,
|
||||
double impactY,
|
||||
double centerX,
|
||||
double centerY,
|
||||
double targetRadius,
|
||||
int ringCount,
|
||||
) {
|
||||
final dx = impactX - centerX;
|
||||
final dy = impactY - centerY;
|
||||
final distance = math.sqrt(dx * dx + dy * dy) / targetRadius;
|
||||
|
||||
for (int i = 0; i < ringCount; i++) {
|
||||
final zoneRadius = (i + 1) / ringCount;
|
||||
if (distance <= zoneRadius) {
|
||||
return 10 - i;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
ImpactCharacteristics? analyzeReferenceImpacts(
|
||||
String imagePath,
|
||||
List<ReferenceImpact> references,
|
||||
) {
|
||||
return _imageProcessingService.analyzeReferenceImpacts(
|
||||
imagePath,
|
||||
references,
|
||||
);
|
||||
}
|
||||
|
||||
List<DetectedImpactResult> detectImpactsFromReferences(
|
||||
String imagePath,
|
||||
TargetType targetType,
|
||||
double centerX,
|
||||
double centerY,
|
||||
double radius,
|
||||
int ringCount,
|
||||
ImpactCharacteristics characteristics, {
|
||||
double tolerance = 2.0,
|
||||
}) {
|
||||
try {
|
||||
final impacts = _imageProcessingService.detectImpactsFromReferences(
|
||||
imagePath,
|
||||
characteristics,
|
||||
tolerance: tolerance,
|
||||
);
|
||||
|
||||
return impacts.map((impact) {
|
||||
final score = targetType == TargetType.concentric
|
||||
? _calculateConcentricScoreWithRings(
|
||||
impact.x,
|
||||
impact.y,
|
||||
centerX,
|
||||
centerY,
|
||||
radius,
|
||||
ringCount,
|
||||
)
|
||||
: _calculateSilhouetteScore(impact.x, impact.y, centerX, centerY);
|
||||
|
||||
return DetectedImpactResult(
|
||||
x: impact.x,
|
||||
y: impact.y,
|
||||
radius: impact.radius,
|
||||
suggestedScore: score,
|
||||
);
|
||||
}).toList();
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
List<DetectedImpactResult> detectImpactsWithOpenCV(
|
||||
String imagePath,
|
||||
TargetType targetType,
|
||||
double centerX,
|
||||
double centerY,
|
||||
double radius,
|
||||
int ringCount, {
|
||||
OpenCVDetectionSettings? settings,
|
||||
}) {
|
||||
try {
|
||||
final impacts = _opencvService.detectImpacts(
|
||||
imagePath,
|
||||
settings: settings ?? const OpenCVDetectionSettings(),
|
||||
);
|
||||
|
||||
return impacts.map((impact) {
|
||||
final score = targetType == TargetType.concentric
|
||||
? _calculateConcentricScoreWithRings(
|
||||
impact.x,
|
||||
impact.y,
|
||||
centerX,
|
||||
centerY,
|
||||
radius,
|
||||
ringCount,
|
||||
)
|
||||
: _calculateSilhouetteScore(impact.x, impact.y, centerX, centerY);
|
||||
|
||||
return DetectedImpactResult(
|
||||
x: impact.x,
|
||||
y: impact.y,
|
||||
radius: impact.radius,
|
||||
suggestedScore: score,
|
||||
);
|
||||
}).toList();
|
||||
} catch (e) {
|
||||
debugPrint('Erreur détection OpenCV: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
List<DetectedImpactResult> detectImpactsWithOpenCVFromReferences(
|
||||
String imagePath,
|
||||
TargetType targetType,
|
||||
double centerX,
|
||||
double centerY,
|
||||
double radius,
|
||||
int ringCount,
|
||||
List<ReferenceImpact> references, {
|
||||
double tolerance = 2.0,
|
||||
}) {
|
||||
try {
|
||||
final refPoints = references.map((r) => (x: r.x, y: r.y)).toList();
|
||||
|
||||
final impacts = _opencvService.detectFromReferences(
|
||||
imagePath,
|
||||
refPoints,
|
||||
tolerance: tolerance,
|
||||
);
|
||||
|
||||
return impacts.map((impact) {
|
||||
final score = targetType == TargetType.concentric
|
||||
? _calculateConcentricScoreWithRings(
|
||||
impact.x,
|
||||
impact.y,
|
||||
centerX,
|
||||
centerY,
|
||||
radius,
|
||||
ringCount,
|
||||
)
|
||||
: _calculateSilhouetteScore(impact.x, impact.y, centerX, centerY);
|
||||
|
||||
return DetectedImpactResult(
|
||||
x: impact.x,
|
||||
y: impact.y,
|
||||
radius: impact.radius,
|
||||
suggestedScore: score,
|
||||
);
|
||||
}).toList();
|
||||
} catch (e) {
|
||||
debugPrint('Erreur détection OpenCV depuis références: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,259 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:opencv_dart/opencv_dart.dart' as cv;
|
||||
|
||||
/// Résultat d'une tentative de redressement de cible.
|
||||
class RectifyResult {
|
||||
/// Chemin du fichier image redressé (ou original si échec).
|
||||
final String outputPath;
|
||||
|
||||
/// true si une cible a été détectée et redressée, false si on a renvoyé
|
||||
/// l'image d'origine sans transformation.
|
||||
final bool rectified;
|
||||
|
||||
/// Angle d'inclinaison estimé de la cible AVANT redressement, en degrés.
|
||||
/// (0 = déjà de face). Utile pour informer l'utilisateur.
|
||||
final double estimatedTiltDegrees;
|
||||
|
||||
/// Message de diagnostic (utile pour debug / affichage).
|
||||
final String message;
|
||||
|
||||
const RectifyResult({
|
||||
required this.outputPath,
|
||||
required this.rectified,
|
||||
required this.estimatedTiltDegrees,
|
||||
required this.message,
|
||||
});
|
||||
}
|
||||
|
||||
/// Service qui redresse une cible RONDE (cercles concentriques) photographiée
|
||||
/// de biais, en la ramenant parfaitement de face.
|
||||
///
|
||||
/// Principe :
|
||||
/// 1. Détecter le plus grand contour ~circulaire de l'image.
|
||||
/// 2. Ajuster une ELLIPSE sur ce contour (fitEllipse).
|
||||
/// 3. Un cercle vu en perspective devient une ellipse : on calcule la
|
||||
/// transformation de perspective qui remappe cette ellipse vers un
|
||||
/// CERCLE parfait, et on l'applique à toute l'image.
|
||||
/// 4. La cible apparaît alors de face.
|
||||
///
|
||||
/// L'image résultat est carrée et centrée sur la cible.
|
||||
class TargetRectifyService {
|
||||
/// Taille (en pixels) du côté de l'image carrée de sortie.
|
||||
final int outputSize;
|
||||
|
||||
/// Marge autour de la cible dans l'image de sortie (1.0 = cible pile au bord,
|
||||
/// 1.3 = 30 % de marge autour). Garde un peu de contexte.
|
||||
final double marginFactor;
|
||||
|
||||
/// En dessous de cet écart d'axes (ratio petit/grand axe proche de 1),
|
||||
/// la cible est considérée déjà de face → pas de warp inutile.
|
||||
final double minTiltRatioToRectify;
|
||||
|
||||
TargetRectifyService({
|
||||
this.outputSize = 1024,
|
||||
this.marginFactor = 1.25,
|
||||
this.minTiltRatioToRectify = 0.985,
|
||||
});
|
||||
|
||||
/// Redresse l'image située à [inputPath]. Écrit le résultat dans
|
||||
/// [outputPath] et renvoie un [RectifyResult].
|
||||
///
|
||||
/// Ne bloque jamais : en cas d'échec de détection, renvoie l'image
|
||||
/// d'origine (rectified = false) pour ne pas perdre la photo du tireur.
|
||||
Future<RectifyResult> rectify({
|
||||
required String inputPath,
|
||||
required String outputPath,
|
||||
}) async {
|
||||
cv.Mat? src;
|
||||
cv.Mat? gray;
|
||||
cv.Mat? blurred;
|
||||
cv.Mat? edges;
|
||||
try {
|
||||
src = cv.imread(inputPath, flags: cv.IMREAD_COLOR);
|
||||
if (src.isEmpty) {
|
||||
return RectifyResult(
|
||||
outputPath: inputPath,
|
||||
rectified: false,
|
||||
estimatedTiltDegrees: 0,
|
||||
message: 'Image illisible',
|
||||
);
|
||||
}
|
||||
|
||||
// ── 1. Prétraitement ────────────────────────────────────────────────
|
||||
gray = cv.cvtColor(src, cv.COLOR_BGR2GRAY);
|
||||
blurred = cv.gaussianBlur(gray, (5, 5), 2, sigmaY: 2);
|
||||
edges = cv.canny(blurred, 60, 160);
|
||||
|
||||
// Dilatation légère pour fermer les contours brisés
|
||||
final cv.Mat kernel = cv.getStructuringElement(
|
||||
cv.MORPH_ELLIPSE,
|
||||
(3, 3),
|
||||
);
|
||||
final cv.Mat dilated = cv.dilate(edges, kernel);
|
||||
|
||||
// ── 2. Recherche du meilleur contour elliptique ──────────────────────
|
||||
final (contours, _) = cv.findContours(
|
||||
dilated,
|
||||
cv.RETR_EXTERNAL,
|
||||
cv.CHAIN_APPROX_SIMPLE,
|
||||
);
|
||||
|
||||
final double imgArea = (src.width * src.height).toDouble();
|
||||
cv.RotatedRect? bestEllipse;
|
||||
double bestScore = 0;
|
||||
|
||||
for (int i = 0; i < contours.length; i++) {
|
||||
final c = contours[i];
|
||||
if (c.length < 5) continue; // fitEllipse exige >= 5 points
|
||||
|
||||
final double area = cv.contourArea(c);
|
||||
// On ignore les contours minuscules et ceux qui couvrent presque tout
|
||||
if (area < imgArea * 0.03 || area > imgArea * 0.97) continue;
|
||||
|
||||
final cv.RotatedRect e = cv.fitEllipse(c);
|
||||
final ep = e.points; // 4 sommets
|
||||
if (ep.length < 4) continue;
|
||||
final double ecx = (ep[0].x + ep[1].x + ep[2].x + ep[3].x) / 4.0;
|
||||
final double ecy = (ep[0].y + ep[1].y + ep[2].y + ep[3].y) / 4.0;
|
||||
final double mAx = (ep[0].x + ep[1].x) / 2.0;
|
||||
final double mAy = (ep[0].y + ep[1].y) / 2.0;
|
||||
final double mBx = (ep[1].x + ep[2].x) / 2.0;
|
||||
final double mBy = (ep[1].y + ep[2].y) / 2.0;
|
||||
final double w =
|
||||
2 * math.sqrt(math.pow(mAx - ecx, 2) + math.pow(mAy - ecy, 2));
|
||||
final double h =
|
||||
2 * math.sqrt(math.pow(mBx - ecx, 2) + math.pow(mBy - ecy, 2));
|
||||
if (w <= 1 || h <= 1) continue;
|
||||
|
||||
// À quel point le contour ressemble-t-il vraiment à son ellipse ?
|
||||
// On compare l'aire du contour à l'aire de l'ellipse ajustée.
|
||||
final double ellipseArea = math.pi * (w / 2) * (h / 2);
|
||||
if (ellipseArea <= 0) continue;
|
||||
final double fitRatio = area / ellipseArea; // ~1 si bon ajustement
|
||||
if (fitRatio < 0.7 || fitRatio > 1.3) continue;
|
||||
|
||||
// Score = taille de l'ellipse (on veut la cible la plus grande)
|
||||
final double score = ellipseArea;
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
bestEllipse = e;
|
||||
}
|
||||
}
|
||||
|
||||
if (bestEllipse == null) {
|
||||
return RectifyResult(
|
||||
outputPath: inputPath,
|
||||
rectified: false,
|
||||
estimatedTiltDegrees: 0,
|
||||
message: 'Aucune cible circulaire détectée',
|
||||
);
|
||||
}
|
||||
|
||||
// ── 3. Extraire les 4 sommets de l'ellipse (robuste à la version d'API) ─
|
||||
// RotatedRect.points renvoie les 4 coins de la boîte englobant l'ellipse.
|
||||
// On en dérive nous-mêmes le centre, les demi-axes et l'orientation, ce
|
||||
// qui évite de dépendre de la forme exacte de `.size` / `.center`
|
||||
// (record vs objet) qui varie selon les versions d'opencv_dart.
|
||||
final pts = bestEllipse.points; // List<Point2f> de 4 sommets
|
||||
|
||||
double px(int i) => pts[i].x;
|
||||
double py(int i) => pts[i].y;
|
||||
|
||||
// Centre = moyenne des 4 sommets
|
||||
final double cx = (px(0) + px(1) + px(2) + px(3)) / 4.0;
|
||||
final double cy = (py(0) + py(1) + py(2) + py(3)) / 4.0;
|
||||
|
||||
// Milieux de deux côtés adjacents → extrémités des deux demi-axes
|
||||
// Côté 0-1 et côté 1-2 (ordre des sommets d'un RotatedRect)
|
||||
final double m01x = (px(0) + px(1)) / 2.0;
|
||||
final double m01y = (py(0) + py(1)) / 2.0;
|
||||
final double m12x = (px(1) + px(2)) / 2.0;
|
||||
final double m12y = (py(1) + py(2)) / 2.0;
|
||||
|
||||
// Demi-axes = distance centre → milieu de chaque côté
|
||||
final double axisA =
|
||||
math.sqrt(math.pow(m01x - cx, 2) + math.pow(m01y - cy, 2));
|
||||
final double axisB =
|
||||
math.sqrt(math.pow(m12x - cx, 2) + math.pow(m12y - cy, 2));
|
||||
|
||||
final double majorAxis = math.max(axisA, axisB);
|
||||
final double minorAxis = math.min(axisA, axisB);
|
||||
if (majorAxis <= 1) {
|
||||
return RectifyResult(
|
||||
outputPath: inputPath,
|
||||
rectified: false,
|
||||
estimatedTiltDegrees: 0,
|
||||
message: 'Ellipse dégénérée',
|
||||
);
|
||||
}
|
||||
|
||||
final double axisRatio = minorAxis / majorAxis; // 1 = cercle parfait
|
||||
final double tiltDeg =
|
||||
math.acos(axisRatio.clamp(0.0, 1.0)) * (180.0 / math.pi);
|
||||
|
||||
// Déjà quasiment de face → on ne touche pas (évite le flou inutile)
|
||||
if (axisRatio >= minTiltRatioToRectify) {
|
||||
cv.imwrite(outputPath, src);
|
||||
return RectifyResult(
|
||||
outputPath: outputPath,
|
||||
rectified: false,
|
||||
estimatedTiltDegrees: tiltDeg,
|
||||
message: 'Cible déjà de face',
|
||||
);
|
||||
}
|
||||
|
||||
// ── 4. Construire la transformation de perspective ────────────────────
|
||||
// Source : extrémités des deux axes de l'ellipse (4 points).
|
||||
// Destination : extrémités des axes d'un cercle parfait centré.
|
||||
// On obtient les extrémités en prolongeant centre→milieu-de-côté.
|
||||
final srcPts = cv.VecPoint.fromList([
|
||||
cv.Point((cx + (m01x - cx)).round(), (cy + (m01y - cy)).round()),
|
||||
cv.Point((cx - (m01x - cx)).round(), (cy - (m01y - cy)).round()),
|
||||
cv.Point((cx + (m12x - cx)).round(), (cy + (m12y - cy)).round()),
|
||||
cv.Point((cx - (m12x - cx)).round(), (cy - (m12y - cy)).round()),
|
||||
]);
|
||||
|
||||
// Cible : cercle parfait centré, rayon R, dans une image carrée.
|
||||
// L'axe "A" (m01) devient l'axe horizontal, l'axe "B" (m12) le vertical.
|
||||
final double out = outputSize.toDouble();
|
||||
final double center = out / 2;
|
||||
final double radius = (out / 2) / marginFactor;
|
||||
final dstPts = cv.VecPoint.fromList([
|
||||
cv.Point((center + radius).round(), center.round()),
|
||||
cv.Point((center - radius).round(), center.round()),
|
||||
cv.Point(center.round(), (center + radius).round()),
|
||||
cv.Point(center.round(), (center - radius).round()),
|
||||
]);
|
||||
|
||||
final cv.Mat transform = cv.getPerspectiveTransform(srcPts, dstPts);
|
||||
|
||||
final cv.Mat warped = cv.warpPerspective(
|
||||
src,
|
||||
transform,
|
||||
(outputSize, outputSize),
|
||||
flags: cv.INTER_LINEAR,
|
||||
borderMode: cv.BORDER_CONSTANT,
|
||||
);
|
||||
|
||||
cv.imwrite(outputPath, warped);
|
||||
|
||||
return RectifyResult(
|
||||
outputPath: outputPath,
|
||||
rectified: true,
|
||||
estimatedTiltDegrees: tiltDeg,
|
||||
message: 'Cible redressée (inclinaison ${tiltDeg.toStringAsFixed(1)}°)',
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('TargetRectify erreur: $e');
|
||||
// En cas de pépin, on renvoie l'original pour ne jamais perdre la photo
|
||||
return RectifyResult(
|
||||
outputPath: inputPath,
|
||||
rectified: false,
|
||||
estimatedTiltDegrees: 0,
|
||||
message: 'Erreur de traitement: $e',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:math';
|
||||
import 'dart:convert';
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'dart:io';
|
||||
@@ -9,6 +10,9 @@ import 'package:flutter/foundation.dart';
|
||||
class WalletIdentityService {
|
||||
static const String _prefsKey = 'wallet_identity_phrase';
|
||||
static const String _uploadEnabledKey = 'is_ai_upload_enabled';
|
||||
static const String _bannedKey = 'wallet_is_banned';
|
||||
static const String _banReasonKey = 'wallet_ban_reason';
|
||||
static const String _serverUrlKey = 'ai_server_url';
|
||||
|
||||
// A standard list of 256 words (8 bits of entropy per word)
|
||||
static const List<String> _wordList = [
|
||||
@@ -40,18 +44,96 @@ class WalletIdentityService {
|
||||
'bulk', 'bullet', 'bundle', 'bunker', 'burden', 'burger', 'burst', 'bus', 'business', 'butter'
|
||||
];
|
||||
|
||||
/// Vérifie si l'utilisateur a accepté l'envoi de données à l'IA
|
||||
/// Retourne l'URL de base du serveur configuré (ex: http://192.168.1.50:3000 ou http://10.0.2.2:3000)
|
||||
Future<String> getServerBaseUrl() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final customUrl = prefs.getString(_serverUrlKey);
|
||||
if (customUrl != null && customUrl.trim().isNotEmpty) {
|
||||
return customUrl.trim().replaceAll(RegExp(r'/api/upload/?$'), '');
|
||||
}
|
||||
if (Platform.isAndroid) {
|
||||
return 'http://10.0.2.2:3000';
|
||||
}
|
||||
return 'http://localhost:3000';
|
||||
}
|
||||
|
||||
/// Définit une URL personnalisée pour le serveur IA
|
||||
Future<void> setServerBaseUrl(String url) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final cleanUrl = url.trim().replaceAll(RegExp(r'/api/upload/?$'), '');
|
||||
await prefs.setString(_serverUrlKey, cleanUrl);
|
||||
}
|
||||
|
||||
/// Vérifie si l'utilisateur a accepté l'envoi de données à l'IA (et n'est pas banni)
|
||||
Future<bool> isUploadEnabled() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final isBanned = prefs.getBool(_bannedKey) ?? false;
|
||||
if (isBanned) return false;
|
||||
return prefs.getBool(_uploadEnabledKey) ?? false;
|
||||
}
|
||||
|
||||
/// Active ou désactive l'envoi de données
|
||||
Future<void> setUploadEnabled(bool enabled) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final isBanned = prefs.getBool(_bannedKey) ?? false;
|
||||
if (isBanned) {
|
||||
await prefs.setBool(_uploadEnabledKey, false);
|
||||
return;
|
||||
}
|
||||
await prefs.setBool(_uploadEnabledKey, enabled);
|
||||
}
|
||||
|
||||
/// Vérifie si ce wallet/utilisateur est banni en local
|
||||
Future<bool> isBanned() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(_bannedKey) ?? false;
|
||||
}
|
||||
|
||||
/// Récupère le motif de bannissement enregistré
|
||||
Future<String?> getBanReason() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getString(_banReasonKey);
|
||||
}
|
||||
|
||||
/// Enregistre l'état de bannissement et le motif
|
||||
Future<void> setBanned(bool banned, {String? reason}) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_bannedKey, banned);
|
||||
if (banned) {
|
||||
await prefs.setString(_banReasonKey, reason ?? 'Photos non conformes aux règles de tir');
|
||||
await prefs.setBool(_uploadEnabledKey, false);
|
||||
} else {
|
||||
await prefs.remove(_banReasonKey);
|
||||
}
|
||||
}
|
||||
|
||||
/// Synchronise le statut de modération/bannissement avec le serveur backend
|
||||
Future<bool> syncBanStatus() async {
|
||||
try {
|
||||
final phrase = await getIdentityPhrase();
|
||||
final phraseBytes = utf8.encode(phrase);
|
||||
final walletHash = sha256.convert(phraseBytes).toString();
|
||||
final baseUrl = await getServerBaseUrl();
|
||||
|
||||
final res = await http.get(Uri.parse('$baseUrl/api/stats/$walletHash')).timeout(
|
||||
const Duration(seconds: 4),
|
||||
);
|
||||
if (res.statusCode == 200) {
|
||||
final data = jsonDecode(res.body);
|
||||
final isBannedOnServer = data['is_banned'] == true;
|
||||
if (isBannedOnServer) {
|
||||
await setBanned(true, reason: data['ban_reason']);
|
||||
} else {
|
||||
await setBanned(false);
|
||||
}
|
||||
return isBannedOnServer;
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Erreur synchro ban: $e');
|
||||
}
|
||||
return await isBanned();
|
||||
}
|
||||
|
||||
/// Gets the unique 15-word identity phrase
|
||||
Future<String> getIdentityPhrase() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
@@ -7,9 +7,13 @@
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <file_selector_linux/file_selector_plugin.h>
|
||||
#include <url_launcher_linux/url_launcher_plugin.h>
|
||||
|
||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
||||
g_autoptr(FlPluginRegistrar) file_selector_linux_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin");
|
||||
file_selector_plugin_register_with_registrar(file_selector_linux_registrar);
|
||||
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
|
||||
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
file_selector_linux
|
||||
url_launcher_linux
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
|
||||
@@ -7,12 +7,14 @@ import Foundation
|
||||
|
||||
import device_info_plus
|
||||
import file_selector_macos
|
||||
import share_plus
|
||||
import shared_preferences_foundation
|
||||
import sqflite_darwin
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
|
||||
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
|
||||
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
|
||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
|
||||
}
|
||||
|
||||
@@ -201,6 +201,30 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.1"
|
||||
file_selector:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: file_selector
|
||||
sha256: bd15e43e9268db636b53eeaca9f56324d1622af30e5c34d6e267649758c84d9a
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
file_selector_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_selector_android
|
||||
sha256: "89243030ea4b3463fb402b44d5eeacc4ccb1c46a88870cb2a5080d693200c1ed"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.5.2+6"
|
||||
file_selector_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_selector_ios
|
||||
sha256: e2ecf2885c121691ce13b60db3508f53c01f869fb6e8dc5c1cfa771e4c46aeca
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.5.3+5"
|
||||
file_selector_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -225,6 +249,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.7.0"
|
||||
file_selector_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_selector_web
|
||||
sha256: "73181fbc5257776d8ecaa6a94ab3c8e920ad143b9132a6d984a9271dfc6928d3"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.9.5"
|
||||
file_selector_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -293,30 +325,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.3"
|
||||
google_mlkit_commons:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: google_mlkit_commons
|
||||
sha256: "3e69fea4211727732cc385104e675ad1e40b29f12edd492ee52fa108423a6124"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.11.1"
|
||||
google_mlkit_document_scanner:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: google_mlkit_document_scanner
|
||||
sha256: "67428ddb853880c8185049a5834cd328e6420921a74786f6aadee0b76f8536bd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.1"
|
||||
google_mlkit_object_detection:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: google_mlkit_object_detection
|
||||
sha256: "9dd35886972e18747e22098f8ebee78d30716a99a789bb2e3a65a24229e031e7"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.15.1"
|
||||
hooks:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -733,6 +741,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.0"
|
||||
share_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: share_plus
|
||||
sha256: "34f00f9becd2743c1fb05363d624f9f70d37f7ccdcdda47450bc0b8c9d327b8c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "13.3.0"
|
||||
share_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: share_plus_platform_interface
|
||||
sha256: "365ef7379fc22507256adda3385152942ffce08935452bc972c2e52a0bebae41"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.2.0"
|
||||
shared_preferences:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -922,6 +946,38 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
url_launcher_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_linux
|
||||
sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.2"
|
||||
url_launcher_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_platform_interface
|
||||
sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.2"
|
||||
url_launcher_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_web
|
||||
sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.3"
|
||||
url_launcher_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_windows
|
||||
sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.5"
|
||||
uuid:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: bully
|
||||
description: "A new Flutter project."
|
||||
description: "Application d'analyse de cibles de tir : capture, centrage, plotting manuel des impacts, scores et statistiques."
|
||||
# The following line prevents the package from being accidentally published to
|
||||
# pub.dev using `flutter pub publish`. This is preferred for private packages.
|
||||
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
||||
@@ -38,11 +38,9 @@ dependencies:
|
||||
cupertino_icons: ^1.0.8
|
||||
sensors_plus: ^4.0.2
|
||||
opencv_dart: ^2.1.0
|
||||
google_mlkit_object_detection: ^0.15.0
|
||||
|
||||
# Image capture from camera/gallery
|
||||
image_picker: ^1.2.1
|
||||
google_mlkit_document_scanner: ^0.2.0
|
||||
|
||||
# Local database for history
|
||||
sqflite: ^2.3.2
|
||||
@@ -72,9 +70,8 @@ dependencies:
|
||||
shared_preferences: ^2.5.5
|
||||
crypto: ^3.0.7
|
||||
camera: ^0.12.0+1
|
||||
|
||||
# Machine Learning for YOLOv8
|
||||
# tflite_flutter: ^0.11.0
|
||||
share_plus: ^13.3.0
|
||||
file_selector: ^1.1.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
@@ -98,11 +95,8 @@ flutter:
|
||||
# the material Icons class.
|
||||
uses-material-design: true
|
||||
|
||||
# To add assets to your application, add an assets section, like this:
|
||||
# assets:
|
||||
# - assets/models/yolov8n_32.tflite
|
||||
# - images/a_dot_burr.jpeg
|
||||
# - images/a_dot_ham.jpeg
|
||||
assets:
|
||||
- assets/icons/
|
||||
|
||||
# An image asset can refer to one or more resolution-specific "variants", see
|
||||
# https://flutter.dev/to/resolution-aware-images
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:bully/data/models/maintenance.dart';
|
||||
import 'package:bully/data/models/session.dart';
|
||||
import 'package:bully/data/models/shot.dart';
|
||||
import 'package:bully/data/models/target_analysis.dart';
|
||||
import 'package:bully/data/models/target_type.dart';
|
||||
import 'package:bully/data/models/weapon.dart';
|
||||
import 'package:bully/data/repositories/session_repository.dart';
|
||||
import 'package:bully/services/backup_service.dart';
|
||||
|
||||
/// Dépôt en mémoire : évite d'ouvrir une vraie base SQLite dans les tests.
|
||||
class _FakeRepository extends SessionRepository {
|
||||
final List<Session> sessions;
|
||||
final List<Weapon> weapons;
|
||||
final List<MaintenanceEntry> maintenance;
|
||||
final List<List<int>> savedImages = [];
|
||||
|
||||
_FakeRepository({
|
||||
List<Session>? sessions,
|
||||
List<Weapon>? weapons,
|
||||
List<MaintenanceEntry>? maintenance,
|
||||
}) : sessions = sessions ?? [],
|
||||
weapons = weapons ?? [],
|
||||
maintenance = maintenance ?? [];
|
||||
|
||||
@override
|
||||
Future<List<Session>> getAllSessions({int? limit, int? offset}) async =>
|
||||
sessions;
|
||||
|
||||
@override
|
||||
Future<List<Weapon>> getWeapons() async => weapons;
|
||||
|
||||
@override
|
||||
Future<List<MaintenanceEntry>> getAllMaintenance() async => maintenance;
|
||||
|
||||
@override
|
||||
Future<void> saveSession(Session session) async {
|
||||
sessions.removeWhere((s) => s.id == session.id);
|
||||
sessions.add(session);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> saveWeapon(Weapon weapon) async {
|
||||
weapons.removeWhere((w) => w.id == weapon.id);
|
||||
weapons.add(weapon);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> saveMaintenanceEntry(MaintenanceEntry entry) async {
|
||||
maintenance.removeWhere((e) => e.id == entry.id);
|
||||
maintenance.add(entry);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> saveImageBytes(List<int> bytes, String extension) async {
|
||||
savedImages.add(bytes);
|
||||
return '/imported/image_${savedImages.length}$extension';
|
||||
}
|
||||
}
|
||||
|
||||
Session _session({String id = 's1', String weapon = 'Glock 17'}) {
|
||||
return Session(
|
||||
id: id,
|
||||
weapon: weapon,
|
||||
weaponId: 'w1',
|
||||
maxShotsPerTarget: 5,
|
||||
createdAt: DateTime(2026, 3, 14, 10, 30),
|
||||
notes: 'Entraînement',
|
||||
distance: 25,
|
||||
analyses: [
|
||||
TargetAnalysis(
|
||||
id: '$id-a1',
|
||||
sessionId: id,
|
||||
targetType: TargetType.concentric,
|
||||
imagePath: '/photos/$id.jpg',
|
||||
totalScore: 18,
|
||||
groupingDiameter: 0.12,
|
||||
groupingCenterX: 0.5,
|
||||
groupingCenterY: 0.48,
|
||||
createdAt: DateTime(2026, 3, 14, 10, 35),
|
||||
targetCenterX: 0.5,
|
||||
targetCenterY: 0.5,
|
||||
targetRadius: 0.4,
|
||||
shots: [
|
||||
Shot(id: '$id-t1', x: 0.5, y: 0.5, score: 10, analysisId: '$id-a1'),
|
||||
Shot(id: '$id-t2', x: 0.55, y: 0.52, score: 8, analysisId: '$id-a1'),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Weapon _weapon() => Weapon(
|
||||
id: 'w1',
|
||||
name: 'Glock 17',
|
||||
type: WeaponType.handgun,
|
||||
caliber: '9mm',
|
||||
magazineCount: 3,
|
||||
magazineCapacity: 17,
|
||||
createdAt: DateTime(2025, 1, 5),
|
||||
optic: 'Point rouge',
|
||||
customName: 'La bleue',
|
||||
);
|
||||
|
||||
MaintenanceEntry _maintenance() => MaintenanceEntry(
|
||||
id: 'm1',
|
||||
weaponId: 'w1',
|
||||
type: MaintenanceType.cleaning,
|
||||
description: 'Nettoyage complet',
|
||||
date: DateTime(2026, 2, 1),
|
||||
roundsSinceLastMaintenance: 500,
|
||||
);
|
||||
|
||||
/// Sérialise puis relit la sauvegarde comme le ferait un vrai fichier partagé.
|
||||
Future<BackupPreview> _roundTrip(
|
||||
Map<String, dynamic> json,
|
||||
BackupService service,
|
||||
) async {
|
||||
final dir = await Directory.systemTemp.createTemp('impact_backup_test');
|
||||
addTearDown(() => dir.delete(recursive: true));
|
||||
final file = File('${dir.path}/backup.json');
|
||||
await file.writeAsString(jsonEncode(json));
|
||||
return service.readBackup(file);
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('export', () {
|
||||
test('la sauvegarde contient sessions, armurerie et stats', () async {
|
||||
final source = _FakeRepository(
|
||||
sessions: [_session()],
|
||||
weapons: [_weapon()],
|
||||
maintenance: [_maintenance()],
|
||||
);
|
||||
final json = await BackupService(repository: source).buildBackupJson();
|
||||
|
||||
expect(json['format'], BackupService.formatId);
|
||||
expect(json['counts'], {
|
||||
'sessions': 1,
|
||||
'targets': 1,
|
||||
'shots': 2,
|
||||
'weapons': 1,
|
||||
'maintenance': 1,
|
||||
});
|
||||
|
||||
// Les stats sont recalculées et écrites telles quelles dans le fichier.
|
||||
final stats = json['statistics'] as Map<String, dynamic>;
|
||||
expect(stats['total_shots'], 2);
|
||||
expect(stats['total_score'], 18);
|
||||
|
||||
// L'entretien voyage avec son arme.
|
||||
final weapon = (json['weapons'] as List).single as Map<String, dynamic>;
|
||||
expect((weapon['maintenance'] as List), hasLength(1));
|
||||
expect(json['orphan_maintenance'], isEmpty);
|
||||
});
|
||||
|
||||
test('sans photos, aucune image n\'est encodée', () async {
|
||||
final source = _FakeRepository(sessions: [_session()]);
|
||||
final json = await BackupService(repository: source).buildBackupJson();
|
||||
|
||||
final session = (json['sessions'] as List).single as Map<String, dynamic>;
|
||||
final analysis = (session['analyses'] as List).single as Map<String, dynamic>;
|
||||
expect(analysis.containsKey('image_base64'), isFalse);
|
||||
});
|
||||
|
||||
test('l\'entretien d\'une arme supprimée n\'est pas perdu', () async {
|
||||
final source = _FakeRepository(maintenance: [_maintenance()]);
|
||||
final json = await BackupService(repository: source).buildBackupJson();
|
||||
|
||||
expect(json['orphan_maintenance'], hasLength(1));
|
||||
});
|
||||
});
|
||||
|
||||
group('import', () {
|
||||
test('aller-retour complet : tout est restauré à l\'identique', () async {
|
||||
final source = _FakeRepository(
|
||||
sessions: [_session()],
|
||||
weapons: [_weapon()],
|
||||
maintenance: [_maintenance()],
|
||||
);
|
||||
final json = await BackupService(repository: source).buildBackupJson();
|
||||
|
||||
final target = _FakeRepository();
|
||||
final service = BackupService(repository: target);
|
||||
final preview = await _roundTrip(json, service);
|
||||
|
||||
expect(preview.sessionCount, 1);
|
||||
expect(preview.shotCount, 2);
|
||||
expect(preview.weaponCount, 1);
|
||||
expect(preview.maintenanceCount, 1);
|
||||
expect(preview.hasImages, isFalse);
|
||||
|
||||
final result = await service.applyBackup(preview);
|
||||
expect(result.errors, isEmpty);
|
||||
expect(result.sessions, 1);
|
||||
expect(result.weapons, 1);
|
||||
expect(result.maintenance, 1);
|
||||
|
||||
final session = target.sessions.single;
|
||||
expect(session.id, 's1');
|
||||
expect(session.weapon, 'Glock 17');
|
||||
expect(session.distance, 25);
|
||||
expect(session.createdAt, DateTime(2026, 3, 14, 10, 30));
|
||||
expect(session.totalShots, 2);
|
||||
expect(session.totalScore, 18);
|
||||
expect(session.analyses.single.targetRadius, 0.4);
|
||||
expect(session.analyses.single.shots.first.score, 10);
|
||||
|
||||
expect(target.weapons.single.customName, 'La bleue');
|
||||
expect(target.weapons.single.magazineCapacity, 17);
|
||||
expect(target.maintenance.single.roundsSinceLastMaintenance, 500);
|
||||
});
|
||||
|
||||
test('réimporter deux fois ne crée pas de doublon', () async {
|
||||
final source = _FakeRepository(
|
||||
sessions: [_session()],
|
||||
weapons: [_weapon()],
|
||||
);
|
||||
final json = await BackupService(repository: source).buildBackupJson();
|
||||
|
||||
final target = _FakeRepository();
|
||||
final service = BackupService(repository: target);
|
||||
await service.applyBackup(await _roundTrip(json, service));
|
||||
await service.applyBackup(await _roundTrip(json, service));
|
||||
|
||||
expect(target.sessions, hasLength(1));
|
||||
expect(target.weapons, hasLength(1));
|
||||
});
|
||||
|
||||
test('les photos embarquées sont réécrites sur le disque', () async {
|
||||
final bytes = utf8.encode('fausse-image');
|
||||
final json = {
|
||||
'format': BackupService.formatId,
|
||||
'version': 1,
|
||||
'sessions': [
|
||||
{
|
||||
'id': 's1',
|
||||
'weapon': 'Glock 17',
|
||||
'max_shots_per_target': 5,
|
||||
'created_at': '2026-03-14T10:30:00.000',
|
||||
'distance': 25,
|
||||
'analyses': [
|
||||
{
|
||||
'id': 'a1',
|
||||
'session_id': 's1',
|
||||
'target_type': 'concentric',
|
||||
'image_path': '/ancien/chemin.jpg',
|
||||
'total_score': 10,
|
||||
'created_at': '2026-03-14T10:35:00.000',
|
||||
'image_extension': '.jpg',
|
||||
'image_base64': base64Encode(bytes),
|
||||
'shots': [
|
||||
{'id': 't1', 'x': 0.5, 'y': 0.5, 'score': 10, 'analysis_id': 'a1'},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
final target = _FakeRepository();
|
||||
final service = BackupService(repository: target);
|
||||
final preview = await _roundTrip(json, service);
|
||||
expect(preview.hasImages, isTrue);
|
||||
|
||||
final result = await service.applyBackup(preview);
|
||||
expect(result.images, 1);
|
||||
expect(target.savedImages.single, bytes);
|
||||
expect(target.sessions.single.analyses.single.imagePath,
|
||||
'/imported/image_1.jpg');
|
||||
});
|
||||
|
||||
test('un nombre entier là où un décimal est attendu ne casse rien', () async {
|
||||
// JSON ne distingue pas 1 de 1.0 : un fichier édité à la main peut
|
||||
// livrer des entiers là où les modèles attendent des doubles.
|
||||
final json = {
|
||||
'format': BackupService.formatId,
|
||||
'version': 1,
|
||||
'sessions': [
|
||||
{
|
||||
'id': 's1',
|
||||
'weapon': 'Glock 17',
|
||||
'max_shots_per_target': 5,
|
||||
'created_at': '2026-03-14T10:30:00.000',
|
||||
'analyses': [
|
||||
{
|
||||
'id': 'a1',
|
||||
'session_id': 's1',
|
||||
'target_type': 'concentric',
|
||||
'image_path': '/photo.jpg',
|
||||
'total_score': 10,
|
||||
'created_at': '2026-03-14T10:35:00.000',
|
||||
'target_radius': 1,
|
||||
'grouping_diameter': 0,
|
||||
'shots': const [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
final target = _FakeRepository();
|
||||
final service = BackupService(repository: target);
|
||||
final result = await service.applyBackup(await _roundTrip(json, service));
|
||||
|
||||
expect(result.errors, isEmpty);
|
||||
expect(target.sessions.single.analyses.single.targetRadius, 1.0);
|
||||
expect(target.sessions.single.analyses.single.groupingDiameter, 0.0);
|
||||
});
|
||||
|
||||
test('un fichier étranger est refusé avec un message clair', () async {
|
||||
final service = BackupService(repository: _FakeRepository());
|
||||
final dir = await Directory.systemTemp.createTemp('impact_backup_test');
|
||||
addTearDown(() => dir.delete(recursive: true));
|
||||
|
||||
final notJson = File('${dir.path}/photo.jpg');
|
||||
await notJson.writeAsString('pas du json du tout');
|
||||
expect(
|
||||
() => service.readBackup(notJson),
|
||||
throwsA(isA<BackupFormatException>()),
|
||||
);
|
||||
|
||||
final otherJson = File('${dir.path}/autre.json');
|
||||
await otherJson.writeAsString('{"hello": "world"}');
|
||||
expect(
|
||||
() => service.readBackup(otherJson),
|
||||
throwsA(isA<BackupFormatException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('une sauvegarde plus récente que l\'app est refusée', () async {
|
||||
final service = BackupService(repository: _FakeRepository());
|
||||
final dir = await Directory.systemTemp.createTemp('impact_backup_test');
|
||||
addTearDown(() => dir.delete(recursive: true));
|
||||
|
||||
final file = File('${dir.path}/futur.json');
|
||||
await file.writeAsString(jsonEncode({
|
||||
'format': BackupService.formatId,
|
||||
'version': BackupService.formatVersion + 1,
|
||||
}));
|
||||
|
||||
expect(
|
||||
() => service.readBackup(file),
|
||||
throwsA(isA<BackupFormatException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('une session corrompue est ignorée sans bloquer les autres', () async {
|
||||
final valid = await BackupService(
|
||||
repository: _FakeRepository(sessions: [_session()]),
|
||||
).buildBackupJson();
|
||||
// On injecte une session sans date : elle doit être la seule écartée.
|
||||
(valid['sessions'] as List).add({
|
||||
'id': 'corrompue',
|
||||
'weapon': 'X',
|
||||
'max_shots_per_target': 5,
|
||||
'analyses': const [],
|
||||
});
|
||||
|
||||
final target = _FakeRepository();
|
||||
final service = BackupService(repository: target);
|
||||
final result = await service.applyBackup(await _roundTrip(valid, service));
|
||||
|
||||
expect(result.sessions, 1);
|
||||
expect(result.errors, hasLength(1));
|
||||
expect(target.sessions.single.id, 's1');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:bully/data/models/shot.dart';
|
||||
import 'package:bully/services/grouping_analyzer_service.dart';
|
||||
|
||||
Shot _shot(String id, double x, double y) =>
|
||||
Shot(id: id, x: x, y: y, score: 0, analysisId: '');
|
||||
|
||||
void main() {
|
||||
final service = GroupingAnalyzerService();
|
||||
|
||||
group('analyzeGrouping', () {
|
||||
test('liste vide → résultat vide', () {
|
||||
final result = service.analyzeGrouping([]);
|
||||
expect(result.shotCount, 0);
|
||||
expect(result.diameter, 0);
|
||||
});
|
||||
|
||||
test('un seul tir → diamètre nul, centre sur le tir', () {
|
||||
final result = service.analyzeGrouping([_shot('a', 0.3, 0.7)]);
|
||||
expect(result.shotCount, 1);
|
||||
expect(result.centerX, 0.3);
|
||||
expect(result.centerY, 0.7);
|
||||
expect(result.diameter, 0);
|
||||
});
|
||||
|
||||
test('deux tirs → centroïde au milieu et diamètre = écart', () {
|
||||
final result = service.analyzeGrouping([
|
||||
_shot('a', 0.4, 0.5),
|
||||
_shot('b', 0.6, 0.5),
|
||||
]);
|
||||
|
||||
expect(result.centerX, closeTo(0.5, 1e-9));
|
||||
expect(result.centerY, closeTo(0.5, 1e-9));
|
||||
expect(result.diameter, closeTo(0.2, 1e-9));
|
||||
expect(result.meanRadius, closeTo(0.1, 1e-9));
|
||||
// Deux tirs équidistants du centroïde → dispersion nulle.
|
||||
expect(result.standardDeviation, closeTo(0.0, 1e-9));
|
||||
});
|
||||
|
||||
test('le diamètre est la plus grande distance entre deux tirs', () {
|
||||
final result = service.analyzeGrouping([
|
||||
_shot('a', 0.2, 0.5),
|
||||
_shot('b', 0.5, 0.5),
|
||||
_shot('c', 0.8, 0.5),
|
||||
]);
|
||||
expect(result.diameter, closeTo(0.6, 1e-9));
|
||||
});
|
||||
});
|
||||
|
||||
group('getOffsetDescription', () {
|
||||
test('décalage négligeable → Centre', () {
|
||||
expect(service.getOffsetDescription(0.01, -0.01), 'Centre');
|
||||
});
|
||||
|
||||
test('décalage combiné → direction composée', () {
|
||||
expect(service.getOffsetDescription(0.1, -0.1), 'Haut-Droite');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:bully/data/models/shot.dart';
|
||||
import 'package:bully/data/models/target_type.dart';
|
||||
import 'package:bully/services/score_calculator_service.dart';
|
||||
|
||||
void main() {
|
||||
final service = ScoreCalculatorService();
|
||||
|
||||
group('calculateConcentricScore', () {
|
||||
test('un tir au centre exact vaut 10', () {
|
||||
final score = service.calculateConcentricScore(
|
||||
shotX: 0.5,
|
||||
shotY: 0.5,
|
||||
targetCenterX: 0.5,
|
||||
targetCenterY: 0.5,
|
||||
targetRadius: 0.4,
|
||||
);
|
||||
expect(score, 10);
|
||||
});
|
||||
|
||||
test('un tir hors de la cible vaut 0', () {
|
||||
final score = service.calculateConcentricScore(
|
||||
shotX: 0.95,
|
||||
shotY: 0.5,
|
||||
targetCenterX: 0.5,
|
||||
targetCenterY: 0.5,
|
||||
targetRadius: 0.4,
|
||||
);
|
||||
expect(score, 0);
|
||||
});
|
||||
|
||||
test('un tir à mi-rayon tombe dans la zone attendue', () {
|
||||
// Distance normalisée ≈ 0.475 → 5e anneau (zone 0.5) → score 6.
|
||||
final score = service.calculateConcentricScore(
|
||||
shotX: 0.5,
|
||||
shotY: 0.69,
|
||||
targetCenterX: 0.5,
|
||||
targetCenterY: 0.5,
|
||||
targetRadius: 0.4,
|
||||
);
|
||||
expect(score, 6);
|
||||
});
|
||||
|
||||
test('le ratio d\'image est pris en compte (image paysage 2:1)', () {
|
||||
// dx = 0.15 mais scaled ×2 → distance normalisée 0.75 → score 3.
|
||||
final score = service.calculateConcentricScore(
|
||||
shotX: 0.65,
|
||||
shotY: 0.5,
|
||||
targetCenterX: 0.5,
|
||||
targetCenterY: 0.5,
|
||||
targetRadius: 0.4,
|
||||
imageAspectRatio: 2.0,
|
||||
);
|
||||
expect(score, 3);
|
||||
});
|
||||
|
||||
test('les rayons d\'anneaux personnalisés sont respectés', () {
|
||||
// Distance normalisée 0.3 avec anneaux [0.2, 0.5, 1.0] → 2e anneau → 9.
|
||||
final score = service.calculateConcentricScore(
|
||||
shotX: 0.5,
|
||||
shotY: 0.62,
|
||||
targetCenterX: 0.5,
|
||||
targetCenterY: 0.5,
|
||||
targetRadius: 0.4,
|
||||
ringCount: 3,
|
||||
ringRadii: [0.2, 0.5, 1.0],
|
||||
);
|
||||
expect(score, 9);
|
||||
});
|
||||
});
|
||||
|
||||
group('calculateScores', () {
|
||||
test('agrège le total, le max possible et le pourcentage', () {
|
||||
final shots = [
|
||||
Shot(id: 'a', x: 0.5, y: 0.5, score: 0, analysisId: ''), // centre → 10
|
||||
Shot(id: 'b', x: 0.95, y: 0.5, score: 0, analysisId: ''), // dehors → 0
|
||||
];
|
||||
|
||||
final result = service.calculateScores(
|
||||
shots: shots,
|
||||
targetType: TargetType.concentric,
|
||||
targetCenterX: 0.5,
|
||||
targetCenterY: 0.5,
|
||||
targetRadius: 0.4,
|
||||
);
|
||||
|
||||
expect(result.totalScore, 10);
|
||||
expect(result.maxPossibleScore, 20);
|
||||
expect(result.percentage, closeTo(50.0, 0.001));
|
||||
expect(result.shotCount, 2);
|
||||
expect(result.scoreDistribution[10], 1);
|
||||
expect(result.scoreDistribution[0], 1);
|
||||
});
|
||||
|
||||
test('une liste vide donne un résultat neutre', () {
|
||||
final result = service.calculateScores(
|
||||
shots: [],
|
||||
targetType: TargetType.concentric,
|
||||
targetCenterX: 0.5,
|
||||
targetCenterY: 0.5,
|
||||
targetRadius: 0.4,
|
||||
);
|
||||
|
||||
expect(result.totalScore, 0);
|
||||
expect(result.maxPossibleScore, 0);
|
||||
expect(result.percentage, 0.0);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,20 +1,32 @@
|
||||
// This is a basic Flutter widget test.
|
||||
// Test de fumée sur un widget pur (sans providers ni base de données).
|
||||
//
|
||||
// To perform an interaction with a widget in your test, use the WidgetTester
|
||||
// utility in the flutter_test package. For example, you can send tap and scroll
|
||||
// gestures. You can also use WidgetTester to find child widgets in the widget
|
||||
// tree, read text, and verify that the values of widget properties are correct.
|
||||
// Le montage complet de BullyApp exige les providers globaux et une base
|
||||
// SQLite initialisée ; pour un test widget rapide et stable, on valide ici
|
||||
// le rendu d'un composant autonome.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:bully/app.dart';
|
||||
import 'package:bully/features/home/widgets/stats_card.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('App loads correctly', (WidgetTester tester) async {
|
||||
// Build our app and trigger a frame.
|
||||
await tester.pumpWidget(const BullyApp());
|
||||
testWidgets('StatsCard affiche le titre, la valeur et l\'icône',
|
||||
(WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
const MaterialApp(
|
||||
home: Scaffold(
|
||||
body: StatsCard(
|
||||
icon: Icons.gps_fixed,
|
||||
title: 'Tirs',
|
||||
value: '42',
|
||||
color: Colors.blue,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Verify that the app title is displayed.
|
||||
expect(find.text('Bully'), findsOneWidget);
|
||||
expect(find.text('Tirs'), findsOneWidget);
|
||||
expect(find.text('42'), findsOneWidget);
|
||||
expect(find.byIcon(Icons.gps_fixed), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import 'package:opencv_dart/opencv_dart.dart' as cv;
|
||||
|
||||
void main() {
|
||||
var p1 = cv.VecPoint.fromList([cv.Point(0, 0), cv.Point(1, 1)]);
|
||||
var p2 = cv.VecPoint2f.fromList([cv.Point2f(0, 0), cv.Point2f(1, 1)]);
|
||||
|
||||
// Is it p1.mat ?
|
||||
// Or is it cv.findHomography(p1, p1) but actually needs specific types ?
|
||||
cv.Mat mat1 = cv.Mat.fromVec(p1);
|
||||
cv.Mat mat2 = cv.Mat.fromVec(p2);
|
||||
cv.findHomography(mat1, mat2);
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import 'package:opencv_dart/opencv_dart.dart' as cv;
|
||||
|
||||
void main() {
|
||||
print(cv.approxPolyDP);
|
||||
print(cv.arcLength);
|
||||
print(cv.contourArea);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import 'package:opencv_dart/opencv_dart.dart' as cv;
|
||||
|
||||
void main() {
|
||||
print(cv.findHomography);
|
||||
}
|
||||
@@ -8,10 +8,16 @@
|
||||
|
||||
#include <file_selector_windows/file_selector_windows.h>
|
||||
#include <permission_handler_windows/permission_handler_windows_plugin.h>
|
||||
#include <share_plus/share_plus_windows_plugin_c_api.h>
|
||||
#include <url_launcher_windows/url_launcher_windows.h>
|
||||
|
||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
FileSelectorWindowsRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FileSelectorWindows"));
|
||||
PermissionHandlerWindowsPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
|
||||
SharePlusWindowsPluginCApiRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi"));
|
||||
UrlLauncherWindowsRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
file_selector_windows
|
||||
permission_handler_windows
|
||||
share_plus
|
||||
url_launcher_windows
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
|
||||