27 Commits

Author SHA1 Message Date
streaper2
774dbfcf40 feat: implement base architecture and core repositories for weapon tracking and target analysis functionality 2026-05-08 20:29:18 +02:00
streaper2
5dd58da51c Merge backend branch and restore functionalities 2026-05-08 08:34:16 +02:00
streaper2
8910a0a87e chore: remove backendia node_modules and build files from git 2026-05-08 08:33:32 +02:00
streaper2
0895b7d5bc Restore UI functionalities and clean up dependencies 2026-05-08 08:32:58 +02:00
streaper2
6892d55c65 feat: add PhotoOverlay component for visualizing and editing impact coordinates 2026-05-07 21:07:55 +02:00
qguillaume
8946955f76 feat: passage du filtre date à une sélection de période (DateRange) dans l'historique 2026-05-06 23:17:22 +02:00
qguillaume
be62669d56 feat: intégration de la vue validation image (20) 2026-05-06 23:05:13 +02:00
qguillaume
db6dd0cee5 feat: refonte des statistiques (00C) et correction ergonomique de la capture (10C) 2026-05-06 22:39:30 +02:00
qguillaume
d98f54cf93 Ajout de la gestion des permissions photos et configuration native Android/iOS 2026-05-05 18:05:49 +02:00
qguillaume
55f6210c97 feat: change titre et ajout nettoyer objectif dans capture screen 2026-05-05 16:53:29 +02:00
qguillaume
8d9b35104a Fix: Intégration du DatePicker français et mise à jour des dépendances SDK 2026-05-05 16:36:14 +02:00
qguillaume
5cc67228e4 MAJ0.1 accueil et historique 2026-05-03 16:46:05 +02:00
streaper2
cea2fab989 branch backend + ajout du serveur backend, next, et sqlite 2026-04-29 15:45:09 +02:00
streaper2
105eb1cab0 correction suppression modele yolo 2026-04-29 10:12:29 +02:00
streaper2
fba3b41f2f desactivation de tous les scripts et ia, supression du modele yolo 2026-04-28 16:52:20 +02:00
streaper2
e32833e366 preparation du modele yolo 2026-03-12 22:03:40 +01:00
streaper2
d4cb179fde correction du centrage des vecteurs de zone (cercle) pour le plotting 2026-02-21 10:22:18 +01:00
streaper2
2e81f4b69e Merge branch 'temp-fix'
detached branch a merge pour récupérer le travail
2026-02-15 22:21:31 +01:00
streaper2
de677aad7e désactivation autocalibration, et correction de distortion dans l'écran de calibration 2026-02-15 22:07:35 +01:00
streaper2
723900b860 Autocalibration de la cible sur le centre de la cible ok + petite correction de la distortion mais pas tres fonctionnel 2026-02-15 09:49:48 +01:00
streaper2
f78184d2cd nettoyage du code avec cunning 2026-02-14 22:33:35 +01:00
streaper2
7e55c52ae7 implementation de cunning pour scanner l'image et la redresser 2026-02-13 22:18:35 +01:00
streaper2
049f77ac6d ajout doc interne a l'appli 2026-02-06 13:40:01 +01:00
qguillaume
91fcd931e8 clickable home screen renamed 2026-02-03 12:16:30 +01:00
streaper2
9ccb149dda désactivation de l'auto analyse au chargement de l'écran d'analyse 2026-01-31 20:55:28 +01:00
streaper2
972474750f - bouton effacer
- bouton ajouter
+ bouton effacer sur l'image de cible
2026-01-30 20:58:57 +01:00
streaper2
2f69ff4ecf +changelog
+readme
2026-01-30 20:28:02 +01:00
125 changed files with 17748 additions and 3208 deletions

12
.gitignore vendored
View File

@@ -43,3 +43,15 @@ app.*.map.json
/android/app/debug /android/app/debug
/android/app/profile /android/app/profile
/android/app/release /android/app/release
# Backendia
backendia/node_modules/
backendia/dashboard/node_modules/
backendia/dashboard/.next/
backendia/dashboard/out/
backendia/exports/*.zip
backendia/uploads/images/*
!backendia/uploads/images/.gitkeep
backendia/uploads/data/*
!backendia/uploads/data/.gitkeep
!backendia/uploads/data/database.sqlite

4
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,4 @@
{
"avdmanager.executable": "c:\\Users\\streaper2\\AppData\\Local\\Android\\Sdk\\cmdline-tools\\latest\\bin\\avdmanager.bat",
"avdmanager.sdkManager": "c:\\Users\\streaper2\\AppData\\Local\\Android\\Sdk\\cmdline-tools\\latest\\bin\\sdkmanager.bat"
}

25
CHANGELOG.md Normal file
View File

@@ -0,0 +1,25 @@
# Changelog
## [v0.0.1] - 2026-01-29
### Ajouté
- **Interface d'Analyse** :
- Implémentation d'un bouton de sauvegarde "Morphing" : le bouton flottant se transforme en un bouton large en bas de page lors du défilement.
- Ajout de la gestion du défilement et de l'espacement pour une meilleure ergonomie.
- Visualisation des impacts et statistiques de groupement.
- **Support Desktop (Windows)** :
- Configuration de la base de données SQLite pour fonctionner sur Windows via `sqflite_common_ffi`.
- Initialisation conditionnelle selon la plateforme.
### Corrigé
- **Crash Windows** : Résolution du plantage dû à l'initialisation manquante de la factory de base de données FFI.
- **Dépendances** : Fixation de la version de `sqflite_common_ffi` à `2.3.3` pour contourner un problème de cache/corruption avec la version `2.4.0+2`.
- **UI/UX** :
- Correction des débordements de texte ("zebra stripes") dans le bouton de sauvegarde lors de l'animation grâce à `FittedBox`.
- Optimisation de l'affichage du titre "Groupement" dans les statistiques pour éviter les dépassements sur petits écrans.
- Nettoyage des appels redondants (`super.initState`) et correction de la structure des widgets (`Stack` mal fermé).
### Historique des Commits
- `db7160b` - +désactivation (2026-01-29)
- `f1a8eef` - ajout correctif (2026-01-28)
- `031d4a4` - premier app version beta (2026-01-18)

View File

@@ -1,17 +1,35 @@
# bully # Bully - Analyseur de Cible
A new Flutter project. Application Flutter multiplateforme pour l'analyse et le suivi de vos séances de tir.
## Getting Started ## Fonctionnalités Principales
This project is a starting point for a Flutter application. * **Capture et Analyse** : Prenez une photo de votre cible et analysez vos impacts.
* **Détection Automatique** : Utilise des algorithmes pour détecter automatiquement les impacts de balle sur la cible.
* **Calibration** : Outils de calibration précis pour définir la taille et le centre de la cible, assurant des mesures exactes.
* **Statistiques Détaillées** :
* Calcul du score total.
* Analyse du groupement (H+L, diamètre moyen).
* Visualisation graphique de la dispersion.
* **Historique** : Sauvegardez vos sessions avec des notes et consultez votre progression au fil du temps.
* **Interface Intuitive** : Design moderne et fluide, avec un bouton de sauvegarde dynamique qui s'adapte à votre navigation.
A few resources to get you started if this is your first Flutter project: ## Détails Techniques
- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter) * **Framework** : Flutter (Compatible Android, iOS, Windows, Linux, macOS).
- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) * **Base de Données** : SQLite (via `sqflite` et `sqflite_common_ffi` pour le support Desktop).
- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources) * **Graphiques** : `fl_chart` pour la visualisation des données.
* **Architecture** : Provider pour la gestion d'état.
For help getting started with Flutter development, view the ## Installation
[online documentation](https://docs.flutter.dev/), which offers tutorials,
samples, guidance on mobile development, and a full API reference. 1. Assurez-vous d'avoir Flutter installé.
2. Clonez le dépôt.
3. Installez les dépendances :
```bash
flutter pub get
```
4. Lancez l'application :
```bash
flutter run
```

BIN
analysis_report.txt Normal file

Binary file not shown.

31
analyze_log.txt Normal file
View File

@@ -0,0 +1,31 @@
Analyzing bully...
info - Statements in an if should be enclosed in a block - lib\features\analysis\analysis_screen.dart:122:17 - curly_braces_in_flow_control_structures
info - 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss - lib\features\analysis\analysis_screen.dart:650:51 - deprecated_member_use
warning - The declaration '_showAddShotHint' isn't referenced - lib\features\analysis\analysis_screen.dart:1083:8 - unused_element
warning - The declaration '_showAutoDetectDialog' isn't referenced - lib\features\analysis\analysis_screen.dart:1120:8 - unused_element
warning - Unused import: 'widgets/target_type_selector.dart' - lib\features\capture\capture_screen.dart:16:8 - unused_import
info - The private field _selectedType could be 'final' - lib\features\capture\capture_screen.dart:28:14 - prefer_final_fields
info - 'scale' is deprecated and shouldn't be used. Use scaleByVector3, scaleByVector4, or scaleByDouble instead - lib\features\crop\crop_screen.dart:141:25 - deprecated_member_use
info - The import of 'package:flutter/foundation.dart' is unnecessary because all of the used elements are also provided by the import of 'package:flutter/material.dart' - lib\features\statistics\statistics_screen.dart:8:8 - unnecessary_import
warning - The declaration '_buildLegendItem' isn't referenced - lib\features\statistics\statistics_screen.dart:309:10 - unused_element
info - Unnecessary use of string interpolation - lib\features\statistics\statistics_screen.dart:408:15 - unnecessary_string_interpolations
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:192:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:239:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:246:9 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:278:9 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:289:11 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:292:11 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:297:9 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:332:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:336:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:683:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:725:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:736:7 - avoid_print
warning - The declaration '_detectDarkSpotsAdaptive' isn't referenced - lib\services\image_processing_service.dart:780:15 - unused_element
info - Don't invoke 'print' in production code - lib\services\opencv_impact_detection_service.dart:104:5 - avoid_print
info - Don't invoke 'print' in production code - lib\services\opencv_impact_detection_service.dart:116:5 - avoid_print
info - Don't invoke 'print' in production code - lib\services\target_detection_service.dart:297:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\target_detection_service.dart:342:7 - avoid_print
27 issues found. (ran in 1.9s)

BIN
analyze_opencv.txt Normal file

Binary file not shown.

BIN
analyze_output.txt Normal file

Binary file not shown.

BIN
analyze_results_final.txt Normal file

Binary file not shown.

37
analyze_results_fixed.txt Normal file
View File

@@ -0,0 +1,37 @@
Analyzing bully...
info - Don't invoke 'print' in production code - lib\features\analysis\analysis_provider.dart:553:7 - avoid_print
info - The private field _selectedType could be 'final' - lib\features\capture\capture_screen.dart:27:14 - prefer_final_fields
info - 'scale' is deprecated and shouldn't be used. Use scaleByVector3, scaleByVector4, or scaleByDouble instead - lib\features\crop\crop_screen.dart:142:25 - deprecated_member_use
info - Statements in an if should be enclosed in a block - lib\services\distortion_correction_service.dart:566:11 - curly_braces_in_flow_control_structures
info - Don't invoke 'print' in production code - lib\services\distortion_correction_service.dart:639:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\distortion_correction_service.dart:764:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\distortion_correction_service.dart:825:9 - avoid_print
info - Don't invoke 'print' in production code - lib\services\distortion_correction_service.dart:953:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\distortion_correction_service.dart:1015:9 - avoid_print
info - Don't invoke 'print' in production code - lib\services\distortion_correction_service.dart:1063:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:192:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:239:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:246:9 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:278:9 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:289:11 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:292:11 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:297:9 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:332:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:336:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:683:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:725:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:736:7 - avoid_print
warning - The declaration '_detectDarkSpotsAdaptive' isn't referenced - lib\services\image_processing_service.dart:780:15 - unused_element
info - Don't invoke 'print' in production code - lib\services\target_detection_service.dart:328:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\target_detection_service.dart:377:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\target_detection_service.dart:414:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\yolo_impact_detection_service.dart:23:9 - avoid_print
info - Don't invoke 'print' in production code - lib\services\yolo_impact_detection_service.dart:29:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\yolo_impact_detection_service.dart:31:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\yolo_impact_detection_service.dart:67:7 - avoid_print
info - Don't invoke 'print' in production code - tests\opencv_quad_test.dart:4:3 - avoid_print
info - Don't invoke 'print' in production code - tests\opencv_quad_test.dart:5:3 - avoid_print
info - Don't invoke 'print' in production code - tests\opencv_quad_test.dart:6:3 - avoid_print
info - Don't invoke 'print' in production code - tests\test_homography.dart:4:3 - avoid_print

38
analyze_utf8.txt Normal file
View File

@@ -0,0 +1,38 @@
Analyzing bully...
info - Don't invoke 'print' in production code - lib\features\analysis\analysis_provider.dart:392:7 - avoid_print
info - Don't invoke 'print' in production code - lib\features\analysis\analysis_provider.dart:596:7 - avoid_print
info - The private field _selectedType could be 'final' - lib\features\capture\capture_screen.dart:27:14 - prefer_final_fields
info - 'scale' is deprecated and shouldn't be used. Use scaleByVector3, scaleByVector4, or scaleByDouble instead - lib\features\crop\crop_screen.dart:142:25 - deprecated_member_use
info - Statements in an if should be enclosed in a block - lib\services\distortion_correction_service.dart:566:11 - curly_braces_in_flow_control_structures
info - Don't invoke 'print' in production code - lib\services\distortion_correction_service.dart:639:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\distortion_correction_service.dart:764:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\distortion_correction_service.dart:825:9 - avoid_print
info - Don't invoke 'print' in production code - lib\services\distortion_correction_service.dart:953:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\distortion_correction_service.dart:1015:9 - avoid_print
info - Don't invoke 'print' in production code - lib\services\distortion_correction_service.dart:1063:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:192:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:239:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:246:9 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:278:9 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:289:11 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:292:11 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:297:9 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:332:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:336:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:683:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:725:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:736:7 - avoid_print
warning - The declaration '_detectDarkSpotsAdaptive' isn't referenced - lib\services\image_processing_service.dart:780:15 - unused_element
info - Don't invoke 'print' in production code - lib\services\target_detection_service.dart:328:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\target_detection_service.dart:377:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\target_detection_service.dart:414:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\yolo_impact_detection_service.dart:23:9 - avoid_print
info - Don't invoke 'print' in production code - lib\services\yolo_impact_detection_service.dart:29:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\yolo_impact_detection_service.dart:31:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\yolo_impact_detection_service.dart:67:7 - avoid_print
info - Don't invoke 'print' in production code - tests\opencv_quad_test.dart:4:3 - avoid_print
info - Don't invoke 'print' in production code - tests\opencv_quad_test.dart:5:3 - avoid_print
info - Don't invoke 'print' in production code - tests\opencv_quad_test.dart:6:3 - avoid_print
info - Don't invoke 'print' in production code - tests\test_homography.dart:4:3 - avoid_print

View File

@@ -35,6 +35,7 @@ android {
// TODO: Add your own signing config for the release build. // TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works. // Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug") signingConfig = signingConfigs.getByName("debug")
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
} }
} }
} }

7
android/app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,7 @@
# TensorFlow Lite
-keep class org.tensorflow.lite.** { *; }
-keep class com.google.android.gms.tflite.** { *; }
-dontwarn org.tensorflow.lite.**
# Specifically for the GPU delegate error
-dontwarn org.tensorflow.lite.gpu.GpuDelegateFactory$Options

View File

@@ -1,4 +1,11 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.CAMERA" />
<!-- Pour Android 12 et inférieur -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
<!-- Pour Android 13 et supérieur -->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<application <application
android:label="bully" android:label="bully"
android:name="${applicationName}" android:name="${applicationName}"

38
backendia/.gitignore vendored Normal file
View File

@@ -0,0 +1,38 @@
# Dependencies
node_modules/
dashboard/node_modules/
# Next.js build output
dashboard/.next/
dashboard/out/
# Environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Database
backendia/uploads/data/database.sqlite
*.sqlite
*.sqlite-journal
# Uploads and Exports (Ignore content but keep directories)
uploads/images/*
!uploads/images/.gitkeep
uploads/data/*
!uploads/data/.gitkeep
!uploads/data/database.sqlite
exports/*
!exports/.gitkeep
# Logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
*.log
# OS files
.DS_Store
Thumbs.db

41
backendia/dashboard/.gitignore vendored Normal file
View File

@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

View File

@@ -0,0 +1,5 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules -->

View File

@@ -0,0 +1 @@
@AGENTS.md

View File

@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

View File

@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

6
backendia/dashboard/next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -0,0 +1,17 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
images: {
remotePatterns: [
{
protocol: 'http',
hostname: '127.0.0.1',
port: '3000',
pathname: '/uploads/**',
},
],
},
};
export default nextConfig;

6616
backendia/dashboard/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,29 @@
{
"name": "dashboard",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"clsx": "^2.1.1",
"lucide-react": "^1.14.0",
"next": "16.2.4",
"react": "19.2.4",
"react-dom": "19.2.4",
"tailwind-merge": "^3.5.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.2.4",
"tailwindcss": "^4",
"typescript": "^5"
}
}

View File

@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

View File

@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

View File

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

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

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

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

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

After

Width:  |  Height:  |  Size: 128 B

View File

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

After

Width:  |  Height:  |  Size: 385 B

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -0,0 +1,26 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}

View File

@@ -0,0 +1,67 @@
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";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "Bully IA Dashboard",
description: "Visualisation des données d'analyse de cibles",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="fr" className="dark">
<body className={`${inter.className} bg-slate-950 text-slate-50 min-h-screen flex`}>
{/* Sidebar */}
<aside className="w-64 border-r border-slate-800 bg-slate-900/50 p-6 flex flex-col gap-8">
<div className="flex items-center gap-3 px-2">
<div className="w-8 h-8 bg-indigo-600 rounded-lg flex items-center justify-center font-bold">B</div>
<h1 className="text-xl font-bold tracking-tight">Bully IA</h1>
</div>
<nav className="flex flex-col gap-2">
<Link
href="/"
className="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-slate-800 transition-colors text-slate-300 hover:text-white"
>
<LayoutDashboard size={20} />
Tableau de bord
</Link>
<Link
href="/contributors"
className="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-slate-800 transition-colors text-slate-300 hover:text-white"
>
<Users size={20} />
Contributeurs
</Link>
</nav>
<div className="mt-auto pt-6 border-t border-slate-800">
<p className="text-xs text-slate-500 px-2">v1.0.0 Alpha</p>
</div>
</aside>
{/* Main Content */}
<main className="flex-1 flex flex-col">
<header className="h-16 border-b border-slate-800 flex items-center px-8 bg-slate-900/20">
<div className="flex items-center gap-2 text-sm text-slate-400">
<span>Admin</span>
<span>/</span>
<span className="text-slate-200">Dashboard</span>
</div>
</header>
<div className="flex-1 p-8 overflow-auto">
{children}
</div>
</main>
</body>
</html>
);
}

View File

