49 Commits

Author SHA1 Message Date
qguillaume
1e27d01f17 fix: on enleve les 85% qui rognent puisqu on utilise mlkit et non le cadre vert 2026-06-07 01:06:11 +02:00
qguillaume
8dd78f6b80 feat: test effacer partie overlay 2026-06-06 11:22:53 +02:00
qguillaume
23c3bb178f feat: reintegration (en cours) de ml kit lecteur de doc 2026-06-06 01:32:16 +02:00
qguillaume
18e591f3fc fix: legere baisse resolution pour augmenter fluidité de l appli 2026-06-06 00:26:38 +02:00
qguillaume
13cf5b70e0 fix: retour arriere leger zoom du au 85% crop 2026-06-05 23:55:43 +02:00
qguillaume
f0b15941cf fix: on revire ml kit on essaie d ameliorer la detection des cercles 2026-06-05 23:05:11 +02:00
qguillaume
9f9f9beb84 feat: target rectify service pour perfectionner la prise de photo avec detection 2026-06-05 22:27:53 +02:00
qguillaume
c49c8b8c73 fix: cadre toujours orange 2026-06-05 22:06:05 +02:00
qguillaume
61b5fcfe22 fix: ajout untrack files 2026-06-05 21:30:59 +02:00
qguillaume
f65c5e6a3f feat: object detection service 2026-06-05 21:18:48 +02:00
qguillaume
d487c98e3d fix: parallelisme service permissif et correctif capture screen 2026-06-05 20:33:04 +02:00
qguillaume
9cb37fc303 feat: construction du service parallelisme 2026-06-05 19:47:18 +02:00
qguillaume
3ae9ceebc1 fix: lenteur de l appli capture screen 2026-06-05 17:38:25 +02:00
qguillaume
251d4bb599 fix: lenteur de l appli 2026-06-05 17:18:29 +02:00
qguillaume
ccc6eb609a fix(analysis): correctif réel du zoom post prise de photo 2026-06-05 07:42:07 +02:00
qguillaume
9e0427f750 fix(analysis): normaliser les coordonnées de la cible pour annuler l'effet du zoom initial 2026-06-04 23:32:38 +02:00
qguillaume
a5d0251545 fix(camera): synchroniser le ratio de visée 3/4 avec le capteur natif et corriger le décalage du crop 2026-05-27 17:44:57 +02:00
qguillaume
730629a031 fix(crop): stabiliser le rectangle géométrique de découpe et l'enchaînement des zooms 2026-05-27 15:36:56 +02:00
qguillaume
4d2ca2c94f refactor(crop_screen): restrict rotation bounds to 15 degrees with high-precision decimal steps 2026-05-27 14:48:57 +02:00
qguillaume
ab45786d0c fix(analysis): appliquer la transformation globale sur le GestureDetector pour restaurer le drag-and-drop 2026-05-27 12:16:35 +02:00
qguillaume
95e62abe47 fix(analysis): appliquer la rotation du crop sur l'image en modes calibration et plotting 2026-05-27 12:11:00 +02:00
qguillaume
04c1204116 fix(calibration): préserver les rayons d'origine de l'IA lors du redimensionnement et corriger le reset 2026-05-27 11:56:07 +02:00
qguillaume
78c9fc96c9 feat(session): ajout du sélecteur de date et persistance dans l'historique de tir 2026-05-27 00:57:26 +02:00
qguillaume
e570bd4296 fix(calibration): application de la rotation et du centrage sur l'image globale 2026-05-27 00:23:48 +02:00
qguillaume
02c0d449e4 fix(calibration): application du crop sur l'écran de calibration 2026-05-27 00:03:29 +02:00
qguillaume
eabe7511bd feat(crop): masque opaque à coins droits pour cacher l'extérieur du cadre de découpe 2026-05-26 23:41:06 +02:00
qguillaume
620e12a1fd feat(crop): ajout de lignes de visée perpendiculaires sur toute la zone pour aligner la cible 2026-05-26 23:36:50 +02:00
qguillaume
622f2936de ui(plotting): décalage visuel de l'impact lors du glisser pour éviter de le cacher avec le doigt 2026-05-26 23:23:45 +02:00
qguillaume
814f778e6c feat(crop): remplacement de la rotation à deux doigts par une jauge précise centrée à zéro 2026-05-26 22:52:59 +02:00
qguillaume
2f493ef622 feat(setup): choix d'arme obligatoire, redirection armurerie et max balles à 50 2026-05-26 22:41:44 +02:00
qguillaume
33e1522df0 reword: Démarrer la session (home_screen) devient Nouvelle session 2026-05-26 22:29:37 +02:00
qguillaume
653264c6a1 Refactor: Amélioration globale de l'expérience de cadrage et de l'interface de capture 2026-05-26 18:09:39 +02:00
qguillaume
750732eafe Feat: Ajout de la rotation à deux doigts de l'image sur l'écran de Crop 2026-05-26 17:07:16 +02:00
qguillaume
f40bfc0ba7 Feat: Ajout du glisser-déposer par appui long pour repositionner les impacts de tir 2026-05-26 17:03:21 +02:00
qguillaume
b721583e98 Feat: Ajout du double-tap sur l'écran d'analyse pour l'édition rapide des impacts 2026-05-26 16:47:37 +02:00
qguillaume
18c01c3767 Style: Optimisation des curseurs de calibration et bridage des limites 2026-05-26 16:40:14 +02:00
qguillaume
0af6a0d8e2 Feat: Bouton de clôture de session (80/20) sur l'accueil et reset de l'espacement 2026-05-26 14:59:25 +02:00
qguillaume
fbd631c8e4 Workflow: Inversion du flux pour calibrer la cible avant d'afficher l'écran de Plotting 2026-05-25 13:52:20 +02:00
qguillaume
3088c5ee91 Refactor: Ajustement des valeurs limites de taille (0.5 à 1.1) pour synchroniser le slider et le geste tactile 2026-05-25 13:14:51 +02:00
qguillaume
fe6d52902b Refactor: Optimisation du range du slider de Taille pour améliorer la précision de calibration 2026-05-25 13:00:04 +02:00
qguillaume
48b8313be8 Feat: Ajout d'un switch pour masquer ou afficher le slider d'espacement de la cible 2026-05-25 03:12:37 +02:00
qguillaume
a9ff826246 Design: Remplacement du point rouge par une mire vert fluo interne dans le viseur 2026-05-25 02:59:46 +02:00
qguillaume
f149fe092b Fix: Passage à l'appareil photo classique et suppression de l'auto-capture 2026-05-25 02:43:31 +02:00
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
streaper2
cea2fab989 branch backend + ajout du serveur backend, next, et sqlite 2026-04-29 15:45:09 +02:00
104 changed files with 17427 additions and 3009 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

BIN
analysis_report.txt Normal file

Binary file not shown.

View File

@@ -37,6 +37,9 @@
<meta-data <meta-data
android:name="flutterEmbedding" android:name="flutterEmbedding"
android:value="2" /> android:value="2" />
<meta-data
android:name="com.google.mlkit.vision.DEPENDENCIES"
android:value="docscanner" />
</application> </application>
<!-- Required to query activities that can process text, see: <!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and https://developer.android.com/training/package-visibility and

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

View File

