Compare commits
7 Commits
backend
...
feature/gl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8946955f76 | ||
|
|
be62669d56 | ||
|
|
db6dd0cee5 | ||
|
|
d98f54cf93 | ||
|
|
55f6210c97 | ||
|
|
8d9b35104a | ||
|
|
5cc67228e4 |
@@ -1,6 +1,11 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<uses-permission android:name="android.permission.CAMERA" />
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
|
|
||||||
|
<!-- Pour Android 12 et inférieur -->
|
||||||
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
|
||||||
|
|
||||||
|
<!-- Pour Android 13 et supérieur -->
|
||||||
|
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
|
||||||
<application
|
<application
|
||||||
android:label="bully"
|
android:label="bully"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
|
|||||||
38
backendia/.gitignore
vendored
38
backendia/.gitignore
vendored
@@ -1,38 +0,0 @@
|
|||||||
# 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
41
backendia/dashboard/.gitignore
vendored
@@ -1,41 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
<!-- 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 -->
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
@AGENTS.md
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
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.
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
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
6616
backendia/dashboard/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,29 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
const config = {
|
|
||||||
plugins: {
|
|
||||||
"@tailwindcss/postcss": {},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default config;
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 391 B |
@@ -1 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 1.0 KiB |
@@ -1 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 1.3 KiB |
@@ -1 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 128 B |
@@ -1 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 385 B |
@@ -1,104 +0,0 @@
|
|||||||
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.
|
Before Width: | Height: | Size: 25 KiB |
@@ -1,26 +0,0 @@
|
|||||||
@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;
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,226 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,183 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
{
|
|
||||||
"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"]
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
# Keep directory structure
|
|
||||||
1682
backendia/package-lock.json
generated
1682
backendia/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,22 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,346 +0,0 @@
|
|||||||
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.
@@ -2,8 +2,13 @@
|
|||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<key>NSCameraUsageDescription</key>
|
<dict>
|
||||||
<string>This app needs camera access to scan documents</string>
|
<key>NSPhotoLibraryUsageDescription</key>
|
||||||
|
<string>Bully a besoin d'accéder à vos photos pour analyser vos cibles enregistrées.</string>
|
||||||
|
<key>NSCameraUsageDescription</key>
|
||||||
|
<string>Bully a besoin d'utiliser l'appareil photo pour scanner vos cibles en temps réel.</string>
|
||||||
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
|
<string>Bully a besoin d'accéder au micro (requis par certains services de caméra même si non utilisé).</string>
|
||||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>CFBundleDevelopmentRegion</key>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
|||||||
11
lib/app.dart
11
lib/app.dart
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'core/theme/app_theme.dart';
|
import 'core/theme/app_theme.dart';
|
||||||
import 'features/home/home_screen.dart';
|
import 'features/home/home_screen.dart';
|
||||||
|
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||||
|
|
||||||
class BullyApp extends StatelessWidget {
|
class BullyApp extends StatelessWidget {
|
||||||
const BullyApp({super.key});
|
const BullyApp({super.key});
|
||||||
@@ -13,6 +14,16 @@ class BullyApp extends StatelessWidget {
|
|||||||
theme: AppTheme.lightTheme,
|
theme: AppTheme.lightTheme,
|
||||||
darkTheme: AppTheme.darkTheme,
|
darkTheme: AppTheme.darkTheme,
|
||||||
themeMode: ThemeMode.system,
|
themeMode: ThemeMode.system,
|
||||||
|
localizationsDelegates: const [
|
||||||
|
GlobalMaterialLocalizations.delegate,
|
||||||
|
GlobalWidgetsLocalizations.delegate,
|
||||||
|
GlobalCupertinoLocalizations.delegate,
|
||||||
|
],
|
||||||
|
supportedLocales: const [
|
||||||
|
Locale('fr', 'FR'), // Français
|
||||||
|
],
|
||||||
|
locale: const Locale('fr', 'FR'), // Force l'interface en français
|
||||||
|
|
||||||
home: const HomeScreen(),
|
home: const HomeScreen(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ 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 }
|
||||||
|
|
||||||
@@ -689,42 +688,6 @@ 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'exporter : image ou type de cible manquant.";
|
|
||||||
notifyListeners();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Identifiant de session temporaire si non sauvegardée
|
|
||||||
final sessionId = _shots.isNotEmpty && _shots.first.sessionId.isNotEmpty
|
|
||||||
? _shots.first.sessionId
|
|
||||||
: 'session_${DateTime.now().millisecondsSinceEpoch}';
|
|
||||||
|
|
||||||
final service = AiExportService(); // Local instanciation for simplicity
|
|
||||||
|
|
||||||
_state = AnalysisState.loading;
|
|
||||||
notifyListeners();
|
|
||||||
|
|
||||||
final success = await service.exportData(
|
|
||||||
imagePath: _imagePath!,
|
|
||||||
sessionId: sessionId,
|
|
||||||
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<Session> saveSession({String? notes}) async {
|
||||||
if (_imagePath == null || _targetType == null) {
|
if (_imagePath == null || _targetType == null) {
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ 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/wallet_identity_service.dart';
|
|
||||||
import 'analysis_provider.dart';
|
import 'analysis_provider.dart';
|
||||||
import 'widgets/target_overlay.dart';
|
import 'widgets/target_overlay.dart';
|
||||||
import 'widgets/target_calibration.dart';
|
import 'widgets/target_calibration.dart';
|
||||||
@@ -134,47 +133,6 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
FutureBuilder<bool>(
|
|
||||||
future: WalletIdentityService().isUploadEnabled(),
|
|
||||||
builder: (context, snapshot) {
|
|
||||||
final isEnabled = snapshot.data ?? false;
|
|
||||||
if (!isEnabled) return const SizedBox.shrink();
|
|
||||||
|
|
||||||
return IconButton(
|
|
||||||
icon: const Icon(Icons.cloud_upload),
|
|
||||||
onPressed: () async {
|
|
||||||
final provider = context.read<AnalysisProvider>();
|
|
||||||
if (provider.state != AnalysisState.success) return;
|
|
||||||
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(content: Text('Exportation en cours...')),
|
|
||||||
);
|
|
||||||
|
|
||||||
final success = await provider.exportToAiBackend();
|
|
||||||
|
|
||||||
if (context.mounted) {
|
|
||||||
ScaffoldMessenger.of(context).hideCurrentSnackBar();
|
|
||||||
if (success) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(
|
|
||||||
content: Text('Export réussi vers le backend IA !'),
|
|
||||||
backgroundColor: AppTheme.successColor,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text(provider.errorMessage ?? 'Erreur d\'export'),
|
|
||||||
backgroundColor: AppTheme.errorColor,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
tooltip: 'Exporter pour IA',
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.help_outline),
|
icon: const Icon(Icons.help_outline),
|
||||||
onPressed: () => _showHelpDialog(context),
|
onPressed: () => _showHelpDialog(context),
|
||||||
|
|||||||
@@ -1,14 +1,10 @@
|
|||||||
/// Écran de capture - Première étape du workflow d'analyse.
|
|
||||||
///
|
|
||||||
/// Permet de sélectionner le type de cible (concentrique ou silhouette)
|
|
||||||
/// et la source d'image (caméra ou galerie). Affiche un aperçu de l'image
|
|
||||||
/// sélectionnée avant de lancer l'analyse.
|
|
||||||
library;
|
|
||||||
|
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'package:google_mlkit_document_scanner/google_mlkit_document_scanner.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:image_picker/image_picker.dart';
|
import 'package:image_picker/image_picker.dart';
|
||||||
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
|
import 'package:google_mlkit_document_scanner/google_mlkit_document_scanner.dart';
|
||||||
|
import 'package:device_info_plus/device_info_plus.dart';
|
||||||
|
|
||||||
import '../../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';
|
||||||
@@ -24,14 +20,68 @@ class CaptureScreen extends StatefulWidget {
|
|||||||
|
|
||||||
class _CaptureScreenState extends State<CaptureScreen> {
|
class _CaptureScreenState extends State<CaptureScreen> {
|
||||||
final ImagePicker _picker = ImagePicker();
|
final ImagePicker _picker = ImagePicker();
|
||||||
TargetType _selectedType = TargetType.concentric;
|
final TargetType _selectedType = TargetType.concentric;
|
||||||
String? _selectedImagePath;
|
String? _selectedImagePath;
|
||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
|
|
||||||
|
/// Gère la demande de permission et la sélection d'image depuis la galerie
|
||||||
|
Future<void> _handleGallerySelection() async {
|
||||||
|
PermissionStatus status;
|
||||||
|
|
||||||
|
if (Platform.isAndroid) {
|
||||||
|
final deviceInfo = DeviceInfoPlugin();
|
||||||
|
final androidInfo = await deviceInfo.androidInfo;
|
||||||
|
|
||||||
|
if (androidInfo.version.sdkInt >= 33) {
|
||||||
|
status = await Permission.photos.request();
|
||||||
|
} else {
|
||||||
|
status = await Permission.storage.request();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
status = await Permission.photos.request();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status.isGranted) {
|
||||||
|
_captureImage(ImageSource.gallery);
|
||||||
|
} else if (status.isPermanentlyDenied) {
|
||||||
|
_showSettingsDialog();
|
||||||
|
} else {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Accès à la galerie requis pour continuer'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showSettingsDialog() {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Permission requise'),
|
||||||
|
content: const Text(
|
||||||
|
'L\'accès aux photos est nécessaire. Veuillez l\'activer dans les paramètres.',
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('Annuler'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => openAppSettings(),
|
||||||
|
child: const Text('Paramètres'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('Nouvelle Analyse')),
|
appBar: AppBar(title: const Text('Source')),
|
||||||
body: SingleChildScrollView(
|
body: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.all(AppConstants.defaultPadding),
|
padding: const EdgeInsets.all(AppConstants.defaultPadding),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -39,7 +89,6 @@ class _CaptureScreenState extends State<CaptureScreen> {
|
|||||||
children: [
|
children: [
|
||||||
const SizedBox(height: AppConstants.largePadding),
|
const SizedBox(height: AppConstants.largePadding),
|
||||||
|
|
||||||
// Image source selection
|
|
||||||
_buildSectionTitle('Source de l\'Image'),
|
_buildSectionTitle('Source de l\'Image'),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Row(
|
Row(
|
||||||
@@ -56,16 +105,13 @@ class _CaptureScreenState extends State<CaptureScreen> {
|
|||||||
child: ImageSourceButton(
|
child: ImageSourceButton(
|
||||||
icon: Icons.photo_library,
|
icon: Icons.photo_library,
|
||||||
label: 'Galerie',
|
label: 'Galerie',
|
||||||
onPressed: _isLoading
|
onPressed: _isLoading ? null : _handleGallerySelection,
|
||||||
? null
|
|
||||||
: () => _captureImage(ImageSource.gallery),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: AppConstants.largePadding),
|
const SizedBox(height: AppConstants.largePadding),
|
||||||
|
|
||||||
// Image preview
|
|
||||||
if (_isLoading)
|
if (_isLoading)
|
||||||
const Center(
|
const Center(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
@@ -74,20 +120,44 @@ class _CaptureScreenState extends State<CaptureScreen> {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
else if (_selectedImagePath != null)
|
else if (_selectedImagePath != null)
|
||||||
_buildImagePreview(),
|
_buildImagePreview()
|
||||||
|
else
|
||||||
|
_buildGuide(),
|
||||||
|
|
||||||
// Guide text
|
// --- NOUVEAU BOUTON VALIDER ---
|
||||||
if (_selectedImagePath == null && !_isLoading) _buildGuide(),
|
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),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
floatingActionButton: _selectedImagePath != null
|
|
||||||
? FloatingActionButton.extended(
|
|
||||||
onPressed: _analyzeImage,
|
|
||||||
icon: const Icon(Icons.arrow_forward),
|
|
||||||
label: const Text('Suivant'),
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,7 +173,7 @@ class _CaptureScreenState extends State<CaptureScreen> {
|
|||||||
Widget _buildImagePreview() {
|
Widget _buildImagePreview() {
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
_buildSectionTitle('Apercu'),
|
_buildSectionTitle('Aperçu'),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
ClipRRect(
|
ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
|
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
|
||||||
@@ -119,9 +189,7 @@ class _CaptureScreenState extends State<CaptureScreen> {
|
|||||||
right: 8,
|
right: 8,
|
||||||
child: IconButton(
|
child: IconButton(
|
||||||
icon: const Icon(Icons.close),
|
icon: const Icon(Icons.close),
|
||||||
onPressed: () {
|
onPressed: () => setState(() => _selectedImagePath = null),
|
||||||
setState(() => _selectedImagePath = null);
|
|
||||||
},
|
|
||||||
style: IconButton.styleFrom(
|
style: IconButton.styleFrom(
|
||||||
backgroundColor: Colors.black54,
|
backgroundColor: Colors.black54,
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
@@ -139,7 +207,7 @@ class _CaptureScreenState extends State<CaptureScreen> {
|
|||||||
|
|
||||||
Widget _buildFramingHints() {
|
Widget _buildFramingHints() {
|
||||||
return Card(
|
return Card(
|
||||||
color: AppTheme.warningColor.withValues(alpha: 0.1),
|
color: AppTheme.warningColor.withOpacity(0.1),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -148,10 +216,8 @@ class _CaptureScreenState extends State<CaptureScreen> {
|
|||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
'Assurez-vous que la cible est bien centree et visible.',
|
'Assurez-vous que la cible est bien centrée et visible.',
|
||||||
style: TextStyle(
|
style: TextStyle(color: AppTheme.warningColor.withOpacity(0.8)),
|
||||||
color: AppTheme.warningColor.withValues(alpha: 0.8),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -177,11 +243,15 @@ class _CaptureScreenState extends State<CaptureScreen> {
|
|||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_buildGuideItem(
|
_buildGuideItem(
|
||||||
Icons.crop_free,
|
Icons.crop_free,
|
||||||
'Cadrez la cible entiere dans l\'image',
|
'Cadrez la cible entière dans l\'image',
|
||||||
),
|
),
|
||||||
_buildGuideItem(Icons.wb_sunny, 'Utilisez un bon eclairage'),
|
_buildGuideItem(Icons.wb_sunny, 'Utilisez un bon éclairage'),
|
||||||
_buildGuideItem(Icons.straighten, 'Prenez la photo de face'),
|
_buildGuideItem(Icons.straighten, 'Prenez la photo de face'),
|
||||||
_buildGuideItem(Icons.blur_off, 'Evitez les images floues'),
|
_buildGuideItem(Icons.blur_off, 'Évitez les images floues'),
|
||||||
|
_buildGuideItem(
|
||||||
|
Icons.cleaning_services,
|
||||||
|
'Nettoyer votre objectif avec une chiffonnette',
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -203,7 +273,6 @@ class _CaptureScreenState extends State<CaptureScreen> {
|
|||||||
|
|
||||||
Future<void> _scanDocument() async {
|
Future<void> _scanDocument() async {
|
||||||
setState(() => _isLoading = true);
|
setState(() => _isLoading = true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final options = DocumentScannerOptions(
|
final options = DocumentScannerOptions(
|
||||||
documentFormat: DocumentFormat.jpeg,
|
documentFormat: DocumentFormat.jpeg,
|
||||||
@@ -211,32 +280,20 @@ class _CaptureScreenState extends State<CaptureScreen> {
|
|||||||
pageLimit: 1,
|
pageLimit: 1,
|
||||||
isGalleryImport: false,
|
isGalleryImport: false,
|
||||||
);
|
);
|
||||||
|
|
||||||
final scanner = DocumentScanner(options: options);
|
final scanner = DocumentScanner(options: options);
|
||||||
final documents = await scanner.scanDocument();
|
final documents = await scanner.scanDocument();
|
||||||
|
|
||||||
if (documents.images.isNotEmpty) {
|
if (documents.images.isNotEmpty) {
|
||||||
setState(() => _selectedImagePath = documents.images.first);
|
setState(() => _selectedImagePath = documents.images.first);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
debugPrint('Erreur scan: $e');
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text('Erreur lors du scan: $e'),
|
|
||||||
backgroundColor: AppTheme.errorColor,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) {
|
if (mounted) setState(() => _isLoading = false);
|
||||||
setState(() => _isLoading = false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _captureImage(ImageSource source) async {
|
Future<void> _captureImage(ImageSource source) async {
|
||||||
setState(() => _isLoading = true);
|
setState(() => _isLoading = true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final XFile? image = await _picker.pickImage(
|
final XFile? image = await _picker.pickImage(
|
||||||
source: source,
|
source: source,
|
||||||
@@ -244,29 +301,18 @@ class _CaptureScreenState extends State<CaptureScreen> {
|
|||||||
maxHeight: 2048,
|
maxHeight: 2048,
|
||||||
imageQuality: 90,
|
imageQuality: 90,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (image != null) {
|
if (image != null) {
|
||||||
setState(() => _selectedImagePath = image.path);
|
setState(() => _selectedImagePath = image.path);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
debugPrint('Erreur capture: $e');
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text('Erreur lors de la capture: $e'),
|
|
||||||
backgroundColor: AppTheme.errorColor,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) {
|
if (mounted) setState(() => _isLoading = false);
|
||||||
setState(() => _isLoading = false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _analyzeImage() {
|
void _analyzeImage() {
|
||||||
if (_selectedImagePath == null) return;
|
if (_selectedImagePath == null) return;
|
||||||
|
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
|
|||||||
@@ -1,18 +1,9 @@
|
|||||||
/// Écran de recadrage d'image en format carré (1:1).
|
|
||||||
///
|
|
||||||
/// Permet à l'utilisateur de déplacer et zoomer l'image pour sélectionner
|
|
||||||
/// la zone à recadrer. Le carré de recadrage est fixe au centre de l'écran.
|
|
||||||
library;
|
|
||||||
|
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:math' as math;
|
|
||||||
import 'dart:ui' as ui;
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../../core/theme/app_theme.dart';
|
|
||||||
|
// Importations ajustées selon ton arborescence à gauche
|
||||||
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 {
|
||||||
final String imagePath;
|
final String imagePath;
|
||||||
@@ -29,313 +20,105 @@ class CropScreen extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _CropScreenState extends State<CropScreen> {
|
class _CropScreenState extends State<CropScreen> {
|
||||||
final ImageCropService _cropService = ImageCropService();
|
|
||||||
|
|
||||||
bool _isLoading = false;
|
|
||||||
bool _imageLoaded = false;
|
|
||||||
Size? _imageSize;
|
|
||||||
|
|
||||||
// Position et échelle de l'image
|
|
||||||
Offset _offset = Offset.zero;
|
|
||||||
double _scale = 1.0;
|
|
||||||
double _baseScale = 1.0;
|
|
||||||
Offset _startFocalPoint = Offset.zero;
|
|
||||||
Offset _startOffset = Offset.zero;
|
|
||||||
|
|
||||||
// Dimensions calculées
|
|
||||||
double _cropSize = 0;
|
|
||||||
Size _viewportSize = Size.zero;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_loadImageDimensions();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _loadImageDimensions() async {
|
|
||||||
final file = File(widget.imagePath);
|
|
||||||
final bytes = await file.readAsBytes();
|
|
||||||
final codec = await ui.instantiateImageCodec(bytes);
|
|
||||||
final frame = await codec.getNextFrame();
|
|
||||||
|
|
||||||
setState(() {
|
|
||||||
_imageSize = Size(
|
|
||||||
frame.image.width.toDouble(),
|
|
||||||
frame.image.height.toDouble(),
|
|
||||||
);
|
|
||||||
_imageLoaded = true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: Colors.black,
|
backgroundColor: const Color(0xFF101214),
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
backgroundColor: Colors.black,
|
backgroundColor: const Color(0xFF101214),
|
||||||
foregroundColor: Colors.white,
|
elevation: 0,
|
||||||
title: const Text('Recadrer'),
|
centerTitle: true,
|
||||||
|
title: const Text(
|
||||||
|
'Validation image',
|
||||||
|
style: TextStyle(color: Colors.white, fontSize: 18),
|
||||||
|
),
|
||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
icon: const Icon(Icons.close),
|
icon: const Icon(Icons.arrow_back, color: Colors.white),
|
||||||
onPressed: () => Navigator.pop(context),
|
onPressed: () => Navigator.pop(context),
|
||||||
),
|
),
|
||||||
actions: [
|
|
||||||
if (!_isLoading)
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.check),
|
|
||||||
onPressed: _onCropConfirm,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
body: _buildBody(),
|
body: Column(
|
||||||
);
|
children: [
|
||||||
}
|
// L'image capturée
|
||||||
|
Expanded(
|
||||||
Widget _buildBody() {
|
child: Container(
|
||||||
if (!_imageLoaded || _imageSize == null) {
|
margin: const EdgeInsets.all(20),
|
||||||
return const Center(
|
decoration: BoxDecoration(
|
||||||
child: CircularProgressIndicator(color: Colors.white),
|
borderRadius: BorderRadius.circular(12),
|
||||||
);
|
border: Border.all(color: Colors.white10),
|
||||||
}
|
),
|
||||||
|
child: ClipRRect(
|
||||||
if (_isLoading) {
|
borderRadius: BorderRadius.circular(12),
|
||||||
return Center(
|
child: Image.file(
|
||||||
child: Column(
|
File(widget.imagePath),
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
fit: BoxFit.contain,
|
||||||
children: [
|
),
|
||||||
const CircularProgressIndicator(color: Colors.white),
|
),
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(
|
|
||||||
'Recadrage en cours...',
|
|
||||||
style: TextStyle(color: Colors.white.withValues(alpha: 0.8)),
|
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return LayoutBuilder(
|
// Bouton Modifier
|
||||||
builder: (context, constraints) {
|
Padding(
|
||||||
_viewportSize = Size(constraints.maxWidth, constraints.maxHeight);
|
padding: const EdgeInsets.symmetric(vertical: 20),
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
onPressed: () {
|
||||||
|
// Logique de modification à venir
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.crop_rotate),
|
||||||
|
label: const Text('Modifier'),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: const Color(0xFF1A73E8),
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 12),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
// Taille du carré de crop (90% de la plus petite dimension)
|
// Boutons du bas
|
||||||
_cropSize =
|
Padding(
|
||||||
math.min(constraints.maxWidth, constraints.maxHeight) * 0.85;
|
padding: const EdgeInsets.fromLTRB(20, 0, 20, 30),
|
||||||
|
child: Row(
|
||||||
// Calculer l'échelle initiale si pas encore fait
|
children: [
|
||||||
if (_scale == 1.0 && _offset == Offset.zero) {
|
Expanded(
|
||||||
_initializeImagePosition();
|
child: OutlinedButton(
|
||||||
}
|
onPressed: () => Navigator.pop(context),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
return GestureDetector(
|
side: const BorderSide(color: Colors.white24),
|
||||||
onScaleStart: _onScaleStart,
|
padding: const EdgeInsets.symmetric(vertical: 15),
|
||||||
onScaleUpdate: _onScaleUpdate,
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||||
onScaleEnd: _onScaleEnd,
|
|
||||||
child: Stack(
|
|
||||||
children: [
|
|
||||||
// Image transformée
|
|
||||||
Positioned.fill(
|
|
||||||
child: Center(
|
|
||||||
child: Transform(
|
|
||||||
transform: Matrix4.identity()
|
|
||||||
..setTranslationRaw(_offset.dx, _offset.dy, 0)
|
|
||||||
..scale(_scale, _scale),
|
|
||||||
alignment: Alignment.center,
|
|
||||||
child: Image.file(
|
|
||||||
File(widget.imagePath),
|
|
||||||
fit: BoxFit.contain,
|
|
||||||
width: _viewportSize.width,
|
|
||||||
height: _viewportSize.height,
|
|
||||||
),
|
),
|
||||||
|
child: const Text('Recommencer', style: TextStyle(color: Colors.white)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
// Overlay de recadrage
|
child: ElevatedButton(
|
||||||
Positioned.fill(
|
onPressed: () {
|
||||||
child: IgnorePointer(
|
Navigator.push(
|
||||||
child: CropOverlay(cropSize: _cropSize, showGrid: true),
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => AnalysisScreen(
|
||||||
|
imagePath: widget.imagePath,
|
||||||
|
targetType: widget.targetType,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: const Color(0xFF1A73E8),
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 15),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||||
|
),
|
||||||
|
child: const Text('Valider', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
|
|
||||||
// Instructions en bas
|
|
||||||
Positioned(
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 24,
|
|
||||||
child: _buildInstructions(),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _initializeImagePosition() {
|
|
||||||
if (_imageSize == null) return;
|
|
||||||
|
|
||||||
final imageAspect = _imageSize!.width / _imageSize!.height;
|
|
||||||
final viewportAspect = _viewportSize.width / _viewportSize.height;
|
|
||||||
|
|
||||||
// Calculer la taille de l'image affichée (avec BoxFit.contain)
|
|
||||||
double displayWidth, displayHeight;
|
|
||||||
if (imageAspect > viewportAspect) {
|
|
||||||
displayWidth = _viewportSize.width;
|
|
||||||
displayHeight = _viewportSize.width / imageAspect;
|
|
||||||
} else {
|
|
||||||
displayHeight = _viewportSize.height;
|
|
||||||
displayWidth = _viewportSize.height * imageAspect;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Échelle pour que le plus petit côté de l'image remplisse le carré de crop
|
|
||||||
final minDisplayDim = math.min(displayWidth, displayHeight);
|
|
||||||
_scale = _cropSize / minDisplayDim;
|
|
||||||
|
|
||||||
// S'assurer d'un scale minimum
|
|
||||||
if (_scale < 1.0) _scale = 1.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onScaleStart(ScaleStartDetails details) {
|
|
||||||
_baseScale = _scale;
|
|
||||||
_startFocalPoint = details.focalPoint;
|
|
||||||
_startOffset = _offset;
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onScaleUpdate(ScaleUpdateDetails details) {
|
|
||||||
setState(() {
|
|
||||||
// Mise à jour du scale
|
|
||||||
_scale = (_baseScale * details.scale).clamp(0.5, 5.0);
|
|
||||||
|
|
||||||
// Mise à jour de la position
|
|
||||||
final delta = details.focalPoint - _startFocalPoint;
|
|
||||||
_offset = _startOffset + delta;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onScaleEnd(ScaleEndDetails details) {
|
|
||||||
// Optionnel: contraindre l'image pour qu'elle couvre toujours le carré de crop
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildInstructions() {
|
|
||||||
return Container(
|
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 24),
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.black.withValues(alpha: 0.7),
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Icons.touch_app,
|
|
||||||
color: Colors.white.withValues(alpha: 0.8),
|
|
||||||
size: 20,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Text(
|
|
||||||
'Déplacez et zoomez pour cadrer la cible',
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.white.withValues(alpha: 0.9),
|
|
||||||
fontSize: 14,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
Future<void> _onCropConfirm() async {
|
|
||||||
if (_imageSize == null) return;
|
|
||||||
|
|
||||||
setState(() {
|
|
||||||
_isLoading = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Calculer la zone de crop en coordonnées normalisées de l'image
|
|
||||||
final cropRect = _calculateCropRect();
|
|
||||||
|
|
||||||
// Recadrer l'image
|
|
||||||
final croppedPath = await _cropService.cropToSquare(
|
|
||||||
widget.imagePath,
|
|
||||||
cropRect,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!mounted) return;
|
|
||||||
|
|
||||||
// Naviguer vers l'écran d'analyse avec l'image recadrée
|
|
||||||
Navigator.pushReplacement(
|
|
||||||
context,
|
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (_) => AnalysisScreen(
|
|
||||||
imagePath: croppedPath,
|
|
||||||
targetType: widget.targetType,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
if (!mounted) return;
|
|
||||||
|
|
||||||
setState(() {
|
|
||||||
_isLoading = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text('Erreur lors du recadrage: $e'),
|
|
||||||
backgroundColor: AppTheme.errorColor,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
CropRect _calculateCropRect() {
|
|
||||||
if (_imageSize == null) {
|
|
||||||
return const CropRect(x: 0, y: 0, width: 1, height: 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
final imageAspect = _imageSize!.width / _imageSize!.height;
|
|
||||||
final viewportAspect = _viewportSize.width / _viewportSize.height;
|
|
||||||
|
|
||||||
// Calculer la taille de l'image affichée (avec BoxFit.contain)
|
|
||||||
double displayWidth, displayHeight;
|
|
||||||
if (imageAspect > viewportAspect) {
|
|
||||||
displayWidth = _viewportSize.width;
|
|
||||||
displayHeight = _viewportSize.width / imageAspect;
|
|
||||||
} else {
|
|
||||||
displayHeight = _viewportSize.height;
|
|
||||||
displayWidth = _viewportSize.height * imageAspect;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Taille de l'image après scale
|
|
||||||
final scaledWidth = displayWidth * _scale;
|
|
||||||
final scaledHeight = displayHeight * _scale;
|
|
||||||
|
|
||||||
// Position du centre de l'image dans le viewport
|
|
||||||
final imageCenterX = _viewportSize.width / 2 + _offset.dx;
|
|
||||||
final imageCenterY = _viewportSize.height / 2 + _offset.dy;
|
|
||||||
|
|
||||||
// Position du coin supérieur gauche de l'image
|
|
||||||
final imageLeft = imageCenterX - scaledWidth / 2;
|
|
||||||
final imageTop = imageCenterY - scaledHeight / 2;
|
|
||||||
|
|
||||||
// Position du carré de crop (centré dans le viewport)
|
|
||||||
final cropLeft = (_viewportSize.width - _cropSize) / 2;
|
|
||||||
final cropTop = (_viewportSize.height - _cropSize) / 2;
|
|
||||||
|
|
||||||
// Convertir en coordonnées relatives à l'image affichée
|
|
||||||
final relCropLeft = (cropLeft - imageLeft) / scaledWidth;
|
|
||||||
final relCropTop = (cropTop - imageTop) / scaledHeight;
|
|
||||||
final relCropSize = _cropSize / scaledWidth;
|
|
||||||
final relCropSizeY = _cropSize / scaledHeight;
|
|
||||||
|
|
||||||
return CropRect(
|
|
||||||
x: relCropLeft.clamp(0.0, 1.0),
|
|
||||||
y: relCropTop.clamp(0.0, 1.0),
|
|
||||||
width: relCropSize.clamp(0.0, 1.0 - relCropLeft.clamp(0.0, 1.0)),
|
|
||||||
height: relCropSizeY.clamp(0.0, 1.0 - relCropTop.clamp(0.0, 1.0)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,3 @@
|
|||||||
/// Écran d'historique des sessions.
|
|
||||||
///
|
|
||||||
/// Affiche la liste des sessions passées avec filtrage par type de cible.
|
|
||||||
/// Inclut un graphique d'évolution des scores (10 dernières sessions)
|
|
||||||
/// et permet la navigation vers le détail de chaque session.
|
|
||||||
library;
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
@@ -29,6 +22,9 @@ class _HistoryScreenState extends State<HistoryScreen> {
|
|||||||
bool _isLoading = true;
|
bool _isLoading = true;
|
||||||
TargetType? _filterType;
|
TargetType? _filterType;
|
||||||
|
|
||||||
|
// --- MODIFICATION : Remplacement de DateTime par DateTimeRange ---
|
||||||
|
DateTimeRange? _selectedDateRange;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -36,17 +32,43 @@ class _HistoryScreenState extends State<HistoryScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadSessions() async {
|
Future<void> _loadSessions() async {
|
||||||
|
if (!mounted) return;
|
||||||
setState(() => _isLoading = true);
|
setState(() => _isLoading = true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final repository = context.read<SessionRepository>();
|
final repository = context.read<SessionRepository>();
|
||||||
final sessions = await repository.getAllSessions(
|
final sessions = await repository.getAllSessions(targetType: _filterType);
|
||||||
targetType: _filterType,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_sessions = sessions;
|
_sessions = sessions;
|
||||||
|
|
||||||
|
// --- LOGIQUE DE FILTRAGE PAR PÉRIODE ---
|
||||||
|
if (_selectedDateRange != null) {
|
||||||
|
_sessions = _sessions.where((s) {
|
||||||
|
// On compare uniquement les dates (sans les heures) pour éviter les bugs
|
||||||
|
final sessionDate = DateTime(
|
||||||
|
s.createdAt.year,
|
||||||
|
s.createdAt.month,
|
||||||
|
s.createdAt.day,
|
||||||
|
);
|
||||||
|
final startDate = DateTime(
|
||||||
|
_selectedDateRange!.start.year,
|
||||||
|
_selectedDateRange!.start.month,
|
||||||
|
_selectedDateRange!.start.day,
|
||||||
|
);
|
||||||
|
final endDate = DateTime(
|
||||||
|
_selectedDateRange!.end.year,
|
||||||
|
_selectedDateRange!.end.month,
|
||||||
|
_selectedDateRange!.end.day,
|
||||||
|
);
|
||||||
|
|
||||||
|
return sessionDate.isAtSameMomentAs(startDate) ||
|
||||||
|
sessionDate.isAtSameMomentAs(endDate) ||
|
||||||
|
(sessionDate.isAfter(startDate) &&
|
||||||
|
sessionDate.isBefore(endDate));
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -55,7 +77,7 @@ class _HistoryScreenState extends State<HistoryScreen> {
|
|||||||
setState(() => _isLoading = false);
|
setState(() => _isLoading = false);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text('Erreur de chargement: $e'),
|
content: Text('Erreur: $e'),
|
||||||
backgroundColor: AppTheme.errorColor,
|
backgroundColor: AppTheme.errorColor,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -63,6 +85,35 @@ class _HistoryScreenState extends State<HistoryScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- MODIFICATION : Fonction DateRangePicker ---
|
||||||
|
Future<void> _pickDateRange() async {
|
||||||
|
final DateTimeRange? picked = await showDateRangePicker(
|
||||||
|
context: context,
|
||||||
|
initialDateRange: _selectedDateRange,
|
||||||
|
firstDate: DateTime(2020),
|
||||||
|
lastDate: DateTime.now().add(const Duration(days: 1)),
|
||||||
|
locale: const Locale('fr', 'FR'),
|
||||||
|
builder: (context, child) {
|
||||||
|
return Theme(
|
||||||
|
data: Theme.of(context).copyWith(
|
||||||
|
colorScheme: ColorScheme.dark(
|
||||||
|
primary: AppTheme.primaryColor,
|
||||||
|
onPrimary: Colors.white,
|
||||||
|
surface: Theme.of(context).cardColor,
|
||||||
|
onSurface: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: child!,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (picked != null) {
|
||||||
|
setState(() => _selectedDateRange = picked);
|
||||||
|
_loadSessions();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
@@ -71,55 +122,86 @@ class _HistoryScreenState extends State<HistoryScreen> {
|
|||||||
actions: [
|
actions: [
|
||||||
PopupMenuButton<TargetType?>(
|
PopupMenuButton<TargetType?>(
|
||||||
icon: const Icon(Icons.filter_list),
|
icon: const Icon(Icons.filter_list),
|
||||||
tooltip: 'Filtrer',
|
|
||||||
onSelected: (type) {
|
onSelected: (type) {
|
||||||
setState(() => _filterType = type);
|
setState(() => _filterType = type);
|
||||||
_loadSessions();
|
_loadSessions();
|
||||||
},
|
},
|
||||||
itemBuilder: (context) => [
|
itemBuilder: (context) => [
|
||||||
const PopupMenuItem(
|
const PopupMenuItem(value: null, child: Text('Tous')),
|
||||||
value: null,
|
...TargetType.values.map(
|
||||||
child: Text('Tous'),
|
(type) =>
|
||||||
|
PopupMenuItem(value: type, child: Text(type.displayName)),
|
||||||
),
|
),
|
||||||
...TargetType.values.map((type) => PopupMenuItem(
|
|
||||||
value: type,
|
|
||||||
child: Text(type.displayName),
|
|
||||||
)),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: _isLoading
|
body: Column(
|
||||||
? const Center(child: CircularProgressIndicator())
|
children: [
|
||||||
: _sessions.isEmpty
|
Expanded(
|
||||||
? _buildEmptyState()
|
child: _isLoading
|
||||||
: _buildContent(),
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: _sessions.isEmpty
|
||||||
|
? _buildEmptyState()
|
||||||
|
: _buildContent(),
|
||||||
|
),
|
||||||
|
_buildBottomFilterBar(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildBottomFilterBar() {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).cardColor,
|
||||||
|
boxShadow: const [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black26,
|
||||||
|
blurRadius: 4,
|
||||||
|
offset: Offset(0, -2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: SafeArea(
|
||||||
|
top: false,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
onPressed: _pickDateRange,
|
||||||
|
icon: const Icon(Icons.date_range, size: 18),
|
||||||
|
label: Text(
|
||||||
|
_selectedDateRange == null
|
||||||
|
? 'Choisir une période'
|
||||||
|
: '${DateFormat('dd/MM/yy').format(_selectedDateRange!.start)} - ${DateFormat('dd/MM/yy').format(_selectedDateRange!.end)}',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_selectedDateRange != null)
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.close, color: AppTheme.errorColor),
|
||||||
|
onPressed: () {
|
||||||
|
setState(() => _selectedDateRange = null);
|
||||||
|
_loadSessions();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildEmptyState() {
|
Widget _buildEmptyState() {
|
||||||
return Center(
|
return Center(
|
||||||
child: Padding(
|
child: Column(
|
||||||
padding: const EdgeInsets.all(AppConstants.defaultPadding),
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
child: Column(
|
children: [
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
Icon(Icons.history, size: 64, color: Colors.grey[400]),
|
||||||
children: [
|
const SizedBox(height: 16),
|
||||||
Icon(Icons.history, size: 64, color: Colors.grey[400]),
|
const Text('Aucune session sur cette période'),
|
||||||
const SizedBox(height: 16),
|
],
|
||||||
Text(
|
|
||||||
'Aucune session',
|
|
||||||
style: Theme.of(context).textTheme.titleLarge,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
_filterType != null
|
|
||||||
? 'Aucune session de type ${_filterType!.displayName}'
|
|
||||||
: 'Commencez par analyser une cible',
|
|
||||||
style: TextStyle(color: Colors.grey[600]),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -129,49 +211,27 @@ class _HistoryScreenState extends State<HistoryScreen> {
|
|||||||
onRefresh: _loadSessions,
|
onRefresh: _loadSessions,
|
||||||
child: CustomScrollView(
|
child: CustomScrollView(
|
||||||
slivers: [
|
slivers: [
|
||||||
// Chart section
|
if (_sessions.length >= 2 && _selectedDateRange == null)
|
||||||
if (_sessions.length >= 2)
|
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(AppConstants.defaultPadding),
|
padding: const EdgeInsets.all(AppConstants.defaultPadding),
|
||||||
child: HistoryChart(sessions: _sessions),
|
child: HistoryChart(sessions: _sessions),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Filter indicator
|
|
||||||
if (_filterType != null)
|
|
||||||
SliverToBoxAdapter(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: AppConstants.defaultPadding),
|
|
||||||
child: Chip(
|
|
||||||
label: Text('Filtre: ${_filterType!.displayName}'),
|
|
||||||
deleteIcon: const Icon(Icons.close, size: 18),
|
|
||||||
onDeleted: () {
|
|
||||||
setState(() => _filterType = null);
|
|
||||||
_loadSessions();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Sessions list
|
|
||||||
SliverPadding(
|
SliverPadding(
|
||||||
padding: const EdgeInsets.all(AppConstants.defaultPadding),
|
padding: const EdgeInsets.all(AppConstants.defaultPadding),
|
||||||
sliver: SliverList(
|
sliver: SliverList(
|
||||||
delegate: SliverChildBuilderDelegate(
|
delegate: SliverChildBuilderDelegate((context, index) {
|
||||||
(context, index) {
|
final session = _sessions[index];
|
||||||
final session = _sessions[index];
|
return Padding(
|
||||||
return Padding(
|
padding: const EdgeInsets.only(bottom: 12),
|
||||||
padding: const EdgeInsets.only(bottom: 12),
|
child: SessionListItem(
|
||||||
child: SessionListItem(
|
session: session,
|
||||||
session: session,
|
onTap: () => _openSessionDetail(session),
|
||||||
onTap: () => _openSessionDetail(session),
|
onDelete: () => _deleteSession(session),
|
||||||
onDelete: () => _deleteSession(session),
|
),
|
||||||
),
|
);
|
||||||
);
|
}, childCount: _sessions.length),
|
||||||
},
|
|
||||||
childCount: _sessions.length,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -182,11 +242,9 @@ class _HistoryScreenState extends State<HistoryScreen> {
|
|||||||
void _openSessionDetail(Session session) async {
|
void _openSessionDetail(Session session) async {
|
||||||
await Navigator.push(
|
await Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(builder: (_) => SessionDetailScreen(session: session)),
|
||||||
builder: (_) => SessionDetailScreen(session: session),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
_loadSessions(); // Refresh in case session was deleted
|
_loadSessions();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _deleteSession(Session session) async {
|
Future<void> _deleteSession(Session session) async {
|
||||||
@@ -204,7 +262,10 @@ class _HistoryScreenState extends State<HistoryScreen> {
|
|||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(context, true),
|
onPressed: () => Navigator.pop(context, true),
|
||||||
child: const Text('Supprimer', style: TextStyle(color: AppTheme.errorColor)),
|
child: const Text(
|
||||||
|
'Supprimer',
|
||||||
|
style: TextStyle(color: AppTheme.errorColor),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -215,20 +276,8 @@ class _HistoryScreenState extends State<HistoryScreen> {
|
|||||||
final repository = context.read<SessionRepository>();
|
final repository = context.read<SessionRepository>();
|
||||||
await repository.deleteSession(session.id);
|
await repository.deleteSession(session.id);
|
||||||
_loadSessions();
|
_loadSessions();
|
||||||
if (mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(content: Text('Session supprimee')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
debugPrint('Erreur suppression: $e');
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text('Erreur: $e'),
|
|
||||||
backgroundColor: AppTheme.errorColor,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,3 @@
|
|||||||
/// Écran d'accueil - Dashboard principal de l'application.
|
|
||||||
///
|
|
||||||
/// Affiche les statistiques globales (sessions, tirs, score moyen) et permet
|
|
||||||
/// la navigation vers les sections Statistiques, Historique et Nouvelle Analyse.
|
|
||||||
library;
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '../../core/constants/app_constants.dart';
|
import '../../core/constants/app_constants.dart';
|
||||||
@@ -12,7 +6,6 @@ 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 '../settings/settings_screen.dart';
|
|
||||||
import 'widgets/stats_card.dart';
|
import 'widgets/stats_card.dart';
|
||||||
|
|
||||||
class HomeScreen extends StatefulWidget {
|
class HomeScreen extends StatefulWidget {
|
||||||
@@ -47,6 +40,13 @@ 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(
|
||||||
|
child: Text(
|
||||||
|
'v1.0.2',
|
||||||
|
style: TextStyle(fontSize: 12, color: Colors.white70),
|
||||||
|
),
|
||||||
|
),
|
||||||
title: const Text('Bully'),
|
title: const Text('Bully'),
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
@@ -59,11 +59,6 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
onPressed: () => _navigateToHistory(context),
|
onPressed: () => _navigateToHistory(context),
|
||||||
tooltip: 'Historique',
|
tooltip: 'Historique',
|
||||||
),
|
),
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.settings),
|
|
||||||
onPressed: () => _navigateToSettings(context),
|
|
||||||
tooltip: 'Paramètres',
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: RefreshIndicator(
|
body: RefreshIndicator(
|
||||||
@@ -100,7 +95,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.withValues(alpha: 0.1),
|
color: AppTheme.primaryColor.withOpacity(0.1),
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
),
|
),
|
||||||
child: const Icon(
|
child: const Icon(
|
||||||
@@ -112,16 +107,16 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
'Analyse de Cibles',
|
'Analyse de Cibles',
|
||||||
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
style: Theme.of(
|
||||||
fontWeight: FontWeight.bold,
|
context,
|
||||||
),
|
).textTheme.headlineMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
'Scannez vos cibles et analysez vos performances',
|
'Scannez vos cibles et analysez vos performances',
|
||||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
style: Theme.of(
|
||||||
color: AppTheme.textSecondary,
|
context,
|
||||||
),
|
).textTheme.bodyLarge?.copyWith(color: AppTheme.textSecondary),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -153,14 +148,15 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'Statistiques',
|
'Statistiques',
|
||||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
style: Theme.of(
|
||||||
fontWeight: FontWeight.bold,
|
context,
|
||||||
),
|
).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: [
|
||||||
// --- BOUTON SESSIONS (Redirige vers Statistiques) ---
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: () => _navigateToStatistics(context),
|
onTap: () => _navigateToStatistics(context),
|
||||||
@@ -174,7 +170,6 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
// Ce bouton reste statique (ou tu peux ajouter une action)
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: StatsCard(
|
child: StatsCard(
|
||||||
icon: Icons.gps_fixed,
|
icon: Icons.gps_fixed,
|
||||||
@@ -185,10 +180,32 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
|
||||||
|
// --- MODIFICATION 2 : AJOUT DU GRAPHIQUE AU MILIEU ---
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16.0),
|
||||||
|
child: Container(
|
||||||
|
height: 150,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withOpacity(0.05),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, 4),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: const Center(
|
||||||
|
child: Icon(Icons.show_chart, size: 50, color: Colors.grey),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Deuxième ligne de vignettes (Historique et Meilleur)
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
// --- BOUTON SCORE MOYEN (Redirige vers Historique) ---
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: () => _navigateToHistory(context),
|
onTap: () => _navigateToHistory(context),
|
||||||
@@ -202,7 +219,6 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
// Ce bouton reste statique
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: StatsCard(
|
child: StatsCard(
|
||||||
icon: Icons.emoji_events,
|
icon: Icons.emoji_events,
|
||||||
@@ -217,12 +233,13 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- MÉTHODES DE NAVIGATION ---
|
||||||
|
|
||||||
void _navigateToCapture(BuildContext context) async {
|
void _navigateToCapture(BuildContext context) async {
|
||||||
await Navigator.push(
|
await Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(builder: (_) => const CaptureScreen()),
|
MaterialPageRoute(builder: (_) => const CaptureScreen()),
|
||||||
);
|
);
|
||||||
// Refresh stats when returning
|
|
||||||
_loadStats();
|
_loadStats();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,7 +248,6 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
context,
|
context,
|
||||||
MaterialPageRoute(builder: (_) => const HistoryScreen()),
|
MaterialPageRoute(builder: (_) => const HistoryScreen()),
|
||||||
);
|
);
|
||||||
// Refresh stats when returning
|
|
||||||
_loadStats();
|
_loadStats();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,14 +256,6 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
context,
|
context,
|
||||||
MaterialPageRoute(builder: (_) => const StatisticsScreen()),
|
MaterialPageRoute(builder: (_) => const StatisticsScreen()),
|
||||||
);
|
);
|
||||||
// Refresh stats when returning
|
|
||||||
_loadStats();
|
_loadStats();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _navigateToSettings(BuildContext context) {
|
|
||||||
Navigator.push(
|
|
||||||
context,
|
|
||||||
MaterialPageRoute(builder: (_) => const SettingsScreen()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,339 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter/services.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';
|
|
||||||
|
|
||||||
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 _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,
|
|
||||||
),
|
|
||||||
_buildSettingsTile(
|
|
||||||
context: context,
|
|
||||||
icon: Icons.color_lens_outlined,
|
|
||||||
title: 'Apparence',
|
|
||||||
subtitle: 'Thème clair/sombre (à venir)',
|
|
||||||
onTap: () {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(content: Text('Fonctionnalité en développement')),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
|
|
||||||
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,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +1,15 @@
|
|||||||
/// Écran des statistiques détaillées.
|
import 'dart:io';
|
||||||
///
|
|
||||||
/// Affiche les métriques de performance avec filtrage par période
|
|
||||||
/// (session, semaine, mois, tout). Inclut heat map, précision,
|
|
||||||
/// écart-type et distribution régionale des tirs.
|
|
||||||
library;
|
|
||||||
|
|
||||||
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 '../../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/repositories/session_repository.dart';
|
import '../../data/repositories/session_repository.dart';
|
||||||
import '../../services/statistics_service.dart';
|
import '../../services/statistics_service.dart';
|
||||||
import 'widgets/heat_map_widget.dart';
|
|
||||||
|
|
||||||
class StatisticsScreen extends StatefulWidget {
|
class StatisticsScreen extends StatefulWidget {
|
||||||
final Session? singleSession; // If provided, show stats for this session only
|
final Session? singleSession;
|
||||||
|
|
||||||
const StatisticsScreen({super.key, this.singleSession});
|
const StatisticsScreen({super.key, this.singleSession});
|
||||||
|
|
||||||
@@ -30,720 +24,282 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
|
|||||||
bool _isLoading = true;
|
bool _isLoading = true;
|
||||||
List<Session> _allSessions = [];
|
List<Session> _allSessions = [];
|
||||||
|
|
||||||
|
// Valeurs pour les Dropdowns (Filtres)
|
||||||
|
String _selectedWeapon = 'Toutes';
|
||||||
|
String _selectedDistance = 'Toutes';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
// Use addPostFrameCallback to ensure context is available
|
WidgetsBinding.instance.addPostFrameCallback((_) => _loadStatistics());
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
_loadStatistics();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadStatistics() async {
|
Future<void> _loadStatistics() async {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() => _isLoading = true);
|
setState(() => _isLoading = true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (widget.singleSession != null) {
|
final repository = context.read<SessionRepository>();
|
||||||
// Single session mode
|
_allSessions = await repository.getAllSessions();
|
||||||
_statistics = _statisticsService.calculateStatistics(
|
_calculateStats();
|
||||||
[widget.singleSession!],
|
|
||||||
period: StatsPeriod.session,
|
|
||||||
targetCenterX: widget.singleSession!.targetCenterX ?? 0.5,
|
|
||||||
targetCenterY: widget.singleSession!.targetCenterY ?? 0.5,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// Load all sessions
|
|
||||||
final repository = context.read<SessionRepository>();
|
|
||||||
_allSessions = await repository.getAllSessions();
|
|
||||||
_calculateStats();
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('Error loading statistics: $e');
|
debugPrint('Error: $e');
|
||||||
}
|
} finally {
|
||||||
|
if (mounted) setState(() => _isLoading = false);
|
||||||
if (mounted) {
|
|
||||||
setState(() => _isLoading = false);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _calculateStats() {
|
void _calculateStats() {
|
||||||
debugPrint(
|
|
||||||
'Calculating stats for ${_allSessions.length} sessions, period: $_selectedPeriod',
|
|
||||||
);
|
|
||||||
for (final session in _allSessions) {
|
|
||||||
debugPrint(
|
|
||||||
' Session: ${session.id}, shots: ${session.shots.length}, date: ${session.createdAt}',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
_statistics = _statisticsService.calculateStatistics(
|
_statistics = _statisticsService.calculateStatistics(
|
||||||
_allSessions,
|
_allSessions,
|
||||||
period: _selectedPeriod,
|
period: _selectedPeriod,
|
||||||
);
|
);
|
||||||
debugPrint(
|
|
||||||
'Statistics result: totalShots=${_statistics?.totalShots}, totalScore=${_statistics?.totalScore}',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@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(
|
||||||
title: Text(
|
backgroundColor: Colors.transparent,
|
||||||
widget.singleSession != null
|
elevation: 0,
|
||||||
? 'Statistiques Session'
|
leading: IconButton(
|
||||||
: 'Statistiques',
|
icon: const Icon(Icons.arrow_back, color: Colors.white),
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
),
|
),
|
||||||
|
title: const Text(
|
||||||
|
'Statistiques',
|
||||||
|
style: TextStyle(color: Colors.white),
|
||||||
|
),
|
||||||
|
centerTitle: true,
|
||||||
),
|
),
|
||||||
body: _isLoading
|
body: _isLoading
|
||||||
? const Center(child: CircularProgressIndicator())
|
? const Center(child: CircularProgressIndicator())
|
||||||
: _statistics == null || _statistics!.totalShots == 0
|
: SingleChildScrollView(
|
||||||
? _buildEmptyState()
|
padding: const EdgeInsets.all(16),
|
||||||
: _buildStatistics(),
|
child: Column(
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildEmptyState() {
|
|
||||||
return Center(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(AppConstants.defaultPadding),
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Icons.analytics_outlined,
|
|
||||||
size: 64,
|
|
||||||
color: Colors.grey.shade400,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(
|
|
||||||
'Aucune donnee disponible',
|
|
||||||
style: Theme.of(context).textTheme.titleLarge,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
'Effectuez des sessions de tir pour voir vos statistiques',
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: TextStyle(color: Colors.grey.shade600),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(
|
|
||||||
'Sessions trouvees: ${_allSessions.length}',
|
|
||||||
style: TextStyle(color: Colors.grey.shade400, fontSize: 12),
|
|
||||||
),
|
|
||||||
if (_allSessions.isNotEmpty)
|
|
||||||
Text(
|
|
||||||
'Tirs totaux: ${_allSessions.fold<int>(0, (sum, s) => sum + s.shots.length)}',
|
|
||||||
style: TextStyle(color: Colors.grey.shade400, fontSize: 12),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildStatistics() {
|
|
||||||
return RefreshIndicator(
|
|
||||||
onRefresh: _loadStatistics,
|
|
||||||
child: SingleChildScrollView(
|
|
||||||
physics: const AlwaysScrollableScrollPhysics(),
|
|
||||||
padding: const EdgeInsets.all(AppConstants.defaultPadding),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
// Period filter (only for multi-session view)
|
|
||||||
if (widget.singleSession == null) _buildPeriodFilter(),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
|
|
||||||
// Summary cards
|
|
||||||
_buildSummaryCards(),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
|
|
||||||
// Heat Map
|
|
||||||
_buildHeatMapSection(),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
|
|
||||||
// Precision stats
|
|
||||||
_buildPrecisionSection(),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
|
|
||||||
// Standard deviation
|
|
||||||
_buildStdDevSection(),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
|
|
||||||
// Regional distribution
|
|
||||||
_buildRegionalSection(),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildPeriodFilter() {
|
|
||||||
return Card(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(12),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
const Text(
|
|
||||||
'Periode',
|
|
||||||
style: TextStyle(fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
SegmentedButton<StatsPeriod>(
|
|
||||||
segments: const [
|
|
||||||
ButtonSegment(
|
|
||||||
value: StatsPeriod.week,
|
|
||||||
label: Text('7 jours'),
|
|
||||||
icon: Icon(Icons.date_range),
|
|
||||||
),
|
|
||||||
ButtonSegment(
|
|
||||||
value: StatsPeriod.month,
|
|
||||||
label: Text('30 jours'),
|
|
||||||
icon: Icon(Icons.calendar_month),
|
|
||||||
),
|
|
||||||
ButtonSegment(
|
|
||||||
value: StatsPeriod.all,
|
|
||||||
label: Text('Tout'),
|
|
||||||
icon: Icon(Icons.all_inclusive),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
selected: {_selectedPeriod},
|
|
||||||
onSelectionChanged: (selection) {
|
|
||||||
setState(() {
|
|
||||||
_selectedPeriod = selection.first;
|
|
||||||
_calculateStats();
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
'${_statistics!.sessions.length} session(s) - ${_statistics!.totalShots} tir(s)',
|
|
||||||
style: TextStyle(color: Colors.grey.shade600, fontSize: 12),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildSummaryCards() {
|
|
||||||
return Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: _StatCard(
|
|
||||||
icon: Icons.gps_fixed,
|
|
||||||
title: 'Tirs',
|
|
||||||
value: '${_statistics!.totalShots}',
|
|
||||||
color: AppTheme.primaryColor,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(
|
|
||||||
child: _StatCard(
|
|
||||||
icon: Icons.score,
|
|
||||||
title: 'Score Total',
|
|
||||||
value: '${_statistics!.totalScore}',
|
|
||||||
color: AppTheme.secondaryColor,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildHeatMapSection() {
|
|
||||||
return Card(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
const Icon(Icons.grid_on, color: AppTheme.primaryColor),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
const Text(
|
|
||||||
'Zones Chaudes',
|
|
||||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
'Repartition de vos tirs sur la cible',
|
|
||||||
style: TextStyle(color: Colors.grey.shade600, fontSize: 12),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Center(
|
|
||||||
child: HeatMapWidget(
|
|
||||||
heatMap: _statistics!.heatMap,
|
|
||||||
size: MediaQuery.of(context).size.width - 80,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
// Legend - gradient bar
|
|
||||||
Container(
|
|
||||||
height: 24,
|
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
borderRadius: BorderRadius.circular(4),
|
|
||||||
gradient: const LinearGradient(
|
|
||||||
colors: [
|
|
||||||
Color(0xFF2196F3), // Blue (cold)
|
|
||||||
Color(0xFF00BCD4), // Cyan
|
|
||||||
Color(0xFFFFEB3B), // Yellow
|
|
||||||
Color(0xFFFF9800), // Orange
|
|
||||||
Color(0xFFFF1744), // Red (hot)
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(left: 16),
|
|
||||||
child: Text(
|
|
||||||
'Peu',
|
|
||||||
style: TextStyle(fontSize: 12, color: Colors.grey.shade600),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(right: 16),
|
|
||||||
child: Text(
|
|
||||||
'Beaucoup',
|
|
||||||
style: TextStyle(fontSize: 12, color: Colors.grey.shade600),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildPrecisionSection() {
|
|
||||||
final precision = _statistics!.precision;
|
|
||||||
|
|
||||||
return Card(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
const Icon(
|
|
||||||
Icons.center_focus_strong,
|
|
||||||
color: AppTheme.successColor,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
const Text(
|
|
||||||
'Precision',
|
|
||||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: _buildPrecisionGauge(
|
|
||||||
'Precision',
|
|
||||||
precision.precisionScore,
|
|
||||||
'Distance moyenne du centre',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 16),
|
|
||||||
Expanded(
|
|
||||||
child: _buildPrecisionGauge(
|
|
||||||
'Regularite',
|
|
||||||
precision.consistencyScore,
|
|
||||||
'Groupement des tirs',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const Divider(height: 32),
|
|
||||||
_buildStatRow(
|
|
||||||
'Distance moyenne du centre',
|
|
||||||
'${(precision.avgDistanceFromCenter * 100).toStringAsFixed(1)}%',
|
|
||||||
),
|
|
||||||
_buildStatRow(
|
|
||||||
'Diametre de groupement',
|
|
||||||
'${(precision.groupingDiameter * 100).toStringAsFixed(1)}%',
|
|
||||||
),
|
|
||||||
_buildStatRow(
|
|
||||||
'Score moyen',
|
|
||||||
_statistics!.avgScore.toStringAsFixed(2),
|
|
||||||
),
|
|
||||||
_buildStatRow('Meilleur score', '${_statistics!.maxScore}'),
|
|
||||||
_buildStatRow('Plus bas score', '${_statistics!.minScore}'),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildPrecisionGauge(String title, double value, String subtitle) {
|
|
||||||
final color = value > 70
|
|
||||||
? AppTheme.successColor
|
|
||||||
: value > 40
|
|
||||||
? AppTheme.warningColor
|
|
||||||
: AppTheme.errorColor;
|
|
||||||
|
|
||||||
return Column(
|
|
||||||
children: [
|
|
||||||
Stack(
|
|
||||||
alignment: Alignment.center,
|
|
||||||
children: [
|
|
||||||
SizedBox(
|
|
||||||
width: 80,
|
|
||||||
height: 80,
|
|
||||||
child: CircularProgressIndicator(
|
|
||||||
value: value / 100,
|
|
||||||
strokeWidth: 8,
|
|
||||||
backgroundColor: Colors.grey.shade200,
|
|
||||||
valueColor: AlwaysStoppedAnimation(color),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
value.toStringAsFixed(0),
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: color,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(title, style: const TextStyle(fontWeight: FontWeight.bold)),
|
|
||||||
Text(
|
|
||||||
subtitle,
|
|
||||||
style: TextStyle(fontSize: 10, color: Colors.grey.shade600),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildStdDevSection() {
|
|
||||||
final stdDev = _statistics!.stdDev;
|
|
||||||
|
|
||||||
return Card(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
const Icon(
|
|
||||||
Icons.stacked_line_chart,
|
|
||||||
color: AppTheme.warningColor,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
const Text(
|
|
||||||
'Ecart Type',
|
|
||||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
'Mesure de la dispersion de vos tirs',
|
|
||||||
style: TextStyle(color: Colors.grey.shade600, fontSize: 12),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
_buildStatRow(
|
|
||||||
'Ecart type X (horizontal)',
|
|
||||||
'${(stdDev.stdDevX * 100).toStringAsFixed(2)}%',
|
|
||||||
),
|
|
||||||
_buildStatRow(
|
|
||||||
'Ecart type Y (vertical)',
|
|
||||||
'${(stdDev.stdDevY * 100).toStringAsFixed(2)}%',
|
|
||||||
),
|
|
||||||
_buildStatRow(
|
|
||||||
'Ecart type radial',
|
|
||||||
'${(stdDev.stdDevRadial * 100).toStringAsFixed(2)}%',
|
|
||||||
),
|
|
||||||
_buildStatRow(
|
|
||||||
'Ecart type score',
|
|
||||||
stdDev.stdDevScore.toStringAsFixed(2),
|
|
||||||
),
|
|
||||||
const Divider(height: 24),
|
|
||||||
_buildStatRow(
|
|
||||||
'Position moyenne X',
|
|
||||||
'${(stdDev.meanX * 100).toStringAsFixed(1)}%',
|
|
||||||
),
|
|
||||||
_buildStatRow(
|
|
||||||
'Position moyenne Y',
|
|
||||||
'${(stdDev.meanY * 100).toStringAsFixed(1)}%',
|
|
||||||
),
|
|
||||||
_buildStatRow('Score moyen', stdDev.meanScore.toStringAsFixed(2)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildRegionalSection() {
|
|
||||||
final regional = _statistics!.regional;
|
|
||||||
|
|
||||||
return Card(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
const Icon(Icons.explore, color: AppTheme.secondaryColor),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
const Text(
|
|
||||||
'Distribution Regionale',
|
|
||||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
|
|
||||||
// Dominant direction
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.all(12),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppTheme.primaryColor.withValues(alpha: 0.1),
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
children: [
|
||||||
const Icon(
|
// 1. FILTRES (Arme et Distance)
|
||||||
Icons.compass_calibration,
|
Row(
|
||||||
color: AppTheme.primaryColor,
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _buildDropdown('Arme utilisée', _selectedWeapon),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: _buildDropdown('Distance', _selectedDistance),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(height: 20),
|
||||||
Expanded(
|
|
||||||
child: Column(
|
// 2. DONNÉES RAPIDES (Tirs et Sessions)
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
Row(
|
||||||
children: [
|
children: [
|
||||||
const Text('Direction dominante'),
|
Expanded(
|
||||||
Text(
|
child: _buildQuickStat(
|
||||||
regional.dominantDirection,
|
'Nbre de tirs',
|
||||||
style: const TextStyle(
|
'${_statistics?.totalShots ?? 0}',
|
||||||
fontWeight: FontWeight.bold,
|
Icons.gps_fixed,
|
||||||
fontSize: 18,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: _buildQuickStat(
|
||||||
|
'Nbre de sessions',
|
||||||
|
'${_statistics?.sessions.length ?? 0}',
|
||||||
|
Icons.analytics,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 25),
|
||||||
|
|
||||||
|
// 3. LES GRAPHIQUES
|
||||||
|
_buildChartSection(
|
||||||
|
'Score',
|
||||||
|
'${_statistics?.totalScore ?? 0}',
|
||||||
|
[8, 12, 10, 15, 14, 18],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
_buildChartSection(
|
||||||
|
'Précision',
|
||||||
|
'${_statistics?.precision.precisionScore.toStringAsFixed(1)}%',
|
||||||
|
[60, 75, 70, 85, 80, 95],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
_buildChartSection('Groupement moyen', '14.2 mm', [
|
||||||
|
20,
|
||||||
|
18,
|
||||||
|
22,
|
||||||
|
15,
|
||||||
|
14,
|
||||||
|
12,
|
||||||
|
]), // mm fictif pour l'exemple
|
||||||
|
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
|
||||||
|
|
||||||
// Bias
|
|
||||||
if (regional.biasX.abs() > 0.02 || regional.biasY.abs() > 0.02)
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.all(12),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppTheme.warningColor.withValues(alpha: 0.1),
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
const Icon(
|
|
||||||
Icons.warning_amber,
|
|
||||||
color: AppTheme.warningColor,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
const Text('Biais detecte'),
|
|
||||||
Text(
|
|
||||||
_getBiasDescription(regional.biasX, regional.biasY),
|
|
||||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
|
|
||||||
// Sector distribution
|
|
||||||
const Text(
|
|
||||||
'Repartition par secteur:',
|
|
||||||
style: TextStyle(fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Wrap(
|
|
||||||
spacing: 8,
|
|
||||||
runSpacing: 8,
|
|
||||||
children: regional.sectorDistribution.entries.map((entry) {
|
|
||||||
final percentage = _statistics!.totalShots > 0
|
|
||||||
? (entry.value / _statistics!.totalShots * 100)
|
|
||||||
: 0.0;
|
|
||||||
return _buildSectorChip(entry.key, entry.value, percentage);
|
|
||||||
}).toList(),
|
|
||||||
),
|
|
||||||
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
|
|
||||||
// Quadrant distribution
|
|
||||||
const Text(
|
|
||||||
'Repartition par quadrant:',
|
|
||||||
style: TextStyle(fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
_buildQuadrantGrid(regional.quadrantDistribution),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildStatRow(String label, String value) {
|
// Widget pour les Dropdowns de filtres
|
||||||
return Padding(
|
Widget _buildDropdown(String label, String value) {
|
||||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
return Container(
|
||||||
child: Row(
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFF1E1E1E),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: Colors.white12),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(label),
|
Text(
|
||||||
Text(value, style: const TextStyle(fontWeight: FontWeight.bold)),
|
label,
|
||||||
|
style: const TextStyle(color: Colors.white54, fontSize: 10),
|
||||||
|
),
|
||||||
|
DropdownButton<String>(
|
||||||
|
value: value,
|
||||||
|
isExpanded: true,
|
||||||
|
underline: Container(),
|
||||||
|
dropdownColor: const Color(0xFF1E1E1E),
|
||||||
|
style: const TextStyle(color: Colors.white, fontSize: 14),
|
||||||
|
items: [value]
|
||||||
|
.map(
|
||||||
|
(String val) =>
|
||||||
|
DropdownMenuItem(value: val, child: Text(val)),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
onChanged: (newValue) {},
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildSectorChip(String sector, int count, double percentage) {
|
// Widget pour les petites cartes de stats
|
||||||
|
Widget _buildQuickStat(String label, String value, IconData icon) {
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: count > 0
|
color: const Color(0xFF1E1E1E),
|
||||||
? AppTheme.primaryColor.withValues(alpha: 0.1)
|
borderRadius: BorderRadius.circular(12),
|
||||||
: Colors.grey.shade100,
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
border: Border.all(
|
|
||||||
color: count > 0 ? AppTheme.primaryColor : Colors.grey.shade300,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Column(
|
||||||
'$sector: $count (${percentage.toStringAsFixed(0)}%)',
|
children: [
|
||||||
style: TextStyle(
|
Icon(icon, color: const Color(0xFF1A73E8), size: 20),
|
||||||
fontSize: 12,
|
const SizedBox(height: 8),
|
||||||
color: count > 0 ? AppTheme.primaryColor : Colors.grey.shade600,
|
Text(
|
||||||
),
|
value,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: const TextStyle(color: Colors.white54, fontSize: 12),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildQuadrantGrid(Map<String, int> quadrants) {
|
// Widget pour une section de graphique
|
||||||
return Table(
|
Widget _buildChartSection(
|
||||||
border: TableBorder.all(color: Colors.grey.shade300),
|
String title,
|
||||||
children: [
|
String value,
|
||||||
TableRow(
|
List<double> dataPoints,
|
||||||
children: [
|
) {
|
||||||
_buildQuadrantCell('Haut-Gauche', quadrants['Haut-Gauche'] ?? 0),
|
return Container(
|
||||||
_buildQuadrantCell('Haut-Droite', quadrants['Haut-Droite'] ?? 0),
|
padding: const EdgeInsets.all(16),
|
||||||
],
|
decoration: BoxDecoration(
|
||||||
),
|
color: const Color(0xFF1E1E1E),
|
||||||
TableRow(
|
borderRadius: BorderRadius.circular(12),
|
||||||
children: [
|
),
|
||||||
_buildQuadrantCell('Bas-Gauche', quadrants['Bas-Gauche'] ?? 0),
|
child: Column(
|
||||||
_buildQuadrantCell('Bas-Droite', quadrants['Bas-Droite'] ?? 0),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
],
|
children: [
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: const TextStyle(color: Colors.white70, fontSize: 14),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
value,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 20),
|
||||||
|
Expanded(
|
||||||
|
child: SizedBox(
|
||||||
|
height: 60,
|
||||||
|
child: LineChart(_mainChartData(dataPoints)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
LineChartData _mainChartData(List<double> points) {
|
||||||
|
return LineChartData(
|
||||||
|
gridData: const FlGridData(show: false),
|
||||||
|
titlesData: const FlTitlesData(show: false),
|
||||||
|
borderData: FlBorderData(show: false),
|
||||||
|
lineBarsData: [
|
||||||
|
LineChartBarData(
|
||||||
|
spots: points
|
||||||
|
.asMap()
|
||||||
|
.entries
|
||||||
|
.map((e) => FlSpot(e.key.toDouble(), e.value))
|
||||||
|
.toList(),
|
||||||
|
isCurved: true,
|
||||||
|
color: const Color(0xFF4CAF50), // Vert comme sur ton design
|
||||||
|
barWidth: 3,
|
||||||
|
isStrokeCapRound: true,
|
||||||
|
dotData: const FlDotData(show: false),
|
||||||
|
belowBarData: BarAreaData(
|
||||||
|
show: true,
|
||||||
|
color: const Color(0xFF4CAF50).withOpacity(0.2),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildQuadrantCell(String label, int count) {
|
|
||||||
final percentage = _statistics!.totalShots > 0
|
|
||||||
? (count / _statistics!.totalShots * 100)
|
|
||||||
: 0.0;
|
|
||||||
final intensity = _statistics!.totalShots > 0
|
|
||||||
? count / _statistics!.totalShots
|
|
||||||
: 0.0;
|
|
||||||
|
|
||||||
return Container(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
color: Color.lerp(Colors.white, AppTheme.primaryColor, intensity * 0.5),
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'$count',
|
|
||||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 24),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
'${percentage.toStringAsFixed(0)}%',
|
|
||||||
style: TextStyle(color: Colors.grey.shade600),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
label,
|
|
||||||
style: const TextStyle(fontSize: 10),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
String _getBiasDescription(double biasX, double biasY) {
|
|
||||||
final descriptions = <String>[];
|
|
||||||
|
|
||||||
if (biasX.abs() > 0.02) {
|
|
||||||
descriptions.add(biasX > 0 ? 'vers la droite' : 'vers la gauche');
|
|
||||||
}
|
|
||||||
if (biasY.abs() > 0.02) {
|
|
||||||
descriptions.add(biasY > 0 ? 'vers le bas' : 'vers le haut');
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'Tendance ${descriptions.join(' et ')}';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _StatCard extends StatelessWidget {
|
|
||||||
final IconData icon;
|
|
||||||
final String title;
|
|
||||||
final String value;
|
|
||||||
final Color color;
|
|
||||||
|
|
||||||
const _StatCard({
|
|
||||||
required this.icon,
|
|
||||||
required this.title,
|
|
||||||
required this.value,
|
|
||||||
required this.color,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Card(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
Icon(icon, color: color, size: 32),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
value,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 24,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: color,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Text(title, style: TextStyle(color: Colors.grey.shade600)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,146 +0,0 @@
|
|||||||
import 'dart:convert';
|
|
||||||
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) {
|
|
||||||
print('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();
|
|
||||||
print('Export réussi: $responseData');
|
|
||||||
return true;
|
|
||||||
} else {
|
|
||||||
final errorData = await response.stream.bytesToString();
|
|
||||||
print('Erreur d\'export: ${response.statusCode} - $errorData');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
print('Exception lors de l\'export: $e');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
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';
|
|
||||||
|
|
||||||
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) {
|
|
||||||
print('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(' ');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -8,13 +8,11 @@ import Foundation
|
|||||||
import device_info_plus
|
import device_info_plus
|
||||||
import file_selector_macos
|
import file_selector_macos
|
||||||
import path_provider_foundation
|
import path_provider_foundation
|
||||||
import shared_preferences_foundation
|
|
||||||
import sqflite_darwin
|
import sqflite_darwin
|
||||||
|
|
||||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
|
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
|
||||||
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
|
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
|
||||||
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
||||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
|
||||||
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
|
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
|
||||||
}
|
}
|
||||||
|
|||||||
143
pubspec.lock
143
pubspec.lock
@@ -37,10 +37,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: characters
|
name: characters
|
||||||
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
|
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.1"
|
version: "1.4.0"
|
||||||
clock:
|
clock:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -74,7 +74,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "0.3.5+2"
|
version: "0.3.5+2"
|
||||||
crypto:
|
crypto:
|
||||||
dependency: "direct main"
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: crypto
|
name: crypto
|
||||||
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
||||||
@@ -197,10 +197,10 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: fl_chart
|
name: fl_chart
|
||||||
sha256: "00b74ae680df6b1135bdbea00a7d1fc072a9180b7c3f3702e4b19a9943f5ed7d"
|
sha256: b938f77d042cbcd822936a7a359a7235bad8bd72070de1f827efc2cc297ac888
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.66.2"
|
version: "1.2.0"
|
||||||
flutter:
|
flutter:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description: flutter
|
description: flutter
|
||||||
@@ -214,6 +214,11 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.0.0"
|
version: "6.0.0"
|
||||||
|
flutter_localizations:
|
||||||
|
dependency: "direct main"
|
||||||
|
description: flutter
|
||||||
|
source: sdk
|
||||||
|
version: "0.0.0"
|
||||||
flutter_plugin_android_lifecycle:
|
flutter_plugin_android_lifecycle:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -257,7 +262,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.0"
|
version: "1.0.0"
|
||||||
http:
|
http:
|
||||||
dependency: "direct main"
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: http
|
name: http
|
||||||
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
||||||
@@ -348,10 +353,10 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: intl
|
name: intl
|
||||||
sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf
|
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.19.0"
|
version: "0.20.2"
|
||||||
leak_tracker:
|
leak_tracker:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -396,26 +401,26 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: matcher
|
name: matcher
|
||||||
sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6"
|
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.12.18"
|
version: "0.12.17"
|
||||||
material_color_utilities:
|
material_color_utilities:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: material_color_utilities
|
name: material_color_utilities
|
||||||
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
|
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.13.0"
|
version: "0.11.1"
|
||||||
meta:
|
meta:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: meta
|
name: meta
|
||||||
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
|
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.18.0"
|
version: "1.17.0"
|
||||||
mime:
|
mime:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -512,6 +517,54 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.3.0"
|
version: "2.3.0"
|
||||||
|
permission_handler:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: permission_handler
|
||||||
|
sha256: bc917da36261b00137bbc8896bf1482169cd76f866282368948f032c8c1caae1
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "12.0.1"
|
||||||
|
permission_handler_android:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: permission_handler_android
|
||||||
|
sha256: "1e3bc410ca1bf84662104b100eb126e066cb55791b7451307f9708d4007350e6"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "13.0.1"
|
||||||
|
permission_handler_apple:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: permission_handler_apple
|
||||||
|
sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "9.4.7"
|
||||||
|
permission_handler_html:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: permission_handler_html
|
||||||
|
sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.1.3+5"
|
||||||
|
permission_handler_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: permission_handler_platform_interface
|
||||||
|
sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.3.0"
|
||||||
|
permission_handler_windows:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: permission_handler_windows
|
||||||
|
sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.2.1"
|
||||||
petitparser:
|
petitparser:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -560,62 +613,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.0"
|
version: "2.2.0"
|
||||||
shared_preferences:
|
|
||||||
dependency: "direct main"
|
|
||||||
description:
|
|
||||||
name: shared_preferences
|
|
||||||
sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "2.5.5"
|
|
||||||
shared_preferences_android:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: shared_preferences_android
|
|
||||||
sha256: e8d4762b1e2e8578fc4d0fd548cebf24afd24f49719c08974df92834565e2c53
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "2.4.23"
|
|
||||||
shared_preferences_foundation:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: shared_preferences_foundation
|
|
||||||
sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "2.5.6"
|
|
||||||
shared_preferences_linux:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: shared_preferences_linux
|
|
||||||
sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "2.4.1"
|
|
||||||
shared_preferences_platform_interface:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: shared_preferences_platform_interface
|
|
||||||
sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "2.4.2"
|
|
||||||
shared_preferences_web:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: shared_preferences_web
|
|
||||||
sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "2.4.3"
|
|
||||||
shared_preferences_windows:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: shared_preferences_windows
|
|
||||||
sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "2.4.1"
|
|
||||||
sky_engine:
|
sky_engine:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description: flutter
|
description: flutter
|
||||||
@@ -729,10 +726,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_api
|
name: test_api
|
||||||
sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636"
|
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.9"
|
version: "0.7.7"
|
||||||
typed_data:
|
typed_data:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -814,5 +811,5 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.3"
|
version: "3.1.3"
|
||||||
sdks:
|
sdks:
|
||||||
dart: ">=3.12.0-35.0.dev <4.0.0"
|
dart: ">=3.10.0 <4.0.0"
|
||||||
flutter: ">=3.38.1"
|
flutter: ">=3.38.1"
|
||||||
|
|||||||
12
pubspec.yaml
12
pubspec.yaml
@@ -19,7 +19,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
|||||||
version: 1.0.0+1
|
version: 1.0.0+1
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.12.0-35.0.dev
|
sdk: '>=3.10.0 <4.0.0'
|
||||||
|
|
||||||
# Dependencies specify other packages that your package needs in order to work.
|
# Dependencies specify other packages that your package needs in order to work.
|
||||||
# To automatically upgrade your package dependencies to the latest versions
|
# To automatically upgrade your package dependencies to the latest versions
|
||||||
@@ -31,6 +31,8 @@ dependencies:
|
|||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
|
||||||
|
flutter_localizations:
|
||||||
|
sdk: flutter
|
||||||
# The following adds the Cupertino Icons font to your application.
|
# The following adds the Cupertino Icons font to your application.
|
||||||
# Use with the CupertinoIcons class for iOS style icons.
|
# Use with the CupertinoIcons class for iOS style icons.
|
||||||
cupertino_icons: ^1.0.8
|
cupertino_icons: ^1.0.8
|
||||||
@@ -56,17 +58,15 @@ dependencies:
|
|||||||
uuid: ^4.3.3
|
uuid: ^4.3.3
|
||||||
|
|
||||||
# Date formatting
|
# Date formatting
|
||||||
intl: ^0.19.0
|
intl: ^0.20.2
|
||||||
|
|
||||||
# Charts for history visualization
|
# Charts for history visualization
|
||||||
fl_chart: ^0.66.2
|
fl_chart: ^1.2.0
|
||||||
|
|
||||||
# Image processing for impact detection
|
# Image processing for impact detection
|
||||||
image: ^4.1.7
|
image: ^4.1.7
|
||||||
http: ^1.6.0
|
permission_handler: ^12.0.1
|
||||||
device_info_plus: ^13.1.0
|
device_info_plus: ^13.1.0
|
||||||
shared_preferences: ^2.5.5
|
|
||||||
crypto: ^3.0.7
|
|
||||||
|
|
||||||
# Machine Learning for YOLOv8
|
# Machine Learning for YOLOv8
|
||||||
# tflite_flutter: ^0.11.0
|
# tflite_flutter: ^0.11.0
|
||||||
|
|||||||
@@ -7,8 +7,11 @@
|
|||||||
#include "generated_plugin_registrant.h"
|
#include "generated_plugin_registrant.h"
|
||||||
|
|
||||||
#include <file_selector_windows/file_selector_windows.h>
|
#include <file_selector_windows/file_selector_windows.h>
|
||||||
|
#include <permission_handler_windows/permission_handler_windows_plugin.h>
|
||||||
|
|
||||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||||
FileSelectorWindowsRegisterWithRegistrar(
|
FileSelectorWindowsRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("FileSelectorWindows"));
|
registry->GetRegistrarForPlugin("FileSelectorWindows"));
|
||||||
|
PermissionHandlerWindowsPluginRegisterWithRegistrar(
|
||||||
|
registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
file_selector_windows
|
file_selector_windows
|
||||||
|
permission_handler_windows
|
||||||
)
|
)
|
||||||
|
|
||||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||||
|
|||||||
Reference in New Issue
Block a user