@@ -0,0 +1,105 @@
import { fetchApi, API_BASE_URL } from "@/lib/api";
import DatasetToolbar from "@/components/DatasetToolbar";
import { Calendar, User, Crosshair } from "lucide-react";
import Link from "next/link";
interface PlottingData {
session_id: string;
wallet_hash: string;
timestamp: string;
plotting: {
impacts: any[];
};
}
interface PhotoData {
filename: string;
imageUrl: string;
data: PlottingData | null;
}
export default async function DashboardPage() {
let photos: PhotoData[] = [];
try {
const response = await fetchApi('/api/all-data');
photos = response.data;
} catch (error) {
console.error("Error fetching photos:", error);
}
return (
<div className="space-y-8">
<div className="flex flex-col md:flex-row md:items-end justify-between gap-4">
<div className="flex flex-col gap-2">
<h2 className="text-3xl font-bold tracking-tight">Galerie des Sessions</h2>
<p className="text-slate-400">Visualisez et gérez les données d'entraînement collectées.</p>
</div>
</div>
<DatasetToolbar />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{photos.map((photo) => (
<Link
key={photo.filename}
href={`/photo/${photo.filename}`}
className="group relative bg-slate-900 rounded-xl border border-slate-800 overflow-hidden hover:border-indigo-500/50 transition-all hover:shadow-2xl hover:shadow-indigo-500/10"
>
{/* Image Preview */}
<div className="aspect-[3/4] relative overflow-hidden bg-slate-800">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={`${API_BASE_URL}${photo.imageUrl}`}
alt={photo.filename}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
/>
<div className="absolute inset-0 bg-gradient-to-t from-slate-950 via-transparent to-transparent opacity-80" />
{/* Badge impacts */}
{photo.data?.plotting?.impacts && (
<div className="absolute top-3 right-3 bg-indigo-600/90 backdrop-blur-md px-2 py-1 rounded-md text-[10px] font-bold flex items-center gap-1">
<Crosshair size={12} />
{photo.data.plotting.impacts.length} IMPACTS
</div>
)}
</div>
{/* Info */}
<div className="p-4 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-xs text-slate-400">
<Calendar size={14} className="text-indigo-400" />
{photo.data ? new Date(photo.data.timestamp).toLocaleDateString() : 'Date inconnue'}
</div>
{photo.data ? (
<span className="text-[10px] bg-emerald-500/10 text-emerald-400 px-1.5 py-0.5 rounded border border-emerald-500/20 font-bold">DATA OK</span>
) : (
<span className="text-[10px] bg-rose-500/10 text-rose-400 px-1.5 py-0.5 rounded border border-rose-500/20 font-bold">NO DATA</span>
)}
</div>
<div className="flex items-center gap-2 text-xs text-slate-300 font-mono truncate">
<User size={14} className="text-indigo-400" />
{photo.data?.wallet_hash?.substring(0, 12) || 'Anonyme'}...
</div>
<div className="pt-2 border-t border-slate-800 flex justify-between items-center">
<span className="text-[10px] text-slate-500 uppercase tracking-widest font-bold">Session ID</span>
<span className="text-xs text-slate-300 font-mono truncate max-w-[120px]">
{photo.data?.session_id || 'N/A'}
</span>
</div>
</div>
</Link>
))}
</div>
{photos.length === 0 && (
<div className="flex flex-col items-center justify-center py-24 text-slate-500 gap-4 border-2 border-dashed border-slate-800 rounded-3xl">
<Crosshair size={48} className="opacity-20" />
<p>Aucune session d'entraînement trouvée.</p>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,49 @@
import { fetchApi, API_BASE_URL } from "@/lib/api";
import PhotoEditor from "@/components/PhotoEditor";
import { ChevronLeft, Download } from "lucide-react";
import Link from "next/link";
export default async function PhotoDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id: encodedId } = await params;
const id = decodeURIComponent(encodedId);
let photo = null;
try {
const response = await fetchApi('/api/all-data');
photo = response.data.find((p: any) => p.filename === id);
} catch (error) {
console.error("Error fetching photo detail:", error);
}
if (!photo) {
return <div className="p-12 text-center">Session non trouvée.</div>;
}
return (
<div className="space-y-8">
{/* Header */}
<div className="flex items-center justify-between">
<Link
href="/"
className="flex items-center gap-2 text-slate-400 hover:text-white transition-colors"
>
<ChevronLeft size={20} />
Retour à la galerie
</Link>
<div className="flex gap-3">
<a
href={`${API_BASE_URL}/uploads/images/${id}`}
download
className="flex items-center gap-2 bg-slate-800 hover:bg-slate-700 px-4 py-2 rounded-lg text-sm font-medium transition-colors"
>
<Download size={18} />
Image Originale
</a>
</div>
</div>
<PhotoEditor initialPhoto={photo} />
</div>
);
}

View File

@@ -0,0 +1,98 @@
"use client";
import { useState } from "react";
import { Download, Archive, AlertTriangle, Check, RefreshCw } from "lucide-react";
import { API_BASE_URL } from "@/lib/api";
import { useRouter } from "next/navigation";
export default function DatasetToolbar() {
const router = useRouter();
const [isExporting, setIsExporting] = useState(false);
const [isArchiving, setIsArchiving] = useState(false);
const [showArchiveConfirm, setShowArchiveConfirm] = useState(false);
const handleExport = async () => {
setIsExporting(true);
try {
// On ouvre simplement l'URL dans un nouvel onglet pour déclencher le téléchargement
window.open(`${API_BASE_URL}/api/export/zip`, '_blank');
} catch (error) {
console.error("Export error:", error);
} finally {
setIsExporting(false);
}
};
const handleArchive = async () => {
setIsArchiving(true);
try {
const response = await fetch(`${API_BASE_URL}/api/export/archive`, {
method: "POST"
});
if (response.ok) {
setShowArchiveConfirm(false);
router.refresh(); // Rafraîchir la galerie
} else {
alert("Erreur lors de l'archivage");
}
} catch (error) {
console.error("Archive error:", error);
alert("Erreur réseau");
} finally {
setIsArchiving(false);
}
};
return (
<div className="bg-slate-900 border border-slate-800 rounded-2xl p-6 flex flex-col md:flex-row items-center justify-between gap-6 shadow-xl shadow-indigo-500/5">
<div className="space-y-1">
<h3 className="font-bold text-lg flex items-center gap-2">
<RefreshCw size={20} className="text-indigo-400" />
Gestion du Dataset
</h3>
<p className="text-xs text-slate-500">Exportez vos sessions étiquetées en ZIP et archivez-les pour nettoyer la galerie.</p>
</div>
<div className="flex gap-3">
<button
onClick={handleExport}
disabled={isExporting}
className="flex items-center gap-2 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 px-5 py-2.5 rounded-xl text-sm font-bold transition-all hover:scale-105 active:scale-95 shadow-lg shadow-indigo-600/20"
>
<Download size={18} />
{isExporting ? "Préparation..." : "Télécharger ZIP"}
</button>
{!showArchiveConfirm ? (
<button
onClick={() => setShowArchiveConfirm(true)}
className="flex items-center gap-2 bg-slate-800 hover:bg-slate-700 px-5 py-2.5 rounded-xl text-sm font-bold transition-all border border-slate-700"
>
<Archive size={18} />
Archiver les sessions
</button>
) : (
<div className="flex items-center gap-2 animate-in fade-in slide-in-from-right-4 duration-300">
<div className="flex items-center gap-2 px-4 py-2 bg-rose-500/10 border border-rose-500/20 rounded-xl text-rose-400 text-xs font-bold">
<AlertTriangle size={14} />
Vider la galerie ?
</div>
<button
onClick={handleArchive}
disabled={isArchiving}
className="bg-rose-600 hover:bg-rose-500 p-2.5 rounded-xl transition-colors disabled:opacity-50"
>
<Check size={18} className="text-white" />
</button>
<button
onClick={() => setShowArchiveConfirm(false)}
className="bg-slate-800 hover:bg-slate-700 p-2.5 rounded-xl transition-colors"
>
<Archive size={18} className="text-slate-400" />
</button>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,226 @@
"use client";
import { useState } from "react";
import PhotoOverlay from "./PhotoOverlay";
import { Info, Calendar, User, Smartphone, Cpu, Save, Edit2, X, Trash2, Check, Tag } from "lucide-react";
import { API_BASE_URL } from "@/lib/api";
import { useRouter } from "next/navigation";
interface PhotoEditorProps {
initialPhoto: any;
}
const PREDEFINED_LABELS = ["bullet_hole", "tear", "miss", "rebound", "other"];
export default function PhotoEditor({ initialPhoto }: PhotoEditorProps) {
const router = useRouter();
const [isEditing, setIsEditing] = useState(false);
const [photoData, setPhotoData] = useState(initialPhoto.data);
const [isSaving, setIsSaving] = useState(false);
const handleImpactsChange = (newImpacts: any[]) => {
setPhotoData({
...photoData,
plotting: {
...photoData.plotting,
impacts: newImpacts
}
});
};
const updateLabel = (index: number, label: string) => {
const newImpacts = [...photoData.plotting.impacts];
newImpacts[index] = { ...newImpacts[index], label };
handleImpactsChange(newImpacts);
};
const updateScore = (index: number, score: number) => {
const newImpacts = [...photoData.plotting.impacts];
newImpacts[index] = { ...newImpacts[index], score };
handleImpactsChange(newImpacts);
};
const handleSave = async () => {
setIsSaving(true);
try {
const response = await fetch(`${API_BASE_URL}/api/data/${initialPhoto.filename}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(photoData),
});
if (response.ok) {
setIsEditing(false);
router.refresh();
} else {
alert("Erreur lors de la sauvegarde");
}
} catch (error) {
console.error("Save error:", error);
alert("Erreur réseau");
} finally {
setIsSaving(false);
}
};
const impacts = photoData?.plotting?.impacts || [];
return (
<div className="grid grid-cols-1 xl:grid-cols-3 gap-8">
{/* Main View */}
<div className="xl:col-span-2 space-y-4">
<div className="flex items-center justify-between bg-slate-900/50 p-4 rounded-xl border border-slate-800">
<div className="flex items-center gap-3">
<div className={`w-2 h-2 rounded-full ${isEditing ? 'bg-amber-500 animate-pulse' : 'bg-emerald-500'}`} />
<span className="text-sm font-medium">
{isEditing ? "Mode Édition Actif" : "Mode Visualisation"}
</span>
</div>
<div className="flex gap-2">
{isEditing ? (
<>
<button
onClick={() => {
setPhotoData(initialPhoto.data);
setIsEditing(false);
}}
className="flex items-center gap-2 bg-slate-800 hover:bg-slate-700 px-4 py-2 rounded-lg text-sm font-medium transition-colors"
>
<X size={18} />
Annuler
</button>
<button
onClick={handleSave}
disabled={isSaving}
className="flex items-center gap-2 bg-indigo-600 hover:bg-indigo-500 px-4 py-2 rounded-lg text-sm font-medium transition-colors disabled:opacity-50"
>
<Save size={18} />
{isSaving ? "Enregistrement..." : "Enregistrer"}
</button>
</>
) : (
<button
onClick={() => setIsEditing(true)}
className="flex items-center gap-2 bg-indigo-600 hover:bg-indigo-500 px-4 py-2 rounded-lg text-sm font-medium transition-colors"
>
<Edit2 size={18} />
Modifier les points
</button>
)}
</div>
</div>
<PhotoOverlay
imageUrl={`${API_BASE_URL}${initialPhoto.imageUrl}`}
impacts={impacts}
targetCorners={photoData?.plotting?.target_corners || []}
isEditing={isEditing}
onImpactsChange={handleImpactsChange}
/>
</div>
{/* Sidebar Info & Labeling */}
<div className="space-y-6">
{/* Labeling Section (Only if editing or has impacts) */}
<div className="bg-slate-900 border border-slate-800 rounded-2xl p-6 space-y-4">
<div className="flex items-center justify-between pb-4 border-b border-slate-800">
<div className="flex items-center gap-3">
<Tag className="text-indigo-400" size={20} />
<h3 className="font-bold text-lg">Étiquetage (Impacts)</h3>
</div>
<span className="bg-slate-800 text-slate-400 text-[10px] font-bold px-2 py-0.5 rounded">
{impacts.length} TOTAL
</span>
</div>
<div className="space-y-3 max-h-[400px] overflow-auto pr-2 custom-scrollbar">
{impacts.map((impact: any, index: number) => (
<div key={impact.id} className="bg-slate-950 p-3 rounded-xl border border-slate-800 space-y-3 group">
<div className="flex items-center justify-between">
<span className="text-[10px] font-bold text-slate-500 uppercase">Impact #{impact.id}</span>
{isEditing && (
<button
onClick={(e) => {
const newImpacts = impacts.filter((_: any, i: number) => i !== index);
handleImpactsChange(newImpacts);
}}
className="text-slate-600 hover:text-rose-500 transition-colors"
>
<Trash2 size={14} />
</button>
)}
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<label className="text-[10px] text-slate-500 font-bold uppercase">Label</label>
{isEditing ? (
<select
value={impact.label}
onChange={(e) => updateLabel(index, e.target.value)}
className="w-full bg-slate-900 border border-slate-800 rounded px-2 py-1 text-xs outline-none focus:border-indigo-500"
>
{PREDEFINED_LABELS.map(l => <option key={l} value={l}>{l}</option>)}
</select>
) : (
<div className="text-xs text-indigo-300 font-medium">{impact.label}</div>
)}
</div>
<div className="space-y-1">
<label className="text-[10px] text-slate-500 font-bold uppercase">Score</label>
{isEditing ? (
<input
type="number"
min="0"
max="10"
value={impact.score}
onChange={(e) => updateScore(index, parseInt(e.target.value) || 0)}
className="w-full bg-slate-900 border border-slate-800 rounded px-2 py-1 text-xs outline-none focus:border-indigo-500"
/>
) : (
<div className="text-xs text-emerald-400 font-bold">{impact.score}</div>
)}
</div>
</div>
</div>
))}
{impacts.length === 0 && (
<p className="text-center py-8 text-xs text-slate-500 italic">
Aucun impact détecté. {isEditing ? "Cliquez sur l'image pour en ajouter." : ""}
</p>
)}
</div>
</div>
{/* Metadata Card (Read Only) */}
<div className="bg-slate-900/50 border border-slate-800 rounded-2xl p-6 space-y-4 opacity-80">
<div className="flex items-center gap-3 pb-2 border-b border-slate-800">
<Info className="text-slate-400" size={18} />
<h3 className="font-bold text-sm text-slate-300">Métadonnées Session</h3>
</div>
<div className="space-y-3">
<InfoItem icon={<Calendar size={14} />} label="Date" value={photoData ? new Date(photoData.timestamp).toLocaleString() : 'Inconnue'} />
<InfoItem icon={<User size={14} />} label="Contributeur" value={photoData?.wallet_hash || 'Anonyme'} isMono />
<InfoItem icon={<Smartphone size={14} />} label="Appareil" value={photoData?.device_info?.model || 'Inconnu'} />
</div>
</div>
</div>
</div>
);
}
function InfoItem({ icon, label, value, isMono = false }: any) {
return (
<div className="flex flex-col gap-0.5">
<div className="flex items-center gap-1.5 text-[10px] text-slate-500 font-bold uppercase">
{icon}
{label}
</div>
<p className={`text-xs text-slate-300 ${isMono ? 'font-mono truncate' : ''}`}>
{value}
</p>
</div>
);
}

View File

@@ -0,0 +1,183 @@
"use client";
import { useEffect, useRef, useState } from "react";
interface Point {
norm_x: number;
norm_y: number;
}
interface Impact {
id: number;
label: string;
score: number;
coords: Point;
}
interface PhotoOverlayProps {
imageUrl: string;
impacts: Impact[];
targetCorners: Point[];
isEditing?: boolean;
onImpactsChange?: (impacts: Impact[]) => void;
}
export default function PhotoOverlay({
imageUrl,
impacts,
targetCorners,
isEditing = false,
onImpactsChange
}: PhotoOverlayProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
const [dragIndex, setDragIndex] = useState<number | null>(null);
useEffect(() => {
const updateDimensions = () => {
if (containerRef.current) {
setDimensions({
width: containerRef.current.offsetWidth,
height: containerRef.current.offsetHeight,
});
}
};
updateDimensions();
window.addEventListener("resize", updateDimensions);
return () => window.removeEventListener("resize", updateDimensions);
}, []);
const handleImageClick = (e: React.MouseEvent) => {
if (!isEditing || !onImpactsChange || dragIndex !== null) return;
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return;
// Calculer les coordonnées normalisées
const x = (e.clientX - rect.left) / dimensions.width;
const y = (e.clientY - rect.top) / dimensions.height;
// Créer un nouvel impact
const newImpact: Impact = {
id: impacts.length > 0 ? Math.max(...impacts.map(i => i.id)) + 1 : 1,
label: "bullet_hole",
score: 10,
coords: { norm_x: x, norm_y: y }
};
onImpactsChange([...impacts, newImpact]);
};
const handleMouseMove = (e: React.MouseEvent) => {
if (dragIndex === null || !onImpactsChange) return;
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return;
const x = (e.clientX - rect.left) / dimensions.width;
const y = (e.clientY - rect.top) / dimensions.height;
const newImpacts = [...impacts];
newImpacts[dragIndex] = {
...newImpacts[dragIndex],
coords: { norm_x: x, norm_y: y }
};
onImpactsChange(newImpacts);
};
const deleteImpact = (e: React.MouseEvent, index: number) => {
e.stopPropagation();
if (!isEditing || !onImpactsChange) return;
const newImpacts = impacts.filter((_, i) => i !== index);
onImpactsChange(newImpacts);
};
return (
<div
ref={containerRef}
className={`relative w-full flex items-center justify-center bg-slate-900 rounded-2xl overflow-hidden border border-slate-800 shadow-2xl min-h-[400px] ${isEditing ? 'cursor-crosshair' : ''}`}
onClick={handleImageClick}
onMouseMove={handleMouseMove}
onMouseUp={() => setDragIndex(null)}
onMouseLeave={() => setDragIndex(null)}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={imageUrl}
alt="Target Analysis"
className="max-w-full max-h-[80vh] object-contain select-none"
draggable={false}
onLoad={(e) => {
const img = e.currentTarget;
setDimensions({ width: img.clientWidth, height: img.clientHeight });
}}
/>
{dimensions.width > 0 && (
<svg
className="absolute pointer-events-none"
viewBox={`0 0 ${dimensions.width} ${dimensions.height}`}
style={{ width: dimensions.width, height: dimensions.height }}
>
{/* Draw Target Box if corners exist */}
{targetCorners && targetCorners.length === 4 && (
<polygon
points={targetCorners.map(c => `${c.norm_x * dimensions.width},${c.norm_y * dimensions.height}`).join(' ')}
fill="rgba(79, 70, 229, 0.15)"
stroke="#6366f1"
strokeWidth="2"
strokeDasharray="4 2"
/>
)}
{/* Draw Impacts */}
{impacts.map((impact, index) => (
<g key={impact.id}>
{/* Glow effect */}
<circle
cx={impact.coords.norm_x * dimensions.width}
cy={impact.coords.norm_y * dimensions.height}
r={isEditing ? "12" : "8"}
fill={isEditing ? "rgba(244, 63, 94, 0.2)" : "rgba(244, 63, 94, 0.3)"}
className={isEditing ? 'pointer-events-auto cursor-move' : ''}
onMouseDown={(e) => {
if (!isEditing) return;
e.stopPropagation();
setDragIndex(index);
}}
onContextMenu={(e) => deleteImpact(e, index)}
/>
{/* Main circle */}
<circle
cx={impact.coords.norm_x * dimensions.width}
cy={impact.coords.norm_y * dimensions.height}
r="5"
fill="#f43f5e"
stroke="white"
strokeWidth="1.5"
/>
{/* Score text */}
<text
x={impact.coords.norm_x * dimensions.width}
y={impact.coords.norm_y * dimensions.height - 10}
className="text-[12px] font-bold fill-white text-center select-none"
textAnchor="middle"
style={{ textShadow: '0 0 4px rgba(0,0,0,0.9), 1px 1px 0 rgba(0,0,0,0.9)' }}
>
{impact.score}
</text>
</g>
))}
</svg>
)}
{isEditing && (
<div className="absolute bottom-4 right-4 bg-slate-950/80 backdrop-blur-md px-3 py-1.5 rounded-full text-[10px] font-bold text-indigo-400 border border-indigo-500/30">
MODE ÉDITION : CLIC POUR AJOUTER DRAG POUR DÉPLACER CLIC DROIT POUR SUPPRIMER
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,11 @@
export const API_BASE_URL = 'http://127.0.0.1:3000';
export async function fetchApi(endpoint: string) {
const res = await fetch(`${API_BASE_URL}${endpoint}`, {
cache: 'no-store',
});
if (!res.ok) {
throw new Error(`Failed to fetch ${endpoint}`);
}
return res.json();
}

View File

@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}

View File

@@ -0,0 +1 @@
# Keep directory structure

View File

@@ -0,0 +1,116 @@
{
"session_id": "session_1777452202935",
"timestamp": "2026-04-29T08:43:22.943303",
"device_info": {
"model": "google sdk_gphone64_x86_64",
"os": "Android 16"
},
"target_metadata": {
"type": "concentric",
"distance_meters": 25,
"weapon": "Unknown"
},
"plotting": {
"target_corners": [
{
"norm_x": 0.09999999999999998,
"norm_y": 0.09999999999999998
},
{
"norm_x": 0.9,
"norm_y": 0.09999999999999998
},
{
"norm_x": 0.9,
"norm_y": 0.9
},
{
"norm_x": 0.09999999999999998,
"norm_y": 0.9
}
],
"impacts": [
{
"id": 1,
"label": "bullet_hole",
"score": 6,
"coords": {
"norm_x": 0.3934941044560185,
"norm_y": 0.665676540798611
}
},
{
"id": 2,
"label": "bullet_hole",
"score": 5,
"coords": {
"norm_x": 0.6055302372685185,
"norm_y": 0.6953124999999999
}
},
{
"id": 3,
"label": "bullet_hole",
"score": 8,
"coords": {
"norm_x": 0.5981137876157407,
"norm_y": 0.5249565972222221
}
},
{
"id": 4,
"label": "bullet_hole",
"score": 6,
"coords": {
"norm_x": 0.6759033203125,
"norm_y": 0.4786376953125
}
},
{
"id": 5,
"label": "bullet_hole",
"score": 9,
"coords": {
"norm_x": 0.48608398437499994,
"norm_y": 0.5490315755208333
}
},
{
"id": 6,
"label": "bullet_hole",
"score": 9,
"coords": {
"norm_x": 0.5101625795717593,
"norm_y": 0.43238661024305547
}
},
{
"id": 7,
"label": "bullet_hole",
"score": 7,
"coords": {
"norm_x": 0.6296079282407407,
"norm_y": 0.4203152126736111
}
},
{
"id": 8,
"label": "bullet_hole",
"score": 9,
"coords": {
"norm_x": 0.46386718749999994,
"norm_y": 0.48046875
}
},
{
"id": 9,
"label": "bullet_hole",
"score": 6,
"coords": {
"norm_x": 0.3379521122685185,
"norm_y": 0.4397786458333333
}
}
]
}
}

View File

@@ -0,0 +1,116 @@
{
"session_id": "session_1777452481252",
"timestamp": "2026-04-29T08:48:01.259705",
"device_info": {
"model": "google sdk_gphone64_x86_64",
"os": "Android 16"
},
"target_metadata": {
"type": "concentric",
"distance_meters": 25,
"weapon": "Unknown"
},
"plotting": {
"target_corners": [
{
"norm_x": 0.09999999999999998,
"norm_y": 0.09999999999999998
},
{
"norm_x": 0.9,
"norm_y": 0.09999999999999998
},
{
"norm_x": 0.9,
"norm_y": 0.9
},
{
"norm_x": 0.09999999999999998,
"norm_y": 0.9
}
],
"impacts": [
{
"id": 1,
"label": "bullet_hole",
"score": 6,
"coords": {
"norm_x": 0.3934941044560185,
"norm_y": 0.665676540798611
}
},
{
"id": 2,
"label": "bullet_hole",
"score": 5,
"coords": {
"norm_x": 0.6055302372685185,
"norm_y": 0.6953124999999999
}
},
{
"id": 3,
"label": "bullet_hole",
"score": 8,
"coords": {
"norm_x": 0.5981137876157407,
"norm_y": 0.5249565972222221
}
},
{
"id": 4,
"label": "bullet_hole",
"score": 6,
"coords": {
"norm_x": 0.6759033203125,
"norm_y": 0.4786376953125
}
},
{
"id": 5,
"label": "bullet_hole",
"score": 9,
"coords": {
"norm_x": 0.48608398437499994,
"norm_y": 0.5490315755208333
}
},
{
"id": 6,
"label": "bullet_hole",
"score": 9,
"coords": {
"norm_x": 0.5101625795717593,
"norm_y": 0.43238661024305547
}
},
{
"id": 7,
"label": "bullet_hole",
"score": 7,
"coords": {
"norm_x": 0.6296079282407407,
"norm_y": 0.4203152126736111
}
},
{
"id": 8,
"label": "bullet_hole",
"score": 9,
"coords": {
"norm_x": 0.46386718749999994,
"norm_y": 0.48046875
}
},
{
"id": 9,
"label": "bullet_hole",
"score": 6,
"coords": {
"norm_x": 0.3379521122685185,
"norm_y": 0.4397786458333333
}
}
]
}
}

View File

@@ -0,0 +1,44 @@
{
"session_id": "session_1777452613625",
"timestamp": "2026-04-29T08:50:13.631206",
"device_info": {
"model": "google sdk_gphone64_x86_64",
"os": "Android 16"
},
"target_metadata": {
"type": "concentric",
"distance_meters": 25,
"weapon": "Unknown"
},
"plotting": {
"target_corners": [
{
"norm_x": 0.09999999999999998,
"norm_y": 0.09999999999999998
},
{
"norm_x": 0.9,
"norm_y": 0.09999999999999998
},
{
"norm_x": 0.9,
"norm_y": 0.9
},
{
"norm_x": 0.09999999999999998,
"norm_y": 0.9
}
],
"impacts": [
{
"id": 1,
"label": "bullet_hole",
"score": 10,
"coords": {
"norm_x": 0.49810836226851846,
"norm_y": 0.4981011284722222
}
}
]
}
}

View File

@@ -0,0 +1,144 @@
{
"session_id": "session_1777463308809",
"wallet_hash": "cc60fcaa5ef7fdc9be3dd1adba4157fa6dfae2845da45b830b5ef2b7f473d887",
"timestamp": "2026-04-29T11:48:28.834236",
"device_info": {
"model": "google sdk_gphone64_x86_64",
"os": "Android 16"
},
"target_metadata": {
"type": "concentric",
"distance_meters": 25,
"weapon": "Unknown"
},
"plotting": {
"target_corners": [
{
"norm_x": 0.09999999999999998,
"norm_y": 0.09999999999999998
},
{
"norm_x": 0.9,
"norm_y": 0.09999999999999998
},
{
"norm_x": 0.9,
"norm_y": 0.9
},
{
"norm_x": 0.09999999999999998,
"norm_y": 0.9
}
],
"impacts": [
{
"id": 1,
"label": "bullet_hole",
"score": 6,
"coords": {
"norm_x": 0.38424660011574074,
"norm_y": 0.3666042751736111
}
},
{
"id": 2,
"label": "bullet_hole",
"score": 7,
"coords": {
"norm_x": 0.6055302372685185,
"norm_y": 0.396240234375
}
},
{
"id": 3,
"label": "bullet_hole",
"score": 7,
"coords": {
"norm_x": 0.5860903139467593,
"norm_y": 0.5999620225694443
}
},
{
"id": 4,
"label": "bullet_hole",
"score": 6,
"coords": {
"norm_x": 0.33517433449074074,
"norm_y": 0.5592041015625
}
},
{
"id": 5,
"label": "bullet_hole",
"score": 8,
"coords": {
"norm_x": 0.6073612919560185,
"norm_y": 0.5101047092013888
}
},
{
"id": 6,
"label": "bullet_hole",
"score": 9,
"coords": {
"norm_x": 0.432373046875,
"norm_y": 0.46663411458333337
}
},
{
"id": 7,
"label": "bullet_hole",
"score": 9,
"coords": {
"norm_x": 0.45181297019675926,
"norm_y": 0.5564236111111112
}
},
{
"id": 8,
"label": "bullet_hole",
"score": 10,
"coords": {
"norm_x": 0.5221860532407407,
"norm_y": 0.48792860243055547
}
},
{
"id": 9,
"label": "bullet_hole",
"score": 3,
"coords": {
"norm_x": 0.6564335575810184,
"norm_y": 0.7536349826388888
}
},
{
"id": 10,
"label": "bullet_hole",
"score": 5,
"coords": {
"norm_x": 0.4712528935185185,
"norm_y": 0.7018229166666666
}
},
{
"id": 11,
"label": "bullet_hole",
"score": 5,
"coords": {
"norm_x": 0.3981327763310185,
"norm_y": 0.7018229166666666
}
},
{
"id": 12,
"label": "bullet_hole",
"score": 2,
"coords": {
"norm_x": 0.48144531249999994,
"norm_y": 0.8212483723958334
}
}
]
}
}

View File

@@ -0,0 +1,117 @@
{
"session_id": "session_1777463703756",
"wallet_hash": "cc60fcaa5ef7fdc9be3dd1adba4157fa6dfae2845da45b830b5ef2b7f473d887",
"timestamp": "2026-04-29T11:55:03.765285",
"device_info": {
"model": "google sdk_gphone64_x86_64",
"os": "Android 16"
},
"target_metadata": {
"type": "concentric",
"distance_meters": 25,
"weapon": "Unknown"
},
"plotting": {
"target_corners": [
{
"norm_x": 0.09999999999999998,
"norm_y": 0.09999999999999998
},
{
"norm_x": 0.9,
"norm_y": 0.09999999999999998
},
{
"norm_x": 0.9,
"norm_y": 0.9
},
{
"norm_x": 0.09999999999999998,
"norm_y": 0.9
}
],
"impacts": [
{
"id": 1,
"label": "bullet_hole",
"score": 7,
"coords": {
"norm_x": 0.38424660011574074,
"norm_y": 0.45178222656249994
}
},
{
"id": 2,
"label": "bullet_hole",
"score": 5,
"coords": {
"norm_x": 0.6518247251157407,
"norm_y": 0.3638237847222222
}
},
{
"id": 3,
"label": "bullet_hole",
"score": 6,
"coords": {
"norm_x": 0.5490415219907407,
"norm_y": 0.66845703125
}
},
{
"id": 4,
"label": "bullet_hole",
"score": 6,
"coords": {
"norm_x": 0.405517578125,
"norm_y": 0.6758490668402777
}
},
{
"id": 5,
"label": "bullet_hole",
"score": 3,
"coords": {
"norm_x": 0.7268066406249999,
"norm_y": 0.6758490668402777
}
},
{
"id": 6,
"label": "bullet_hole",
"score": 9,
"coords": {
"norm_x": 0.5471806278935185,
"norm_y": 0.529568142361111
}
},
{
"id": 7,
"label": "bullet_hole",
"score": 8,
"coords": {
"norm_x": 0.44720504195601846,
"norm_y": 0.5980631510416666
}
},
{
"id": 8,
"label": "bullet_hole",
"score": 8,
"coords": {
"norm_x": 0.45458984375,
"norm_y": 0.4221462673611111
}
},
{
"id": 9,
"label": "bullet_hole",
"score": 7,
"coords": {
"norm_x": 0.5203251591435185,
"norm_y": 0.3638237847222222
}
}
]
}
}

View File

@@ -0,0 +1,117 @@
{
"session_id": "session_1777464156234",
"wallet_hash": "cc60fcaa5ef7fdc9be3dd1adba4157fa6dfae2845da45b830b5ef2b7f473d887",
"timestamp": "2026-04-29T12:02:36.255849",
"device_info": {
"model": "google sdk_gphone64_x86_64",
"os": "Android 16"
},
"target_metadata": {
"type": "concentric",
"distance_meters": 25,
"weapon": "Unknown"
},
"plotting": {
"target_corners": [
{
"norm_x": 0.09999999999999998,
"norm_y": 0.09999999999999998
},
{
"norm_x": 0.9,
"norm_y": 0.09999999999999998
},
{
"norm_x": 0.9,
"norm_y": 0.9
},
{
"norm_x": 0.09999999999999998,
"norm_y": 0.9
}
],
"impacts": [
{
"id": 1,
"label": "bullet_hole",
"score": 7,
"coords": {
"norm_x": 0.38424660011574074,
"norm_y": 0.45178222656249994
}
},
{
"id": 2,
"label": "bullet_hole",
"score": 5,
"coords": {
"norm_x": 0.6518247251157407,
"norm_y": 0.3638237847222222
}
},
{
"id": 3,
"label": "bullet_hole",
"score": 6,
"coords": {
"norm_x": 0.5490415219907407,
"norm_y": 0.66845703125
}
},
{
"id": 4,
"label": "bullet_hole",
"score": 6,
"coords": {
"norm_x": 0.405517578125,
"norm_y": 0.6758490668402777
}
},
{
"id": 5,
"label": "bullet_hole",
"score": 3,
"coords": {
"norm_x": 0.7268066406249999,
"norm_y": 0.6758490668402777
}
},
{
"id": 6,
"label": "bullet_hole",
"score": 9,
"coords": {
"norm_x": 0.5471806278935185,
"norm_y": 0.529568142361111
}
},
{
"id": 7,
"label": "bullet_hole",
"score": 8,
"coords": {
"norm_x": 0.44720504195601846,
"norm_y": 0.5980631510416666
}
},
{
"id": 8,
"label": "bullet_hole",
"score": 8,
"coords": {
"norm_x": 0.45458984375,
"norm_y": 0.4221462673611111
}
},
{
"id": 9,
"label": "bullet_hole",
"score": 7,
"coords": {
"norm_x": 0.5203251591435185,
"norm_y": 0.3638237847222222
}
}
]
}
}

View File

@@ -0,0 +1,180 @@
{
"session_id": "session_1777464607256",
"wallet_hash": "cc60fcaa5ef7fdc9be3dd1adba4157fa6dfae2845da45b830b5ef2b7f473d887",
"timestamp": "2026-04-29T12:10:07.260954",
"device_info": {
"model": "google sdk_gphone64_x86_64",
"os": "Android 16"
},
"target_metadata": {
"type": "concentric",
"distance_meters": 25,
"weapon": "Unknown"
},
"plotting": {
"target_corners": [
{
"norm_x": 0.09999999999999998,
"norm_y": 0.09999999999999998
},
{
"norm_x": 0.9,
"norm_y": 0.09999999999999998
},
{
"norm_x": 0.9,
"norm_y": 0.9
},
{
"norm_x": 0.09999999999999998,
"norm_y": 0.9
}
],
"impacts": [
{
"id": 1,
"label": "bullet_hole",
"score": 3,
"coords": {
"norm_x": 0.31570457175925926,
"norm_y": 0.7388509114583333
}
},
{
"id": 2,
"label": "bullet_hole",
"score": 5,
"coords": {
"norm_x": 0.3184814453125,
"norm_y": 0.6489935980902778
}
},
{
"id": 3,
"label": "bullet_hole",
"score": 6,
"coords": {
"norm_x": 0.3110966435185185,
"norm_y": 0.529568142361111
}
},
{
"id": 4,
"label": "bullet_hole",
"score": 6,
"coords": {
"norm_x": 0.31570457175925926,
"norm_y": 0.4416097005208333
}
},
{
"id": 5,
"label": "bullet_hole",
"score": 4,
"coords": {
"norm_x": 0.31570457175925926,
"norm_y": 0.34252929687499994
}
},
{
"id": 6,
"label": "bullet_hole",
"score": 3,
"coords": {
"norm_x": 0.32589699074074074,
"norm_y": 0.2332763671875
}
},
{
"id": 7,
"label": "bullet_hole",
"score": 3,
"coords": {
"norm_x": 0.45458984375,
"norm_y": 0.19624837239583331
}
},
{
"id": 8,
"label": "bullet_hole",
"score": 3,
"coords": {
"norm_x": 0.5981137876157407,
"norm_y": 0.23049587673611108
}
},
{
"id": 9,
"label": "bullet_hole",
"score": 5,
"coords": {
"norm_x": 0.6907045717592593,
"norm_y": 0.3546006944444444
}
},
{
"id": 10,
"label": "bullet_hole",
"score": 5,
"coords": {
"norm_x": 0.699951171875,
"norm_y": 0.48046875
}
},
{
"id": 11,
"label": "bullet_hole",
"score": 7,
"coords": {
"norm_x": 0.6268310546874999,
"norm_y": 0.5416395399305556
}
},
{
"id": 12,
"label": "bullet_hole",
"score": 9,
"coords": {
"norm_x": 0.49072265624999994,
"norm_y": 0.5610351562499999
}
},
{
"id": 13,
"label": "bullet_hole",
"score": 8,
"coords": {
"norm_x": 0.4203495732060185,
"norm_y": 0.536960177951389
}
},
{
"id": 14,
"label": "bullet_hole",
"score": 6,
"coords": {
"norm_x": 0.5370180483217593,
"norm_y": 0.6879204644097222
}
},
{
"id": 15,
"label": "bullet_hole",
"score": 3,
"coords": {
"norm_x": 0.6175537109375,
"norm_y": 0.7601453993055556
}
},
{
"id": 16,
"label": "bullet_hole",
"score": 1,
"coords": {
"norm_x": 0.7027280454282406,
"norm_y": 0.8406439887152777
}
}
]
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

View File

@@ -0,0 +1 @@
# Keep directory structure

View File

@@ -0,0 +1,270 @@
{
"session_id": "session_1777553535029",
"wallet_hash": "cc60fcaa5ef7fdc9be3dd1adba4157fa6dfae2845da45b830b5ef2b7f473d887",
"timestamp": "2026-04-30T12:52:15.048830",
"device_info": {
"model": "google sdk_gphone64_x86_64",
"os": "Android 16"
},
"target_metadata": {
"type": "concentric",
"distance_meters": 25,
"weapon": "Unknown"
},
"plotting": {
"target_corners": [
{
"norm_x": 0.09999999999999998,
"norm_y": 0.09999999999999998
},
{
"norm_x": 0.9,
"norm_y": 0.09999999999999998
},
{
"norm_x": 0.9,
"norm_y": 0.9
},
{
"norm_x": 0.09999999999999998,
"norm_y": 0.9
}
],
"impacts": [
{
"id": 1,
"label": "bullet_hole",
"score": 0,
"coords": {
"norm_x": 0.17681929976851848,
"norm_y": 0.8851318359375
}
},
{
"id": 2,
"label": "bullet_hole",
"score": 0,
"coords": {
"norm_x": 0.17681929976851848,
"norm_y": 0.7721489800347222
}
},
{
"id": 3,
"label": "bullet_hole",
"score": 1,
"coords": {
"norm_x": 0.17959617332175923,
"norm_y": 0.6888020833333334
}
},
{
"id": 4,
"label": "bullet_hole",
"score": 2,
"coords": {
"norm_x": 0.17959617332175923,
"norm_y": 0.6342095269097222
}
},
{
"id": 5,
"label": "bullet_hole",
"score": 2,
"coords": {
"norm_x": 0.17959617332175923,
"norm_y": 0.560153537326389
}
},
{
"id": 6,
"label": "bullet_hole",
"score": 2,
"coords": {
"norm_x": 0.17959617332175923,
"norm_y": 0.5138346354166666
}
},
{
"id": 7,
"label": "bullet_hole",
"score": 2,
"coords": {
"norm_x": 0.1814579716435185,
"norm_y": 0.45273166232638884
}
},
{
"id": 8,
"label": "bullet_hole",
"score": 2,
"coords": {
"norm_x": 0.18701171875,
"norm_y": 0.3878987630208333
}
},
{
"id": 9,
"label": "bullet_hole",
"score": 1,
"coords": {
"norm_x": 0.18887351707175926,
"norm_y": 0.2925482855902778
}
},
{
"id": 10,
"label": "bullet_hole",
"score": 0,
"coords": {
"norm_x": 0.2036747685185185,
"norm_y": 0.20085991753472218
}
},
{
"id": 11,
"label": "bullet_hole",
"score": 0,
"coords": {
"norm_x": 0.27035499855324074,
"norm_y": 0.16200086805555558
}
},
{
"id": 12,
"label": "bullet_hole",
"score": 1,
"coords": {
"norm_x": 0.37588523582175926,
"norm_y": 0.1546088324652778
}
},
{
"id": 13,
"label": "bullet_hole",
"score": 2,
"coords": {
"norm_x": 0.46292136863425926,
"norm_y": 0.1518283420138889
}
},
{
"id": 14,
"label": "bullet_hole",
"score": 1,
"coords": {
"norm_x": 0.6814579716435185,
"norm_y": 0.1499294704861111
}
},
{
"id": 15,
"label": "bullet_hole",
"score": 2,
"coords": {
"norm_x": 0.5509033203125,
"norm_y": 0.1546088324652778
}
},
{
"id": 16,
"label": "bullet_hole",
"score": 1,
"coords": {
"norm_x": 0.6156919126157407,
"norm_y": 0.1546088324652778
}
},
{
"id": 17,
"label": "bullet_hole",
"score": 1,
"coords": {
"norm_x": 0.7703251591435185,
"norm_y": 0.2360568576388889
}
},
{
"id": 18,
"label": "bullet_hole",
"score": 4,
"coords": {
"norm_x": 0.7110595703125,
"norm_y": 0.3536512586805555
}
},
{
"id": 19,
"label": "bullet_hole",
"score": 7,
"coords": {
"norm_x": 0.6027524594907407,
"norm_y": 0.4129231770833333
}
},
{
"id": 20,
"label": "bullet_hole",
"score": 9,
"coords": {
"norm_x": 0.4823911313657407,
"norm_y": 0.4304877387152777
}
},
{
"id": 21,
"label": "bullet_hole",
"score": 7,
"coords": {
"norm_x": 0.39627097800925926,
"norm_y": 0.43509928385416663
}
},
{
"id": 22,
"label": "bullet_hole",
"score": 5,
"coords": {
"norm_x": 0.289794921875,
"norm_y": 0.43238661024305547
}
},
{
"id": 23,
"label": "bullet_hole",
"score": 2,
"coords": {
"norm_x": 0.7166440610532407,
"norm_y": 0.7693684895833333
}
},
{
"id": 24,
"label": "bullet_hole",
"score": 3,
"coords": {
"norm_x": 0.7314453125,
"norm_y": 0.7008734809027778
}
},
{
"id": 25,
"label": "bullet_hole",
"score": 1,
"coords": {
"norm_x": 0.7879331235532407,
"norm_y": 0.7601453993055556
}
},
{
"id": 26,
"label": "bullet_hole",
"score": 0,
"coords": {
"norm_x": 0.736083984375,
"norm_y": 0.828640407986111
}
}
]
}
}

View File

@@ -0,0 +1 @@
# Keep directory structure

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

1682
backendia/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

22
backendia/package.json Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "backendia",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"start": "node server.js",
"dashboard": "cd dashboard && npm run dev -- -p 3001",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"adm-zip": "^0.5.17",
"cors": "^2.8.6",
"dotenv": "^17.4.2",
"express": "^5.2.1",
"multer": "^2.1.1",
"sqlite3": "^6.0.1"
}
}

346
backendia/server.js Normal file
View File

@@ -0,0 +1,346 @@
const express = require('express');
const multer = require('multer');
const cors = require('cors');
const path = require('path');
const fs = require('fs');
const sqlite3 = require('sqlite3').verbose();
const AdmZip = require('adm-zip');
const app = express();
const PORT = process.env.PORT || 3000;
// Middleware
app.use(cors());
app.use(express.json());
// Arborescence demandée
const uploadDir = path.join(__dirname, 'uploads');
const imagesDir = path.join(uploadDir, 'images');
const dataDir = path.join(uploadDir, 'data');
const exportsDir = path.join(__dirname, 'exports');
// S'assurer que les dossiers existent
[uploadDir, imagesDir, dataDir, exportsDir].forEach(dir => {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
});
// Initialisation de la base de données SQLite
const dbPath = path.join(dataDir, 'database.sqlite');
const db = new sqlite3.Database(dbPath, (err) => {
if (err) {
console.error('Erreur de connexion à SQLite:', err.message);
} else {
console.log('Connecté à la base de données SQLite.');
db.run(`CREATE TABLE IF NOT EXISTS user_stats (
wallet_hash TEXT PRIMARY KEY,
photo_count INTEGER DEFAULT 0,
last_upload DATETIME DEFAULT CURRENT_TIMESTAMP
)`);
}
});
// Configuration de multer pour le stockage des fichiers
const storage = multer.diskStorage({
destination: function (req, file, cb) {
cb(null, imagesDir); // Les photos vont dans uploads/images/
},
filename: function (req, file, cb) {
// Créer un nom de fichier unique tout en gardant l'extension originale
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
const ext = path.extname(file.originalname);
cb(null, file.fieldname + '-' + uniqueSuffix + ext);
}
});
const upload = multer({
storage: storage,
limits: {
fileSize: 15 * 1024 * 1024 // Limite de taille: 15 MB
},
fileFilter: (req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase();
const allowedExts = ['.jpg', '.jpeg', '.png', '.gif', '.webp'];
// Accepter si c'est un mimetype image OU si l'extension est celle d'une image
if (file.mimetype.startsWith('image/') || allowedExts.includes(ext) || file.mimetype === 'application/octet-stream') {
cb(null, true);
} else {
console.error(`Fichier rejeté: mimetype=${file.mimetype}, extension=${ext}`);
cb(new Error('Seules les images sont autorisées !'), false);
}
}
});
// Routes de base
app.get('/api/health', (req, res) => {
res.json({ status: 'ok', message: 'Le serveur backend IA fonctionne' });
});
// Route pour l'upload de photo + données JSON
// Attend un form-data avec un champ nommé 'photo' et un champ texte 'plotting'
app.post('/api/upload', upload.single('photo'), (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ error: 'Aucune photo fournie' });
}
let plottingData = {};
if (req.body.plotting) {
try {
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' });
}
}
// Nom de base sans l'extension
const baseFilename = path.parse(req.file.filename).name;
const jsonFilename = `${baseFilename}.json`;
const jsonFilePath = path.join(dataDir, jsonFilename);
// Sauvegarde du JSON dans uploads/data/
fs.writeFileSync(jsonFilePath, JSON.stringify(plottingData, null, 2));
// 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)
VALUES (?, 1, CURRENT_TIMESTAMP)
ON CONFLICT(wallet_hash) DO UPDATE SET
photo_count = photo_count + 1,
last_upload = CURRENT_TIMESTAMP
`, [walletHash], function(err) {
if (err) {
console.error("Erreur lors de la mise à jour des stats :", err);
} else {
console.log(`Stats mises à jour pour le wallet_hash ${walletHash.substring(0, 8)}...`);
}
});
}
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({
message: 'Photo et données uploadées avec succès',
file: {
filename: req.file.filename,
url: `/uploads/images/${req.file.filename}`,
size: req.file.size
},
data: {
filename: jsonFilename
}
});
} catch (error) {
console.error('Erreur lors de l\'upload:', error);
res.status(500).json({ error: 'Erreur interne du serveur lors de l\'upload' });
}
});
// Route pour récupérer toutes les photos disponibles
app.get('/api/photos', (req, res) => {
try {
fs.readdir(imagesDir, (err, files) => {
if (err) {
return res.status(500).json({ error: 'Impossible de lire le dossier des photos' });
}
const photos = files.map(file => ({
filename: file,
url: `/uploads/images/${file}`
}));
res.status(200).json({ photos });
});
} catch (error) {
res.status(500).json({ error: 'Erreur serveur' });
}
});
// Route pour récupérer les statistiques d'un wallet_hash
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) => {
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 } });
}
});
});
// Route pour récupérer TOUS les contributeurs
app.get('/api/contributors', (req, res) => {
db.all('SELECT * FROM user_stats ORDER BY photo_count DESC', [], (err, rows) => {
if (err) {
return res.status(500).json({ error: 'Erreur lors de la lecture des contributeurs' });
}
res.json({ contributors: rows });
});
});
// Route pour récupérer toutes les données (Photos + JSON)
app.get('/api/all-data', (req, res) => {
try {
fs.readdir(imagesDir, (err, files) => {
if (err) {
return res.status(500).json({ error: 'Impossible de lire le dossier des photos' });
}
const allData = files.map(file => {
const baseName = path.parse(file).name;
const jsonPath = path.join(dataDir, `${baseName}.json`);
let plotting = null;
if (fs.existsSync(jsonPath)) {
try {
plotting = JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
} catch (e) {
console.error(`Erreur lecture JSON pour ${file}`);
}
}
return {
filename: file,
imageUrl: `/uploads/images/${file}`,
data: plotting
};
});
res.status(200).json({ data: allData });
});
} catch (error) {
res.status(500).json({ error: 'Erreur serveur' });
}
});
// Route pour mettre à jour les données JSON d'une session
app.put('/api/data/:filename', (req, res) => {
const filename = req.params.filename;
const baseName = path.parse(filename).name;
const jsonPath = path.join(dataDir, `${baseName}.json`);
try {
if (!fs.existsSync(jsonPath)) {
return res.status(404).json({ error: 'Fichier JSON non trouvé' });
}
// On écrase le fichier avec les nouvelles données reçues
const newData = req.body;
fs.writeFileSync(jsonPath, JSON.stringify(newData, null, 2));
console.log(`Données mises à jour pour : ${baseName}.json`);
res.status(200).json({ message: 'Données mises à jour avec succès' });
} catch (error) {
console.error('Erreur lors de la mise à jour du JSON:', error);
res.status(500).json({ error: 'Erreur serveur lors de la mise à jour' });
}
});
// Route pour exporter toutes les données en ZIP
app.get('/api/export/zip', (req, res) => {
try {
const zip = new AdmZip();
// Ajouter les dossiers au ZIP
if (fs.existsSync(imagesDir)) zip.addLocalFolder(imagesDir, 'images');
if (fs.existsSync(dataDir)) zip.addLocalFolder(dataDir, 'data');
const zipFilename = `dataset_export_${Date.now()}.zip`;
const zipPath = path.join(exportsDir, zipFilename);
// Sauvegarder sur le disque
zip.writeZip(zipPath);
// Envoyer le fichier au client
res.download(zipPath, zipFilename, (err) => {
if (err) console.error("Erreur lors de l'envoi du ZIP:", err);
// Optionnel: supprimer le ZIP local après envoi si vous voulez gagner de la place
// fs.unlinkSync(zipPath);
});
} catch (error) {
console.error('Erreur lors de la création du ZIP:', error);
res.status(500).json({ error: 'Erreur lors de la création de l\'archive' });
}
});
// Route pour archiver et vider les dossiers uploads
app.post('/api/export/archive', (req, res) => {
try {
const archiveName = `archive_${new Date().toISOString().replace(/[:.]/g, '-')}`;
const archivePath = path.join(exportsDir, archiveName);
if (!fs.existsSync(archivePath)) {
fs.mkdirSync(archivePath, { recursive: true });
}
// Fonction pour déplacer les fichiers
const moveFiles = (source, destSubDir) => {
const dest = path.join(archivePath, destSubDir);
if (!fs.existsSync(dest)) fs.mkdirSync(dest, { recursive: true });
const files = fs.readdirSync(source);
files.forEach(file => {
// Ne pas déplacer la base de données sqlite
if (file === 'database.sqlite') return;
const oldPath = path.join(source, file);
const newPath = path.join(dest, file);
try {
fs.renameSync(oldPath, newPath);
} catch (e) {
// Fallback si renameSync échoue (ex: disque différent)
fs.copyFileSync(oldPath, newPath);
fs.unlinkSync(oldPath);
}
});
};
moveFiles(imagesDir, 'images');
moveFiles(dataDir, 'data');
res.json({ message: 'Données archivées avec succès', archive: archiveName });
} catch (error) {
console.error('Erreur lors de l\'archivage:', error);
res.status(500).json({ error: 'Erreur lors de l\'archivage des données' });
}
});
// Servir les fichiers statiques (pour pouvoir accéder aux photos via URL)
app.use('/uploads/images', express.static(imagesDir));
// Gestion des erreurs
app.use((err, req, res, next) => {
if (err instanceof multer.MulterError) {
if (err.code === 'LIMIT_FILE_SIZE') {
return res.status(400).json({ error: 'Le fichier est trop volumineux (limite 15MB)' });
}
}
console.error(err.stack);
res.status(500).json({ error: err.message || 'Une erreur inattendue est survenue' });
});
// Démarrer le serveur
app.listen(PORT, () => {
console.log(`=================================`);
console.log(`Serveur Backend IA démarré`);
console.log(`Port: ${PORT}`);
console.log(`Dossiers:`);
console.log(` - Images: ${imagesDir}`);
console.log(` - Data : ${dataDir}`);
console.log(` - Export: ${exportsDir}`);
console.log(`=================================`);
});