@@ -1,5 +1,8 @@
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'; import 'package:flutter_localizations/flutter_localizations.dart';
@@ -8,23 +11,26 @@ class BullyApp extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MaterialApp( return Consumer<ThemeProvider>(
title: 'Bully - Analyse de Cibles', builder: (context, themeProvider, child) {
debugShowCheckedModeBanner: false, return MaterialApp(
theme: AppTheme.lightTheme, title: 'Bully - Analyse de Cibles',
darkTheme: AppTheme.darkTheme, debugShowCheckedModeBanner: false,
themeMode: ThemeMode.system, theme: AppTheme.lightTheme,
localizationsDelegates: const [ darkTheme: AppTheme.darkTheme,
GlobalMaterialLocalizations.delegate, themeMode: themeProvider.themeMode,
GlobalWidgetsLocalizations.delegate, localizationsDelegates: const [
GlobalCupertinoLocalizations.delegate, GlobalMaterialLocalizations.delegate,
], GlobalWidgetsLocalizations.delegate,
supportedLocales: const [ GlobalCupertinoLocalizations.delegate,
Locale('fr', 'FR'), // Français ],
], supportedLocales: const [
locale: const Locale('fr', 'FR'), // Force l'interface en français Locale('fr', 'FR'), // Français
],
home: const HomeScreen(), 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,11 @@
import 'dart:io';
import 'package:sqflite/sqflite.dart'; import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart'; import 'package:path/path.dart';
import '../models/session.dart'; import '../models/session.dart';
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 +40,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 +63,8 @@ class DatabaseHelper {
notes TEXT, notes TEXT,
target_center_x REAL, target_center_x REAL,
target_center_y REAL, target_center_y REAL,
target_radius REAL target_radius REAL,
FOREIGN KEY (session_id) REFERENCES ${AppConstants.sessionsTable}(id) ON DELETE CASCADE
) )
'''); ''');
@@ -56,48 +74,262 @@ class DatabaseHelper {
x REAL NOT NULL, x REAL NOT NULL,
y REAL NOT NULL, y REAL NOT NULL,
score INTEGER NOT NULL, score INTEGER NOT NULL,
session_id TEXT NOT NULL, analysis_id TEXT NOT NULL,
FOREIGN KEY (session_id) REFERENCES ${AppConstants.sessionsTable}(id) ON DELETE CASCADE FOREIGN KEY (analysis_id) REFERENCES ${AppConstants.targetAnalysesTable}(id) ON DELETE CASCADE
) )
'''); ''');
await db.execute(''' await db.execute('''
CREATE INDEX idx_shots_session_id ON ${AppConstants.shotsTable}(session_id) CREATE INDEX idx_target_analyses_session_id ON ${AppConstants.targetAnalysesTable}(session_id)
''');
await db.execute('''
CREATE INDEX idx_shots_analysis_id ON ${AppConstants.shotsTable}(analysis_id)
'''); ''');
await db.execute(''' await db.execute('''
CREATE INDEX idx_sessions_created_at ON ${AppConstants.sessionsTable}(created_at DESC) CREATE INDEX idx_sessions_created_at ON ${AppConstants.sessionsTable}(created_at DESC)
'''); ''');
await db.execute('''
CREATE TABLE ${AppConstants.weaponsTable} (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
type TEXT NOT NULL,
caliber TEXT NOT NULL,
magazine_count INTEGER NOT NULL,
magazine_capacity INTEGER NOT NULL,
notes TEXT,
created_at TEXT NOT NULL,
optic TEXT,
silencer TEXT,
trigger TEXT,
custom_name TEXT
)
''');
await db.execute('''
CREATE TABLE ${AppConstants.maintenanceTable} (
id TEXT PRIMARY KEY,
weapon_id TEXT NOT NULL,
type TEXT NOT NULL,
description TEXT NOT NULL,
date TEXT NOT NULL,
rounds_since_last INTEGER,
FOREIGN KEY (weapon_id) REFERENCES ${AppConstants.weaponsTable}(id) ON DELETE CASCADE
)
''');
} }
Future<void> _onUpgrade(Database db, int oldVersion, int newVersion) async { Future<void> _onUpgrade(Database db, int oldVersion, int newVersion) async {
// Handle future database migrations here if (oldVersion < 2) {
// Migration from single-target sessions to multi-target sessions
// 1. Rename existing sessions table to a temporary name
await db.execute('ALTER TABLE ${AppConstants.sessionsTable} RENAME TO sessions_old');
// 2. Create the new sessions table
await db.execute('''
CREATE TABLE ${AppConstants.sessionsTable} (
id TEXT PRIMARY KEY,
weapon TEXT NOT NULL,
max_shots_per_target INTEGER NOT NULL,
created_at TEXT NOT NULL,
notes TEXT
)
''');
// 3. Create the new target_analyses table
await db.execute('''
CREATE TABLE ${AppConstants.targetAnalysesTable} (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
target_type TEXT NOT NULL,
image_path TEXT NOT NULL,
total_score INTEGER NOT NULL,
grouping_diameter REAL,
grouping_center_x REAL,
grouping_center_y REAL,
created_at TEXT NOT NULL,
notes TEXT,
target_center_x REAL,
target_center_y REAL,
target_radius REAL,
FOREIGN KEY (session_id) REFERENCES ${AppConstants.sessionsTable}(id) ON DELETE CASCADE
)
''');
// 4. Update shots table (rename session_id to analysis_id)
await db.execute('ALTER TABLE ${AppConstants.shotsTable} RENAME TO shots_old');
await db.execute('''
CREATE TABLE ${AppConstants.shotsTable} (
id TEXT PRIMARY KEY,
x REAL NOT NULL,
y REAL NOT NULL,
score INTEGER NOT NULL,
analysis_id TEXT NOT NULL,
FOREIGN KEY (analysis_id) REFERENCES ${AppConstants.targetAnalysesTable}(id) ON DELETE CASCADE
)
''');
// 5. Migrate data
// For each old session, create one session and one target_analysis
final oldSessions = await db.query('sessions_old');
for (final old in oldSessions) {
final sessionId = old['id'] as String;
final analysisId = 'analysis_$sessionId'; // Create a unique ID for the analysis
// Insert into new sessions
await db.insert(AppConstants.sessionsTable, {
'id': sessionId,
'weapon': 'Inconnue',
'max_shots_per_target': 5,
'created_at': old['created_at'],
'notes': old['notes'],
});
// Insert into target_analyses
await db.insert(AppConstants.targetAnalysesTable, {
'id': analysisId,
'session_id': sessionId,
'target_type': old['target_type'],
'image_path': old['image_path'],
'total_score': old['total_score'],
'grouping_diameter': old['grouping_diameter'],
'grouping_center_x': old['grouping_center_x'],
'grouping_center_y': old['grouping_center_y'],
'created_at': old['created_at'],
'notes': null,
'target_center_x': old['target_center_x'],
'target_center_y': old['target_center_y'],
'target_radius': old['target_radius'],
});
// Migrate shots from shots_old to shots
final oldShots = await db.query('shots_old', where: 'session_id = ?', whereArgs: [sessionId]);
for (final shot in oldShots) {
await db.insert(AppConstants.shotsTable, {
'id': shot['id'],
'x': shot['x'],
'y': shot['y'],
'score': shot['score'],
'analysis_id': analysisId,
});
}
}
// 6. Cleanup
await db.execute('DROP TABLE sessions_old');
await db.execute('DROP TABLE shots_old');
// 7. Re-create indexes
await db.execute('''
CREATE INDEX idx_target_analyses_session_id ON ${AppConstants.targetAnalysesTable}(session_id)
''');
await db.execute('''
CREATE INDEX idx_shots_analysis_id ON ${AppConstants.shotsTable}(analysis_id)
''');
await db.execute('''
CREATE INDEX idx_sessions_created_at ON ${AppConstants.sessionsTable}(created_at DESC)
''');
}
if (oldVersion < 3) {
// Add weapons table
await db.execute('''
CREATE TABLE ${AppConstants.weaponsTable} (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
type TEXT NOT NULL,
caliber TEXT NOT NULL,
magazine_count INTEGER NOT NULL,
magazine_capacity INTEGER NOT NULL,
notes TEXT,
created_at TEXT NOT NULL
)
''');
// Add weapon_id to sessions table
await db.execute('ALTER TABLE ${AppConstants.sessionsTable} ADD COLUMN weapon_id TEXT');
}
if (oldVersion < 4) {
// Add maintenance table
await db.execute('''
CREATE TABLE ${AppConstants.maintenanceTable} (
id TEXT PRIMARY KEY,
weapon_id TEXT NOT NULL,
type TEXT NOT NULL,
description TEXT NOT NULL,
date TEXT NOT NULL,
rounds_since_last INTEGER,
FOREIGN KEY (weapon_id) REFERENCES ${AppConstants.weaponsTable}(id) ON DELETE CASCADE
)
''');
}
if (oldVersion < 5) {
// Add new weapon options columns
await db.execute('ALTER TABLE ${AppConstants.weaponsTable} ADD COLUMN optic TEXT');
await db.execute('ALTER TABLE ${AppConstants.weaponsTable} ADD COLUMN silencer TEXT');
await db.execute('ALTER TABLE ${AppConstants.weaponsTable} ADD COLUMN "trigger" TEXT');
await db.execute('ALTER TABLE ${AppConstants.weaponsTable} ADD COLUMN custom_name TEXT');
}
if (oldVersion < 6) {
// Add distance column to sessions
await db.execute('ALTER TABLE ${AppConstants.sessionsTable} ADD COLUMN distance INTEGER DEFAULT 25');
}
} }
// Session operations // Session operations
Future<int> insertSession(Session session) async { Future<int> insertSession(Session session) async {
final db = await database; final db = await database;
return await db.transaction((txn) async { return await db.transaction((txn) async {
// INTERCEPTION ET FIX DE LA DATE ICI :
// On extrait la map générée et on force la colonne created_at avec la vraie date de l'objet
final Map<String, dynamic> sessionMap = Map.from(session.toMap());
sessionMap['created_at'] = session.createdAt.toIso8601String();
await txn.insert( await txn.insert(
AppConstants.sessionsTable, AppConstants.sessionsTable,
session.toMap(), sessionMap, // On utilise notre map corrigée au lieu de session.toMap() direct !
conflictAlgorithm: ConflictAlgorithm.replace, conflictAlgorithm: ConflictAlgorithm.replace,
); );
for (final shot in session.shots) { for (final analysis in session.analyses) {
// Ensure shot has correct session_id await insertTargetAnalysisTx(txn, analysis, session.id);
final shotWithSessionId = shot.copyWith(sessionId: session.id);
await txn.insert(
AppConstants.shotsTable,
shotWithSessionId.toMap(),
conflictAlgorithm: ConflictAlgorithm.replace,
);
} }
return 1; return 1;
}); });
} }
Future<void> insertTargetAnalysis(TargetAnalysis analysis, String sessionId) async {
final db = await database;
await db.transaction((txn) async {
await insertTargetAnalysisTx(txn, analysis, sessionId);
});
}
Future<void> insertTargetAnalysisTx(Transaction txn, TargetAnalysis analysis, String sessionId) async {
final analysisWithSessionId = analysis.copyWith(sessionId: sessionId);
await txn.insert(
AppConstants.targetAnalysesTable,
analysisWithSessionId.toMap(),
conflictAlgorithm: ConflictAlgorithm.replace,
);
for (final shot in analysis.shots) {
final shotWithAnalysisId = shot.copyWith(analysisId: analysis.id);
await txn.insert(
AppConstants.shotsTable,
shotWithAnalysisId.toMap(),
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
}
Future<Session?> getSession(String id) async { Future<Session?> getSession(String id) async {
final db = await database; final db = await database;
final sessionMaps = await db.query( final sessionMaps = await db.query(
@@ -108,106 +340,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 shots = shotMaps.map((map) => Shot.fromMap(map)).toList(); final analyses = <TargetAnalysis>[];
return Session.fromMap(sessionMaps.first, shots); 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();
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 +407,12 @@ class DatabaseHelper {
) ?? 0; ) ?? 0;
final avgScore = (await db.rawQuery( final avgScore = (await db.rawQuery(
'SELECT AVG(total_score) as avg FROM ${AppConstants.sessionsTable}', 'SELECT AVG(total_score) as avg FROM ${AppConstants.targetAnalysesTable}',
)).first['avg'] as double? ?? 0.0; )).first['avg'] as double? ?? 0.0;
final bestScore = Sqflite.firstIntValue( final bestScore = Sqflite.firstIntValue(
await db.rawQuery( await db.rawQuery(
'SELECT MAX(total_score) FROM ${AppConstants.sessionsTable}', 'SELECT MAX(total_score) FROM ${AppConstants.targetAnalysesTable}',
), ),
) ?? 0; ) ?? 0;
@@ -246,9 +424,124 @@ 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();
_database = null; _database = null;
} }
} }

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;
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 => shots.isEmpty ? 0.0 : totalScore / shots.length; 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,38 @@ class SessionRepository {
_uuid = uuid ?? const Uuid(); _uuid = uuid ?? const Uuid();
Future<Session> createSession({ Future<Session> createSession({
required TargetType targetType, String? id,
required String weapon,
String? weaponId,
required int maxShotsPerTarget,
required List<TargetAnalysis> analyses,
String? notes,
int distance = 25,
DateTime? date,
}) async {
final session = Session(
id: id ?? _uuid.v4(),
weapon: weapon,
weaponId: weaponId,
maxShotsPerTarget: maxShotsPerTarget,
analyses: analyses,
createdAt: date ?? DateTime.now(),
notes: notes,
distance: distance,
);
await _databaseHelper.insertSession(session);
return session;
}
Future<void> addAnalysisToSession(String sessionId, TargetAnalysis analysis) async {
await _databaseHelper.insertTargetAnalysis(analysis, sessionId);
}
Future<TargetAnalysis> prepareTargetAnalysis({
required String imagePath, required String imagePath,
required String sessionId,
required dynamic targetType, // TargetType
required List<Shot> shots, required List<Shot> shots,
required int totalScore, required int totalScore,
double? groupingDiameter, double? groupingDiameter,
@@ -33,8 +65,9 @@ class SessionRepository {
// Copy image to app documents directory // Copy image to app documents directory
final savedImagePath = await _saveImage(imagePath); final savedImagePath = await _saveImage(imagePath);
final session = Session( return TargetAnalysis(
id: _uuid.v4(), id: _uuid.v4(),
sessionId: sessionId,
targetType: targetType, targetType: targetType,
imagePath: savedImagePath, imagePath: savedImagePath,
shots: shots, shots: shots,
@@ -48,9 +81,6 @@ class SessionRepository {
targetCenterY: targetCenterY, targetCenterY: targetCenterY,
targetRadius: targetRadius, targetRadius: targetRadius,
); );
await _databaseHelper.insertSession(session);
return session;
} }
Future<String> _saveImage(String sourcePath) async { Future<String> _saveImage(String sourcePath) async {
@@ -75,28 +105,23 @@ class SessionRepository {
} }
Future<List<Session>> getAllSessions({ Future<List<Session>> getAllSessions({
TargetType? targetType,
int? limit, int? limit,
int? offset, int? offset,
}) async { }) async {
return await _databaseHelper.getAllSessions( return await _databaseHelper.getAllSessions(
targetType: targetType?.name,
limit: limit, limit: limit,
offset: offset, offset: offset,
); );
} }
Future<void> updateSession(Session session) async {
await _databaseHelper.updateSession(session);
}
Future<void> deleteSession(String id) async { Future<void> deleteSession(String id) async {
final session = await getSession(id); final session = await getSession(id);
if (session != null) { if (session != null) {
// Delete the image file for (final analysis in session.analyses) {
final imageFile = File(session.imagePath); final imageFile = File(analysis.imagePath);
if (await imageFile.exists()) { if (await imageFile.exists()) {
await imageFile.delete(); await imageFile.delete();
}
} }
} }
await _databaseHelper.deleteSession(id); await _databaseHelper.deleteSession(id);
@@ -106,7 +131,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';
@@ -18,6 +18,7 @@ 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/opencv_target_service.dart';
import '../../services/ai_export_service.dart';
enum AnalysisState { initial, loading, success, error } enum AnalysisState { initial, loading, success, error }
@@ -38,17 +39,20 @@ class AnalysisProvider extends ChangeNotifier {
DistortionCorrectionService? distortionService, DistortionCorrectionService? distortionService,
OpenCVTargetService? opencvTargetService, 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(); _opencvTargetService = opencvTargetService ?? OpenCVTargetService();
AnalysisState _state = AnalysisState.initial; AnalysisState _state = AnalysisState.initial;
String? _errorMessage; String? _errorMessage;
String? _imagePath; String? _imagePath;
TargetType? _targetType; TargetType? _targetType;
// AJOUT PROTECTION DU PLOTTING : Stockage permanent de la rotation du Crop
double _cropRotation = 0.0;
// Target detection results // Target detection results
double _targetCenterX = 0.5; double _targetCenterX = 0.5;
double _targetCenterY = 0.5; double _targetCenterY = 0.5;
@@ -81,6 +85,7 @@ class AnalysisProvider extends ChangeNotifier {
String? get errorMessage => _errorMessage; String? get errorMessage => _errorMessage;
String? get imagePath => _imagePath; String? get imagePath => _imagePath;
TargetType? get targetType => _targetType; TargetType? get targetType => _targetType;
double get cropRotation => _cropRotation; // Getter pour le Plotting
double get targetCenterX => _targetCenterX; double get targetCenterX => _targetCenterX;
double get targetCenterY => _targetCenterY; double get targetCenterY => _targetCenterY;
double get targetRadius => _targetRadius; double get targetRadius => _targetRadius;
@@ -108,18 +113,25 @@ class AnalysisProvider extends ChangeNotifier {
/// 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 => String? get displayImagePath =>
_distortionCorrectionEnabled && _correctedImagePath != null _distortionCorrectionEnabled && _correctedImagePath != null
? _correctedImagePath ? _correctedImagePath
: _imagePath; : _imagePath;
/// Modifie et mémorise la rotation de l'image pour le Plotting
void setCropRotation(double rotation) {
_cropRotation = rotation;
notifyListeners();
}
/// Analyze an image /// Analyze an image
/// ///
/// [autoAnalyze] determines if we should run automatic detection immediately. /// [autoAnalyze] determines if we should run automatic detection immediately.
/// If false, only the image is loaded and default target parameters are set. /// If false, only the image is loaded and default target parameters are set.
Future<void> analyzeImage( Future<void> analyzeImage(
String imagePath, String imagePath,
TargetType targetType, { TargetType targetType, {
bool autoAnalyze = true, bool autoAnalyze = true,
}) async { Offset? manualCenter,
}) async {
_state = AnalysisState.loading; _state = AnalysisState.loading;
_imagePath = imagePath; _imagePath = imagePath;
_targetType = targetType; _targetType = targetType;
@@ -137,8 +149,8 @@ class AnalysisProvider extends ChangeNotifier {
if (!autoAnalyze) { if (!autoAnalyze) {
// Just setup default values without running detection // Just setup default values without running detection
_targetCenterX = 0.5; _targetCenterX = manualCenter?.dx ?? 0.5;
_targetCenterY = 0.5; _targetCenterY = manualCenter?.dy ?? 0.5;
_targetRadius = 0.4; _targetRadius = 0.4;
_targetInnerRadius = 0.04; _targetInnerRadius = 0.04;
@@ -150,8 +162,10 @@ class AnalysisProvider extends ChangeNotifier {
return; return;
} }
// Detect target and impacts final result = await _detectionService.detectTargetAsync(
final result = _detectionService.detectTarget(imagePath, targetType); imagePath,
targetType,
);
if (!result.success) { if (!result.success) {
_state = AnalysisState.error; _state = AnalysisState.error;
@@ -172,7 +186,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: '',
); );
}).toList(); }).toList();
@@ -194,7 +208,7 @@ class AnalysisProvider extends ChangeNotifier {
/// Add a manual shot /// Add a manual shot
void addShot(double x, double y) { void addShot(double x, double y) {
final score = _calculateShotScore(x, y); final score = _calculateShotScore(x, y);
final shot = Shot(id: _uuid.v4(), x: x, y: y, score: score, sessionId: ''); final shot = Shot(id: _uuid.v4(), x: x, y: y, score: score, analysisId: '');
_shots.add(shot); _shots.add(shot);
_recalculateScores(); _recalculateScores();
@@ -274,7 +288,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);
} }
@@ -287,14 +301,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,
@@ -351,7 +357,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,15 +387,15 @@ class AnalysisProvider extends ChangeNotifier {
final detectedImpacts = _detectionService final detectedImpacts = _detectionService
.detectImpactsWithOpenCVFromReferences( .detectImpactsWithOpenCVFromReferences(
_imagePath!, _imagePath!,
_targetType!, _targetType!,
_targetCenterX, _targetCenterX,
_targetCenterY, _targetCenterY,
_targetRadius, _targetRadius,
_ringCount, _ringCount,
references, references,
tolerance: tolerance, tolerance: tolerance,
); );
if (clearExisting) { if (clearExisting) {
_shots.clear(); _shots.clear();
@@ -403,7 +409,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);
} }
@@ -418,7 +424,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(id: _uuid.v4(), x: x, y: y, score: score, sessionId: ''); final shot = Shot(id: _uuid.v4(), x: x, y: y, score: score, analysisId: '');
_referenceImpacts.add(shot); _referenceImpacts.add(shot);
notifyListeners(); notifyListeners();
} }
@@ -488,7 +494,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);
} }
@@ -502,23 +508,26 @@ class AnalysisProvider extends ChangeNotifier {
/// Adjust target position /// Adjust target position
void adjustTargetPosition( void adjustTargetPosition(
double centerX, double centerX,
double centerY, double centerY,
double innerRadius, double innerRadius,
double radius, { double radius, {
int? ringCount, int? ringCount,
List<double>? ringRadii, List<double>? ringRadii,
}) { double zoomScale = 1.0,
_targetCenterX = centerX; Offset offset = Offset.zero,
_targetCenterY = centerY; }) {
_targetInnerRadius = innerRadius; _targetCenterX = (centerX - offset.dx) / zoomScale;
_targetRadius = radius; _targetCenterY = (centerY - offset.dy) / zoomScale;
_targetRadius = radius / zoomScale;
_targetInnerRadius = innerRadius / zoomScale;
if (ringCount != null) { if (ringCount != null) {
_ringCount = ringCount; _ringCount = ringCount;
} }
if (ringRadii != null) {
_ringRadii = ringRadii; // CORRECTION : On accepte désormais la valeur null pour pouvoir réinitialiser l'espacement !
} _ringRadii = ringRadii;
// Recalculate all shot scores based on new target position // Recalculate all shot scores based on new target position
_shots = _shots.map((shot) { _shots = _shots.map((shot) {
@@ -543,8 +552,7 @@ class AnalysisProvider extends ChangeNotifier {
_imagePath = correctedPath; _imagePath = correctedPath;
_correctedImagePath = correctedPath; _correctedImagePath = correctedPath;
_distortionCorrectionEnabled = true; _distortionCorrectionEnabled = true;
_imageAspectRatio = _imageAspectRatio = 1.0;
1.0; // The corrected image is always square (side x side)
notifyListeners(); notifyListeners();
} }
@@ -562,7 +570,7 @@ class AnalysisProvider extends ChangeNotifier {
} }
return false; return false;
} catch (e) { } catch (e) {
print('Auto-calibration error: $e'); debugPrint('Auto-calibration error: $e');
return false; return false;
} }
} }
@@ -607,7 +615,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)
@@ -623,18 +630,18 @@ class AnalysisProvider extends ChangeNotifier {
// 3. Application immédiate de la transformation (méthode asynchrone) // 3. Application immédiate de la transformation (méthode asynchrone)
await applyDistortionCorrection(); await applyDistortionCorrection();
} else { } else {
notifyListeners(); // On prévient quand même si pas de correction notifyListeners();
} }
} }
Future<void> runFullDistortionWorkflow() async { Future<void> runFullDistortionWorkflow() async {
_state = AnalysisState.loading; // Affiche un spinner sur votre UI _state = AnalysisState.loading;
notifyListeners(); notifyListeners();
try { try {
calculateDistortion(); // Calcule les paramètres calculateDistortion();
await applyDistortionCorrection(); // Génère le fichier corrigé await applyDistortionCorrection();
_distortionCorrectionEnabled = true; // Active l'affichage _distortionCorrectionEnabled = true;
_state = AnalysisState.success; _state = AnalysisState.success;
} catch (e) { } catch (e) {
_errorMessage = "Erreur de rendu : $e"; _errorMessage = "Erreur de rendu : $e";
@@ -643,7 +650,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) {
@@ -688,16 +694,55 @@ class AnalysisProvider extends ChangeNotifier {
_groupingResult = _groupingAnalyzerService.analyzeGrouping(_shots); _groupingResult = _groupingAnalyzerService.analyzeGrouping(_shots);
} }
/// Exporte l'image et le json vers le backend IA
Future<bool> exportToAiBackend() async {
if (_imagePath == null || _targetType == null) {
_errorMessage = "Impossible d'export : image ou type de cible manquant.";
notifyListeners();
return false;
}
final service = AiExportService();
_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 /// Save the session
Future<Session> saveSession({String? notes}) async { Future<TargetAnalysis> saveSession({
String? notes,
String? sessionId,
String? weaponName,
String? weaponId,
int? distance,
DateTime? date,
}) async {
if (_imagePath == null || _targetType == null) { if (_imagePath == null || _targetType == null) {
throw Exception('Cannot save: missing image or target type'); throw Exception('Cannot save: missing image or target type');
} }
final session = await _sessionRepository.createSession( final targetAnalysis = await _sessionRepository.prepareTargetAnalysis(
targetType: _targetType!,
imagePath: _imagePath!, imagePath: _imagePath!,
shots: _shots.map((s) => s.copyWith(sessionId: '')).toList(), sessionId: sessionId ?? 'standalone',
targetType: _targetType!,
shots: _shots,
totalScore: totalScore, totalScore: totalScore,
groupingDiameter: _groupingResult?.diameter, groupingDiameter: _groupingResult?.diameter,
groupingCenterX: _groupingResult?.centerX, groupingCenterX: _groupingResult?.centerX,
@@ -708,11 +753,41 @@ class AnalysisProvider extends ChangeNotifier {
targetRadius: _targetRadius, targetRadius: _targetRadius,
); );
// Update shots with session ID if (sessionId != null && sessionId != 'standalone') {
_shots = session.shots; final existingSession = await _sessionRepository.getSession(sessionId);
notifyListeners(); if (existingSession != null) {
await _sessionRepository.addAnalysisToSession(
sessionId,
targetAnalysis,
);
} else {
// CORRECTION : Utilise la date injectée plutôt que DateTime.now()
await _sessionRepository.createSession(
id: sessionId,
weapon: weaponName ?? 'Inconnue',
weaponId: weaponId,
maxShotsPerTarget: shotCount,
analyses: [targetAnalysis],
notes: notes,
distance: distance ?? 25,
date: date ?? DateTime.now(),
);
}
} else {
// CORRECTION : S'applique aussi au flux alternatif sans ID préalable
await _sessionRepository.createSession(
weapon: weaponName ?? 'Inconnue',
weaponId: weaponId,
maxShotsPerTarget: shotCount,
analyses: [targetAnalysis],
notes: notes,
distance: distance ?? 25,
date: date ?? DateTime.now(),
);
}
return session; notifyListeners();
return targetAnalysis;
} }
/// Reset the provider /// Reset the provider
@@ -721,6 +796,7 @@ class AnalysisProvider extends ChangeNotifier {
_errorMessage = null; _errorMessage = null;
_imagePath = null; _imagePath = null;
_targetType = null; _targetType = null;
_cropRotation = 0.0;
_targetCenterX = 0.5; _targetCenterX = 0.5;
_targetCenterY = 0.5; _targetCenterY = 0.5;
_targetRadius = 0.4; _targetRadius = 0.4;
@@ -738,4 +814,23 @@ class AnalysisProvider extends ChangeNotifier {
_correctedImagePath = null; _correctedImagePath = null;
notifyListeners(); notifyListeners();
} }
}
/// Met à jour la position d'un impact de tir en direct (Glisser-Déposer).
void updateShotPosition(String shotId, double newX, double newY) {
final index = _shots.indexWhere((s) => s.id == shotId);
if (index != -1) {
// 1. On calcule dynamiquement le nouveau score de la zone survolée
final newScore = _calculateShotScore(newX, newY);
// 2. On remplace l'ancien impact par le nouveau avec ses coordonnées et son score ajustés
_shots[index] = _shots[index].copyWith(x: newX, y: newY, score: newScore);
// 3. On applique les deux méthodes natives de recalcul de ton application
_recalculateScores();
_recalculateGrouping();
// 4. On demande à l'écran de se redessiner en direct
notifyListeners();
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -18,13 +18,13 @@ class TargetCalibration extends StatefulWidget {
final TargetType targetType; final TargetType targetType;
final List<double>? initialRingRadii; final List<double>? initialRingRadii;
final Function( final Function(
double centerX, double centerX,
double centerY, double centerY,
double innerRadius, double innerRadius,
double radius, double radius,
int ringCount, { int ringCount, {
List<double>? ringRadii, List<double>? ringRadii,
}) })
onCalibrationChanged; onCalibrationChanged;
const TargetCalibration({ const TargetCalibration({
@@ -40,10 +40,10 @@ 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;
@@ -51,10 +51,19 @@ class _TargetCalibrationState extends State<TargetCalibration> {
late int _ringCount; late int _ringCount;
late List<double> _ringRadii; late List<double> _ringRadii;
// Variable dédiée pour piloter la jauge orange sans conflit
late double _currentEspacementRatio;
bool _isDraggingCenter = false; bool _isDraggingCenter = false;
bool _isDraggingRadius = false; bool _isDraggingRadius = false;
bool _isDraggingInnerRadius = false; bool _isDraggingInnerRadius = false;
bool _showEspacement = false;
double _baseRadiusBeforeScale = 1.0;
// SAUVEGARDE CRITIQUE : Pour mémoriser la structure d'origine de l'IA
List<double>? _originalRingRadii;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -63,19 +72,27 @@ class _TargetCalibrationState extends State<TargetCalibration> {
_radius = widget.initialRadius; _radius = widget.initialRadius;
_innerRadius = widget.initialInnerRadius; _innerRadius = widget.initialInnerRadius;
_ringCount = widget.initialRingCount; _ringCount = widget.initialRingCount;
// On mémorise la configuration d'usine de l'image
if (widget.initialRingRadii != null) {
_originalRingRadii = List.from(widget.initialRingRadii!);
}
// Initialisation du ratio par défaut
_currentEspacementRatio = (_radius > 0) ? (_innerRadius / _radius).clamp(0.01, 0.70) : 0.1;
_initRingRadii(); _initRingRadii();
} }
void _initRingRadii() { void _initRingRadii({bool forceRecalculate = false}) {
if (widget.initialRingRadii != null && // CORRECTION : Si on ne recalcule pas activement l'espacement linéaire, on préserve en priorité la structure d'origine
widget.initialRingRadii!.length == _ringCount) { if (!forceRecalculate && _originalRingRadii != null && _originalRingRadii!.length == _ringCount) {
_ringRadii = List.from(_originalRingRadii!);
} else if (!forceRecalculate && widget.initialRingRadii != null && widget.initialRingRadii!.length == _ringCount) {
_ringRadii = List.from(widget.initialRingRadii!); _ringRadii = List.from(widget.initialRingRadii!);
} else { } else {
// Initialize with default proportional radii interpolated between inner and outer
_ringRadii = List.generate(_ringCount, (i) { _ringRadii = List.generate(_ringCount, (i) {
if (_ringCount <= 1) return 1.0; if (_ringCount <= 1) return 1.0;
final ratio = _innerRadius / _radius; return _currentEspacementRatio + (1.0 - _currentEspacementRatio) * i / (_ringCount - 1);
return ratio + (1.0 - ratio) * i / (_ringCount - 1);
}); });
} }
} }
@@ -85,12 +102,10 @@ class _TargetCalibrationState extends State<TargetCalibration> {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
bool shouldReinit = false; bool shouldReinit = false;
if (widget.initialCenterX != oldWidget.initialCenterX && if (widget.initialCenterX != oldWidget.initialCenterX && !_isDraggingCenter) {
!_isDraggingCenter) {
_centerX = widget.initialCenterX; _centerX = widget.initialCenterX;
} }
if (widget.initialCenterY != oldWidget.initialCenterY && if (widget.initialCenterY != oldWidget.initialCenterY && !_isDraggingCenter) {
!_isDraggingCenter) {
_centerY = widget.initialCenterY; _centerY = widget.initialCenterY;
} }
if (widget.initialRingCount != oldWidget.initialRingCount) { if (widget.initialRingCount != oldWidget.initialRingCount) {
@@ -101,12 +116,13 @@ class _TargetCalibrationState extends State<TargetCalibration> {
_radius = widget.initialRadius; _radius = widget.initialRadius;
shouldReinit = true; shouldReinit = true;
} }
if (widget.initialInnerRadius != oldWidget.initialInnerRadius && if (widget.initialInnerRadius != oldWidget.initialInnerRadius && !_isDraggingInnerRadius) {
!_isDraggingInnerRadius) {
_innerRadius = widget.initialInnerRadius; _innerRadius = widget.initialInnerRadius;
_currentEspacementRatio = (_radius > 0) ? (_innerRadius / _radius).clamp(0.01, 0.70) : 0.1;
shouldReinit = true; shouldReinit = true;
} }
if (widget.initialRingRadii != oldWidget.initialRingRadii) { if (widget.initialRingRadii != oldWidget.initialRingRadii && widget.initialRingRadii != null) {
_originalRingRadii = List.from(widget.initialRingRadii!);
shouldReinit = true; shouldReinit = true;
} }
@@ -121,107 +137,229 @@ class _TargetCalibrationState extends State<TargetCalibration> {
builder: (context, constraints) { builder: (context, constraints) {
final size = constraints.biggest; final size = constraints.biggest;
return GestureDetector( return Stack(
onPanStart: (details) => _onPanStart(details, size), children: [
onPanUpdate: (details) => _onPanUpdate(details, size), GestureDetector(
onPanEnd: (_) => _onPanEnd(), onScaleStart: (details) {
child: CustomPaint( _baseRadiusBeforeScale = _radius;
size: size, final tapX = details.localFocalPoint.dx / size.width;
painter: _CalibrationPainter( final tapY = details.localFocalPoint.dy / size.height;
centerX: _centerX, final distToCenter = _distance(tapX, tapY, _centerX, _centerY);
centerY: _centerY,
radius: _radius, if (distToCenter < 0.05 || distToCenter < _radius + 0.02) {
innerRadius: _innerRadius, setState(() {
ringCount: _ringCount, _isDraggingCenter = true;
ringRadii: _ringRadii, });
targetType: widget.targetType, }
isDraggingCenter: _isDraggingCenter, },
isDraggingRadius: _isDraggingRadius, onScaleUpdate: (details) => _onScaleUpdate(details, size),
isDraggingInnerRadius: _isDraggingInnerRadius, onScaleEnd: (_) => _onScaleEnd(),
child: CustomPaint(
size: size,
painter: _CalibrationPainter(
centerX: _centerX,
centerY: _centerY,
radius: _radius,
innerRadius: _innerRadius,
ringCount: _ringCount,
ringRadii: _ringRadii,
targetType: widget.targetType,
isDraggingCenter: _isDraggingCenter,
isDraggingRadius: false,
isDraggingInnerRadius: false,
),
),
), ),
),
Positioned(
top: 10,
left: 40,
right: 40,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 2),
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Options d\'espacement avancées',
style: TextStyle(color: Colors.white70, fontSize: 11, fontWeight: FontWeight.w500),
),
SizedBox(
height: 28,
child: Switch(
value: _showEspacement,
activeThumbColor: const Color(0xFF00FF00),
onChanged: (bool value) {
setState(() {
_showEspacement = value;
// Quand on désactive l'espacement manuel, on restaure la configuration d'usine !
if (!value) {
_initRingRadii(forceRecalculate: false);
}
});
_notifyChange();
},
),
),
],
),
),
// Slider pour la taille (toujours visible)
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(20),
),
child: Row(
children: [
const Text('Taille ', style: TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.bold)),
const Icon(Icons.zoom_out, color: Colors.white, size: 16),
Expanded(
child: Slider(
// SÉCURITÉ : Ouverture mesurée des bornes pour plus de liberté sans débordement incontrôlé
value: _radius.clamp(0.3, 0.95),
min: 0.3,
max: 0.95,
activeColor: AppTheme.primaryColor,
onChanged: (value) {
setState(() {
_radius = value;
_innerRadius = _radius * _currentEspacementRatio;
// CORRECTION : Si le mode avancé n'est pas coché, on applique la taille pure sans détruire le ratio d'origine
_initRingRadii(forceRecalculate: _showEspacement);
});
_notifyChange();
},
),
),
const Icon(Icons.zoom_in, color: Colors.white, size: 16),
],
),
),
// Affichage conditionnel du slider d'espacement orange
if (_showEspacement) ...[
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.fromLTRB(16, 4, 4, 4),
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(20),
),
child: Row(
children: [
const Text('Espacement ', style: TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.bold)),
const Icon(Icons.compress, color: Colors.white, size: 16),
Expanded(
child: Row(
children: [
Expanded(
child: Slider(
value: _currentEspacementRatio,
min: 0.01,
// SÉCURITÉ : Écartement max bridé à 0.70 pour confiner le dernier cercle
max: 0.70,
activeColor: Colors.orange,
onChanged: (value) {
setState(() {
_currentEspacementRatio = value;
_innerRadius = _radius * _currentEspacementRatio;
_initRingRadii(forceRecalculate: true);
});
_notifyChange();
},
),
),
const Icon(Icons.expand, color: Colors.white, size: 16),
],
),
),
// CORRECTION DU BOUTON RESET : Restaure désormais le vrai profil d'usine de l'IA
IconButton(
icon: const Icon(Icons.refresh, color: Colors.white70, size: 20),
onPressed: () {
setState(() {
if (_originalRingRadii != null) {
_initRingRadii(forceRecalculate: false);
if (_ringRadii.isNotEmpty) {
_currentEspacementRatio = _ringRadii.first;
_innerRadius = _radius * _currentEspacementRatio;
}
} else {
_currentEspacementRatio = 0.1;
_innerRadius = _radius * _currentEspacementRatio;
_initRingRadii(forceRecalculate: true);
}
});
_notifyChange();
},
tooltip: 'Réinitialiser l\'espacement',
constraints: const BoxConstraints(),
padding: const EdgeInsets.symmetric(horizontal: 8),
),
],
),
),
],
],
),
),
],
); );
}, },
); );
} }
void _onPanStart(DragStartDetails details, Size size) { Widget buildDirectionalControls(BuildContext context, Size size) {
final tapX = details.localPosition.dx / size.width; return Container(
final tapY = details.localPosition.dy / size.height; padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
// Check if tapping on center handle color: Colors.black.withValues(alpha: 0.05),
final distToCenter = _distance(tapX, tapY, _centerX, _centerY); borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.white10),
// Check if tapping on outer radius handle ),
final minDim = math.min(size.width, size.height); child: Column(
final outerRadius = _radius; mainAxisSize: MainAxisSize.min,
final radiusHandleX = _centerX + outerRadius * minDim / size.width; children: [
final radiusHandleY = _centerY; _buildDirectionButton(Icons.keyboard_arrow_up, () => _moveCenterByPixels(0, -1, size)),
final distToOuterHandle = _distance( Row(
tapX, mainAxisSize: MainAxisSize.min,
tapY, children: [
radiusHandleX.clamp(0.0, 1.0), _buildDirectionButton(Icons.keyboard_arrow_left, () => _moveCenterByPixels(-1, 0, size)),
radiusHandleY.clamp(0.0, 1.0), const SizedBox(width: 40),
_buildDirectionButton(Icons.keyboard_arrow_right, () => _moveCenterByPixels(1, 0, size)),
],
),
_buildDirectionButton(Icons.keyboard_arrow_down, () => _moveCenterByPixels(0, 1, size)),
],
),
); );
// Check if tapping on inner radius handle (top edge of innermost circle)
final actualInnerRadius = _innerRadius;
final innerHandleX = _centerX;
final innerHandleY = _centerY - actualInnerRadius * minDim / size.height;
final distToInnerHandle = _distance(
tapX,
tapY,
innerHandleX.clamp(0.0, 1.0),
innerHandleY.clamp(0.0, 1.0),
);
// Increase touch target size slightly for handles
if (distToCenter < 0.05) {
setState(() {
_isDraggingCenter = true;
});
} else if (distToOuterHandle < 0.05) {
setState(() {
_isDraggingRadius = true;
});
} else if (distToInnerHandle < 0.05) {
setState(() {
_isDraggingInnerRadius = true;
});
} else if (distToCenter < _radius + 0.02) {
// Tapping inside the target - move center
setState(() {
_isDraggingCenter = true;
});
}
} }
void _onPanUpdate(DragUpdateDetails details, Size size) { Widget _buildDirectionButton(IconData icon, VoidCallback onPressed) {
final deltaX = details.delta.dx / size.width; return Container(
final deltaY = details.delta.dy / size.height; decoration: BoxDecoration(color: Colors.black87, borderRadius: BorderRadius.circular(8)),
final minDim = math.min(size.width, size.height); margin: const EdgeInsets.all(2),
child: IconButton(
setState(() { icon: Icon(icon, color: Colors.white, size: 28),
if (_isDraggingCenter) { onPressed: onPressed,
// Move center padding: EdgeInsets.zero,
_centerX = _centerX + deltaX; constraints: const BoxConstraints(minWidth: 40, minHeight: 40),
_centerY = _centerY + deltaY; ),
} else if (_isDraggingRadius) { );
// Adjust outer radius }
final newRadius = _radius + deltaX * (size.width / minDim);
_radius = newRadius.clamp(math.max(0.05, _innerRadius + 0.01), 3.0);
_initRingRadii(); // Recalculate linear separation
} else if (_isDraggingInnerRadius) {
// Adjust inner radius (sliding up reduces Y, so deltaY is negative when growing. Thus we subtract deltaY)
final newInnerRadius = _innerRadius - deltaY * (size.height / minDim);
_innerRadius = newInnerRadius.clamp(
0.01,
math.max(0.01, _radius - 0.01),
);
_initRingRadii(); // Recalculate linear separation
}
});
void _notifyChange() {
widget.onCalibrationChanged( widget.onCalibrationChanged(
_centerX, _centerX,
_centerY, _centerY,
@@ -232,7 +370,31 @@ class _TargetCalibrationState extends State<TargetCalibration> {
); );
} }
void _onPanEnd() { void _moveCenterByPixels(double dx, double dy, Size size) {
setState(() {
_centerX = _centerX + (dx / size.width);
_centerY = _centerY + (dy / size.height);
});
_notifyChange();
}
void _onScaleUpdate(ScaleUpdateDetails details, Size size) {
setState(() {
if (details.pointerCount == 2) {
_radius = (_baseRadiusBeforeScale * details.scale).clamp(0.3, 0.95);
_innerRadius = _radius * _currentEspacementRatio;
_initRingRadii(forceRecalculate: _showEspacement);
} else if (_isDraggingCenter) {
final deltaX = details.focalPointDelta.dx / size.width;
final deltaY = details.focalPointDelta.dy / size.height;
_centerX = _centerX + deltaX;
_centerY = _centerY + deltaY;
}
});
_notifyChange();
}
void _onScaleEnd() {
setState(() { setState(() {
_isDraggingCenter = false; _isDraggingCenter = false;
_isDraggingRadius = false; _isDraggingRadius = false;
@@ -277,7 +439,6 @@ 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);
@@ -285,43 +446,17 @@ class _CalibrationPainter extends CustomPainter {
_drawSilhouetteZones(canvas, size, centerPx, baseRadiusPx); _drawSilhouetteZones(canvas, size, centerPx, baseRadiusPx);
} }
// Fullscreen crosshairs when dragging center
if (isDraggingCenter) { if (isDraggingCenter) {
final crosshairLinePaint = Paint() final crosshairLinePaint = Paint()..color = Colors.red.withValues(alpha: 0.5)..strokeWidth = 1;
..color = AppTheme.successColor.withValues(alpha: 0.5) canvas.drawLine(Offset(0, centerPx.dy), Offset(size.width, centerPx.dy), crosshairLinePaint);
..strokeWidth = 1; canvas.drawLine(Offset(centerPx.dx, 0), Offset(centerPx.dx, size.height), crosshairLinePaint);
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
_drawCenterHandle(canvas, centerPx); _drawCenterHandle(canvas, centerPx);
// Draw radius handle (for outer ring)
_drawRadiusHandle(canvas, size, centerPx, baseRadiusPx);
// Draw inner radius handle
_drawInnerRadiusHandle(canvas, size, centerPx, innerRadiusPx);
// Draw instructions
_drawInstructions(canvas, size); _drawInstructions(canvas, size);
} }
void _drawConcentricZones( void _drawConcentricZones(Canvas canvas, Size size, Offset center, double baseRadius) {
Canvas canvas,
Size size,
Offset center,
double baseRadius,
) {
// Generate colors for zones
List<Color> zoneColors = []; List<Color> zoneColors = [];
for (int i = 0; i < ringCount; i++) { for (int i = 0; i < ringCount; i++) {
final ratio = i / ringCount; final ratio = i / ringCount;
@@ -342,13 +477,10 @@ class _CalibrationPainter extends CustomPainter {
final strokePaint = Paint() final strokePaint = Paint()
..style = PaintingStyle.stroke ..style = PaintingStyle.stroke
..strokeWidth = 1 ..strokeWidth = 1
..color = Colors.white.withValues(alpha: 0.6); ..color = Colors.red.withValues(alpha: 0.8);
// Draw from outside to inside
for (int i = ringCount - 1; i >= 0; i--) { for (int i = ringCount - 1; i >= 0; i--) {
final ringRadius = ringRadii.length > i final ringRadius = ringRadii.length > i ? ringRadii[i] : (i + 1) / ringCount;
? ringRadii[i]
: (i + 1) / ringCount;
final zoneRadius = baseRadius * ringRadius; final zoneRadius = baseRadius * ringRadius;
zonePaint.color = zoneColors[i]; zonePaint.color = zoneColors[i];
@@ -356,22 +488,14 @@ class _CalibrationPainter extends CustomPainter {
canvas.drawCircle(center, zoneRadius, strokePaint); canvas.drawCircle(center, zoneRadius, strokePaint);
} }
// Draw zone labels (only if within visible area)
final textPainter = TextPainter(textDirection: TextDirection.ltr); final textPainter = TextPainter(textDirection: TextDirection.ltr);
for (int i = 0; i < ringCount; i++) { for (int i = 0; i < ringCount; i++) {
final ringRadius = ringRadii.length > i final ringRadius = ringRadii.length > i ? ringRadii[i] : (i + 1) / ringCount;
? ringRadii[i] final textSpanRatio = i > 0 ? (ringRadii.length > i - 1 ? ringRadii[i - 1] : i / ringCount) : 0.0;
: (i + 1) / ringCount; final zoneRadius = baseRadius * (ringRadius + textSpanRatio) / 2;
final prevRingRadius = i > 0
? (ringRadii.length > i - 1 ? ringRadii[i - 1] : i / ringCount)
: 0.0;
final zoneRadius = baseRadius * (ringRadius + prevRingRadius) / 2;
// Score: center = 10, decrement by 1 for each ring
final score = 10 - i; final score = 10 - i;
// Only draw label if it's within the visible area
final labelX = center.dx + zoneRadius; final labelX = center.dx + zoneRadius;
if (labelX < 0 || labelX > size.width) continue; if (labelX < 0 || labelX > size.width) continue;
@@ -386,251 +510,49 @@ class _CalibrationPainter extends CustomPainter {
); );
textPainter.layout(); textPainter.layout();
// Draw label on the right side of each zone
final labelY = center.dy - textPainter.height / 2; final labelY = center.dy - textPainter.height / 2;
if (labelY >= 0 && labelY <= size.height) { if (labelY >= 0 && labelY <= size.height) {
textPainter.paint( textPainter.paint(canvas, Offset(labelX - textPainter.width / 2, labelY));
canvas,
Offset(labelX - textPainter.width / 2, labelY),
);
} }
} }
} }
void _drawSilhouetteZones( void _drawSilhouetteZones(Canvas canvas, Size size, Offset center, double radius) {
Canvas canvas, final paint = Paint()..style = PaintingStyle.stroke..strokeWidth = 2;
Size size,
Offset center,
double radius,
) {
// Simplified silhouette zones
final paint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 2;
// Draw silhouette outline (simplified as rectangle for now)
final silhouetteWidth = radius * 0.8; final silhouetteWidth = radius * 0.8;
final silhouetteHeight = radius * 2; final silhouetteHeight = radius * 2;
paint.color = Colors.green.withValues(alpha: 0.5); paint.color = Colors.green.withValues(alpha: 0.5);
canvas.drawRect( canvas.drawRect(Rect.fromCenter(center: center, width: silhouetteWidth, height: silhouetteHeight), paint);
Rect.fromCenter(
center: center,
width: silhouetteWidth,
height: silhouetteHeight,
),
paint,
);
} }
void _drawCenterHandle(Canvas canvas, Offset center) { void _drawCenterHandle(Canvas canvas, Offset center) {
// Outer circle final outerPaint = Paint()..color = isDraggingCenter ? Colors.redAccent : Colors.red..style = PaintingStyle.stroke..strokeWidth = 3;
final outerPaint = Paint()
..color = isDraggingCenter ? AppTheme.successColor : AppTheme.primaryColor
..style = PaintingStyle.stroke
..strokeWidth = 3;
canvas.drawCircle(center, 15, outerPaint); canvas.drawCircle(center, 15, outerPaint);
// Inner dot final innerPaint = Paint()..color = isDraggingCenter ? Colors.redAccent : Colors.red..style = PaintingStyle.fill;
final innerPaint = Paint()
..color = isDraggingCenter ? AppTheme.successColor : AppTheme.primaryColor
..style = PaintingStyle.fill;
canvas.drawCircle(center, 5, innerPaint); canvas.drawCircle(center, 5, innerPaint);
// Crosshair final crossPaint = Paint()..color = isDraggingCenter ? Colors.redAccent : Colors.red..strokeWidth = 2;
final crossPaint = Paint() canvas.drawLine(Offset(center.dx - 20, center.dy), Offset(center.dx - 8, center.dy), crossPaint);
..color = isDraggingCenter ? AppTheme.successColor : AppTheme.primaryColor canvas.drawLine(Offset(center.dx + 8, center.dy), Offset(center.dx + 20, center.dy), crossPaint);
..strokeWidth = 2; canvas.drawLine(Offset(center.dx, center.dy - 20), Offset(center.dx, center.dy - 8), crossPaint);
canvas.drawLine( canvas.drawLine(Offset(center.dx, center.dy + 4), Offset(center.dx, center.dy + 20), crossPaint);
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 actualHandleX = center.dx + baseRadius;
final clampedHandleX = actualHandleX.clamp(20.0, size.width - 20);
final clampedHandleY = center.dy.clamp(20.0, size.height - 20);
final handlePos = Offset(clampedHandleX, clampedHandleY);
// Check if handle is clamped (radius extends beyond visible area)
final isClamped = actualHandleX > size.width - 20;
final paint = Paint()
..color = isDraggingRadius
? AppTheme.successColor
: (isClamped ? Colors.orange : AppTheme.warningColor)
..style = PaintingStyle.fill;
// Draw handle as a small circle with arrows
canvas.drawCircle(handlePos, 14, paint);
// Draw arrow indicators
final arrowPaint = Paint()
..color = Colors.white
..strokeWidth = 2
..style = PaintingStyle.stroke;
// Left arrow
canvas.drawLine(
Offset(handlePos.dx - 4, handlePos.dy),
Offset(handlePos.dx - 8, handlePos.dy - 4),
arrowPaint,
);
canvas.drawLine(
Offset(handlePos.dx - 4, handlePos.dy),
Offset(handlePos.dx - 8, handlePos.dy + 4),
arrowPaint,
);
// Right arrow
canvas.drawLine(
Offset(handlePos.dx + 4, handlePos.dy),
Offset(handlePos.dx + 8, handlePos.dy - 4),
arrowPaint,
);
canvas.drawLine(
Offset(handlePos.dx + 4, handlePos.dy),
Offset(handlePos.dx + 8, handlePos.dy + 4),
arrowPaint,
);
// Label
final textPainter = TextPainter(
text: const TextSpan(
text: 'EXT.',
style: TextStyle(
color: Colors.white,
fontSize: 8,
fontWeight: FontWeight.bold,
),
),
textDirection: TextDirection.ltr,
);
textPainter.layout();
textPainter.paint(
canvas,
Offset(handlePos.dx - textPainter.width / 2, handlePos.dy + 16),
);
}
void _drawInnerRadiusHandle(
Canvas canvas,
Size size,
Offset center,
double innerRadiusPx,
) {
// Inner radius handle on the top edge of the innermost ring
final actualHandleY = center.dy - innerRadiusPx;
final clampedHandleX = center.dx.clamp(20.0, size.width - 20);
final clampedHandleY = actualHandleY.clamp(20.0, size.height - 20);
final handlePos = Offset(clampedHandleX, clampedHandleY);
final isClamped = actualHandleY < 20.0;
final paint = Paint()
..color = isDraggingInnerRadius
? AppTheme.successColor
: (isClamped ? Colors.orange : Colors.purpleAccent)
..style = PaintingStyle.fill;
// Draw handle
canvas.drawCircle(handlePos, 14, paint);
// Up/Down arrow indicators
final arrowPaint = Paint()
..color = Colors.white
..strokeWidth = 2
..style = PaintingStyle.stroke;
// Up arrow
canvas.drawLine(
Offset(handlePos.dx, handlePos.dy - 4),
Offset(handlePos.dx - 4, handlePos.dy - 8),
arrowPaint,
);
canvas.drawLine(
Offset(handlePos.dx, handlePos.dy - 4),
Offset(handlePos.dx + 4, handlePos.dy - 8),
arrowPaint,
);
// Down arrow
canvas.drawLine(
Offset(handlePos.dx, handlePos.dy + 4),
Offset(handlePos.dx - 4, handlePos.dy + 8),
arrowPaint,
);
canvas.drawLine(
Offset(handlePos.dx, handlePos.dy + 4),
Offset(handlePos.dx + 4, handlePos.dy + 8),
arrowPaint,
);
// Label
final textPainter = TextPainter(
text: const TextSpan(
text: 'INT.',
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 - 24),
);
} }
void _drawInstructions(Canvas canvas, Size size) { void _drawInstructions(Canvas canvas, Size size) {
const instruction = 'Deplacez le centre ou ajustez le rayon'; const instruction = 'Deplacez le centre ou ajustez le rayon';
final textPainter = TextPainter( final textPainter = TextPainter(
text: TextSpan( text: TextSpan(text: instruction, style: TextStyle(color: Colors.white.withValues(alpha: 0.9), fontSize: 12, backgroundColor: Colors.black54)),
text: instruction,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.9),
fontSize: 12,
backgroundColor: Colors.black54,
),
),
textDirection: TextDirection.ltr, textDirection: TextDirection.ltr,
); );
textPainter.layout(); textPainter.layout();
textPainter.paint( textPainter.paint(canvas, Offset((size.width - textPainter.width) / 2, size.height - 30));
canvas,
Offset((size.width - textPainter.width) / 2, size.height - 30),
);
} }
@override @override
bool shouldRepaint(covariant _CalibrationPainter oldDelegate) { bool shouldRepaint(CustomPainter oldDelegate) {
if (oldDelegate is! _CalibrationPainter) return true;
return centerX != oldDelegate.centerX || return centerX != oldDelegate.centerX ||
centerY != oldDelegate.centerY || centerY != oldDelegate.centerY ||
radius != oldDelegate.radius || radius != oldDelegate.radius ||
@@ -641,4 +563,4 @@ class _CalibrationPainter extends CustomPainter {
isDraggingInnerRadius != oldDelegate.isDraggingInnerRadius || isDraggingInnerRadius != oldDelegate.isDraggingInnerRadius ||
ringRadii != oldDelegate.ringRadii; ringRadii != oldDelegate.ringRadii;
} }
} }

View File

@@ -2,14 +2,17 @@ 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: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: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 '../session/session_provider.dart';
import 'widgets/image_source_button.dart'; import 'widgets/image_source_button.dart';
import '../../services/image_crop_service.dart';
import '../../services/document_scanner_service.dart'; // Scanner ML Kit : redresse la cible de face
class CaptureScreen extends StatefulWidget { class CaptureScreen extends StatefulWidget {
const CaptureScreen({super.key}); const CaptureScreen({super.key});
@@ -20,29 +23,122 @@ class CaptureScreen extends StatefulWidget {
class _CaptureScreenState extends State<CaptureScreen> { class _CaptureScreenState extends State<CaptureScreen> {
final ImagePicker _picker = ImagePicker(); final ImagePicker _picker = ImagePicker();
final ImageCropService _cropService = ImageCropService();
final TargetType _selectedType = TargetType.concentric; final TargetType _selectedType = TargetType.concentric;
// Scanner de documents Google ML Kit (redresse la cible de face automatiquement)
final DocumentScannerService _docScanner = DocumentScannerService();
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 // ─────────────────────────────────────────────────────────────────────────
// Build principal
// ─────────────────────────────────────────────────────────────────────────
@override
Widget build(BuildContext context) {
final sessionProvider = context.watch<SessionProvider>();
final title = sessionProvider.isSessionActive
? 'Cible ${sessionProvider.targetCount + 1}'
: 'Source';
return Scaffold(
appBar: AppBar(title: Text(title)),
body: SingleChildScrollView(
padding: const EdgeInsets.all(AppConstants.defaultPadding),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: AppConstants.largePadding),
_buildSectionTitle('Source de l\'Image'),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: ImageSourceButton(
icon: Icons.document_scanner,
label: 'Scanner',
onPressed: _isLoading ? null : _scanWithDocumentScanner,
),
),
const SizedBox(width: 12),
Expanded(
child: ImageSourceButton(
icon: Icons.photo_library,
label: 'Galerie',
onPressed: _isLoading ? null : _handleGallerySelection,
),
),
],
),
const SizedBox(height: AppConstants.largePadding),
if (_isLoading)
const Center(
child: Padding(
padding: EdgeInsets.all(32),
child: CircularProgressIndicator(),
),
)
else
_buildGuide(),
],
),
),
);
}
// ─────────────────────────────────────────────────────────────────────────
// Déclic → lance le scanner de documents Google ML Kit qui redresse
// automatiquement la cible (feuille rectangulaire) de face.
// ─────────────────────────────────────────────────────────────────────────
Future<void> _scanWithDocumentScanner() async {
setState(() => _isLoading = true);
try {
final result = await _docScanner.scanTarget();
if (!result.success) {
// L'utilisateur a annulé le scan : on ne fait rien de plus.
if (mounted) setState(() => _isLoading = false);
return;
}
setState(() {
_selectedImagePath = result.imagePath;
});
_analyzeImage();
} catch (e) {
debugPrint('Erreur scanner document: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Le scan a échoué. Réessayez.'),
),
);
}
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
// ─────────────────────────────────────────────────────────────────────────
// Galerie (inchangée)
// ─────────────────────────────────────────────────────────────────────────
Future<void> _handleGallerySelection() async { Future<void> _handleGallerySelection() async {
PermissionStatus status; PermissionStatus status;
if (Platform.isAndroid) { if (Platform.isAndroid) {
final deviceInfo = DeviceInfoPlugin(); final deviceInfo = DeviceInfoPlugin();
final androidInfo = await deviceInfo.androidInfo; final androidInfo = await deviceInfo.androidInfo;
status = androidInfo.version.sdkInt >= 33
if (androidInfo.version.sdkInt >= 33) { ? await Permission.photos.request()
status = await Permission.photos.request(); : await Permission.storage.request();
} else {
status = await Permission.storage.request();
}
} else { } else {
status = await Permission.photos.request(); status = await Permission.photos.request();
} }
if (status.isGranted) { if (status.isGranted) {
_captureImage(ImageSource.gallery); _captureImageFromGallery();
} else if (status.isPermanentlyDenied) { } else if (status.isPermanentlyDenied) {
_showSettingsDialog(); _showSettingsDialog();
} else { } else {
@@ -78,151 +174,52 @@ class _CaptureScreenState extends State<CaptureScreen> {
); );
} }
@override Future<void> _captureImageFromGallery() async {
Widget build(BuildContext context) { setState(() => _isLoading = true);
return Scaffold( try {
appBar: AppBar(title: const Text('Source')), final XFile? image = await _picker.pickImage(
body: SingleChildScrollView( source: ImageSource.gallery,
padding: const EdgeInsets.all(AppConstants.defaultPadding), maxWidth: 2048,
child: Column( maxHeight: 2048,
crossAxisAlignment: CrossAxisAlignment.stretch, imageQuality: 90,
children: [ );
const SizedBox(height: AppConstants.largePadding), if (image != null) {
setState(() {
_selectedImagePath = image.path;
});
_analyzeImage();
}
} catch (e) {
debugPrint('Erreur galerie: $e');
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
_buildSectionTitle('Source de l\'Image'), void _analyzeImage() {
const SizedBox(height: 12), if (_selectedImagePath == null) return;
Row(
children: [
Expanded(
child: ImageSourceButton(
icon: Icons.camera_alt,
label: 'Scanner',
onPressed: _isLoading ? null : _scanDocument,
),
),
const SizedBox(width: 12),
Expanded(
child: ImageSourceButton(
icon: Icons.photo_library,
label: 'Galerie',
onPressed: _isLoading ? null : _handleGallerySelection,
),
),
],
),
const SizedBox(height: AppConstants.largePadding),
if (_isLoading) Navigator.push(
const Center( context,
child: Padding( MaterialPageRoute(
padding: EdgeInsets.all(32), builder: (_) => CropScreen(
child: CircularProgressIndicator(), imagePath: _selectedImagePath!,
), targetType: _selectedType,
)
else if (_selectedImagePath != null)
_buildImagePreview()
else
_buildGuide(),
// --- NOUVEAU BOUTON VALIDER ---
if (_selectedImagePath != null && !_isLoading) ...[
const SizedBox(height: 24),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF112233), // Ton bleu foncé
foregroundColor: Colors.white,
minimumSize: const Size(double.infinity, 54),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
elevation: 0,
),
onPressed: _analyzeImage,
child: const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'Valider',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
SizedBox(width: 12),
Icon(Icons.arrow_forward, size: 20),
],
),
),
const SizedBox(height: 32),
],
],
), ),
), ),
); );
} }
// ─────────────────────────────────────────────────────────────────────────
// Helpers UI
// ─────────────────────────────────────────────────────────────────────────
Widget _buildSectionTitle(String title) { Widget _buildSectionTitle(String title) {
return Text( return Text(
title, title,
style: Theme.of( style: Theme.of(context)
context, .textTheme
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), .titleMedium
); ?.copyWith(fontWeight: FontWeight.bold),
}
Widget _buildImagePreview() {
return Column(
children: [
_buildSectionTitle('Aperçu'),
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.withOpacity(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 centrée et visible.',
style: TextStyle(color: AppTheme.warningColor.withOpacity(0.8)),
),
),
],
),
),
); );
} }
@@ -236,9 +233,10 @@ class _CaptureScreenState extends State<CaptureScreen> {
const SizedBox(height: 12), const SizedBox(height: 12),
Text( Text(
'Conseils pour une bonne analyse', 'Conseils pour une bonne analyse',
style: Theme.of( style: Theme.of(context)
context, .textTheme
).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold), .titleSmall
?.copyWith(fontWeight: FontWeight.bold),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
_buildGuideItem( _buildGuideItem(
@@ -270,57 +268,4 @@ 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) {
setState(() => _selectedImagePath = documents.images.first);
}
} catch (e) {
debugPrint('Erreur scan: $e');
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
Future<void> _captureImage(ImageSource source) async {
setState(() => _isLoading = true);
try {
final XFile? image = await _picker.pickImage(
source: source,
maxWidth: 2048,
maxHeight: 2048,
imageQuality: 90,
);
if (image != null) {
setState(() => _selectedImagePath = image.path);
}
} catch (e) {
debugPrint('Erreur capture: $e');
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
void _analyzeImage() {
if (_selectedImagePath == null) return;
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => CropScreen(
imagePath: _selectedImagePath!,
targetType: _selectedType,
),
),
);
}
}

View File

@@ -1,18 +1,36 @@
import 'dart:io'; import 'dart:io';
import 'dart:math' as math;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
// Importations ajustées selon ton arborescence à gauche 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 '../analysis/analysis_screen.dart'; import '../analysis/analysis_screen.dart';
import 'widgets/crop_overlay.dart';
class CropScreen extends StatefulWidget { class CropScreen extends StatefulWidget {
/// Image affichée/recadrée dans cet écran. Au tout premier passage, c'est
/// aussi l'image originale. Lors des allers-retours, on recharge toujours
/// l'ORIGINAL ici (jamais une image déjà recadrée) pour éviter le zoom
/// cumulatif.
final String imagePath; final String imagePath;
/// Chemin de l'image ORIGINALE (jamais recadrée). Si null, [imagePath] est
/// considéré comme l'original (premier passage depuis la caméra/galerie).
final String? originalImagePath;
final TargetType targetType; final TargetType targetType;
/// Rotation à restaurer au retour (en degrés). Le zoom n'est volontairement
/// PAS restauré : on repart toujours de l'image entière.
final double? initialRotation;
const CropScreen({ const CropScreen({
super.key, super.key,
required this.imagePath, required this.imagePath,
this.originalImagePath,
required this.targetType, required this.targetType,
this.initialRotation,
}); });
@override @override
@@ -20,6 +38,50 @@ class CropScreen extends StatefulWidget {
} }
class _CropScreenState extends State<CropScreen> { class _CropScreenState extends State<CropScreen> {
final ImageCropService _cropService = ImageCropService();
// États de transformation
double _scale = 1.0;
double _baseScale = 1.0;
Offset _offset = Offset.zero;
Offset _startOffset = Offset.zero;
Offset _startFocalPoint = Offset.zero;
// PRÉCISION MAXIMUM : Amplitude restreinte de -15.0 à 15.0 degrés
double _rotation = 0.0;
bool _isLoading = false;
bool _imageLoaded = false;
Size? _imageSize;
late Size _viewportSize;
late double _cropSize;
/// L'image effectivement travaillée par cet écran : toujours l'originale.
String get _workingImagePath =>
widget.originalImagePath ?? widget.imagePath;
@override
void initState() {
super.initState();
// On restaure uniquement la rotation (pas le zoom).
_rotation = widget.initialRotation ?? 0.0;
_loadImageInfo();
}
Future<void> _loadImageInfo() async {
final file = File(_workingImagePath);
final decodedImage = await decodeImageFromList(await file.readAsBytes());
if (mounted) {
setState(() {
_imageSize = Size(
decodedImage.width.toDouble(),
decodedImage.height.toDouble(),
);
_imageLoaded = true;
});
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
@@ -29,17 +91,34 @@ class _CropScreenState extends State<CropScreen> {
elevation: 0, elevation: 0,
centerTitle: true, centerTitle: true,
title: const Text( title: const Text(
'Validation image', 'Centrage de la cible',
style: TextStyle(color: Colors.white, fontSize: 18), style: TextStyle(color: Colors.white, fontSize: 18),
), ),
leading: IconButton( leading: IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.white), icon: const Icon(Icons.arrow_back, color: Colors.white),
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
), ),
actions: [
if (_rotation != 0.0 || _scale != 1.0 || _offset != Offset.zero)
IconButton(
icon: const Icon(Icons.refresh, color: Colors.white70),
tooltip: 'Réinitialiser l\'orientation',
onPressed: () {
setState(() {
_rotation = 0.0;
_scale = 1.0;
_offset = Offset.zero;
_initializeImagePosition();
});
},
),
],
), ),
body: Column( body: _isLoading
? const Center(child: CircularProgressIndicator(color: Color(0xFF1A73E8)))
: Column(
children: [ children: [
// L'image capturée // Zone interactive de crop
Expanded( Expanded(
child: Container( child: Container(
margin: const EdgeInsets.all(20), margin: const EdgeInsets.all(20),
@@ -49,35 +128,94 @@ class _CropScreenState extends State<CropScreen> {
), ),
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
child: Image.file( child: _imageLoaded ? _buildInteractiveCrop() : const Center(child: CircularProgressIndicator()),
File(widget.imagePath),
fit: BoxFit.contain,
),
), ),
), ),
), ),
// Bouton Modifier // TEXTE D'AIDE AJUSTÉ
Padding( Padding(
padding: const EdgeInsets.symmetric(vertical: 20), padding: const EdgeInsets.symmetric(horizontal: 20),
child: ElevatedButton.icon( child: Column(
onPressed: () { children: [
// Logique de modification à venir Row(
}, mainAxisAlignment: MainAxisAlignment.center,
icon: const Icon(Icons.crop_rotate), children: [
label: const Text('Modifier'), const Icon(Icons.center_focus_strong, color: Color(0xFF00FF00), size: 20),
style: ElevatedButton.styleFrom( const SizedBox(width: 8),
backgroundColor: const Color(0xFF1A73E8), Text(
foregroundColor: Colors.white, 'Alignez et pivotez la cible sur la croix',
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 12), style: TextStyle(color: Colors.white.withOpacity(0.8), fontSize: 14, fontWeight: FontWeight.w500),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), ),
), ],
),
const SizedBox(height: 4),
Text(
'Glissez à un doigt pour déplacer, pincez pour zoomer',
style: TextStyle(color: Colors.white.withOpacity(0.5), fontSize: 12),
),
],
),
),
const SizedBox(height: 16),
// JAUGE DE ROTATION HAUTE PRÉCISION BRIDÉE À 15°
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Rotation de l\'image',
style: TextStyle(color: Colors.white70, fontSize: 13, fontWeight: FontWeight.w600),
),
Text(
'${_rotation.toStringAsFixed(1)}°',
style: const TextStyle(color: Color(0xFF00FF00), fontWeight: FontWeight.bold),
),
],
),
Row(
children: [
const Icon(Icons.rotate_left, color: Colors.white38, size: 20),
Expanded(
child: Slider(
value: _rotation,
min: -15.0,
max: 15.0,
divisions: 300,
label: '${_rotation.toStringAsFixed(1)}°',
activeColor: const Color(0xFF1A73E8),
inactiveColor: Colors.white12,
onChanged: (double value) {
setState(() {
_rotation = value;
});
},
),
),
const Icon(Icons.rotate_right, color: Colors.white38, size: 20),
const SizedBox(width: 4),
IconButton(
icon: const Icon(Icons.restart_alt, color: Colors.white54, size: 20),
onPressed: () {
setState(() {
_rotation = 0.0;
});
},
),
],
),
],
), ),
), ),
// Boutons du bas // Boutons du bas
Padding( Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 30), padding: const EdgeInsets.fromLTRB(20, 10, 20, 30),
child: Row( child: Row(
children: [ children: [
Expanded( Expanded(
@@ -88,30 +226,20 @@ class _CropScreenState extends State<CropScreen> {
padding: const EdgeInsets.symmetric(vertical: 15), padding: const EdgeInsets.symmetric(vertical: 15),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
), ),
child: const Text('Recommencer', style: TextStyle(color: Colors.white)), child: const Text('Retour', style: TextStyle(color: Colors.white)),
), ),
), ),
const SizedBox(width: 16), const SizedBox(width: 16),
Expanded( Expanded(
child: ElevatedButton( child: ElevatedButton(
onPressed: () { onPressed: _onCropConfirm,
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => AnalysisScreen(
imagePath: widget.imagePath,
targetType: widget.targetType,
),
),
);
},
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF1A73E8), backgroundColor: const Color(0xFF1A73E8),
foregroundColor: Colors.white, foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 15), padding: const EdgeInsets.symmetric(vertical: 15),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
), ),
child: const Text('Valider', style: TextStyle(fontWeight: FontWeight.bold)), child: const Text('Suivant', style: TextStyle(fontWeight: FontWeight.bold)),
), ),
), ),
], ],
@@ -121,4 +249,244 @@ class _CropScreenState extends State<CropScreen> {
), ),
); );
} }
Widget _buildInteractiveCrop() {
return LayoutBuilder(
builder: (context, constraints) {
_viewportSize = Size(constraints.maxWidth, constraints.maxHeight);
// FIX : On calcule d'abord la taille de la photo affichée avant de définir la taille du cadre vert !
final imageAspect = _imageSize != null ? _imageSize!.width / _imageSize!.height : 1.0;
final viewportAspect = _viewportSize.width / _viewportSize.height;
double displayWidth, displayHeight;
if (imageAspect > viewportAspect) {
displayWidth = _viewportSize.width;
displayHeight = _viewportSize.width / imageAspect;
} else {
displayHeight = _viewportSize.height;
displayWidth = _viewportSize.height * imageAspect;
}
// Le cadre couvre TOUT le petit côté de l'image affichée (carré maximum),
// au lieu des 85% précédents qui rognaient inutilement les bords.
_cropSize = math.min(displayWidth, displayHeight);
if (_scale == 1.0 && _offset == Offset.zero) {
_initializeImagePosition();
}
return GestureDetector(
onScaleStart: _onScaleStart,
onScaleUpdate: _onScaleUpdate,
child: Stack(
children: [
// 1. L'image brute avec ses transformations (Reste calé sur BoxFit.contain d'origine)
Positioned.fill(
child: Center(
child: Transform(
transform: Matrix4.identity()
..setTranslationRaw(_offset.dx, _offset.dy, 0)
..scale(_scale, _scale)
..rotateZ(_rotation * (math.pi / 180)),
alignment: Alignment.center,
child: Image.file(
File(_workingImagePath),
fit: BoxFit.contain,
width: _viewportSize.width,
height: _viewportSize.height,
),
),
),
),
// 2. Les lignes vertes de visée prolongées
Positioned.fill(
child: IgnorePointer(
child: Stack(
children: [
Center(
child: Container(
width: double.infinity,
height: 1.5,
color: const Color(0xFF00FF00).withOpacity(0.6),
),
),
Center(
child: Container(
width: 1.5,
height: double.infinity,
color: const Color(0xFF00FF00).withOpacity(0.6),
),
),
],
),
),
),
// 3. MASQUE OPAQUE EXTÉRIEUR - COINS DROITS ET CARRÉS
Positioned.fill(
child: IgnorePointer(
child: ColorFiltered(
colorFilter: ColorFilter.mode(
const Color(0xFF101214),
BlendMode.srcOut,
),
child: Stack(
children: [
Center(
child: Container(
width: _cropSize,
height: _cropSize,
color: Colors.black,
),
),
],
),
),
),
),
// 4. Ton overlay avec les coins blancs
Positioned.fill(
child: IgnorePointer(
child: CropOverlay(cropSize: _cropSize, showGrid: false),
),
),
],
),
);
},
);
}
void _initializeImagePosition() {
if (_imageSize == null) return;
final imageAspect = _imageSize!.width / _imageSize!.height;
final viewportAspect = _viewportSize.width / _viewportSize.height;
// 1. Calcul strict de la taille de l'image affichée en BoxFit.contain
double displayWidth, displayHeight;
if (imageAspect > viewportAspect) {
displayWidth = _viewportSize.width;
displayHeight = _viewportSize.width / imageAspect;
} else {
displayHeight = _viewportSize.height;
displayWidth = _viewportSize.height * imageAspect;
}
// On repart TOUJOURS de l'image entière, centrée, sans zoom :
// c'est ce qui empêche tout cumul de recadrage entre les allers-retours.
_scale = 1.0;
_offset = Offset.zero;
}
void _onScaleStart(ScaleStartDetails details) {
_baseScale = _scale;
_startFocalPoint = details.focalPoint;
_startOffset = _offset;
}
void _onScaleUpdate(ScaleUpdateDetails details) {
setState(() {
_scale = (_baseScale * details.scale).clamp(0.8, 8.0);
final delta = details.focalPoint - _startFocalPoint;
_offset = _startOffset + delta;
});
}
Future<void> _onCropConfirm() async {
setState(() => _isLoading = true);
try {
// CORRECTIF ZOOM : On sauvegarde et réinitialise le zoom avant de calculer
// la zone de découpe pour ne pas transmettre le zoom aux écrans suivants
final savedScale = _scale;
final savedOffset = _offset;
_scale = 1.0;
_offset = Offset.zero;
final cropRect = _calculateCropRect();
// On restaure pour l'affichage (au cas où on revient en arrière)
_scale = savedScale;
_offset = savedOffset;
// Le crop est TOUJOURS calculé sur l'image originale, jamais sur un
// résultat déjà recadré.
final croppedImagePath = await _cropService.cropToSquare(
_workingImagePath,
cropRect,
rotationDegrees: _rotation,
);
const targetCenterX = 0.5;
const targetCenterY = 0.5;
if (!mounted) return;
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (_) => AnalysisScreen(
imagePath: croppedImagePath,
// On fait suivre l'ORIGINAL et la rotation choisie, pour pouvoir
// revenir au centrage sans jamais re-recadrer le résultat.
originalImagePath: _workingImagePath,
cropRotation: _rotation,
targetType: widget.targetType,
initialCenterX: targetCenterX,
initialCenterY: targetCenterY,
),
),
);
} catch (e) {
if (mounted) {
setState(() => _isLoading = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Erreur de découpe : $e'), backgroundColor: AppTheme.errorColor),
);
}
}
}
CropRect _calculateCropRect() {
if (_imageSize == null) return const CropRect(x: 0, y: 0, width: 1, height: 1);
final imageAspect = _imageSize!.width / _imageSize!.height;
final viewportAspect = _viewportSize.width / _viewportSize.height;
double displayWidth, displayHeight;
if (imageAspect > viewportAspect) {
displayWidth = _viewportSize.width;
displayHeight = _viewportSize.width / imageAspect;
} else {
displayHeight = _viewportSize.height;
displayWidth = _viewportSize.height * imageAspect;
}
final scaledWidth = displayWidth * _scale;
final scaledHeight = displayHeight * _scale;
final imageCenterX = _viewportSize.width / 2 + _offset.dx;
final imageCenterY = _viewportSize.height / 2 + _offset.dy;
final imageLeft = imageCenterX - scaledWidth / 2;
final imageTop = imageCenterY - scaledHeight / 2;
final cropLeft = (_viewportSize.width - _cropSize) / 2;
final cropTop = (_viewportSize.height - _cropSize) / 2;
final relCropLeft = (cropLeft - imageLeft) / scaledWidth;
final relCropTop = (cropTop - imageTop) / scaledHeight;
final relCropWidth = _cropSize / scaledWidth;
final relCropHeight = _cropSize / scaledHeight;
return CropRect(
x: relCropLeft.clamp(0.0, 1.0),
y: relCropTop.clamp(0.0, 1.0),
width: relCropWidth.clamp(0.0, 1.0),
height: relCropHeight.clamp(0.0, 1.0),
);
}
} }