Binary file not shown.

View File

@@ -0,0 +1,90 @@
{
"session_id": "session_1777554006515",
"wallet_hash": "cc60fcaa5ef7fdc9be3dd1adba4157fa6dfae2845da45b830b5ef2b7f473d887",
"timestamp": "2026-04-30T13:00:06.519693",
"device_info": {
"model": "google sdk_gphone64_x86_64",
"os": "Android 16"
},
"target_metadata": {
"type": "concentric",
"distance_meters": 25,
"weapon": "Unknown"
},
"plotting": {
"target_corners": [
{
"norm_x": 0.09999999999999998,
"norm_y": 0.09999999999999998
},
{
"norm_x": 0.9,
"norm_y": 0.09999999999999998
},
{
"norm_x": 0.9,
"norm_y": 0.9
},
{
"norm_x": 0.09999999999999998,
"norm_y": 0.9
}
],
"impacts": [
{
"id": 1,
"label": "bullet_hole",
"score": 5,
"coords": {
"norm_x": 0.267578125,
"norm_y": 0.5554741753472222
}
},
{
"id": 2,
"label": "bullet_hole",
"score": 7,
"coords": {
"norm_x": 0.5712583188657407,
"norm_y": 0.3592800564236111
}
},
{
"id": 3,
"label": "bullet_hole",
"score": 4,
"coords": {
"norm_x": 0.7555239076967593,
"norm_y": 0.5703938802083333
}
},
{
"id": 4,
"label": "bullet_hole",
"score": 4,
"coords": {
"norm_x": 0.6666259765625,
"norm_y": 0.6759168836805556
}
},
{
"id": 5,
"label": "bullet_hole",
"score": 9,
"coords": {
"norm_x": 0.5490415219907407,
"norm_y": 0.5212944878472222
}
},
{
"id": 6,
"label": "bullet_hole",
"score": 8,
"coords": {
"norm_x": 0.4897768373842592,
"norm_y": 0.39352756076388895
}
}
]
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

20
build_log.txt Normal file
View File

@@ -0,0 +1,20 @@
Running Gradle task 'assembleDebug'...
FAILURE: Build failed with an exception.
* What went wrong:
Execution failed for task ':app:processDebugResources'.
> A failure occurred while executing com.android.build.gradle.internal.res.LinkApplicationAndroidResourcesTask$TaskAction
> Android resource linking failed
ERROR: C:\Users\streaper2\Documents\00 - projet\bully\build\cunning_document_scanner\intermediates\merged_manifest\debug\processDebugManifest\AndroidManifest.xml:9:5-65: AAPT: error: unexpected element <uses-permission> found in <manifest><application>.
* Try:
> Run with --stacktrace option to get the stack trace.
> Run with --info or --debug option to get more log output.
> Run with --scan to get full insights.
> Get more help at https://help.gradle.org.
BUILD FAILED in 5s
Running Gradle task 'assembleDebug'... 5,4s
Gradle task assembleDebug failed with exit code 1

3
devtools_options.yaml Normal file
View File

@@ -0,0 +1,3 @@
description: This file stores settings for Dart & Flutter DevTools.
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
extensions:

26
docs/README.md Normal file
View File

@@ -0,0 +1,26 @@
# Documentation du Projet Bully
Bienvenue dans la documentation développeur de l'application **Bully**.
Ce projet est une application Flutter d'analyse de cibles de tir (Impact Detection).
## Architecture
Le code source est organisé dans le dossier `lib/` selon les couches suivantes :
- **Features (`lib/features`)** : Contient les écrans et la logique UI (Vues/Pages). C'est ici que réside l'interface utilisateur.
- **Services (`lib/services`)** : Services "métier" et utilitaires (traitement d'image, calculs, etc.). Indépendant de l'UI.
- **Data (`lib/data`)** : Gestion des données (Modèles, Base de données locale, Repositories).
## Sections de la Documentation
Pour plus de détails sur chaque partie, consultez les sections dédiées :
- 🏗️ **[Services (Logique Métier)](services/README.md)** : Documentation des services comme le traitement d'image et le calcul de score.
- 📱 **[Vues & Features (UI)](features/README.md)** : Documentation des écrans principaux (ex: Analyse).
- 💾 **[Base de Données & Modèles](data/README.md)** : Structure des données et persistance.
## Pour commencer
1. Assurez-vous d'avoir Flutter installé.
2. Lancez `flutter run` pour démarrer l'application.

17
docs/data/README.md Normal file
View File

@@ -0,0 +1,17 @@
# Data & Persistance
Cette couche gère la sauvegarde et la récupération des données.
## Base de Données
L'application utilise une base de données locale (probablement SQLite/Drift ou Hive, à vérifier dans `lib/data/database`).
## Modèles (`lib/data/models`)
Les classes représentant les objets métier persistés.
Exemples probables :
- `Session` : Une session de tir.
- `Impact` : Un impact de balle sur la cible.
- `Target` : Configuration d'une cible.
## Repositories (`lib/data/repositories`)
Le pattern Repository est utilisé pour abstraire la source de données (DB locale, API distante, etc.) du reste de l'application.

17
docs/features/README.md Normal file
View File

@@ -0,0 +1,17 @@
# Features & Vues
Cette section documente les écrans principaux de l'application et leur organisation.
## Écrans Principaux
### Analysis (`lib/features/analysis`)
C'est le cœur de l'application. Il permet à l'utilisateur de prendre une photo ou choisir une image pour analyser les impacts.
- **AnalysisScreen** (`analysis_screen.dart`): L'écran principal qui orchestre la capture et l'affichage des résultats.
- **AnalysisProvider** (`analysis_provider.dart`): Gestionnaire d'état (State Management) pour cet écran. Il fait le pont entre la vue et les services.
## Structure d'une Feature
Chaque feature est généralement composée de :
- `_screen.dart` : Le Widget de la page.
- `_provider.dart` : La logique d'état (ChangeNotifier, Bloc, etc.).
- `widgets/` : Widgets spécifiques à cette feature.

20
docs/services/README.md Normal file
View File

@@ -0,0 +1,20 @@
# Services
Les services contiennent la logique métier de l'application, isolée de l'interface utilisateur.
## Liste des Services Principaux
| Service | Description | Fichier |
| :--- | :--- | :--- |
| **ImageProcessingService** | Gère le traitement lourd des images (filtres, détection). | `lib/services/image_processing_service.dart` |
| **DistortionCorrection** | Corrige la distorsion de perspective des cibles. | `lib/services/distortion_correction_service.dart` |
| **ScoreCalculator** | Calcule le score en fonction des impacts détectés. | `lib/services/score_calculator_service.dart` |
| **StatisticsService** | Génère des statistiques sur les sessions de tir. | `lib/services/statistics_service.dart` |
## Exemple d'utilisation (Fictif)
```dart
// Exemple d'appel au service de calcul de score
final calculator = ScoreCalculatorService();
final score = calculator.calculate(impacts);
```

View File

@@ -2,6 +2,13 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<dict>
<key>NSPhotoLibraryUsageDescription</key>
<string>Bully a besoin d'accéder à vos photos pour analyser vos cibles enregistrées.</string>
<key>NSCameraUsageDescription</key>
<string>Bully a besoin d'utiliser l'appareil photo pour scanner vos cibles en temps réel.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Bully a besoin d'accéder au micro (requis par certains services de caméra même si non utilisé).</string>
<key>CADisableMinimumFrameDurationOnPhone</key> <key>CADisableMinimumFrameDurationOnPhone</key>
<true/> <true/>
<key>CFBundleDevelopmentRegion</key> <key>CFBundleDevelopmentRegion</key>

View File

@@ -1,19 +1,36 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'core/theme/theme_provider.dart';
import 'core/theme/app_theme.dart'; import 'core/theme/app_theme.dart';
import 'main_navigation_holder.dart';
import 'features/home/home_screen.dart'; import 'features/home/home_screen.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
class BullyApp extends StatelessWidget { class BullyApp extends StatelessWidget {
const BullyApp({super.key}); const BullyApp({super.key});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Consumer<ThemeProvider>(
builder: (context, themeProvider, child) {
return MaterialApp( return MaterialApp(
title: 'Bully - Analyse de Cibles', title: 'Bully - Analyse de Cibles',
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
theme: AppTheme.lightTheme, theme: AppTheme.lightTheme,
darkTheme: AppTheme.darkTheme, darkTheme: AppTheme.darkTheme,
themeMode: ThemeMode.system, themeMode: themeProvider.themeMode,
home: const HomeScreen(), localizationsDelegates: const [
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: const [
Locale('fr', 'FR'), // Français
],
locale: const Locale('fr', 'FR'), // Force l'interface en français
home: const MainNavigationHolder(),
);
},
); );
} }
} }

View File

@@ -3,11 +3,14 @@ class AppConstants {
// Database // Database
static const String databaseName = 'bully_targets.db'; static const String databaseName = 'bully_targets.db';
static const int databaseVersion = 1; static const int databaseVersion = 6;
// Tables // Tables
static const String sessionsTable = 'sessions'; static const String sessionsTable = 'sessions';
static const String targetAnalysesTable = 'target_analyses';
static const String shotsTable = 'shots'; static const String shotsTable = 'shots';
static const String weaponsTable = 'weapons';
static const String maintenanceTable = 'weapon_maintenance';
// Image processing // Image processing
static const double minImpactRadius = 2.0; static const double minImpactRadius = 2.0;

View File

@@ -0,0 +1,44 @@
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
class ThemeProvider with ChangeNotifier {
static const String _themeModeKey = 'user_theme_mode';
ThemeMode _themeMode = ThemeMode.system;
ThemeMode get themeMode => _themeMode;
ThemeProvider() {
loadThemeMode();
}
Future<void> loadThemeMode() async {
final prefs = await SharedPreferences.getInstance();
final modeIndex = prefs.getInt(_themeModeKey);
if (modeIndex != null) {
_themeMode = ThemeMode.values[modeIndex];
notifyListeners();
}
}
Future<void> setThemeMode(ThemeMode mode) async {
if (_themeMode == mode) return;
_themeMode = mode;
notifyListeners();
final prefs = await SharedPreferences.getInstance();
await prefs.setInt(_themeModeKey, mode.index);
}
String get themeModeName {
switch (_themeMode) {
case ThemeMode.system:
return 'Automatique';
case ThemeMode.light:
return 'Clair';
case ThemeMode.dark:
return 'Sombre';
}
}
}

View File

@@ -1,7 +1,10 @@
import 'package:sqflite/sqflite.dart'; import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart'; import 'package:path/path.dart';
import '../models/session.dart'; import '../models/session.dart';
import '../models/target_analysis.dart';
import '../models/shot.dart'; import '../models/shot.dart';
import '../models/weapon.dart';
import '../models/maintenance.dart';
import '../../core/constants/app_constants.dart'; import '../../core/constants/app_constants.dart';
class DatabaseHelper { class DatabaseHelper {
@@ -36,6 +39,19 @@ class DatabaseHelper {
await db.execute(''' await db.execute('''
CREATE TABLE ${AppConstants.sessionsTable} ( CREATE TABLE ${AppConstants.sessionsTable} (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
weapon TEXT NOT NULL,
weapon_id TEXT,
max_shots_per_target INTEGER NOT NULL,
created_at TEXT NOT NULL,
notes TEXT,
distance INTEGER DEFAULT 25
)
''');
await db.execute('''
CREATE TABLE ${AppConstants.targetAnalysesTable} (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
target_type TEXT NOT NULL, target_type TEXT NOT NULL,
image_path TEXT NOT NULL, image_path TEXT NOT NULL,
total_score INTEGER NOT NULL, total_score INTEGER NOT NULL,
@@ -46,7 +62,8 @@ class DatabaseHelper {
notes TEXT, notes TEXT,
target_center_x REAL, target_center_x REAL,
target_center_y REAL, target_center_y REAL,
target_radius REAL target_radius REAL,
FOREIGN KEY (session_id) REFERENCES ${AppConstants.sessionsTable}(id) ON DELETE CASCADE
) )
'''); ''');
@@ -56,22 +73,212 @@ class DatabaseHelper {
x REAL NOT NULL, x REAL NOT NULL,
y REAL NOT NULL, y REAL NOT NULL,
score INTEGER NOT NULL, score INTEGER NOT NULL,
session_id TEXT NOT NULL, analysis_id TEXT NOT NULL,
FOREIGN KEY (session_id) REFERENCES ${AppConstants.sessionsTable}(id) ON DELETE CASCADE FOREIGN KEY (analysis_id) REFERENCES ${AppConstants.targetAnalysesTable}(id) ON DELETE CASCADE
) )
'''); ''');
await db.execute(''' await db.execute('''
CREATE INDEX idx_shots_session_id ON ${AppConstants.shotsTable}(session_id) CREATE INDEX idx_target_analyses_session_id ON ${AppConstants.targetAnalysesTable}(session_id)
''');
await db.execute('''
CREATE INDEX idx_shots_analysis_id ON ${AppConstants.shotsTable}(analysis_id)
'''); ''');
await db.execute(''' await db.execute('''
CREATE INDEX idx_sessions_created_at ON ${AppConstants.sessionsTable}(created_at DESC) CREATE INDEX idx_sessions_created_at ON ${AppConstants.sessionsTable}(created_at DESC)
'''); ''');
await db.execute('''
CREATE TABLE ${AppConstants.weaponsTable} (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
type TEXT NOT NULL,
caliber TEXT NOT NULL,
magazine_count INTEGER NOT NULL,
magazine_capacity INTEGER NOT NULL,
notes TEXT,
created_at TEXT NOT NULL,
optic TEXT,
silencer TEXT,
trigger TEXT,
custom_name TEXT
)
''');
await db.execute('''
CREATE TABLE ${AppConstants.maintenanceTable} (
id TEXT PRIMARY KEY,
weapon_id TEXT NOT NULL,
type TEXT NOT NULL,
description TEXT NOT NULL,
date TEXT NOT NULL,
rounds_since_last INTEGER,
FOREIGN KEY (weapon_id) REFERENCES ${AppConstants.weaponsTable}(id) ON DELETE CASCADE
)
''');
} }
Future<void> _onUpgrade(Database db, int oldVersion, int newVersion) async { Future<void> _onUpgrade(Database db, int oldVersion, int newVersion) async {
// Handle future database migrations here if (oldVersion < 2) {
// Migration from single-target sessions to multi-target sessions
// 1. Rename existing sessions table to a temporary name
await db.execute('ALTER TABLE ${AppConstants.sessionsTable} RENAME TO sessions_old');
// 2. Create the new sessions table
await db.execute('''
CREATE TABLE ${AppConstants.sessionsTable} (
id TEXT PRIMARY KEY,
weapon TEXT NOT NULL,
max_shots_per_target INTEGER NOT NULL,
created_at TEXT NOT NULL,
notes TEXT
)
''');
// 3. Create the new target_analyses table
await db.execute('''
CREATE TABLE ${AppConstants.targetAnalysesTable} (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
target_type TEXT NOT NULL,
image_path TEXT NOT NULL,
total_score INTEGER NOT NULL,
grouping_diameter REAL,
grouping_center_x REAL,
grouping_center_y REAL,
created_at TEXT NOT NULL,
notes TEXT,
target_center_x REAL,
target_center_y REAL,
target_radius REAL,
FOREIGN KEY (session_id) REFERENCES ${AppConstants.sessionsTable}(id) ON DELETE CASCADE
)
''');
// 4. Update shots table (rename session_id to analysis_id)
await db.execute('ALTER TABLE ${AppConstants.shotsTable} RENAME TO shots_old');
await db.execute('''
CREATE TABLE ${AppConstants.shotsTable} (
id TEXT PRIMARY KEY,
x REAL NOT NULL,
y REAL NOT NULL,
score INTEGER NOT NULL,
analysis_id TEXT NOT NULL,
FOREIGN KEY (analysis_id) REFERENCES ${AppConstants.targetAnalysesTable}(id) ON DELETE CASCADE
)
''');
// 5. Migrate data
// For each old session, create one session and one target_analysis
final oldSessions = await db.query('sessions_old');
for (final old in oldSessions) {
final sessionId = old['id'] as String;
final analysisId = 'analysis_$sessionId'; // Create a unique ID for the analysis
// Insert into new sessions
await db.insert(AppConstants.sessionsTable, {
'id': sessionId,
'weapon': 'Inconnue',
'max_shots_per_target': 5,
'created_at': old['created_at'],
'notes': old['notes'],
});
// Insert into target_analyses
await db.insert(AppConstants.targetAnalysesTable, {
'id': analysisId,
'session_id': sessionId,
'target_type': old['target_type'],
'image_path': old['image_path'],
'total_score': old['total_score'],
'grouping_diameter': old['grouping_diameter'],
'grouping_center_x': old['grouping_center_x'],
'grouping_center_y': old['grouping_center_y'],
'created_at': old['created_at'],
'notes': null,
'target_center_x': old['target_center_x'],
'target_center_y': old['target_center_y'],
'target_radius': old['target_radius'],
});
// Migrate shots from shots_old to shots
final oldShots = await db.query('shots_old', where: 'session_id = ?', whereArgs: [sessionId]);
for (final shot in oldShots) {
await db.insert(AppConstants.shotsTable, {
'id': shot['id'],
'x': shot['x'],
'y': shot['y'],
'score': shot['score'],
'analysis_id': analysisId,
});
}
}
// 6. Cleanup
await db.execute('DROP TABLE sessions_old');
await db.execute('DROP TABLE shots_old');
// 7. Re-create indexes
await db.execute('''
CREATE INDEX idx_target_analyses_session_id ON ${AppConstants.targetAnalysesTable}(session_id)
''');
await db.execute('''
CREATE INDEX idx_shots_analysis_id ON ${AppConstants.shotsTable}(analysis_id)
''');
await db.execute('''
CREATE INDEX idx_sessions_created_at ON ${AppConstants.sessionsTable}(created_at DESC)
''');
}
if (oldVersion < 3) {
// Add weapons table
await db.execute('''
CREATE TABLE ${AppConstants.weaponsTable} (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
type TEXT NOT NULL,
caliber TEXT NOT NULL,
magazine_count INTEGER NOT NULL,
magazine_capacity INTEGER NOT NULL,
notes TEXT,
created_at TEXT NOT NULL
)
''');
// Add weapon_id to sessions table
await db.execute('ALTER TABLE ${AppConstants.sessionsTable} ADD COLUMN weapon_id TEXT');
}
if (oldVersion < 4) {
// Add maintenance table
await db.execute('''
CREATE TABLE ${AppConstants.maintenanceTable} (
id TEXT PRIMARY KEY,
weapon_id TEXT NOT NULL,
type TEXT NOT NULL,
description TEXT NOT NULL,
date TEXT NOT NULL,
rounds_since_last INTEGER,
FOREIGN KEY (weapon_id) REFERENCES ${AppConstants.weaponsTable}(id) ON DELETE CASCADE
)
''');
}
if (oldVersion < 5) {
// Add new weapon options columns
await db.execute('ALTER TABLE ${AppConstants.weaponsTable} ADD COLUMN optic TEXT');
await db.execute('ALTER TABLE ${AppConstants.weaponsTable} ADD COLUMN silencer TEXT');
await db.execute('ALTER TABLE ${AppConstants.weaponsTable} ADD COLUMN "trigger" TEXT');
await db.execute('ALTER TABLE ${AppConstants.weaponsTable} ADD COLUMN custom_name TEXT');
}
if (oldVersion < 6) {
// Add distance column to sessions
await db.execute('ALTER TABLE ${AppConstants.sessionsTable} ADD COLUMN distance INTEGER DEFAULT 25');
}
} }
// Session operations // Session operations
@@ -84,20 +291,39 @@ class DatabaseHelper {
conflictAlgorithm: ConflictAlgorithm.replace, conflictAlgorithm: ConflictAlgorithm.replace,
); );
for (final shot in session.shots) { for (final analysis in session.analyses) {
// Ensure shot has correct session_id await insertTargetAnalysisTx(txn, analysis, session.id);
final shotWithSessionId = shot.copyWith(sessionId: session.id);
await txn.insert(
AppConstants.shotsTable,
shotWithSessionId.toMap(),
conflictAlgorithm: ConflictAlgorithm.replace,
);
} }
return 1; return 1;
}); });
} }
Future<void> insertTargetAnalysis(TargetAnalysis analysis, String sessionId) async {
final db = await database;
await db.transaction((txn) async {
await insertTargetAnalysisTx(txn, analysis, sessionId);
});
}
Future<void> insertTargetAnalysisTx(Transaction txn, TargetAnalysis analysis, String sessionId) async {
final analysisWithSessionId = analysis.copyWith(sessionId: sessionId);
await txn.insert(
AppConstants.targetAnalysesTable,
analysisWithSessionId.toMap(),
conflictAlgorithm: ConflictAlgorithm.replace,
);
for (final shot in analysis.shots) {
final shotWithAnalysisId = shot.copyWith(analysisId: analysis.id);
await txn.insert(
AppConstants.shotsTable,
shotWithAnalysisId.toMap(),
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
}
Future<Session?> getSession(String id) async { Future<Session?> getSession(String id) async {
final db = await database; final db = await database;
final sessionMaps = await db.query( final sessionMaps = await db.query(
@@ -108,106 +334,52 @@ class DatabaseHelper {
if (sessionMaps.isEmpty) return null; if (sessionMaps.isEmpty) return null;
final shotMaps = await db.query( final analysisMaps = await db.query(
AppConstants.shotsTable, AppConstants.targetAnalysesTable,
where: 'session_id = ?', where: 'session_id = ?',
whereArgs: [id], whereArgs: [id],
); );
final analyses = <TargetAnalysis>[];
for (final analysisMap in analysisMaps) {
final analysisId = analysisMap['id'] as String;
final shotMaps = await db.query(
AppConstants.shotsTable,
where: 'analysis_id = ?',
whereArgs: [analysisId],
);
final shots = shotMaps.map((map) => Shot.fromMap(map)).toList(); final shots = shotMaps.map((map) => Shot.fromMap(map)).toList();
return Session.fromMap(sessionMaps.first, shots); analyses.add(TargetAnalysis.fromMap(analysisMap, shots));
}
return Session.fromMap(sessionMaps.first, analyses);
} }
Future<List<Session>> getAllSessions({ Future<List<Session>> getAllSessions({
String? targetType,
int? limit, int? limit,
int? offset, int? offset,
}) async { }) async {
final db = await database; final db = await database;
String? whereClause;
List<dynamic>? whereArgs;
if (targetType != null) {
whereClause = 'target_type = ?';
whereArgs = [targetType];
}
final sessionMaps = await db.query( final sessionMaps = await db.query(
AppConstants.sessionsTable, AppConstants.sessionsTable,
where: whereClause,
whereArgs: whereArgs,
orderBy: 'created_at DESC', orderBy: 'created_at DESC',
limit: limit, limit: limit,
offset: offset, offset: offset,
); );
// First, check if there are orphaned shots (with empty session_id)
// and only one session - if so, assign them to that session
if (sessionMaps.length == 1) {
final orphanedShots = await db.query(
AppConstants.shotsTable,
where: 'session_id = ?',
whereArgs: [''],
);
if (orphanedShots.isNotEmpty) {
await db.update(
AppConstants.shotsTable,
{'session_id': sessionMaps.first['id']},
where: 'session_id = ?',
whereArgs: [''],
);
}
}
final sessions = <Session>[]; final sessions = <Session>[];
for (final sessionMap in sessionMaps) { for (final sessionMap in sessionMaps) {
final sessionId = sessionMap['id'] as String; final sessionId = sessionMap['id'] as String;
final session = await getSession(sessionId);
final shotMaps = await db.query( if (session != null) {
AppConstants.shotsTable, sessions.add(session);
where: 'session_id = ?', }
whereArgs: [sessionId],
);
final shots = shotMaps.map((map) => Shot.fromMap(map)).toList();
sessions.add(Session.fromMap(sessionMap, shots));
} }
return sessions; return sessions;
} }
Future<int> updateSession(Session session) async {
final db = await database;
return await db.transaction((txn) async {
await txn.update(
AppConstants.sessionsTable,
session.toMap(),
where: 'id = ?',
whereArgs: [session.id],
);
// Delete existing shots and insert new ones
await txn.delete(
AppConstants.shotsTable,
where: 'session_id = ?',
whereArgs: [session.id],
);
for (final shot in session.shots) {
// Ensure shot has correct session_id
final shotWithSessionId = shot.copyWith(sessionId: session.id);
await txn.insert(
AppConstants.shotsTable,
shotWithSessionId.toMap(),
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
return 1;
});
}
Future<int> deleteSession(String id) async { Future<int> deleteSession(String id) async {
final db = await database; final db = await database;
return await db.delete( return await db.delete(
@@ -229,12 +401,12 @@ class DatabaseHelper {
) ?? 0; ) ?? 0;
final avgScore = (await db.rawQuery( final avgScore = (await db.rawQuery(
'SELECT AVG(total_score) as avg FROM ${AppConstants.sessionsTable}', 'SELECT AVG(total_score) as avg FROM ${AppConstants.targetAnalysesTable}',
)).first['avg'] as double? ?? 0.0; )).first['avg'] as double? ?? 0.0;
final bestScore = Sqflite.firstIntValue( final bestScore = Sqflite.firstIntValue(
await db.rawQuery( await db.rawQuery(
'SELECT MAX(total_score) FROM ${AppConstants.sessionsTable}', 'SELECT MAX(total_score) FROM ${AppConstants.targetAnalysesTable}',
), ),
) ?? 0; ) ?? 0;
@@ -246,6 +418,121 @@ class DatabaseHelper {
}; };
} }
// Weapon operations
Future<int> insertWeapon(Weapon weapon) async {
final db = await database;
return await db.insert(
AppConstants.weaponsTable,
weapon.toMap(),
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
Future<List<Weapon>> getAllWeapons() async {
final db = await database;
final List<Map<String, dynamic>> maps = await db.query(AppConstants.weaponsTable, orderBy: 'name ASC');
return List.generate(maps.length, (i) => Weapon.fromMap(maps[i]));
}
Future<Weapon?> getWeapon(String id) async {
final db = await database;
final List<Map<String, dynamic>> maps = await db.query(
AppConstants.weaponsTable,
where: 'id = ?',
whereArgs: [id],
);
if (maps.isEmpty) return null;
return Weapon.fromMap(maps.first);
}
Future<int> updateWeapon(Weapon weapon) async {
final db = await database;
// Start a transaction to ensure both updates succeed
return await db.transaction((txn) async {
// 1. Update weapon in armory
final count = await txn.update(
AppConstants.weaponsTable,
weapon.toMap(),
where: 'id = ?',
whereArgs: [weapon.id],
);
// 2. Propagate name change to all sessions linked to this weapon
await txn.update(
AppConstants.sessionsTable,
{'weapon': weapon.displayName},
where: 'weapon_id = ?',
whereArgs: [weapon.id],
);
return count;
});
}
Future<int> deleteWeapon(String id) async {
final db = await database;
return await db.transaction((txn) async {
// 1. Unlink sessions from this weapon but KEEP the name (gravé à jamais)
await txn.update(
AppConstants.sessionsTable,
{'weapon_id': null},
where: 'weapon_id = ?',
whereArgs: [id],
);
// 2. Delete the weapon record
return await txn.delete(
AppConstants.weaponsTable,
where: 'id = ?',
whereArgs: [id],
);
});
}
// Weapon statistics & Maintenance
Future<int> getRoundsFiredForWeapon(String weaponId) async {
final db = await database;
final result = await db.rawQuery('''
SELECT COUNT(s.id) as count
FROM ${AppConstants.shotsTable} s
JOIN ${AppConstants.targetAnalysesTable} ta ON s.analysis_id = ta.id
JOIN ${AppConstants.sessionsTable} sess ON ta.session_id = sess.id
WHERE sess.weapon_id = ?
''', [weaponId]);
return Sqflite.firstIntValue(result) ?? 0;
}
Future<int> insertMaintenance(MaintenanceEntry entry) async {
final db = await database;
return await db.insert(
AppConstants.maintenanceTable,
entry.toMap(),
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
Future<List<MaintenanceEntry>> getMaintenanceForWeapon(String weaponId) async {
final db = await database;
final maps = await db.query(
AppConstants.maintenanceTable,
where: 'weapon_id = ?',
orderBy: 'date DESC',
whereArgs: [weaponId],
);
return List.generate(maps.length, (i) => MaintenanceEntry.fromMap(maps[i]));
}
Future<int> deleteMaintenance(String id) async {
final db = await database;
return await db.delete(
AppConstants.maintenanceTable,
where: 'id = ?',
whereArgs: [id],
);
}
Future<void> close() async { Future<void> close() async {
final db = await database; final db = await database;
await db.close(); await db.close();

View File

@@ -0,0 +1,57 @@
enum MaintenanceType {
cleaning('Nettoyage'),
repair('Réparation'),
inspection('Inspection'),
upgrade('Amélioration'),
other('Autre');
final String displayName;
const MaintenanceType(this.displayName);
static MaintenanceType fromString(String value) {
return MaintenanceType.values.firstWhere(
(type) => type.name == value,
orElse: () => MaintenanceType.other,
);
}
}
class MaintenanceEntry {
final String id;
final String weaponId;
final MaintenanceType type;
final String description;
final DateTime date;
final int? roundsSinceLastMaintenance;
MaintenanceEntry({
required this.id,
required this.weaponId,
required this.type,
required this.description,
required this.date,
this.roundsSinceLastMaintenance,
});
Map<String, dynamic> toMap() {
return {
'id': id,
'weapon_id': weaponId,
'type': type.name,
'description': description,
'date': date.toIso8601String(),
'rounds_since_last': roundsSinceLastMaintenance,
};
}
factory MaintenanceEntry.fromMap(Map<String, dynamic> map) {
return MaintenanceEntry(
id: map['id'] as String,
weaponId: map['weapon_id'] as String,
type: MaintenanceType.fromString(map['type'] as String),
description: map['description'] as String,
date: DateTime.parse(map['date'] as String),
roundsSinceLastMaintenance: map['rounds_since_last'] as int?,
);
}
}

View File

@@ -1,112 +1,83 @@
import 'shot.dart'; import 'target_analysis.dart';
import 'target_type.dart';
class Session { class Session {
final String id; final String id;
final TargetType targetType; final String weapon;
final String imagePath; final String? weaponId;
final List<Shot> shots; final int maxShotsPerTarget;
final int totalScore; final List<TargetAnalysis> analyses;
final double? groupingDiameter;
final double? groupingCenterX;
final double? groupingCenterY;
final DateTime createdAt; final DateTime createdAt;
final String? notes; final String? notes;
final int distance; // Shooting distance in meters
// Target detection data
final double? targetCenterX;
final double? targetCenterY;
final double? targetRadius;
Session({ Session({
required this.id, required this.id,
required this.targetType, required this.weapon,
required this.imagePath, this.weaponId,
required this.shots, required this.maxShotsPerTarget,
required this.totalScore, required this.analyses,
this.groupingDiameter,
this.groupingCenterX,
this.groupingCenterY,
required this.createdAt, required this.createdAt,
this.notes, this.notes,
this.targetCenterX, this.distance = 25, // Default distance
this.targetCenterY,
this.targetRadius,
}); });
int get shotCount => shots.length; int get targetCount => analyses.length;
double get averageScore => shots.isEmpty ? 0.0 : totalScore / shots.length; int get totalShots => analyses.fold(0, (sum, a) => sum + a.shotCount);
int get totalScore => analyses.fold(0, (sum, a) => sum + a.totalScore);
double get averageScore => analyses.isEmpty ? 0.0 : totalScore / analyses.length;
Session copyWith({ Session copyWith({
String? id, String? id,
TargetType? targetType, String? weapon,
String? imagePath, String? weaponId,
List<Shot>? shots, int? maxShotsPerTarget,
int? totalScore, List<TargetAnalysis>? analyses,
double? groupingDiameter,
double? groupingCenterX,
double? groupingCenterY,
DateTime? createdAt, DateTime? createdAt,
String? notes, String? notes,
double? targetCenterX, int? distance,
double? targetCenterY,
double? targetRadius,
}) { }) {
return Session( return Session(
id: id ?? this.id, id: id ?? this.id,
targetType: targetType ?? this.targetType, weapon: weapon ?? this.weapon,
imagePath: imagePath ?? this.imagePath, weaponId: weaponId ?? this.weaponId,
shots: shots ?? this.shots, maxShotsPerTarget: maxShotsPerTarget ?? this.maxShotsPerTarget,
totalScore: totalScore ?? this.totalScore, analyses: analyses ?? this.analyses,
groupingDiameter: groupingDiameter ?? this.groupingDiameter,
groupingCenterX: groupingCenterX ?? this.groupingCenterX,
groupingCenterY: groupingCenterY ?? this.groupingCenterY,
createdAt: createdAt ?? this.createdAt, createdAt: createdAt ?? this.createdAt,
notes: notes ?? this.notes, notes: notes ?? this.notes,
targetCenterX: targetCenterX ?? this.targetCenterX, distance: distance ?? this.distance,
targetCenterY: targetCenterY ?? this.targetCenterY,
targetRadius: targetRadius ?? this.targetRadius,
); );
} }
Map<String, dynamic> toMap() { Map<String, dynamic> toMap() {
return { return {
'id': id, 'id': id,
'target_type': targetType.name, 'weapon': weapon,
'image_path': imagePath, 'weapon_id': weaponId,
'total_score': totalScore, 'max_shots_per_target': maxShotsPerTarget,
'grouping_diameter': groupingDiameter,
'grouping_center_x': groupingCenterX,
'grouping_center_y': groupingCenterY,
'created_at': createdAt.toIso8601String(), 'created_at': createdAt.toIso8601String(),
'notes': notes, 'notes': notes,
'target_center_x': targetCenterX, 'distance': distance,
'target_center_y': targetCenterY,
'target_radius': targetRadius,
}; };
} }
factory Session.fromMap(Map<String, dynamic> map, List<Shot> shots) { factory Session.fromMap(Map<String, dynamic> map, List<TargetAnalysis> analyses) {
return Session( return Session(
id: map['id'] as String, id: map['id'] as String,
targetType: TargetType.fromString(map['target_type'] as String), weapon: map['weapon'] as String? ?? 'Inconnue',
imagePath: map['image_path'] as String, weaponId: map['weapon_id'] as String?,
shots: shots, maxShotsPerTarget: map['max_shots_per_target'] as int? ?? 5,
totalScore: map['total_score'] as int, analyses: analyses,
groupingDiameter: map['grouping_diameter'] as double?,
groupingCenterX: map['grouping_center_x'] as double?,
groupingCenterY: map['grouping_center_y'] as double?,
createdAt: DateTime.parse(map['created_at'] as String), createdAt: DateTime.parse(map['created_at'] as String),
notes: map['notes'] as String?, notes: map['notes'] as String?,
targetCenterX: map['target_center_x'] as double?, distance: map['distance'] as int? ?? 25,
targetCenterY: map['target_center_y'] as double?,
targetRadius: map['target_radius'] as double?,
); );
} }
@override @override
String toString() { String toString() {
return 'Session(id: $id, targetType: $targetType, shots: ${shots.length}, totalScore: $totalScore, createdAt: $createdAt)'; return 'Session(id: $id, weapon: $weapon, distance: ${distance}m, targets: ${analyses.length}, createdAt: $createdAt)';
} }
} }

View File

@@ -3,14 +3,14 @@ class Shot {
final double x; // Relative position (0.0 - 1.0) final double x; // Relative position (0.0 - 1.0)
final double y; // Relative position (0.0 - 1.0) final double y; // Relative position (0.0 - 1.0)
final int score; final int score;
final String sessionId; final String analysisId;
Shot({ Shot({
required this.id, required this.id,
required this.x, required this.x,
required this.y, required this.y,
required this.score, required this.score,
required this.sessionId, required this.analysisId,
}); });
Shot copyWith({ Shot copyWith({
@@ -18,14 +18,14 @@ class Shot {
double? x, double? x,
double? y, double? y,
int? score, int? score,
String? sessionId, String? analysisId,
}) { }) {
return Shot( return Shot(
id: id ?? this.id, id: id ?? this.id,
x: x ?? this.x, x: x ?? this.x,
y: y ?? this.y, y: y ?? this.y,
score: score ?? this.score, score: score ?? this.score,
sessionId: sessionId ?? this.sessionId, analysisId: analysisId ?? this.analysisId,
); );
} }
@@ -35,7 +35,7 @@ class Shot {
'x': x, 'x': x,
'y': y, 'y': y,
'score': score, 'score': score,
'session_id': sessionId, 'analysis_id': analysisId,
}; };
} }
@@ -45,13 +45,13 @@ class Shot {
x: (map['x'] as num).toDouble(), x: (map['x'] as num).toDouble(),
y: (map['y'] as num).toDouble(), y: (map['y'] as num).toDouble(),
score: map['score'] as int, score: map['score'] as int,
sessionId: map['session_id'] as String, analysisId: map['analysis_id'] as String? ?? map['session_id'] as String? ?? '',
); );
} }
@override @override
String toString() { String toString() {
return 'Shot(id: $id, x: $x, y: $y, score: $score, sessionId: $sessionId)'; return 'Shot(id: $id, x: $x, y: $y, score: $score, analysisId: $analysisId)';
} }
@override @override
@@ -62,11 +62,11 @@ class Shot {
other.x == x && other.x == x &&
other.y == y && other.y == y &&
other.score == score && other.score == score &&
other.sessionId == sessionId; other.analysisId == analysisId;
} }
@override @override
int get hashCode { int get hashCode {
return id.hashCode ^ x.hashCode ^ y.hashCode ^ score.hashCode ^ sessionId.hashCode; return id.hashCode ^ x.hashCode ^ y.hashCode ^ score.hashCode ^ analysisId.hashCode;
} }
} }

View File

@@ -0,0 +1,113 @@
import 'shot.dart';
import 'target_type.dart';
class TargetAnalysis {
final String id;
final String sessionId;
final TargetType targetType;
final String imagePath;
final List<Shot> shots;
final int totalScore;
final double? groupingDiameter;
final double? groupingCenterX;
final double? groupingCenterY;
final DateTime createdAt;
final String? notes;
// Target detection data
final double? targetCenterX;
final double? targetCenterY;
final double? targetRadius;
TargetAnalysis({
required this.id,
required this.sessionId,
required this.targetType,
required this.imagePath,
required this.shots,
required this.totalScore,
this.groupingDiameter,
this.groupingCenterX,
this.groupingCenterY,
required this.createdAt,
this.notes,
this.targetCenterX,
this.targetCenterY,
this.targetRadius,
});
int get shotCount => shots.length;
double get averageScore => shots.isEmpty ? 0.0 : totalScore / shots.length;
TargetAnalysis copyWith({
String? id,
String? sessionId,
TargetType? targetType,
String? imagePath,
List<Shot>? shots,
int? totalScore,
double? groupingDiameter,
double? groupingCenterX,
double? groupingCenterY,
DateTime? createdAt,
String? notes,
double? targetCenterX,
double? targetCenterY,
double? targetRadius,
}) {
return TargetAnalysis(
id: id ?? this.id,
sessionId: sessionId ?? this.sessionId,
targetType: targetType ?? this.targetType,
imagePath: imagePath ?? this.imagePath,
shots: shots ?? this.shots,
totalScore: totalScore ?? this.totalScore,
groupingDiameter: groupingDiameter ?? this.groupingDiameter,
groupingCenterX: groupingCenterX ?? this.groupingCenterX,
groupingCenterY: groupingCenterY ?? this.groupingCenterY,
createdAt: createdAt ?? this.createdAt,
notes: notes ?? this.notes,
targetCenterX: targetCenterX ?? this.targetCenterX,
targetCenterY: targetCenterY ?? this.targetCenterY,
targetRadius: targetRadius ?? this.targetRadius,
);
}
Map<String, dynamic> toMap() {
return {
'id': id,
'session_id': sessionId,
'target_type': targetType.name,
'image_path': imagePath,
'total_score': totalScore,
'grouping_diameter': groupingDiameter,
'grouping_center_x': groupingCenterX,
'grouping_center_y': groupingCenterY,
'created_at': createdAt.toIso8601String(),
'notes': notes,
'target_center_x': targetCenterX,
'target_center_y': targetCenterY,
'target_radius': targetRadius,
};
}
factory TargetAnalysis.fromMap(Map<String, dynamic> map, List<Shot> shots) {
return TargetAnalysis(
id: map['id'] as String,
sessionId: map['session_id'] as String,
targetType: TargetType.fromString(map['target_type'] as String),
imagePath: map['image_path'] as String,
shots: shots,
totalScore: map['total_score'] as int,
groupingDiameter: map['grouping_diameter'] as double?,
groupingCenterX: map['grouping_center_x'] as double?,
groupingCenterY: map['grouping_center_y'] as double?,
createdAt: DateTime.parse(map['created_at'] as String),
notes: map['notes'] as String?,
targetCenterX: map['target_center_x'] as double?,
targetCenterY: map['target_center_y'] as double?,
targetRadius: map['target_radius'] as double?,
);
}
}

114
lib/data/models/weapon.dart Normal file
View File

@@ -0,0 +1,114 @@
enum WeaponType {
handgun('Arme de Poing'),
rifle('Arme d\'Épaule'),
shotgun('Fusil à Pompe'),
airgun('Airsoft / Airgun');
final String displayName;
const WeaponType(this.displayName);
static WeaponType fromString(String value) {
return WeaponType.values.firstWhere(
(type) => type.name == value,
orElse: () => WeaponType.handgun,
);
}
}
class Weapon {
final String id;
final String name;
final WeaponType type;
final String caliber;
final int magazineCount;
final int magazineCapacity;
final String? notes;
final DateTime createdAt;
// New options
final String? optic;
final String? silencer;
final String? trigger;
final String? customName;
String get displayName => (customName != null && customName!.isNotEmpty) ? customName! : name;
Weapon({
required this.id,
required this.name,
required this.type,
required this.caliber,
required this.magazineCount,
required this.magazineCapacity,
this.notes,
required this.createdAt,
this.optic,
this.silencer,
this.trigger,
this.customName,
});
Weapon copyWith({
String? id,
String? name,
WeaponType? type,
String? caliber,
int? magazineCount,
int? magazineCapacity,
String? notes,
DateTime? createdAt,
String? optic,
String? silencer,
String? trigger,
String? customName,
}) {
return Weapon(
id: id ?? this.id,
name: name ?? this.name,
type: type ?? this.type,
caliber: caliber ?? this.caliber,
magazineCount: magazineCount ?? this.magazineCount,
magazineCapacity: magazineCapacity ?? this.magazineCapacity,
notes: notes ?? this.notes,
createdAt: createdAt ?? this.createdAt,
optic: optic ?? this.optic,
silencer: silencer ?? this.silencer,
trigger: trigger ?? this.trigger,
customName: customName ?? this.customName,
);
}
Map<String, dynamic> toMap() {
return {
'id': id,
'name': name,
'type': type.name,
'caliber': caliber,
'magazine_count': magazineCount,
'magazine_capacity': magazineCapacity,
'notes': notes,
'created_at': createdAt.toIso8601String(),
'optic': optic,
'silencer': silencer,
'trigger': trigger,
'custom_name': customName,
};
}
factory Weapon.fromMap(Map<String, dynamic> map) {
return Weapon(
id: map['id'] as String,
name: map['name'] as String,
type: WeaponType.fromString(map['type'] as String),
caliber: map['caliber'] as String,
magazineCount: map['magazine_count'] as int,
magazineCapacity: map['magazine_capacity'] as int,
notes: map['notes'] as String?,
createdAt: DateTime.parse(map['created_at'] as String),
optic: map['optic'] as String?,
silencer: map['silencer'] as String?,
trigger: map['trigger'] as String?,
customName: map['custom_name'] as String?,
);
}
}

View File

@@ -4,8 +4,10 @@ import 'package:path/path.dart' as path;
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
import '../database/database_helper.dart'; import '../database/database_helper.dart';
import '../models/session.dart'; import '../models/session.dart';
import '../models/target_analysis.dart';
import '../models/shot.dart'; import '../models/shot.dart';
import '../models/target_type.dart'; import '../models/weapon.dart';
import '../models/maintenance.dart';
class SessionRepository { class SessionRepository {
final DatabaseHelper _databaseHelper; final DatabaseHelper _databaseHelper;
@@ -18,8 +20,37 @@ class SessionRepository {
_uuid = uuid ?? const Uuid(); _uuid = uuid ?? const Uuid();
Future<Session> createSession({ Future<Session> createSession({
required TargetType targetType, String? id,
required String weapon,
String? weaponId,
required int maxShotsPerTarget,
required List<TargetAnalysis> analyses,
String? notes,
int distance = 25,
}) async {
final session = Session(
id: id ?? _uuid.v4(),
weapon: weapon,
weaponId: weaponId,
maxShotsPerTarget: maxShotsPerTarget,
analyses: analyses,
createdAt: DateTime.now(),
notes: notes,
distance: distance,
);
await _databaseHelper.insertSession(session);
return session;
}
Future<void> addAnalysisToSession(String sessionId, TargetAnalysis analysis) async {
await _databaseHelper.insertTargetAnalysis(analysis, sessionId);
}
Future<TargetAnalysis> prepareTargetAnalysis({
required String imagePath, required String imagePath,
required String sessionId,
required dynamic targetType, // TargetType
required List<Shot> shots, required List<Shot> shots,
required int totalScore, required int totalScore,
double? groupingDiameter, double? groupingDiameter,
@@ -33,8 +64,9 @@ class SessionRepository {
// Copy image to app documents directory // Copy image to app documents directory
final savedImagePath = await _saveImage(imagePath); final savedImagePath = await _saveImage(imagePath);
final session = Session( return TargetAnalysis(
id: _uuid.v4(), id: _uuid.v4(),
sessionId: sessionId,
targetType: targetType, targetType: targetType,
imagePath: savedImagePath, imagePath: savedImagePath,
shots: shots, shots: shots,
@@ -48,9 +80,6 @@ class SessionRepository {
targetCenterY: targetCenterY, targetCenterY: targetCenterY,
targetRadius: targetRadius, targetRadius: targetRadius,
); );
await _databaseHelper.insertSession(session);
return session;
} }
Future<String> _saveImage(String sourcePath) async { Future<String> _saveImage(String sourcePath) async {
@@ -75,30 +104,25 @@ class SessionRepository {
} }
Future<List<Session>> getAllSessions({ Future<List<Session>> getAllSessions({
TargetType? targetType,
int? limit, int? limit,
int? offset, int? offset,
}) async { }) async {
return await _databaseHelper.getAllSessions( return await _databaseHelper.getAllSessions(
targetType: targetType?.name,
limit: limit, limit: limit,
offset: offset, offset: offset,
); );
} }
Future<void> updateSession(Session session) async {
await _databaseHelper.updateSession(session);
}
Future<void> deleteSession(String id) async { Future<void> deleteSession(String id) async {
final session = await getSession(id); final session = await getSession(id);
if (session != null) { if (session != null) {
// Delete the image file for (final analysis in session.analyses) {
final imageFile = File(session.imagePath); final imageFile = File(analysis.imagePath);
if (await imageFile.exists()) { if (await imageFile.exists()) {
await imageFile.delete(); await imageFile.delete();
} }
} }
}
await _databaseHelper.deleteSession(id); await _databaseHelper.deleteSession(id);
} }
@@ -106,7 +130,79 @@ class SessionRepository {
return await _databaseHelper.getStatistics(); return await _databaseHelper.getStatistics();
} }
String generateShotId() { String generateId() {
return _uuid.v4(); return _uuid.v4();
} }
// Weapon Repository methods
Future<Weapon> addWeapon({
required String name,
required WeaponType type,
required String caliber,
required int magazineCount,
required int magazineCapacity,
String? notes,
String? optic,
String? silencer,
String? trigger,
String? customName,
}) async {
final weapon = Weapon(
id: _uuid.v4(),
name: name,
type: type,
caliber: caliber,
magazineCount: magazineCount,
magazineCapacity: magazineCapacity,
notes: notes,
createdAt: DateTime.now(),
optic: optic,
silencer: silencer,
trigger: trigger,
customName: customName,
);
await _databaseHelper.insertWeapon(weapon);
return weapon;
}
Future<List<Weapon>> getWeapons() async {
return await _databaseHelper.getAllWeapons();
}
Future<void> updateWeapon(Weapon weapon) async {
await _databaseHelper.updateWeapon(weapon);
}
Future<void> deleteWeapon(String id) async {
await _databaseHelper.deleteWeapon(id);
}
Future<int> getRoundsFiredForWeapon(String weaponId) async {
return await _databaseHelper.getRoundsFiredForWeapon(weaponId);
}
Future<void> addMaintenanceEntry({
required String weaponId,
required MaintenanceType type,
required String description,
int? roundsSinceLast,
}) async {
final entry = MaintenanceEntry(
id: _uuid.v4(),
weaponId: weaponId,
type: type,
description: description,
date: DateTime.now(),
roundsSinceLastMaintenance: roundsSinceLast,
);
await _databaseHelper.insertMaintenance(entry);
}
Future<List<MaintenanceEntry>> getMaintenanceHistory(String weaponId) async {
return await _databaseHelper.getMaintenanceForWeapon(weaponId);
}
Future<void> deleteMaintenanceEntry(String id) async {
await _databaseHelper.deleteMaintenance(id);
}
} }

View File

@@ -7,9 +7,9 @@ library;
import 'dart:io'; import 'dart:io';
import 'dart:ui' as ui; import 'dart:ui' as ui;
import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
import '../../data/models/session.dart'; import '../../data/models/target_analysis.dart';
import '../../data/models/shot.dart'; import '../../data/models/shot.dart';
import '../../data/models/target_type.dart'; import '../../data/models/target_type.dart';
import '../../data/repositories/session_repository.dart'; import '../../data/repositories/session_repository.dart';
@@ -17,6 +17,8 @@ import '../../services/target_detection_service.dart';
import '../../services/score_calculator_service.dart'; import '../../services/score_calculator_service.dart';
import '../../services/grouping_analyzer_service.dart'; import '../../services/grouping_analyzer_service.dart';
import '../../services/distortion_correction_service.dart'; import '../../services/distortion_correction_service.dart';
import '../../services/opencv_target_service.dart';
import '../../services/ai_export_service.dart';
enum AnalysisState { initial, loading, success, error } enum AnalysisState { initial, loading, success, error }
@@ -26,6 +28,7 @@ class AnalysisProvider extends ChangeNotifier {
final GroupingAnalyzerService _groupingAnalyzerService; final GroupingAnalyzerService _groupingAnalyzerService;
final SessionRepository _sessionRepository; final SessionRepository _sessionRepository;
final DistortionCorrectionService _distortionService; final DistortionCorrectionService _distortionService;
final OpenCVTargetService _opencvTargetService;
final Uuid _uuid = const Uuid(); final Uuid _uuid = const Uuid();
AnalysisProvider({ AnalysisProvider({
@@ -34,11 +37,13 @@ class AnalysisProvider extends ChangeNotifier {
required GroupingAnalyzerService groupingAnalyzerService, required GroupingAnalyzerService groupingAnalyzerService,
required SessionRepository sessionRepository, required SessionRepository sessionRepository,
DistortionCorrectionService? distortionService, DistortionCorrectionService? distortionService,
OpenCVTargetService? opencvTargetService,
}) : _detectionService = detectionService, }) : _detectionService = detectionService,
_scoreCalculatorService = scoreCalculatorService, _scoreCalculatorService = scoreCalculatorService,
_groupingAnalyzerService = groupingAnalyzerService, _groupingAnalyzerService = groupingAnalyzerService,
_sessionRepository = sessionRepository, _sessionRepository = sessionRepository,
_distortionService = distortionService ?? DistortionCorrectionService(); _distortionService = distortionService ?? DistortionCorrectionService(),
_opencvTargetService = opencvTargetService ?? OpenCVTargetService();
AnalysisState _state = AnalysisState.initial; AnalysisState _state = AnalysisState.initial;
String? _errorMessage; String? _errorMessage;
@@ -49,6 +54,7 @@ class AnalysisProvider extends ChangeNotifier {
double _targetCenterX = 0.5; double _targetCenterX = 0.5;
double _targetCenterY = 0.5; double _targetCenterY = 0.5;
double _targetRadius = 0.4; double _targetRadius = 0.4;
double _targetInnerRadius = 0.04;
int _ringCount = 10; int _ringCount = 10;
List<double>? _ringRadii; // Individual ring radii multipliers List<double>? _ringRadii; // Individual ring radii multipliers
double _imageAspectRatio = 1.0; // width / height double _imageAspectRatio = 1.0; // width / height
@@ -79,8 +85,10 @@ class AnalysisProvider extends ChangeNotifier {
double get targetCenterX => _targetCenterX; double get targetCenterX => _targetCenterX;
double get targetCenterY => _targetCenterY; double get targetCenterY => _targetCenterY;
double get targetRadius => _targetRadius; double get targetRadius => _targetRadius;
double get targetInnerRadius => _targetInnerRadius;
int get ringCount => _ringCount; int get ringCount => _ringCount;
List<double>? get ringRadii => _ringRadii != null ? List.unmodifiable(_ringRadii!) : null; List<double>? get ringRadii =>
_ringRadii != null ? List.unmodifiable(_ringRadii!) : null;
double get imageAspectRatio => _imageAspectRatio; double get imageAspectRatio => _imageAspectRatio;
List<Shot> get shots => List.unmodifiable(_shots); List<Shot> get shots => List.unmodifiable(_shots);
ScoreResult? get scoreResult => _scoreResult; ScoreResult? get scoreResult => _scoreResult;
@@ -97,13 +105,23 @@ class AnalysisProvider extends ChangeNotifier {
DistortionParameters? get distortionParams => _distortionParams; DistortionParameters? get distortionParams => _distortionParams;
String? get correctedImagePath => _correctedImagePath; String? get correctedImagePath => _correctedImagePath;
bool get hasDistortion => _distortionParams?.needsCorrection ?? false; bool get hasDistortion => _distortionParams?.needsCorrection ?? false;
/// Retourne le chemin de l'image à afficher (corrigée si activée, originale sinon) /// Retourne le chemin de l'image à afficher (corrigée si activée, originale sinon)
String? get displayImagePath => _distortionCorrectionEnabled && _correctedImagePath != null String? get displayImagePath =>
_distortionCorrectionEnabled && _correctedImagePath != null
? _correctedImagePath ? _correctedImagePath
: _imagePath; : _imagePath;
/// Analyze an image /// Analyze an image
Future<void> analyzeImage(String imagePath, TargetType targetType) async { ///
/// [autoAnalyze] determines if we should run automatic detection immediately.
/// If false, only the image is loaded and default target parameters are set.
Future<void> analyzeImage(
String imagePath,
TargetType targetType, {
bool autoAnalyze = true,
Offset? manualCenter,
}) async {
_state = AnalysisState.loading; _state = AnalysisState.loading;
_imagePath = imagePath; _imagePath = imagePath;
_targetType = targetType; _targetType = targetType;
@@ -119,6 +137,21 @@ class AnalysisProvider extends ChangeNotifier {
_imageAspectRatio = frame.image.width / frame.image.height; _imageAspectRatio = frame.image.width / frame.image.height;
frame.image.dispose(); frame.image.dispose();
if (!autoAnalyze) {
// 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;
}
// Detect target and impacts // Detect target and impacts
final result = _detectionService.detectTarget(imagePath, targetType); final result = _detectionService.detectTarget(imagePath, targetType);
@@ -132,6 +165,7 @@ class AnalysisProvider extends ChangeNotifier {
_targetCenterX = result.centerX; _targetCenterX = result.centerX;
_targetCenterY = result.centerY; _targetCenterY = result.centerY;
_targetRadius = result.radius; _targetRadius = result.radius;
_targetInnerRadius = result.radius * 0.1;
// Create shots from detected impacts // Create shots from detected impacts
_shots = result.impacts.map((impact) { _shots = result.impacts.map((impact) {
@@ -140,7 +174,7 @@ class AnalysisProvider extends ChangeNotifier {
x: impact.x, x: impact.x,
y: impact.y, y: impact.y,
score: impact.suggestedScore, score: impact.suggestedScore,
sessionId: '', // Will be set when saving analysisId: '', // Will be set when saving
); );
}).toList(); }).toList();
@@ -162,13 +196,7 @@ class AnalysisProvider extends ChangeNotifier {
/// Add a manual shot /// Add a manual shot
void addShot(double x, double y) { void addShot(double x, double y) {
final score = _calculateShotScore(x, y); final score = _calculateShotScore(x, y);
final shot = Shot( final shot = Shot(id: _uuid.v4(), x: x, y: y, score: score, analysisId: '');
id: _uuid.v4(),
x: x,
y: y,
score: score,
sessionId: '',
);
_shots.add(shot); _shots.add(shot);
_recalculateScores(); _recalculateScores();
@@ -190,17 +218,23 @@ class AnalysisProvider extends ChangeNotifier {
if (index == -1) return; if (index == -1) return;
final newScore = _calculateShotScore(newX, newY); final newScore = _calculateShotScore(newX, newY);
_shots[index] = _shots[index].copyWith( _shots[index] = _shots[index].copyWith(x: newX, y: newY, score: newScore);
x: newX,
y: newY,
score: newScore,
);
_recalculateScores(); _recalculateScores();
_recalculateGrouping(); _recalculateGrouping();
notifyListeners(); notifyListeners();
} }
/// Update a shot's score manually
void updateShotScore(String shotId, int newScore) {
final index = _shots.indexWhere((shot) => shot.id == shotId);
if (index == -1) return;
_shots[index] = _shots[index].copyWith(score: newScore);
_recalculateScores();
notifyListeners();
}
/// Auto-detect impacts using image processing /// Auto-detect impacts using image processing
Future<int> autoDetectImpacts({ Future<int> autoDetectImpacts({
int darkThreshold = 80, int darkThreshold = 80,
@@ -242,7 +276,7 @@ class AnalysisProvider extends ChangeNotifier {
x: impact.x, x: impact.x,
y: impact.y, y: impact.y,
score: score, score: score,
sessionId: '', analysisId: '',
); );
_shots.add(shot); _shots.add(shot);
} }
@@ -255,14 +289,6 @@ class AnalysisProvider extends ChangeNotifier {
} }
/// Auto-detect impacts using OpenCV (Hough Circles + Contours) /// Auto-detect impacts using OpenCV (Hough Circles + Contours)
///
/// NOTE: OpenCV est actuellement désactivé sur Windows en raison de problèmes
/// de compilation. Cette méthode retourne 0 (aucun impact détecté).
/// Utiliser autoDetectImpacts() à la place.
///
/// Utilise les algorithmes OpenCV pour une détection plus robuste:
/// - Transformation de Hough pour détecter les cercles
/// - Analyse de contours avec filtrage par circularité
Future<int> autoDetectImpactsWithOpenCV({ Future<int> autoDetectImpactsWithOpenCV({
double cannyThreshold1 = 50, double cannyThreshold1 = 50,
double cannyThreshold2 = 150, double cannyThreshold2 = 150,
@@ -271,6 +297,8 @@ class AnalysisProvider extends ChangeNotifier {
double param2 = 30, double param2 = 30,
int minRadius = 5, int minRadius = 5,
int maxRadius = 50, int maxRadius = 50,
int minSize = 5,
int maxSize = 1000,
int blurSize = 5, int blurSize = 5,
bool useContourDetection = true, bool useContourDetection = true,
double minCircularity = 0.6, double minCircularity = 0.6,
@@ -317,7 +345,7 @@ class AnalysisProvider extends ChangeNotifier {
x: impact.x, x: impact.x,
y: impact.y, y: impact.y,
score: score, score: score,
sessionId: '', analysisId: '',
); );
_shots.add(shot); _shots.add(shot);
} }
@@ -334,7 +362,9 @@ class AnalysisProvider extends ChangeNotifier {
double tolerance = 2.0, double tolerance = 2.0,
bool clearExisting = false, bool clearExisting = false,
}) async { }) async {
if (_imagePath == null || _targetType == null || _referenceImpacts.length < 2) { if (_imagePath == null ||
_targetType == null ||
_referenceImpacts.length < 2) {
return 0; return 0;
} }
@@ -343,7 +373,8 @@ class AnalysisProvider extends ChangeNotifier {
.map((shot) => ReferenceImpact(x: shot.x, y: shot.y)) .map((shot) => ReferenceImpact(x: shot.x, y: shot.y))
.toList(); .toList();
final detectedImpacts = _detectionService.detectImpactsWithOpenCVFromReferences( final detectedImpacts = _detectionService
.detectImpactsWithOpenCVFromReferences(
_imagePath!, _imagePath!,
_targetType!, _targetType!,
_targetCenterX, _targetCenterX,
@@ -366,7 +397,7 @@ class AnalysisProvider extends ChangeNotifier {
x: impact.x, x: impact.x,
y: impact.y, y: impact.y,
score: score, score: score,
sessionId: '', analysisId: '',
); );
_shots.add(shot); _shots.add(shot);
} }
@@ -381,13 +412,7 @@ class AnalysisProvider extends ChangeNotifier {
/// Add a reference impact for calibrated detection /// Add a reference impact for calibrated detection
void addReferenceImpact(double x, double y) { void addReferenceImpact(double x, double y) {
final score = _calculateShotScore(x, y); final score = _calculateShotScore(x, y);
final shot = Shot( final shot = Shot(id: _uuid.v4(), x: x, y: y, score: score, analysisId: '');
id: _uuid.v4(),
x: x,
y: y,
score: score,
sessionId: '',
);
_referenceImpacts.add(shot); _referenceImpacts.add(shot);
notifyListeners(); notifyListeners();
} }
@@ -428,7 +453,9 @@ class AnalysisProvider extends ChangeNotifier {
double tolerance = 2.0, double tolerance = 2.0,
bool clearExisting = false, bool clearExisting = false,
}) async { }) async {
if (_imagePath == null || _targetType == null || _learnedCharacteristics == null) { if (_imagePath == null ||
_targetType == null ||
_learnedCharacteristics == null) {
return 0; return 0;
} }
@@ -455,7 +482,7 @@ class AnalysisProvider extends ChangeNotifier {
x: impact.x, x: impact.x,
y: impact.y, y: impact.y,
score: score, score: score,
sessionId: '', analysisId: '',
); );
_shots.add(shot); _shots.add(shot);
} }
@@ -468,9 +495,17 @@ class AnalysisProvider extends ChangeNotifier {
} }
/// Adjust target position /// Adjust target position
void adjustTargetPosition(double centerX, double centerY, double radius, {int? ringCount, List<double>? ringRadii}) { void adjustTargetPosition(
double centerX,
double centerY,
double innerRadius,
double radius, {
int? ringCount,
List<double>? ringRadii,
}) {
_targetCenterX = centerX; _targetCenterX = centerX;
_targetCenterY = centerY; _targetCenterY = centerY;
_targetInnerRadius = innerRadius;
_targetRadius = radius; _targetRadius = radius;
if (ringCount != null) { if (ringCount != null) {
_ringCount = ringCount; _ringCount = ringCount;
@@ -489,6 +524,43 @@ class AnalysisProvider extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
/// Auto-calibrate target using OpenCV
Future<bool> autoCalibrateTarget() async {
if (_imagePath == null) return false;
try {
// 1. Attempt to correct perspective/distortion first
final correctedPath = await _distortionService
.correctPerspectiveWithConcentricMesh(_imagePath!);
if (correctedPath != _imagePath) {
_imagePath = correctedPath;
_correctedImagePath = correctedPath;
_distortionCorrectionEnabled = true;
_imageAspectRatio =
1.0; // The corrected image is always square (side x side)
notifyListeners();
}
// 2. Detect the target on the straight/corrected image
final result = await _opencvTargetService.detectTarget(_imagePath!);
if (result.success) {
adjustTargetPosition(
result.centerX,
result.centerY,
result.radius * 0.1,
result.radius,
);
return true;
}
return false;
} catch (e) {
debugPrint('Auto-calibration error: $e');
return false;
}
}
/// Calcule les paramètres de distorsion basés sur la calibration actuelle /// Calcule les paramètres de distorsion basés sur la calibration actuelle
void calculateDistortion() { void calculateDistortion() {
_distortionParams = _distortionService.calculateDistortionFromCalibration( _distortionParams = _distortionService.calculateDistortionFromCalibration(
@@ -529,8 +601,6 @@ class AnalysisProvider extends ChangeNotifier {
} }
} }
/* version deux a tester*/
/// Calcule ET applique la correction pour un feedback immédiat /// Calcule ET applique la correction pour un feedback immédiat
Future<void> calculateAndApplyDistortion() async { Future<void> calculateAndApplyDistortion() async {
// 1. Calcul des paramètres (votre code actuel) // 1. Calcul des paramètres (votre code actuel)
@@ -566,7 +636,6 @@ class AnalysisProvider extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
} }
/* fin section deux a tester*/
int _calculateShotScore(double x, double y) { int _calculateShotScore(double x, double y) {
if (_targetType == TargetType.concentric) { if (_targetType == TargetType.concentric) {
@@ -611,16 +680,59 @@ class AnalysisProvider extends ChangeNotifier {
_groupingResult = _groupingAnalyzerService.analyzeGrouping(_shots); _groupingResult = _groupingAnalyzerService.analyzeGrouping(_shots);
} }
/// Save the session /// Exporte l'image et le json vers le backend IA
Future<Session> saveSession({String? notes}) async { Future<bool> exportToAiBackend() async {
if (_imagePath == null || _targetType == null) {
_errorMessage = "Impossible d'exporter : image ou type de cible manquant.";
notifyListeners();
return false;
}
// Identifiant d'analyse temporaire
final analysisId = _shots.isNotEmpty && _shots.first.analysisId.isNotEmpty
? _shots.first.analysisId
: 'analysis_${DateTime.now().millisecondsSinceEpoch}';
final service = AiExportService(); // Local instanciation for simplicity
_state = AnalysisState.loading;
notifyListeners();
final success = await service.exportData(
imagePath: _imagePath!,
sessionId: 'export',
targetType: _targetType!,
targetCenterX: _targetCenterX,
targetCenterY: _targetCenterY,
targetRadius: _targetRadius,
shots: _shots,
);
_state = AnalysisState.success;
if (!success) {
_errorMessage = "Échec de l'export vers le serveur IA.";
}
notifyListeners();
return success;
}
/// Save the session (legacy standalone flow)
Future<TargetAnalysis> saveSession({
String? notes,
String? sessionId,
String? weaponName,
String? weaponId,
int? distance,
}) async {
if (_imagePath == null || _targetType == null) { if (_imagePath == null || _targetType == null) {
throw Exception('Cannot save: missing image or target type'); throw Exception('Cannot save: missing image or target type');
} }
final session = await _sessionRepository.createSession( final targetAnalysis = await _sessionRepository.prepareTargetAnalysis(
targetType: _targetType!,
imagePath: _imagePath!, imagePath: _imagePath!,
shots: _shots.map((s) => s.copyWith(sessionId: '')).toList(), sessionId: sessionId ?? 'standalone',
targetType: _targetType!,
shots: _shots,
totalScore: totalScore, totalScore: totalScore,
groupingDiameter: _groupingResult?.diameter, groupingDiameter: _groupingResult?.diameter,
groupingCenterX: _groupingResult?.centerX, groupingCenterX: _groupingResult?.centerX,
@@ -631,11 +743,34 @@ class AnalysisProvider extends ChangeNotifier {
targetRadius: _targetRadius, targetRadius: _targetRadius,
); );
// Update shots with session ID if (sessionId != null && sessionId != 'standalone') {
_shots = session.shots; final existingSession = await _sessionRepository.getSession(sessionId);
notifyListeners(); if (existingSession != null) {
await _sessionRepository.addAnalysisToSession(sessionId, targetAnalysis);
} else {
await _sessionRepository.createSession(
id: sessionId,
weapon: weaponName ?? 'Inconnue',
weaponId: weaponId,
maxShotsPerTarget: shotCount,
analyses: [targetAnalysis],
notes: notes,
distance: distance ?? 25,
);
}
} else {
await _sessionRepository.createSession(
weapon: weaponName ?? 'Inconnue',
weaponId: weaponId,
maxShotsPerTarget: shotCount,
analyses: [targetAnalysis],
notes: notes,
distance: distance ?? 25,
);
}
return session; notifyListeners();
return targetAnalysis;
} }
/// Reset the provider /// Reset the provider
@@ -647,6 +782,7 @@ class AnalysisProvider extends ChangeNotifier {
_targetCenterX = 0.5; _targetCenterX = 0.5;
_targetCenterY = 0.5; _targetCenterY = 0.5;
_targetRadius = 0.4; _targetRadius = 0.4;
_targetInnerRadius = 0.04;
_ringCount = 10; _ringCount = 10;
_ringRadii = null; _ringRadii = null;
_imageAspectRatio = 1.0; _imageAspectRatio = 1.0;

File diff suppressed because it is too large Load Diff

View File

@@ -13,16 +13,26 @@ class TargetCalibration extends StatefulWidget {
final double initialCenterX; final double initialCenterX;
final double initialCenterY; final double initialCenterY;
final double initialRadius; final double initialRadius;
final double initialInnerRadius;
final int initialRingCount; final int initialRingCount;
final TargetType targetType; final TargetType targetType;
final List<double>? initialRingRadii; final List<double>? initialRingRadii;
final Function(double centerX, double centerY, double radius, int ringCount, {List<double>? ringRadii}) onCalibrationChanged; final Function(
double centerX,
double centerY,
double innerRadius,
double radius,
int ringCount, {
List<double>? ringRadii,
})
onCalibrationChanged;
const TargetCalibration({ const TargetCalibration({
super.key, super.key,
required this.initialCenterX, required this.initialCenterX,
required this.initialCenterY, required this.initialCenterY,
required this.initialRadius, required this.initialRadius,
required this.initialInnerRadius,
this.initialRingCount = 10, this.initialRingCount = 10,
required this.targetType, required this.targetType,
this.initialRingRadii, this.initialRingRadii,
@@ -30,18 +40,20 @@ class TargetCalibration extends StatefulWidget {
}); });
@override @override
State<TargetCalibration> createState() => _TargetCalibrationState(); State<TargetCalibration> createState() => TargetCalibrationState();
} }
class _TargetCalibrationState extends State<TargetCalibration> { class TargetCalibrationState extends State<TargetCalibration> {
late double _centerX; late double _centerX;
late double _centerY; late double _centerY;
late double _radius; late double _radius;
late double _innerRadius;
late int _ringCount; late int _ringCount;
late List<double> _ringRadii; late List<double> _ringRadii;
bool _isDraggingCenter = false; bool _isDraggingCenter = false;
bool _isDraggingRadius = false; bool _isDraggingRadius = false;
bool _isDraggingInnerRadius = false;
@override @override
void initState() { void initState() {
@@ -49,28 +61,57 @@ class _TargetCalibrationState extends State<TargetCalibration> {
_centerX = widget.initialCenterX; _centerX = widget.initialCenterX;
_centerY = widget.initialCenterY; _centerY = widget.initialCenterY;
_radius = widget.initialRadius; _radius = widget.initialRadius;
_innerRadius = widget.initialInnerRadius;
_ringCount = widget.initialRingCount; _ringCount = widget.initialRingCount;
_initRingRadii(); _initRingRadii();
} }
void _initRingRadii() { void _initRingRadii({bool forceRecalculate = false}) {
if (widget.initialRingRadii != null && widget.initialRingRadii!.length == _ringCount) { if (!forceRecalculate && widget.initialRingRadii != null &&
widget.initialRingRadii!.length == _ringCount) {
_ringRadii = List.from(widget.initialRingRadii!); _ringRadii = List.from(widget.initialRingRadii!);
} else { } else {
// Initialize with default proportional radii // Initialize with default proportional radii interpolated between inner and outer
_ringRadii = List.generate(_ringCount, (i) => (i + 1) / _ringCount); _ringRadii = List.generate(_ringCount, (i) {
if (_ringCount <= 1) return 1.0;
final ratio = _innerRadius / _radius;
return ratio + (1.0 - ratio) * i / (_ringCount - 1);
});
} }
} }
@override @override
void didUpdateWidget(TargetCalibration oldWidget) { void didUpdateWidget(TargetCalibration oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
bool shouldReinit = false;
if (widget.initialCenterX != oldWidget.initialCenterX &&
!_isDraggingCenter) {
_centerX = widget.initialCenterX;
}
if (widget.initialCenterY != oldWidget.initialCenterY &&
!_isDraggingCenter) {
_centerY = widget.initialCenterY;
}
if (widget.initialRingCount != oldWidget.initialRingCount) { if (widget.initialRingCount != oldWidget.initialRingCount) {
_ringCount = widget.initialRingCount; _ringCount = widget.initialRingCount;
_initRingRadii(); shouldReinit = true;
} }
if (widget.initialRadius != oldWidget.initialRadius && !_isDraggingRadius) { if (widget.initialRadius != oldWidget.initialRadius && !_isDraggingRadius) {
_radius = widget.initialRadius; _radius = widget.initialRadius;
shouldReinit = true;
}
if (widget.initialInnerRadius != oldWidget.initialInnerRadius &&
!_isDraggingInnerRadius) {
_innerRadius = widget.initialInnerRadius;
shouldReinit = true;
}
if (widget.initialRingRadii != oldWidget.initialRingRadii) {
shouldReinit = true;
}
if (shouldReinit) {
_initRingRadii();
} }
} }
@@ -80,7 +121,9 @@ class _TargetCalibrationState extends State<TargetCalibration> {
builder: (context, constraints) { builder: (context, constraints) {
final size = constraints.biggest; final size = constraints.biggest;
return GestureDetector( return Stack(
children: [
GestureDetector(
onPanStart: (details) => _onPanStart(details, size), onPanStart: (details) => _onPanStart(details, size),
onPanUpdate: (details) => _onPanUpdate(details, size), onPanUpdate: (details) => _onPanUpdate(details, size),
onPanEnd: (_) => _onPanEnd(), onPanEnd: (_) => _onPanEnd(),
@@ -90,42 +133,176 @@ class _TargetCalibrationState extends State<TargetCalibration> {
centerX: _centerX, centerX: _centerX,
centerY: _centerY, centerY: _centerY,
radius: _radius, radius: _radius,
innerRadius: _innerRadius,
ringCount: _ringCount, ringCount: _ringCount,
ringRadii: _ringRadii, ringRadii: _ringRadii,
targetType: widget.targetType, targetType: widget.targetType,
isDraggingCenter: _isDraggingCenter, isDraggingCenter: _isDraggingCenter,
isDraggingRadius: _isDraggingRadius, isDraggingRadius: false, // Plus de drag direct du rayon
isDraggingInnerRadius: false,
), ),
), ),
),
// Slider pour le rayon (agrandissement)
Positioned(
top: 10,
left: 40,
right: 40,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
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(
value: _radius.clamp(0.05, 3.0),
min: 0.05,
max: 3.0,
activeColor: AppTheme.primaryColor,
onChanged: (value) {
setState(() {
_radius = value;
// On force le recalcul car on change la taille
_initRingRadii(forceRecalculate: true);
});
_notifyChange();
},
),
),
const Icon(Icons.zoom_in, color: Colors.white, size: 16),
],
),
),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 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: Slider(
value: (_innerRadius / _radius).clamp(0.01, 0.9),
min: 0.01,
max: 0.9,
activeColor: Colors.orange,
onChanged: (value) {
setState(() {
// On ajuste l'innerRadius comme un ratio du radius global
_innerRadius = _radius * value;
_initRingRadii(forceRecalculate: true);
});
_notifyChange();
},
),
),
const Icon(Icons.expand, color: Colors.white, size: 16),
],
),
),
],
),
),
],
); );
}, },
); );
} }
Widget buildDirectionalControls(BuildContext context, Size size) {
return Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.white10),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildDirectionButton(
Icons.keyboard_arrow_up,
() => _moveCenterByPixels(0, -1, size),
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildDirectionButton(
Icons.keyboard_arrow_left,
() => _moveCenterByPixels(-1, 0, size),
),
const SizedBox(width: 40),
_buildDirectionButton(
Icons.keyboard_arrow_right,
() => _moveCenterByPixels(1, 0, size),
),
],
),
_buildDirectionButton(
Icons.keyboard_arrow_down,
() => _moveCenterByPixels(0, 1, size),
),
],
),
);
}
Widget _buildDirectionButton(IconData icon, VoidCallback onPressed) {
return Container(
decoration: BoxDecoration(
color: Colors.black87,
borderRadius: BorderRadius.circular(8),
),
margin: const EdgeInsets.all(2),
child: IconButton(
icon: Icon(icon, color: Colors.white, size: 28),
onPressed: onPressed,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 40, minHeight: 40),
),
);
}
void _notifyChange() {
widget.onCalibrationChanged(
_centerX,
_centerY,
_innerRadius,
_radius,
_ringCount,
ringRadii: _ringRadii,
);
}
void _moveCenterByPixels(double dx, double dy, Size size) {
setState(() {
_centerX = _centerX + (dx / size.width);
_centerY = _centerY + (dy / size.height);
});
_notifyChange();
}
void _onPanStart(DragStartDetails details, Size size) { void _onPanStart(DragStartDetails details, Size size) {
final tapX = details.localPosition.dx / size.width; final tapX = details.localPosition.dx / size.width;
final tapY = details.localPosition.dy / size.height; final tapY = details.localPosition.dy / size.height;
// Check if tapping on center handle
final distToCenter = _distance(tapX, tapY, _centerX, _centerY); final distToCenter = _distance(tapX, tapY, _centerX, _centerY);
// Check if tapping on radius handle (on the right edge of the outermost circle) if (distToCenter < 0.05 || distToCenter < _radius + 0.02) {
final minDim = math.min(size.width, size.height);
final outerRadius = _radius * (_ringRadii.isNotEmpty ? _ringRadii.last : 1.0);
final radiusHandleX = _centerX + outerRadius * minDim / size.width;
final radiusHandleY = _centerY;
final distToRadiusHandle = _distance(tapX, tapY, radiusHandleX.clamp(0.0, 1.0), radiusHandleY.clamp(0.0, 1.0));
if (distToCenter < 0.05) {
setState(() {
_isDraggingCenter = true;
});
} else if (distToRadiusHandle < 0.05) {
setState(() {
_isDraggingRadius = true;
});
} else if (distToCenter < _radius + 0.02) {
// Tapping inside the target - move center
setState(() { setState(() {
_isDraggingCenter = true; _isDraggingCenter = true;
}); });
@@ -142,20 +319,17 @@ class _TargetCalibrationState extends State<TargetCalibration> {
// Move center // Move center
_centerX = _centerX + deltaX; _centerX = _centerX + deltaX;
_centerY = _centerY + deltaY; _centerY = _centerY + deltaY;
} else if (_isDraggingRadius) {
// Adjust outer radius (scales all rings proportionally)
final newRadius = _radius + deltaX * (size.width / minDim);
_radius = newRadius.clamp(0.05, 3.0);
} }
}); });
widget.onCalibrationChanged(_centerX, _centerY, _radius, _ringCount, ringRadii: _ringRadii); _notifyChange();
} }
void _onPanEnd() { void _onPanEnd() {
setState(() { setState(() {
_isDraggingCenter = false; _isDraggingCenter = false;
_isDraggingRadius = false; _isDraggingRadius = false;
_isDraggingInnerRadius = false;
}); });
} }
@@ -170,21 +344,25 @@ class _CalibrationPainter extends CustomPainter {
final double centerX; final double centerX;
final double centerY; final double centerY;
final double radius; final double radius;
final double innerRadius;
final int ringCount; final int ringCount;
final List<double> ringRadii; final List<double> ringRadii;
final TargetType targetType; final TargetType targetType;
final bool isDraggingCenter; final bool isDraggingCenter;
final bool isDraggingRadius; final bool isDraggingRadius;
final bool isDraggingInnerRadius;
_CalibrationPainter({ _CalibrationPainter({
required this.centerX, required this.centerX,
required this.centerY, required this.centerY,
required this.radius, required this.radius,
required this.innerRadius,
required this.ringCount, required this.ringCount,
required this.ringRadii, required this.ringRadii,
required this.targetType, required this.targetType,
required this.isDraggingCenter, required this.isDraggingCenter,
required this.isDraggingRadius, required this.isDraggingRadius,
required this.isDraggingInnerRadius,
}); });
@override @override
@@ -192,6 +370,7 @@ class _CalibrationPainter extends CustomPainter {
final centerPx = Offset(centerX * size.width, centerY * size.height); final centerPx = Offset(centerX * size.width, centerY * size.height);
final minDim = size.width < size.height ? size.width : size.height; final minDim = size.width < size.height ? size.width : size.height;
final baseRadiusPx = radius * minDim; final baseRadiusPx = radius * minDim;
final innerRadiusPx = innerRadius * minDim;
if (targetType == TargetType.concentric) { if (targetType == TargetType.concentric) {
_drawConcentricZones(canvas, size, centerPx, baseRadiusPx); _drawConcentricZones(canvas, size, centerPx, baseRadiusPx);
@@ -199,17 +378,36 @@ class _CalibrationPainter extends CustomPainter {
_drawSilhouetteZones(canvas, size, centerPx, baseRadiusPx); _drawSilhouetteZones(canvas, size, centerPx, baseRadiusPx);
} }
// Fullscreen crosshairs when dragging center
if (isDraggingCenter) {
final crosshairLinePaint = Paint()
..color = Colors.red.withValues(alpha: 0.5)
..strokeWidth = 1;
canvas.drawLine(
Offset(0, centerPx.dy),
Offset(size.width, centerPx.dy),
crosshairLinePaint,
);
canvas.drawLine(
Offset(centerPx.dx, 0),
Offset(centerPx.dx, size.height),
crosshairLinePaint,
);
}
// Draw center handle // Draw center handle
_drawCenterHandle(canvas, centerPx); _drawCenterHandle(canvas, centerPx);
// Draw radius handle (for outer ring)
_drawRadiusHandle(canvas, size, centerPx, baseRadiusPx);
// Draw instructions // Draw instructions
_drawInstructions(canvas, size); _drawInstructions(canvas, size);
} }
void _drawConcentricZones(Canvas canvas, Size size, Offset center, double baseRadius) { void _drawConcentricZones(
Canvas canvas,
Size size,
Offset center,
double baseRadius,
) {
// Generate colors for zones // Generate colors for zones
List<Color> zoneColors = []; List<Color> zoneColors = [];
for (int i = 0; i < ringCount; i++) { for (int i = 0; i < ringCount; i++) {
@@ -231,11 +429,13 @@ class _CalibrationPainter extends CustomPainter {
final strokePaint = Paint() final strokePaint = Paint()
..style = PaintingStyle.stroke ..style = PaintingStyle.stroke
..strokeWidth = 1 ..strokeWidth = 1
..color = Colors.white.withValues(alpha: 0.6); ..color = Colors.red.withValues(alpha: 0.8);
// Draw from outside to inside // Draw from outside to inside
for (int i = ringCount - 1; i >= 0; i--) { for (int i = ringCount - 1; i >= 0; i--) {
final ringRadius = ringRadii.length > i ? ringRadii[i] : (i + 1) / ringCount; final ringRadius = ringRadii.length > i
? ringRadii[i]
: (i + 1) / ringCount;
final zoneRadius = baseRadius * ringRadius; final zoneRadius = baseRadius * ringRadius;
zonePaint.color = zoneColors[i]; zonePaint.color = zoneColors[i];
@@ -244,12 +444,12 @@ class _CalibrationPainter extends CustomPainter {
} }
// Draw zone labels (only if within visible area) // Draw zone labels (only if within visible area)
final textPainter = TextPainter( final textPainter = TextPainter(textDirection: TextDirection.ltr);
textDirection: TextDirection.ltr,
);
for (int i = 0; i < ringCount; i++) { for (int i = 0; i < ringCount; i++) {
final ringRadius = ringRadii.length > i ? ringRadii[i] : (i + 1) / ringCount; final ringRadius = ringRadii.length > i
? ringRadii[i]
: (i + 1) / ringCount;
final prevRingRadius = i > 0 final prevRingRadius = i > 0
? (ringRadii.length > i - 1 ? ringRadii[i - 1] : i / ringCount) ? (ringRadii.length > i - 1 ? ringRadii[i - 1] : i / ringCount)
: 0.0; : 0.0;
@@ -268,9 +468,7 @@ class _CalibrationPainter extends CustomPainter {
color: Colors.white.withValues(alpha: 0.9), color: Colors.white.withValues(alpha: 0.9),
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
shadows: const [ shadows: const [Shadow(color: Colors.black, blurRadius: 2)],
Shadow(color: Colors.black, blurRadius: 2),
],
), ),
); );
textPainter.layout(); textPainter.layout();
@@ -278,14 +476,24 @@ class _CalibrationPainter extends CustomPainter {
// Draw label on the right side of each zone // Draw label on the right side of each zone
final labelY = center.dy - textPainter.height / 2; final labelY = center.dy - textPainter.height / 2;
if (labelY >= 0 && labelY <= size.height) { if (labelY >= 0 && labelY <= size.height) {
textPainter.paint(canvas, Offset(labelX - textPainter.width / 2, labelY)); textPainter.paint(
canvas,
Offset(labelX - textPainter.width / 2, labelY),
);
} }
} }
} }
void _drawSilhouetteZones(Canvas canvas, Size size, Offset center, double radius) { void _drawSilhouetteZones(
Canvas canvas,
Size size,
Offset center,
double radius,
) {
// Simplified silhouette zones // Simplified silhouette zones
final paint = Paint()..style = PaintingStyle.stroke..strokeWidth = 2; final paint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 2;
// Draw silhouette outline (simplified as rectangle for now) // Draw silhouette outline (simplified as rectangle for now)
final silhouetteWidth = radius * 0.8; final silhouetteWidth = radius * 0.8;
@@ -293,7 +501,11 @@ class _CalibrationPainter extends CustomPainter {
paint.color = Colors.green.withValues(alpha: 0.5); paint.color = Colors.green.withValues(alpha: 0.5);
canvas.drawRect( canvas.drawRect(
Rect.fromCenter(center: center, width: silhouetteWidth, height: silhouetteHeight), Rect.fromCenter(
center: center,
width: silhouetteWidth,
height: silhouetteHeight,
),
paint, paint,
); );
} }
@@ -301,94 +513,40 @@ class _CalibrationPainter extends CustomPainter {
void _drawCenterHandle(Canvas canvas, Offset center) { void _drawCenterHandle(Canvas canvas, Offset center) {
// Outer circle // Outer circle
final outerPaint = Paint() final outerPaint = Paint()
..color = isDraggingCenter ? AppTheme.successColor : AppTheme.primaryColor ..color = isDraggingCenter ? Colors.redAccent : Colors.red
..style = PaintingStyle.stroke ..style = PaintingStyle.stroke
..strokeWidth = 3; ..strokeWidth = 3;
canvas.drawCircle(center, 15, outerPaint); canvas.drawCircle(center, 15, outerPaint);
// Inner dot // Inner dot
final innerPaint = Paint() final innerPaint = Paint()
..color = isDraggingCenter ? AppTheme.successColor : AppTheme.primaryColor ..color = isDraggingCenter ? Colors.redAccent : Colors.red
..style = PaintingStyle.fill; ..style = PaintingStyle.fill;
canvas.drawCircle(center, 5, innerPaint); canvas.drawCircle(center, 5, innerPaint);
// Crosshair // Crosshair
final crossPaint = Paint() final crossPaint = Paint()
..color = isDraggingCenter ? AppTheme.successColor : AppTheme.primaryColor ..color = isDraggingCenter ? Colors.redAccent : Colors.red
..strokeWidth = 2; ..strokeWidth = 2;
canvas.drawLine(Offset(center.dx - 20, center.dy), Offset(center.dx - 8, center.dy), crossPaint);
canvas.drawLine(Offset(center.dx + 8, center.dy), Offset(center.dx + 20, center.dy), crossPaint);
canvas.drawLine(Offset(center.dx, center.dy - 20), Offset(center.dx, center.dy - 8), crossPaint);
canvas.drawLine(Offset(center.dx, center.dy + 8), Offset(center.dx, center.dy + 20), crossPaint);
}
void _drawRadiusHandle(Canvas canvas, Size size, Offset center, double baseRadius) {
// Radius handle on the right edge of the outermost ring
final outerRingRadius = ringRadii.isNotEmpty ? ringRadii.last : 1.0;
final actualRadius = baseRadius * outerRingRadius;
final actualHandleX = center.dx + actualRadius;
final clampedHandleX = actualHandleX.clamp(20.0, size.width - 20);
final clampedHandleY = center.dy.clamp(20.0, size.height - 20);
final handlePos = Offset(clampedHandleX, clampedHandleY);
// Check if handle is clamped (radius extends beyond visible area)
final isClamped = actualHandleX > size.width - 20;
final paint = Paint()
..color = isDraggingRadius
? AppTheme.successColor
: (isClamped ? Colors.orange : AppTheme.warningColor)
..style = PaintingStyle.fill;
// Draw handle as a small circle with arrows
canvas.drawCircle(handlePos, 14, paint);
// Draw arrow indicators
final arrowPaint = Paint()
..color = Colors.white
..strokeWidth = 2
..style = PaintingStyle.stroke;
// Left arrow
canvas.drawLine( canvas.drawLine(
Offset(handlePos.dx - 4, handlePos.dy), Offset(center.dx - 20, center.dy),
Offset(handlePos.dx - 8, handlePos.dy - 4), Offset(center.dx - 8, center.dy),
arrowPaint, crossPaint,
); );
canvas.drawLine( canvas.drawLine(
Offset(handlePos.dx - 4, handlePos.dy), Offset(center.dx + 8, center.dy),
Offset(handlePos.dx - 8, handlePos.dy + 4), Offset(center.dx + 20, center.dy),
arrowPaint, crossPaint,
);
// Right arrow
canvas.drawLine(
Offset(handlePos.dx + 4, handlePos.dy),
Offset(handlePos.dx + 8, handlePos.dy - 4),
arrowPaint,
); );
canvas.drawLine( canvas.drawLine(
Offset(handlePos.dx + 4, handlePos.dy), Offset(center.dx, center.dy - 20),
Offset(handlePos.dx + 8, handlePos.dy + 4), Offset(center.dx, center.dy - 8),
arrowPaint, crossPaint,
); );
canvas.drawLine(
// Label Offset(center.dx, center.dy + 8),
final textPainter = TextPainter( Offset(center.dx, center.dy + 20),
text: const TextSpan( crossPaint,
text: 'RAYON',
style: TextStyle(
color: Colors.white,
fontSize: 8,
fontWeight: FontWeight.bold,
),
),
textDirection: TextDirection.ltr,
);
textPainter.layout();
textPainter.paint(
canvas,
Offset(handlePos.dx - textPainter.width / 2, handlePos.dy + 16),
); );
} }
@@ -414,13 +572,16 @@ class _CalibrationPainter extends CustomPainter {
} }
@override @override
bool shouldRepaint(covariant _CalibrationPainter oldDelegate) { bool shouldRepaint(CustomPainter oldDelegate) {
if (oldDelegate is! _CalibrationPainter) return true;
return centerX != oldDelegate.centerX || return centerX != oldDelegate.centerX ||
centerY != oldDelegate.centerY || centerY != oldDelegate.centerY ||
radius != oldDelegate.radius || radius != oldDelegate.radius ||
innerRadius != oldDelegate.innerRadius ||
ringCount != oldDelegate.ringCount || ringCount != oldDelegate.ringCount ||
isDraggingCenter != oldDelegate.isDraggingCenter || isDraggingCenter != oldDelegate.isDraggingCenter ||
isDraggingRadius != oldDelegate.isDraggingRadius || isDraggingRadius != oldDelegate.isDraggingRadius ||
isDraggingInnerRadius != oldDelegate.isDraggingInnerRadius ||
ringRadii != oldDelegate.ringRadii; ringRadii != oldDelegate.ringRadii;
} }
} }

View File

@@ -25,6 +25,7 @@ class TargetOverlay extends StatelessWidget {
final double? groupingDiameter; final double? groupingDiameter;
final List<Shot>? referenceImpacts; final List<Shot>? referenceImpacts;
final double zoomScale; final double zoomScale;
final bool showRings;
const TargetOverlay({ const TargetOverlay({
super.key, super.key,
@@ -42,6 +43,7 @@ class TargetOverlay extends StatelessWidget {
this.groupingDiameter, this.groupingDiameter,
this.referenceImpacts, this.referenceImpacts,
this.zoomScale = 1.0, this.zoomScale = 1.0,
this.showRings = false,
}); });
@override @override
@@ -72,6 +74,7 @@ class TargetOverlay extends StatelessWidget {
groupingDiameter: groupingDiameter, groupingDiameter: groupingDiameter,
referenceImpacts: referenceImpacts, referenceImpacts: referenceImpacts,
zoomScale: zoomScale, zoomScale: zoomScale,
showRings: showRings,
), ),
child: Stack( child: Stack(
children: shots.map((shot) { children: shots.map((shot) {
@@ -132,6 +135,7 @@ class _TargetOverlayPainter extends CustomPainter {
final double? groupingDiameter; final double? groupingDiameter;
final List<Shot>? referenceImpacts; final List<Shot>? referenceImpacts;
final double zoomScale; final double zoomScale;
final bool showRings;
_TargetOverlayPainter({ _TargetOverlayPainter({
required this.shots, required this.shots,
@@ -146,12 +150,15 @@ class _TargetOverlayPainter extends CustomPainter {
this.groupingDiameter, this.groupingDiameter,
this.referenceImpacts, this.referenceImpacts,
this.zoomScale = 1.0, this.zoomScale = 1.0,
this.showRings = false,
}); });
@override @override
void paint(Canvas canvas, Size size) { void paint(Canvas canvas, Size size) {
// Draw target center indicator // Draw target center indicator
if (showRings) {
_drawTargetCenter(canvas, size); _drawTargetCenter(canvas, size);
}
// Draw grouping circle // Draw grouping circle
if (groupingCenterX != null && groupingCenterY != null && groupingDiameter != null && shots.length > 1) { if (groupingCenterX != null && groupingCenterY != null && groupingDiameter != null && shots.length > 1) {
@@ -371,6 +378,7 @@ class _TargetOverlayPainter extends CustomPainter {
groupingCenterY != oldDelegate.groupingCenterY || groupingCenterY != oldDelegate.groupingCenterY ||
groupingDiameter != oldDelegate.groupingDiameter || groupingDiameter != oldDelegate.groupingDiameter ||
referenceImpacts != oldDelegate.referenceImpacts || referenceImpacts != oldDelegate.referenceImpacts ||
zoomScale != oldDelegate.zoomScale; zoomScale != oldDelegate.zoomScale ||
showRings != oldDelegate.showRings;
} }
} }

View File

@@ -1,18 +1,16 @@
/// Écran de capture - Première étape du workflow d'analyse.
///
/// Permet de sélectionner le type de cible (concentrique ou silhouette)
/// et la source d'image (caméra ou galerie). Affiche un aperçu de l'image
/// sélectionnée avant de lancer l'analyse.
library;
import 'dart:io'; import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:google_mlkit_document_scanner/google_mlkit_document_scanner.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:provider/provider.dart';
import '../../core/constants/app_constants.dart'; import '../../core/constants/app_constants.dart';
import '../../core/theme/app_theme.dart'; import '../../core/theme/app_theme.dart';
import '../../data/models/target_type.dart'; import '../../data/models/target_type.dart';
import '../crop/crop_screen.dart'; import '../crop/crop_screen.dart';
import 'widgets/target_type_selector.dart'; import '../session/session_provider.dart';
import 'widgets/image_source_button.dart'; import 'widgets/image_source_button.dart';
class CaptureScreen extends StatefulWidget { class CaptureScreen extends StatefulWidget {
@@ -24,34 +22,80 @@ class CaptureScreen extends StatefulWidget {
class _CaptureScreenState extends State<CaptureScreen> { class _CaptureScreenState extends State<CaptureScreen> {
final ImagePicker _picker = ImagePicker(); final ImagePicker _picker = ImagePicker();
TargetType _selectedType = TargetType.concentric; final TargetType _selectedType = TargetType.concentric;
String? _selectedImagePath; String? _selectedImagePath;
bool _isLoading = false; bool _isLoading = false;
/// Gère la demande de permission et la sélection d'image depuis la galerie
Future<void> _handleGallerySelection() async {
PermissionStatus status;
if (Platform.isAndroid) {
final deviceInfo = DeviceInfoPlugin();
final androidInfo = await deviceInfo.androidInfo;
if (androidInfo.version.sdkInt >= 33) {
status = await Permission.photos.request();
} else {
status = await Permission.storage.request();
}
} else {
status = await Permission.photos.request();
}
if (status.isGranted) {
_captureImage(ImageSource.gallery);
} else if (status.isPermanentlyDenied) {
_showSettingsDialog();
} else {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Accès à la galerie requis pour continuer'),
),
);
}
}
}
void _showSettingsDialog() {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Permission requise'),
content: const Text(
'L\'accès aux photos est nécessaire. Veuillez l\'activer dans les paramètres.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Annuler'),
),
TextButton(
onPressed: () => openAppSettings(),
child: const Text('Paramètres'),
),
],
),
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final sessionProvider = context.watch<SessionProvider>();
final title = sessionProvider.isSessionActive
? 'Cible ${sessionProvider.targetCount + 1}'
: 'Source';
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(title: Text(title)),
title: const Text('Nouvelle Analyse'),
),
body: SingleChildScrollView( body: SingleChildScrollView(
padding: const EdgeInsets.all(AppConstants.defaultPadding), padding: const EdgeInsets.all(AppConstants.defaultPadding),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
// TODO: une fois la cible de silhouette mise en place, rajouter le selecteur
// Target type selection
// _buildSectionTitle('Type de Cible'),
// const SizedBox(height: 12),
// TargetTypeSelector(
// selectedType: _selectedType,
// onTypeSelected: (type) {
// setState(() => _selectedType = type);
// },
// ),
const SizedBox(height: AppConstants.largePadding), const SizedBox(height: AppConstants.largePadding),
// Image source selection
_buildSectionTitle('Source de l\'Image'), _buildSectionTitle('Source de l\'Image'),
const SizedBox(height: 12), const SizedBox(height: 12),
Row( Row(
@@ -59,8 +103,8 @@ class _CaptureScreenState extends State<CaptureScreen> {
Expanded( Expanded(
child: ImageSourceButton( child: ImageSourceButton(
icon: Icons.camera_alt, icon: Icons.camera_alt,
label: 'Camera', label: 'Scanner',
onPressed: _isLoading ? null : () => _captureImage(ImageSource.camera), onPressed: _isLoading ? null : _scanDocument,
), ),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
@@ -68,14 +112,13 @@ class _CaptureScreenState extends State<CaptureScreen> {
child: ImageSourceButton( child: ImageSourceButton(
icon: Icons.photo_library, icon: Icons.photo_library,
label: 'Galerie', label: 'Galerie',
onPressed: _isLoading ? null : () => _captureImage(ImageSource.gallery), onPressed: _isLoading ? null : _handleGallerySelection,
), ),
), ),
], ],
), ),
const SizedBox(height: AppConstants.largePadding), const SizedBox(height: AppConstants.largePadding),
// Image preview
if (_isLoading) if (_isLoading)
const Center( const Center(
child: Padding( child: Padding(
@@ -83,91 +126,26 @@ class _CaptureScreenState extends State<CaptureScreen> {
child: CircularProgressIndicator(), child: CircularProgressIndicator(),
), ),
) )
else if (_selectedImagePath != null) else
_buildImagePreview(),
// Guide text
if (_selectedImagePath == null && !_isLoading)
_buildGuide(), _buildGuide(),
], ],
), ),
), ),
floatingActionButton: _selectedImagePath != null
? FloatingActionButton.extended(
onPressed: _analyzeImage,
icon: const Icon(Icons.analytics),
label: const Text('Analyser'),
)
: null,
); );
} }
Widget _buildSectionTitle(String title) { Widget _buildSectionTitle(String title) {
return Text( return Text(
title, title,
style: Theme.of(context).textTheme.titleMedium?.copyWith( style: Theme.of(
fontWeight: FontWeight.bold, context,
), ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
); );
} }
Widget _buildImagePreview() {
return Column(
children: [
_buildSectionTitle('Apercu'),
const SizedBox(height: 12),
ClipRRect(
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
child: Stack(
children: [
Image.file(
File(_selectedImagePath!),
fit: BoxFit.contain,
width: double.infinity,
),
Positioned(
top: 8,
right: 8,
child: IconButton(
icon: const Icon(Icons.close),
onPressed: () {
setState(() => _selectedImagePath = null);
},
style: IconButton.styleFrom(
backgroundColor: Colors.black54,
foregroundColor: Colors.white,
),
),
),
],
),
),
const SizedBox(height: 12),
_buildFramingHints(),
],
);
}
Widget _buildFramingHints() {
return Card(
color: AppTheme.warningColor.withValues(alpha: 0.1),
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
Icon(Icons.info_outline, color: AppTheme.warningColor),
const SizedBox(width: 12),
Expanded(
child: Text(
'Assurez-vous que la cible est bien centree et visible.',
style: TextStyle(color: AppTheme.warningColor.withValues(alpha: 0.8)),
),
),
],
),
),
);
}
Widget _buildGuide() { Widget _buildGuide() {
return Card( return Card(
@@ -175,23 +153,26 @@ class _CaptureScreenState extends State<CaptureScreen> {
padding: const EdgeInsets.all(AppConstants.defaultPadding), padding: const EdgeInsets.all(AppConstants.defaultPadding),
child: Column( child: Column(
children: [ children: [
Icon( Icon(Icons.help_outline, size: 48, color: Colors.grey[400]),
Icons.help_outline,
size: 48,
color: Colors.grey[400],
),
const SizedBox(height: 12), const SizedBox(height: 12),
Text( Text(
'Conseils pour une bonne analyse', 'Conseils pour une bonne analyse',
style: Theme.of(context).textTheme.titleSmall?.copyWith( style: Theme.of(
fontWeight: FontWeight.bold, context,
), ).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
_buildGuideItem(Icons.crop_free, 'Cadrez la cible entiere dans l\'image'), _buildGuideItem(
_buildGuideItem(Icons.wb_sunny, 'Utilisez un bon eclairage'), Icons.crop_free,
'Cadrez la cible entière dans l\'image',
),
_buildGuideItem(Icons.wb_sunny, 'Utilisez un bon éclairage'),
_buildGuideItem(Icons.straighten, 'Prenez la photo de face'), _buildGuideItem(Icons.straighten, 'Prenez la photo de face'),
_buildGuideItem(Icons.blur_off, 'Evitez les images floues'), _buildGuideItem(Icons.blur_off, 'Évitez les images floues'),
_buildGuideItem(
Icons.cleaning_services,
'Nettoyer votre objectif avec une chiffonnette',
),
], ],
), ),
), ),
@@ -211,9 +192,30 @@ class _CaptureScreenState extends State<CaptureScreen> {
); );
} }
Future<void> _scanDocument() async {
setState(() => _isLoading = true);
try {
final options = DocumentScannerOptions(
documentFormat: DocumentFormat.jpeg,
mode: ScannerMode.base,
pageLimit: 1,
isGalleryImport: false,
);
final scanner = DocumentScanner(options: options);
final documents = await scanner.scanDocument();
if (documents.images.isNotEmpty) {
_selectedImagePath = documents.images.first;
_analyzeImage();
}
} catch (e) {
debugPrint('Erreur scan: $e');
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
Future<void> _captureImage(ImageSource source) async { Future<void> _captureImage(ImageSource source) async {
setState(() => _isLoading = true); setState(() => _isLoading = true);
try { try {
final XFile? image = await _picker.pickImage( final XFile? image = await _picker.pickImage(
source: source, source: source,
@@ -221,29 +223,19 @@ class _CaptureScreenState extends State<CaptureScreen> {
maxHeight: 2048, maxHeight: 2048,
imageQuality: 90, imageQuality: 90,
); );
if (image != null) { if (image != null) {
setState(() => _selectedImagePath = image.path); _selectedImagePath = image.path;
_analyzeImage();
} }
} catch (e) { } catch (e) {
if (mounted) { debugPrint('Erreur capture: $e');
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur lors de la capture: $e'),
backgroundColor: AppTheme.errorColor,
),
);
}
} finally { } finally {
if (mounted) { if (mounted) setState(() => _isLoading = false);
setState(() => _isLoading = false);
}
} }
} }
void _analyzeImage() { void _analyzeImage() {
if (_selectedImagePath == null) return; if (_selectedImagePath == null) return;
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(

View File

@@ -1,13 +1,7 @@
/// Écran de recadrage d'image en format carré (1:1).
///
/// Permet à l'utilisateur de déplacer et zoomer l'image pour sélectionner
/// la zone à recadrer. Le carré de recadrage est fixe au centre de l'écran.
library;
import 'dart:io'; import 'dart:io';
import 'dart:math' as math; import 'dart:math' as math;
import 'dart:ui' as ui;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../core/theme/app_theme.dart'; import '../../core/theme/app_theme.dart';
import '../../data/models/target_type.dart'; import '../../data/models/target_type.dart';
import '../../services/image_crop_service.dart'; import '../../services/image_crop_service.dart';
@@ -17,11 +11,15 @@ import 'widgets/crop_overlay.dart';
class CropScreen extends StatefulWidget { class CropScreen extends StatefulWidget {
final String imagePath; final String imagePath;
final TargetType targetType; final TargetType targetType;
final double? initialScale;
final Offset? initialOffset;
const CropScreen({ const CropScreen({
super.key, super.key,
required this.imagePath, required this.imagePath,
required this.targetType, required this.targetType,
this.initialScale,
this.initialOffset,
}); });
@override @override
@@ -31,97 +29,145 @@ class CropScreen extends StatefulWidget {
class _CropScreenState extends State<CropScreen> { class _CropScreenState extends State<CropScreen> {
final ImageCropService _cropService = ImageCropService(); final ImageCropService _cropService = ImageCropService();
// États de transformation
double _scale = 1.0;
double _baseScale = 1.0;
Offset _offset = Offset.zero;
Offset _startOffset = Offset.zero;
Offset _startFocalPoint = Offset.zero;
bool _isLoading = false; bool _isLoading = false;
bool _imageLoaded = false; bool _imageLoaded = false;
Size? _imageSize; Size? _imageSize;
late Size _viewportSize;
// Position et échelle de l'image late double _cropSize;
Offset _offset = Offset.zero;
double _scale = 1.0;
double _baseScale = 1.0;
Offset _startFocalPoint = Offset.zero;
Offset _startOffset = Offset.zero;
// Dimensions calculées
double _cropSize = 0;
Size _viewportSize = Size.zero;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_loadImageDimensions(); _loadImageInfo();
} }
Future<void> _loadImageDimensions() async { Future<void> _loadImageInfo() async {
final file = File(widget.imagePath); final file = File(widget.imagePath);
final bytes = await file.readAsBytes(); final decodedImage = await decodeImageFromList(await file.readAsBytes());
final codec = await ui.instantiateImageCodec(bytes); if (mounted) {
final frame = await codec.getNextFrame();
setState(() { setState(() {
_imageSize = Size( _imageSize = Size(
frame.image.width.toDouble(), decodedImage.width.toDouble(),
frame.image.height.toDouble(), decodedImage.height.toDouble(),
); );
_imageLoaded = true; _imageLoaded = true;
}); });
} }
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
backgroundColor: Colors.black, backgroundColor: const Color(0xFF101214),
appBar: AppBar( appBar: AppBar(
backgroundColor: Colors.black, backgroundColor: const Color(0xFF101214),
foregroundColor: Colors.white, elevation: 0,
title: const Text('Recadrer'), centerTitle: true,
title: const Text(
'Centrage de la cible',
style: TextStyle(color: Colors.white, fontSize: 18),
),
leading: IconButton( leading: IconButton(
icon: const Icon(Icons.close), icon: const Icon(Icons.arrow_back, color: Colors.white),
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
), ),
actions: [
if (!_isLoading)
IconButton(
icon: const Icon(Icons.check),
onPressed: _onCropConfirm,
), ),
], body: _isLoading
? const Center(child: CircularProgressIndicator(color: Color(0xFF1A73E8)))
: Column(
children: [
// Zone interactive de crop
Expanded(
child: Container(
margin: const EdgeInsets.all(20),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.white10),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: _imageLoaded ? _buildInteractiveCrop() : const Center(child: CircularProgressIndicator()),
),
),
), ),
body: _buildBody(),
);
}
Widget _buildBody() { // Bouton d'aide / Modifier
if (!_imageLoaded || _imageSize == null) { Padding(
return const Center( padding: const EdgeInsets.symmetric(vertical: 20),
child: CircularProgressIndicator(color: Colors.white),
);
}
if (_isLoading) {
return Center(
child: Column( child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
const CircularProgressIndicator(color: Colors.white), const Icon(Icons.center_focus_strong, color: Colors.redAccent, size: 20),
const SizedBox(height: 16), const SizedBox(width: 8),
Text( Text(
'Recadrage en cours...', 'Alignez le centre de la cible sur la croix',
style: TextStyle(color: Colors.white.withValues(alpha: 0.8)), style: TextStyle(color: Colors.white.withOpacity(0.8), fontSize: 14, fontWeight: FontWeight.w500),
),
],
),
const SizedBox(height: 4),
Text(
'Zoomez pour plus de précision',
style: TextStyle(color: Colors.white.withOpacity(0.5), fontSize: 12),
),
],
),
),
// Boutons du bas
Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 30),
child: Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () => Navigator.pop(context),
style: OutlinedButton.styleFrom(
side: const BorderSide(color: Colors.white24),
padding: const EdgeInsets.symmetric(vertical: 15),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
child: const Text('Retour', style: TextStyle(color: Colors.white)),
),
),
const SizedBox(width: 16),
Expanded(
child: ElevatedButton(
onPressed: _onCropConfirm,
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF1A73E8),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 15),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
child: const Text('Analyser', style: TextStyle(fontWeight: FontWeight.bold)),
),
),
],
),
), ),
], ],
), ),
); );
} }
Widget _buildInteractiveCrop() {
return LayoutBuilder( return LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
_viewportSize = Size(constraints.maxWidth, constraints.maxHeight); _viewportSize = Size(constraints.maxWidth, constraints.maxHeight);
// Taille du carré de crop (90% de la plus petite dimension) // Le carré de crop occupe presque tout l'espace disponible
_cropSize = math.min(constraints.maxWidth, constraints.maxHeight) * 0.85; _cropSize = math.min(constraints.maxWidth, constraints.maxHeight) * 0.95;
// Calculer l'échelle initiale si pas encore fait
if (_scale == 1.0 && _offset == Offset.zero) { if (_scale == 1.0 && _offset == Offset.zero) {
_initializeImagePosition(); _initializeImagePosition();
} }
@@ -129,16 +175,14 @@ class _CropScreenState extends State<CropScreen> {
return GestureDetector( return GestureDetector(
onScaleStart: _onScaleStart, onScaleStart: _onScaleStart,
onScaleUpdate: _onScaleUpdate, onScaleUpdate: _onScaleUpdate,
onScaleEnd: _onScaleEnd,
child: Stack( child: Stack(
children: [ children: [
// Image transformée
Positioned.fill( Positioned.fill(
child: Center( child: Center(
child: Transform( child: Transform(
transform: Matrix4.identity() transform: Matrix4.identity()
..setTranslationRaw(_offset.dx, _offset.dy, 0) ..setTranslationRaw(_offset.dx, _offset.dy, 0)
..scale(_scale, _scale, 1.0), ..scale(_scale, _scale),
alignment: Alignment.center, alignment: Alignment.center,
child: Image.file( child: Image.file(
File(widget.imagePath), File(widget.imagePath),
@@ -149,24 +193,11 @@ class _CropScreenState extends State<CropScreen> {
), ),
), ),
), ),
// Overlay de recadrage
Positioned.fill( Positioned.fill(
child: IgnorePointer( child: IgnorePointer(
child: CropOverlay( child: CropOverlay(cropSize: _cropSize, showGrid: false),
cropSize: _cropSize,
showGrid: true,
), ),
), ),
),
// Instructions en bas
Positioned(
left: 0,
right: 0,
bottom: 24,
child: _buildInstructions(),
),
], ],
), ),
); );
@@ -180,7 +211,6 @@ class _CropScreenState extends State<CropScreen> {
final imageAspect = _imageSize!.width / _imageSize!.height; final imageAspect = _imageSize!.width / _imageSize!.height;
final viewportAspect = _viewportSize.width / _viewportSize.height; final viewportAspect = _viewportSize.width / _viewportSize.height;
// Calculer la taille de l'image affichée (avec BoxFit.contain)
double displayWidth, displayHeight; double displayWidth, displayHeight;
if (imageAspect > viewportAspect) { if (imageAspect > viewportAspect) {
displayWidth = _viewportSize.width; displayWidth = _viewportSize.width;
@@ -190,12 +220,14 @@ class _CropScreenState extends State<CropScreen> {
displayWidth = _viewportSize.height * imageAspect; displayWidth = _viewportSize.height * imageAspect;
} }
// Échelle pour que le plus petit côté de l'image remplisse le carré de crop
final minDisplayDim = math.min(displayWidth, displayHeight); final minDisplayDim = math.min(displayWidth, displayHeight);
_scale = _cropSize / minDisplayDim;
// S'assurer d'un scale minimum _scale = widget.initialScale ?? (_cropSize / minDisplayDim);
if (_scale < 1.0) _scale = 1.0; if (_scale < 1.0 && widget.initialScale == null) _scale = 1.0;
if (widget.initialOffset != null) {
_offset = widget.initialOffset!;
}
} }
void _onScaleStart(ScaleStartDetails details) { void _onScaleStart(ScaleStartDetails details) {
@@ -206,102 +238,49 @@ class _CropScreenState extends State<CropScreen> {
void _onScaleUpdate(ScaleUpdateDetails details) { void _onScaleUpdate(ScaleUpdateDetails details) {
setState(() { setState(() {
// Mise à jour du scale _scale = (_baseScale * details.scale).clamp(0.8, 8.0);
_scale = (_baseScale * details.scale).clamp(0.5, 5.0);
// Mise à jour de la position
final delta = details.focalPoint - _startFocalPoint; final delta = details.focalPoint - _startFocalPoint;
_offset = _startOffset + delta; _offset = _startOffset + delta;
}); });
} }
void _onScaleEnd(ScaleEndDetails details) {
// Optionnel: contraindre l'image pour qu'elle couvre toujours le carré de crop
}
Widget _buildInstructions() {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 24),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.7),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.touch_app,
color: Colors.white.withValues(alpha: 0.8),
size: 20,
),
const SizedBox(width: 8),
Text(
'Déplacez et zoomez pour cadrer la cible',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.9),
fontSize: 14,
),
),
],
),
);
}
Future<void> _onCropConfirm() async { Future<void> _onCropConfirm() async {
if (_imageSize == null) return; setState(() => _isLoading = true);
setState(() {
_isLoading = true;
});
try { try {
// Calculer la zone de crop en coordonnées normalisées de l'image
final cropRect = _calculateCropRect(); final cropRect = _calculateCropRect();
// On calcule le centre relatif basé sur le centrage utilisateur
// Recadrer l'image final targetCenterX = cropRect.x + cropRect.width / 2;
final croppedPath = await _cropService.cropToSquare( final targetCenterY = cropRect.y + cropRect.height / 2;
widget.imagePath,
cropRect,
);
if (!mounted) return; if (!mounted) return;
// Naviguer vers l'écran d'analyse avec l'image recadrée
Navigator.pushReplacement( Navigator.pushReplacement(
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (_) => AnalysisScreen( builder: (_) => AnalysisScreen(
imagePath: croppedPath, imagePath: widget.imagePath,
targetType: widget.targetType, targetType: widget.targetType,
targetCenter: Offset(targetCenterX, targetCenterY),
cropScale: _scale,
cropOffset: _offset,
), ),
), ),
); );
} catch (e) { } catch (e) {
if (!mounted) return; if (mounted) {
setState(() => _isLoading = false);
setState(() {
_isLoading = false;
});
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(content: Text('Erreur: $e'), backgroundColor: AppTheme.errorColor),
content: Text('Erreur lors du recadrage: $e'),
backgroundColor: AppTheme.errorColor,
),
); );
} }
} }
}
CropRect _calculateCropRect() { CropRect _calculateCropRect() {
if (_imageSize == null) { if (_imageSize == null) return const CropRect(x: 0, y: 0, width: 1, height: 1);
return const CropRect(x: 0, y: 0, width: 1, height: 1);
}
final imageAspect = _imageSize!.width / _imageSize!.height; final imageAspect = _imageSize!.width / _imageSize!.height;
final viewportAspect = _viewportSize.width / _viewportSize.height; final viewportAspect = _viewportSize.width / _viewportSize.height;
// Calculer la taille de l'image affichée (avec BoxFit.contain)
double displayWidth, displayHeight; double displayWidth, displayHeight;
if (imageAspect > viewportAspect) { if (imageAspect > viewportAspect) {
displayWidth = _viewportSize.width; displayWidth = _viewportSize.width;
@@ -311,33 +290,28 @@ class _CropScreenState extends State<CropScreen> {
displayWidth = _viewportSize.height * imageAspect; displayWidth = _viewportSize.height * imageAspect;
} }
// Taille de l'image après scale
final scaledWidth = displayWidth * _scale; final scaledWidth = displayWidth * _scale;
final scaledHeight = displayHeight * _scale; final scaledHeight = displayHeight * _scale;
// Position du centre de l'image dans le viewport
final imageCenterX = _viewportSize.width / 2 + _offset.dx; final imageCenterX = _viewportSize.width / 2 + _offset.dx;
final imageCenterY = _viewportSize.height / 2 + _offset.dy; final imageCenterY = _viewportSize.height / 2 + _offset.dy;
// Position du coin supérieur gauche de l'image
final imageLeft = imageCenterX - scaledWidth / 2; final imageLeft = imageCenterX - scaledWidth / 2;
final imageTop = imageCenterY - scaledHeight / 2; final imageTop = imageCenterY - scaledHeight / 2;
// Position du carré de crop (centré dans le viewport)
final cropLeft = (_viewportSize.width - _cropSize) / 2; final cropLeft = (_viewportSize.width - _cropSize) / 2;
final cropTop = (_viewportSize.height - _cropSize) / 2; final cropTop = (_viewportSize.height - _cropSize) / 2;
// Convertir en coordonnées relatives à l'image affichée
final relCropLeft = (cropLeft - imageLeft) / scaledWidth; final relCropLeft = (cropLeft - imageLeft) / scaledWidth;
final relCropTop = (cropTop - imageTop) / scaledHeight; final relCropTop = (cropTop - imageTop) / scaledHeight;
final relCropSize = _cropSize / scaledWidth; final relCropWidth = _cropSize / scaledWidth;
final relCropSizeY = _cropSize / scaledHeight; final relCropHeight = _cropSize / scaledHeight;
return CropRect( return CropRect(
x: relCropLeft.clamp(0.0, 1.0), x: relCropLeft,
y: relCropTop.clamp(0.0, 1.0), y: relCropTop,
width: relCropSize.clamp(0.0, 1.0 - relCropLeft.clamp(0.0, 1.0)), width: relCropWidth,
height: relCropSizeY.clamp(0.0, 1.0 - relCropTop.clamp(0.0, 1.0)), height: relCropHeight,
); );
} }
} }

View File

@@ -13,20 +13,13 @@ class CropOverlay extends StatelessWidget {
/// Afficher la grille des tiers /// Afficher la grille des tiers
final bool showGrid; final bool showGrid;
const CropOverlay({ const CropOverlay({super.key, required this.cropSize, this.showGrid = true});
super.key,
required this.cropSize,
this.showGrid = true,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return CustomPaint( return CustomPaint(
size: Size.infinite, size: Size.infinite,
painter: _CropOverlayPainter( painter: _CropOverlayPainter(cropSize: cropSize, showGrid: showGrid),
cropSize: cropSize,
showGrid: showGrid,
),
); );
} }
} }
@@ -35,10 +28,7 @@ class _CropOverlayPainter extends CustomPainter {
final double cropSize; final double cropSize;
final bool showGrid; final bool showGrid;
_CropOverlayPainter({ _CropOverlayPainter({required this.cropSize, required this.showGrid});
required this.cropSize,
required this.showGrid,
});
@override @override
void paint(Canvas canvas, Size size) { void paint(Canvas canvas, Size size) {
@@ -77,6 +67,9 @@ class _CropOverlayPainter extends CustomPainter {
if (showGrid) { if (showGrid) {
_drawGrid(canvas, cropRect); _drawGrid(canvas, cropRect);
} }
// Dessiner le point central (croix)
_drawCenterPoint(canvas, cropRect);
} }
void _drawCorners(Canvas canvas, Rect rect) { void _drawCorners(Canvas canvas, Rect rect) {
@@ -171,6 +164,38 @@ class _CropOverlayPainter extends CustomPainter {
); );
} }
void _drawCenterPoint(Canvas canvas, Rect rect) {
final centerPaint = Paint()
..color = Colors.white.withValues(alpha: 0.8)
..style = PaintingStyle.stroke
..strokeWidth = 2;
const size = 10.0;
final centerX = rect.center.dx;
final centerY = rect.center.dy;
// Ligne horizontale
canvas.drawLine(
Offset(centerX - size, centerY),
Offset(centerX + size, centerY),
centerPaint,
);
// Ligne verticale
canvas.drawLine(
Offset(centerX, centerY - size),
Offset(centerX, centerY + size),
centerPaint,
);
// Petit cercle central pour précision (optionnel, mais aide à viser)
canvas.drawCircle(
rect.center,
2,
Paint()..color = Colors.red.withValues(alpha: 0.6),
);
}
@override @override
bool shouldRepaint(covariant _CropOverlayPainter oldDelegate) { bool shouldRepaint(covariant _CropOverlayPainter oldDelegate) {
return cropSize != oldDelegate.cropSize || showGrid != oldDelegate.showGrid; return cropSize != oldDelegate.cropSize || showGrid != oldDelegate.showGrid;

View File

@@ -0,0 +1,414 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:intl/intl.dart';
import '../../core/constants/app_constants.dart';
import '../../core/theme/app_theme.dart';
import '../../data/models/weapon.dart';
import '../../data/models/maintenance.dart';
import '../../data/repositories/session_repository.dart';
class WeaponDetailScreen extends StatefulWidget {
final Weapon weapon;
const WeaponDetailScreen({super.key, required this.weapon});
@override
State<WeaponDetailScreen> createState() => _WeaponDetailScreenState();
}
class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
late Weapon _weapon;
List<MaintenanceEntry> _maintenance = [];
int _totalRounds = 0;
bool _isLoading = true;
@override
void initState() {
super.initState();
_weapon = widget.weapon;
_loadData();
}
Future<void> _loadData() async {
final repository = context.read<SessionRepository>();
final history = await repository.getMaintenanceHistory(_weapon.id);
final rounds = await repository.getRoundsFiredForWeapon(_weapon.id);
if (mounted) {
setState(() {
_maintenance = history;
_totalRounds = rounds;
_isLoading = false;
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(_weapon.customName ?? _weapon.name),
actions: [
IconButton(
icon: const Icon(Icons.edit),
onPressed: () => _showEditWeaponDialog(context),
),
],
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: SingleChildScrollView(
padding: const EdgeInsets.all(AppConstants.defaultPadding),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildStatsSection(),
const SizedBox(height: 24),
_buildInfoCard(),
const SizedBox(height: 24),
_buildOptionsCard(),
const SizedBox(height: 24),
_buildHistorySection(),
],
),
),
floatingActionButton: FloatingActionButton.extended(
onPressed: () => _showAddMaintenanceDialog(context),
label: const Text('Entretien'),
icon: const Icon(Icons.build),
),
);
}
Widget _buildStatsSection() {
return Row(
children: [
Expanded(
child: _buildStatCard(
'Total Tirs',
_totalRounds.toString(),
Icons.ads_click,
AppTheme.primaryColor,
),
),
const SizedBox(width: 16),
Expanded(
child: _buildStatCard(
'Sessions',
'N/A',
Icons.history,
Colors.orange,
),
),
],
);
}
Widget _buildStatCard(String label, String value, IconData icon, Color color) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
Icon(icon, color: color),
const SizedBox(height: 8),
Text(value, style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
Text(label, style: const TextStyle(fontSize: 12, color: Colors.grey)),
],
),
),
);
}
Widget _buildInfoCard() {
return Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Informations techniques', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
const Divider(),
_buildInfoRow('Modèle', _weapon.name),
_buildInfoRow('Type', _weapon.type.displayName),
_buildInfoRow('Calibre', _weapon.caliber),
_buildInfoRow('Chargeurs', '${_weapon.magazineCount} x ${_weapon.magazineCapacity} coups'),
if (_weapon.notes != null && _weapon.notes!.isNotEmpty) ...[
const SizedBox(height: 8),
const Text('Notes:', style: TextStyle(fontWeight: FontWeight.w500)),
Text(_weapon.notes!, style: const TextStyle(color: Colors.grey)),
],
],
),
),
);
}
Widget _buildOptionsCard() {
return Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Options & Personnalisation', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
const Divider(),
_buildInfoRow('Optique / Lunette', _weapon.optic ?? 'Mire fer'),
_buildInfoRow('Modérateur / Silencieux', _weapon.silencer ?? 'Aucun'),
_buildInfoRow('Détente', _weapon.trigger ?? 'Standard'),
if (_weapon.customName != null)
_buildInfoRow('Surnom', _weapon.customName!),
],
),
),
);
}
Widget _buildInfoRow(String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label, style: const TextStyle(color: Colors.grey)),
Text(value, style: const TextStyle(fontWeight: FontWeight.w500)),
],
),
);
}
Widget _buildHistorySection() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Historique d\'entretien', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18)),
const SizedBox(height: 8),
if (_maintenance.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(vertical: 24.0),
child: Center(child: Text('Aucun entretien enregistré', style: TextStyle(color: Colors.grey))),
)
else
ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: _maintenance.length,
itemBuilder: (context, index) {
final entry = _maintenance[index];
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
leading: CircleAvatar(
backgroundColor: _getMaintenanceColor(entry.type).withValues(alpha: 0.1),
child: Icon(_getMaintenanceIcon(entry.type), color: _getMaintenanceColor(entry.type), size: 20),
),
title: Text(entry.type.displayName),
subtitle: Text(entry.description),
trailing: Text(DateFormat('dd/MM/yy').format(entry.date), style: const TextStyle(fontSize: 12)),
onLongPress: () => _confirmDeleteMaintenance(context, entry),
),
);
},
),
],
);
}
Color _getMaintenanceColor(MaintenanceType type) {
switch (type) {
case MaintenanceType.cleaning: return Colors.blue;
case MaintenanceType.repair: return Colors.red;
case MaintenanceType.inspection: return Colors.green;
case MaintenanceType.upgrade: return Colors.purple;
case MaintenanceType.other: return Colors.grey;
}
}
IconData _getMaintenanceIcon(MaintenanceType type) {
switch (type) {
case MaintenanceType.cleaning: return Icons.opacity;
case MaintenanceType.repair: return Icons.build;
case MaintenanceType.inspection: return Icons.visibility;
case MaintenanceType.upgrade: return Icons.trending_up;
case MaintenanceType.other: return Icons.more_horiz;
}
}
void _showEditWeaponDialog(BuildContext context) async {
final nameController = TextEditingController(text: _weapon.name);
final caliberController = TextEditingController(text: _weapon.caliber);
final magCountController = TextEditingController(text: _weapon.magazineCount.toString());
final magCapController = TextEditingController(text: _weapon.magazineCapacity.toString());
final notesController = TextEditingController(text: _weapon.notes);
final opticController = TextEditingController(text: _weapon.optic);
final silencerController = TextEditingController(text: _weapon.silencer);
final triggerController = TextEditingController(text: _weapon.trigger);
final customNameController = TextEditingController(text: _weapon.customName);
WeaponType selectedType = _weapon.type;
final result = await showDialog<bool>(
context: context,
builder: (context) => StatefulBuilder(
builder: (context, setState) => AlertDialog(
title: const Text('Modifier l\'arme'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: nameController,
decoration: const InputDecoration(labelText: 'Modèle', hintText: 'ex: Glock 17'),
),
TextField(
controller: customNameController,
decoration: const InputDecoration(labelText: 'Surnom / Custom Name', hintText: 'ex: Mon Glock de Compète'),
),
DropdownButtonFormField<WeaponType>(
value: selectedType,
decoration: const InputDecoration(labelText: 'Type'),
items: WeaponType.values.map((t) => DropdownMenuItem(value: t, child: Text(t.displayName))).toList(),
onChanged: (v) => setState(() => selectedType = v!),
),
TextField(
controller: caliberController,
decoration: const InputDecoration(labelText: 'Calibre'),
),
const SizedBox(height: 16),
const Text('Équipement & Options', style: TextStyle(fontWeight: FontWeight.bold)),
TextField(
controller: opticController,
decoration: const InputDecoration(labelText: 'Optique / Lunette', hintText: 'ex: Holosun 507C'),
),
TextField(
controller: silencerController,
decoration: const InputDecoration(labelText: 'Modérateur / Silencieux'),
),
TextField(
controller: triggerController,
decoration: const InputDecoration(labelText: 'Détente / Trigger'),
),
const SizedBox(height: 16),
const Text('Logistique', style: TextStyle(fontWeight: FontWeight.bold)),
Row(
children: [
Expanded(
child: TextField(
controller: magCountController,
decoration: const InputDecoration(labelText: 'Chargeurs'),
keyboardType: TextInputType.number,
),
),
const SizedBox(width: 16),
Expanded(
child: TextField(
controller: magCapController,
decoration: const InputDecoration(labelText: 'Capacité'),
keyboardType: TextInputType.number,
),
),
],
),
TextField(
controller: notesController,
decoration: const InputDecoration(labelText: 'Notes'),
maxLines: 2,
),
],
),
),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Annuler')),
ElevatedButton(onPressed: () => Navigator.pop(context, true), child: const Text('Enregistrer')),
],
),
),
);
if (result == true) {
final updatedWeapon = _weapon.copyWith(
name: nameController.text,
type: selectedType,
caliber: caliberController.text,
magazineCount: int.tryParse(magCountController.text) ?? 1,
magazineCapacity: int.tryParse(magCapController.text) ?? 10,
notes: notesController.text,
optic: opticController.text.isEmpty ? null : opticController.text,
silencer: silencerController.text.isEmpty ? null : silencerController.text,
trigger: triggerController.text.isEmpty ? null : triggerController.text,
customName: customNameController.text.isEmpty ? null : customNameController.text,
);
final repository = context.read<SessionRepository>();
await repository.updateWeapon(updatedWeapon);
setState(() {
_weapon = updatedWeapon;
});
}
}
void _showAddMaintenanceDialog(BuildContext context) async {
final descController = TextEditingController();
MaintenanceType selectedType = MaintenanceType.cleaning;
final result = await showDialog<bool>(
context: context,
builder: (context) => StatefulBuilder(
builder: (context, setState) => AlertDialog(
title: const Text('Ajouter un entretien'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
DropdownButtonFormField<MaintenanceType>(
value: selectedType,
decoration: const InputDecoration(labelText: 'Type d\'intervention'),
items: MaintenanceType.values.map((t) => DropdownMenuItem(value: t, child: Text(t.displayName))).toList(),
onChanged: (v) => setState(() => selectedType = v!),
),
TextField(
controller: descController,
decoration: const InputDecoration(labelText: 'Description', hintText: 'ex: Nettoyage complet après séance'),
maxLines: 2,
),
],
),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Annuler')),
ElevatedButton(onPressed: () => Navigator.pop(context, true), child: const Text('Ajouter')),
],
),
),
);
if (result == true && descController.text.isNotEmpty) {
final repository = context.read<SessionRepository>();
await repository.addMaintenanceEntry(
weaponId: _weapon.id,
type: selectedType,
description: descController.text,
roundsSinceLast: _totalRounds,
);
_loadData();
}
}
void _confirmDeleteMaintenance(BuildContext context, MaintenanceEntry entry) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Supprimer'),
content: const Text('Supprimer cette entrée de l\'historique ?'),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Annuler')),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Supprimer', style: TextStyle(color: AppTheme.errorColor)),
),
],
),
);
if (confirmed == true) {
final repository = context.read<SessionRepository>();
await repository.deleteMaintenanceEntry(entry.id);
_loadData();
}
}
}