View File

@@ -68,7 +68,7 @@ class _CropOverlayPainter extends CustomPainter {
_drawGrid(canvas, cropRect); _drawGrid(canvas, cropRect);
} }
// Dessiner le point central (croix) // Dessiner le point central (croix interne vert fluo)
_drawCenterPoint(canvas, cropRect); _drawCenterPoint(canvas, cropRect);
} }
@@ -165,39 +165,44 @@ class _CropOverlayPainter extends CustomPainter {
} }
void _drawCenterPoint(Canvas canvas, Rect rect) { void _drawCenterPoint(Canvas canvas, Rect rect) {
// Peinture de la croix interne (Vert Fluo)
final centerPaint = Paint() final centerPaint = Paint()
..color = Colors.white.withValues(alpha: 0.8) ..color = const Color(0xFF00FF00)
..style = PaintingStyle.stroke ..style = PaintingStyle.stroke
..strokeWidth = 2; ..strokeWidth = 2.0;
const size = 10.0; // Cercle extérieur (Vert Fluo fin)
final circlePaint = Paint()
..color = const Color(0xFF00FF00).withValues(alpha: 0.9)
..style = PaintingStyle.stroke
..strokeWidth = 1.2;
const circleRadius = 10.0; // Augmenté légèrement à 10 pour que la croix respire
final centerX = rect.center.dx; final centerX = rect.center.dx;
final centerY = rect.center.dy; final centerY = rect.center.dy;
// Ligne horizontale // 1. Dessiner le cercle de contour
canvas.drawCircle(rect.center, circleRadius, circlePaint);
// 2. Dessiner la croix UNIQUEMENT à l'intérieur du cercle
// Ligne horizontale (de gauche à droite, reste à l'intérieur)
canvas.drawLine( canvas.drawLine(
Offset(centerX - size, centerY), Offset(centerX - circleRadius, centerY),
Offset(centerX + size, centerY), Offset(centerX + circleRadius, centerY),
centerPaint, centerPaint,
); );
// Ligne verticale // Ligne verticale (de haut en bas, reste à l'intérieur)
canvas.drawLine( canvas.drawLine(
Offset(centerX, centerY - size), Offset(centerX, centerY - circleRadius),
Offset(centerX, centerY + size), Offset(centerX, centerY + circleRadius),
centerPaint, 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

@@ -37,7 +37,7 @@ class _HistoryScreenState extends State<HistoryScreen> {
try { try {
final repository = context.read<SessionRepository>(); final repository = context.read<SessionRepository>();
final sessions = await repository.getAllSessions(targetType: _filterType); final sessions = await repository.getAllSessions();
if (mounted) { if (mounted) {
setState(() { setState(() {
@@ -95,12 +95,17 @@ class _HistoryScreenState extends State<HistoryScreen> {
locale: const Locale('fr', 'FR'), locale: const Locale('fr', 'FR'),
builder: (context, child) { builder: (context, child) {
return Theme( return Theme(
data: Theme.of(context).copyWith( data: ThemeData.light().copyWith(
colorScheme: ColorScheme.dark( colorScheme: ColorScheme.light(
primary: AppTheme.primaryColor, primary: AppTheme.primaryColor, // En-tête et sélection
onPrimary: Colors.white, onPrimary: Colors.white, // Texte sur en-tête/sélection
surface: Theme.of(context).cardColor, surface: Colors.white, // Fond du calendrier
onSurface: Colors.white, 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!, child: child!,

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,

View File

@@ -1,15 +1,8 @@
/// Item de liste représentant une session.
///
/// Affiche les informations clés d'une session (date, type de cible,
/// score, nombre de tirs) avec miniature de l'image et actions de suppression.
library;
import 'dart:io'; import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import '../../../core/theme/app_theme.dart'; import '../../../core/theme/app_theme.dart';
import '../../../data/models/session.dart'; import '../../../data/models/session.dart';
import '../../../data/models/target_type.dart';
class SessionListItem extends StatelessWidget { class SessionListItem extends StatelessWidget {
final Session session; final Session session;
@@ -33,7 +26,7 @@ class SessionListItem extends StatelessWidget {
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
child: Row( child: Row(
children: [ children: [
// Thumbnail // Thumbnail (from first target)
ClipRRect( ClipRRect(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
child: SizedBox( child: SizedBox(
@@ -51,14 +44,14 @@ class SessionListItem extends StatelessWidget {
children: [ children: [
Row( Row(
children: [ children: [
Icon( const Icon(
_getTargetIcon(), Icons.shield,
size: 16, size: 16,
color: AppTheme.primaryColor, color: AppTheme.primaryColor,
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
session.targetType.displayName, session.weapon,
style: const TextStyle( style: const TextStyle(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
@@ -70,18 +63,19 @@ class SessionListItem extends StatelessWidget {
DateFormat('dd/MM/yyyy HH:mm').format(session.createdAt), DateFormat('dd/MM/yyyy HH:mm').format(session.createdAt),
style: Theme.of(context).textTheme.bodySmall, style: Theme.of(context).textTheme.bodySmall,
), ),
if (session.notes != null && session.notes!.isNotEmpty) ...[ const SizedBox(height: 4),
const SizedBox(height: 2), Row(
Text( children: [
session.notes!, Icon(Icons.track_changes, size: 14, color: Colors.grey[600]),
style: Theme.of(context).textTheme.bodySmall?.copyWith( const SizedBox(width: 4),
color: Colors.grey[600], Text(
fontStyle: FontStyle.italic, '${session.targetCount} cible(s) • ${session.distance}m',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.grey[600],
),
), ),
maxLines: 1, ],
overflow: TextOverflow.ellipsis, ),
),
],
], ],
), ),
), ),
@@ -98,7 +92,7 @@ class SessionListItem extends StatelessWidget {
), ),
), ),
Text( Text(
'${session.shotCount} tirs', '${session.totalShots} tirs',
style: Theme.of(context).textTheme.bodySmall, style: Theme.of(context).textTheme.bodySmall,
), ),
], ],
@@ -120,7 +114,9 @@ class SessionListItem extends StatelessWidget {
} }
Widget _buildThumbnail() { Widget _buildThumbnail() {
final file = File(session.imagePath); if (session.analyses.isEmpty) return _buildPlaceholder();
final file = File(session.analyses.first.imagePath);
if (file.existsSync()) { if (file.existsSync()) {
return Image.file( return Image.file(
@@ -137,18 +133,9 @@ class SessionListItem extends StatelessWidget {
return Container( return Container(
color: Colors.grey[200], color: Colors.grey[200],
child: Icon( child: Icon(
_getTargetIcon(), Icons.track_changes,
color: Colors.grey[400], color: Colors.grey[400],
), ),
); );
} }
IconData _getTargetIcon() {
switch (session.targetType) {
case TargetType.concentric:
return Icons.track_changes;
case TargetType.silhouette:
return Icons.person;
}
}
} }

View File

@@ -6,6 +6,11 @@ import '../../data/repositories/session_repository.dart';
import '../capture/capture_screen.dart'; import '../capture/capture_screen.dart';
import '../history/history_screen.dart'; import '../history/history_screen.dart';
import '../statistics/statistics_screen.dart'; import '../statistics/statistics_screen.dart';
import 'package:fl_chart/fl_chart.dart';
import '../../data/models/session.dart';
import '../settings/settings_screen.dart';
import '../session/session_setup_screen.dart';
import '../session/session_provider.dart';
import 'widgets/stats_card.dart'; import 'widgets/stats_card.dart';
class HomeScreen extends StatefulWidget { class HomeScreen extends StatefulWidget {
@@ -17,6 +22,7 @@ class HomeScreen extends StatefulWidget {
class _HomeScreenState extends State<HomeScreen> { class _HomeScreenState extends State<HomeScreen> {
Map<String, dynamic>? _stats; Map<String, dynamic>? _stats;
List<Session> _recentSessions = [];
bool _isLoading = true; bool _isLoading = true;
@override @override
@@ -28,9 +34,12 @@ class _HomeScreenState extends State<HomeScreen> {
Future<void> _loadStats() async { Future<void> _loadStats() async {
final repository = context.read<SessionRepository>(); final repository = context.read<SessionRepository>();
final stats = await repository.getStatistics(); final stats = await repository.getStatistics();
final sessions = await repository.getAllSessions();
if (mounted) { if (mounted) {
setState(() { setState(() {
_stats = stats; _stats = stats;
_recentSessions = sessions;
_isLoading = false; _isLoading = false;
}); });
} }
@@ -40,7 +49,6 @@ class _HomeScreenState extends State<HomeScreen> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
// --- MODIFICATION 1 : AJOUT DE LA VERSION À GAUCHE ---
leading: const Center( leading: const Center(
child: Text( child: Text(
'v1.0.2', 'v1.0.2',
@@ -50,14 +58,9 @@ class _HomeScreenState extends State<HomeScreen> {
title: const Text('Bully'), title: const Text('Bully'),
actions: [ actions: [
IconButton( IconButton(
icon: const Icon(Icons.analytics), icon: const Icon(Icons.settings),
onPressed: () => _navigateToStatistics(context), onPressed: () => _navigateToSettings(context),
tooltip: 'Statistiques', tooltip: 'Paramètres',
),
IconButton(
icon: const Icon(Icons.history),
onPressed: () => _navigateToHistory(context),
tooltip: 'Historique',
), ),
], ],
), ),
@@ -69,15 +72,10 @@ class _HomeScreenState extends State<HomeScreen> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
// App logo/header
_buildHeader(), _buildHeader(),
const SizedBox(height: AppConstants.largePadding), const SizedBox(height: AppConstants.largePadding),
// Main action button
_buildMainActionButton(context), _buildMainActionButton(context),
const SizedBox(height: AppConstants.largePadding), const SizedBox(height: AppConstants.largePadding),
// Statistics section
if (_isLoading) if (_isLoading)
const Center(child: CircularProgressIndicator()) const Center(child: CircularProgressIndicator())
else if (_stats != null) else if (_stats != null)
@@ -95,7 +93,7 @@ class _HomeScreenState extends State<HomeScreen> {
Container( Container(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppTheme.primaryColor.withOpacity(0.1), color: AppTheme.primaryColor.withValues(alpha: 0.1),
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
child: const Icon( child: const Icon(
@@ -124,21 +122,103 @@ class _HomeScreenState extends State<HomeScreen> {
} }
Widget _buildMainActionButton(BuildContext context) { Widget _buildMainActionButton(BuildContext context) {
return ElevatedButton.icon( final sessionProvider = context.watch<SessionProvider>();
onPressed: () => _navigateToCapture(context), final isSessionActive = sessionProvider.isSessionActive;
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primaryColor, // Si aucune session n'est active, on affiche juste le bouton de départ habituel
foregroundColor: Colors.white, if (!isSessionActive) {
padding: const EdgeInsets.symmetric(vertical: 20), return ElevatedButton.icon(
shape: RoundedRectangleBorder( onPressed: () => _navigateToSessionSetup(context),
borderRadius: BorderRadius.circular(AppConstants.borderRadius), style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primaryColor,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 20),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
),
), ),
), icon: const Icon(Icons.add_circle_outline, size: 28),
icon: const Icon(Icons.add_a_photo, size: 28), label: const Text(
label: const Text( 'Nouvelle session',
'Nouvelle Analyse', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), ),
), );
}
// MODIFICATION : Alignement sur une seule ligne (80% / 20%) avec une croix pour l'annulation
return Row(
children: [
// 80% de largeur pour continuer la session
Expanded(
flex: 8,
child: ElevatedButton.icon(
onPressed: () => _navigateToSessionSetup(context),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.secondaryColor,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 20),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
),
),
icon: const Icon(Icons.play_circle_outline, size: 28),
label: const Text(
'Continuer la session',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
),
const SizedBox(width: 12),
// 20% de largeur pour terminer/annuler la session (Bouton Croix)
Expanded(
flex: 2,
child: OutlinedButton(
onPressed: () {
// Boite de dialogue de confirmation pour éviter les erreurs
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: const Text('Terminer la session ?'),
content: const Text('Êtes-vous sûr de vouloir clôturer la session en cours ?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('ANNULER'),
),
TextButton(
onPressed: () {
Navigator.pop(context);
// Clôture propre de la session dans le State global
context.read<SessionProvider>().endSession();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Session terminée avec succès !'),
backgroundColor: AppTheme.successColor,
),
);
},
child: const Text('TERMINER', style: TextStyle(color: Colors.redAccent)),
),
],
);
},
);
},
style: OutlinedButton.styleFrom(
foregroundColor: Colors.redAccent,
side: const BorderSide(color: Colors.redAccent, width: 1.5),
padding: const EdgeInsets.symmetric(vertical: 20), // Même hauteur que le bouton continuer
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
),
),
child: const Icon(Icons.close, size: 28),
),
),
],
); );
} }
@@ -153,8 +233,6 @@ class _HomeScreenState extends State<HomeScreen> {
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
// Première ligne de vignettes (Sessions et Tirs)
Row( Row(
children: [ children: [
Expanded( Expanded(
@@ -180,30 +258,38 @@ class _HomeScreenState extends State<HomeScreen> {
), ),
], ],
), ),
// --- MODIFICATION 2 : AJOUT DU GRAPHIQUE AU MILIEU ---
Padding( Padding(
padding: const EdgeInsets.symmetric(vertical: 16.0), padding: const EdgeInsets.symmetric(vertical: 16.0),
child: Container( child: Container(
height: 150, height: 150,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: Theme.of(context).cardColor,
borderRadius: BorderRadius.circular(AppConstants.borderRadius), borderRadius: BorderRadius.circular(AppConstants.borderRadius),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: Colors.black.withOpacity(0.05), color: Colors.black.withValues(alpha: 0.05),
blurRadius: 10, blurRadius: 10,
offset: const Offset(0, 4), offset: const Offset(0, 4),
), ),
], ],
), ),
child: const Center( child: _recentSessions.length < 2
child: Icon(Icons.show_chart, size: 50, color: Colors.grey), ? const Center(
), child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.show_chart, size: 40, color: Colors.grey),
Text(
'Pas assez de données',
style: TextStyle(color: Colors.grey, fontSize: 12),
),
],
),
)
: _buildHomeTrendChart(),
), ),
), ),
// Deuxième ligne de vignettes (Historique et Meilleur)
Row( Row(
children: [ children: [
Expanded( Expanded(
@@ -233,13 +319,19 @@ class _HomeScreenState extends State<HomeScreen> {
); );
} }
// --- MÉTHODES DE NAVIGATION --- void _navigateToSessionSetup(BuildContext context) async {
final sessionProvider = context.read<SessionProvider>();
void _navigateToCapture(BuildContext context) async { if (sessionProvider.isSessionActive) {
await Navigator.push( await Navigator.push(
context, context,
MaterialPageRoute(builder: (_) => const CaptureScreen()), MaterialPageRoute(builder: (_) => const CaptureScreen()),
); );
} else {
await Navigator.push(
context,
MaterialPageRoute(builder: (_) => const SessionSetupScreen()),
);
}
_loadStats(); _loadStats();
} }
@@ -258,4 +350,105 @@ class _HomeScreenState extends State<HomeScreen> {
); );
_loadStats(); _loadStats();
} }
}
Widget _buildHomeTrendChart() {
final sorted = List<Session>.from(_recentSessions)
..sort((a, b) => a.createdAt.compareTo(b.createdAt));
final display =
sorted.length > 7 ? sorted.sublist(sorted.length - 7) : sorted;
return LineChart(
LineChartData(
gridData: FlGridData(
show: true,
drawVerticalLine: false,
getDrawingHorizontalLine: (value) => FlLine(
color: Colors.grey.withValues(alpha: 0.1),
strokeWidth: 1,
),
),
titlesData: FlTitlesData(
show: true,
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 22,
interval: 1,
getTitlesWidget: (value, meta) {
final index = value.toInt();
if (index < 0 || index >= display.length) return const SizedBox();
final date = display[index].createdAt;
return Padding(
padding: const EdgeInsets.only(top: 4.0),
child: Text(
'${date.day}/${date.month}',
style: TextStyle(
fontSize: 9,
color: Colors.grey.withValues(alpha: 0.8),
),
),
);
},
),
),
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 28,
getTitlesWidget: (value, meta) {
return Text(
value.toInt().toString(),
style: TextStyle(
fontSize: 9,
color: Colors.grey.withValues(alpha: 0.8),
),
);
},
),
),
),
borderData: FlBorderData(
show: true,
border: Border(
bottom: BorderSide(color: Colors.grey.withValues(alpha: 0.2)),
left: BorderSide(color: Colors.grey.withValues(alpha: 0.2)),
),
),
lineBarsData: [
LineChartBarData(
spots: display.asMap().entries.map((e) {
return FlSpot(e.key.toDouble(), e.value.averageScore);
}).toList(),
isCurved: true,
color: AppTheme.primaryColor,
barWidth: 3,
isStrokeCapRound: true,
dotData: FlDotData(
show: true,
getDotPainter: (spot, percent, barData, index) => FlDotCirclePainter(
radius: 3,
color: Colors.white,
strokeWidth: 2,
strokeColor: AppTheme.primaryColor,
),
),
belowBarData: BarAreaData(
show: true,
color: AppTheme.primaryColor.withValues(alpha: 0.1),
),
),
],
),
);
}
void _navigateToSettings(BuildContext context) async {
await Navigator.push(
context,
MaterialPageRoute(builder: (_) => const SettingsScreen()),
);
_loadStats();
}
}

View File

@@ -0,0 +1,54 @@
import 'package:flutter/foundation.dart';
import '../../data/models/session.dart';
import '../../data/models/target_analysis.dart';
import '../../data/models/weapon.dart';
class SessionProvider extends ChangeNotifier {
DateTime? _sessionDate;
DateTime? get sessionDate => _sessionDate;
String? _currentWeaponName;
String? _currentWeaponId;
int _shotsPerTarget = 5;
int _distance = 25;
String? _activeSessionId;
final List<TargetAnalysis> _currentAnalyses = [];
bool _isSessionActive = false;
String? get currentWeapon => _currentWeaponName;
String? get currentWeaponId => _currentWeaponId;
int get shotsPerTarget => _shotsPerTarget;
int get distance => _distance;
bool get isSessionActive => _isSessionActive;
String? get activeSessionId => _activeSessionId;
List<TargetAnalysis> get currentAnalyses => List.unmodifiable(_currentAnalyses);
int get totalSessionScore => _currentAnalyses.fold(0, (sum, a) => sum + a.totalScore);
int get targetCount => _currentAnalyses.length;
void startSession(String weaponName, int shots, String sessionId, {String? weaponId, int distance = 25, DateTime? date}) {
_currentWeaponName = weaponName;
_currentWeaponId = weaponId;
_shotsPerTarget = shots;
_distance = distance;
_sessionDate = date ?? DateTime.now();
_activeSessionId = sessionId;
_currentAnalyses.clear();
_isSessionActive = true;
notifyListeners();
}
void addAnalysis(TargetAnalysis analysis) {
_currentAnalyses.add(analysis);
notifyListeners();
}
void endSession() {
_isSessionActive = false;
_activeSessionId = null;
_currentWeaponId = null;
_currentWeaponName = null;
_currentAnalyses.clear();
_distance = 25;
notifyListeners();
}
}

View File

@@ -0,0 +1,326 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:intl/intl.dart'; // Utile pour formater proprement la date en français
import '../../core/constants/app_constants.dart';
import '../../core/theme/app_theme.dart';
import '../../data/models/weapon.dart';
import '../../data/repositories/session_repository.dart';
import 'session_provider.dart';
import '../capture/capture_screen.dart';
import '../garage/weapon_list_screen.dart';
class SessionSetupScreen extends StatefulWidget {
const SessionSetupScreen({super.key});
@override
State<SessionSetupScreen> createState() => _SessionSetupScreenState();
}
class _SessionSetupScreenState extends State<SessionSetupScreen> {
final _formKey = GlobalKey<FormState>();
int _shotsPerTarget = 5;
int _distance = 25;
List<Weapon> _availableWeapons = [];
Weapon? _selectedWeapon;
bool _isLoadingWeapons = true;
// AJOUT DE LA VARIABLE DATE : Initialisée par défaut à maintenant
DateTime _selectedDate = DateTime.now();
@override
void initState() {
super.initState();
_loadWeapons();
}
Future<void> _loadWeapons() async {
setState(() => _isLoadingWeapons = true);
final repository = context.read<SessionRepository>();
final weapons = await repository.getWeapons();
if (mounted) {
setState(() {
_availableWeapons = weapons;
if (_availableWeapons.isNotEmpty && _selectedWeapon == null) {
_selectedWeapon = _availableWeapons.first;
_updateSettingsForWeapon(_selectedWeapon!);
}
_isLoadingWeapons = false;
});
}
}
void _updateSettingsForWeapon(Weapon weapon) {
_shotsPerTarget = weapon.magazineCapacity;
_distance = (weapon.type == WeaponType.handgun) ? 25 : 50;
}
// Fonction pour ouvrir le calendrier si l'utilisateur clique sur le champ
Future<void> _pickDate() async {
final DateTime? picked = await showDatePicker(
context: context,
initialDate: _selectedDate,
firstDate: DateTime(2020),
lastDate: DateTime(2100),
locale: const Locale('fr', 'FR'), // Force le calendrier en français
);
if (picked != null && picked != _selectedDate) {
setState(() {
// On garde aussi l'heure actuelle lors du changement de date
final now = DateTime.now();
_selectedDate = DateTime(picked.year, picked.month, picked.day, now.hour, now.minute);
});
}
}
void _startSession() {
if (_formKey.currentState!.validate() && _selectedWeapon != null) {
final repository = context.read<SessionRepository>();
final sessionProvider = context.read<SessionProvider>();
final sessionId = repository.generateId();
// PASSE-PARTOUT : On envoie la session au provider.
// Note : Si ton `sessionProvider.startSession` ne prend pas encore la date en paramètre,
// pas de panique, la compilation passera car Dart tolère les arguments nommés optionnels
// s'ils sont déjà présents, ou tu pourras l'ajouter à ta méthode startSession.
sessionProvider.startSession(
_selectedWeapon!.displayName,
_shotsPerTarget,
sessionId,
weaponId: _selectedWeapon!.id,
distance: _distance,
date: _selectedDate,
);
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (_) => const CaptureScreen()),
);
}
}
@override
Widget build(BuildContext context) {
// Petit formatage sympa en français (ex: "27 mai 2026")
final String formattedDate = DateFormat('dd MMMM yyyy', 'fr_FR').format(_selectedDate);
return Scaffold(
appBar: AppBar(
title: const Text('Configuration de la session'),
),
body: _isLoadingWeapons
? const Center(child: CircularProgressIndicator())
: SingleChildScrollView(
padding: const EdgeInsets.all(AppConstants.defaultPadding),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
'Informations générales',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
// AFFICHAGE CONDITIONNEL : Armurerie vide vs Armurerie remplie
if (_availableWeapons.isEmpty)
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppTheme.errorColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
border: Border.all(color: AppTheme.errorColor.withValues(alpha: 0.5)),
),
child: Column(
children: [
const Icon(Icons.warning_amber_rounded, color: AppTheme.errorColor, size: 48),
const SizedBox(height: 8),
const Text(
'Ton armurerie est vide.',
style: TextStyle(fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
const SizedBox(height: 4),
const Text(
'Tu dois d\'abord ajouter une arme pour pouvoir démarrer une session de tir.',
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(builder: (_) => const WeaponListScreen()),
).then((_) {
_loadWeapons();
});
},
icon: const Icon(Icons.add),
label: const Text('ALLER À MON ARMURERIE'),
),
],
),
)
else
Column(
children: [
DropdownButtonFormField<Weapon>(
value: _selectedWeapon,
decoration: InputDecoration(
labelText: 'Sélectionner une arme',
prefixIcon: const Icon(Icons.shield),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
),
),
items: _availableWeapons.map((w) => DropdownMenuItem(
value: w,
child: Text(w.displayName),
)).toList(),
onChanged: (value) {
if (value != null) {
setState(() {
_selectedWeapon = value;
_updateSettingsForWeapon(value);
});
}
},
),
const SizedBox(height: 16),
// NOUVEAU CHAMP : Sélecteur de date interactif
InkWell(
onTap: _availableWeapons.isEmpty ? null : _pickDate,
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
child: IgnorePointer(
child: TextFormField(
decoration: InputDecoration(
labelText: 'Date de la session',
prefixIcon: const Icon(Icons.calendar_today),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
),
),
controller: TextEditingController(text: formattedDate),
),
),
),
],
),
const SizedBox(height: 24),
// Distance selector
const Text(
'Distance de tir (mètres)',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: Slider(
value: _distance.toDouble(),
min: 5,
max: 300,
divisions: 59,
label: '${_distance}m',
onChanged: _availableWeapons.isEmpty ? null : (value) {
setState(() {
_distance = value.round();
});
},
),
),
Container(
width: 70,
height: 50,
alignment: Alignment.center,
decoration: BoxDecoration(
color: AppTheme.secondaryColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'${_distance}m',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppTheme.secondaryColor,
),
),
),
],
),
const SizedBox(height: 24),
// Shots per target
const Text(
'Nombre de balles pour la cible actuelle',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: Slider(
value: _shotsPerTarget.toDouble(),
min: 1,
max: 50,
divisions: 49,
label: '$_shotsPerTarget',
onChanged: _availableWeapons.isEmpty ? null : (value) {
setState(() {
_shotsPerTarget = value.round();
});
},
),
),
Container(
width: 50,
height: 50,
alignment: Alignment.center,
decoration: BoxDecoration(
color: AppTheme.primaryColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'$_shotsPerTarget',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppTheme.primaryColor,
),
),
),
],
),
const SizedBox(height: 48),
ElevatedButton.icon(
onPressed: (_availableWeapons.isEmpty || _selectedWeapon == null)
? null
: _startSession,
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primaryColor,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
),
),
icon: const Icon(Icons.play_arrow),
label: const Text(
'DÉMARRER LA SESSION',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,400 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:flutter/services.dart';
import '../../core/theme/theme_provider.dart';
import 'dart:convert';
import 'package:crypto/crypto.dart';
import 'package:http/http.dart' as http;
import '../../core/constants/app_constants.dart';
import '../../core/theme/app_theme.dart';
import '../../services/wallet_identity_service.dart';
import '../garage/weapon_list_screen.dart';
class SettingsScreen extends StatefulWidget {
const SettingsScreen({super.key});
@override
State<SettingsScreen> createState() => _SettingsScreenState();
}
class _SettingsScreenState extends State<SettingsScreen> {
final WalletIdentityService _walletService = WalletIdentityService();
String? _identityPhrase;
int? _photoCount;
bool _isLoadingStats = false;
bool _isUploadEnabled = false;
@override
void initState() {
super.initState();
_loadIdentity();
}
Future<void> _loadIdentity() async {
final phrase = await _walletService.getIdentityPhrase();
final isEnabled = await _walletService.isUploadEnabled();
if (mounted) {
setState(() {
_identityPhrase = phrase;
_isUploadEnabled = isEnabled;
});
_fetchStats(phrase);
}
}
Future<void> _fetchStats(String phrase) async {
setState(() => _isLoadingStats = true);
try {
final phraseBytes = utf8.encode(phrase);
final walletHash = sha256.convert(phraseBytes).toString();
// Utilise 10.0.2.2 pour l'émulateur Android, sinon localhost
final baseUrl = Theme.of(context).platform == TargetPlatform.android
? 'http://10.0.2.2:3000'
: 'http://localhost:3000';
final response = await http.get(Uri.parse('$baseUrl/api/stats/$walletHash'));
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
if (mounted) {
setState(() {
_photoCount = data['stats']['photo_count'] ?? 0;
_isLoadingStats = false;
});
}
} else {
if (mounted) setState(() => _isLoadingStats = false);
}
} catch (e) {
print('Erreur lors du chargement des statistiques: $e');
if (mounted) setState(() => _isLoadingStats = false);
}
}
void _copyToClipboard() {
if (_identityPhrase != null) {
Clipboard.setData(ClipboardData(text: _identityPhrase!));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Identité copiée dans le presse-papiers'),
backgroundColor: AppTheme.successColor,
),
);
}
}
void _showThemeDialog() {
final themeProvider = context.read<ThemeProvider>();
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Apparence'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildThemeOption(ThemeMode.system, 'Automatique', Icons.brightness_auto),
_buildThemeOption(ThemeMode.light, 'Clair', Icons.light_mode),
_buildThemeOption(ThemeMode.dark, 'Sombre', Icons.dark_mode),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Fermer'),
),
],
),
);
}
Widget _buildThemeOption(ThemeMode mode, String label, IconData icon) {
final themeProvider = context.watch<ThemeProvider>();
final isSelected = themeProvider.themeMode == mode;
return ListTile(
leading: Icon(icon, color: isSelected ? AppTheme.primaryColor : null),
title: Text(label, style: TextStyle(
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
color: isSelected ? AppTheme.primaryColor : null,
)),
trailing: isSelected ? const Icon(Icons.check, color: AppTheme.primaryColor) : null,
onTap: () {
themeProvider.setThemeMode(mode);
Navigator.pop(context);
},
);
}
void _showIdentityDialog() {
if (_identityPhrase == null) return;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Votre Identité Unique', textAlign: TextAlign.center),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.security, size: 48, color: AppTheme.primaryColor),
const SizedBox(height: 16),
const Text(
'Cette phrase de 15 mots vous identifie de manière unique. Ne la partagez qu\'en cas de besoin.',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 14, color: Colors.grey),
),
const SizedBox(height: 24),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.grey.withAlpha(20),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppTheme.primaryColor.withAlpha(100)),
),
child: Text(
_identityPhrase!,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
letterSpacing: 0.5,
height: 1.5,
),
textAlign: TextAlign.center,
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Fermer'),
),
ElevatedButton.icon(
icon: const Icon(Icons.copy, size: 18),
label: const Text('Copier'),
onPressed: () {
_copyToClipboard();
Navigator.pop(context);
},
),
],
),
);
}
void _showOptInDisclaimer(bool value) {
if (!value) {
// Si on désactive, pas besoin de disclaimer, on le fait direct.
_walletService.setUploadEnabled(false);
setState(() {
_isUploadEnabled = false;
});
return;
}
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => AlertDialog(
title: const Text('Participer à l\'entraînement IA', textAlign: TextAlign.center),
content: const Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.psychology, size: 48, color: AppTheme.primaryColor),
SizedBox(height: 16),
Text(
'En activant cette option, vous acceptez d\'envoyer vos photos de cibles à notre serveur sécurisé.',
style: TextStyle(fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
SizedBox(height: 12),
Text(
'🔒 Anonymat Garanti : L\'envoi est totalement anonymisé. Votre identité est remplacée par un hash cryptographique incassable.',
style: TextStyle(fontSize: 13),
),
SizedBox(height: 12),
Text(
'🎁 Avantages Futurs : Votre contribution (Hash de Wallet) sera comptabilisée. Lors de la sortie publique de notre IA, les participants actifs recevront des fonctionnalités premium ou des badges exclusifs en récompense !',
style: TextStyle(fontSize: 13),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Refuser', style: TextStyle(color: Colors.grey)),
),
ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: AppTheme.primaryColor),
onPressed: () {
_walletService.setUploadEnabled(true);
setState(() {
_isUploadEnabled = true;
});
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Merci pour votre contribution !'),
backgroundColor: AppTheme.successColor,
),
);
},
child: const Text('J\'accepte', style: TextStyle(color: Colors.white)),
),
],
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Paramètres'),
),
body: ListView(
padding: const EdgeInsets.all(AppConstants.defaultPadding),
children: [
_buildSectionHeader('Identité'),
_buildSettingsTile(
context: context,
icon: Icons.fingerprint,
title: 'Identité Wallet',
subtitle: _identityPhrase != null ? 'Phrase de 15 mots générée' : 'Génération en cours...',
onTap: _showIdentityDialog,
),
Consumer<ThemeProvider>(
builder: (context, themeProvider, child) {
return _buildSettingsTile(
context: context,
icon: Icons.color_lens_outlined,
title: 'Apparence',
subtitle: themeProvider.themeModeName,
onTap: _showThemeDialog,
);
},
),
const SizedBox(height: 24),
_buildSectionHeader('Gestion'),
_buildSettingsTile(
context: context,
icon: Icons.shield_outlined,
title: 'Mon Armurerie',
subtitle: 'Gérer mes armes et équipements',
onTap: () async {
await Navigator.push(
context,
MaterialPageRoute(builder: (_) => const WeaponListScreen()),
);
},
),
const SizedBox(height: 24),
_buildSectionHeader('Configuration Serveur IA'),
Card(
elevation: 0,
color: Colors.transparent,
margin: const EdgeInsets.only(bottom: 8.0),
child: SwitchListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 4.0),
title: const Text('Participer à l\'entraînement IA', style: TextStyle(fontWeight: FontWeight.w500)),
subtitle: const Text('Aidez-nous à améliorer la détection tout en gagnant des avantages', style: TextStyle(fontSize: 12)),
secondary: const Icon(Icons.psychology, color: AppTheme.textPrimary),
value: _isUploadEnabled,
activeColor: AppTheme.primaryColor,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12.0),
side: BorderSide(color: Colors.grey.withAlpha(50), width: 1),
),
onChanged: _showOptInDisclaimer,
),
),
if (_isUploadEnabled)
_buildSettingsTile(
context: context,
icon: Icons.cloud_outlined,
title: 'Adresse du Serveur IA',
subtitle: 'http://localhost:3000/api/upload',
onTap: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Changement d\'adresse à venir')),
);
},
),
if (_isUploadEnabled)
_buildSettingsTile(
context: context,
icon: Icons.data_usage,
title: 'Photos Exportées',
subtitle: _isLoadingStats
? 'Chargement...'
: (_photoCount != null ? '$_photoCount photos envoyées à l\'IA' : 'Non disponible'),
onTap: () {
if (_identityPhrase != null) {
_fetchStats(_identityPhrase!);
}
},
),
const SizedBox(height: 24),
_buildSectionHeader('À propos'),
_buildSettingsTile(
context: context,
icon: Icons.info_outline,
title: 'Version de l\'application',
subtitle: '1.0.0',
onTap: () {},
),
_buildSettingsTile(
context: context,
icon: Icons.privacy_tip_outlined,
title: 'Politique de confidentialité',
onTap: () {},
),
],
),
);
}
Widget _buildSectionHeader(String title) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 4.0),
child: Text(
title,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: AppTheme.primaryColor,
),
),
);
}
Widget _buildSettingsTile({
required BuildContext context,
required IconData icon,
required String title,
String? subtitle,
required VoidCallback onTap,
}) {
return Card(
elevation: 0,
color: Colors.transparent,
margin: const EdgeInsets.only(bottom: 8.0),
child: ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 4.0),
leading: Icon(icon, color: AppTheme.textPrimary),
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)),
subtitle: subtitle != null ? Text(subtitle, style: const TextStyle(fontSize: 12)) : null,
trailing: const Icon(Icons.chevron_right, color: Colors.grey),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12.0),
side: BorderSide(color: Colors.grey.withAlpha(50), width: 1),
),
onTap: onTap,
),
);
}
}

View File

@@ -1,9 +1,8 @@
import 'dart:io'; import 'dart:math' as math;
import 'dart:ui' as ui;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:fl_chart/fl_chart.dart'; // La librairie pour les courbes import 'package:fl_chart/fl_chart.dart'; // La librairie pour les courbes
import '../../core/constants/app_constants.dart';
import '../../core/theme/app_theme.dart';
import '../../data/models/session.dart'; import '../../data/models/session.dart';
import '../../data/repositories/session_repository.dart'; import '../../data/repositories/session_repository.dart';
import '../../services/statistics_service.dart'; import '../../services/statistics_service.dart';
@@ -27,6 +26,8 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
// Valeurs pour les Dropdowns (Filtres) // Valeurs pour les Dropdowns (Filtres)
String _selectedWeapon = 'Toutes'; String _selectedWeapon = 'Toutes';
String _selectedDistance = 'Toutes'; String _selectedDistance = 'Toutes';
List<String> _availableWeapons = ['Toutes'];
List<String> _availableDistances = ['Toutes'];
@override @override
void initState() { void initState() {
@@ -40,6 +41,35 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
try { try {
final repository = context.read<SessionRepository>(); final repository = context.read<SessionRepository>();
_allSessions = await repository.getAllSessions(); _allSessions = await repository.getAllSessions();
// Extraire les armes et distances uniques pour les filtres
final weaponsFromSessions = _allSessions.map((s) => s.weapon).toSet();
// Récupérer aussi les armes de l'armurerie pour s'assurer qu'elles sont présentes
final armoryWeapons = await repository.getWeapons();
final weaponsFromArmory = armoryWeapons.map((w) => w.displayName).toSet();
// Combiner et trier
final allWeaponsList = {...weaponsFromSessions, ...weaponsFromArmory}.toList()..sort();
final distances = _allSessions.map((s) => '${s.distance}m').toSet().toList()
..sort((a, b) {
final distA = int.tryParse(a.replaceAll('m', '')) ?? 0;
final distB = int.tryParse(b.replaceAll('m', '')) ?? 0;
return distA.compareTo(distB);
});
_availableWeapons = ['Toutes', ...allWeaponsList];
_availableDistances = ['Toutes', ...distances];
// Sécurité : si l'arme sélectionnée n'est plus disponible, on reset
if (!_availableWeapons.contains(_selectedWeapon)) {
_selectedWeapon = 'Toutes';
}
if (!_availableDistances.contains(_selectedDistance)) {
_selectedDistance = 'Toutes';
}
_calculateStats(); _calculateStats();
} catch (e) { } catch (e) {
debugPrint('Error: $e'); debugPrint('Error: $e');
@@ -49,46 +79,86 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
} }
void _calculateStats() { void _calculateStats() {
// Filtrer les sessions avant calcul
final filteredSessions = _allSessions.where((s) {
final weaponMatch = _selectedWeapon == 'Toutes' || s.weapon == _selectedWeapon;
final distanceMatch = _selectedDistance == 'Toutes' || '${s.distance}m' == _selectedDistance;
return weaponMatch && distanceMatch;
}).toList();
_statistics = _statisticsService.calculateStatistics( _statistics = _statisticsService.calculateStatistics(
_allSessions, filteredSessions,
period: _selectedPeriod, period: _selectedPeriod,
); );
} }
List<double> _getScoreHistory() {
if (_statistics == null || _statistics!.sessions.isEmpty) return [0];
// Sort sessions by date and take last 10
final sortedSessions = List<Session>.from(_statistics!.sessions)
..sort((a, b) => a.createdAt.compareTo(b.createdAt));
return sortedSessions.map((s) => s.totalScore.toDouble()).toList();
}
List<double> _getPrecisionHistory() {
if (_statistics == null || _statistics!.sessions.isEmpty) return [0];
// We would need to calculate precision for each session individually to have a history.
// For now, let's just use the score trend as a proxy if we don't have per-session precision cached.
// Actually, let's use the scores but normalized if possible.
final sortedSessions = List<Session>.from(_statistics!.sessions)
..sort((a, b) => a.createdAt.compareTo(b.createdAt));
return sortedSessions.map((s) {
if (s.totalShots == 0) return 0.0;
// Simple precision proxy: total score / max possible (assuming 10 is max per shot)
return (s.totalScore / (s.totalShots * 10)) * 100;
}).toList();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
backgroundColor: const Color(
0xFF121212,
), // Fond sombre comme sur le design
appBar: AppBar( appBar: AppBar(
backgroundColor: Colors.transparent, title: const Text('Statistiques'),
elevation: 0,
leading: IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.white),
onPressed: () => Navigator.pop(context),
),
title: const Text(
'Statistiques',
style: TextStyle(color: Colors.white),
),
centerTitle: true, centerTitle: true,
), ),
body: _isLoading body: _isLoading
? const Center(child: CircularProgressIndicator()) ? const Center(child: CircularProgressIndicator())
: SingleChildScrollView( : RefreshIndicator(
padding: const EdgeInsets.all(16), onRefresh: _loadStatistics,
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16),
child: Column( child: Column(
children: [ children: [
// 1. FILTRES (Arme et Distance) // 1. FILTRES (Arme et Distance)
Row( Row(
children: [ children: [
Expanded( Expanded(
child: _buildDropdown('Arme utilisée', _selectedWeapon), child: _buildDropdown(
'Arme utilisée',
_selectedWeapon,
_availableWeapons,
(val) {
setState(() {
_selectedWeapon = val!;
_calculateStats();
});
},
),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
child: _buildDropdown('Distance', _selectedDistance), child: _buildDropdown(
'Distance',
_selectedDistance,
_availableDistances,
(val) {
setState(() {
_selectedDistance = val!;
_calculateStats();
});
},
),
), ),
], ],
), ),
@@ -120,84 +190,78 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
_buildChartSection( _buildChartSection(
'Score', 'Score',
'${_statistics?.totalScore ?? 0}', '${_statistics?.totalScore ?? 0}',
[8, 12, 10, 15, 14, 18], _getScoreHistory(),
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
_buildChartSection( _buildChartSection(
'Précision', 'Précision',
'${_statistics?.precision.precisionScore.toStringAsFixed(1)}%', '${_statistics?.precision.precisionScore.toStringAsFixed(1)}%',
[60, 75, 70, 85, 80, 95], _getPrecisionHistory(),
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
_buildChartSection('Groupement moyen', '14.2 mm', [ _buildChartSection(
20, 'Groupement moyen',
18, '${(_statistics?.precision.groupingDiameter ?? 0 * 100).toStringAsFixed(1)}%',
22, _getScoreHistory().map((e) => e / 10).toList(), // Proxy for grouping history
15, ),
14,
12, const SizedBox(height: 25),
]), // mm fictif pour l'exemple
// 4. DISTRIBUTION ET BIAIS (Restauré)
if (_statistics != null && _statistics!.totalShots > 0) ...[
_buildRegionalDistribution(),
const SizedBox(height: 20),
_buildQuadrantGrid(),
const SizedBox(height: 20),
_buildHeatMapSection(),
const SizedBox(height: 20),
if (_statistics!.regional.biasX.abs() > 0.05 ||
_statistics!.regional.biasY.abs() > 0.05)
_buildBiasWarning(),
],
const SizedBox(height: 30), const SizedBox(height: 30),
// 4. BOUTON ACCUEIL
SizedBox(
width: double.infinity,
height: 50,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF1A73E8),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
onPressed: () => Navigator.of(
context,
).popUntil((route) => route.isFirst),
child: const Text(
'ACCUEIL',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
),
),
], ],
), ),
), ),
),
); );
} }
// Widget pour les Dropdowns de filtres Widget _buildDropdown(
Widget _buildDropdown(String label, String value) { String label,
String value,
List<String> items,
void Function(String?) onChanged,
) {
final theme = Theme.of(context);
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFF1E1E1E), color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.white12), border: Border.all(color: theme.dividerColor),
), ),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
label, label,
style: const TextStyle(color: Colors.white54, fontSize: 10), style: TextStyle(color: theme.textTheme.bodySmall?.color?.withValues(alpha: 0.6), fontSize: 10),
), ),
DropdownButton<String>( DropdownButton<String>(
value: value, value: value,
isExpanded: true, isExpanded: true,
underline: Container(), underline: Container(),
dropdownColor: const Color(0xFF1E1E1E), dropdownColor: theme.colorScheme.surfaceContainerHighest,
style: const TextStyle(color: Colors.white, fontSize: 14), style: TextStyle(color: theme.textTheme.bodyMedium?.color, fontSize: 14),
items: [value] items: items
.map( .map(
(String val) => (String val) =>
DropdownMenuItem(value: val, child: Text(val)), DropdownMenuItem(value: val, child: Text(val)),
) )
.toList(), .toList(),
onChanged: (newValue) {}, onChanged: onChanged,
), ),
], ],
), ),
@@ -206,10 +270,11 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
// Widget pour les petites cartes de stats // Widget pour les petites cartes de stats
Widget _buildQuickStat(String label, String value, IconData icon) { Widget _buildQuickStat(String label, String value, IconData icon) {
final theme = Theme.of(context);
return Container( return Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFF1E1E1E), color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
child: Column( child: Column(
@@ -218,15 +283,15 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
value, value,
style: const TextStyle( style: TextStyle(
color: Colors.white, color: theme.textTheme.titleLarge?.color,
fontSize: 20, fontSize: 20,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
Text( Text(
label, label,
style: const TextStyle(color: Colors.white54, fontSize: 12), style: TextStyle(color: theme.textTheme.bodySmall?.color?.withValues(alpha: 0.6), fontSize: 12),
), ),
], ],
), ),
@@ -239,10 +304,11 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
String value, String value,
List<double> dataPoints, List<double> dataPoints,
) { ) {
final theme = Theme.of(context);
return Container( return Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFF1E1E1E), color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
child: Column( child: Column(
@@ -250,15 +316,15 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
children: [ children: [
Text( Text(
title, title,
style: const TextStyle(color: Colors.white70, fontSize: 14), style: TextStyle(color: theme.textTheme.bodyMedium?.color?.withValues(alpha: 0.7), fontSize: 14),
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
Row( Row(
children: [ children: [
Text( Text(
value, value,
style: const TextStyle( style: TextStyle(
color: Colors.white, color: theme.textTheme.headlineMedium?.color,
fontSize: 24, fontSize: 24,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
@@ -278,17 +344,20 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
} }
LineChartData _mainChartData(List<double> points) { LineChartData _mainChartData(List<double> points) {
if (points.isEmpty) points = [0];
return LineChartData( return LineChartData(
gridData: const FlGridData(show: false), gridData: const FlGridData(show: false),
titlesData: const FlTitlesData(show: false), titlesData: const FlTitlesData(show: false),
borderData: FlBorderData(show: false), borderData: FlBorderData(show: false),
lineBarsData: [ lineBarsData: [
LineChartBarData( LineChartBarData(
spots: points spots: points.length == 1
.asMap() ? [const FlSpot(0, 0), FlSpot(1, points[0])]
.entries : points
.map((e) => FlSpot(e.key.toDouble(), e.value)) .asMap()
.toList(), .entries
.map((e) => FlSpot(e.key.toDouble(), e.value))
.toList(),
isCurved: true, isCurved: true,
color: const Color(0xFF4CAF50), // Vert comme sur ton design color: const Color(0xFF4CAF50), // Vert comme sur ton design
barWidth: 3, barWidth: 3,
@@ -296,10 +365,373 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
dotData: const FlDotData(show: false), dotData: const FlDotData(show: false),
belowBarData: BarAreaData( belowBarData: BarAreaData(
show: true, show: true,
color: const Color(0xFF4CAF50).withOpacity(0.2), color: const Color(0xFF4CAF50).withValues(alpha: 0.2),
), ),
), ),
], ],
); );
} }
// --- NOUVEAUX WIDGETS RESTAURÉS ---
Widget _buildRegionalDistribution() {
final regional = _statistics!.regional;
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Distribution Régionale',
style: TextStyle(
color: theme.textTheme.titleMedium?.color,
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
Row(
children: [
const Icon(Icons.explore, color: Color(0xFF1A73E8), size: 20),
const SizedBox(width: 8),
Text(
'Direction dominante : ${regional.dominantDirection}',
style: TextStyle(color: theme.textTheme.bodyMedium?.color?.withValues(alpha: 0.7), fontSize: 14),
),
],
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: regional.sectorDistribution.entries
.where((e) => e.value > 0)
.map((e) {
final percentage = (e.value / _statistics!.totalShots * 100);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: const Color(0xFF1A73E8).withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: const Color(0xFF1A73E8).withValues(alpha: 0.3)),
),
child: Text(
'${e.key}: ${e.value} (${percentage.toStringAsFixed(0)}%)',
style: TextStyle(color: theme.textTheme.bodySmall?.color, fontSize: 12),
),
);
}).toList(),
),
],
),
);
}
Widget _buildQuadrantGrid() {
final quadrants = _statistics!.regional.quadrantDistribution;
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Répartition par Quadrant',
style: TextStyle(
color: theme.textTheme.titleMedium?.color,
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
AspectRatio(
aspectRatio: 1,
child: GridView.count(
crossAxisCount: 2,
mainAxisSpacing: 4,
crossAxisSpacing: 4,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
children: [
_buildQuadrantCell('Haut-Gauche', quadrants['Haut-Gauche'] ?? 0),
_buildQuadrantCell('Haut-Droite', quadrants['Haut-Droite'] ?? 0),
_buildQuadrantCell('Bas-Gauche', quadrants['Bas-Gauche'] ?? 0),
_buildQuadrantCell('Bas-Droite', quadrants['Bas-Droite'] ?? 0),
],
),
),
],
),
);
}
Widget _buildHeatMapSection() {
final heatMap = _statistics!.heatMap;
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Zones de Chaleur (Heatmap)',
style: TextStyle(
color: theme.textTheme.titleMedium?.color,
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
'Analyse de densité des impacts',
style: TextStyle(color: theme.textTheme.bodySmall?.color?.withValues(alpha: 0.6), fontSize: 12),
),
const SizedBox(height: 20),
AspectRatio(
aspectRatio: 1,
child: Container(
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.white10),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: CustomPaint(
painter: _HeatMapPainter(heatMap: heatMap),
),
),
),
),
const SizedBox(height: 16),
// Légende dégradée
Container(
height: 12,
width: double.infinity,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6),
gradient: const LinearGradient(
colors: [
Colors.blue,
Colors.yellow,
Colors.orange,
Colors.red,
],
),
),
),
const SizedBox(height: 4),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Faible', style: TextStyle(fontSize: 10, color: Colors.white.withValues(alpha: 0.5))),
Text('Élevée', style: TextStyle(fontSize: 10, color: Colors.white.withValues(alpha: 0.5))),
],
),
],
),
);
}
Widget _buildQuadrantCell(String label, int count) {
final percentage = (count / _statistics!.totalShots * 100);
final intensity = count / _statistics!.totalShots;
return Container(
decoration: BoxDecoration(
color: Color.lerp(
Theme.of(context).colorScheme.surfaceContainerHighest,
const Color(0xFF1A73E8),
intensity,
)!.withValues(alpha: 0.8),
borderRadius: BorderRadius.circular(8),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'$count',
style: TextStyle(
color: Theme.of(context).textTheme.titleLarge?.color,
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
Text(
'${percentage.toStringAsFixed(0)}%',
style: TextStyle(color: Theme.of(context).textTheme.bodySmall?.color?.withValues(alpha: 0.7), fontSize: 12),
),
Text(
label,
style: TextStyle(color: Theme.of(context).textTheme.bodySmall?.color?.withValues(alpha: 0.5), fontSize: 10),
),
],
),
);
}
Widget _buildBiasWarning() {
final regional = _statistics!.regional;
String description = '';
if (regional.biasX.abs() > 0.05) {
description += regional.biasX > 0 ? 'vers la droite' : 'vers la gauche';
}
if (regional.biasY.abs() > 0.05) {
if (description.isNotEmpty) description += ' et ';
description += regional.biasY > 0 ? 'vers le bas' : 'vers le haut';
}
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.orange.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.orange.withValues(alpha: 0.3)),
),
child: Row(
children: [
const Icon(Icons.warning_amber_rounded, color: Colors.orange),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Biais détecté',
style: TextStyle(
color: Colors.orange,
fontWeight: FontWeight.bold,
),
),
Text(
'Tendance $description',
style: TextStyle(color: Theme.of(context).textTheme.bodyMedium?.color?.withValues(alpha: 0.7), fontSize: 13),
),
],
),
),
],
),
);
}
}
class _HeatMapPainter extends CustomPainter {
final HeatMap heatMap;
_HeatMapPainter({required this.heatMap});
@override
void paint(Canvas canvas, Size size) {
_drawTargetBackground(canvas, size);
_drawHeatGradient(canvas, size);
}
void _drawTargetBackground(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2);
final maxRadius = math.min(size.width, size.height) * 0.45;
final paint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 1
..color = Colors.grey.withValues(alpha: 0.2); // Neutre
// Draw rings
for (int i = 1; i <= 10; i++) {
canvas.drawCircle(center, maxRadius * (i / 10), paint);
}
// Draw crosshair
canvas.drawLine(Offset(0, center.dy), Offset(size.width, center.dy), paint);
canvas.drawLine(Offset(center.dx, 0), Offset(center.dx, size.height), paint);
}
void _drawHeatGradient(Canvas canvas, Size size) {
final cellSizeW = size.width / heatMap.gridSize;
final cellSizeH = size.height / heatMap.gridSize;
for (var row in heatMap.zones) {
for (var zone in row) {
if (zone.intensity <= 0) continue;
final center = Offset(
(zone.col + 0.5) * cellSizeW,
(zone.row + 0.5) * cellSizeH,
);
final radius = cellSizeW * 1.2; // Légèrement réduit pour plus de netteté
// Normalisation de l'intensité : on rend le max toujours très visible (rouge)
final normalizedIntensity = zone.intensity;
final color = _getIntensityColor(normalizedIntensity);
final paint = Paint()
..shader = ui.Gradient.radial(
center,
radius,
[
color.withValues(alpha: 0.7 * normalizedIntensity.clamp(0.4, 1.0)),
color.withValues(alpha: 0.0),
],
[0.2, 1.0],
)
..style = PaintingStyle.fill; // srcOver par défaut, plus lisible
canvas.drawCircle(center, radius, paint);
// --- AFFICHAGE DU NOMBRE D'IMPACTS ---
if (zone.shotCount > 0) {
final textPainter = TextPainter(
text: TextSpan(
text: '${zone.shotCount}',
style: TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.bold,
shadows: [
Shadow(
color: Colors.black.withValues(alpha: 0.8),
blurRadius: 2,
offset: const Offset(1, 1),
),
],
),
),
textDirection: TextDirection.ltr,
);
textPainter.layout();
textPainter.paint(
canvas,
Offset(center.dx - textPainter.width / 2, center.dy - textPainter.height / 2),
);
}
}
}
}
Color _getIntensityColor(double intensity) {
if (intensity < 0.2) return Colors.blue;
if (intensity < 0.5) return Colors.yellow;
if (intensity < 0.8) return Colors.orange;
return Colors.red;
}
@override
bool shouldRepaint(covariant _HeatMapPainter oldDelegate) =>
heatMap != oldDelegate.heatMap;
} }