View File

@@ -0,0 +1,208 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../core/constants/app_constants.dart';
import '../../core/theme/app_theme.dart';
import '../../data/models/weapon.dart';
import '../../data/repositories/session_repository.dart';
import 'weapon_detail_screen.dart';
class WeaponListScreen extends StatefulWidget {
const WeaponListScreen({super.key});
@override
State<WeaponListScreen> createState() => _WeaponListScreenState();
}
class _WeaponListScreenState extends State<WeaponListScreen> {
List<Weapon> _weapons = [];
bool _isLoading = true;
@override
void initState() {
super.initState();
_loadWeapons();
}
Future<void> _loadWeapons() async {
final repository = context.read<SessionRepository>();
final weapons = await repository.getWeapons();
if (mounted) {
setState(() {
_weapons = weapons;
_isLoading = false;
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Mon Armurerie'),
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: _weapons.isEmpty
? _buildEmptyState()
: _buildWeaponList(),
floatingActionButton: FloatingActionButton(
onPressed: () => _showAddWeaponDialog(context),
child: const Icon(Icons.add),
),
);
}
Widget _buildEmptyState() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.shield, size: 64, color: Colors.grey[400]),
const SizedBox(height: 16),
const Text('Aucune arme enregistrée'),
const SizedBox(height: 24),
ElevatedButton(
onPressed: () => _showAddWeaponDialog(context),
child: const Text('Ajouter ma première arme'),
),
],
),
);
}
Widget _buildWeaponList() {
return ListView.builder(
padding: const EdgeInsets.all(AppConstants.defaultPadding),
itemCount: _weapons.length,
itemBuilder: (context, index) {
final weapon = _weapons[index];
return Card(
margin: const EdgeInsets.only(bottom: 12),
child: ListTile(
leading: CircleAvatar(
backgroundColor: AppTheme.primaryColor.withValues(alpha: 0.1),
child: Icon(
weapon.type == WeaponType.handgun ? Icons.shield : Icons.ads_click,
color: AppTheme.primaryColor,
),
),
title: Text(weapon.displayName, style: const TextStyle(fontWeight: FontWeight.bold)),
subtitle: Text('${weapon.type.displayName}${weapon.caliber}'),
trailing: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text('${weapon.magazineCount} chargeurs', style: const TextStyle(fontSize: 12)),
Text('${weapon.magazineCapacity} coups', style: const TextStyle(fontSize: 12, color: Colors.grey)),
],
),
onTap: () async {
await Navigator.push(
context,
MaterialPageRoute(builder: (_) => WeaponDetailScreen(weapon: weapon)),
);
_loadWeapons(); // Reload in case it was edited or maintenance was added
},
onLongPress: () => _confirmDelete(context, weapon),
),
);
},
);
}
void _showAddWeaponDialog(BuildContext context) async {
final nameController = TextEditingController();
final caliberController = TextEditingController();
final magCountController = TextEditingController(text: '2');
final magCapController = TextEditingController(text: '15');
WeaponType selectedType = WeaponType.handgun;
final result = await showDialog<bool>(
context: context,
builder: (context) => StatefulBuilder(
builder: (context, setState) => AlertDialog(
title: const Text('Ajouter une arme'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: nameController,
decoration: const InputDecoration(labelText: 'Nom de l\'arme', hintText: 'ex: Glock 17'),
),
DropdownButtonFormField<WeaponType>(
value: selectedType,
decoration: const InputDecoration(labelText: 'Type'),
items: WeaponType.values.map((t) => DropdownMenuItem(value: t, child: Text(t.displayName))).toList(),
onChanged: (v) => setState(() => selectedType = v!),
),
TextField(
controller: caliberController,
decoration: const InputDecoration(labelText: 'Calibre', hintText: 'ex: 9mm, .22LR'),
),
Row(
children: [
Expanded(
child: TextField(
controller: magCountController,
decoration: const InputDecoration(labelText: 'Chargeurs'),
keyboardType: TextInputType.number,
),
),
const SizedBox(width: 16),
Expanded(
child: TextField(
controller: magCapController,
decoration: const InputDecoration(labelText: 'Capacité'),
keyboardType: TextInputType.number,
),
),
],
),
],
),
),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Annuler')),
ElevatedButton(onPressed: () => Navigator.pop(context, true), child: const Text('Ajouter')),
],
),
),
);
if (result == true && nameController.text.isNotEmpty) {
final repository = context.read<SessionRepository>();
await repository.addWeapon(
name: nameController.text,
type: selectedType,
caliber: caliberController.text,
magazineCount: int.tryParse(magCountController.text) ?? 1,
magazineCapacity: int.tryParse(magCapController.text) ?? 10,
);
_loadWeapons();
}
}
void _confirmDelete(BuildContext context, Weapon weapon) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Supprimer'),
content: Text('Voulez-vous supprimer ${weapon.name} de votre armurerie ?'),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Annuler')),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Supprimer', style: TextStyle(color: AppTheme.errorColor)),
),
],
),
);
if (confirmed == true) {
final repository = context.read<SessionRepository>();
await repository.deleteWeapon(weapon.id);
_loadWeapons();
}
}
}