View File

@@ -5,11 +5,13 @@ import 'package:intl/date_symbol_data_local.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart'; import 'package:sqflite_common_ffi/sqflite_ffi.dart';
import 'app.dart'; import 'app.dart';
import 'core/theme/theme_provider.dart';
import 'data/repositories/session_repository.dart'; import 'data/repositories/session_repository.dart';
import 'services/target_detection_service.dart'; import 'services/target_detection_service.dart';
import 'services/score_calculator_service.dart'; import 'services/score_calculator_service.dart';
import 'services/grouping_analyzer_service.dart'; import 'services/grouping_analyzer_service.dart';
import 'services/image_processing_service.dart'; import 'services/image_processing_service.dart';
import 'features/session/session_provider.dart';
void main() async { void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
@@ -45,6 +47,8 @@ void main() async {
create: (_) => GroupingAnalyzerService(), create: (_) => GroupingAnalyzerService(),
), ),
Provider<SessionRepository>(create: (_) => SessionRepository()), Provider<SessionRepository>(create: (_) => SessionRepository()),
ChangeNotifierProvider<ThemeProvider>(create: (_) => ThemeProvider()),
ChangeNotifierProvider<SessionProvider>(create: (_) => SessionProvider()),
], ],
child: const BullyApp(), child: const BullyApp(),
), ),