View File

@@ -1,10 +1,3 @@
/// Écran d'historique des sessions.
///
/// Affiche la liste des sessions passées avec filtrage par type de cible.
/// Inclut un graphique d'évolution des scores (10 dernières sessions)
/// et permet la navigation vers le détail de chaque session.
library;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
@@ -29,6 +22,9 @@ class _HistoryScreenState extends State<HistoryScreen> {
bool _isLoading = true; bool _isLoading = true;
TargetType? _filterType; TargetType? _filterType;
// --- MODIFICATION : Remplacement de DateTime par DateTimeRange ---
DateTimeRange? _selectedDateRange;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -36,17 +32,43 @@ class _HistoryScreenState extends State<HistoryScreen> {
} }
Future<void> _loadSessions() async { Future<void> _loadSessions() async {
if (!mounted) return;
setState(() => _isLoading = true); setState(() => _isLoading = true);
try { try {
final repository = context.read<SessionRepository>(); final repository = context.read<SessionRepository>();
final sessions = await repository.getAllSessions( final sessions = await repository.getAllSessions();
targetType: _filterType,
);
if (mounted) { if (mounted) {
setState(() { setState(() {
_sessions = sessions; _sessions = sessions;
// --- LOGIQUE DE FILTRAGE PAR PÉRIODE ---
if (_selectedDateRange != null) {
_sessions = _sessions.where((s) {
// On compare uniquement les dates (sans les heures) pour éviter les bugs
final sessionDate = DateTime(
s.createdAt.year,
s.createdAt.month,
s.createdAt.day,
);
final startDate = DateTime(
_selectedDateRange!.start.year,
_selectedDateRange!.start.month,
_selectedDateRange!.start.day,
);
final endDate = DateTime(
_selectedDateRange!.end.year,
_selectedDateRange!.end.month,
_selectedDateRange!.end.day,
);
return sessionDate.isAtSameMomentAs(startDate) ||
sessionDate.isAtSameMomentAs(endDate) ||
(sessionDate.isAfter(startDate) &&
sessionDate.isBefore(endDate));
}).toList();
}
_isLoading = false; _isLoading = false;
}); });
} }
@@ -55,7 +77,7 @@ class _HistoryScreenState extends State<HistoryScreen> {
setState(() => _isLoading = false); setState(() => _isLoading = false);
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text('Erreur de chargement: $e'), content: Text('Erreur: $e'),
backgroundColor: AppTheme.errorColor, backgroundColor: AppTheme.errorColor,
), ),
); );
@@ -63,6 +85,40 @@ class _HistoryScreenState extends State<HistoryScreen> {
} }
} }
// --- MODIFICATION : Fonction DateRangePicker ---
Future<void> _pickDateRange() async {
final DateTimeRange? picked = await showDateRangePicker(
context: context,
initialDateRange: _selectedDateRange,
firstDate: DateTime(2020),
lastDate: DateTime.now().add(const Duration(days: 1)),
locale: const Locale('fr', 'FR'),
builder: (context, child) {
return Theme(
data: ThemeData.light().copyWith(
colorScheme: ColorScheme.light(
primary: AppTheme.primaryColor, // En-tête et sélection
onPrimary: Colors.white, // Texte sur en-tête/sélection
surface: Colors.white, // Fond du calendrier
onSurface: Colors.black87, // Texte des dates (Noir sur Blanc)
secondary: AppTheme.primaryColor,
),
dialogBackgroundColor: Colors.white,
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(foregroundColor: AppTheme.primaryColor),
),
),
child: child!,
);
},
);
if (picked != null) {
setState(() => _selectedDateRange = picked);
_loadSessions();
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
@@ -71,56 +127,87 @@ class _HistoryScreenState extends State<HistoryScreen> {
actions: [ actions: [
PopupMenuButton<TargetType?>( PopupMenuButton<TargetType?>(
icon: const Icon(Icons.filter_list), icon: const Icon(Icons.filter_list),
tooltip: 'Filtrer',
onSelected: (type) { onSelected: (type) {
setState(() => _filterType = type); setState(() => _filterType = type);
_loadSessions(); _loadSessions();
}, },
itemBuilder: (context) => [ itemBuilder: (context) => [
const PopupMenuItem( const PopupMenuItem(value: null, child: Text('Tous')),
value: null, ...TargetType.values.map(
child: Text('Tous'), (type) =>
PopupMenuItem(value: type, child: Text(type.displayName)),
), ),
...TargetType.values.map((type) => PopupMenuItem(
value: type,
child: Text(type.displayName),
)),
], ],
), ),
], ],
), ),
body: _isLoading body: Column(
children: [
Expanded(
child: _isLoading
? const Center(child: CircularProgressIndicator()) ? const Center(child: CircularProgressIndicator())
: _sessions.isEmpty : _sessions.isEmpty
? _buildEmptyState() ? _buildEmptyState()
: _buildContent(), : _buildContent(),
),
_buildBottomFilterBar(),
],
),
);
}
Widget _buildBottomFilterBar() {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: Theme.of(context).cardColor,
boxShadow: const [
BoxShadow(
color: Colors.black26,
blurRadius: 4,
offset: Offset(0, -2),
),
],
),
child: SafeArea(
top: false,
child: Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: _pickDateRange,
icon: const Icon(Icons.date_range, size: 18),
label: Text(
_selectedDateRange == null
? 'Choisir une période'
: '${DateFormat('dd/MM/yy').format(_selectedDateRange!.start)} - ${DateFormat('dd/MM/yy').format(_selectedDateRange!.end)}',
),
),
),
if (_selectedDateRange != null)
IconButton(
icon: const Icon(Icons.close, color: AppTheme.errorColor),
onPressed: () {
setState(() => _selectedDateRange = null);
_loadSessions();
},
),
],
),
),
); );
} }
Widget _buildEmptyState() { Widget _buildEmptyState() {
return Center( return Center(
child: Padding(
padding: const EdgeInsets.all(AppConstants.defaultPadding),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon(Icons.history, size: 64, color: Colors.grey[400]), Icon(Icons.history, size: 64, color: Colors.grey[400]),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( const Text('Aucune session sur cette période'),
'Aucune session',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(
_filterType != null
? 'Aucune session de type ${_filterType!.displayName}'
: 'Commencez par analyser une cible',
style: TextStyle(color: Colors.grey[600]),
textAlign: TextAlign.center,
),
], ],
), ),
),
); );
} }
@@ -129,37 +216,17 @@ class _HistoryScreenState extends State<HistoryScreen> {
onRefresh: _loadSessions, onRefresh: _loadSessions,
child: CustomScrollView( child: CustomScrollView(
slivers: [ slivers: [
// Chart section if (_sessions.length >= 2 && _selectedDateRange == null)
if (_sessions.length >= 2)
SliverToBoxAdapter( SliverToBoxAdapter(
child: Padding( child: Padding(
padding: const EdgeInsets.all(AppConstants.defaultPadding), padding: const EdgeInsets.all(AppConstants.defaultPadding),
child: HistoryChart(sessions: _sessions), child: HistoryChart(sessions: _sessions),
), ),
), ),
// Filter indicator
if (_filterType != null)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: AppConstants.defaultPadding),
child: Chip(
label: Text('Filtre: ${_filterType!.displayName}'),
deleteIcon: const Icon(Icons.close, size: 18),
onDeleted: () {
setState(() => _filterType = null);
_loadSessions();
},
),
),
),
// Sessions list
SliverPadding( SliverPadding(
padding: const EdgeInsets.all(AppConstants.defaultPadding), padding: const EdgeInsets.all(AppConstants.defaultPadding),
sliver: SliverList( sliver: SliverList(
delegate: SliverChildBuilderDelegate( delegate: SliverChildBuilderDelegate((context, index) {
(context, index) {
final session = _sessions[index]; final session = _sessions[index];
return Padding( return Padding(
padding: const EdgeInsets.only(bottom: 12), padding: const EdgeInsets.only(bottom: 12),
@@ -169,9 +236,7 @@ class _HistoryScreenState extends State<HistoryScreen> {
onDelete: () => _deleteSession(session), onDelete: () => _deleteSession(session),
), ),
); );
}, }, childCount: _sessions.length),
childCount: _sessions.length,
),
), ),
), ),
], ],
@@ -182,11 +247,9 @@ class _HistoryScreenState extends State<HistoryScreen> {
void _openSessionDetail(Session session) async { void _openSessionDetail(Session session) async {
await Navigator.push( await Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(builder: (_) => SessionDetailScreen(session: session)),
builder: (_) => SessionDetailScreen(session: session),
),
); );
_loadSessions(); // Refresh in case session was deleted _loadSessions();
} }
Future<void> _deleteSession(Session session) async { Future<void> _deleteSession(Session session) async {
@@ -204,7 +267,10 @@ class _HistoryScreenState extends State<HistoryScreen> {
), ),
TextButton( TextButton(
onPressed: () => Navigator.pop(context, true), onPressed: () => Navigator.pop(context, true),
child: const Text('Supprimer', style: TextStyle(color: AppTheme.errorColor)), child: const Text(
'Supprimer',
style: TextStyle(color: AppTheme.errorColor),
),
), ),
], ],
), ),
@@ -215,20 +281,8 @@ class _HistoryScreenState extends State<HistoryScreen> {
final repository = context.read<SessionRepository>(); final repository = context.read<SessionRepository>();
await repository.deleteSession(session.id); await repository.deleteSession(session.id);
_loadSessions(); _loadSessions();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Session supprimee')),
);
}
} catch (e) { } catch (e) {
if (mounted) { debugPrint('Erreur suppression: $e');
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur: $e'),
backgroundColor: AppTheme.errorColor,
),
);
}
} }
} }
} }