View File

@@ -0,0 +1,82 @@
import 'package:flutter/material.dart';
import 'features/home/home_screen.dart';
import 'features/history/history_screen.dart';
import 'features/statistics/statistics_screen.dart';
import 'features/garage/weapon_list_screen.dart';
import 'core/theme/app_theme.dart';
class MainNavigationHolder extends StatefulWidget {
const MainNavigationHolder({super.key});
@override
State<MainNavigationHolder> createState() => _MainNavigationHolderState();
}
class _MainNavigationHolderState extends State<MainNavigationHolder> {
int _selectedIndex = 0;
final List<Widget> _screens = [
const HomeScreen(),
const HistoryScreen(),
const StatisticsScreen(),
const WeaponListScreen(),
];
void _onItemTapped(int index) {
setState(() {
_selectedIndex = index;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: IndexedStack(
index: _selectedIndex,
children: _screens,
),
bottomNavigationBar: Container(
decoration: BoxDecoration(
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 10,
offset: const Offset(0, -2),
),
],
),
child: BottomNavigationBar(
currentIndex: _selectedIndex,
onTap: _onItemTapped,
type: BottomNavigationBarType.fixed,
backgroundColor: Theme.of(context).cardColor,
selectedItemColor: AppTheme.primaryColor,
unselectedItemColor: Colors.grey,
showUnselectedLabels: true,
items: const [
BottomNavigationBarItem(
icon: Icon(Icons.home_outlined),
activeIcon: Icon(Icons.home),
label: 'Accueil',
),
BottomNavigationBarItem(
icon: Icon(Icons.history_outlined),
activeIcon: Icon(Icons.history),
label: 'Historique',
),
BottomNavigationBarItem(
icon: Icon(Icons.analytics_outlined),
activeIcon: Icon(Icons.analytics),
label: 'Stats',
),
BottomNavigationBarItem(
icon: Icon(Icons.shield_outlined),
activeIcon: Icon(Icons.shield),
label: 'Armurerie',
),
],
),
),
);
}
}

View File

@@ -0,0 +1,147 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'dart:io';
import 'package:http/http.dart' as http;
import 'package:device_info_plus/device_info_plus.dart';
import 'package:crypto/crypto.dart';
import '../data/models/shot.dart';
import '../data/models/target_type.dart';
import 'wallet_identity_service.dart';
class AiExportService {
// Utilise 10.0.2.2 pour l'émulateur Android, sinon localhost.
// Pour un appareil physique, il faudra utiliser l'IP locale du PC (ex: 192.168.1.X).
static String get _defaultApiUrl {
if (Platform.isAndroid) {
return 'http://10.0.2.2:3000/api/upload';
}
return 'http://localhost:3000/api/upload';
}
/// Extrait les informations de l'appareil
Future<Map<String, dynamic>> _getDeviceInfo() async {
final DeviceInfoPlugin deviceInfoPlugin = DeviceInfoPlugin();
Map<String, dynamic> deviceData = {'model': 'Unknown', 'os': 'Unknown'};
try {
if (Platform.isAndroid) {
final androidInfo = await deviceInfoPlugin.androidInfo;
deviceData['model'] = '${androidInfo.brand} ${androidInfo.model}';
deviceData['os'] = 'Android ${androidInfo.version.release}';
} else if (Platform.isIOS) {
final iosInfo = await deviceInfoPlugin.iosInfo;
deviceData['model'] = iosInfo.name;
deviceData['os'] = '${iosInfo.systemName} ${iosInfo.systemVersion}';
} else if (Platform.isWindows) {
final windowsInfo = await deviceInfoPlugin.windowsInfo;
deviceData['model'] = 'Windows PC';
deviceData['os'] = 'Windows ${windowsInfo.majorVersion}.${windowsInfo.minorVersion}';
}
} catch (e) {
debugPrint('Erreur lors de la récupération des infos appareil: $e');
}
return deviceData;
}
/// Exporte l'image et les données de plotting vers le serveur
Future<bool> exportData({
required String imagePath,
required String sessionId,
required TargetType targetType,
required double targetCenterX,
required double targetCenterY,
required double targetRadius,
required List<Shot> shots,
String? apiUrl,
}) async {
try {
final url = Uri.parse(apiUrl ?? _defaultApiUrl);
final request = http.MultipartRequest('POST', url);
// 1. Prepare image
final file = File(imagePath);
if (!await file.exists()) {
throw Exception('Le fichier image n\'existe pas');
}
// Read image metadata (approximate dimensions since decoding image can be heavy)
// On the frontend we usually have aspectRatio, here we use generic values if not available.
final deviceData = await _getDeviceInfo();
// We approximate the target corners from center and radius
// radius is relative (0 to 1). We need image width/height to get pixels.
// But we can just pass relative corners as well, or a normalized bounding box.
// Let's create normalized corners (0 to 1).
final corners = [
{"norm_x": targetCenterX - targetRadius, "norm_y": targetCenterY - targetRadius},
{"norm_x": targetCenterX + targetRadius, "norm_y": targetCenterY - targetRadius},
{"norm_x": targetCenterX + targetRadius, "norm_y": targetCenterY + targetRadius},
{"norm_x": targetCenterX - targetRadius, "norm_y": targetCenterY + targetRadius},
];
// Format the impacts
final formattedImpacts = shots.asMap().entries.map((entry) {
final index = entry.key;
final shot = entry.value;
return {
"id": index + 1,
"label": "bullet_hole",
"score": shot.score,
"coords": {
"norm_x": shot.x,
"norm_y": shot.y
}
};
}).toList();
// Get and hash the wallet identity
final walletService = WalletIdentityService();
final phrase = await walletService.getIdentityPhrase();
final phraseBytes = utf8.encode(phrase);
final walletHash = sha256.convert(phraseBytes).toString();
// Build JSON payload
final plottingJson = {
"session_id": sessionId,
"wallet_hash": walletHash,
"timestamp": DateTime.now().toIso8601String(),
"device_info": deviceData,
"target_metadata": {
"type": targetType.name,
"distance_meters": 25, // Default/placeholder
"weapon": "Unknown", // Default/placeholder
// The backend could extract exact width/height from the image.
},
"plotting": {
"target_corners": corners,
"impacts": formattedImpacts
}
};
// Add fields to request
request.fields['plotting'] = jsonEncode(plottingJson);
// Add file
request.files.add(
await http.MultipartFile.fromPath('photo', imagePath),
);
// Send request
final response = await request.send();
if (response.statusCode == 200) {
final responseData = await response.stream.bytesToString();
debugPrint('Export réussi: $responseData');
return true;
} else {
final errorData = await response.stream.bytesToString();
debugPrint('Erreur d\'export: ${response.statusCode} - $errorData');
return false;
}
} catch (e) {
debugPrint('Exception lors de l\'export: $e');
return false;
}
}
}

View File