View File

@@ -1,10 +1,3 @@
/// Écran de détail d'une session.
///
/// Affiche la visualisation complète d'une session sauvegardée :
/// image de la cible avec overlay des impacts, scores recalculés,
/// statistiques de groupement et lien vers les statistiques détaillées.
library;
import 'dart:io'; import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@@ -12,6 +5,8 @@ import 'package:intl/intl.dart';
import '../../core/constants/app_constants.dart'; import '../../core/constants/app_constants.dart';
import '../../core/theme/app_theme.dart'; import '../../core/theme/app_theme.dart';
import '../../data/models/session.dart'; import '../../data/models/session.dart';
import '../../data/models/target_analysis.dart';
import '../../data/models/target_type.dart';
import '../../data/repositories/session_repository.dart'; import '../../data/repositories/session_repository.dart';
import '../../services/score_calculator_service.dart'; import '../../services/score_calculator_service.dart';
import '../../services/grouping_analyzer_service.dart'; import '../../services/grouping_analyzer_service.dart';
@@ -20,7 +15,7 @@ import '../analysis/widgets/score_card.dart';
import '../analysis/widgets/grouping_stats.dart'; import '../analysis/widgets/grouping_stats.dart';
import '../statistics/statistics_screen.dart'; import '../statistics/statistics_screen.dart';
class SessionDetailScreen extends StatelessWidget { class SessionDetailScreen extends StatefulWidget {
final Session session; final Session session;
const SessionDetailScreen({ const SessionDetailScreen({
@@ -28,33 +23,40 @@ class SessionDetailScreen extends StatelessWidget {
required this.session, required this.session,
}); });
@override
State<SessionDetailScreen> createState() => _SessionDetailScreenState();
}
class _SessionDetailScreenState extends State<SessionDetailScreen> {
int _currentTargetIndex = 0;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final scoreCalculator = context.read<ScoreCalculatorService>(); final scoreCalculator = context.read<ScoreCalculatorService>();
final groupingAnalyzer = context.read<GroupingAnalyzerService>(); final groupingAnalyzer = context.read<GroupingAnalyzerService>();
final currentAnalysis = widget.session.analyses[_currentTargetIndex];
final scoreResult = scoreCalculator.calculateScores( final scoreResult = scoreCalculator.calculateScores(
shots: session.shots, shots: currentAnalysis.shots,
targetType: session.targetType, targetType: currentAnalysis.targetType,
targetCenterX: session.targetCenterX ?? 0.5, targetCenterX: currentAnalysis.targetCenterX ?? 0.5,
targetCenterY: session.targetCenterY ?? 0.5, targetCenterY: currentAnalysis.targetCenterY ?? 0.5,
targetRadius: session.targetRadius ?? 0.4, targetRadius: currentAnalysis.targetRadius ?? 0.4,
); );
final groupingResult = groupingAnalyzer.analyzeGrouping(session.shots); final groupingResult = groupingAnalyzer.analyzeGrouping(currentAnalysis.shots);
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text( title: Text(widget.session.weapon),
DateFormat('dd/MM/yyyy HH:mm').format(session.createdAt),
),
actions: [ actions: [
IconButton( IconButton(
icon: const Icon(Icons.analytics), icon: const Icon(Icons.analytics),
onPressed: () => Navigator.push( onPressed: () => Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (_) => StatisticsScreen(singleSession: session), builder: (_) => StatisticsScreen(singleSession: widget.session),
), ),
), ),
tooltip: 'Statistiques', tooltip: 'Statistiques',
@@ -70,15 +72,22 @@ class SessionDetailScreen extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
// Weapon and session overview
_buildSessionHeader(context),
// Target selector if multiple targets
if (widget.session.targetCount > 1)
_buildTargetSelector(),
// Target image with overlay // Target image with overlay
AspectRatio( AspectRatio(
aspectRatio: 1, aspectRatio: 1,
child: Stack( child: Stack(
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
if (File(session.imagePath).existsSync()) if (File(currentAnalysis.imagePath).existsSync())
Image.file( Image.file(
File(session.imagePath), File(currentAnalysis.imagePath),
fit: BoxFit.contain, fit: BoxFit.contain,
) )
else else
@@ -89,14 +98,14 @@ class SessionDetailScreen extends StatelessWidget {
), ),
), ),
TargetOverlay( TargetOverlay(
shots: session.shots, shots: currentAnalysis.shots,
targetCenterX: session.targetCenterX ?? 0.5, targetCenterX: currentAnalysis.targetCenterX ?? 0.5,
targetCenterY: session.targetCenterY ?? 0.5, targetCenterY: currentAnalysis.targetCenterY ?? 0.5,
targetRadius: session.targetRadius ?? 0.4, targetRadius: currentAnalysis.targetRadius ?? 0.4,
targetType: session.targetType, targetType: currentAnalysis.targetType,
groupingCenterX: session.groupingCenterX, groupingCenterX: currentAnalysis.groupingCenterX,
groupingCenterY: session.groupingCenterY, groupingCenterY: currentAnalysis.groupingCenterY,
groupingDiameter: session.groupingDiameter, groupingDiameter: currentAnalysis.groupingDiameter,
), ),
], ],
), ),
@@ -107,31 +116,37 @@ class SessionDetailScreen extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Session info // Analysis Info
_buildSessionInfo(context), _buildAnalysisInfo(context, currentAnalysis),
const SizedBox(height: 12), const SizedBox(height: 12),
// Score card // Score card for current target
ScoreCard( ScoreCard(
totalScore: session.totalScore, totalScore: currentAnalysis.totalScore,
shotCount: session.shotCount, shotCount: currentAnalysis.shotCount,
scoreResult: scoreResult, scoreResult: scoreResult,
targetType: session.targetType, targetType: currentAnalysis.targetType,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
// Grouping stats // Grouping stats for current target
if (session.shotCount > 1) if (currentAnalysis.shotCount > 1)
GroupingStats( GroupingStats(
groupingResult: groupingResult, groupingResult: groupingResult,
targetCenterX: session.targetCenterX ?? 0.5, targetCenterX: currentAnalysis.targetCenterX ?? 0.5,
targetCenterY: session.targetCenterY ?? 0.5, targetCenterY: currentAnalysis.targetCenterY ?? 0.5,
), ),
// Notes // Notes for current target
if (session.notes != null && session.notes!.isNotEmpty) ...[ if (currentAnalysis.notes != null && currentAnalysis.notes!.isNotEmpty) ...[
const SizedBox(height: 12), const SizedBox(height: 12),
_buildNotesCard(context), _buildNotesCard(context, currentAnalysis.notes!),
],
// Session notes
if (widget.session.notes != null && widget.session.notes!.isNotEmpty) ...[
const SizedBox(height: 12),
_buildNotesCard(context, widget.session.notes!, title: 'Notes de session'),
], ],
], ],
), ),
@@ -142,16 +157,83 @@ class SessionDetailScreen extends StatelessWidget {
); );
} }
Widget _buildSessionInfo(BuildContext context) { Widget _buildSessionHeader(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
color: AppTheme.primaryColor.withValues(alpha: 0.1),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
DateFormat('EEEE dd MMMM yyyy', 'fr_FR').format(widget.session.createdAt),
style: const TextStyle(fontWeight: FontWeight.bold),
),
Text(
DateFormat('HH:mm').format(widget.session.createdAt),
style: Theme.of(context).textTheme.bodySmall,
),
],
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'Score Total: ${widget.session.totalScore}',
style: const TextStyle(fontWeight: FontWeight.bold, color: AppTheme.primaryColor),
),
Text(
'${widget.session.totalShots} tirs au total',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
],
),
],
),
);
}
Widget _buildTargetSelector() {
return Container(
height: 50,
margin: const EdgeInsets.symmetric(vertical: 8),
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: widget.session.targetCount,
itemBuilder: (context, index) {
final isSelected = _currentTargetIndex == index;
return Padding(
padding: const EdgeInsets.only(right: 8),
child: ChoiceChip(
label: Text('Cible ${index + 1}'),
selected: isSelected,
onSelected: (selected) {
if (selected) {
setState(() => _currentTargetIndex = index);
}
},
),
);
},
),
);
}
Widget _buildAnalysisInfo(BuildContext context, TargetAnalysis analysis) {
return Card( return Card(
child: Padding( child: Padding(
padding: const EdgeInsets.all(AppConstants.defaultPadding), padding: const EdgeInsets.all(12),
child: Row( child: Row(
children: [ children: [
Icon( Icon(
session.targetType == session.targetType analysis.targetType == TargetType.concentric ? Icons.track_changes : Icons.person,
? Icons.track_changes
: Icons.person,
color: AppTheme.primaryColor, color: AppTheme.primaryColor,
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
@@ -160,14 +242,11 @@ class SessionDetailScreen extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
session.targetType.displayName, analysis.targetType.displayName,
style: Theme.of(context).textTheme.titleMedium?.copyWith( style: const TextStyle(fontWeight: FontWeight.bold),
fontWeight: FontWeight.bold,
),
), ),
Text( Text(
DateFormat('EEEE dd MMMM yyyy, HH:mm', 'fr_FR') 'Cible ${_currentTargetIndex + 1} sur ${widget.session.targetCount}',
.format(session.createdAt),
style: Theme.of(context).textTheme.bodySmall, style: Theme.of(context).textTheme.bodySmall,
), ),
], ],
@@ -179,7 +258,7 @@ class SessionDetailScreen extends StatelessWidget {
); );
} }
Widget _buildNotesCard(BuildContext context) { Widget _buildNotesCard(BuildContext context, String notes, {String title = 'Notes'}) {
return Card( return Card(
child: Padding( child: Padding(
padding: const EdgeInsets.all(AppConstants.defaultPadding), padding: const EdgeInsets.all(AppConstants.defaultPadding),
@@ -188,18 +267,16 @@ class SessionDetailScreen extends StatelessWidget {
children: [ children: [
Row( Row(
children: [ children: [
const Icon(Icons.notes, color: AppTheme.primaryColor), const Icon(Icons.notes, color: AppTheme.primaryColor, size: 20),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
'Notes', title,
style: Theme.of(context).textTheme.titleMedium?.copyWith( style: const TextStyle(fontWeight: FontWeight.bold),
fontWeight: FontWeight.bold,
),
), ),
], ],
), ),
const Divider(), const Divider(),
Text(session.notes!), Text(notes),
], ],
), ),
), ),
@@ -211,7 +288,7 @@ class SessionDetailScreen extends StatelessWidget {
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
title: const Text('Supprimer'), title: const Text('Supprimer'),
content: const Text('Voulez-vous vraiment supprimer cette session?'), content: const Text('Voulez-vous vraiment supprimer cette session entière ?'),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(context, false), onPressed: () => Navigator.pop(context, false),
@@ -231,20 +308,17 @@ class SessionDetailScreen extends StatelessWidget {
if (confirmed == true && context.mounted) { if (confirmed == true && context.mounted) {
try { try {
final repository = context.read<SessionRepository>(); final repository = context.read<SessionRepository>();
await repository.deleteSession(session.id); await repository.deleteSession(widget.session.id);
if (context.mounted) { if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Session supprimee')), const SnackBar(content: Text('Session supprimée')),
); );
Navigator.pop(context); Navigator.pop(context);
} }
} catch (e) { } catch (e) {
if (context.mounted) { if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(content: Text('Erreur: $e'), backgroundColor: AppTheme.errorColor),
content: Text('Erreur: $e'),
backgroundColor: AppTheme.errorColor,
),
); );
} }
} }

View File

@@ -155,7 +155,7 @@ class HistoryChart extends StatelessWidget {
return touchedSpots.map((spot) { return touchedSpots.map((spot) {
final session = displaySessions[spot.x.toInt()]; final session = displaySessions[spot.x.toInt()];
return LineTooltipItem( return LineTooltipItem(
'Score: ${session.totalScore}\n${session.shotCount} tirs', 'Score: ${session.totalScore}\n${session.totalShots} tirs',
const TextStyle( const TextStyle(
color: Colors.white, color: Colors.white,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,

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