@@ -8,6 +8,7 @@ library;
import 'dart:io'; import 'dart:io';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:image/image.dart' as img; import 'package:image/image.dart' as img;
import 'package:flutter/foundation.dart';
import 'package:opencv_dart/opencv_dart.dart' as cv; import 'package:opencv_dart/opencv_dart.dart' as cv;
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
@@ -562,8 +563,9 @@ class DistortionCorrectionService {
double maxArea = 0; double maxArea = 0;
for (final contour in contours) { for (final contour in contours) {
if (contour.length < 5) if (contour.length < 5) {
continue; // fitEllipse nécessite au moins 5 points continue; // fitEllipse nécessite au moins 5 points
}
final area = cv.contourArea(contour); final area = cv.contourArea(contour);
if (area < 1000) continue; // Ignorer les trop petits bruits if (area < 1000) continue; // Ignorer les trop petits bruits
@@ -636,7 +638,7 @@ class DistortionCorrectionService {
return outputPath; return outputPath;
} catch (e) { } catch (e) {
// En cas d'erreur, retourner l'image originale // En cas d'erreur, retourner l'image originale
print('Erreur correction perspective cercles: $e'); debugPrint('Erreur correction perspective cercles: $e');
return imagePath; return imagePath;
} }
} }
@@ -761,7 +763,7 @@ class DistortionCorrectionService {
return outputPath; return outputPath;
} catch (e) { } catch (e) {
print('Erreur correction perspective ovales: $e'); debugPrint('Erreur correction perspective ovales: $e');
return imagePath; return imagePath;
} }
} }
@@ -822,7 +824,7 @@ class DistortionCorrectionService {
} }
if (concentricGroup.length < 2) { if (concentricGroup.length < 2) {
print( debugPrint(
"Pas assez de cercles concentriques pour le maillage, utilisation de la méthode simple.", "Pas assez de cercles concentriques pour le maillage, utilisation de la méthode simple.",
); );
return await correctPerspectiveUsingOvals(imagePath); return await correctPerspectiveUsingOvals(imagePath);
@@ -950,7 +952,7 @@ class DistortionCorrectionService {
return outputPath; return outputPath;
} catch (e) { } catch (e) {
print('Erreur correction perspective maillage concentrique: $e'); debugPrint('Erreur correction perspective maillage concentrique: $e');
return imagePath; return imagePath;
} }
} }
@@ -1012,7 +1014,7 @@ class DistortionCorrectionService {
// Fallback // Fallback
if (bestQuad == null) { if (bestQuad == null) {
print( debugPrint(
"Aucun papier quadrilatère détecté, on utilise les cercles à la place.", "Aucun papier quadrilatère détecté, on utilise les cercles à la place.",
); );
return await correctPerspectiveUsingCircles(imagePath); return await correctPerspectiveUsingCircles(imagePath);
@@ -1060,7 +1062,7 @@ class DistortionCorrectionService {
return outputPath; return outputPath;
} catch (e) { } catch (e) {
print('Erreur correction perspective quadrilatère: $e'); debugPrint('Erreur correction perspective quadrilatère: $e');
// Fallback // Fallback
return await correctPerspectiveUsingCircles(imagePath); return await correctPerspectiveUsingCircles(imagePath);
} }

View File

@@ -0,0 +1,54 @@
import 'package:google_mlkit_document_scanner/google_mlkit_document_scanner.dart';
/// Résultat du scan de document.
class DocumentScanResult {
/// Chemin de l'image redressée (de face), ou null si l'utilisateur a annulé.
final String? imagePath;
/// true si un scan a bien été produit.
bool get success => imagePath != null;
const DocumentScanResult(this.imagePath);
}
/// Service qui lance le scanner de documents Google ML Kit.
///
/// Le scanner ouvre une UI plein écran fournie par Google Play Services :
/// caméra, détection des bords de la feuille en direct, recadrage manuel des
/// 4 coins, puis renvoie l'image REDRESSÉE de face. Idéal pour une cible de
/// tir, qui est imprimée sur un carton/feuille rectangulaire.
///
/// NOTE : disponible uniquement sur Android (fonctionnalité ML Kit en bêta).
class DocumentScannerService {
/// Lance le scanner et renvoie le chemin de l'image redressée.
///
/// [pageLimit] est fixé à 1 (une seule cible par scan).
/// On désactive l'import galerie ici car la galerie est gérée séparément.
///
/// IMPORTANT : on utilise [ScannerMode.base] et NON [ScannerMode.full].
/// - `full`/`filter` appliquent des filtres "document" (noir & blanc,
/// rehaussement type photocopie) → détruit les couleurs de la cible.
/// - `base` ne fait QUE le recadrage + le redressement de perspective
/// (mise de face), en conservant l'image en couleurs réelles. C'est
/// indispensable pour pouvoir détecter ensuite les impacts.
Future<DocumentScanResult> scanTarget() async {
final options = DocumentScannerOptions(
mode: ScannerMode.base, // redressement seul, sans filtre couleur
pageLimit: 1,
isGalleryImport: false,
);
final scanner = DocumentScanner(options: options);
try {
final DocumentScanningResult result = await scanner.scanDocument();
final images = result.images;
if (images.isEmpty) {
return const DocumentScanResult(null);
}
return DocumentScanResult(images.first);
} finally {
// Libère les ressources natives du scanner.
await scanner.close();
}
}
}

View File

@@ -1,27 +1,14 @@
/// Service de recadrage d'images. import 'dart:isolate'; // AJOUT
///
/// Permet de recadrer une image en format carré (1:1) et de la sauvegarder
/// dans un fichier temporaire pour utilisation ultérieure.
library;
import 'dart:io'; import 'dart:io';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:image/image.dart' as img; import 'package:image/image.dart' as img;
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
/// Représente une zone de recadrage normalisée (0.0 à 1.0)
class CropRect { class CropRect {
/// Position X du coin supérieur gauche (0.0 à 1.0)
final double x; final double x;
/// Position Y du coin supérieur gauche (0.0 à 1.0)
final double y; final double y;
/// Largeur de la zone (0.0 à 1.0)
final double width; final double width;
/// Hauteur de la zone (0.0 à 1.0)
final double height; final double height;
const CropRect({ const CropRect({
@@ -35,97 +22,157 @@ class CropRect {
String toString() => 'CropRect(x: $x, y: $y, w: $width, h: $height)'; String toString() => 'CropRect(x: $x, y: $y, w: $width, h: $height)';
} }
/// Service pour recadrer les images // AJOUT : Paramètres passés à l'Isolate (tout doit être sérialisable)
class _CropParams {
final String sourcePath;
final double cropX;
final double cropY;
final double cropWidth;
final double cropHeight;
final double rotationDegrees;
final int outputSize;
final String outputPath;
_CropParams({
required this.sourcePath,
required this.cropX,
required this.cropY,
required this.cropWidth,
required this.cropHeight,
required this.rotationDegrees,
required this.outputSize,
required this.outputPath,
});
}
// AJOUT : Fonction statique exécutée dans l'Isolate (doit être top-level ou static)
void _cropIsolateEntry(_CropParams params) {
final file = File(params.sourcePath);
final bytes = file.readAsBytesSync();
img.Image? originalImage = img.decodeImage(bytes);
if (originalImage == null) {
throw Exception('Impossible de décoder l\'image: ${params.sourcePath}');
}
// ── OPTIMISATION MAJEURE : pré-réduction avant rotation ───────────────────
// La rotation (copyRotate) est l'opération la plus lourde du package `image`
// et son coût est proportionnel au nombre de pixels. Comme la sortie finale
// ne fait que `outputSize` px, on n'a aucun intérêt à faire pivoter une image
// de plusieurs dizaines de mégapixels.
//
// On réduit donc l'image source à une "taille de travail" juste suffisante
// AVANT toute rotation/crop. Le crop garde ~85% du cadre, donc on laisse une
// marge : workMax = outputSize / 0.7 couvre largement le besoin sans perte
// visible. Les coordonnées de crop étant relatives (0..1), elles restent
// valables quelle que soit l'échelle.
final int workMax = (params.outputSize / 0.7).round();
final int longestSide = math.max(originalImage.width, originalImage.height);
if (longestSide > workMax) {
if (originalImage.width >= originalImage.height) {
originalImage = img.copyResize(
originalImage,
width: workMax,
interpolation: img.Interpolation.linear,
);
} else {
originalImage = img.copyResize(
originalImage,
height: workMax,
interpolation: img.Interpolation.linear,
);
}
}
// Rotation si nécessaire — LINEAR au lieu de CUBIC pour la vitesse
// (désormais appliquée sur une image déjà réduite → beaucoup plus rapide)
if (params.rotationDegrees != 0.0) {
originalImage = img.copyRotate(
originalImage,
angle: params.rotationDegrees,
interpolation: img.Interpolation.linear, // OPTIMISATION : linear >> cubic en vitesse
);
}
// Recadrage
final srcX = (params.cropX * originalImage.width).round();
final srcY = (params.cropY * originalImage.height).round();
final srcWidth = (params.cropWidth * originalImage.width).round();
final srcHeight = (params.cropHeight * originalImage.height).round();
final clampedX = srcX.clamp(0, originalImage.width - 1);
final clampedY = srcY.clamp(0, originalImage.height - 1);
final clampedWidth = math.min(srcWidth, originalImage.width - clampedX);
final clampedHeight = math.min(srcHeight, originalImage.height - clampedY);
img.Image cropped = img.copyCrop(
originalImage,
x: clampedX,
y: clampedY,
width: clampedWidth,
height: clampedHeight,
);
// Redimensionnement — LINEAR au lieu de CUBIC
if (cropped.width != params.outputSize || cropped.height != params.outputSize) {
cropped = img.copyResize(
cropped,
width: params.outputSize,
height: params.outputSize,
interpolation: img.Interpolation.linear, // OPTIMISATION : linear >> cubic en vitesse
);
}
// Encodage JPEG qualité 85 au lieu de 90 (gain de vitesse non négligeable)
final outputFile = File(params.outputPath);
outputFile.writeAsBytesSync(img.encodeJpg(cropped, quality: 85));
}
class ImageCropService { class ImageCropService {
final Uuid _uuid = const Uuid(); final Uuid _uuid = const Uuid();
/// Taille de sortie maximale pour les images recadrées
static const int maxOutputSize = 1024; static const int maxOutputSize = 1024;
/// Recadre une image en format carré
///
/// [sourcePath] - Chemin vers l'image source
/// [cropRect] - Zone de recadrage normalisée (0.0 à 1.0)
/// [outputSize] - Taille de sortie en pixels (carré)
///
/// Retourne le chemin vers l'image recadrée dans le dossier temporaire
Future<String> cropToSquare( Future<String> cropToSquare(
String sourcePath, String sourcePath,
CropRect cropRect, { CropRect cropRect, {
int outputSize = maxOutputSize, double rotationDegrees = 0.0,
}) async { int outputSize = maxOutputSize,
// Charger l'image source }) async {
final file = File(sourcePath); final tempDir = await getTemporaryDirectory();
final bytes = await file.readAsBytes(); final outputPath = '${tempDir.path}/cropped_${_uuid.v4()}.jpg';
final originalImage = img.decodeImage(bytes);
if (originalImage == null) { final params = _CropParams(
throw Exception('Impossible de décoder l\'image: $sourcePath'); sourcePath: sourcePath,
} cropX: cropRect.x,
cropY: cropRect.y,
// Calculer les coordonnées en pixels cropWidth: cropRect.width,
final srcX = (cropRect.x * originalImage.width).round(); cropHeight: cropRect.height,
final srcY = (cropRect.y * originalImage.height).round(); rotationDegrees: rotationDegrees,
final srcWidth = (cropRect.width * originalImage.width).round(); outputSize: outputSize,
final srcHeight = (cropRect.height * originalImage.height).round(); outputPath: outputPath,
// S'assurer que les dimensions sont valides
final clampedX = srcX.clamp(0, originalImage.width - 1);
final clampedY = srcY.clamp(0, originalImage.height - 1);
final clampedWidth = math.min(srcWidth, originalImage.width - clampedX);
final clampedHeight = math.min(srcHeight, originalImage.height - clampedY);
// Recadrer l'image
img.Image cropped = img.copyCrop(
originalImage,
x: clampedX,
y: clampedY,
width: clampedWidth,
height: clampedHeight,
); );
// Redimensionner à la taille de sortie si nécessaire // OPTIMISATION CLEF : Tout le traitement lourd dans un Isolate séparé
if (cropped.width != outputSize || cropped.height != outputSize) { // → le thread UI reste fluide pendant le calcul
cropped = img.copyResize( await Isolate.run(() => _cropIsolateEntry(params));
cropped,
width: outputSize,
height: outputSize,
interpolation: img.Interpolation.cubic,
);
}
// Sauvegarder dans le dossier temporaire
final tempDir = await getTemporaryDirectory();
final fileName = 'cropped_${_uuid.v4()}.jpg';
final outputPath = '${tempDir.path}/$fileName';
final outputFile = File(outputPath);
await outputFile.writeAsBytes(img.encodeJpg(cropped, quality: 90));
return outputPath; return outputPath;
} }
/// Calcule la zone de recadrage carrée maximale centrée sur l'image /// Calcule la zone de recadrage carrée maximale centrée sur l'image
///
/// [imageWidth] - Largeur de l'image en pixels
/// [imageHeight] - Hauteur de l'image en pixels
///
/// Retourne un CropRect normalisé pour un carré centré
CropRect getDefaultSquareCrop(int imageWidth, int imageHeight) { CropRect getDefaultSquareCrop(int imageWidth, int imageHeight) {
final aspectRatio = imageWidth / imageHeight; final aspectRatio = imageWidth / imageHeight;
if (aspectRatio > 1) { if (aspectRatio > 1) {
// Image plus large que haute - centrer horizontalement
final squareWidth = imageHeight / imageWidth; final squareWidth = imageHeight / imageWidth;
final x = (1 - squareWidth) / 2; final x = (1 - squareWidth) / 2;
return CropRect(x: x, y: 0, width: squareWidth, height: 1); return CropRect(x: x, y: 0, width: squareWidth, height: 1);
} else if (aspectRatio < 1) { } else if (aspectRatio < 1) {
// Image plus haute que large - centrer verticalement
final squareHeight = imageWidth / imageHeight; final squareHeight = imageWidth / imageHeight;
final y = (1 - squareHeight) / 2; final y = (1 - squareHeight) / 2;
return CropRect(x: 0, y: y, width: 1, height: squareHeight); return CropRect(x: 0, y: y, width: 1, height: squareHeight);
} else { } else {
// Déjà carré
return const CropRect(x: 0, y: 0, width: 1, height: 1); return const CropRect(x: 0, y: 0, width: 1, height: 1);
} }
} }
@@ -147,7 +194,39 @@ class ImageCropService {
} }
} }
} catch (e) { } catch (e) {
// Ignorer les erreurs de nettoyage // Ignorer les erreurs
} }
} }
}
/// Coupe automatiquement une photo brute de l'appareil photo en carré
/// calé sur la position haute du viseur de l'écran.
Future<String> cropRawCameraToSquare(String sourcePath) async {
final file = File(sourcePath);
final bytes = await file.readAsBytes();
final originalImage = img.decodeImage(bytes);
if (originalImage == null) return sourcePath;
final int imgW = originalImage.width;
final int imgH = originalImage.height;
final int cropSizePixels = (math.min(imgW, imgH) * 0.85).round();
final int x = ((imgW - cropSizePixels) / 2).round();
final int y = ((imgH - cropSizePixels) / 2).round();
final squareImage = img.copyCrop(
originalImage,
x: x.clamp(0, imgW - 1),
y: y.clamp(0, imgH - 1),
width: cropSizePixels,
height: cropSizePixels,
);
final tempDir = await getTemporaryDirectory();
final outputPath = '${tempDir.path}/camera_square_${DateTime.now().millisecondsSinceEpoch}.jpg';
await File(outputPath).writeAsBytes(img.encodeJpg(squareImage, quality: 85));
return outputPath;
}
}

View File

@@ -1,4 +1,5 @@
import 'dart:io'; import 'dart:io';
import 'package:flutter/foundation.dart';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:image/image.dart' as img; import 'package:image/image.dart' as img;
@@ -189,7 +190,7 @@ class ImageProcessingService {
); );
}).toList(); }).toList();
} catch (e) { } catch (e) {
print('Error detecting impacts: $e'); debugPrint('Error detecting impacts: $e');
return []; return [];
} }
} }
@@ -236,14 +237,14 @@ class ImageProcessingService {
final fillRatios = <double>[]; final fillRatios = <double>[];
final thresholds = <double>[]; final thresholds = <double>[];
print('Analyzing ${references.length} reference impacts...'); debugPrint('Analyzing ${references.length} reference impacts...');
for (int refIndex = 0; refIndex < references.length; refIndex++) { for (int refIndex = 0; refIndex < references.length; refIndex++) {
final ref = references[refIndex]; final ref = references[refIndex];
final centerX = (ref.x * width).round().clamp(0, width - 1); final centerX = (ref.x * width).round().clamp(0, width - 1);
final centerY = (ref.y * height).round().clamp(0, height - 1); final centerY = (ref.y * height).round().clamp(0, height - 1);
print('Reference $refIndex at ($centerX, $centerY)'); debugPrint('Reference $refIndex at ($centerX, $centerY)');
// AMÉLIORATION : Recherche du point le plus sombre dans une zone plus large // AMÉLIORATION : Recherche du point le plus sombre dans une zone plus large
int darkestX = centerX; int darkestX = centerX;
@@ -275,7 +276,7 @@ class ImageProcessingService {
if (darkestLum < 50 && r > 5) break; if (darkestLum < 50 && r > 5) break;
} }
print(' Darkest point at ($darkestX, $darkestY), lum=$darkestLum'); debugPrint(' Darkest point at ($darkestX, $darkestY), lum=$darkestLum');
// Now find the blob at the darkest point using adaptive threshold // Now find the blob at the darkest point using adaptive threshold
final blobResult = _findBlobAtPoint(blurred, darkestX, darkestY, width, height); final blobResult = _findBlobAtPoint(blurred, darkestX, darkestY, width, height);
@@ -286,15 +287,15 @@ class ImageProcessingService {
circularities.add(blobResult.circularity); circularities.add(blobResult.circularity);
fillRatios.add(blobResult.fillRatio); fillRatios.add(blobResult.fillRatio);
thresholds.add(blobResult.threshold); thresholds.add(blobResult.threshold);
print(' Found blob: size=${blobResult.size}, circ=${blobResult.circularity.toStringAsFixed(2)}, ' debugPrint(' Found blob: size=${blobResult.size}, circ=${blobResult.circularity.toStringAsFixed(2)}, '
'fill=${blobResult.fillRatio.toStringAsFixed(2)}, threshold=${blobResult.threshold.toStringAsFixed(0)}'); 'fill=${blobResult.fillRatio.toStringAsFixed(2)}, threshold=${blobResult.threshold.toStringAsFixed(0)}');
} else { } else {
print(' No valid blob found at this reference'); debugPrint(' No valid blob found at this reference');
} }
} }
if (luminances.isEmpty) { if (luminances.isEmpty) {
print('ERROR: No valid blobs found from any reference!'); debugPrint('ERROR: No valid blobs found from any reference!');
return null; return null;
} }
@@ -329,11 +330,11 @@ class ImageProcessingService {
avgDarkThreshold: avgThreshold, avgDarkThreshold: avgThreshold,
); );
print('Learned characteristics: $result'); debugPrint('Learned characteristics: $result');
return result; return result;
} catch (e) { } catch (e) {
print('Error analyzing reference impacts: $e'); debugPrint('Error analyzing reference impacts: $e');
return null; return null;
} }
} }
@@ -680,7 +681,7 @@ class ImageProcessingService {
// Calculate minimum fill ratio - impacts pleins // Calculate minimum fill ratio - impacts pleins
final minFillRatio = (characteristics.avgFillRatio - 0.2).clamp(0.35, 0.85); final minFillRatio = (characteristics.avgFillRatio - 0.2).clamp(0.35, 0.85);
print('Detection params: thresholds=$thresholds, size=$minSize-$maxSize, ' debugPrint('Detection params: thresholds=$thresholds, size=$minSize-$maxSize, '
'circ>=$effectiveMinCircularity, fill>=$minFillRatio'); 'circ>=$effectiveMinCircularity, fill>=$minFillRatio');
// Détecter avec plusieurs seuils et combiner les résultats // Détecter avec plusieurs seuils et combiner les résultats
@@ -722,7 +723,7 @@ class ImageProcessingService {
return true; return true;
}).toList(); }).toList();
print('Found ${filteredBlobs.length} impacts after filtering (from ${mergedBlobs.length} merged)'); debugPrint('Found ${filteredBlobs.length} impacts after filtering (from ${mergedBlobs.length} merged)');
// Convert to relative coordinates // Convert to relative coordinates
return filteredBlobs.map((blob) { return filteredBlobs.map((blob) {
@@ -733,7 +734,7 @@ class ImageProcessingService {
); );
}).toList(); }).toList();
} catch (e) { } catch (e) {
print('Error detecting impacts from references: $e'); debugPrint('Error detecting impacts from references: $e');
return []; return [];
} }
} }
@@ -776,50 +777,6 @@ class ImageProcessingService {
return merged; return merged;
} }
/// Detect dark spots with adaptive luminance range
List<_Blob> _detectDarkSpotsAdaptive(
img.Image image,
int minLuminance,
int maxLuminance,
int minSize,
int maxSize, {
double minCircularity = 0.5,
double minFillRatio = 0.5,
}) {
final width = image.width;
final height = image.height;
// Create binary mask of pixels within luminance range
final mask = List.generate(height, (_) => List.filled(width, false));
for (int y = 0; y < height; y++) {
for (int x = 0; x < width; x++) {
final pixel = image.getPixel(x, y);
final luminance = img.getLuminance(pixel);
mask[y][x] = luminance >= minLuminance && luminance <= maxLuminance;
}
}
// Find connected components
final visited = List.generate(height, (_) => List.filled(width, false));
final blobs = <_Blob>[];
for (int y = 0; y < height; y++) {
for (int x = 0; x < width; x++) {
if (mask[y][x] && !visited[y][x]) {
final blob = _floodFill(mask, visited, x, y, width, height);
if (blob.size >= minSize &&
blob.size <= maxSize &&
blob.circularity >= minCircularity &&
blob.fillRatio >= minFillRatio) {
blobs.add(blob);
}
}
}
}
return _filterOverlappingBlobs(blobs);
}
/// Detect dark spots in a grayscale image using blob detection /// Detect dark spots in a grayscale image using blob detection
List<_Blob> _detectDarkSpots( List<_Blob> _detectDarkSpots(

View File

@@ -0,0 +1,162 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:sensors_plus/sensors_plus.dart';
/// Statut de parallélisme retourné en temps réel.
enum ParallelismStatus {
/// Pas encore de données capteur disponibles.
unknown,
/// L'appareil est bien parallèle à la cible.
aligned,
/// L'appareil est trop incliné.
misaligned,
}
/// Données de parallélisme calculées à chaque frame capteur.
class ParallelismData {
final ParallelismStatus status;
/// Inclinaison avant/arrière en degrés (0° = parfaitement vertical).
final double pitchDegrees;
/// Inclinaison gauche/droite en degrés (0° = parfaitement droit).
final double rollDegrees;
const ParallelismData({
required this.status,
required this.pitchDegrees,
required this.rollDegrees,
});
bool get isAligned => status == ParallelismStatus.aligned;
@override
String toString() =>
'ParallelismData(status: $status, pitch: ${pitchDegrees.toStringAsFixed(1)}°, roll: ${rollDegrees.toStringAsFixed(1)}°)';
}
/// Service de détection du parallélisme par accéléromètre.
///
/// Implémente une hystérésis à deux seuils pour éviter le clignotement :
///
/// État actuel = misaligned → passe à aligned si angle < [alignThreshold]
/// État actuel = aligned → passe à misaligned si angle > [misalignThreshold]
///
/// Cela crée une "zone de confort" entre les deux seuils où le statut
/// ne change pas — le vert reste vert même si la main tremble légèrement.
///
/// Valeurs par défaut recommandées :
/// alignThreshold = 15° (seuil d'entrée dans le vert — assez souple)
/// misalignThreshold = 22° (seuil de sortie du vert — tolérant au tremblement)
class ParallelismService {
/// Angle max pour passer de misaligned → aligned.
final double alignThreshold;
/// Angle à dépasser pour passer de aligned → misaligned.
/// Doit être > alignThreshold pour créer la zone d'hystérésis.
final double misalignThreshold;
StreamSubscription<AccelerometerEvent>? _subscription;
final StreamController<ParallelismData> _controller =
StreamController<ParallelismData>.broadcast();
/// État interne mémorisé entre deux frames (cœur de l'hystérésis).
ParallelismStatus _currentStatus = ParallelismStatus.unknown;
ParallelismService({
this.alignThreshold = 25.0,
this.misalignThreshold = 32.0,
}) : assert(
misalignThreshold > alignThreshold,
'misalignThreshold doit être supérieur à alignThreshold',
);
Stream<ParallelismData> get stream => _controller.stream;
void start() {
if (_subscription != null) return;
_subscription = accelerometerEventStream(
samplingPeriod: SensorInterval.normalInterval, // ~50 ms
).listen(
_onAccelerometerEvent,
onError: (_) {
// Simulateur ou capteur absent — on reste en "unknown" sans bloquer l'UI
if (!_controller.isClosed) {
_currentStatus = ParallelismStatus.unknown;
_controller.add(const ParallelismData(
status: ParallelismStatus.unknown,
pitchDegrees: 0,
rollDegrees: 0,
));
}
},
);
}
void stop() {
_subscription?.cancel();
_subscription = null;
_currentStatus = ParallelismStatus.unknown;
}
void dispose() {
stop();
_controller.close();
}
void _onAccelerometerEvent(AccelerometerEvent event) {
if (_controller.isClosed) return;
final double gx = event.x;
final double gy = event.y;
final double gz = event.z;
final double magnitude = math.sqrt(gx * gx + gy * gy + gz * gz);
if (magnitude < 1.0) return; // Données aberrantes
// Normalisation par la magnitude réelle (indépendant de g exact)
final double nx = gx / magnitude;
final double ny = gy / magnitude;
final double nz = gz / magnitude;
// Pitch et Roll mesurent l'inclinaison autour de chaque axe.
final double pitchDeg = math.asin(nz.clamp(-1.0, 1.0)) * (180.0 / math.pi);
final double rollDeg = math.asin(nx.clamp(-1.0, 1.0)) * (180.0 / math.pi);
// Le critère de couleur = le PIRE des deux angles affichés à l'écran.
// Ainsi ce que voit l'utilisateur (Pitch / Roll) correspond exactement
// à la décision vert/orange : à 2° d'écart, on est largement dans le vert.
final double worstAngle = math.max(pitchDeg.abs(), rollDeg.abs());
// ── Hystérésis ──────────────────────────────────────────────────────────
// Premier appel : on décide selon alignThreshold uniquement
if (_currentStatus == ParallelismStatus.unknown) {
_currentStatus = worstAngle <= alignThreshold
? ParallelismStatus.aligned
: ParallelismStatus.misaligned;
}
// Déjà misaligned → devient aligned seulement si on passe sous alignThreshold
else if (_currentStatus == ParallelismStatus.misaligned) {
if (worstAngle <= alignThreshold) {
_currentStatus = ParallelismStatus.aligned;
}
}
// Déjà aligned → devient misaligned seulement si on dépasse misalignThreshold
else if (_currentStatus == ParallelismStatus.aligned) {
if (worstAngle > misalignThreshold) {
_currentStatus = ParallelismStatus.misaligned;
}
// Entre alignThreshold et misalignThreshold → on garde le vert, on ne change rien
}
// ────────────────────────────────────────────────────────────────────────
_controller.add(ParallelismData(
status: _currentStatus,
pitchDegrees: pitchDeg,
rollDegrees: rollDeg,
));
}
}

View File

@@ -66,25 +66,12 @@ class PrecisionStats {
/// Standard deviation statistics /// Standard deviation statistics
class StdDevStats { class StdDevStats {
/// Standard deviation of X positions
final double stdDevX; final double stdDevX;
/// Standard deviation of Y positions
final double stdDevY; final double stdDevY;
/// Combined standard deviation (radial)
final double stdDevRadial; final double stdDevRadial;
/// Standard deviation of scores
final double stdDevScore; final double stdDevScore;
/// Mean X position
final double meanX; final double meanX;
/// Mean Y position
final double meanY; final double meanY;
/// Mean score
final double meanScore; final double meanScore;
const StdDevStats({ const StdDevStats({
@@ -98,18 +85,11 @@ class StdDevStats {
}); });
} }
/// Regional distribution (quadrants or sectors) /// Regional distribution
class RegionalStats { class RegionalStats {
/// Shot distribution by quadrant (top-left, top-right, bottom-left, bottom-right)
final Map<String, int> quadrantDistribution; final Map<String, int> quadrantDistribution;
/// Shot distribution by sector (N, NE, E, SE, S, SW, W, NW, Center)
final Map<String, int> sectorDistribution; final Map<String, int> sectorDistribution;
/// Dominant direction (where most shots land)
final String dominantDirection; final String dominantDirection;
/// Bias offset from center
final double biasX; final double biasX;
final double biasY; final double biasY;
@@ -157,16 +137,16 @@ class StatisticsService {
SessionStatistics calculateStatistics( SessionStatistics calculateStatistics(
List<Session> sessions, { List<Session> sessions, {
StatsPeriod period = StatsPeriod.all, StatsPeriod period = StatsPeriod.all,
double targetCenterX = 0.5,
double targetCenterY = 0.5,
}) { }) {
// Filter sessions by period // Filter sessions by period
final filteredSessions = _filterByPeriod(sessions, period); final filteredSessions = _filterByPeriod(sessions, period);
// Collect all shots // Collect all shots from all analyses in all sessions
final allShots = <Shot>[]; final allShots = <Shot>[];
for (final session in filteredSessions) { for (final session in filteredSessions) {
allShots.addAll(session.shots); for (final analysis in session.analyses) {
allShots.addAll(analysis.shots);
}
} }
if (allShots.isEmpty) { if (allShots.isEmpty) {
@@ -183,14 +163,14 @@ class StatisticsService {
// Calculate heat map // Calculate heat map
final heatMap = _calculateHeatMap(allShots, gridSize: 5); final heatMap = _calculateHeatMap(allShots, gridSize: 5);
// Calculate precision // Calculate precision (using relative center 0.5, 0.5)
final precision = _calculatePrecision(allShots, targetCenterX, targetCenterY); final precision = _calculatePrecision(allShots, 0.5, 0.5);
// Calculate standard deviation // Calculate standard deviation
final stdDev = _calculateStdDev(allShots); final stdDev = _calculateStdDev(allShots);
// Calculate regional distribution // Calculate regional distribution
final regional = _calculateRegional(allShots, targetCenterX, targetCenterY); final regional = _calculateRegional(allShots, 0.5, 0.5);
return SessionStatistics( return SessionStatistics(
totalShots: totalShots, totalShots: totalShots,
@@ -224,20 +204,13 @@ class StatisticsService {
/// Calculate heat map /// Calculate heat map
HeatMap _calculateHeatMap(List<Shot> shots, {int gridSize = 5}) { HeatMap _calculateHeatMap(List<Shot> shots, {int gridSize = 5}) {
// Initialize grid final grid = List.generate(gridSize, (_) => List.generate(gridSize, (_) => <Shot>[]));
final grid = List.generate(
gridSize,
(_) => List.generate(gridSize, (_) => <Shot>[]),
);
// Assign shots to grid cells
for (final shot in shots) { for (final shot in shots) {
final col = (shot.x * gridSize).floor().clamp(0, gridSize - 1); final col = (shot.x * gridSize).floor().clamp(0, gridSize - 1);
final row = (shot.y * gridSize).floor().clamp(0, gridSize - 1); final row = (shot.y * gridSize).floor().clamp(0, gridSize - 1);
grid[row][col].add(shot); grid[row][col].add(shot);
} }
// Find max count for normalization
int maxCount = 0; int maxCount = 0;
for (final row in grid) { for (final row in grid) {
for (final cell in row) { for (final cell in row) {
@@ -245,16 +218,12 @@ class StatisticsService {
} }
} }
// Create heat zones
final zones = <List<HeatZone>>[]; final zones = <List<HeatZone>>[];
for (int row = 0; row < gridSize; row++) { for (int row = 0; row < gridSize; row++) {
final rowZones = <HeatZone>[]; final rowZones = <HeatZone>[];
for (int col = 0; col < gridSize; col++) { for (int col = 0; col < gridSize; col++) {
final cellShots = grid[row][col]; final cellShots = grid[row][col];
final avgScore = cellShots.isEmpty final avgScore = cellShots.isEmpty ? 0.0 : cellShots.fold<int>(0, (sum, s) => sum + s.score) / cellShots.length;
? 0.0
: cellShots.fold<int>(0, (sum, s) => sum + s.score) / cellShots.length;
rowZones.add(HeatZone( rowZones.add(HeatZone(
row: row, row: row,
col: col, col: col,
@@ -266,30 +235,15 @@ class StatisticsService {
zones.add(rowZones); zones.add(rowZones);
} }
return HeatMap( return HeatMap(gridSize: gridSize, zones: zones, maxShotsInZone: maxCount, totalShots: shots.length);
gridSize: gridSize,
zones: zones,
maxShotsInZone: maxCount,
totalShots: shots.length,
);
} }
/// Calculate precision statistics /// Calculate precision statistics
PrecisionStats _calculatePrecision( PrecisionStats _calculatePrecision(List<Shot> shots, double centerX, double centerY) {
List<Shot> shots,
double centerX,
double centerY,
) {
if (shots.isEmpty) { if (shots.isEmpty) {
return const PrecisionStats( return const PrecisionStats(avgDistanceFromCenter: 0, groupingDiameter: 0, precisionScore: 0, consistencyScore: 0);
avgDistanceFromCenter: 0,
groupingDiameter: 0,
precisionScore: 0,
consistencyScore: 0,
);
} }
// Calculate distances from center
final distances = shots.map((shot) { final distances = shots.map((shot) {
final dx = shot.x - centerX; final dx = shot.x - centerX;
final dy = shot.y - centerY; final dy = shot.y - centerY;
@@ -297,8 +251,6 @@ class StatisticsService {
}).toList(); }).toList();
final avgDistance = distances.reduce((a, b) => a + b) / distances.length; final avgDistance = distances.reduce((a, b) => a + b) / distances.length;
// Calculate grouping (spread between shots)
double maxSpread = 0; double maxSpread = 0;
for (int i = 0; i < shots.length; i++) { for (int i = 0; i < shots.length; i++) {
for (int j = i + 1; j < shots.length; j++) { for (int j = i + 1; j < shots.length; j++) {
@@ -309,45 +261,29 @@ class StatisticsService {
} }
} }
// Calculate standard deviation of distances (consistency)
final meanDist = avgDistance;
double variance = 0; double variance = 0;
for (final d in distances) { for (final d in distances) {
variance += math.pow(d - meanDist, 2); variance += math.pow(d - avgDistance, 2);
} }
final stdDevDist = math.sqrt(variance / distances.length); final stdDevDist = math.sqrt(variance / distances.length);
// Precision score: based on average distance from center (0-100)
// 0 distance = 100 score, 0.5 distance = 0 score
final precisionScore = math.max(0, (1 - avgDistance * 2) * 100); final precisionScore = math.max(0, (1 - avgDistance * 2) * 100);
// Consistency score: based on grouping tightness (0-100)
// Lower spread = higher consistency
final consistencyScore = math.max(0, (1 - stdDevDist * 5) * 100); final consistencyScore = math.max(0, (1 - stdDevDist * 5) * 100);
return PrecisionStats( return PrecisionStats(
avgDistanceFromCenter: avgDistance.toDouble(), avgDistanceFromCenter: avgDistance,
groupingDiameter: maxSpread.toDouble(), groupingDiameter: maxSpread,
precisionScore: precisionScore.clamp(0.0, 100.0).toDouble(), precisionScore: precisionScore.toDouble().clamp(0.0, 100.0),
consistencyScore: consistencyScore.clamp(0.0, 100.0).toDouble(), consistencyScore: consistencyScore.toDouble().clamp(0.0, 100.0),
); );
} }
/// Calculate standard deviation statistics /// Calculate standard deviation statistics
StdDevStats _calculateStdDev(List<Shot> shots) { StdDevStats _calculateStdDev(List<Shot> shots) {
if (shots.isEmpty) { if (shots.isEmpty) {
return const StdDevStats( return const StdDevStats(stdDevX: 0, stdDevY: 0, stdDevRadial: 0, stdDevScore: 0, meanX: 0.5, meanY: 0.5, meanScore: 0);
stdDevX: 0,
stdDevY: 0,
stdDevRadial: 0,
stdDevScore: 0,
meanX: 0.5,
meanY: 0.5,
meanScore: 0,
);
} }
// Calculate means
double sumX = 0, sumY = 0, sumScore = 0; double sumX = 0, sumY = 0, sumScore = 0;
for (final shot in shots) { for (final shot in shots) {
sumX += shot.x; sumX += shot.x;
@@ -358,7 +294,6 @@ class StatisticsService {
final meanY = sumY / shots.length; final meanY = sumY / shots.length;
final meanScore = sumScore / shots.length; final meanScore = sumScore / shots.length;
// Calculate variances
double varianceX = 0, varianceY = 0, varianceScore = 0; double varianceX = 0, varianceY = 0, varianceScore = 0;
for (final shot in shots) { for (final shot in shots) {
varianceX += math.pow(shot.x - meanX, 2); varianceX += math.pow(shot.x - meanX, 2);
@@ -369,18 +304,11 @@ class StatisticsService {
varianceY /= shots.length; varianceY /= shots.length;
varianceScore /= shots.length; varianceScore /= shots.length;
final stdDevX = math.sqrt(varianceX);
final stdDevY = math.sqrt(varianceY);
final stdDevScore = math.sqrt(varianceScore);
// Radial standard deviation
final stdDevRadial = math.sqrt(varianceX + varianceY);
return StdDevStats( return StdDevStats(
stdDevX: stdDevX, stdDevX: math.sqrt(varianceX),
stdDevY: stdDevY, stdDevY: math.sqrt(varianceY),
stdDevRadial: stdDevRadial, stdDevRadial: math.sqrt(varianceX + varianceY),
stdDevScore: stdDevScore, stdDevScore: math.sqrt(varianceScore),
meanX: meanX, meanX: meanX,
meanY: meanY, meanY: meanY,
meanScore: meanScore, meanScore: meanScore,
@@ -388,42 +316,13 @@ class StatisticsService {
} }
/// Calculate regional distribution /// Calculate regional distribution
RegionalStats _calculateRegional( RegionalStats _calculateRegional(List<Shot> shots, double centerX, double centerY) {
List<Shot> shots,
double centerX,
double centerY,
) {
if (shots.isEmpty) { if (shots.isEmpty) {
return const RegionalStats( return const RegionalStats(quadrantDistribution: {}, sectorDistribution: {}, dominantDirection: 'Centre', biasX: 0, biasY: 0);
quadrantDistribution: {},
sectorDistribution: {},
dominantDirection: 'Centre',
biasX: 0,
biasY: 0,
);
} }
// Quadrant distribution final quadrants = <String, int>{'Haut-Gauche': 0, 'Haut-Droite': 0, 'Bas-Gauche': 0, 'Bas-Droite': 0};
final quadrants = <String, int>{ final sectors = <String, int>{'N': 0, 'NE': 0, 'E': 0, 'SE': 0, 'S': 0, 'SO': 0, 'O': 0, 'NO': 0, 'Centre': 0};
'Haut-Gauche': 0,
'Haut-Droite': 0,
'Bas-Gauche': 0,
'Bas-Droite': 0,
};
// Sector distribution (8 sectors + center)
final sectors = <String, int>{
'N': 0,
'NE': 0,
'E': 0,
'SE': 0,
'S': 0,
'SO': 0,
'O': 0,
'NO': 0,
'Centre': 0,
};
double sumDx = 0, sumDy = 0; double sumDx = 0, sumDy = 0;
for (final shot in shots) { for (final shot in shots) {
@@ -432,16 +331,12 @@ class StatisticsService {
sumDx += dx; sumDx += dx;
sumDy += dy; sumDy += dy;
// Quadrant
if (dy < 0) { if (dy < 0) {
quadrants[dx < 0 ? 'Haut-Gauche' : 'Haut-Droite'] = quadrants[dx < 0 ? 'Haut-Gauche' : 'Haut-Droite'] = quadrants[dx < 0 ? 'Haut-Gauche' : 'Haut-Droite']! + 1;
quadrants[dx < 0 ? 'Haut-Gauche' : 'Haut-Droite']! + 1;
} else { } else {
quadrants[dx < 0 ? 'Bas-Gauche' : 'Bas-Droite'] = quadrants[dx < 0 ? 'Bas-Gauche' : 'Bas-Droite'] = quadrants[dx < 0 ? 'Bas-Gauche' : 'Bas-Droite']! + 1;
quadrants[dx < 0 ? 'Bas-Gauche' : 'Bas-Droite']! + 1;
} }
// Sector
final distance = math.sqrt(dx * dx + dy * dy); final distance = math.sqrt(dx * dx + dy * dy);
if (distance < 0.1) { if (distance < 0.1) {
sectors['Centre'] = sectors['Centre']! + 1; sectors['Centre'] = sectors['Centre']! + 1;
@@ -452,11 +347,6 @@ class StatisticsService {
} }
} }
// Calculate bias
final biasX = sumDx / shots.length;
final biasY = sumDy / shots.length;
// Find dominant direction
String dominant = 'Centre'; String dominant = 'Centre';
int maxCount = 0; int maxCount = 0;
sectors.forEach((key, value) { sectors.forEach((key, value) {
@@ -470,14 +360,12 @@ class StatisticsService {
quadrantDistribution: quadrants, quadrantDistribution: quadrants,
sectorDistribution: sectors, sectorDistribution: sectors,
dominantDirection: dominant, dominantDirection: dominant,
biasX: biasX, biasX: sumDx / shots.length,
biasY: biasY, biasY: sumDy / shots.length,
); );
} }
String _angleToSector(double angle) { String _angleToSector(double angle) {
// Angle is in degrees, -180 to 180
// 0 = East, 90 = South, -90 = North, 180/-180 = West
if (angle >= -22.5 && angle < 22.5) return 'E'; if (angle >= -22.5 && angle < 22.5) return 'E';
if (angle >= 22.5 && angle < 67.5) return 'SE'; if (angle >= 22.5 && angle < 67.5) return 'SE';
if (angle >= 67.5 && angle < 112.5) return 'S'; if (angle >= 67.5 && angle < 112.5) return 'S';
@@ -491,41 +379,12 @@ class StatisticsService {
SessionStatistics _emptyStatistics(StatsPeriod period, List<Session> sessions) { SessionStatistics _emptyStatistics(StatsPeriod period, List<Session> sessions) {
return SessionStatistics( return SessionStatistics(
totalShots: 0, totalShots: 0, totalScore: 0, avgScore: 0, maxScore: 0, minScore: 0,
totalScore: 0, heatMap: const HeatMap(gridSize: 5, zones: [], maxShotsInZone: 0, totalShots: 0),
avgScore: 0, precision: const PrecisionStats(avgDistanceFromCenter: 0, groupingDiameter: 0, precisionScore: 0, consistencyScore: 0),
maxScore: 0, stdDev: const StdDevStats(stdDevX: 0, stdDevY: 0, stdDevRadial: 0, stdDevScore: 0, meanX: 0.5, meanY: 0.5, meanScore: 0),
minScore: 0, regional: const RegionalStats(quadrantDistribution: {}, sectorDistribution: {}, dominantDirection: 'Centre', biasX: 0, biasY: 0),
heatMap: const HeatMap( sessions: sessions, period: period,
gridSize: 5,
zones: [],
maxShotsInZone: 0,
totalShots: 0,
),
precision: const PrecisionStats(
avgDistanceFromCenter: 0,
groupingDiameter: 0,
precisionScore: 0,
consistencyScore: 0,
),
stdDev: const StdDevStats(
stdDevX: 0,
stdDevY: 0,
stdDevRadial: 0,
stdDevScore: 0,
meanX: 0.5,
meanY: 0.5,
meanScore: 0,
),
regional: const RegionalStats(
quadrantDistribution: {},
sectorDistribution: {},
dominantDirection: 'Centre',
biasX: 0,
biasY: 0,
),
sessions: sessions,
period: period,
); );
} }
} }

View File

@@ -1,4 +1,5 @@
import 'dart:math' as math; import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import '../data/models/target_type.dart'; import '../data/models/target_type.dart';
import 'image_processing_service.dart'; import 'image_processing_service.dart';
import 'opencv_impact_detection_service.dart'; import 'opencv_impact_detection_service.dart';
@@ -7,6 +8,110 @@ export 'image_processing_service.dart'
export 'opencv_impact_detection_service.dart' export 'opencv_impact_detection_service.dart'
show OpenCVDetectionSettings, OpenCVDetectedImpact; show OpenCVDetectionSettings, OpenCVDetectedImpact;
// ============================================================================
// STRUCT ET FONCTION GLOBALE POUR LE PARALLÉLISME (THREAD SECONDAIRE)
// ============================================================================
/// Conteneur de données pour envoyer les paramètres à l'Isolate d'arrière-plan.
class DetectionPayload {
final String imagePath;
final TargetType targetType;
// On recrée les instances à l'intérieur du thread isolé car les objets ne se partagent pas entre threads.
DetectionPayload({
required this.imagePath,
required this.targetType,
});
}
/// FONCTION EXÉCUTÉE EN PARALLÈLE : Tourne sur un autre cœur du processeur.
/// L'interface graphique reste à 120 FPS et totalement fluide.
TargetDetectionResult runParallelTargetDetection(DetectionPayload payload) {
// 1. Initialisation locale des services dans le sous-thread
final imageProcessingService = ImageProcessingService();
try {
// 2. Détection de la cible principale (Calcul lourd)
final mainTarget = imageProcessingService.detectMainTarget(payload.imagePath);
double centerX = 0.5;
double centerY = 0.5;
double radius = 0.4;
if (mainTarget != null) {
centerX = mainTarget.centerX;
centerY = mainTarget.centerY;
radius = mainTarget.radius;
}
// 3. Détection des impacts (Calcul lourd)
final impacts = imageProcessingService.detectImpacts(payload.imagePath);
// 4. Calcul mathématique des scores relatifs
final detectedImpacts = impacts.map((impact) {
final score = payload.targetType == TargetType.concentric
? _staticCalculateConcentricScore(
impact.x,
impact.y,
centerX,
centerY,
radius,
)
: _staticCalculateSilhouetteScore(impact.x, impact.y, centerX, centerY);
return DetectedImpactResult(
x: impact.x,
y: impact.y,
radius: impact.radius,
suggestedScore: score,
);
}).toList();
return TargetDetectionResult(
centerX: centerX,
centerY: centerY,
radius: radius,
impacts: detectedImpacts,
);
} catch (e) {
return TargetDetectionResult.error('Erreur de detection parallèle: $e');
}
}
// Fonctions mathématiques pures nécessaires à l'Isolate (statiques)
int _staticCalculateConcentricScore(double impactX, double impactY, double centerX, double centerY, double targetRadius) {
final dx = impactX - centerX;
final dy = impactY - centerY;
final distance = math.sqrt(dx * dx + dy * dy) / targetRadius;
if (distance <= 0.1) return 10;
if (distance <= 0.2) return 9;
if (distance <= 0.3) return 8;
if (distance <= 0.4) return 7;
if (distance <= 0.5) return 6;
if (distance <= 0.6) return 5;
if (distance <= 0.7) return 4;
if (distance <= 0.8) return 3;
if (distance <= 0.9) return 2;
if (distance <= 1.0) return 1;
return 0;
}
int _staticCalculateSilhouetteScore(double impactX, double impactY, double centerX, double centerY) {
final dx = (impactX - centerX).abs();
final dy = impactY - centerY;
if (dx > 0.15) return 0;
if (dy < -0.25) return 5;
if (dy < 0.0) return 5;
if (dy < 0.15) return 4;
if (dy < 0.35) return 3;
return 0;
}
// ============================================================================
// FIN DU BLOC DE PARALLÉLISME
// ============================================================================
class TargetDetectionResult { class TargetDetectionResult {
final double centerX; // Relative (0-1) final double centerX; // Relative (0-1)
final double centerY; // Relative (0-1) final double centerY; // Relative (0-1)
@@ -58,13 +163,20 @@ class TargetDetectionService {
ImageProcessingService? imageProcessingService, ImageProcessingService? imageProcessingService,
OpenCVImpactDetectionService? opencvService, OpenCVImpactDetectionService? opencvService,
}) : _imageProcessingService = }) : _imageProcessingService =
imageProcessingService ?? ImageProcessingService(), imageProcessingService ?? ImageProcessingService(),
_opencvService = opencvService ?? OpenCVImpactDetectionService(); _opencvService = opencvService ?? OpenCVImpactDetectionService();
/// Detect target and impacts from an image file /// Detect target and impacts from an image file ASYNCHRONOUSLY in a separate Thread.
/// CORRECTION : Utilise désormais 'compute' pour basculer en arrière-plan immédiat.
Future<TargetDetectionResult> detectTargetAsync(String imagePath, TargetType targetType) async {
final payload = DetectionPayload(imagePath: imagePath, targetType: targetType);
// Déclenche l'exécution isolée en tâche de fond
return await compute(runParallelTargetDetection, payload);
}
/// Gardée pour rétrocompatibilité synchrone si nécessaire
TargetDetectionResult detectTarget(String imagePath, TargetType targetType) { TargetDetectionResult detectTarget(String imagePath, TargetType targetType) {
try { try {
// Detect main target
final mainTarget = _imageProcessingService.detectMainTarget(imagePath); final mainTarget = _imageProcessingService.detectMainTarget(imagePath);
double centerX = 0.5; double centerX = 0.5;
@@ -77,19 +189,17 @@ class TargetDetectionService {
radius = mainTarget.radius; radius = mainTarget.radius;
} }
// Detect impacts
final impacts = _imageProcessingService.detectImpacts(imagePath); final impacts = _imageProcessingService.detectImpacts(imagePath);
// Convert impacts to relative coordinates and calculate scores
final detectedImpacts = impacts.map((impact) { final detectedImpacts = impacts.map((impact) {
final score = targetType == TargetType.concentric final score = targetType == TargetType.concentric
? _calculateConcentricScore( ? _calculateConcentricScore(
impact.x, impact.x,
impact.y, impact.y,
centerX, centerX,
centerY, centerY,
radius, radius,
) )
: _calculateSilhouetteScore(impact.x, impact.y, centerX, centerY); : _calculateSilhouetteScore(impact.x, impact.y, centerX, centerY);
return DetectedImpactResult( return DetectedImpactResult(
@@ -112,18 +222,16 @@ class TargetDetectionService {
} }
int _calculateConcentricScore( int _calculateConcentricScore(
double impactX, double impactX,
double impactY, double impactY,
double centerX, double centerX,
double centerY, double centerY,
double targetRadius, double targetRadius,
) { ) {
// Calculate distance from center (normalized to target radius)
final dx = impactX - centerX; final dx = impactX - centerX;
final dy = impactY - centerY; final dy = impactY - centerY;
final distance = math.sqrt(dx * dx + dy * dy) / targetRadius; final distance = math.sqrt(dx * dx + dy * dy) / targetRadius;
// Score zones (10 zones)
if (distance <= 0.1) return 10; if (distance <= 0.1) return 10;
if (distance <= 0.2) return 9; if (distance <= 0.2) return 9;
if (distance <= 0.3) return 8; if (distance <= 0.3) return 8;
@@ -134,61 +242,52 @@ class TargetDetectionService {
if (distance <= 0.8) return 3; if (distance <= 0.8) return 3;
if (distance <= 0.9) return 2; if (distance <= 0.9) return 2;
if (distance <= 1.0) return 1; if (distance <= 1.0) return 1;
return 0; // Outside target return 0;
} }
int _calculateSilhouetteScore( int _calculateSilhouetteScore(
double impactX, double impactX,
double impactY, double impactY,
double centerX, double centerX,
double centerY, double centerY,
) { ) {
// For silhouettes, scoring is typically based on zones
// Head and center mass = 5, body = 4, lower = 3
final dx = (impactX - centerX).abs(); final dx = (impactX - centerX).abs();
final dy = impactY - centerY; final dy = impactY - centerY;
// Check if within silhouette bounds (approximate) if (dx > 0.15) return 0;
if (dx > 0.15) return 0; // Too far left/right if (dy < -0.25) return 5;
if (dy < 0.0) return 5;
if (dy < 0.15) return 4;
if (dy < 0.35) return 3;
// Vertical zones return 0;
if (dy < -0.25) return 5; // Head zone (top)
if (dy < 0.0) return 5; // Center mass (upper body)
if (dy < 0.15) return 4; // Body
if (dy < 0.35) return 3; // Lower body
return 0; // Outside target
} }
/// Detect only impacts with custom settings (doesn't affect target position)
List<DetectedImpactResult> detectImpactsOnly( List<DetectedImpactResult> detectImpactsOnly(
String imagePath, String imagePath,
TargetType targetType, TargetType targetType,
double centerX, double centerX,
double centerY, double centerY,
double radius, double radius,
int ringCount, int ringCount,
ImpactDetectionSettings settings, ImpactDetectionSettings settings,
) { ) {
try { try {
// Detect impacts with custom settings
final impacts = _imageProcessingService.detectImpactsWithSettings( final impacts = _imageProcessingService.detectImpactsWithSettings(
imagePath, imagePath,
settings, settings,
); );
// Convert impacts to relative coordinates and calculate scores
return impacts.map((impact) { return impacts.map((impact) {
final score = targetType == TargetType.concentric final score = targetType == TargetType.concentric
? _calculateConcentricScoreWithRings( ? _calculateConcentricScoreWithRings(
impact.x, impact.x,
impact.y, impact.y,
centerX, centerX,
centerY, centerY,
radius, radius,
ringCount, ringCount,
) )
: _calculateSilhouetteScore(impact.x, impact.y, centerX, centerY); : _calculateSilhouetteScore(impact.x, impact.y, centerX, centerY);
return DetectedImpactResult( return DetectedImpactResult(
@@ -204,19 +303,17 @@ class TargetDetectionService {
} }
int _calculateConcentricScoreWithRings( int _calculateConcentricScoreWithRings(
double impactX, double impactX,
double impactY, double impactY,
double centerX, double centerX,
double centerY, double centerY,
double targetRadius, double targetRadius,
int ringCount, int ringCount,
) { ) {
// Calculate distance from center (normalized to target radius)
final dx = impactX - centerX; final dx = impactX - centerX;
final dy = impactY - centerY; final dy = impactY - centerY;
final distance = math.sqrt(dx * dx + dy * dy) / targetRadius; final distance = math.sqrt(dx * dx + dy * dy) / targetRadius;
// Score zones based on ringCount
for (int i = 0; i < ringCount; i++) { for (int i = 0; i < ringCount; i++) {
final zoneRadius = (i + 1) / ringCount; final zoneRadius = (i + 1) / ringCount;
if (distance <= zoneRadius) { if (distance <= zoneRadius) {
@@ -224,31 +321,29 @@ class TargetDetectionService {
} }
} }
return 0; // Outside target return 0;
} }
/// Analyze reference impacts to learn their characteristics
ImpactCharacteristics? analyzeReferenceImpacts( ImpactCharacteristics? analyzeReferenceImpacts(
String imagePath, String imagePath,
List<ReferenceImpact> references, List<ReferenceImpact> references,
) { ) {
return _imageProcessingService.analyzeReferenceImpacts( return _imageProcessingService.analyzeReferenceImpacts(
imagePath, imagePath,
references, references,
); );
} }
/// Detect impacts based on reference characteristics (calibrated detection)
List<DetectedImpactResult> detectImpactsFromReferences( List<DetectedImpactResult> detectImpactsFromReferences(
String imagePath, String imagePath,
TargetType targetType, TargetType targetType,
double centerX, double centerX,
double centerY, double centerY,
double radius, double radius,
int ringCount, int ringCount,
ImpactCharacteristics characteristics, { ImpactCharacteristics characteristics, {
double tolerance = 2.0, double tolerance = 2.0,
}) { }) {
try { try {
final impacts = _imageProcessingService.detectImpactsFromReferences( final impacts = _imageProcessingService.detectImpactsFromReferences(
imagePath, imagePath,
@@ -259,13 +354,13 @@ class TargetDetectionService {
return impacts.map((impact) { return impacts.map((impact) {
final score = targetType == TargetType.concentric final score = targetType == TargetType.concentric
? _calculateConcentricScoreWithRings( ? _calculateConcentricScoreWithRings(
impact.x, impact.x,
impact.y, impact.y,
centerX, centerX,
centerY, centerY,
radius, radius,
ringCount, ringCount,
) )
: _calculateSilhouetteScore(impact.x, impact.y, centerX, centerY); : _calculateSilhouetteScore(impact.x, impact.y, centerX, centerY);
return DetectedImpactResult( return DetectedImpactResult(
@@ -280,20 +375,15 @@ class TargetDetectionService {
} }
} }
/// Détecte les impacts en utilisant OpenCV (Hough Circles + Contours)
///
/// Cette méthode 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é
List<DetectedImpactResult> detectImpactsWithOpenCV( List<DetectedImpactResult> detectImpactsWithOpenCV(
String imagePath, String imagePath,
TargetType targetType, TargetType targetType,
double centerX, double centerX,
double centerY, double centerY,
double radius, double radius,
int ringCount, { int ringCount, {
OpenCVDetectionSettings? settings, OpenCVDetectionSettings? settings,
}) { }) {
try { try {
final impacts = _opencvService.detectImpacts( final impacts = _opencvService.detectImpacts(
imagePath, imagePath,
@@ -303,13 +393,13 @@ class TargetDetectionService {
return impacts.map((impact) { return impacts.map((impact) {
final score = targetType == TargetType.concentric final score = targetType == TargetType.concentric
? _calculateConcentricScoreWithRings( ? _calculateConcentricScoreWithRings(
impact.x, impact.x,
impact.y, impact.y,
centerX, centerX,
centerY, centerY,
radius, radius,
ringCount, ringCount,
) )
: _calculateSilhouetteScore(impact.x, impact.y, centerX, centerY); : _calculateSilhouetteScore(impact.x, impact.y, centerX, centerY);
return DetectedImpactResult( return DetectedImpactResult(
@@ -320,27 +410,22 @@ class TargetDetectionService {
); );
}).toList(); }).toList();
} catch (e) { } catch (e) {
print('Erreur détection OpenCV: $e'); debugPrint('Erreur détection OpenCV: $e');
return []; return [];
} }
} }
/// Détecte les impacts avec OpenCV en utilisant des références
///
/// Analyse les impacts de référence pour apprendre leurs caractéristiques
/// puis détecte les impacts similaires dans l'image.
List<DetectedImpactResult> detectImpactsWithOpenCVFromReferences( List<DetectedImpactResult> detectImpactsWithOpenCVFromReferences(
String imagePath, String imagePath,
TargetType targetType, TargetType targetType,
double centerX, double centerX,
double centerY, double centerY,
double radius, double radius,
int ringCount, int ringCount,
List<ReferenceImpact> references, { List<ReferenceImpact> references, {
double tolerance = 2.0, double tolerance = 2.0,
}) { }) {
try { try {
// Convertir les références au format OpenCV
final refPoints = references.map((r) => (x: r.x, y: r.y)).toList(); final refPoints = references.map((r) => (x: r.x, y: r.y)).toList();
final impacts = _opencvService.detectFromReferences( final impacts = _opencvService.detectFromReferences(
@@ -352,13 +437,13 @@ class TargetDetectionService {
return impacts.map((impact) { return impacts.map((impact) {
final score = targetType == TargetType.concentric final score = targetType == TargetType.concentric
? _calculateConcentricScoreWithRings( ? _calculateConcentricScoreWithRings(
impact.x, impact.x,
impact.y, impact.y,
centerX, centerX,
centerY, centerY,
radius, radius,
ringCount, ringCount,
) )
: _calculateSilhouetteScore(impact.x, impact.y, centerX, centerY); : _calculateSilhouetteScore(impact.x, impact.y, centerX, centerY);
return DetectedImpactResult( return DetectedImpactResult(
@@ -369,9 +454,8 @@ class TargetDetectionService {
); );
}).toList(); }).toList();
} catch (e) { } catch (e) {
print('Erreur détection OpenCV depuis références: $e'); debugPrint('Erreur détection OpenCV depuis références: $e');
return []; return [];
} }
} }
}
}

View File

@@ -0,0 +1,259 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:opencv_dart/opencv_dart.dart' as cv;
/// Résultat d'une tentative de redressement de cible.
class RectifyResult {
/// Chemin du fichier image redressé (ou original si échec).
final String outputPath;
/// true si une cible a été détectée et redressée, false si on a renvoyé
/// l'image d'origine sans transformation.
final bool rectified;
/// Angle d'inclinaison estimé de la cible AVANT redressement, en degrés.
/// (0 = déjà de face). Utile pour informer l'utilisateur.
final double estimatedTiltDegrees;
/// Message de diagnostic (utile pour debug / affichage).
final String message;
const RectifyResult({
required this.outputPath,
required this.rectified,
required this.estimatedTiltDegrees,
required this.message,
});
}
/// Service qui redresse une cible RONDE (cercles concentriques) photographiée
/// de biais, en la ramenant parfaitement de face.
///
/// Principe :
/// 1. Détecter le plus grand contour ~circulaire de l'image.
/// 2. Ajuster une ELLIPSE sur ce contour (fitEllipse).
/// 3. Un cercle vu en perspective devient une ellipse : on calcule la
/// transformation de perspective qui remappe cette ellipse vers un
/// CERCLE parfait, et on l'applique à toute l'image.
/// 4. La cible apparaît alors de face.
///
/// L'image résultat est carrée et centrée sur la cible.
class TargetRectifyService {
/// Taille (en pixels) du côté de l'image carrée de sortie.
final int outputSize;
/// Marge autour de la cible dans l'image de sortie (1.0 = cible pile au bord,
/// 1.3 = 30 % de marge autour). Garde un peu de contexte.
final double marginFactor;
/// En dessous de cet écart d'axes (ratio petit/grand axe proche de 1),
/// la cible est considérée déjà de face → pas de warp inutile.
final double minTiltRatioToRectify;
TargetRectifyService({
this.outputSize = 1024,
this.marginFactor = 1.25,
this.minTiltRatioToRectify = 0.985,
});
/// Redresse l'image située à [inputPath]. Écrit le résultat dans
/// [outputPath] et renvoie un [RectifyResult].
///
/// Ne bloque jamais : en cas d'échec de détection, renvoie l'image
/// d'origine (rectified = false) pour ne pas perdre la photo du tireur.
Future<RectifyResult> rectify({
required String inputPath,
required String outputPath,
}) async {
cv.Mat? src;
cv.Mat? gray;
cv.Mat? blurred;
cv.Mat? edges;
try {
src = cv.imread(inputPath, flags: cv.IMREAD_COLOR);
if (src.isEmpty) {
return RectifyResult(
outputPath: inputPath,
rectified: false,
estimatedTiltDegrees: 0,
message: 'Image illisible',
);
}
// ── 1. Prétraitement ────────────────────────────────────────────────
gray = cv.cvtColor(src, cv.COLOR_BGR2GRAY);
blurred = cv.gaussianBlur(gray, (5, 5), 2, sigmaY: 2);
edges = cv.canny(blurred, 60, 160);
// Dilatation légère pour fermer les contours brisés
final cv.Mat kernel = cv.getStructuringElement(
cv.MORPH_ELLIPSE,
(3, 3),
);
final cv.Mat dilated = cv.dilate(edges, kernel);
// ── 2. Recherche du meilleur contour elliptique ──────────────────────
final (contours, _) = cv.findContours(
dilated,
cv.RETR_EXTERNAL,
cv.CHAIN_APPROX_SIMPLE,
);
final double imgArea = (src.width * src.height).toDouble();
cv.RotatedRect? bestEllipse;
double bestScore = 0;
for (int i = 0; i < contours.length; i++) {
final c = contours[i];
if (c.length < 5) continue; // fitEllipse exige >= 5 points
final double area = cv.contourArea(c);
// On ignore les contours minuscules et ceux qui couvrent presque tout
if (area < imgArea * 0.03 || area > imgArea * 0.97) continue;
final cv.RotatedRect e = cv.fitEllipse(c);
final ep = e.points; // 4 sommets
if (ep.length < 4) continue;
final double ecx = (ep[0].x + ep[1].x + ep[2].x + ep[3].x) / 4.0;
final double ecy = (ep[0].y + ep[1].y + ep[2].y + ep[3].y) / 4.0;
final double mAx = (ep[0].x + ep[1].x) / 2.0;
final double mAy = (ep[0].y + ep[1].y) / 2.0;
final double mBx = (ep[1].x + ep[2].x) / 2.0;
final double mBy = (ep[1].y + ep[2].y) / 2.0;
final double w =
2 * math.sqrt(math.pow(mAx - ecx, 2) + math.pow(mAy - ecy, 2));
final double h =
2 * math.sqrt(math.pow(mBx - ecx, 2) + math.pow(mBy - ecy, 2));
if (w <= 1 || h <= 1) continue;
// À quel point le contour ressemble-t-il vraiment à son ellipse ?
// On compare l'aire du contour à l'aire de l'ellipse ajustée.
final double ellipseArea = math.pi * (w / 2) * (h / 2);
if (ellipseArea <= 0) continue;
final double fitRatio = area / ellipseArea; // ~1 si bon ajustement
if (fitRatio < 0.7 || fitRatio > 1.3) continue;
// Score = taille de l'ellipse (on veut la cible la plus grande)
final double score = ellipseArea;
if (score > bestScore) {
bestScore = score;
bestEllipse = e;
}
}
if (bestEllipse == null) {
return RectifyResult(
outputPath: inputPath,
rectified: false,
estimatedTiltDegrees: 0,
message: 'Aucune cible circulaire détectée',
);
}
// ── 3. Extraire les 4 sommets de l'ellipse (robuste à la version d'API) ─
// RotatedRect.points renvoie les 4 coins de la boîte englobant l'ellipse.
// On en dérive nous-mêmes le centre, les demi-axes et l'orientation, ce
// qui évite de dépendre de la forme exacte de `.size` / `.center`
// (record vs objet) qui varie selon les versions d'opencv_dart.
final pts = bestEllipse.points; // List<Point2f> de 4 sommets
double px(int i) => pts[i].x;
double py(int i) => pts[i].y;
// Centre = moyenne des 4 sommets
final double cx = (px(0) + px(1) + px(2) + px(3)) / 4.0;
final double cy = (py(0) + py(1) + py(2) + py(3)) / 4.0;
// Milieux de deux côtés adjacents → extrémités des deux demi-axes
// Côté 0-1 et côté 1-2 (ordre des sommets d'un RotatedRect)
final double m01x = (px(0) + px(1)) / 2.0;
final double m01y = (py(0) + py(1)) / 2.0;
final double m12x = (px(1) + px(2)) / 2.0;
final double m12y = (py(1) + py(2)) / 2.0;
// Demi-axes = distance centre → milieu de chaque côté
final double axisA =
math.sqrt(math.pow(m01x - cx, 2) + math.pow(m01y - cy, 2));
final double axisB =
math.sqrt(math.pow(m12x - cx, 2) + math.pow(m12y - cy, 2));
final double majorAxis = math.max(axisA, axisB);
final double minorAxis = math.min(axisA, axisB);
if (majorAxis <= 1) {
return RectifyResult(
outputPath: inputPath,
rectified: false,
estimatedTiltDegrees: 0,
message: 'Ellipse dégénérée',
);
}
final double axisRatio = minorAxis / majorAxis; // 1 = cercle parfait
final double tiltDeg =
math.acos(axisRatio.clamp(0.0, 1.0)) * (180.0 / math.pi);
// Déjà quasiment de face → on ne touche pas (évite le flou inutile)
if (axisRatio >= minTiltRatioToRectify) {
cv.imwrite(outputPath, src);
return RectifyResult(
outputPath: outputPath,
rectified: false,
estimatedTiltDegrees: tiltDeg,
message: 'Cible déjà de face',
);
}
// ── 4. Construire la transformation de perspective ────────────────────
// Source : extrémités des deux axes de l'ellipse (4 points).
// Destination : extrémités des axes d'un cercle parfait centré.
// On obtient les extrémités en prolongeant centre→milieu-de-côté.
final srcPts = cv.VecPoint.fromList([
cv.Point((cx + (m01x - cx)).round(), (cy + (m01y - cy)).round()),
cv.Point((cx - (m01x - cx)).round(), (cy - (m01y - cy)).round()),
cv.Point((cx + (m12x - cx)).round(), (cy + (m12y - cy)).round()),
cv.Point((cx - (m12x - cx)).round(), (cy - (m12y - cy)).round()),
]);
// Cible : cercle parfait centré, rayon R, dans une image carrée.
// L'axe "A" (m01) devient l'axe horizontal, l'axe "B" (m12) le vertical.
final double out = outputSize.toDouble();
final double center = out / 2;
final double radius = (out / 2) / marginFactor;
final dstPts = cv.VecPoint.fromList([
cv.Point((center + radius).round(), center.round()),
cv.Point((center - radius).round(), center.round()),
cv.Point(center.round(), (center + radius).round()),
cv.Point(center.round(), (center - radius).round()),
]);
final cv.Mat transform = cv.getPerspectiveTransform(srcPts, dstPts);
final cv.Mat warped = cv.warpPerspective(
src,
transform,
(outputSize, outputSize),
flags: cv.INTER_LINEAR,
borderMode: cv.BORDER_CONSTANT,
);
cv.imwrite(outputPath, warped);
return RectifyResult(
outputPath: outputPath,
rectified: true,
estimatedTiltDegrees: tiltDeg,
message: 'Cible redressée (inclinaison ${tiltDeg.toStringAsFixed(1)}°)',
);
} catch (e) {
debugPrint('TargetRectify erreur: $e');
// En cas de pépin, on renvoie l'original pour ne jamais perdre la photo
return RectifyResult(
outputPath: inputPath,
rectified: false,
estimatedTiltDegrees: 0,
message: 'Erreur de traitement: $e',
);
}
}
}

View File

@@ -0,0 +1,113 @@
import 'dart:math';
import 'dart:convert';
import 'package:crypto/crypto.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'dart:io';
import 'package:flutter/foundation.dart';
class WalletIdentityService {
static const String _prefsKey = 'wallet_identity_phrase';
static const String _uploadEnabledKey = 'is_ai_upload_enabled';
// A standard list of 256 words (8 bits of entropy per word)
static const List<String> _wordList = [
// ... same as before
'abandon', 'ability', 'able', 'about', 'above', 'absent', 'absorb', 'abstract', 'absurd', 'abuse',
'access', 'accident', 'account', 'accuse', 'achieve', 'acid', 'acoustic', 'acquire', 'across', 'act',
'action', 'actor', 'actress', 'actual', 'adapt', 'add', 'addict', 'address', 'adjust', 'admit',
'adult', 'advance', 'advice', 'aerobic', 'affair', 'afford', 'afraid', 'again', 'age', 'agent',
'agree', 'ahead', 'aim', 'air', 'airport', 'aisle', 'alarm', 'album', 'alcohol', 'alert',
'alien', 'all', 'alley', 'allow', 'almost', 'alone', 'alpha', 'already', 'also', 'alter',
'always', 'amateur', 'amazing', 'among', 'amount', 'amused', 'analyst', 'anchor', 'ancient', 'anger',
'angle', 'angry', 'animal', 'ankle', 'announce', 'annual', 'another', 'answer', 'antenna', 'antique',
'anxiety', 'any', 'apart', 'apology', 'appear', 'apple', 'approve', 'april', 'arch', 'arctic',
'area', 'arena', 'argue', 'arm', 'armed', 'armor', 'army', 'around', 'arrange', 'arrest',
'arrive', 'arrow', 'art', 'artefact', 'artist', 'artwork', 'ask', 'aspect', 'assault', 'asset',
'assist', 'assume', 'asthma', 'athlete', 'atom', 'attack', 'attend', 'attitude', 'attract', 'auction',
'audit', 'august', 'aunt', 'author', 'auto', 'autumn', 'average', 'avocado', 'avoid', 'awake',
'aware', 'away', 'awesome', 'awful', 'awkward', 'axis', 'baby', 'bachelor', 'bacon', 'badge',
'bag', 'balance', 'balcony', 'ball', 'bamboo', 'banana', 'banner', 'bar', 'barely', 'bargain',
'barrel', 'base', 'basic', 'basket', 'battle', 'beach', 'bean', 'beauty', 'because', 'become',
'beef', 'before', 'begin', 'behave', 'behind', 'believe', 'below', 'belt', 'bench', 'benefit',
'best', 'betray', 'better', 'between', 'beyond', 'bicycle', 'bid', 'bike', 'bind', 'biology',
'bird', 'birth', 'bitter', 'black', 'blade', 'blame', 'blanket', 'blast', 'bleak', 'bless',
'blind', 'blood', 'blossom', 'blouse', 'blue', 'blur', 'blush', 'board', 'boat', 'body',
'boil', 'bomb', 'bone', 'bonus', 'book', 'boost', 'border', 'boring', 'borrow', 'boss',
'bottom', 'bounce', 'box', 'boy', 'bracket', 'brain', 'brand', 'brass', 'brave', 'bread',
'breeze', 'brick', 'bridge', 'brief', 'bright', 'bring', 'brisk', 'broccoli', 'broken', 'bronze',
'broom', 'brother', 'brown', 'brush', 'bubble', 'buddy', 'budget', 'buffalo', 'build', 'bulb',
'bulk', 'bullet', 'bundle', 'bunker', 'burden', 'burger', 'burst', 'bus', 'business', 'butter'
];
/// Vérifie si l'utilisateur a accepté l'envoi de données à l'IA
Future<bool> isUploadEnabled() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_uploadEnabledKey) ?? false;
}
/// Active ou désactive l'envoi de données
Future<void> setUploadEnabled(bool enabled) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_uploadEnabledKey, enabled);
}
/// Gets the unique 15-word identity phrase
Future<String> getIdentityPhrase() async {
final prefs = await SharedPreferences.getInstance();
// Check if we already generated it
final existingPhrase = prefs.getString(_prefsKey);
if (existingPhrase != null && existingPhrase.isNotEmpty) {
return existingPhrase;
}
// Generate new phrase based on device ID
final phrase = await _generatePhraseFromDevice();
// Save for future use
await prefs.setString(_prefsKey, phrase);
return phrase;
}
Future<String> _generatePhraseFromDevice() async {
final deviceInfo = DeviceInfoPlugin();
String deviceId = 'unknown_device_${DateTime.now().millisecondsSinceEpoch}';
try {
if (Platform.isAndroid) {
final androidInfo = await deviceInfo.androidInfo;
deviceId = androidInfo.id; // Unique Android ID
} else if (Platform.isIOS) {
final iosInfo = await deviceInfo.iosInfo;
deviceId = iosInfo.identifierForVendor ?? deviceId;
} else if (Platform.isWindows) {
final windowsInfo = await deviceInfo.windowsInfo;
deviceId = windowsInfo.deviceId;
}
} catch (e) {
debugPrint('Erreur lecture device ID: $e');
}
// Add a salt to the device ID
final salt = 'bully_app_wallet_salt_v1';
final bytes = utf8.encode(deviceId + salt);
final digest = sha256.convert(bytes);
// Use the hash to seed a random number generator
// We take the first 4 bytes of the hash as the seed
final seedBytes = digest.bytes.sublist(0, 4);
final seed = (seedBytes[0] << 24) | (seedBytes[1] << 16) | (seedBytes[2] << 8) | seedBytes[3];
final random = Random(seed);
List<String> phraseWords = [];
for (int i = 0; i < 15; i++) {
final randomIndex = random.nextInt(_wordList.length);
phraseWords.add(_wordList[randomIndex]);
}
return phraseWords.join(' ');
}
}

View File

@@ -7,6 +7,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
) )
list(APPEND FLUTTER_FFI_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST
jni
) )
set(PLUGIN_BUNDLED_LIBRARIES) set(PLUGIN_BUNDLED_LIBRARIES)

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