Compare commits

...
Author SHA1 Message Date
qlionbleusamandClaude Opus 5 32582aba3d feat(tutorial): guide the first launch with spotlighted steps and gesture hands
Ajoute un didacticiel joué à la première utilisation : un voile sombre perce un
trou de lumière autour de l'élément à découvrir, une main animée mime le geste
attendu (tap, appui long, glisser, pincement à deux doigts pour zoomer) et une
bulle explique l'étape avec sa progression.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 11:46:02 +02:00
qlionbleusam 32143c5bb1 Merge branch 'feature/detection-cible-au-sol' 2026-08-29 11:24:28 +02:00
qlionbleusam 6ee0839db5 feat(capture): detect the flat pose for targets laid on the ground 2026-08-29 11:07:46 +02:00
qlionbleusam 8804d8b6a1 feat(capture): detect the flat pose for targets laid on the ground 2026-08-29 10:33:59 +02:00
qlionbleusam 869835f234 Merge branch 'feature/ai-contribution-filter' 2026-08-29 10:19:42 +02:00
qlionbleusam 3d9e574309 feat(ai-export): annotate uploads with the expected shot count 2026-08-29 10:19:01 +02:00
qlionbleusam 3f79252bb5 Merge branch 'feature/ai-consent-wording' 2026-08-29 10:07:31 +02:00
qlionbleusam f6f134f2a5 docs(ai-export): state what the training upload sends and drop the anonymity claim 2026-08-29 10:04:09 +02:00
qlionbleusam 56e88c3e06 Merge branch 'fix/ai-export-privacy' 2026-08-29 09:42:25 +02:00
qlionbleusam 0ab9043d82 fix(ai-export): strip photo metadata and minimize the training payload 2026-08-29 09:39:31 +02:00
qlionbleusam eeb857d452 Merge branch 'feature/ajout-armurerie' 2026-08-29 09:21:55 +02:00
qlionbleusam 6dc525c0db fix(session): keep weapon selection valid when returning from the armory 2026-08-29 09:11:20 +02:00
qlionbleusam 96cc487b55 feat(session): add armory shortcut and 50-shot preset 2026-08-29 08:48:41 +02:00
streaper2 8dc6542603 feat: implement AI export service and establish project navigation and theming infrastructure + acces internet
Build & Release Android APK / build-apk (push) Successful in 23m34s
2026-08-27 18:32:59 +02:00
streaper2 e943538133 docs: update changelog with v0.0.7 release notes covering CI/CD, backend improvements, and mobile app features 2026-08-27 08:46:35 +02:00
streaper2 7f1fa2d80b feat(ci): add persistent caching for Gradle, Pub, and Android SDK components 2026-08-26 22:53:40 +02:00
streaper2 a2f7bfc158 fix(ci): remove /data/build completely before clone
Build & Release Android APK / build-apk (push) Successful in 21m20s
2026-08-26 22:51:32 +02:00
streaper2 d0a7700d02 feat: add Gitea workflow to build and release Android APKs via Flutter Docker container
Build & Release Android APK / build-apk (push) Failing after 1s
2026-08-26 22:47:18 +02:00
streaper2 9b52623ebe fix(ci): clone into shared /data/build volume for flutter build
Build & Release Android APK / build-apk (push) Failing after 21m37s
2026-08-26 22:20:17 +02:00
streaper2 ab12e07847 fix(ci): use gitea-act-runner container name for volume
Build & Release Android APK / build-apk (push) Failing after 2s
2026-08-26 22:18:59 +02:00
streaper2 98b9f1cd4c feat: add Gitea workflow to build and release Android APKs on version tags
Build & Release Android APK / build-apk (push) Failing after 2s
2026-08-26 22:16:37 +02:00
streaper2 e889456bfa fix: correction accès backendia avec yunohost
Build & Release Android APK / build-apk (push) Failing after 2m1s
2026-08-26 22:10:03 +02:00
streaper2 c0177b19e3 correction chemin des photos backendia
Deploy Backendia / deploy (push) Successful in 19s
2026-08-26 09:11:20 +02:00
streaper2 99abf60b52 feat: implement automated deployment pipeline and add identity management settings screen with account status synchronization
Deploy Backendia / deploy (push) Successful in 3s
2026-08-26 09:05:30 +02:00
streaper2 6e09ea25dd feat: implement backend server with SQLite logging and target validation middleware
Deploy Backendia / deploy (push) Successful in 40s
2026-08-26 09:01:25 +02:00
streaper2 9a429d476d feat: initialize backend service with Express, SQLite logging, and API utilities
Deploy Backendia / deploy (push) Successful in 5m0s
2026-08-26 08:42:16 +02:00
47 changed files with 5605 additions and 1187 deletions
+81
View File
@@ -0,0 +1,81 @@
name: Build & Release Android APK
on:
push:
tags:
- 'v*'
- 'V*' # Se déclenche quand vous poussez un tag comme v1.0.0, v1.0.1...
workflow_dispatch: # Permet aussi de lancer la compilation manuellement depuis l'interface Gitea
jobs:
build-apk:
# Utilise votre runner hôte avec Docker
runs-on: docker
steps:
- name: 📥 Récupération du code
run: |
echo "📥 Clonage du code source dans /data/build..."
rm -rf /data/build
git config --global --add safe.directory "*"
git clone --depth 1 "https://${{ github.actor }}:${{ secrets.GITHUB_TOKEN }}@git.kevlar.cloud/${{ github.repository }}.git" /data/build
- name: 🔨 Compilation de l'APK (Conteneur Flutter / Android SDK avec Caches)
run: |
echo "🚀 Démarrage de la compilation Flutter avec Caches persistants..."
mkdir -p /data/caches/gradle /data/caches/pub /data/caches/android-ndk /data/caches/android-cmake /data/caches/android-platforms
# Montage des caches persistants : Gradle, Pub, NDK, CMake et Plateformes Android
docker run --rm \
--volumes-from gitea-act-runner \
-v /data/caches/gradle:/root/.gradle \
-v /data/caches/pub:/root/.pub-cache \
-v /data/caches/android-ndk:/opt/android-sdk-linux/ndk \
-v /data/caches/android-cmake:/opt/android-sdk-linux/cmake \
-v /data/caches/android-platforms:/opt/android-sdk-linux/platforms \
-w /data/build \
ghcr.io/cirruslabs/flutter:stable \
sh -c "git config --global --add safe.directory '*' && flutter pub get && flutter build apk --release"
echo "✅ APK généré avec succès dans /data/build/build/app/outputs/flutter-apk/app-release.apk"
- name: 📦 Publication de la Release sur Gitea & Upload de l'APK
run: |
which curl >/dev/null 2>&1 || apk add --no-cache curl
TAG_NAME="${{ github.ref_name }}"
if [ -z "$TAG_NAME" ] || [ "$TAG_NAME" = "main" ]; then
TAG_NAME="build-$(date +'%Y%m%d-%H%M%S')"
fi
APK_PATH="/data/build/build/app/outputs/flutter-apk/app-release.apk"
APK_NAME="bully-impact-${TAG_NAME}.apk"
echo "🚀 Création de la release $TAG_NAME sur Gitea..."
# 1. Création de la Release via l'API REST de Gitea
RELEASE_RESPONSE=$(curl -s -X POST "https://git.kevlar.cloud/api/v1/repos/${{ github.repository }}/releases" \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
-H "Content-Type: application/json" \
-d "{
\"tag_name\": \"${TAG_NAME}\",
\"name\": \"Version ${TAG_NAME}\",
\"body\": \"Nouvelle version de l'application Bully Impact générée automatiquement par CI/CD.\",
\"draft\": false,
\"prerelease\": false
}")
RELEASE_ID=$(echo "$RELEASE_RESPONSE" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
if [ -z "$RELEASE_ID" ]; then
echo "❌ Erreur lors de la création de la release: $RELEASE_RESPONSE"
exit 1
fi
echo "📦 Upload du fichier APK (Release ID: $RELEASE_ID)..."
# 2. Upload de l'APK en tant qu'asset téléchargeable
curl -s -X POST "https://git.kevlar.cloud/api/v1/repos/${{ github.repository }}/releases/${RELEASE_ID}/assets?name=${APK_NAME}" \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
-H "Content-Type: application/vnd.android.package-archive" \
--data-binary @"$APK_PATH"
echo "🎉 Release terminée avec succès ! APK téléchargeable sur Gitea."
+5
View File
@@ -4,6 +4,11 @@ on:
push: push:
branches: branches:
- main - main
paths:
- 'backendia/**'
- 'docker-compose.prod.yml'
- '.gitea/workflows/deploy.yaml'
workflow_dispatch:
jobs: jobs:
deploy: deploy:
+35
View File
@@ -1,5 +1,40 @@
# Changelog # Changelog
## [v1.0.3] - 2026-08-27 (Branche `design/newdefault`)
### 🎨 Refonte UI/UX "Tactical & Precision"
- **Système de Thèmes & Couleurs d'Accentuation Personnalisables** : Choix dynamique de la couleur d'accent dans les Paramètres parmi 7 déclinaisons tactiques (Bleu Cobalt, Rouge Cible, Vert Viseur, Orange Ambre, Violet Cyber, Cyan Néon, Or Compétition) avec persistance SharedPreferences.
- **Nouvelle Direction Artistique & Thèmes** : Thème sombre Gunmetal/Slate contrasté pour stand de tir et thème clair technique épuré avec effets Glassmorphism et flous d'arrière-plan.
- **Barre de Navigation Principale** : Intégration de la `NavigationBar` Material 3 avec indicateur pill moderne et icônes thématiques.
- **Dashboard d'Accueil** : En-tête dynamique "BULLY • PRÉCISION", carte interactive de session en cours (badge lumineux, reprise rapide / fin sécurisée), tuiles télémétriques de KPIs avec graphique d'évolution et accès direct aux dernières séances.
- **Armurerie (Arsenal)** : Fiches d'armes techniques avec mise en valeur du calibre, compteurs de chargeurs/coups, puces d'accessoires et **icônes vectorielles dédiées pour chaque type d'arme** (Arme de Poing, Arme d'Épaule, Fusil à Pompe, Airsoft/Airgun).
- **Carnet de Tir (Historique)** : Filtres par puces de type de cible et de date, cartes de séances avec pastilles de score colorées selon la précision.
- **Setup de Session** : Sélecteurs rapides de distance (10m à 200m) et de coups par cible (3 à 30).
- **Réseau & Export Serveur IA (Fix APK Mobile)** : Ajout des permissions `INTERNET` et `ACCESS_NETWORK_STATE` ainsi que `usesCleartextTraffic` dans le manifeste Android principal (`AndroidManifest.xml`) pour débloquer l'envoi vers le serveur IA et les IP locales depuis l'APK installée.
## [v0.0.7] - 2026-08-26
### 🚀 CI/CD & Déploiement Automatique (Gitea Actions)
- **Pipeline de Release Android APK** : Compilation automatique de l'APK de production (`flutter build apk --release`) lors du push d'un tag (`v*`) et publication directe dans les Releases Gitea.
- **Mise en cache persistante** : Sauvegarde des caches Gradle, Pub, Android NDK 28, CMake et SDK Platform pour des temps de build réduits à 1-2 minutes.
- **Pipeline Backendia** : Déploiement continu automatisé du serveur backend et du dashboard lors des modifications sur `main`.
### 🧠 Backend IA & Dashboard Web (Backendia)
- **Architecture Tout-en-un** : API Express et Dashboard Next.js co-hébergés sur le même port sous `https://backendia.kevlar.cloud`.
- **Validation OpenCV** : Certification et détection des anneaux de cibles concentriques.
- **Journalisation & Modération** : Base SQLite avec historique complet des uploads, détection d'appareils, métadonnées et système de bannissement/débannissement de wallets.
- **Dashboard Web** : Galerie de visualisation des photos de tir, éditeur de points/impacts et export global des datasets (ZIP / CSV).
### 🔒 Sécurité & Reverse Proxy (YunoHost)
- **Dashboard Privé** : Accès à l'interface d'administration protégé par le portail SSO YunoHost.
- **API d'Upload Sécurisée** : Filtrage Nginx par en-tête secret `X-API-KEY` autorisant uniquement l'application Android.
### 📱 Application Mobile Flutter
- **Connexion Serveur Officiel** : Configuration par défaut sur `https://backendia.kevlar.cloud` avec transmission automatique de la clé d'API.
- **Identité Anonyme & Stats** : Phrase de récupération de 15 mots, hachage SHA-256 du wallet et synchronisation du statut de modération.
- **Expérience de Tir** : Nouveau flux de calibration, boutons de rotation fine, guidage au premier impact et popup de fin de session avec export IA intégré.
- **Sauvegarde & Partage** : Export/Import JSON complet des sessions, statistiques et armurerie.
## [v0.0.1] - 2026-01-29 ## [v0.0.1] - 2026-01-29
### Ajouté ### Ajouté
+19
View File
@@ -98,6 +98,19 @@ flutter test --coverage
- Visualisation des sessions passées - Visualisation des sessions passées
- Suppression de sessions - Suppression de sessions
### Didacticiel (visite guidée)
- Démarre automatiquement à la **première utilisation** sur l'écran d'accueil :
voile sombre, « trou de lumière » autour de l'élément à découvrir et bulle
explicative (bouton nouvelle session, télémétrie, barre de navigation, réglages)
- Visite dédiée à la **première ouverture de l'éditeur d'impacts** : ajouter,
déplacer, **pincer pour zoomer**, valider
- **Main animée** qui mime le geste attendu (tap, appui long, glisser,
pincement à deux doigts, balayage), dessinée au CustomPainter — aucun asset
- « Passer » interrompt la visite, un tap n'importe où passe à l'étape suivante ;
chaque visite n'est jouée qu'une fois (mémorisée dans les SharedPreferences)
- **Paramètres > Aide & didacticiel > Revoir le didacticiel** : réinitialise
toutes les visites, qui rejouent dès le retour sur l'écran concerné
### Interface utilisateur ### Interface utilisateur
- Thème sombre adapté au tir - Thème sombre adapté au tir
- Support multilingue (Français) - Support multilingue (Français)
@@ -135,3 +148,9 @@ history_chart.dart Graphique d'évolution des 10 dernières sessions
statistics_screen.dart Écran statistiques avec filtrage par période statistics_screen.dart Écran statistiques avec filtrage par période
heat_map_widget.dart Heat map avec gradient bleu→rouge heat_map_widget.dart Heat map avec gradient bleu→rouge
backup_service.dart Export/import JSON des sessions, stats et armurerie backup_service.dart Export/import JSON des sessions, stats et armurerie
tutorial_service.dart Persistance des visites guidées déjà vues (SharedPreferences)
tutorial_provider.dart État du didacticiel : visites à jouer, réactivation
tutorial_coach.dart Lance une visite guidée par-dessus l'écran courant
tutorial_step.dart Modèle d'étape : cible, texte, geste, forme du spot
tutorial_overlay.dart Voile sombre, trou de lumière et bulle explicative
tutorial_hand.dart Main animée qui mime le geste (tap, appui long, pincement…)
+4 -1
View File
@@ -1,4 +1,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.CAMERA" />
<!-- Pour Android 12 et inférieur --> <!-- Pour Android 12 et inférieur -->
@@ -9,7 +11,8 @@
<application <application
android:label="bully" android:label="bully"
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"> android:icon="@mipmap/ic_launcher"
android:usesCleartextTraffic="true">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
+7 -1
View File
@@ -10,10 +10,16 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
COPY package*.json ./ COPY package*.json ./
RUN npm ci --omit=dev && npm rebuild sqlite3 --build-from-source COPY dashboard/package*.json ./dashboard/
RUN npm install && npm rebuild sqlite3 --build-from-source
RUN cd dashboard && npm install
COPY . . COPY . .
# Build du Dashboard Next.js
RUN cd dashboard && npm run build
# Dossiers nécessaires # Dossiers nécessaires
RUN mkdir -p uploads/images uploads/data exports RUN mkdir -p uploads/images uploads/data exports
+2 -2
View File
@@ -1,4 +1,4 @@
import { fetchApi, API_BASE_URL } from "@/lib/api"; import { fetchApi } from "@/lib/api";
import DatasetToolbar from "@/components/DatasetToolbar"; import DatasetToolbar from "@/components/DatasetToolbar";
import { Calendar, User, Crosshair } from "lucide-react"; import { Calendar, User, Crosshair } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
@@ -49,7 +49,7 @@ export default async function DashboardPage() {
<div className="aspect-[3/4] relative overflow-hidden bg-slate-800"> <div className="aspect-[3/4] relative overflow-hidden bg-slate-800">
{/* eslint-disable-next-line @next/next/no-img-element */} {/* eslint-disable-next-line @next/next/no-img-element */}
<img <img
src={`${API_BASE_URL}${photo.imageUrl}`} src={photo.imageUrl}
alt={photo.filename} alt={photo.filename}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
/> />
@@ -1,4 +1,4 @@
import { fetchApi, API_BASE_URL } from "@/lib/api"; import { fetchApi } from "@/lib/api";
import PhotoEditor from "@/components/PhotoEditor"; import PhotoEditor from "@/components/PhotoEditor";
import { ChevronLeft, Download } from "lucide-react"; import { ChevronLeft, Download } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
@@ -33,7 +33,7 @@ export default async function PhotoDetailPage({ params }: { params: Promise<{ id
<div className="flex gap-3"> <div className="flex gap-3">
<a <a
href={`${API_BASE_URL}/uploads/images/${id}`} href={`/uploads/images/${id}`}
download download
className="flex items-center gap-2 bg-slate-800 hover:bg-slate-700 px-4 py-2 rounded-lg text-sm font-medium transition-colors" className="flex items-center gap-2 bg-slate-800 hover:bg-slate-700 px-4 py-2 rounded-lg text-sm font-medium transition-colors"
> >
@@ -112,7 +112,7 @@ export default function PhotoEditor({ initialPhoto }: PhotoEditorProps) {
</div> </div>
<PhotoOverlay <PhotoOverlay
imageUrl={`${API_BASE_URL}${initialPhoto.imageUrl}`} imageUrl={initialPhoto.imageUrl}
impacts={impacts} impacts={impacts}
targetCorners={photoData?.plotting?.target_corners || []} targetCorners={photoData?.plotting?.target_corners || []}
isEditing={isEditing} isEditing={isEditing}
+4 -1
View File
@@ -1,4 +1,7 @@
export const API_BASE_URL = 'http://127.0.0.1:3000'; const isServer = typeof window === 'undefined';
export const API_BASE_URL = isServer
? `http://127.0.0.1:${process.env.PORT || 3000}`
: '';
export async function fetchApi(endpoint: string) { export async function fetchApi(endpoint: string) {
const res = await fetch(`${API_BASE_URL}${endpoint}`, { const res = await fetch(`${API_BASE_URL}${endpoint}`, {
+3
View File
@@ -18,6 +18,9 @@
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"express": "^5.2.1", "express": "^5.2.1",
"multer": "^2.1.1", "multer": "^2.1.1",
"next": "^16.2.4",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"sharp": "^0.35.3", "sharp": "^0.35.3",
"sqlite3": "^6.0.1" "sqlite3": "^6.0.1"
} }
+88 -58
View File
@@ -36,57 +36,61 @@ const db = new sqlite3.Database(dbPath, (err) => {
console.error('Erreur de connexion à SQLite:', err.message); console.error('Erreur de connexion à SQLite:', err.message);
} else { } else {
console.log('Connecté à la base de données SQLite.'); console.log('Connecté à la base de données SQLite.');
db.run(`CREATE TABLE IF NOT EXISTS user_stats ( db.serialize(() => {
wallet_hash TEXT PRIMARY KEY, db.run(`CREATE TABLE IF NOT EXISTS user_stats (
photo_count INTEGER DEFAULT 0, wallet_hash TEXT PRIMARY KEY,
last_upload DATETIME DEFAULT CURRENT_TIMESTAMP photo_count INTEGER DEFAULT 0,
)`); last_upload DATETIME DEFAULT CURRENT_TIMESTAMP
)`);
db.run(`CREATE TABLE IF NOT EXISTS banned_wallets ( db.run(`CREATE TABLE IF NOT EXISTS banned_wallets (
wallet_hash TEXT PRIMARY KEY, wallet_hash TEXT PRIMARY KEY,
reason TEXT, reason TEXT,
banned_at DATETIME DEFAULT CURRENT_TIMESTAMP, banned_at DATETIME DEFAULT CURRENT_TIMESTAMP,
banned_by TEXT DEFAULT 'Admin' banned_by TEXT DEFAULT 'Admin'
)`); )`);
db.run(`CREATE TABLE IF NOT EXISTS upload_logs ( db.run(`CREATE TABLE IF NOT EXISTS upload_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
session_id TEXT, session_id TEXT,
wallet_hash TEXT, wallet_hash TEXT,
image_filename TEXT, image_filename TEXT,
json_filename TEXT, json_filename TEXT,
file_size INTEGER, file_size INTEGER,
ip_address TEXT, ip_address TEXT,
user_agent TEXT, user_agent TEXT,
device_model TEXT, device_model TEXT,
device_os TEXT, device_os TEXT,
target_type TEXT, target_type TEXT,
weapon TEXT, weapon TEXT,
distance_meters INTEGER, distance_meters INTEGER,
impacts_count INTEGER DEFAULT 0, impacts_count INTEGER DEFAULT 0,
status TEXT DEFAULT 'SUCCESS', status TEXT DEFAULT 'SUCCESS',
target_valid INTEGER DEFAULT 1, target_valid INTEGER DEFAULT 1,
target_status TEXT DEFAULT 'VALID', target_status TEXT DEFAULT 'VALID',
target_confidence REAL DEFAULT 1.0, target_confidence REAL DEFAULT 1.0,
target_rings_count INTEGER DEFAULT 0, target_rings_count INTEGER DEFAULT 0,
target_details TEXT, target_details TEXT,
error_message TEXT, error_message TEXT,
raw_metadata TEXT raw_metadata TEXT
)`); )`);
// Migration safe des colonnes OpenCV si la table existait déjà // Migration safe des colonnes OpenCV si la table existait déjà
const migrations = [ const migrations = [
"ALTER TABLE upload_logs ADD COLUMN target_valid INTEGER DEFAULT 1", "ALTER TABLE upload_logs ADD COLUMN target_valid INTEGER DEFAULT 1",
"ALTER TABLE upload_logs ADD COLUMN target_status TEXT DEFAULT 'VALID'", "ALTER TABLE upload_logs ADD COLUMN target_status TEXT DEFAULT 'VALID'",
"ALTER TABLE upload_logs ADD COLUMN target_confidence REAL DEFAULT 1.0", "ALTER TABLE upload_logs ADD COLUMN target_confidence REAL DEFAULT 1.0",
"ALTER TABLE upload_logs ADD COLUMN target_rings_count INTEGER DEFAULT 0", "ALTER TABLE upload_logs ADD COLUMN target_rings_count INTEGER DEFAULT 0",
"ALTER TABLE upload_logs ADD COLUMN target_details TEXT" "ALTER TABLE upload_logs ADD COLUMN target_details TEXT"
]; ];
migrations.forEach(sql => db.run(sql, () => {})); migrations.forEach(sql => {
db.run(sql, () => {});
});
db.run(`CREATE INDEX IF NOT EXISTS idx_upload_logs_wallet ON upload_logs(wallet_hash)`); db.run(`CREATE INDEX IF NOT EXISTS idx_upload_logs_wallet ON upload_logs(wallet_hash)`);
db.run(`CREATE INDEX IF NOT EXISTS idx_upload_logs_timestamp ON upload_logs(timestamp)`); db.run(`CREATE INDEX IF NOT EXISTS idx_upload_logs_timestamp ON upload_logs(timestamp)`);
});
} }
}); });
@@ -814,14 +818,40 @@ app.use((err, req, res, next) => {
res.status(500).json({ error: err.message || 'Une erreur inattendue est survenue' }); res.status(500).json({ error: err.message || 'Une erreur inattendue est survenue' });
}); });
// Démarrer le serveur // Intégration du Dashboard Next.js
app.listen(PORT, () => { const dashboardDir = path.join(__dirname, 'dashboard');
console.log(`=================================`); const dev = process.env.NODE_ENV !== 'production';
console.log(`Serveur Backend IA démarré`);
console.log(`Port: ${PORT}`); let nextApp;
console.log(`Dossiers:`); try {
console.log(` - Images: ${imagesDir}`); const next = require('next');
console.log(` - Data : ${dataDir}`); nextApp = next({ dev, dir: dashboardDir });
console.log(` - Export: ${exportsDir}`); } catch (e) {
console.log(`=================================`); console.warn("Module 'next' non disponible, mode API seule.");
}); }
async function startServer() {
if (nextApp) {
try {
await nextApp.prepare();
const handle = nextApp.getRequestHandler();
app.use((req, res) => handle(req, res));
console.log("Dashboard Next.js initialisé avec succès.");
} catch (err) {
console.error("Erreur d'initialisation du Dashboard Next.js:", err);
}
}
app.listen(PORT, () => {
console.log(`=================================`);
console.log(`Serveur Backend IA & Dashboard démarré`);
console.log(`Port: ${PORT}`);
console.log(`Dossiers:`);
console.log(` - Images: ${imagesDir}`);
console.log(` - Data : ${dataDir}`);
console.log(` - Export: ${exportsDir}`);
console.log(`=================================`);
});
}
startServer();
+2 -3
View File
@@ -1,7 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'core/theme/theme_provider.dart'; import 'core/theme/theme_provider.dart';
import 'core/theme/app_theme.dart';
import 'main_navigation_holder.dart'; import 'main_navigation_holder.dart';
import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_localizations/flutter_localizations.dart';
@@ -15,8 +14,8 @@ class BullyApp extends StatelessWidget {
return MaterialApp( return MaterialApp(
title: 'Bully - Analyse de Cibles', title: 'Bully - Analyse de Cibles',
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
theme: AppTheme.lightTheme, theme: themeProvider.lightTheme,
darkTheme: AppTheme.darkTheme, darkTheme: themeProvider.darkTheme,
themeMode: themeProvider.themeMode, themeMode: themeProvider.themeMode,
localizationsDelegates: const [ localizationsDelegates: const [
GlobalMaterialLocalizations.delegate, GlobalMaterialLocalizations.delegate,
+375 -40
View File
@@ -1,89 +1,424 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
/// Définition des couleurs d'accent disponibles pour la personnalisation.
class AppAccentColor {
final String id;
final String name;
final Color color;
final Color lightColor;
final Color darkColor;
final IconData icon;
const AppAccentColor({
required this.id,
required this.name,
required this.color,
required this.lightColor,
required this.darkColor,
required this.icon,
});
static const AppAccentColor blue = AppAccentColor(
id: 'blue',
name: 'Bleu Cobalt',
color: Color(0xFF2563EB),
lightColor: Color(0xFF60A5FA),
darkColor: Color(0xFF1D4ED8),
icon: Icons.lens,
);
static const AppAccentColor red = AppAccentColor(
id: 'red',
name: 'Rouge Cible',
color: Color(0xFFDC2626),
lightColor: Color(0xFFF87171),
darkColor: Color(0xFFB91C1C),
icon: Icons.lens,
);
static const AppAccentColor green = AppAccentColor(
id: 'green',
name: 'Vert Viseur',
color: Color(0xFF10B981),
lightColor: Color(0xFF34D399),
darkColor: Color(0xFF059669),
icon: Icons.lens,
);
static const AppAccentColor orange = AppAccentColor(
id: 'orange',
name: 'Orange Ambre',
color: Color(0xFFFF6D00),
lightColor: Color(0xFFFF9E40),
darkColor: Color(0xFFD84315),
icon: Icons.lens,
);
static const AppAccentColor purple = AppAccentColor(
id: 'purple',
name: 'Violet Cyber',
color: Color(0xFF8B5CF6),
lightColor: Color(0xFFA78BFA),
darkColor: Color(0xFF6D28D9),
icon: Icons.lens,
);
static const AppAccentColor cyan = AppAccentColor(
id: 'cyan',
name: 'Cyan Néon',
color: Color(0xFF06B6D4),
lightColor: Color(0xFF67E8F9),
darkColor: Color(0xFF0E7490),
icon: Icons.lens,
);
static const AppAccentColor gold = AppAccentColor(
id: 'gold',
name: 'Or Compétition',
color: Color(0xFFF59E0B),
lightColor: Color(0xFFFBBF24),
darkColor: Color(0xFFD97706),
icon: Icons.lens,
);
static const List<AppAccentColor> allAccents = [
blue,
red,
green,
orange,
purple,
cyan,
gold,
];
static AppAccentColor fromId(String? id) {
return allAccents.firstWhere(
(a) => a.id == id,
orElse: () => blue,
);
}
}
/// Système de design et thème de l'application Bully.
class AppTheme { class AppTheme {
AppTheme._(); AppTheme._();
static const Color primaryColor = Color(0xFF1E88E5); // Accents par défaut (compatibilité statique)
static const Color secondaryColor = Color(0xFF43A047); static const Color primaryColor = Color(0xFF2563EB);
static const Color errorColor = Color(0xFFE53935); static const Color primaryLight = Color(0xFF60A5FA);
static const Color warningColor = Color(0xFFFFA726); static const Color primaryDark = Color(0xFF1D4ED8);
static const Color successColor = Color(0xFF66BB6A);
static const Color backgroundColor = Color(0xFFF5F5F5); static const Color secondaryColor = Color(0xFF10B981);
static const Color surfaceColor = Colors.white; static const Color secondaryDark = Color(0xFF059669);
static const Color textPrimary = Color(0xFF212121);
static const Color textSecondary = Color(0xFF757575);
// Impact colors for visualization static const Color accentBlue = Color(0xFF0EA5E9);
static const Color impactColor = Color(0xFFFF5722); static const Color accentGold = Color(0xFFFFB300);
static const Color errorColor = Color(0xFFEF4444);
static const Color warningColor = Color(0xFFF59E0B);
static const Color successColor = Color(0xFF10B981);
// Palette Thème Sombre (Standard Stand de Tir)
static const Color darkBackground = Color(0xFF0D1219);
static const Color darkSurface = Color(0xFF161E28);
static const Color darkSurfaceElevated = Color(0xFF1E2836);
static const Color darkSurfaceVariant = Color(0xFF253243);
static const Color darkBorder = Color(0xFF2C3B4E);
static const Color darkBorderLight = Color(0xFF3B4D65);
static const Color darkTextPrimary = Color(0xFFF1F5F9);
static const Color darkTextSecondary = Color(0xFF94A3B8);
static const Color darkTextMuted = Color(0xFF64748B);
// Palette Thème Clair
static const Color lightBackground = Color(0xFFF8FAFC);
static const Color lightSurface = Color(0xFFFFFFFF);
static const Color lightSurfaceElevated = Color(0xFFFFFFFF);
static const Color lightSurfaceVariant = Color(0xFFF1F5F9);
static const Color lightBorder = Color(0xFFE2E8F0);
static const Color lightTextPrimary = Color(0xFF0F172A);
static const Color lightTextSecondary = Color(0xFF64748B);
static const Color lightTextMuted = Color(0xFF94A3B8);
// Compatibilité ascendante
static const Color backgroundColor = lightBackground;
static const Color surfaceColor = lightSurface;
static const Color textPrimary = lightTextPrimary;
static const Color textSecondary = lightTextSecondary;
// Couleurs des impacts pour l'overlay
static const Color impactColor = Color(0xFFFF3D00);
static const Color impactOutlineColor = Color(0xFFFFFFFF); static const Color impactOutlineColor = Color(0xFFFFFFFF);
static const Color groupingCenterColor = Color(0xFF2196F3); static const Color groupingCenterColor = Color(0xFF00E5FF);
static const Color groupingCircleColor = Color(0x4D2196F3); static const Color groupingCircleColor = Color(0x4D00E5FF);
// Score zone colors // Couleurs des zones de score cibles concentriques
static const List<Color> zoneColors = [ static const List<Color> zoneColors = [
Color(0xFFFFEB3B), // Zone 10 - Gold Color(0xFFFFB300), // Zone 10 - Or
Color(0xFFFFEB3B), // Zone 9 Color(0xFFFFCA28), // Zone 9
Color(0xFFFF5722), // Zone 8 Color(0xFFFF5722), // Zone 8
Color(0xFFFF5722), // Zone 7 Color(0xFFFF7043), // Zone 7
Color(0xFF2196F3), // Zone 6 Color(0xFF29B6F6), // Zone 6
Color(0xFF2196F3), // Zone 5 Color(0xFF4FC3F7), // Zone 5
Color(0xFF4CAF50), // Zone 4 Color(0xFF66BB6A), // Zone 4
Color(0xFF4CAF50), // Zone 3 Color(0xFF81C784), // Zone 3
Color(0xFFFFFFFF), // Zone 2 Color(0xFFFFFFFF), // Zone 2
Color(0xFFFFFFFF), // Zone 1 Color(0xFFE0E0E0), // Zone 1
]; ];
static ThemeData get lightTheme { static ThemeData get lightTheme => buildLightTheme(AppAccentColor.blue);
static ThemeData get darkTheme => buildDarkTheme(AppAccentColor.blue);
static ThemeData buildLightTheme(AppAccentColor accent) {
final activePrimary = accent.color;
return ThemeData( return ThemeData(
useMaterial3: true, useMaterial3: true,
colorScheme: ColorScheme.fromSeed( brightness: Brightness.light,
seedColor: primaryColor, colorScheme: ColorScheme.light(
brightness: Brightness.light, primary: activePrimary,
secondary: secondaryColor,
surface: lightSurface,
error: errorColor,
onPrimary: Colors.white,
onSecondary: Colors.white,
onSurface: lightTextPrimary,
onError: Colors.white,
outline: lightBorder,
), ),
scaffoldBackgroundColor: backgroundColor, scaffoldBackgroundColor: lightBackground,
cardColor: lightSurface,
appBarTheme: const AppBarTheme( appBarTheme: const AppBarTheme(
elevation: 0, elevation: 0,
centerTitle: true, centerTitle: true,
backgroundColor: primaryColor, backgroundColor: lightSurface,
foregroundColor: Colors.white, foregroundColor: lightTextPrimary,
surfaceTintColor: Colors.transparent,
titleTextStyle: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: lightTextPrimary,
letterSpacing: 0.3,
),
), ),
cardTheme: CardThemeData( cardTheme: CardThemeData(
elevation: 2, elevation: 0,
color: lightSurface,
surfaceTintColor: Colors.transparent,
margin: EdgeInsets.zero,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(16),
side: const BorderSide(color: lightBorder, width: 1),
), ),
), ),
elevatedButtonTheme: ElevatedButtonThemeData( elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), elevation: 0,
backgroundColor: activePrimary,
foregroundColor: Colors.white,
minimumSize: const Size(double.infinity, 50),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(12),
),
textStyle: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
letterSpacing: 0.2,
), ),
), ),
), ),
floatingActionButtonTheme: const FloatingActionButtonThemeData( outlinedButtonTheme: OutlinedButtonThemeData(
backgroundColor: primaryColor, style: OutlinedButton.styleFrom(
foregroundColor: lightTextPrimary,
minimumSize: const Size(double.infinity, 50),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
side: const BorderSide(color: lightBorder, width: 1.5),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
textStyle: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
),
chipTheme: ChipThemeData(
backgroundColor: lightSurfaceVariant,
selectedColor: activePrimary.withValues(alpha: 0.15),
labelStyle: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: lightTextPrimary,
),
secondaryLabelStyle: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: activePrimary,
),
side: const BorderSide(color: lightBorder, width: 1),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: lightSurfaceVariant,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: lightBorder),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: lightBorder),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: activePrimary, width: 1.8),
),
labelStyle: const TextStyle(color: lightTextSecondary),
hintStyle: const TextStyle(color: lightTextMuted),
),
dividerTheme: const DividerThemeData(
color: lightBorder,
thickness: 1,
space: 24,
),
floatingActionButtonTheme: FloatingActionButtonThemeData(
backgroundColor: activePrimary,
foregroundColor: Colors.white, foregroundColor: Colors.white,
elevation: 3,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(16)),
),
), ),
); );
} }
static ThemeData get darkTheme { static ThemeData buildDarkTheme(AppAccentColor accent) {
final activePrimary = accent.color;
return ThemeData( return ThemeData(
useMaterial3: true, useMaterial3: true,
colorScheme: ColorScheme.fromSeed( brightness: Brightness.dark,
seedColor: primaryColor, colorScheme: ColorScheme.dark(
brightness: Brightness.dark, primary: activePrimary,
secondary: secondaryColor,
surface: darkSurface,
error: errorColor,
onPrimary: Colors.white,
onSecondary: Colors.white,
onSurface: darkTextPrimary,
onError: Colors.white,
outline: darkBorder,
), ),
scaffoldBackgroundColor: darkBackground,
cardColor: darkSurface,
appBarTheme: const AppBarTheme( appBarTheme: const AppBarTheme(
elevation: 0, elevation: 0,
centerTitle: true, centerTitle: true,
backgroundColor: darkBackground,
foregroundColor: darkTextPrimary,
surfaceTintColor: Colors.transparent,
titleTextStyle: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: darkTextPrimary,
letterSpacing: 0.3,
),
), ),
cardTheme: CardThemeData( cardTheme: CardThemeData(
elevation: 2, elevation: 0,
color: darkSurface,
surfaceTintColor: Colors.transparent,
margin: EdgeInsets.zero,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: const BorderSide(color: darkBorder, width: 1),
),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
elevation: 0,
backgroundColor: activePrimary,
foregroundColor: Colors.white,
minimumSize: const Size(double.infinity, 50),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
textStyle: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
letterSpacing: 0.2,
),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
foregroundColor: darkTextPrimary,
minimumSize: const Size(double.infinity, 50),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
side: const BorderSide(color: darkBorder, width: 1.5),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
textStyle: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
),
chipTheme: ChipThemeData(
backgroundColor: darkSurfaceElevated,
selectedColor: activePrimary.withValues(alpha: 0.2),
labelStyle: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: darkTextPrimary,
),
secondaryLabelStyle: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: activePrimary,
),
side: const BorderSide(color: darkBorder, width: 1),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: darkSurfaceElevated,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: darkBorder),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: darkBorder),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: activePrimary, width: 1.8),
),
labelStyle: const TextStyle(color: darkTextSecondary),
hintStyle: const TextStyle(color: darkTextMuted),
),
dividerTheme: const DividerThemeData(
color: darkBorder,
thickness: 1,
space: 24,
),
floatingActionButtonTheme: FloatingActionButtonThemeData(
backgroundColor: activePrimary,
foregroundColor: Colors.white,
elevation: 3,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(16)),
), ),
), ),
); );
+29 -6
View File
@@ -1,36 +1,59 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'app_theme.dart';
class ThemeProvider with ChangeNotifier { class ThemeProvider with ChangeNotifier {
static const String _themeModeKey = 'user_theme_mode'; static const String _themeModeKey = 'user_theme_mode';
static const String _accentKey = 'user_accent_color';
ThemeMode _themeMode = ThemeMode.system; ThemeMode _themeMode = ThemeMode.system;
AppAccentColor _accent = AppAccentColor.blue;
ThemeMode get themeMode => _themeMode; ThemeMode get themeMode => _themeMode;
AppAccentColor get currentAccent => _accent;
Color get primaryColor => _accent.color;
ThemeData get lightTheme => AppTheme.buildLightTheme(_accent);
ThemeData get darkTheme => AppTheme.buildDarkTheme(_accent);
ThemeProvider() { ThemeProvider() {
loadThemeMode(); loadSettings();
} }
Future<void> loadThemeMode() async { Future<void> loadSettings() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final modeIndex = prefs.getInt(_themeModeKey); final modeIndex = prefs.getInt(_themeModeKey);
final accentId = prefs.getString(_accentKey);
if (modeIndex != null) { if (modeIndex != null) {
_themeMode = ThemeMode.values[modeIndex]; _themeMode = ThemeMode.values[modeIndex];
notifyListeners();
} }
if (accentId != null) {
_accent = AppAccentColor.fromId(accentId);
}
notifyListeners();
} }
Future<void> setThemeMode(ThemeMode mode) async { Future<void> setThemeMode(ThemeMode mode) async {
if (_themeMode == mode) return; if (_themeMode == mode) return;
_themeMode = mode; _themeMode = mode;
notifyListeners(); notifyListeners();
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.setInt(_themeModeKey, mode.index); await prefs.setInt(_themeModeKey, mode.index);
} }
Future<void> setAccent(AppAccentColor accent) async {
if (_accent.id == accent.id) return;
_accent = accent;
notifyListeners();
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_accentKey, accent.id);
}
String get themeModeName { String get themeModeName {
switch (_themeMode) { switch (_themeMode) {
case ThemeMode.system: case ThemeMode.system:
+109
View File
@@ -0,0 +1,109 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import '../theme/app_theme.dart';
/// Conteneur avec effet de verre givré (Glassmorphism),
/// bordures translucides fines et reflets subtils pour casser l'aspect standard Flutter.
class GlassContainer extends StatelessWidget {
final Widget child;
final double borderRadius;
final double blur;
final EdgeInsetsGeometry padding;
final EdgeInsetsGeometry margin;
final Color? customBackgroundColor;
final Color? borderColor;
final Color? glowColor;
final VoidCallback? onTap;
final VoidCallback? onLongPress;
const GlassContainer({
super.key,
required this.child,
this.borderRadius = 18.0,
this.blur = 12.0,
this.padding = const EdgeInsets.all(16.0),
this.margin = EdgeInsets.zero,
this.customBackgroundColor,
this.borderColor,
this.glowColor,
this.onTap,
this.onLongPress,
});
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final defaultBg = isDark
? const Color(0xFF141C26).withValues(alpha: 0.72)
: Colors.white.withValues(alpha: 0.82);
final defaultBorder = isDark
? Colors.white.withValues(alpha: 0.12)
: Colors.black.withValues(alpha: 0.08);
final resolvedBorderColor = borderColor ?? defaultBorder;
final resolvedBg = customBackgroundColor ?? defaultBg;
Widget content = Container(
padding: padding,
decoration: BoxDecoration(
color: resolvedBg,
borderRadius: BorderRadius.circular(borderRadius),
border: Border.all(color: resolvedBorderColor, width: 1.2),
boxShadow: [
if (glowColor != null)
BoxShadow(
color: glowColor!.withValues(alpha: isDark ? 0.25 : 0.15),
blurRadius: 18,
spreadRadius: -2,
offset: const Offset(0, 4),
)
else
BoxShadow(
color: Colors.black.withValues(alpha: isDark ? 0.28 : 0.04),
blurRadius: 16,
offset: const Offset(0, 6),
),
],
),
child: child,
);
if (blur > 0) {
content = ClipRRect(
borderRadius: BorderRadius.circular(borderRadius),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: blur, sigmaY: blur),
child: content,
),
);
} else {
content = ClipRRect(
borderRadius: BorderRadius.circular(borderRadius),
child: content,
);
}
if (margin != EdgeInsets.zero) {
content = Padding(padding: margin, child: content);
}
if (onTap != null || onLongPress != null) {
return Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(borderRadius),
child: InkWell(
borderRadius: BorderRadius.circular(borderRadius),
onTap: onTap,
onLongPress: onLongPress,
splashColor: (glowColor ?? AppTheme.primaryColor).withValues(alpha: 0.15),
highlightColor: (glowColor ?? AppTheme.primaryColor).withValues(alpha: 0.08),
child: content,
),
);
}
return content;
}
}
+335
View File
@@ -0,0 +1,335 @@
import 'package:flutter/material.dart';
import '../../data/models/weapon.dart';
/// Widget affichant une icône vectorielle stylisée et fidèle pour chaque type d'arme.
class WeaponTypeIcon extends StatelessWidget {
final WeaponType type;
final Color? color;
final double size;
const WeaponTypeIcon({
super.key,
required this.type,
this.color,
this.size = 24,
});
@override
Widget build(BuildContext context) {
final effectiveColor = color ?? Theme.of(context).colorScheme.primary;
return CustomPaint(
size: Size(size, size),
painter: _WeaponTypePainter(
type: type,
color: effectiveColor,
),
);
}
}
class _WeaponTypePainter extends CustomPainter {
final WeaponType type;
final Color color;
_WeaponTypePainter({
required this.type,
required this.color,
});
@override
void paint(Canvas canvas, Size size) {
final fillPaint = Paint()
..color = color
..style = PaintingStyle.fill
..isAntiAlias = true;
final strokePaint = Paint()
..color = color
..style = PaintingStyle.stroke
..strokeWidth = 1.0
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round
..isAntiAlias = true;
canvas.save();
// Normalisation sur une grille 32x32 pour un rendu très précis
final scale = size.width / 32.0;
canvas.scale(scale, scale);
switch (type) {
case WeaponType.handgun:
_drawGlock(canvas, fillPaint, strokePaint);
break;
case WeaponType.rifle:
_drawKalashnikov(canvas, fillPaint, strokePaint);
break;
case WeaponType.shotgun:
_drawPumpShotgun(canvas, fillPaint, strokePaint);
break;
case WeaponType.airgun:
_drawAirgun(canvas, fillPaint, strokePaint);
break;
}
canvas.restore();
}
/// Silhouette fidèle d'un Glock : culasse carrée "boxy", pontet angulaire et poignée ergonomique
void _drawGlock(Canvas canvas, Paint fillPaint, Paint strokePaint) {
// 1. Culasse rectangulaire Glock (Slide)
final slidePath = Path();
slidePath.moveTo(4, 9);
slidePath.lineTo(26.5, 9); // dessus de culasse
slidePath.lineTo(27.5, 10); // chanfrein nez avant
slidePath.lineTo(27.5, 14.5); // bouche du canon
slidePath.lineTo(4, 14.5); // bas de culasse
slidePath.lineTo(3.5, 13); // chanfrein arrière
slidePath.lineTo(3.5, 10);
slidePath.close();
canvas.drawPath(slidePath, fillPaint);
// Organes de visée Glock
// Cran de mire arrière carré
canvas.drawRect(const Rect.fromLTWH(5, 7.2, 2.5, 1.8), fillPaint);
// Guidon avant
canvas.drawRect(const Rect.fromLTWH(24.5, 7.2, 1.8, 1.8), fillPaint);
// 2. Carcasse inférieure & Poignée (Frame & Grip)
final framePath = Path();
framePath.moveTo(4, 14.5);
framePath.lineTo(26, 14.5); // rail picatinny avant
framePath.lineTo(25.5, 16.5);
framePath.lineTo(17.5, 16.5); // bas du rail avant pontet
framePath.lineTo(17.5, 19.5); // face avant du pontet carré Glock
framePath.lineTo(12.5, 20.5); // bas du pontet
framePath.lineTo(12, 17.5); // jonction détente / poignée avant
framePath.lineTo(9.5, 27); // poignée avant avec empreinte
framePath.lineTo(4.5, 27); // talon de chargeur / magwell
framePath.lineTo(8, 16.5); // dos de poignée / beavertail Glock
framePath.lineTo(4, 15.5);
framePath.close();
canvas.drawPath(framePath, fillPaint);
// 3. Queue de détente Safe Action
final triggerPath = Path();
triggerPath.moveTo(15.5, 15.5);
triggerPath.quadraticBezierTo(14, 17.5, 15.2, 19);
canvas.drawPath(triggerPath, strokePaint..strokeWidth = 1.3);
// 4. Stries de préhension arrière (Serrations)
for (double i = 6; i <= 10; i += 1.5) {
canvas.drawLine(
Offset(i, 10),
Offset(i, 13.5),
Paint()
..color = Colors.black.withValues(alpha: 0.35)
..strokeWidth = 0.8,
);
}
}
/// Silhouette fidèle d'une Kalashnikov (AK-47 / AKM) : chargeur banane, emprunt de gaz et guidon haut
void _drawKalashnikov(Canvas canvas, Paint fillPaint, Paint strokePaint) {
// 1. Crosse arrière en bois / épaule
final stockPath = Path();
stockPath.moveTo(1.5, 13);
stockPath.lineTo(8, 14.5);
stockPath.lineTo(8, 18);
stockPath.lineTo(1.5, 20.5);
stockPath.close();
canvas.drawPath(stockPath, fillPaint);
// 2. Boîtier de culasse (Receiver) & Capot supérieur
final receiverPath = Path();
receiverPath.moveTo(8, 13.5);
receiverPath.lineTo(17, 13.5);
receiverPath.lineTo(17, 18);
receiverPath.lineTo(8, 18);
receiverPath.close();
canvas.drawPath(receiverPath, fillPaint);
// 3. Garde-main & Tube d'emprunt de gaz supérieur
final handguardPath = Path();
handguardPath.moveTo(17, 13.5);
handguardPath.lineTo(24, 13.5);
handguardPath.lineTo(24, 17);
handguardPath.lineTo(17, 17);
handguardPath.close();
canvas.drawPath(handguardPath, fillPaint);
// 4. Canon fin avant & Bloc guidon Kalash
final barrelPath = Path();
barrelPath.moveTo(24, 14.5);
barrelPath.lineTo(30.5, 14.5);
barrelPath.lineTo(30.5, 16);
barrelPath.lineTo(24, 16);
barrelPath.close();
canvas.drawPath(barrelPath, fillPaint);
// Bloc guidon triangulaire surélevé avant
final frontSightPath = Path();
frontSightPath.moveTo(28, 14.5);
frontSightPath.lineTo(29, 11);
frontSightPath.lineTo(30, 14.5);
frontSightPath.close();
canvas.drawPath(frontSightPath, fillPaint);
// Hausse arrière au dessus du boîtier
canvas.drawRect(const Rect.fromLTWH(16.5, 12, 2, 1.5), fillPaint);
// 5. Chargeur courbé "banane" emblématique (30 coups)
final magPath = Path();
magPath.moveTo(14, 18);
magPath.lineTo(16.5, 18);
magPath.quadraticBezierTo(17.5, 22.5, 15, 26);
magPath.lineTo(12, 25.5);
magPath.quadraticBezierTo(14, 22, 13.5, 18);
magPath.close();
canvas.drawPath(magPath, fillPaint);
// 6. Poignée pistolet séparée
final gripPath = Path();
gripPath.moveTo(8.5, 18);
gripPath.lineTo(10.5, 18);
gripPath.lineTo(9.5, 23.5);
gripPath.lineTo(7.5, 23);
gripPath.close();
canvas.drawPath(gripPath, fillPaint);
// 7. Pontet et détente
final triggerGuard = Path();
triggerGuard.moveTo(11, 18);
triggerGuard.quadraticBezierTo(11.5, 20, 13, 19.5);
canvas.drawPath(triggerGuard, strokePaint..strokeWidth = 1.0);
}
/// Silhouette fidèle d'un Fusil à Pompe (Pump Action Shotgun : type Remington 870)
void _drawPumpShotgun(Canvas canvas, Paint fillPaint, Paint strokePaint) {
// 1. Crosse arrière traditionnelle ergonomique
final stockPath = Path();
stockPath.moveTo(1, 14.5);
stockPath.lineTo(9, 15.5);
stockPath.lineTo(9, 19.5);
stockPath.lineTo(6.5, 20); // poignée semi-pistolet
stockPath.lineTo(1, 21.5);
stockPath.close();
canvas.drawPath(stockPath, fillPaint);
// 2. Boîtier de culasse récepteur (Receiver)
final receiverPath = Path();
receiverPath.moveTo(9, 14);
receiverPath.lineTo(16.5, 14);
receiverPath.lineTo(16.5, 19);
receiverPath.lineTo(9, 19);
receiverPath.close();
canvas.drawPath(receiverPath, fillPaint);
// 3. Canon long calibre 12 (Barrel)
final barrelPath = Path();
barrelPath.moveTo(16.5, 14);
barrelPath.lineTo(31, 14);
barrelPath.lineTo(31, 15.5);
barrelPath.lineTo(16.5, 15.5);
barrelPath.close();
canvas.drawPath(barrelPath, fillPaint);
// Grain d'orge / mire avant sur le canon
canvas.drawCircle(const Offset(30, 13.2), 0.8, fillPaint);
// 4. Tube magasin inférieur sous le canon
final magTubePath = Path();
magTubePath.moveTo(16.5, 16);
magTubePath.lineTo(27, 16);
magTubePath.lineTo(27, 17.5);
magTubePath.lineTo(16.5, 17.5);
magTubePath.close();
canvas.drawPath(magTubePath, fillPaint);
// Bague de fixation canon / tube magasin
canvas.drawRect(const Rect.fromLTWH(26, 14, 1.2, 4), fillPaint);
// 5. Pompe mobile striée d'actionnement (Forend / Pump)
final pumpPath = Path();
pumpPath.addRRect(RRect.fromRectAndRadius(
const Rect.fromLTWH(18, 15.5, 6.5, 3.5),
const Radius.circular(0.8),
));
canvas.drawPath(pumpPath, fillPaint);
// Stries sur la pompe
for (double x = 19.5; x <= 23.5; x += 1.3) {
canvas.drawLine(
Offset(x, 16),
Offset(x, 18.5),
Paint()
..color = Colors.black.withValues(alpha: 0.4)
..strokeWidth = 0.6,
);
}
// 6. Pontet et détente
final triggerGuard = Path();
triggerGuard.moveTo(10, 19);
triggerGuard.quadraticBezierTo(11.5, 21.5, 13.5, 20);
triggerGuard.lineTo(13.5, 19);
canvas.drawPath(triggerGuard, strokePaint..strokeWidth = 1.0);
}
/// Silhouette fidèle d'une arme à air comprimé / cible plomb (Airgun)
void _drawAirgun(Canvas canvas, Paint fillPaint, Paint strokePaint) {
// Pistolet match 10m / Airgun olympique avec réservoir d'air cylindrique
// 1. Canon supérieur et boîtier
final barrelPath = Path();
barrelPath.moveTo(4, 12);
barrelPath.lineTo(28, 12);
barrelPath.lineTo(28, 14);
barrelPath.lineTo(4, 14);
barrelPath.close();
canvas.drawPath(barrelPath, fillPaint);
// Organes de visée match : dioptre arrière et tunnel de guidon avant
canvas.drawRect(const Rect.fromLTWH(4.5, 9.5, 3, 2.5), fillPaint);
canvas.drawRect(const Rect.fromLTWH(25, 10, 2.5, 2), fillPaint);
// 2. Bonbonne d'air comprimé cylindrique inférieure (Cylinder)
final tankPath = Path();
tankPath.addRRect(RRect.fromRectAndRadius(
const Rect.fromLTWH(10, 14.5, 15, 3),
const Radius.circular(1.5),
));
canvas.drawPath(tankPath, fillPaint);
// Manomètre avant
canvas.drawCircle(const Offset(25, 16), 1.2, strokePaint..strokeWidth = 0.8);
// 3. Poignée anatomique bois de tir sportif avec repose-paume
final gripPath = Path();
gripPath.moveTo(6, 14);
gripPath.lineTo(10, 14);
gripPath.lineTo(9.5, 23.5);
gripPath.lineTo(3.5, 22.5);
gripPath.close();
canvas.drawPath(gripPath, fillPaint);
// Tablette repose-paume réglable
canvas.drawRRect(
RRect.fromRectAndRadius(
const Rect.fromLTWH(2, 22.5, 9, 2.2),
const Radius.circular(0.8),
),
fillPaint,
);
// 4. Pontet match
final guardPath = Path();
guardPath.moveTo(10, 14.5);
guardPath.quadraticBezierTo(12, 18.5, 9.5, 18.5);
canvas.drawPath(guardPath, strokePaint..strokeWidth = 1.0);
}
@override
bool shouldRepaint(covariant _WeaponTypePainter oldDelegate) {
return oldDelegate.type != type || oldDelegate.color != color;
}
}
+8 -5
View File
@@ -1,11 +1,14 @@
import 'package:flutter/material.dart';
enum WeaponType { enum WeaponType {
handgun('Arme de Poing'), handgun('Arme de Poing', Icons.shield),
rifle('Arme d\'Épaule'), rifle('Arme d\'Épaule', Icons.filter_center_focus),
shotgun('Fusil à Pompe'), shotgun('Fusil à Pompe', Icons.splitscreen),
airgun('Airsoft / Airgun'); airgun('Airsoft / Airgun', Icons.air);
final String displayName; final String displayName;
const WeaponType(this.displayName); final IconData defaultIcon;
const WeaponType(this.displayName, this.defaultIcon);
static WeaponType fromString(String value) { static WeaponType fromString(String value) {
return WeaponType.values.firstWhere( return WeaponType.values.firstWhere(
+8 -3
View File
@@ -239,12 +239,16 @@ class AnalysisProvider extends ChangeNotifier {
} }
/// Exporte l'image et le json vers le backend IA. /// Exporte l'image et le json vers le backend IA.
/// [sessionId], [distance] et [weapon] proviennent du SessionProvider de /// [sessionId], [distance] et [caliber] proviennent du SessionProvider de
/// l'écran appelant, pour que le dataset contienne les vraies métadonnées. /// l'écran appelant, pour que le dataset contienne les vraies métadonnées.
///
/// Le nom de l'arme n'est volontairement pas transmis : il est souvent
/// personnalisé par l'utilisateur et n'apporte rien au modèle de détection.
Future<AiExportResult> exportToAiBackend({ Future<AiExportResult> exportToAiBackend({
String? sessionId, String? sessionId,
int? distance, int? distance,
String? weapon, String? caliber,
int? expectedShots,
}) async { }) async {
if (_imagePath == null || _targetType == null) { if (_imagePath == null || _targetType == null) {
_errorMessage = "Impossible d'exporter : image ou type de cible manquant."; _errorMessage = "Impossible d'exporter : image ou type de cible manquant.";
@@ -269,7 +273,8 @@ class AnalysisProvider extends ChangeNotifier {
targetRadius: _targetRadius, targetRadius: _targetRadius,
shots: _shots, shots: _shots,
distanceMeters: distance ?? 25, distanceMeters: distance ?? 25,
weaponName: weapon ?? 'Unknown', caliber: caliber ?? 'unknown',
expectedShots: expectedShots,
); );
_state = AnalysisState.success; _state = AnalysisState.success;
+7 -2
View File
@@ -784,7 +784,7 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
// qu'elle ressorte sur le bouton. // qu'elle ressorte sur le bouton.
color: Colors.white, color: Colors.white,
), ),
label: 'TERMINER TOUT ET EXPORTER', label: 'TERMINER ET CONTRIBUER À L\'IA',
color: AppTheme.warningColor, color: AppTheme.warningColor,
onPressed: () => onPressed: () =>
_finishSession(context, provider, export: true), _finishSession(context, provider, export: true),
@@ -942,7 +942,12 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
exportResult = await provider.exportToAiBackend( exportResult = await provider.exportToAiBackend(
sessionId: sessionProvider.activeSessionId, sessionId: sessionProvider.activeSessionId,
distance: sessionProvider.distance, distance: sessionProvider.distance,
weapon: sessionProvider.currentWeapon, caliber: sessionProvider.currentWeaponCaliber,
// Hors session, shotsPerTarget vaut sa valeur par defaut : mieux
// vaut ne rien annoncer qu'annoncer un nombre faux.
expectedShots: sessionProvider.isSessionActive
? sessionProvider.shotsPerTarget
: null,
); );
messenger.hideCurrentSnackBar(); messenger.hideCurrentSnackBar();
} }
@@ -25,6 +25,9 @@ import 'package:provider/provider.dart';
import '../../core/theme/app_theme.dart'; import '../../core/theme/app_theme.dart';
import '../../data/models/shot.dart'; import '../../data/models/shot.dart';
import '../../services/tutorial_service.dart';
import '../tutorial/tutorial_coach.dart';
import '../tutorial/tutorial_step.dart';
import 'analysis_provider.dart'; import 'analysis_provider.dart';
import 'widgets/target_overlay.dart'; import 'widgets/target_overlay.dart';
@@ -43,12 +46,71 @@ class _ImpactEditorScreenState extends State<ImpactEditorScreen> {
double _currentZoomScale = 1.0; double _currentZoomScale = 1.0;
String? _movingShotId; String? _movingShotId;
// Clés du didacticiel : zone de travail et bouton de validation.
final GlobalKey _tutoCanvasKey = GlobalKey();
final GlobalKey _tutoValidateKey = GlobalKey();
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_transformationController.addListener(_onTransformChanged); _transformationController.addListener(_onTransformChanged);
// Première ouverture de l'éditeur : on montre les gestes disponibles.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
TutorialCoach.maybeStart(
context,
tourId: TutorialTours.impactEditor,
stepsBuilder: _buildEditorTutorialSteps,
);
});
} }
List<TutorialStep> _buildEditorTutorialSteps() => [
TutorialStep(
targetKey: _tutoCanvasKey,
title: 'Ajouter un impact',
description:
'Touchez la cible à l\'endroit de l\'impact : il est ajouté '
'immédiatement, même collé à un impact déjà placé. Le score est '
'calculé automatiquement selon la zone touchée.',
gesture: TutorialGesture.tap,
icon: Icons.add_location_alt_outlined,
spotPadding: 0,
),
TutorialStep(
targetKey: _tutoCanvasKey,
title: 'Déplacer un impact',
description:
'Appui long sur un impact, puis glissez le doigt pour l\'ajuster '
'au millimètre. L\'impact reste visible au-dessus du doigt.',
gesture: TutorialGesture.drag,
icon: Icons.open_with,
spotPadding: 0,
),
TutorialStep(
targetKey: _tutoCanvasKey,
title: 'Zoomer sur la cible',
description:
'Écartez deux doigts pour zoomer (jusqu\'à 12×) et placer vos '
'impacts avec précision ; rapprochez-les pour dézoomer. À un '
'doigt, vous faites glisser l\'image.',
gesture: TutorialGesture.pinch,
icon: Icons.zoom_in,
spotPadding: 0,
),
TutorialStep(
targetKey: _tutoValidateKey,
title: 'Valider vos impacts',
description:
'VALIDER renvoie vers la synthèse avec les scores et le '
'groupement. La corbeille, à gauche, efface tous les impacts '
'sans toucher à la calibration.',
gesture: TutorialGesture.tap,
icon: Icons.check_circle_outline,
spotPadding: 8,
),
];
@override @override
void dispose() { void dispose() {
_transformationController.removeListener(_onTransformChanged); _transformationController.removeListener(_onTransformChanged);
@@ -123,6 +185,7 @@ class _ImpactEditorScreenState extends State<ImpactEditorScreen> {
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
FloatingActionButton.extended( FloatingActionButton.extended(
key: _tutoValidateKey,
heroTag: 'validate_impacts', heroTag: 'validate_impacts',
onPressed: () => Navigator.pop(context, true), onPressed: () => Navigator.pop(context, true),
backgroundColor: AppTheme.primaryColor, backgroundColor: AppTheme.primaryColor,
@@ -147,6 +210,7 @@ class _ImpactEditorScreenState extends State<ImpactEditorScreen> {
// Zone image plein écran : InteractiveViewer dans un body nu. // Zone image plein écran : InteractiveViewer dans un body nu.
Expanded( Expanded(
key: _tutoCanvasKey,
child: InteractiveViewer( child: InteractiveViewer(
transformationController: _transformationController, transformationController: _transformationController,
minScale: 1.0, minScale: 1.0,
+25 -9
View File
@@ -169,8 +169,9 @@ class _CaptureScreenState extends State<CaptureScreen>
final prev = _parallelismData; final prev = _parallelismData;
final bool changed = prev == null || final bool changed = prev == null ||
prev.status != data.status || prev.status != data.status ||
prev.pitchDegrees.toStringAsFixed(1) != prev.pose != data.pose ||
data.pitchDegrees.toStringAsFixed(1) || prev.pitchDeviation.toStringAsFixed(1) !=
data.pitchDeviation.toStringAsFixed(1) ||
prev.rollDegrees.toStringAsFixed(1) != prev.rollDegrees.toStringAsFixed(1) !=
data.rollDegrees.toStringAsFixed(1); data.rollDegrees.toStringAsFixed(1);
@@ -218,16 +219,21 @@ class _CaptureScreenState extends State<CaptureScreen>
return 'ALIGNEZ LA CIBLE DANS LE CADRE'; return 'ALIGNEZ LA CIBLE DANS LE CADRE';
} }
final bool onGround = _parallelismData!.pose == TargetPose.ground;
// Aligné → message de validation (avec bonus si la cible est détectée) // Aligné → message de validation (avec bonus si la cible est détectée)
if (_parallelismData!.isAligned) { if (_parallelismData!.isAligned) {
return _targetReady if (_targetReady) return 'PARFAIT — CIBLE DÉTECTÉE, PRÊT';
? 'PARFAIT — CIBLE DÉTECTÉE, PRÊT' return onGround
? 'CIBLE AU SOL — PRÊT À PHOTOGRAPHIER'
: 'PARALLÈLE OK — PRÊT À PHOTOGRAPHIER'; : 'PARALLÈLE OK — PRÊT À PHOTOGRAPHIER';
} }
// Mal aligné → message directif selon l'axe le plus dévié // Mal aligné → message directif selon l'axe le plus dévié.
final double pitch = _parallelismData!.pitchDegrees; // On raisonne sur les écarts à la pose détectée, pas sur le tangage brut :
final double roll = _parallelismData!.rollDegrees; // à plat, celui-ci vaut -90° alors que le cadrage peut être parfait.
final double pitch = _parallelismData!.pitchDeviation;
final double roll = _parallelismData!.rollDeviation;
if (pitch.abs() >= roll.abs()) { if (pitch.abs() >= roll.abs()) {
return pitch > 0 return pitch > 0
@@ -776,15 +782,25 @@ class _CaptureScreenState extends State<CaptureScreen>
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Text(
data.pose == TargetPose.ground ? 'CIBLE AU SOL' : 'CIBLE AU MUR',
style: TextStyle(
color: color,
fontSize: 10,
fontWeight: FontWeight.w700,
letterSpacing: 0.5,
),
),
const SizedBox(height: 4),
_buildAngleRow( _buildAngleRow(
label: 'Pitch', label: 'Pitch',
value: data.pitchDegrees, value: data.pitchDeviation,
color: color, color: color,
), ),
const SizedBox(height: 2), const SizedBox(height: 2),
_buildAngleRow( _buildAngleRow(
label: 'Roll ', label: 'Roll ',
value: data.rollDegrees, value: data.rollDeviation,
color: color, color: color,
), ),
], ],
+35 -2
View File
@@ -3,6 +3,7 @@ import 'package:provider/provider.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import '../../core/constants/app_constants.dart'; import '../../core/constants/app_constants.dart';
import '../../core/theme/app_theme.dart'; import '../../core/theme/app_theme.dart';
import '../../core/widgets/weapon_type_icon.dart';
import '../../data/models/weapon.dart'; import '../../data/models/weapon.dart';
import '../../data/models/maintenance.dart'; import '../../data/models/maintenance.dart';
import '../../data/repositories/session_repository.dart'; import '../../data/repositories/session_repository.dart';
@@ -131,7 +132,26 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
const Text('Informations techniques', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), const Text('Informations techniques', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
const Divider(), const Divider(),
_buildInfoRow('Modèle', _weapon.name), _buildInfoRow('Modèle', _weapon.name),
_buildInfoRow('Type', _weapon.type.displayName), Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Type', style: TextStyle(color: Colors.grey)),
Row(
mainAxisSize: MainAxisSize.min,
children: [
WeaponTypeIcon(type: _weapon.type, size: 16),
const SizedBox(width: 6),
Text(
_weapon.type.displayName,
style: const TextStyle(fontWeight: FontWeight.w500),
),
],
),
],
),
),
_buildInfoRow('Calibre', _weapon.caliber), _buildInfoRow('Calibre', _weapon.caliber),
_buildInfoRow('Chargeurs', '${_weapon.magazineCount} x ${_weapon.magazineCapacity} coups'), _buildInfoRow('Chargeurs', '${_weapon.magazineCount} x ${_weapon.magazineCapacity} coups'),
if (_weapon.notes != null && _weapon.notes!.isNotEmpty) ...[ if (_weapon.notes != null && _weapon.notes!.isNotEmpty) ...[
@@ -313,7 +333,20 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
DropdownButtonFormField<WeaponType>( DropdownButtonFormField<WeaponType>(
initialValue: selectedType, initialValue: selectedType,
decoration: const InputDecoration(labelText: 'Type'), decoration: const InputDecoration(labelText: 'Type'),
items: WeaponType.values.map((t) => DropdownMenuItem(value: t, child: Text(t.displayName))).toList(), items: WeaponType.values
.map(
(t) => DropdownMenuItem(
value: t,
child: Row(
children: [
WeaponTypeIcon(type: t, size: 20),
const SizedBox(width: 10),
Text(t.displayName),
],
),
),
)
.toList(),
onChanged: (v) => setState(() => selectedType = v!), onChanged: (v) => setState(() => selectedType = v!),
), ),
_autoScrollOnFocus(TextField( _autoScrollOnFocus(TextField(
+257 -88
View File
@@ -2,14 +2,13 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../../core/constants/app_constants.dart'; import '../../core/constants/app_constants.dart';
import '../../core/theme/app_theme.dart'; import '../../core/theme/app_theme.dart';
import '../../core/widgets/glass_container.dart';
import '../../core/widgets/weapon_type_icon.dart';
import '../../data/models/weapon.dart'; import '../../data/models/weapon.dart';
import '../../data/repositories/session_repository.dart'; import '../../data/repositories/session_repository.dart';
import 'weapon_detail_screen.dart'; import 'weapon_detail_screen.dart';
class WeaponListScreen extends StatefulWidget { class WeaponListScreen extends StatefulWidget {
/// Incrémenté par la navigation à chaque ouverture de l'onglet Armurerie
/// (l'écran est gardé vivant par l'IndexedStack et ne se rafraîchit pas
/// seul : sans ça, un import de sauvegarde resterait invisible).
final int refreshTick; final int refreshTick;
const WeaponListScreen({super.key, this.refreshTick = 0}); const WeaponListScreen({super.key, this.refreshTick = 0});
@@ -49,118 +48,242 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return Scaffold( return Scaffold(
extendBody: true,
appBar: AppBar( appBar: AppBar(
title: const Text('Mon Armurerie'), title: const Text('Mon Armurerie'),
actions: [
IconButton(
icon: const Icon(Icons.add_circle_outline),
tooltip: 'Ajouter une arme',
onPressed: _showAddWeaponDialog,
),
const SizedBox(width: 8),
],
), ),
body: _isLoading body: _isLoading
? const Center(child: CircularProgressIndicator()) ? const Center(child: CircularProgressIndicator())
: _weapons.isEmpty : _weapons.isEmpty
? _buildEmptyState() ? _buildEmptyState(isDark)
: _buildWeaponList(), : _buildWeaponList(isDark),
floatingActionButton: FloatingActionButton(
onPressed: _showAddWeaponDialog,
child: const Icon(Icons.add),
),
); );
} }
Widget _buildEmptyState() { Widget _buildEmptyState(bool isDark) {
return Center( return Center(
child: Column( child: Padding(
mainAxisAlignment: MainAxisAlignment.center, padding: const EdgeInsets.all(32.0),
children: [ child: Column(
Icon(Icons.shield, size: 64, color: Colors.grey[400]), mainAxisAlignment: MainAxisAlignment.center,
const SizedBox(height: 16), children: [
const Text('Aucune arme enregistrée'), GlassContainer(
const SizedBox(height: 24), borderRadius: 30,
ElevatedButton( padding: const EdgeInsets.all(28),
onPressed: _showAddWeaponDialog, child: Icon(
child: const Text('Ajouter ma première arme'), Icons.shield_outlined,
), size: 64,
], color: isDark ? AppTheme.darkTextMuted : AppTheme.lightTextMuted,
),
),
const SizedBox(height: 24),
Text(
'Aucune arme enregistrée',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w800,
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
),
),
const SizedBox(height: 8),
Text(
'Ajoutez vos armes pour suivre vos tirs, chargeurs et entretiens.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13,
color: isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary,
),
),
const SizedBox(height: 24),
ElevatedButton.icon(
onPressed: _showAddWeaponDialog,
icon: const Icon(Icons.add),
label: const Text('Ajouter ma première arme'),
),
],
),
), ),
); );
} }
Widget _buildWeaponList() { Widget _buildWeaponList(bool isDark) {
return ListView.builder( return ListView.builder(
padding: const EdgeInsets.all(AppConstants.defaultPadding), padding: const EdgeInsets.fromLTRB(
AppConstants.defaultPadding,
12,
AppConstants.defaultPadding,
100, // Espace pour le floating dock
),
itemCount: _weapons.length, itemCount: _weapons.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final weapon = _weapons[index]; final weapon = _weapons[index];
final accessories = _accessoryChips(weapon); final accessories = _accessoryChips(weapon, isDark);
return Card( final primaryColor = Theme.of(context).colorScheme.primary;
margin: const EdgeInsets.only(bottom: 12),
child: InkWell( return GlassContainer(
borderRadius: BorderRadius.circular(12), borderRadius: 18,
onTap: () async { blur: 14,
await Navigator.push( glowColor: primaryColor,
context, borderColor: isDark
MaterialPageRoute(builder: (_) => WeaponDetailScreen(weapon: weapon)), ? primaryColor.withValues(alpha: 0.18)
); : primaryColor.withValues(alpha: 0.12),
_loadWeapons(); // Reload in case it was edited or maintenance was added margin: const EdgeInsets.only(bottom: 14),
}, padding: const EdgeInsets.all(16),
onLongPress: () => _confirmDelete(weapon), onTap: () async {
child: Padding( await Navigator.push(
// La hauteur du cadre s'adapte automatiquement à la liste d'accessoires. context,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), MaterialPageRoute(
child: Row( builder: (_) => WeaponDetailScreen(weapon: weapon),
),
);
_loadWeapons();
},
onLongPress: () => _confirmDelete(weapon),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
CircleAvatar( Container(
backgroundColor: AppTheme.primaryColor.withValues(alpha: 0.1), padding: const EdgeInsets.all(10),
child: Icon( decoration: BoxDecoration(
weapon.type == WeaponType.handgun ? Icons.shield : Icons.ads_click, gradient: LinearGradient(
color: AppTheme.primaryColor, colors: [
primaryColor.withValues(
alpha: isDark ? 0.25 : 0.15,
),
primaryColor.withValues(
alpha: 0.05,
),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: primaryColor.withValues(
alpha: isDark ? 0.35 : 0.2,
),
),
),
child: WeaponTypeIcon(
type: weapon.type,
color: primaryColor,
size: 28,
), ),
), ),
const SizedBox(width: 16), const SizedBox(width: 14),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [ children: [
Text(weapon.displayName, style: const TextStyle(fontWeight: FontWeight.bold)),
if (accessories.isNotEmpty) ...[
const SizedBox(height: 6),
Wrap(
spacing: 6,
runSpacing: 6,
children: accessories,
),
const SizedBox(height: 6),
] else
const SizedBox(height: 2),
Text( Text(
'${weapon.type.displayName}${weapon.caliber}', weapon.displayName,
style: const TextStyle(fontSize: 13, color: Colors.grey), style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w800,
color: isDark
? AppTheme.darkTextPrimary
: AppTheme.lightTextPrimary,
),
),
const SizedBox(height: 4),
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 7,
vertical: 2,
),
decoration: BoxDecoration(
color: (isDark ? Colors.white : Colors.black)
.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: isDark
? AppTheme.darkBorder
: AppTheme.lightBorder,
),
),
child: Text(
weapon.caliber,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w800,
color: primaryColor,
),
),
),
const SizedBox(width: 8),
Text(
weapon.type.displayName,
style: TextStyle(
fontSize: 12,
color: isDark
? AppTheme.darkTextSecondary
: AppTheme.lightTextSecondary,
),
),
],
), ),
], ],
), ),
), ),
const SizedBox(width: 12),
Column( Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end,
children: [ children: [
Text('${weapon.magazineCount} chargeurs', style: const TextStyle(fontSize: 12)), Text(
Text('${weapon.magazineCapacity} coups', style: const TextStyle(fontSize: 12, color: Colors.grey)), '${weapon.magazineCount} chargeur${weapon.magazineCount > 1 ? 's' : ''}',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: isDark
? AppTheme.darkTextPrimary
: AppTheme.lightTextPrimary,
),
),
const SizedBox(height: 2),
Text(
'${weapon.magazineCapacity} coups/ch.',
style: TextStyle(
fontSize: 11,
color: isDark
? AppTheme.darkTextMuted
: AppTheme.lightTextMuted,
),
),
], ],
), ),
], ],
), ),
), if (accessories.isNotEmpty) ...[
const SizedBox(height: 12),
Wrap(
spacing: 6,
runSpacing: 6,
children: accessories,
),
],
],
), ),
); );
}, },
); );
} }
// Construit la liste des "puces" d'accessoires renseignés pour une arme. List<Widget> _accessoryChips(Weapon weapon, bool isDark) {
// Seuls les accessoires effectivement définis sont affichés ; la card
// s'agrandit donc en fonction du nombre d'accessoires.
List<Widget> _accessoryChips(Weapon weapon) {
final items = <(IconData, String)>[]; final items = <(IconData, String)>[];
if (weapon.optic != null && weapon.optic!.isNotEmpty) { if (weapon.optic != null && weapon.optic!.isNotEmpty) {
items.add((Icons.center_focus_strong, weapon.optic!)); items.add((Icons.center_focus_strong, weapon.optic!));
@@ -176,16 +299,29 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppTheme.primaryColor.withValues(alpha: 0.08), color: (isDark ? Colors.white : Colors.black).withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppTheme.primaryColor.withValues(alpha: 0.25)), border: Border.all(
color: isDark ? AppTheme.darkBorder : AppTheme.lightBorder,
),
), ),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(item.$1, size: 13, color: AppTheme.primaryColor), Icon(
const SizedBox(width: 4), item.$1,
Text(item.$2, style: const TextStyle(fontSize: 12)), size: 13,
color: isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary,
),
const SizedBox(width: 5),
Text(
item.$2,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary,
),
),
], ],
), ),
); );
@@ -193,7 +329,6 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
} }
void _showAddWeaponDialog() async { void _showAddWeaponDialog() async {
// Capturé AVANT l'await : plus aucun usage du context après le dialogue.
final repository = context.read<SessionRepository>(); final repository = context.read<SessionRepository>();
final nameController = TextEditingController(); final nameController = TextEditingController();
final caliberController = TextEditingController(); final caliberController = TextEditingController();
@@ -203,7 +338,7 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
final result = await showDialog<bool>( final result = await showDialog<bool>(
context: context, context: context,
builder: (context) => StatefulBuilder( builder: (dialogCtx) => StatefulBuilder(
builder: (context, setState) => AlertDialog( builder: (context, setState) => AlertDialog(
title: const Text('Ajouter une arme'), title: const Text('Ajouter une arme'),
content: SingleChildScrollView( content: SingleChildScrollView(
@@ -212,18 +347,40 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
children: [ children: [
TextField( TextField(
controller: nameController, controller: nameController,
decoration: const InputDecoration(labelText: 'Nom de l\'arme', hintText: 'ex: Glock 17'), decoration: const InputDecoration(
labelText: 'Nom de l\'arme',
hintText: 'ex: Glock 17 Gen 5',
),
), ),
const SizedBox(height: 12),
DropdownButtonFormField<WeaponType>( DropdownButtonFormField<WeaponType>(
initialValue: selectedType, initialValue: selectedType,
decoration: const InputDecoration(labelText: 'Type'), decoration: const InputDecoration(labelText: 'Type'),
items: WeaponType.values.map((t) => DropdownMenuItem(value: t, child: Text(t.displayName))).toList(), items: WeaponType.values
.map(
(t) => DropdownMenuItem(
value: t,
child: Row(
children: [
WeaponTypeIcon(type: t, size: 20),
const SizedBox(width: 10),
Text(t.displayName),
],
),
),
)
.toList(),
onChanged: (v) => setState(() => selectedType = v!), onChanged: (v) => setState(() => selectedType = v!),
), ),
const SizedBox(height: 12),
TextField( TextField(
controller: caliberController, controller: caliberController,
decoration: const InputDecoration(labelText: 'Calibre', hintText: 'ex: 9mm, .22LR'), decoration: const InputDecoration(
labelText: 'Calibre',
hintText: 'ex: 9x19mm, .22 LR, .223 Rem',
),
), ),
const SizedBox(height: 12),
Row( Row(
children: [ children: [
Expanded( Expanded(
@@ -233,7 +390,7 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
), ),
), ),
const SizedBox(width: 16), const SizedBox(width: 12),
Expanded( Expanded(
child: TextField( child: TextField(
controller: magCapController, controller: magCapController,
@@ -247,8 +404,14 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
), ),
), ),
actions: [ actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Annuler')), TextButton(
ElevatedButton(onPressed: () => Navigator.pop(context, true), child: const Text('Ajouter')), onPressed: () => Navigator.pop(dialogCtx, false),
child: const Text('Annuler'),
),
ElevatedButton(
onPressed: () => Navigator.pop(dialogCtx, true),
child: const Text('Ajouter'),
),
], ],
), ),
), ),
@@ -268,18 +431,24 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
} }
void _confirmDelete(Weapon weapon) async { void _confirmDelete(Weapon weapon) async {
// Capturé AVANT l'await : plus aucun usage du context après le dialogue.
final repository = context.read<SessionRepository>(); final repository = context.read<SessionRepository>();
final confirmed = await showDialog<bool>( final confirmed = await showDialog<bool>(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
title: const Text('Supprimer'), title: const Text('Supprimer l\'arme'),
content: Text('Voulez-vous supprimer ${weapon.name} de votre armurerie ?'), content: Text('Voulez-vous supprimer ${weapon.name} de votre armurerie ?'),
actions: [ actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Annuler')),
TextButton( TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Annuler'),
),
ElevatedButton(
onPressed: () => Navigator.pop(context, true), onPressed: () => Navigator.pop(context, true),
child: const Text('Supprimer', style: TextStyle(color: AppTheme.errorColor)), style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.errorColor,
foregroundColor: Colors.white,
),
child: const Text('Supprimer'),
), ),
], ],
), ),
+171 -115
View File
@@ -1,7 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import '../../core/constants/app_constants.dart';
import '../../core/theme/app_theme.dart'; import '../../core/theme/app_theme.dart';
import '../../data/models/session.dart'; import '../../data/models/session.dart';
import '../../data/models/target_type.dart'; import '../../data/models/target_type.dart';
@@ -11,10 +10,6 @@ import 'widgets/session_list_item.dart';
import 'widgets/history_chart.dart'; import 'widgets/history_chart.dart';
class HistoryScreen extends StatefulWidget { class HistoryScreen extends StatefulWidget {
/// Incrémenté par la navigation à chaque ouverture de l'onglet Historique,
/// pour forcer un rechargement des sessions (l'écran est gardé vivant par un
/// IndexedStack, sinon une session tout juste clôturée n'apparaîtrait pas
/// tant qu'on ne tire pas manuellement pour rafraîchir).
final int refreshTick; final int refreshTick;
const HistoryScreen({super.key, this.refreshTick = 0}); const HistoryScreen({super.key, this.refreshTick = 0});
@@ -27,8 +22,6 @@ class _HistoryScreenState extends State<HistoryScreen> {
List<Session> _sessions = []; List<Session> _sessions = [];
bool _isLoading = true; bool _isLoading = true;
TargetType? _filterType; TargetType? _filterType;
// --- MODIFICATION : Remplacement de DateTime par DateTimeRange ---
DateTimeRange? _selectedDateRange; DateTimeRange? _selectedDateRange;
@override @override
@@ -40,8 +33,6 @@ class _HistoryScreenState extends State<HistoryScreen> {
@override @override
void didUpdateWidget(HistoryScreen oldWidget) { void didUpdateWidget(HistoryScreen oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
// L'onglet vient d'être ré-ouvert : on recharge pour afficher les sessions
// récemment clôturées sans avoir à tirer manuellement pour rafraîchir.
if (oldWidget.refreshTick != widget.refreshTick) { if (oldWidget.refreshTick != widget.refreshTick) {
_loadSessions(); _loadSessions();
} }
@@ -59,18 +50,14 @@ class _HistoryScreenState extends State<HistoryScreen> {
setState(() { setState(() {
_sessions = sessions; _sessions = sessions;
// --- FILTRAGE PAR TYPE DE CIBLE ---
// Une session est retenue si au moins une de ses cibles est du type choisi.
if (_filterType != null) { if (_filterType != null) {
_sessions = _sessions _sessions = _sessions
.where((s) => s.analyses.any((a) => a.targetType == _filterType)) .where((s) => s.analyses.any((a) => a.targetType == _filterType))
.toList(); .toList();
} }
// --- LOGIQUE DE FILTRAGE PAR PÉRIODE ---
if (_selectedDateRange != null) { if (_selectedDateRange != null) {
_sessions = _sessions.where((s) { _sessions = _sessions.where((s) {
// On compare uniquement les dates (sans les heures) pour éviter les bugs
final sessionDate = DateTime( final sessionDate = DateTime(
s.createdAt.year, s.createdAt.year,
s.createdAt.month, s.createdAt.month,
@@ -109,32 +96,13 @@ class _HistoryScreenState extends State<HistoryScreen> {
} }
} }
// --- MODIFICATION : Fonction DateRangePicker ---
Future<void> _pickDateRange() async { Future<void> _pickDateRange() async {
final DateTimeRange? picked = await showDateRangePicker( final picked = await showDateRangePicker(
context: context, context: context,
initialDateRange: _selectedDateRange, initialDateRange: _selectedDateRange,
firstDate: DateTime(2020), firstDate: DateTime(2020),
lastDate: DateTime.now().add(const Duration(days: 1)), lastDate: DateTime.now().add(const Duration(days: 1)),
locale: const Locale('fr', 'FR'), locale: const Locale('fr', 'FR'),
builder: (context, child) {
return Theme(
data: ThemeData.light().copyWith(
colorScheme: ColorScheme.light(
primary: AppTheme.primaryColor, // En-tête et sélection
onPrimary: Colors.white, // Texte sur en-tête/sélection
surface: Colors.white, // Fond du calendrier
onSurface: Colors.black87, // Texte des dates (Noir sur Blanc)
secondary: AppTheme.primaryColor,
),
dialogTheme: const DialogThemeData(backgroundColor: Colors.white),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(foregroundColor: AppTheme.primaryColor),
),
),
child: child!,
);
},
); );
if (picked != null) { if (picked != null) {
@@ -145,134 +113,221 @@ class _HistoryScreenState extends State<HistoryScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return Scaffold( return Scaffold(
extendBody: true,
appBar: AppBar( appBar: AppBar(
title: const Text('Historique'), title: const Text('Carnet de Tir'),
actions: [ actions: [
// NOTE : on passe par un String sentinelle ('all') car un IconButton(
// PopupMenuItem avec value null ne déclenche jamais onSelected
// (Flutter l'interprète comme une annulation du menu).
PopupMenuButton<String>(
icon: Icon( icon: Icon(
Icons.filter_list, Icons.date_range_outlined,
// Icône colorée quand un filtre est actif, pour le rendre visible. color: _selectedDateRange != null ? AppTheme.primaryColor : null,
color: _filterType != null ? AppTheme.primaryColor : null,
), ),
onSelected: (value) { tooltip: 'Filtrer par date',
setState(() { onPressed: _pickDateRange,
_filterType =
value == 'all' ? null : TargetType.fromString(value);
});
_loadSessions();
},
itemBuilder: (context) => [
const PopupMenuItem(value: 'all', child: Text('Tous')),
...TargetType.values.map(
(type) => PopupMenuItem(
value: type.name,
child: Text(type.displayName),
),
),
],
), ),
const SizedBox(width: 8),
], ],
), ),
body: Column( body: Column(
children: [ children: [
_buildFilterChips(isDark),
Expanded( Expanded(
child: _isLoading child: _isLoading
? const Center(child: CircularProgressIndicator()) ? const Center(child: CircularProgressIndicator())
: _sessions.isEmpty : _sessions.isEmpty
? _buildEmptyState() ? _buildEmptyState(isDark)
: _buildContent(), : _buildContent(isDark),
), ),
_buildBottomFilterBar(), if (_selectedDateRange != null) _buildActivePeriodBanner(isDark),
], ],
), ),
); );
} }
Widget _buildBottomFilterBar() { Widget _buildFilterChips(bool isDark) {
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Theme.of(context).cardColor, color: isDark ? AppTheme.darkBackground : AppTheme.lightBackground,
boxShadow: const [ border: Border(
BoxShadow( bottom: BorderSide(
color: Colors.black26, color: isDark ? AppTheme.darkBorder : AppTheme.lightBorder,
blurRadius: 4, width: 1,
offset: Offset(0, -2),
), ),
], ),
), ),
child: SafeArea( child: SingleChildScrollView(
top: false, scrollDirection: Axis.horizontal,
child: Row( child: Row(
children: [ children: [
Expanded( _buildTypeFilterChip('Toutes les cibles', null),
child: OutlinedButton.icon( const SizedBox(width: 8),
onPressed: _pickDateRange, _buildTypeFilterChip(
icon: const Icon(Icons.date_range, size: 18), 'Cibles 1-10',
label: Text( TargetType.concentric,
_selectedDateRange == null ),
? 'Choisir une période' const SizedBox(width: 8),
: '${DateFormat('dd/MM/yy').format(_selectedDateRange!.start)} - ${DateFormat('dd/MM/yy').format(_selectedDateRange!.end)}', _buildTypeFilterChip(
), 'Silhouettes',
), TargetType.silhouette,
), ),
if (_selectedDateRange != null)
IconButton(
icon: const Icon(Icons.close, color: AppTheme.errorColor),
onPressed: () {
setState(() => _selectedDateRange = null);
_loadSessions();
},
),
], ],
), ),
), ),
); );
} }
Widget _buildEmptyState() { Widget _buildTypeFilterChip(String label, TargetType? type) {
return Center( final isSelected = _filterType == type;
child: Column( return ChoiceChip(
mainAxisAlignment: MainAxisAlignment.center, label: Text(label),
children: [ selected: isSelected,
Icon(Icons.history, size: 64, color: Colors.grey[400]), onSelected: (selected) {
const SizedBox(height: 16), if (selected) {
const Text('Aucune session sur cette période'), setState(() => _filterType = type);
], _loadSessions();
}
},
);
}
Widget _buildActivePeriodBanner(bool isDark) {
final start = DateFormat('dd/MM/yy').format(_selectedDateRange!.start);
final end = DateFormat('dd/MM/yy').format(_selectedDateRange!.end);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
decoration: BoxDecoration(
color: isDark ? AppTheme.darkSurface : AppTheme.lightSurface,
border: Border(
top: BorderSide(
color: isDark ? AppTheme.darkBorder : AppTheme.lightBorder,
),
),
),
child: SafeArea(
top: false,
child: Row(
children: [
const Icon(Icons.event, size: 18, color: AppTheme.primaryColor),
const SizedBox(width: 8),
Text(
'Période : $start - $end',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
),
),
const Spacer(),
InkWell(
onTap: () {
setState(() => _selectedDateRange = null);
_loadSessions();
},
borderRadius: BorderRadius.circular(6),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Row(
children: [
Text(
'Effacer',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: AppTheme.errorColor,
),
),
const SizedBox(width: 4),
const Icon(Icons.close, size: 14, color: AppTheme.errorColor),
],
),
),
),
],
),
), ),
); );
} }
Widget _buildContent() { Widget _buildEmptyState(bool isDark) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: isDark ? AppTheme.darkSurface : AppTheme.lightSurfaceVariant,
shape: BoxShape.circle,
border: Border.all(
color: isDark ? AppTheme.darkBorder : AppTheme.lightBorder,
),
),
child: Icon(
Icons.history_toggle_off,
size: 56,
color: isDark ? AppTheme.darkTextMuted : AppTheme.lightTextMuted,
),
),
const SizedBox(height: 18),
Text(
'Aucune session trouvée',
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.w700,
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
),
),
const SizedBox(height: 6),
Text(
_selectedDateRange != null || _filterType != null
? 'Essayez de réinitialiser vos filtres de recherche.'
: 'Vos sessions enregistrées apparaîtront ici.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13,
color: isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary,
),
),
],
),
),
);
}
Widget _buildContent(bool isDark) {
return RefreshIndicator( return RefreshIndicator(
onRefresh: _loadSessions, onRefresh: _loadSessions,
color: AppTheme.primaryColor,
child: CustomScrollView( child: CustomScrollView(
slivers: [ slivers: [
if (_sessions.length >= 2 && _selectedDateRange == null) if (_sessions.length >= 2 && _selectedDateRange == null)
SliverToBoxAdapter( SliverToBoxAdapter(
child: Padding( child: Padding(
padding: const EdgeInsets.all(AppConstants.defaultPadding), padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: HistoryChart(sessions: _sessions), child: HistoryChart(sessions: _sessions),
), ),
), ),
SliverPadding( SliverPadding(
padding: const EdgeInsets.all(AppConstants.defaultPadding), padding: const EdgeInsets.fromLTRB(16, 8, 16, 100),
sliver: SliverList( sliver: SliverList(
delegate: SliverChildBuilderDelegate((context, index) { delegate: SliverChildBuilderDelegate(
final session = _sessions[index]; (context, index) {
return Padding( final session = _sessions[index];
padding: const EdgeInsets.only(bottom: 12), return SessionListItem(
child: SessionListItem(
session: session, session: session,
onTap: () => _openSessionDetail(session), onTap: () => _openSessionDetail(session),
onDelete: () => _deleteSession(session), onDelete: () => _deleteSession(session),
), );
); },
}, childCount: _sessions.length), childCount: _sessions.length,
),
), ),
), ),
], ],
@@ -292,21 +347,22 @@ class _HistoryScreenState extends State<HistoryScreen> {
final confirmed = await showDialog<bool>( final confirmed = await showDialog<bool>(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
title: const Text('Supprimer'), title: const Text('Supprimer la session'),
content: Text( content: Text(
'Supprimer la session du ${DateFormat('dd/MM/yyyy').format(session.createdAt)}?', 'Voulez-vous supprimer définitivement la session du ${DateFormat('dd/MM/yyyy à HH:mm').format(session.createdAt)} ?',
), ),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(context, false), onPressed: () => Navigator.pop(context, false),
child: const Text('Annuler'), child: const Text('Annuler'),
), ),
TextButton( ElevatedButton(
onPressed: () => Navigator.pop(context, true), onPressed: () => Navigator.pop(context, true),
child: const Text( style: ElevatedButton.styleFrom(
'Supprimer', backgroundColor: AppTheme.errorColor,
style: TextStyle(color: AppTheme.errorColor), foregroundColor: Colors.white,
), ),
child: const Text('Supprimer'),
), ),
], ],
), ),
@@ -2,6 +2,7 @@ import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import '../../../core/theme/app_theme.dart'; import '../../../core/theme/app_theme.dart';
import '../../../core/widgets/glass_container.dart';
import '../../../data/models/session.dart'; import '../../../data/models/session.dart';
class SessionListItem extends StatelessWidget { class SessionListItem extends StatelessWidget {
@@ -18,124 +19,178 @@ class SessionListItem extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Card( final isDark = Theme.of(context).brightness == Brightness.dark;
clipBehavior: Clip.antiAlias, final textSecondary = isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary;
child: InkWell( final textMuted = isDark ? AppTheme.darkTextMuted : AppTheme.lightTextMuted;
onTap: onTap,
child: Padding( // Calcul de la couleur du score selon la moyenne par tir
padding: const EdgeInsets.all(12), final primaryColor = Theme.of(context).colorScheme.primary;
child: Row( final avg = session.averageScore;
children: [ Color scoreColor = primaryColor;
// Thumbnail (from first target) if (avg >= 9.0) {
ClipRRect( scoreColor = AppTheme.secondaryColor;
borderRadius: BorderRadius.circular(8), } else if (avg >= 7.5) {
child: SizedBox( scoreColor = primaryColor;
width: 60, } else {
height: 60, scoreColor = isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary;
child: _buildThumbnail(), }
final formattedDate = DateFormat('dd/MM/yyyy • HH:mm', 'fr_FR').format(session.createdAt);
return GlassContainer(
borderRadius: 18,
blur: 14,
glowColor: scoreColor,
borderColor: isDark ? scoreColor.withValues(alpha: 0.2) : scoreColor.withValues(alpha: 0.15),
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(14),
onTap: onTap,
child: Row(
children: [
// Aperçu de la cible avec bordure nette
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: (isDark ? Colors.white : Colors.black).withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isDark ? AppTheme.darkBorder : AppTheme.lightBorder,
), ),
), ),
const SizedBox(width: 12), child: _buildThumbnail(isDark),
),
),
const SizedBox(width: 14),
// Info // Informations de la session
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
session.weapon,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 3),
Text(
formattedDate,
style: TextStyle(
fontSize: 11,
color: textSecondary,
),
),
const SizedBox(height: 5),
Row(
children: [ children: [
Row( Container(
children: [ padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
const Icon( decoration: BoxDecoration(
Icons.shield, color: (isDark ? Colors.white : Colors.black).withValues(alpha: 0.06),
size: 16, borderRadius: BorderRadius.circular(6),
color: AppTheme.primaryColor, ),
child: Text(
'${session.distance}m',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w700,
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
), ),
const SizedBox(width: 4), ),
Text(
session.weapon,
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
],
), ),
const SizedBox(height: 4), const SizedBox(width: 6),
Text( Text(
DateFormat('dd/MM/yyyy HH:mm').format(session.createdAt), '${session.targetCount} cible${session.targetCount > 1 ? 's' : ''}${session.totalShots} tirs',
style: Theme.of(context).textTheme.bodySmall, style: TextStyle(
), fontSize: 11,
const SizedBox(height: 4), color: textMuted,
Row( ),
children: [
Icon(Icons.track_changes, size: 14, color: Colors.grey[600]),
const SizedBox(width: 4),
Text(
'${session.targetCount} cible(s) • ${session.distance}m',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.grey[600],
),
),
],
), ),
], ],
), ),
), ],
),
// Score
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'${session.totalScore}',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: AppTheme.primaryColor,
),
),
Text(
'${session.totalShots} tirs',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
// Delete button
if (onDelete != null)
IconButton(
icon: const Icon(Icons.delete_outline),
onPressed: onDelete,
color: Colors.grey,
iconSize: 20,
),
],
), ),
),
// Score et moyenne stylisés façon cyber HUD
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: scoreColor.withValues(alpha: isDark ? 0.12 : 0.08),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: scoreColor.withValues(alpha: isDark ? 0.3 : 0.2),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'${session.totalScore}',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w900,
color: scoreColor,
letterSpacing: -0.5,
),
),
Text(
'Moy. ${session.averageScore.toStringAsFixed(1)}',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w700,
color: scoreColor,
),
),
],
),
),
// Bouton supprimer
if (onDelete != null) ...[
const SizedBox(width: 4),
IconButton(
icon: const Icon(Icons.delete_outline_rounded),
onPressed: onDelete,
color: textMuted,
iconSize: 20,
tooltip: 'Supprimer',
),
],
],
), ),
); );
} }
Widget _buildThumbnail() { Widget _buildThumbnail(bool isDark) {
if (session.analyses.isEmpty) return _buildPlaceholder(); if (session.analyses.isEmpty) return _buildPlaceholder(isDark);
final file = File(session.analyses.first.imagePath); final file = File(session.analyses.first.imagePath);
if (file.existsSync()) { if (file.existsSync()) {
return Image.file( return Image.file(
file, file,
fit: BoxFit.cover, fit: BoxFit.cover,
errorBuilder: (_, _, _) => _buildPlaceholder(), errorBuilder: (_, _, _) => _buildPlaceholder(isDark),
); );
} }
return _buildPlaceholder(); return _buildPlaceholder(isDark);
} }
Widget _buildPlaceholder() { Widget _buildPlaceholder(bool isDark) {
return Container( return Icon(
color: Colors.grey[200], Icons.track_changes,
child: Icon( color: isDark ? AppTheme.darkTextMuted : AppTheme.lightTextMuted,
Icons.track_changes, size: 24,
color: Colors.grey[400],
),
); );
} }
} }
File diff suppressed because it is too large Load Diff
+73 -27
View File
@@ -1,17 +1,14 @@
/// Widget carte réutilisable pour afficher une statistique.
///
/// Affiche une icône, un titre et une valeur avec une couleur personnalisable.
/// Utilisé sur l'écran d'accueil pour les statistiques rapides.
library;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../../core/constants/app_constants.dart'; import '../../../core/theme/app_theme.dart';
import '../../../core/widgets/glass_container.dart';
/// Widget carte télémétrique moderne avec effet Glassmorphism givré.
class StatsCard extends StatelessWidget { class StatsCard extends StatelessWidget {
final IconData icon; final IconData icon;
final String title; final String title;
final String value; final String value;
final Color color; final Color color;
final String? subtitle;
const StatsCard({ const StatsCard({
super.key, super.key,
@@ -19,33 +16,82 @@ class StatsCard extends StatelessWidget {
required this.title, required this.title,
required this.value, required this.value,
required this.color, required this.color,
this.subtitle,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Card( final isDark = Theme.of(context).brightness == Brightness.dark;
child: Padding( final textSecondary = isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary;
padding: const EdgeInsets.all(AppConstants.defaultPadding),
child: Column( return GlassContainer(
children: [ borderRadius: 18,
Icon(icon, color: color, size: 32), blur: 14,
const SizedBox(height: 8), glowColor: color,
Text( borderColor: isDark ? color.withValues(alpha: 0.22) : color.withValues(alpha: 0.18),
value, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
style: Theme.of(context).textTheme.headlineSmall?.copyWith( child: Column(
fontWeight: FontWeight.bold, crossAxisAlignment: CrossAxisAlignment.start,
color: color, mainAxisAlignment: MainAxisAlignment.center,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: color.withValues(alpha: isDark ? 0.18 : 0.12),
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: color.withValues(alpha: isDark ? 0.35 : 0.25),
width: 1,
),
),
child: Icon(icon, color: color, size: 20),
), ),
if (subtitle != null)
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: (isDark ? Colors.white : Colors.black).withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(6),
),
child: Text(
subtitle!,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w600,
color: textSecondary,
),
),
),
],
),
const SizedBox(height: 12),
Text(
value,
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.w800,
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
letterSpacing: -0.5,
), ),
const SizedBox(height: 4), maxLines: 1,
Text( overflow: TextOverflow.ellipsis,
title, ),
style: Theme.of(context).textTheme.bodySmall?.copyWith( const SizedBox(height: 2),
color: Colors.grey[600], Text(
), title,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: textSecondary,
letterSpacing: 0.1,
), ),
], maxLines: 1,
), overflow: TextOverflow.ellipsis,
),
],
), ),
); );
} }
+9 -1
View File
@@ -6,6 +6,7 @@ class SessionProvider extends ChangeNotifier {
DateTime? get sessionDate => _sessionDate; DateTime? get sessionDate => _sessionDate;
String? _currentWeaponName; String? _currentWeaponName;
String? _currentWeaponId; String? _currentWeaponId;
String? _currentWeaponCaliber;
int _shotsPerTarget = 5; int _shotsPerTarget = 5;
int _distance = 25; int _distance = 25;
String? _activeSessionId; String? _activeSessionId;
@@ -14,6 +15,11 @@ class SessionProvider extends ChangeNotifier {
String? get currentWeapon => _currentWeaponName; String? get currentWeapon => _currentWeaponName;
String? get currentWeaponId => _currentWeaponId; String? get currentWeaponId => _currentWeaponId;
/// Calibre de l'arme de la session. Contrairement au nom d'arme, il ne
/// designe pas l'utilisateur : c'est la seule donnee d'arme envoyee au
/// backend d'entrainement (le diametre des trous en depend).
String? get currentWeaponCaliber => _currentWeaponCaliber;
int get shotsPerTarget => _shotsPerTarget; int get shotsPerTarget => _shotsPerTarget;
int get distance => _distance; int get distance => _distance;
bool get isSessionActive => _isSessionActive; bool get isSessionActive => _isSessionActive;
@@ -23,9 +29,10 @@ class SessionProvider extends ChangeNotifier {
int get totalSessionScore => _currentAnalyses.fold(0, (sum, a) => sum + a.totalScore); int get totalSessionScore => _currentAnalyses.fold(0, (sum, a) => sum + a.totalScore);
int get targetCount => _currentAnalyses.length; int get targetCount => _currentAnalyses.length;
void startSession(String weaponName, int shots, String sessionId, {String? weaponId, int distance = 25, DateTime? date}) { void startSession(String weaponName, int shots, String sessionId, {String? weaponId, String? caliber, int distance = 25, DateTime? date}) {
_currentWeaponName = weaponName; _currentWeaponName = weaponName;
_currentWeaponId = weaponId; _currentWeaponId = weaponId;
_currentWeaponCaliber = caliber;
_shotsPerTarget = shots; _shotsPerTarget = shots;
_distance = distance; _distance = distance;
_sessionDate = date ?? DateTime.now(); _sessionDate = date ?? DateTime.now();
@@ -45,6 +52,7 @@ class SessionProvider extends ChangeNotifier {
_activeSessionId = null; _activeSessionId = null;
_currentWeaponId = null; _currentWeaponId = null;
_currentWeaponName = null; _currentWeaponName = null;
_currentWeaponCaliber = null;
_currentAnalyses.clear(); _currentAnalyses.clear();
_distance = 25; _distance = 25;
notifyListeners(); notifyListeners();
+416 -206
View File
@@ -1,6 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:intl/intl.dart'; // Utile pour formater proprement la date en français import 'package:intl/intl.dart';
import '../../core/constants/app_constants.dart'; import '../../core/constants/app_constants.dart';
import '../../core/theme/app_theme.dart'; import '../../core/theme/app_theme.dart';
import '../../data/models/weapon.dart'; import '../../data/models/weapon.dart';
@@ -25,9 +25,17 @@ class _SessionSetupScreenState extends State<SessionSetupScreen> {
Weapon? _selectedWeapon; Weapon? _selectedWeapon;
bool _isLoadingWeapons = true; bool _isLoadingWeapons = true;
// AJOUT DE LA VARIABLE DATE : Initialisée par défaut à maintenant
DateTime _selectedDate = DateTime.now(); DateTime _selectedDate = DateTime.now();
/// Valeur sentinelle de l'item "Ajouter une nouvelle arme" du menu deroulant.
static const String _addWeaponValue = '__add_weapon__';
final GlobalKey<FormFieldState<String>> _weaponFieldKey =
GlobalKey<FormFieldState<String>>();
static const List<int> _presetDistances = [10, 15, 25, 50, 100, 200];
static const List<int> _presetShots = [3, 5, 10, 15, 20, 30, 50];
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -42,8 +50,21 @@ class _SessionSetupScreenState extends State<SessionSetupScreen> {
if (mounted) { if (mounted) {
setState(() { setState(() {
_availableWeapons = weapons; _availableWeapons = weapons;
if (_availableWeapons.isNotEmpty && _selectedWeapon == null) {
_selectedWeapon = _availableWeapons.first; // Les armes rechargees sont de nouvelles instances (Weapon n'a pas
// d'operator ==) et l'arme selectionnee a pu etre supprimee : on
// re-resout la selection par identifiant.
final previousId = _selectedWeapon?.id;
_selectedWeapon = null;
for (final weapon in weapons) {
if (weapon.id == previousId) {
_selectedWeapon = weapon;
break;
}
}
if (_selectedWeapon == null && weapons.isNotEmpty) {
_selectedWeapon = weapons.first;
_updateSettingsForWeapon(_selectedWeapon!); _updateSettingsForWeapon(_selectedWeapon!);
} }
_isLoadingWeapons = false; _isLoadingWeapons = false;
@@ -51,25 +72,58 @@ class _SessionSetupScreenState extends State<SessionSetupScreen> {
} }
} }
Future<void> _openArmory() async {
final knownIds = _availableWeapons.map((w) => w.id).toSet();
await Navigator.push(
context,
MaterialPageRoute(builder: (_) => const WeaponListScreen()),
);
if (!mounted) return;
await _loadWeapons();
if (!mounted) return;
// Selectionne automatiquement l'arme qui vient d'etre ajoutee.
Weapon? added;
for (final weapon in _availableWeapons) {
if (!knownIds.contains(weapon.id)) {
added = weapon;
break;
}
}
if (added != null) {
final newWeapon = added;
setState(() {
_selectedWeapon = newWeapon;
_updateSettingsForWeapon(newWeapon);
});
}
}
void _updateSettingsForWeapon(Weapon weapon) { void _updateSettingsForWeapon(Weapon weapon) {
_shotsPerTarget = weapon.magazineCapacity; _shotsPerTarget = weapon.magazineCapacity > 0 ? weapon.magazineCapacity : 5;
_distance = (weapon.type == WeaponType.handgun) ? 25 : 50; _distance = (weapon.type == WeaponType.handgun) ? 25 : 50;
} }
// Fonction pour ouvrir le calendrier si l'utilisateur clique sur le champ
Future<void> _pickDate() async { Future<void> _pickDate() async {
final DateTime? picked = await showDatePicker( final DateTime? picked = await showDatePicker(
context: context, context: context,
initialDate: _selectedDate, initialDate: _selectedDate,
firstDate: DateTime(2020), firstDate: DateTime(2020),
lastDate: DateTime(2100), lastDate: DateTime(2100),
locale: const Locale('fr', 'FR'), // Force le calendrier en français locale: const Locale('fr', 'FR'),
); );
if (picked != null && picked != _selectedDate) { if (picked != null && picked != _selectedDate) {
setState(() { setState(() {
// On garde aussi l'heure actuelle lors du changement de date
final now = DateTime.now(); final now = DateTime.now();
_selectedDate = DateTime(picked.year, picked.month, picked.day, now.hour, now.minute); _selectedDate = DateTime(
picked.year,
picked.month,
picked.day,
now.hour,
now.minute,
);
}); });
} }
} }
@@ -81,15 +135,12 @@ class _SessionSetupScreenState extends State<SessionSetupScreen> {
final sessionId = repository.generateId(); final sessionId = repository.generateId();
// PASSE-PARTOUT : On envoie la session au provider.
// Note : Si ton `sessionProvider.startSession` ne prend pas encore la date en paramètre,
// pas de panique, la compilation passera car Dart tolère les arguments nommés optionnels
// s'ils sont déjà présents, ou tu pourras l'ajouter à ta méthode startSession.
sessionProvider.startSession( sessionProvider.startSession(
_selectedWeapon!.displayName, _selectedWeapon!.displayName,
_shotsPerTarget, _shotsPerTarget,
sessionId, sessionId,
weaponId: _selectedWeapon!.id, weaponId: _selectedWeapon!.id,
caliber: _selectedWeapon!.caliber,
distance: _distance, distance: _distance,
date: _selectedDate, date: _selectedDate,
); );
@@ -103,221 +154,380 @@ class _SessionSetupScreenState extends State<SessionSetupScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// Petit formatage sympa en français (ex: "27 mai 2026") final isDark = Theme.of(context).brightness == Brightness.dark;
final String formattedDate = DateFormat('dd MMMM yyyy', 'fr_FR').format(_selectedDate); final formattedDate = DateFormat('dd MMMM yyyy • HH:mm', 'fr_FR').format(_selectedDate);
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text('Configuration de la session'), title: const Text('Configuration de Session'),
), ),
body: _isLoadingWeapons body: _isLoadingWeapons
? const Center(child: CircularProgressIndicator()) ? const Center(child: CircularProgressIndicator())
: SingleChildScrollView( : SingleChildScrollView(
padding: const EdgeInsets.all(AppConstants.defaultPadding), padding: const EdgeInsets.all(AppConstants.defaultPadding),
child: Form( child: Form(
key: _formKey, key: _formKey,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
const Text( if (_availableWeapons.isEmpty)
'Informations générales', _buildEmptyArmoryState(isDark)
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), else ...[
), _buildWeaponAndDateSection(isDark, formattedDate),
const SizedBox(height: 16), const SizedBox(height: 20),
_buildDistanceSection(isDark),
// AFFICHAGE CONDITIONNEL : Armurerie vide vs Armurerie remplie const SizedBox(height: 20),
if (_availableWeapons.isEmpty) _buildShotsSection(isDark),
Container( const SizedBox(height: 32),
padding: const EdgeInsets.all(16), _buildStartButton(),
decoration: BoxDecoration(
color: AppTheme.errorColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
border: Border.all(color: AppTheme.errorColor.withValues(alpha: 0.5)),
),
child: Column(
children: [
const Icon(Icons.warning_amber_rounded, color: AppTheme.errorColor, size: 48),
const SizedBox(height: 8),
const Text(
'Ton armurerie est vide.',
style: TextStyle(fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
const SizedBox(height: 4),
const Text(
'Tu dois d\'abord ajouter une arme pour pouvoir démarrer une session de tir.',
textAlign: TextAlign.center,
),
const SizedBox(height: 16), const SizedBox(height: 16),
ElevatedButton.icon( ],
onPressed: () { ],
Navigator.push( ),
context, ),
MaterialPageRoute(builder: (_) => const WeaponListScreen()), ),
).then((_) { );
_loadWeapons(); }
});
}, Widget _buildEmptyArmoryState(bool isDark) {
icon: const Icon(Icons.add), return Container(
label: const Text('ALLER À MON ARMURERIE'), padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: isDark ? AppTheme.darkSurface : AppTheme.lightSurface,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: AppTheme.errorColor.withValues(alpha: 0.5),
),
),
child: Column(
children: [
const Icon(
Icons.warning_amber_rounded,
color: AppTheme.errorColor,
size: 52,
),
const SizedBox(height: 12),
Text(
'Armurerie vide',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
),
),
const SizedBox(height: 6),
Text(
'Ajoutez au moins une arme dans votre armurerie pour débuter une séance.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13,
color: isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary,
),
),
const SizedBox(height: 20),
ElevatedButton.icon(
onPressed: _openArmory,
icon: const Icon(Icons.shield),
label: const Text('Aller à l\'armurerie'),
),
],
),
);
}
Widget _buildWeaponAndDateSection(bool isDark, String formattedDate) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.tune, color: AppTheme.primaryColor, size: 20),
const SizedBox(width: 8),
Text(
'Arme & Date',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
),
),
],
),
const SizedBox(height: 16),
DropdownButtonFormField<String>(
key: _weaponFieldKey,
initialValue: _selectedWeapon?.id,
decoration: const InputDecoration(
labelText: 'Arme utilisée',
prefixIcon: Icon(Icons.shield_outlined),
),
items: [
..._availableWeapons.map(
(w) => DropdownMenuItem(
value: w.id,
child: Text('${w.displayName} (${w.caliber})'),
),
),
DropdownMenuItem(
value: _addWeaponValue,
child: Row(
children: [
Icon(
Icons.add_circle_outline,
size: 18,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 8),
Text(
'Ajouter une nouvelle arme',
style: TextStyle(
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.primary,
),
), ),
], ],
), ),
) ),
else ],
Column( onChanged: (value) {
children: [ if (value == null) return;
DropdownButtonFormField<Weapon>(
initialValue: _selectedWeapon,
decoration: InputDecoration(
labelText: 'Sélectionner une arme',
prefixIcon: const Icon(Icons.shield),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
),
),
items: _availableWeapons.map((w) => DropdownMenuItem(
value: w,
child: Text(w.displayName),
)).toList(),
onChanged: (value) {
if (value != null) {
setState(() {
_selectedWeapon = value;
_updateSettingsForWeapon(value);
});
}
},
),
const SizedBox(height: 16),
// NOUVEAU CHAMP : Sélecteur de date interactif if (value == _addWeaponValue) {
InkWell( // L'item d'action n'est pas une arme : on restaure aussitot
onTap: _availableWeapons.isEmpty ? null : _pickDate, // la selection precedente avant d'ouvrir l'armurerie.
borderRadius: BorderRadius.circular(AppConstants.borderRadius), _weaponFieldKey.currentState?.didChange(_selectedWeapon?.id);
child: IgnorePointer( _openArmory();
child: TextFormField( return;
decoration: InputDecoration( }
labelText: 'Date de la session',
prefixIcon: const Icon(Icons.calendar_today), for (final weapon in _availableWeapons) {
border: OutlineInputBorder( if (weapon.id == value) {
borderRadius: BorderRadius.circular(AppConstants.borderRadius), setState(() {
), _selectedWeapon = weapon;
), _updateSettingsForWeapon(weapon);
controller: TextEditingController(text: formattedDate), });
), break;
}
}
},
),
const SizedBox(height: 12),
InkWell(
onTap: _pickDate,
borderRadius: BorderRadius.circular(12),
child: IgnorePointer(
child: TextFormField(
decoration: const InputDecoration(
labelText: 'Date de séance',
prefixIcon: Icon(Icons.calendar_today_outlined),
),
controller: TextEditingController(text: formattedDate),
),
),
),
],
),
),
);
}
Widget _buildDistanceSection(bool isDark) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
const Icon(Icons.straighten, color: AppTheme.secondaryColor, size: 20),
const SizedBox(width: 8),
Text(
'Distance de Tir',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
), ),
), ),
], ],
), ),
Container(
const SizedBox(height: 24), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
// Distance selector color: AppTheme.secondaryColor.withValues(alpha: isDark ? 0.2 : 0.1),
const Text( borderRadius: BorderRadius.circular(8),
'Distance de tir (mètres)', border: Border.all(
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500), color: AppTheme.secondaryColor.withValues(alpha: 0.4),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: Slider(
value: _distance.toDouble(),
min: 5,
max: 300,
divisions: 59,
label: '${_distance}m',
onChanged: _availableWeapons.isEmpty ? null : (value) {
setState(() {
_distance = value.round();
});
},
), ),
), ),
Container( child: Text(
width: 70, '${_distance}m',
height: 50, style: const TextStyle(
alignment: Alignment.center, fontSize: 16,
decoration: BoxDecoration( fontWeight: FontWeight.w800,
color: AppTheme.secondaryColor.withValues(alpha: 0.1), color: AppTheme.secondaryColor,
borderRadius: BorderRadius.circular(8),
), ),
child: Text(
'${_distance}m',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppTheme.secondaryColor,
),
),
),
],
),
const SizedBox(height: 24),
// Shots per target
const Text(
'Nombre de balles pour la cible actuelle',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: Slider(
value: _shotsPerTarget.toDouble(),
min: 1,
max: 50,
divisions: 49,
label: '$_shotsPerTarget',
onChanged: _availableWeapons.isEmpty ? null : (value) {
setState(() {
_shotsPerTarget = value.round();
});
},
),
),
Container(
width: 50,
height: 50,
alignment: Alignment.center,
decoration: BoxDecoration(
color: AppTheme.primaryColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'$_shotsPerTarget',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppTheme.primaryColor,
),
),
),
],
),
const SizedBox(height: 48),
ElevatedButton.icon(
onPressed: (_availableWeapons.isEmpty || _selectedWeapon == null)
? null
: _startSession,
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primaryColor,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
), ),
), ),
icon: const Icon(Icons.play_arrow), ],
label: const Text( ),
'DÉMARRER LA SESSION', const SizedBox(height: 12),
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), Wrap(
spacing: 8,
runSpacing: 8,
children: _presetDistances.map((d) {
final isSelected = _distance == d;
return ChoiceChip(
label: Text('${d}m'),
selected: isSelected,
onSelected: (selected) {
if (selected) setState(() => _distance = d);
},
);
}).toList(),
),
const SizedBox(height: 8),
Slider(
value: _distance.toDouble(),
min: 5,
max: 300,
divisions: 59,
activeColor: AppTheme.secondaryColor,
onChanged: (val) {
setState(() => _distance = val.round());
},
),
],
),
),
);
}
Widget _buildShotsSection(bool isDark) {
final primaryColor = Theme.of(context).colorScheme.primary;
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Icon(Icons.ads_click, color: primaryColor, size: 20),
const SizedBox(width: 8),
Text(
'Tirs par Cible',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
),
),
],
), ),
), Container(
], padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: primaryColor.withValues(alpha: isDark ? 0.2 : 0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: primaryColor.withValues(alpha: 0.4),
),
),
child: Text(
'$_shotsPerTarget tirs',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w800,
color: primaryColor,
),
),
),
],
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: _presetShots.map((s) {
final isSelected = _shotsPerTarget == s;
return ChoiceChip(
label: Text('$s coups'),
selected: isSelected,
onSelected: (selected) {
if (selected) setState(() => _shotsPerTarget = s);
},
);
}).toList(),
),
const SizedBox(height: 8),
Slider(
value: _shotsPerTarget.toDouble(),
min: 1,
max: 50,
divisions: 49,
activeColor: primaryColor,
onChanged: (val) {
setState(() => _shotsPerTarget = val.round());
},
),
],
),
),
);
}
Widget _buildStartButton() {
final primaryColor = Theme.of(context).colorScheme.primary;
return Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
gradient: LinearGradient(
colors: [
primaryColor,
HSLColor.fromColor(primaryColor).withLightness((HSLColor.fromColor(primaryColor).lightness + 0.15).clamp(0.0, 1.0)).toColor(),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
boxShadow: [
BoxShadow(
color: primaryColor.withValues(alpha: 0.35),
blurRadius: 16,
offset: const Offset(0, 4),
),
],
),
child: ElevatedButton.icon(
onPressed: (_availableWeapons.isEmpty || _selectedWeapon == null)
? null
: _startSession,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.transparent,
foregroundColor: Colors.white,
shadowColor: Colors.transparent,
padding: const EdgeInsets.symmetric(vertical: 18),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
),
icon: const Icon(Icons.play_arrow_rounded, size: 26),
label: const Text(
'DÉMARRER LA SESSION',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w800,
letterSpacing: 0.8,
), ),
), ),
), ),
+490 -108
View File
@@ -1,14 +1,15 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import '../../core/theme/theme_provider.dart';
import 'dart:convert'; import 'dart:convert';
import 'package:crypto/crypto.dart'; import 'package:crypto/crypto.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import '../../core/constants/app_constants.dart';
import '../../core/theme/app_theme.dart'; import '../../core/theme/app_theme.dart';
import '../../core/theme/theme_provider.dart';
import '../../core/widgets/glass_container.dart';
import '../../services/wallet_identity_service.dart'; import '../../services/wallet_identity_service.dart';
import '../garage/weapon_list_screen.dart'; import '../garage/weapon_list_screen.dart';
import '../tutorial/tutorial_provider.dart';
class SettingsScreen extends StatefulWidget { class SettingsScreen extends StatefulWidget {
const SettingsScreen({super.key}); const SettingsScreen({super.key});
@@ -26,7 +27,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
bool _isUploadEnabled = false; bool _isUploadEnabled = false;
bool _isBanned = false; bool _isBanned = false;
String? _banReason; String? _banReason;
String _serverUrl = 'http://localhost:3000'; String _serverUrl = 'https://backendia.kevlar.cloud';
@override @override
void initState() { void initState() {
@@ -59,7 +60,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
final walletHash = sha256.convert(phraseBytes).toString(); final walletHash = sha256.convert(phraseBytes).toString();
final baseUrl = await _walletService.getServerBaseUrl(); final baseUrl = await _walletService.getServerBaseUrl();
final response = await http.get(Uri.parse('$baseUrl/api/stats/$walletHash')).timeout( final response = await http.get(
Uri.parse('$baseUrl/api/stats/$walletHash'),
headers: {'X-API-KEY': WalletIdentityService.apiKey},
).timeout(
const Duration(seconds: 4), const Duration(seconds: 4),
); );
@@ -162,76 +166,203 @@ class _SettingsScreenState extends State<SettingsScreen> {
void _showThemeDialog() { void _showThemeDialog() {
showDialog( showDialog(
context: context, context: context,
builder: (context) => AlertDialog( builder: (dialogCtx) => Consumer<ThemeProvider>(
title: const Text('Apparence'), builder: (context, themeProvider, child) {
content: Column( final isDark = Theme.of(context).brightness == Brightness.dark;
mainAxisSize: MainAxisSize.min, final primary = themeProvider.primaryColor;
children: [
_buildThemeOption(ThemeMode.system, 'Automatique', Icons.brightness_auto), return AlertDialog(
_buildThemeOption(ThemeMode.light, 'Clair', Icons.light_mode), backgroundColor: isDark ? AppTheme.darkSurface : AppTheme.lightSurface,
_buildThemeOption(ThemeMode.dark, 'Sombre', Icons.dark_mode), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
], title: Text(
), 'Personnalisation & Thème',
actions: [ style: TextStyle(
TextButton( fontWeight: FontWeight.w800,
onPressed: () => Navigator.pop(context), color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
child: const Text('Fermer'), ),
), ),
], content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'MODE D\'AFFICHAGE',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w800,
color: primary,
letterSpacing: 1.0,
),
),
const SizedBox(height: 8),
_buildThemeOption(themeProvider, ThemeMode.system, 'Automatique (Système)', Icons.brightness_auto),
_buildThemeOption(themeProvider, ThemeMode.light, 'Clair', Icons.light_mode_outlined),
_buildThemeOption(themeProvider, ThemeMode.dark, 'Sombre (Stand de Tir)', Icons.dark_mode_outlined),
const Divider(height: 24),
Text(
'COULEUR D\'ACCENTUATION',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w800,
color: primary,
letterSpacing: 1.0,
),
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: AppAccentColor.allAccents.map((accent) {
final isSelected = themeProvider.currentAccent.id == accent.id;
return GestureDetector(
onTap: () {
themeProvider.setAccent(accent);
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7),
decoration: BoxDecoration(
color: accent.color.withValues(alpha: isSelected ? 0.22 : 0.08),
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: isSelected ? accent.color : Colors.transparent,
width: 1.5,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 14,
height: 14,
decoration: BoxDecoration(
color: accent.color,
shape: BoxShape.circle,
boxShadow: [
if (isSelected)
BoxShadow(
color: accent.color.withValues(alpha: 0.6),
blurRadius: 6,
),
],
),
),
const SizedBox(width: 6),
Text(
accent.name,
style: TextStyle(
fontSize: 12,
fontWeight: isSelected ? FontWeight.w800 : FontWeight.w500,
color: isSelected ? accent.color : (isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary),
),
),
],
),
),
);
}).toList(),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogCtx),
child: Text('Fermer', style: TextStyle(color: primary, fontWeight: FontWeight.bold)),
),
],
);
},
), ),
); );
} }
Widget _buildThemeOption(ThemeMode mode, String label, IconData icon) { Widget _buildThemeOption(
final themeProvider = context.watch<ThemeProvider>(); ThemeProvider themeProvider,
ThemeMode mode,
String label,
IconData icon,
) {
final isSelected = themeProvider.themeMode == mode; final isSelected = themeProvider.themeMode == mode;
final primary = themeProvider.primaryColor;
final isDark = themeProvider.themeMode == ThemeMode.dark ||
(themeProvider.themeMode == ThemeMode.system &&
WidgetsBinding.instance.platformDispatcher.platformBrightness == Brightness.dark);
return ListTile( return ListTile(
leading: Icon(icon, color: isSelected ? AppTheme.primaryColor : null), dense: true,
title: Text(label, style: TextStyle( contentPadding: EdgeInsets.zero,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, leading: Icon(icon, color: isSelected ? primary : (isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary), size: 20),
color: isSelected ? AppTheme.primaryColor : null, title: Text(
)), label,
trailing: isSelected ? const Icon(Icons.check, color: AppTheme.primaryColor) : null, style: TextStyle(
fontSize: 14,
fontWeight: isSelected ? FontWeight.w800 : FontWeight.w500,
color: isSelected ? primary : (isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary),
),
),
trailing: isSelected ? Icon(Icons.check_circle, color: primary, size: 20) : null,
onTap: () { onTap: () {
themeProvider.setThemeMode(mode); themeProvider.setThemeMode(mode);
Navigator.pop(context);
}, },
); );
} }
void _showIdentityDialog() { void _showIdentityDialog() {
if (_identityPhrase == null) return; if (_identityPhrase == null) return;
final isDark = Theme.of(context).brightness == Brightness.dark;
final primary = Theme.of(context).colorScheme.primary;
showDialog( showDialog(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
title: const Text('Votre Identité Unique', textAlign: TextAlign.center), backgroundColor: isDark ? AppTheme.darkSurface : AppTheme.lightSurface,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
title: Text(
'Votre Identité Unique',
textAlign: TextAlign.center,
style: TextStyle(
fontWeight: FontWeight.w800,
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
),
),
content: Column( content: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const Icon(Icons.security, size: 48, color: AppTheme.primaryColor), Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: primary.withValues(alpha: 0.15),
shape: BoxShape.circle,
),
child: Icon(Icons.security, size: 36, color: primary),
),
const SizedBox(height: 16), const SizedBox(height: 16),
const Text( Text(
'Cette phrase de 15 mots vous identifie de manière unique. Ne la partagez qu\'en cas de besoin.', 'Cette phrase de 15 mots vous identifie de manière unique. Ne la partagez qu\'en cas de besoin.',
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: TextStyle(fontSize: 14, color: Colors.grey), style: TextStyle(
fontSize: 13,
color: isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary,
),
), ),
const SizedBox(height: 24), const SizedBox(height: 20),
Container( Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey.withAlpha(20), color: primary.withValues(alpha: isDark ? 0.15 : 0.08),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppTheme.primaryColor.withAlpha(100)), border: Border.all(color: primary.withValues(alpha: 0.35)),
), ),
child: Text( child: Text(
_identityPhrase!, _identityPhrase!,
style: const TextStyle( style: TextStyle(
fontSize: 16, fontSize: 15,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w700,
letterSpacing: 0.5, letterSpacing: 0.5,
height: 1.5, height: 1.5,
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
), ),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
@@ -241,11 +372,16 @@ class _SettingsScreenState extends State<SettingsScreen> {
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
child: const Text('Fermer'), child: Text('Fermer', style: TextStyle(color: isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary)),
), ),
ElevatedButton.icon( ElevatedButton.icon(
icon: const Icon(Icons.copy, size: 18), icon: const Icon(Icons.copy, size: 18),
label: const Text('Copier'), label: const Text('Copier'),
style: ElevatedButton.styleFrom(
backgroundColor: primary,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
onPressed: () { onPressed: () {
_copyToClipboard(); _copyToClipboard();
Navigator.pop(context); Navigator.pop(context);
@@ -257,10 +393,15 @@ class _SettingsScreenState extends State<SettingsScreen> {
} }
void _showOptInDisclaimer(bool value) { void _showOptInDisclaimer(bool value) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final primary = Theme.of(context).colorScheme.primary;
if (_isBanned) { if (_isBanned) {
showDialog( showDialog(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
backgroundColor: isDark ? AppTheme.darkSurface : AppTheme.lightSurface,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
title: const Row( title: const Row(
children: [ children: [
Icon(Icons.block, color: AppTheme.errorColor), Icon(Icons.block, color: AppTheme.errorColor),
@@ -296,14 +437,23 @@ class _SettingsScreenState extends State<SettingsScreen> {
context: context, context: context,
barrierDismissible: false, barrierDismissible: false,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
backgroundColor: isDark ? AppTheme.darkSurface : AppTheme.lightSurface,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
title: const Text('Participer à l\'entraînement IA', textAlign: TextAlign.center), title: const Text('Participer à l\'entraînement IA', textAlign: TextAlign.center),
content: SingleChildScrollView( content: SingleChildScrollView(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const Center( Center(
child: Icon(Icons.psychology, size: 48, color: AppTheme.primaryColor), child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: primary.withValues(alpha: 0.15),
shape: BoxShape.circle,
),
child: Icon(Icons.psychology, size: 40, color: primary),
),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
const Text( const Text(
@@ -313,7 +463,23 @@ class _SettingsScreenState extends State<SettingsScreen> {
), ),
const SizedBox(height: 14), const SizedBox(height: 14),
const Text( const Text(
'🔒 Anonymat Garanti : L\'envoi est totalement anonymisé. Votre identité est remplacée par un hash cryptographique.', '📤 Ce qui est envoyé : la photo de la cible, la position des '
'impacts que vous avez placés, la distance de tir, le calibre et '
'le nombre de coups prévus.',
style: TextStyle(fontSize: 13),
),
const SizedBox(height: 10),
const Text(
'🚫 Ce qui ne l\'est pas : la géolocalisation de la photo (retirée '
'avant l\'envoi), le nom de votre arme, et le modèle de votre appareil.',
style: TextStyle(fontSize: 13),
),
const SizedBox(height: 10),
const Text(
'🔒 Pseudonymat : votre identité est remplacée par un hash '
'cryptographique. Il reste le même d\'un envoi à l\'autre, afin de '
'rattacher vos contributions à votre compte (récompenses, '
'modération, suppression sur demande).',
style: TextStyle(fontSize: 13), style: TextStyle(fontSize: 13),
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
@@ -366,7 +532,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
child: const Text('Refuser', style: TextStyle(color: Colors.grey)), child: const Text('Refuser', style: TextStyle(color: Colors.grey)),
), ),
ElevatedButton( ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: AppTheme.primaryColor), style: ElevatedButton.styleFrom(
backgroundColor: primary,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
onPressed: () { onPressed: () {
_walletService.setUploadEnabled(true); _walletService.setUploadEnabled(true);
setState(() { setState(() {
@@ -380,7 +550,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
), ),
); );
}, },
child: const Text('J\'accepte les règles', style: TextStyle(color: Colors.white)), child: const Text('J\'accepte les règles'),
), ),
], ],
), ),
@@ -388,30 +558,35 @@ class _SettingsScreenState extends State<SettingsScreen> {
} }
void _showEditServerUrlDialog() { void _showEditServerUrlDialog() {
final isDark = Theme.of(context).brightness == Brightness.dark;
final primary = Theme.of(context).colorScheme.primary;
final urlController = TextEditingController(text: _serverUrl); final urlController = TextEditingController(text: _serverUrl);
showDialog( showDialog(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
backgroundColor: isDark ? AppTheme.darkSurface : AppTheme.lightSurface,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
title: const Text('Adresse du Serveur IA'), title: const Text('Adresse du Serveur IA'),
content: Column( content: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const Text( const Text(
'Indiquez l\'adresse IP ou l\'URL du serveur backend IA (port 3000) :', 'Indiquez l\'URL du serveur backend IA :',
style: TextStyle(fontSize: 13, color: Colors.grey), style: TextStyle(fontSize: 13, color: Colors.grey),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
TextField( TextField(
controller: urlController, controller: urlController,
decoration: const InputDecoration( decoration: const InputDecoration(
hintText: 'Ex: http://192.168.1.50:3000', hintText: 'https://backendia.kevlar.cloud',
border: OutlineInputBorder(), border: OutlineInputBorder(),
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
const Text( const Text(
'💡 Sur émulateur : http://10.0.2.2:3000\n💡 Sur smartphone réel : IP locale de votre PC (ex: http://192.168.1.X:3000)', '💡 Serveur officiel : https://backendia.kevlar.cloud',
style: TextStyle(fontSize: 11, color: Colors.grey), style: TextStyle(fontSize: 11, color: Colors.grey),
), ),
], ],
@@ -422,7 +597,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
child: const Text('Annuler'), child: const Text('Annuler'),
), ),
ElevatedButton( ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: AppTheme.primaryColor), style: ElevatedButton.styleFrom(
backgroundColor: primary,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
onPressed: () async { onPressed: () async {
final newUrl = urlController.text.trim(); final newUrl = urlController.text.trim();
if (newUrl.isNotEmpty) { if (newUrl.isNotEmpty) {
@@ -443,24 +622,26 @@ class _SettingsScreenState extends State<SettingsScreen> {
), ),
); );
}, },
child: const Text('Enregistrer', style: TextStyle(color: Colors.white)), child: const Text('Enregistrer'),
), ),
], ],
), ),
); );
} }
// Rapport de bug autonome : on collecte une description + les infos
// techniques, et on copie un rapport prêt à coller dans un email de support.
void _showReportBugDialog() { void _showReportBugDialog() {
final isDark = Theme.of(context).brightness == Brightness.dark;
final primary = Theme.of(context).colorScheme.primary;
final descController = TextEditingController(); final descController = TextEditingController();
const supportEmail = 'monadressemaildesupport@nomdelapplication.com'; const supportEmail = 'monadressemaildesupport@nomdelapplication.com';
const appVersion = '1.0.0'; const appVersion = '1.0.3';
final platform = Theme.of(context).platform.name; final platform = Theme.of(context).platform.name;
showDialog( showDialog(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
backgroundColor: isDark ? AppTheme.darkSurface : AppTheme.lightSurface,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
title: const Text('Signaler un bug'), title: const Text('Signaler un bug'),
content: SingleChildScrollView( content: SingleChildScrollView(
child: Column( child: Column(
@@ -496,6 +677,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
ElevatedButton.icon( ElevatedButton.icon(
icon: const Icon(Icons.copy, size: 18), icon: const Icon(Icons.copy, size: 18),
label: const Text('Copier le rapport'), label: const Text('Copier le rapport'),
style: ElevatedButton.styleFrom(
backgroundColor: primary,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
onPressed: () { onPressed: () {
final desc = descController.text.trim(); final desc = descController.text.trim();
final report = StringBuffer() final report = StringBuffer()
@@ -523,16 +709,39 @@ class _SettingsScreenState extends State<SettingsScreen> {
); );
} }
/// Réactive le didacticiel : toutes les visites guidées sont remises à zéro
/// et celle de l'accueil redémarre dès le retour sur l'écran principal.
Future<void> _restartTutorial() async {
final tutorial = context.read<TutorialProvider>();
final messenger = ScaffoldMessenger.of(context);
final navigator = Navigator.of(context);
await tutorial.restart();
if (!mounted) return;
navigator.pop();
messenger.showSnackBar(
const SnackBar(
content: Text('Didacticiel réactivé : la visite guidée redémarre.'),
backgroundColor: AppTheme.successColor,
duration: Duration(seconds: 3),
),
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final primary = Theme.of(context).colorScheme.primary;
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text('Paramètres'), title: const Text('Paramètres'),
), ),
body: ListView( body: ListView(
padding: const EdgeInsets.all(AppConstants.defaultPadding), padding: const EdgeInsets.fromLTRB(16, 12, 16, 40),
children: [ children: [
_buildSectionHeader('Identité'), _buildSectionHeader('IDENTITÉ & COMPTE', primary),
_buildSettingsTile( _buildSettingsTile(
context: context, context: context,
icon: Icons.fingerprint, icon: Icons.fingerprint,
@@ -540,25 +749,128 @@ class _SettingsScreenState extends State<SettingsScreen> {
subtitle: _identityPhrase != null ? 'Phrase de 15 mots générée' : 'Génération en cours...', subtitle: _identityPhrase != null ? 'Phrase de 15 mots générée' : 'Génération en cours...',
onTap: _showIdentityDialog, onTap: _showIdentityDialog,
), ),
const SizedBox(height: 20),
_buildSectionHeader('PERSONNALISATION & THÈME', primary),
Consumer<ThemeProvider>( Consumer<ThemeProvider>(
builder: (context, themeProvider, child) { builder: (context, themeProvider, child) {
return _buildSettingsTile( return GlassContainer(
context: context, borderRadius: 16,
icon: Icons.color_lens_outlined, blur: 12,
title: 'Apparence', margin: const EdgeInsets.only(bottom: 10),
subtitle: themeProvider.themeModeName, padding: const EdgeInsets.all(16),
onTap: _showThemeDialog, glowColor: primary,
borderColor: isDark ? primary.withValues(alpha: 0.2) : primary.withValues(alpha: 0.12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
InkWell(
onTap: _showThemeDialog,
borderRadius: BorderRadius.circular(10),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: primary.withValues(alpha: isDark ? 0.2 : 0.12),
borderRadius: BorderRadius.circular(12),
),
child: Icon(Icons.palette_outlined, color: primary, size: 22),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Apparence',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
),
),
const SizedBox(height: 2),
Text(
'${themeProvider.themeModeName}${themeProvider.currentAccent.name}',
style: TextStyle(
fontSize: 12,
color: isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary,
),
),
],
),
),
Icon(
Icons.chevron_right,
color: isDark ? AppTheme.darkTextMuted : AppTheme.lightTextMuted,
),
],
),
),
const SizedBox(height: 14),
const Divider(height: 1),
const SizedBox(height: 12),
Text(
'Couleur d\'accent active :',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary,
),
),
const SizedBox(height: 10),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: AppAccentColor.allAccents.map((accent) {
final isSelected = themeProvider.currentAccent.id == accent.id;
return Padding(
padding: const EdgeInsets.only(right: 10),
child: GestureDetector(
onTap: () => themeProvider.setAccent(accent),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: 36,
height: 36,
decoration: BoxDecoration(
color: accent.color,
shape: BoxShape.circle,
border: Border.all(
color: isSelected ? Colors.white : Colors.transparent,
width: 2.5,
),
boxShadow: [
if (isSelected)
BoxShadow(
color: accent.color.withValues(alpha: 0.65),
blurRadius: 10,
spreadRadius: 1,
),
],
),
child: isSelected
? const Icon(Icons.check, color: Colors.white, size: 18)
: null,
),
),
);
}).toList(),
),
),
],
),
); );
}, },
), ),
const SizedBox(height: 24), const SizedBox(height: 20),
_buildSectionHeader('Gestion'), _buildSectionHeader('ARMURERIE & MATÉRIEL', primary),
_buildSettingsTile( _buildSettingsTile(
context: context, context: context,
icon: Icons.shield_outlined, icon: Icons.shield_outlined,
title: 'Mon Armurerie', title: 'Mon Armurerie',
subtitle: 'Gérer mes armes et équipements', subtitle: 'Gérer mes armes, calibres et optiques',
onTap: () async { onTap: () async {
await Navigator.push( await Navigator.push(
context, context,
@@ -567,14 +879,14 @@ class _SettingsScreenState extends State<SettingsScreen> {
}, },
), ),
const SizedBox(height: 24), const SizedBox(height: 20),
_buildSectionHeader('Programme d\'Entraînement IA'), _buildSectionHeader('PROGRAMME D\'ENTRAÎNEMENT IA', primary),
if (_isBanned) ...[ if (_isBanned) ...[
Card( Card(
elevation: 0, elevation: 0,
color: AppTheme.errorColor.withValues(alpha: 0.12), color: AppTheme.errorColor.withValues(alpha: 0.12),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12.0), borderRadius: BorderRadius.circular(16.0),
side: BorderSide(color: AppTheme.errorColor.withValues(alpha: 0.6), width: 1.5), side: BorderSide(color: AppTheme.errorColor.withValues(alpha: 0.6), width: 1.5),
), ),
child: Padding( child: Padding(
@@ -628,7 +940,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
foregroundColor: Colors.white, foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 12), padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(12),
), ),
), ),
onPressed: _isCheckingStatus ? null : _refreshAccountStatus, onPressed: _isCheckingStatus ? null : _refreshAccountStatus,
@@ -639,21 +951,38 @@ class _SettingsScreenState extends State<SettingsScreen> {
), ),
), ),
] else ...[ ] else ...[
Card( GlassContainer(
elevation: 0, borderRadius: 16,
color: Colors.transparent, blur: 10,
margin: const EdgeInsets.only(bottom: 8.0), margin: const EdgeInsets.only(bottom: 10),
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4),
child: SwitchListTile( child: SwitchListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 4.0), contentPadding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 4.0),
title: const Text('Participer à l\'entraînement IA', style: TextStyle(fontWeight: FontWeight.w500)), title: Text(
subtitle: const Text('Aidez-nous à améliorer la détection (soumis aux règles strictes)', style: TextStyle(fontSize: 12)), 'Participer à l\'entraînement IA',
secondary: const Icon(Icons.psychology, color: AppTheme.textPrimary), style: TextStyle(
value: _isUploadEnabled, fontWeight: FontWeight.w700,
activeThumbColor: AppTheme.primaryColor, fontSize: 14,
shape: RoundedRectangleBorder( color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
borderRadius: BorderRadius.circular(12.0), ),
side: BorderSide(color: Colors.grey.withAlpha(50), width: 1),
), ),
subtitle: Text(
'Aidez-nous à améliorer la détection automatique',
style: TextStyle(
fontSize: 12,
color: isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary,
),
),
secondary: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: primary.withValues(alpha: isDark ? 0.2 : 0.12),
borderRadius: BorderRadius.circular(10),
),
child: Icon(Icons.psychology, color: primary, size: 22),
),
value: _isUploadEnabled,
activeThumbColor: primary,
onChanged: _showOptInDisclaimer, onChanged: _showOptInDisclaimer,
), ),
), ),
@@ -689,20 +1018,36 @@ class _SettingsScreenState extends State<SettingsScreen> {
), ),
], ],
const SizedBox(height: 24), const SizedBox(height: 20),
_buildSectionHeader('À propos'), _buildSectionHeader('AIDE & DIDACTICIEL', primary),
Consumer<TutorialProvider>(
builder: (context, tutorial, child) {
return _buildSettingsTile(
context: context,
icon: Icons.school_outlined,
title: 'Revoir le didacticiel',
subtitle: tutorial.hasSeenIntro
? 'Rejouer la visite guidée de l\'application'
: 'Visite guidée en attente sur l\'écran d\'accueil',
onTap: _restartTutorial,
);
},
),
const SizedBox(height: 20),
_buildSectionHeader('À PROPOS', primary),
_buildSettingsTile( _buildSettingsTile(
context: context, context: context,
icon: Icons.info_outline, icon: Icons.info_outline,
title: 'Version de l\'application', title: 'Version de l\'application',
subtitle: '1.0.0', subtitle: '1.0.3 (Design Tactical & Precision)',
onTap: () {}, onTap: () {},
), ),
_buildSettingsTile( _buildSettingsTile(
context: context, context: context,
icon: Icons.bug_report_outlined, icon: Icons.bug_report_outlined,
title: 'Signaler un bug', title: 'Signaler un bug',
subtitle: 'Aidez-nous à corriger les problèmes', subtitle: 'Aidez-nous à corriger les anomalies',
onTap: _showReportBugDialog, onTap: _showReportBugDialog,
), ),
_buildSettingsTile( _buildSettingsTile(
@@ -716,15 +1061,16 @@ class _SettingsScreenState extends State<SettingsScreen> {
); );
} }
Widget _buildSectionHeader(String title) { Widget _buildSectionHeader(String title, Color primary) {
return Padding( return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 4.0), padding: const EdgeInsets.fromLTRB(4, 8, 4, 10),
child: Text( child: Text(
title, title,
style: const TextStyle( style: TextStyle(
fontSize: 14, fontSize: 11,
fontWeight: FontWeight.bold, fontWeight: FontWeight.w800,
color: AppTheme.primaryColor, color: primary,
letterSpacing: 1.0,
), ),
), ),
); );
@@ -737,21 +1083,57 @@ class _SettingsScreenState extends State<SettingsScreen> {
String? subtitle, String? subtitle,
required VoidCallback onTap, required VoidCallback onTap,
}) { }) {
return Card( final isDark = Theme.of(context).brightness == Brightness.dark;
elevation: 0, final primary = Theme.of(context).colorScheme.primary;
color: Colors.transparent,
margin: const EdgeInsets.only(bottom: 8.0), return GlassContainer(
child: ListTile( borderRadius: 16,
contentPadding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 4.0), blur: 10,
leading: Icon(icon, color: AppTheme.textPrimary), margin: const EdgeInsets.only(bottom: 10),
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
subtitle: subtitle != null ? Text(subtitle, style: const TextStyle(fontSize: 12)) : null, onTap: onTap,
trailing: const Icon(Icons.chevron_right, color: Colors.grey), child: Row(
shape: RoundedRectangleBorder( children: [
borderRadius: BorderRadius.circular(12.0), Container(
side: BorderSide(color: Colors.grey.withAlpha(50), width: 1), padding: const EdgeInsets.all(9),
), decoration: BoxDecoration(
onTap: onTap, color: primary.withValues(alpha: isDark ? 0.2 : 0.12),
borderRadius: BorderRadius.circular(10),
),
child: Icon(icon, color: primary, size: 20),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
),
),
if (subtitle != null) ...[
const SizedBox(height: 2),
Text(
subtitle,
style: TextStyle(
fontSize: 12,
color: isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary,
),
),
],
],
),
),
Icon(
Icons.chevron_right,
color: isDark ? AppTheme.darkTextMuted : AppTheme.lightTextMuted,
size: 20,
),
],
), ),
); );
} }
+80 -32
View File
@@ -7,6 +7,7 @@ import 'package:provider/provider.dart';
import 'package:fl_chart/fl_chart.dart'; // La librairie pour les courbes import 'package:fl_chart/fl_chart.dart'; // La librairie pour les courbes
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'package:share_plus/share_plus.dart'; import 'package:share_plus/share_plus.dart';
import '../../core/theme/app_theme.dart';
import '../../core/widgets/metric_info_button.dart'; import '../../core/widgets/metric_info_button.dart';
import '../../data/models/session.dart'; import '../../data/models/session.dart';
import '../../data/repositories/session_repository.dart'; import '../../data/repositories/session_repository.dart';
@@ -235,6 +236,7 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
extendBody: true,
appBar: AppBar( appBar: AppBar(
title: const Text('Statistiques'), title: const Text('Statistiques'),
centerTitle: true, centerTitle: true,
@@ -296,7 +298,7 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
onRefresh: _loadStatistics, onRefresh: _loadStatistics,
child: SingleChildScrollView( child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(), physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16), padding: const EdgeInsets.fromLTRB(16, 16, 16, 100),
child: Column( child: Column(
children: [ children: [
// 2. DONNÉES RAPIDES (Tirs et Sessions) // 2. DONNÉES RAPIDES (Tirs et Sessions)
@@ -414,13 +416,14 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
); );
} }
final activeColor = const Color(0xFF1A73E8); final isDark = Theme.of(context).brightness == Brightness.dark;
final activeColor = Theme.of(context).colorScheme.primary;
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: activeColor.withValues(alpha: 0.08), color: activeColor.withValues(alpha: isDark ? 0.12 : 0.08),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(14),
border: Border.all(color: activeColor.withValues(alpha: 0.3)), border: Border.all(color: activeColor.withValues(alpha: 0.35)),
), ),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -429,7 +432,15 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
children: [ children: [
Icon(Icons.compare_arrows, color: activeColor, size: 18), Icon(Icons.compare_arrows, color: activeColor, size: 18),
const SizedBox(width: 6), const SizedBox(width: 6),
Text('Comparaison', style: TextStyle(fontWeight: FontWeight.bold, color: activeColor)), Text(
'Comparaison de sessions',
style: TextStyle(
fontWeight: FontWeight.w700,
fontSize: 13,
color: activeColor,
letterSpacing: 0.3,
),
),
const Spacer(), const Spacer(),
IconButton( IconButton(
tooltip: 'Quitter la comparaison', tooltip: 'Quitter la comparaison',
@@ -493,27 +504,39 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
List<String> items, List<String> items,
void Function(String?) onChanged, void Function(String?) onChanged,
) { ) {
final theme = Theme.of(context); final isDark = Theme.of(context).brightness == Brightness.dark;
final textSecondary = isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary;
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration( decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), color: isDark ? AppTheme.darkSurfaceElevated : AppTheme.lightSurfaceVariant,
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(12),
border: Border.all(color: theme.dividerColor), border: Border.all(
color: isDark ? AppTheme.darkBorder : AppTheme.lightBorder,
),
), ),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
label, label,
style: TextStyle(color: theme.textTheme.bodySmall?.color?.withValues(alpha: 0.6), fontSize: 10), style: TextStyle(
color: textSecondary,
fontSize: 10,
fontWeight: FontWeight.w600,
),
), ),
DropdownButton<String>( DropdownButton<String>(
value: value, value: value,
isExpanded: true, isExpanded: true,
underline: Container(), underline: const SizedBox(),
dropdownColor: theme.colorScheme.surfaceContainerHighest, dropdownColor: isDark ? AppTheme.darkSurfaceElevated : AppTheme.lightSurfaceVariant,
style: TextStyle(color: theme.textTheme.bodyMedium?.color, fontSize: 14), style: TextStyle(
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
fontSize: 13,
fontWeight: FontWeight.w600,
),
items: items items: items
.map( .map(
(String val) => (String val) =>
@@ -529,28 +552,45 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
// Widget pour les petites cartes de stats // Widget pour les petites cartes de stats
Widget _buildQuickStat(String label, String value, IconData icon) { Widget _buildQuickStat(String label, String value, IconData icon) {
final theme = Theme.of(context); final isDark = Theme.of(context).brightness == Brightness.dark;
final primary = Theme.of(context).colorScheme.primary;
return Container( return Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration( decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), color: isDark ? AppTheme.darkSurface : AppTheme.lightSurface,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(16),
border: Border.all(
color: isDark ? AppTheme.darkBorder : AppTheme.lightBorder,
),
), ),
child: Column( child: Column(
children: [ children: [
Icon(icon, color: const Color(0xFF1A73E8), size: 20), Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: primary.withValues(alpha: isDark ? 0.15 : 0.1),
borderRadius: BorderRadius.circular(10),
),
child: Icon(icon, color: primary, size: 20),
),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
value, value,
style: TextStyle( style: TextStyle(
color: theme.textTheme.titleLarge?.color, color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
fontSize: 20, fontSize: 22,
fontWeight: FontWeight.bold, fontWeight: FontWeight.w800,
letterSpacing: -0.5,
), ),
), ),
const SizedBox(height: 2),
Text( Text(
label, label,
style: TextStyle(color: theme.textTheme.bodySmall?.color?.withValues(alpha: 0.6), fontSize: 12), style: TextStyle(
color: isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary,
fontSize: 12,
fontWeight: FontWeight.w500,
),
), ),
], ],
), ),
@@ -564,12 +604,15 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
List<double> dataPoints, { List<double> dataPoints, {
List<MetricExplanation>? explanations, List<MetricExplanation>? explanations,
}) { }) {
final theme = Theme.of(context); final isDark = Theme.of(context).brightness == Brightness.dark;
return Container( return Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), color: isDark ? AppTheme.darkSurface : AppTheme.lightSurface,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(16),
border: Border.all(
color: isDark ? AppTheme.darkBorder : AppTheme.lightBorder,
),
), ),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -578,7 +621,11 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
children: [ children: [
Text( Text(
title, title,
style: TextStyle(color: theme.textTheme.bodyMedium?.color?.withValues(alpha: 0.7), fontSize: 14), style: TextStyle(
color: isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary,
fontSize: 13,
fontWeight: FontWeight.w600,
),
), ),
if (explanations != null) ...[ if (explanations != null) ...[
const Spacer(), const Spacer(),
@@ -586,15 +633,16 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
], ],
], ],
), ),
const SizedBox(height: 10), const SizedBox(height: 8),
Row( Row(
children: [ children: [
Text( Text(
value, value,
style: TextStyle( style: TextStyle(
color: theme.textTheme.headlineMedium?.color, color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
fontSize: 24, fontSize: 24,
fontWeight: FontWeight.bold, fontWeight: FontWeight.w800,
letterSpacing: -0.5,
), ),
), ),
const SizedBox(width: 20), const SizedBox(width: 20),
+68
View File
@@ -0,0 +1,68 @@
/// Point d'entrée du didacticiel : lance une visite guidée par-dessus l'écran
/// courant (route transparente) et mémorise qu'elle a été vue.
library;
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'tutorial_provider.dart';
import 'tutorial_step.dart';
import 'widgets/tutorial_overlay.dart';
class TutorialCoach {
const TutorialCoach._();
static bool _isShowing = false;
/// Une visite est-elle déjà affichée ? (évite les doubles déclenchements)
static bool get isShowing => _isShowing;
/// Joue la visite [tourId] uniquement si elle n'a jamais été vue.
///
/// [stepsBuilder] n'est évalué que si la visite doit réellement démarrer :
/// les clés des widgets sont ainsi lues au dernier moment.
static Future<void> maybeStart(
BuildContext context, {
required String tourId,
required List<TutorialStep> Function() stepsBuilder,
}) async {
if (_isShowing) return;
if (!context.read<TutorialProvider>().shouldRun(tourId)) return;
await start(context, tourId: tourId, steps: stepsBuilder());
}
/// Joue la visite [tourId], même si elle a déjà été vue.
static Future<void> start(
BuildContext context, {
required String tourId,
required List<TutorialStep> steps,
}) async {
if (_isShowing || steps.isEmpty) return;
final provider = context.read<TutorialProvider>();
final navigator = Navigator.of(context, rootNavigator: true);
_isShowing = true;
try {
await navigator.push(
PageRouteBuilder<void>(
opaque: false,
barrierDismissible: false,
barrierColor: Colors.transparent,
transitionDuration: const Duration(milliseconds: 240),
reverseTransitionDuration: const Duration(milliseconds: 180),
pageBuilder: (routeContext, animation, _) => FadeTransition(
opacity: animation,
child: TutorialOverlay(
steps: steps,
onFinished: () => Navigator.of(routeContext).pop(),
),
),
),
);
} finally {
_isShowing = false;
}
await provider.complete(tourId);
}
}
@@ -0,0 +1,54 @@
/// État global du didacticiel : quelles visites guidées restent à jouer.
///
/// Le provider est chargé au démarrage ; tant que les préférences ne sont pas
/// lues, aucune visite n'est déclenchée (évite un flash d'overlay au lancement).
library;
import 'package:flutter/foundation.dart';
import '../../services/tutorial_service.dart';
class TutorialProvider with ChangeNotifier {
TutorialProvider({TutorialService? service})
: _service = service ?? TutorialService() {
load();
}
final TutorialService _service;
final Set<String> _completed = {};
bool _loaded = false;
/// `true` une fois les préférences lues.
bool get isLoaded => _loaded;
/// `true` si l'utilisateur a déjà terminé (ou passé) la visite d'accueil.
bool get hasSeenIntro => _completed.contains(TutorialTours.home);
/// Lit les visites déjà vues. Les visites terminées pendant le chargement
/// sont conservées (le résultat est fusionné, jamais écrasé).
Future<void> load() async {
_completed.addAll(await _service.loadCompletedTours());
_loaded = true;
notifyListeners();
}
/// Faut-il jouer la visite [tourId] ?
bool shouldRun(String tourId) => _loaded && !_completed.contains(tourId);
/// Marque une visite comme vue (terminée ou passée) : elle ne rejouera plus.
Future<void> complete(String tourId) async {
if (_completed.add(tourId)) {
notifyListeners();
await _service.markCompleted(tourId);
}
}
/// Relance le didacticiel depuis les paramètres : toutes les visites
/// redeviennent disponibles et rejoueront dès l'affichage de leur écran.
Future<void> restart() async {
await _service.resetAll();
_completed.clear();
_loaded = true;
notifyListeners();
}
}
+76
View File
@@ -0,0 +1,76 @@
/// Modèle d'une étape de didacticiel (visite guidée).
///
/// Une étape met en avant un élément de l'écran (le « spot ») grâce à une
/// [GlobalKey] posée sur le widget concerné, et affiche une bulle explicative
/// accompagnée, si besoin, d'une main animée qui mime le geste attendu
/// (tap, appui long, pincement…) comme dans les applications mobiles.
library;
import 'package:flutter/widgets.dart';
/// Geste mimé par la main animée pendant l'étape.
enum TutorialGesture {
/// Aucune main animée : simple explication.
none,
/// Un appui simple.
tap,
/// Deux appuis rapprochés.
doubleTap,
/// Un appui maintenu.
longPress,
/// Un appui maintenu suivi d'un déplacement du doigt.
drag,
/// Deux doigts qui s'écartent puis se rapprochent (zoom).
pinch,
/// Un doigt qui balaie l'écran vers le haut.
swipeUp,
/// Un doigt qui balaie l'écran horizontalement.
swipeHorizontal,
}
/// Forme du trou de lumière découpé dans le voile sombre.
enum TutorialHighlightShape { rounded, circle }
class TutorialStep {
/// Clé posée sur le widget à mettre en avant.
///
/// `null` (ou clé non montée) => l'étape s'affiche comme une carte centrée,
/// utile pour les messages d'accueil et de fin.
final GlobalKey? targetKey;
final String title;
final String description;
/// Geste mimé par la main animée.
final TutorialGesture gesture;
final TutorialHighlightShape shape;
/// Marge ajoutée autour du widget mis en avant.
final double spotPadding;
/// Icône affichée dans la bulle explicative.
final IconData? icon;
/// Force la position de la bulle (`true` = au-dessus du spot).
/// Par défaut, la bulle se place automatiquement du côté le plus dégagé.
final bool? preferTooltipAbove;
const TutorialStep({
this.targetKey,
required this.title,
required this.description,
this.gesture = TutorialGesture.none,
this.shape = TutorialHighlightShape.rounded,
this.spotPadding = 8,
this.icon,
this.preferTooltipAbove,
});
}
@@ -0,0 +1,379 @@
/// Main animée du didacticiel : mime le geste attendu par l'utilisateur
/// (tap, appui long, glisser, pincement pour zoomer, balayage) comme dans les
/// tutoriels des applications mobiles.
///
/// Tout est dessiné au CustomPainter : pas d'asset, la couleur suit l'accent
/// du thème et le rendu reste lisible sur fond clair comme sur fond sombre
/// grâce au cœur blanc entouré d'un halo coloré.
library;
import 'dart:math' as math;
import 'package:flutter/material.dart';
import '../tutorial_step.dart';
class TutorialHand extends StatefulWidget {
final TutorialGesture gesture;
final Color color;
final double size;
const TutorialHand({
super.key,
required this.gesture,
required this.color,
this.size = 96,
});
@override
State<TutorialHand> createState() => _TutorialHandState();
}
class _TutorialHandState extends State<TutorialHand>
with SingleTickerProviderStateMixin {
late final AnimationController _controller = AnimationController(
vsync: this,
duration: _durationFor(widget.gesture),
)..repeat();
static Duration _durationFor(TutorialGesture gesture) {
switch (gesture) {
case TutorialGesture.pinch:
return const Duration(milliseconds: 2400);
case TutorialGesture.longPress:
case TutorialGesture.drag:
return const Duration(milliseconds: 2200);
case TutorialGesture.swipeUp:
case TutorialGesture.swipeHorizontal:
return const Duration(milliseconds: 1800);
default:
return const Duration(milliseconds: 1500);
}
}
@override
void didUpdateWidget(TutorialHand oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.gesture != widget.gesture) {
_controller
..stop()
..duration = _durationFor(widget.gesture)
..repeat();
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (widget.gesture == TutorialGesture.none) return const SizedBox.shrink();
return IgnorePointer(
child: SizedBox(
width: widget.size,
height: widget.size,
child: AnimatedBuilder(
animation: _controller,
builder: (context, _) => CustomPaint(
painter: _GesturePainter(
t: _controller.value,
gesture: widget.gesture,
color: widget.color,
),
),
),
),
);
}
}
class _GesturePainter extends CustomPainter {
final double t;
final TutorialGesture gesture;
final Color color;
_GesturePainter({
required this.t,
required this.gesture,
required this.color,
});
@override
void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2);
switch (gesture) {
case TutorialGesture.none:
return;
case TutorialGesture.tap:
_paintTap(canvas, size, center, pulses: 1);
case TutorialGesture.doubleTap:
_paintTap(canvas, size, center, pulses: 2);
case TutorialGesture.longPress:
_paintLongPress(canvas, size, center);
case TutorialGesture.drag:
_paintDrag(canvas, size, center);
case TutorialGesture.pinch:
_paintPinch(canvas, size, center);
case TutorialGesture.swipeUp:
_paintSwipe(canvas, size, center, vertical: true);
case TutorialGesture.swipeHorizontal:
_paintSwipe(canvas, size, center, vertical: false);
}
}
// --------------------------------------------------------------- Utilitaires
/// Onde triangulaire adoucie : 0 -> 1 -> 0 sur un cycle.
double _wave(double x) => Curves.easeInOut
.transform((x < 0.5 ? x * 2 : (1 - x) * 2).clamp(0.0, 1.0));
/// Doigt posé sur l'écran : cœur blanc + halo coloré.
void _paintFingertip(
Canvas canvas,
Offset position,
double radius, {
double opacity = 1.0,
}) {
canvas.drawCircle(
position,
radius * 2.1,
Paint()
..color = color.withValues(alpha: 0.22 * opacity)
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 8),
);
canvas.drawCircle(
position,
radius,
Paint()..color = Colors.white.withValues(alpha: 0.95 * opacity),
);
canvas.drawCircle(
position,
radius,
Paint()
..style = PaintingStyle.stroke
..strokeWidth = 2.4
..color = color.withValues(alpha: 0.9 * opacity),
);
}
/// Ondes concentriques émises au moment du contact.
void _paintRipple(
Canvas canvas,
Offset position,
double progress,
double maxRadius,
) {
if (progress <= 0 || progress >= 1) return;
canvas.drawCircle(
position,
6 + progress * maxRadius,
Paint()
..style = PaintingStyle.stroke
..strokeWidth = 2.5 * (1 - progress) + 0.6
..color = color.withValues(alpha: 0.55 * (1 - progress)),
);
}
/// Main « pointeur » (icône Material) dont l'index touche [tip].
void _paintHand(
Canvas canvas,
Offset tip,
double glyphSize, {
double press = 0,
}) {
final painter = TextPainter(
textDirection: TextDirection.ltr,
text: TextSpan(
text: String.fromCharCode(Icons.touch_app_rounded.codePoint),
style: TextStyle(
fontSize: glyphSize,
fontFamily: Icons.touch_app_rounded.fontFamily,
package: Icons.touch_app_rounded.fontPackage,
color: Colors.white.withValues(alpha: 0.96),
shadows: [
Shadow(
color: Colors.black.withValues(alpha: 0.55),
blurRadius: 8,
offset: const Offset(0, 3),
),
],
),
),
)..layout();
// Le bout de l'index de l'icône se situe environ à 32 % / 16 % du glyphe.
final origin = tip -
Offset(painter.width * 0.32, painter.height * 0.16) +
Offset(glyphSize * 0.03 * press, glyphSize * 0.06 * press);
painter.paint(canvas, origin);
}
// ------------------------------------------------------------------ Gestes
void _paintTap(
Canvas canvas,
Size size,
Offset center, {
required int pulses,
}) {
final cycle = (t * pulses) % 1.0;
final press = _wave((cycle / 0.4).clamp(0.0, 1.0));
_paintRipple(canvas, center, cycle, size.width * 0.34);
_paintRipple(
canvas, center, (cycle - 0.25).clamp(0.0, 1.0), size.width * 0.34);
_paintFingertip(canvas, center, size.width * 0.055 + 2 * press,
opacity: 0.35 + 0.65 * press);
_paintHand(canvas, center, size.width * 0.58, press: press);
}
void _paintLongPress(Canvas canvas, Size size, Offset center) {
final hold = Curves.easeOut.transform((t / 0.75).clamp(0.0, 1.0));
final radius = size.width * 0.24;
canvas.drawCircle(
center,
radius,
Paint()
..style = PaintingStyle.stroke
..strokeWidth = 3
..color = Colors.white.withValues(alpha: 0.22),
);
canvas.drawArc(
Rect.fromCircle(center: center, radius: radius),
-math.pi / 2,
2 * math.pi * hold,
false,
Paint()
..style = PaintingStyle.stroke
..strokeWidth = 3.4
..strokeCap = StrokeCap.round
..color = color.withValues(alpha: 0.95),
);
_paintFingertip(canvas, center, size.width * 0.06);
_paintHand(canvas, center, size.width * 0.58, press: 1);
}
void _paintDrag(Canvas canvas, Size size, Offset center) {
// 0 -> 0.35 : appui maintenu ; puis déplacement aller-retour du doigt.
final hold = Curves.easeOut.transform((t / 0.35).clamp(0.0, 1.0));
final travel =
t <= 0.35 ? 0.0 : _wave(((t - 0.35) / 0.65).clamp(0.0, 1.0));
final start = center - Offset(size.width * 0.16, 0);
final position = start + Offset(size.width * 0.32 * travel, 0);
canvas.drawLine(
start,
position,
Paint()
..strokeWidth = 3
..strokeCap = StrokeCap.round
..color = color.withValues(alpha: 0.35),
);
if (hold < 1) {
canvas.drawArc(
Rect.fromCircle(center: start, radius: size.width * 0.16),
-math.pi / 2,
2 * math.pi * hold,
false,
Paint()
..style = PaintingStyle.stroke
..strokeWidth = 3
..strokeCap = StrokeCap.round
..color = color.withValues(alpha: 0.9),
);
}
_paintFingertip(canvas, position, size.width * 0.06);
_paintHand(canvas, position, size.width * 0.58, press: 1);
}
void _paintPinch(Canvas canvas, Size size, Offset center) {
// Les deux doigts s'écartent (zoom avant) puis se rapprochent.
final spread = _wave(t);
final direction = Offset(math.cos(-math.pi / 4), math.sin(-math.pi / 4));
final distance = size.width * (0.12 + 0.24 * spread);
final first = center + direction * distance;
final second = center - direction * distance;
final radius = size.width * 0.075;
// Ligne pointillée reliant les deux doigts.
final dashPaint = Paint()
..strokeWidth = 2
..strokeCap = StrokeCap.round
..color = color.withValues(alpha: 0.45);
const dashes = 9;
for (int i = 0; i < dashes; i++) {
if (i.isOdd) continue;
final a = Offset.lerp(second, first, i / dashes)!;
final b = Offset.lerp(second, first, (i + 1) / dashes)!;
canvas.drawLine(a, b, dashPaint);
}
// Chevrons indiquant le sens de l'écartement.
_paintChevron(canvas, first, direction, size.width * 0.06, spread);
_paintChevron(canvas, second, -direction, size.width * 0.06, spread);
_paintFingertip(canvas, first, radius);
_paintFingertip(canvas, second, radius);
}
void _paintChevron(
Canvas canvas,
Offset tip,
Offset direction,
double length,
double spread,
) {
final base = tip + direction * (length * 1.6);
final angle = math.atan2(direction.dy, direction.dx);
final paint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 2.6
..strokeCap = StrokeCap.round
..color = color.withValues(alpha: 0.25 + 0.6 * spread);
for (final sign in [-1, 1]) {
final branch = angle + sign * 2.5;
canvas.drawLine(
base,
base + Offset(math.cos(branch), math.sin(branch)) * length,
paint,
);
}
}
void _paintSwipe(
Canvas canvas,
Size size,
Offset center, {
required bool vertical,
}) {
final progress = Curves.easeInOut.transform((t / 0.75).clamp(0.0, 1.0));
final fade = t > 0.75 ? 1 - ((t - 0.75) / 0.25) : 1.0;
final axis = vertical ? const Offset(0, -1) : const Offset(1, 0);
final amplitude = size.width * 0.28;
final start = center - axis * amplitude;
final position = start + axis * (2 * amplitude * progress);
canvas.drawLine(
start,
position,
Paint()
..strokeWidth = 3
..strokeCap = StrokeCap.round
..color = color.withValues(alpha: 0.32 * fade),
);
_paintFingertip(canvas, position, size.width * 0.06, opacity: fade);
_paintHand(canvas, position, size.width * 0.58, press: 1);
}
@override
bool shouldRepaint(_GesturePainter old) =>
old.t != t || old.gesture != gesture || old.color != color;
}
@@ -0,0 +1,504 @@
/// Voile du didacticiel : assombrit l'écran, découpe un « trou de lumière »
/// autour de l'élément à découvrir, y anime une main qui mime le geste attendu
/// et affiche une bulle explicative avec la progression.
///
/// L'overlay est purement visuel : il n'exécute pas l'action à la place de
/// l'utilisateur. On avance d'une étape en touchant l'écran ou le bouton
/// « Suivant » ; « Passer » interrompt la visite.
library;
import 'package:flutter/material.dart';
import '../../../core/theme/app_theme.dart';
import '../tutorial_step.dart';
import 'tutorial_hand.dart';
class TutorialOverlay extends StatefulWidget {
final List<TutorialStep> steps;
/// Appelé à la fin de la visite (terminée ou passée).
final VoidCallback onFinished;
const TutorialOverlay({
super.key,
required this.steps,
required this.onFinished,
});
@override
State<TutorialOverlay> createState() => _TutorialOverlayState();
}
class _TutorialOverlayState extends State<TutorialOverlay>
with SingleTickerProviderStateMixin {
late final AnimationController _pulseController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1600),
)..repeat();
int _index = 0;
Rect? _spotRect;
bool _ready = false;
TutorialStep get _step => widget.steps[_index];
bool get _isLast => _index == widget.steps.length - 1;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _prepareStep());
}
@override
void dispose() {
_pulseController.dispose();
super.dispose();
}
/// Amène l'élément visé à l'écran puis mesure sa position.
Future<void> _prepareStep() async {
final targetContext = _step.targetKey?.currentContext;
if (targetContext == null || !targetContext.mounted) {
if (mounted) setState(() { _spotRect = null; _ready = true; });
return;
}
await Scrollable.ensureVisible(
targetContext,
alignment: 0.35,
duration: const Duration(milliseconds: 320),
curve: Curves.easeOutCubic,
);
// Laisse le temps au défilement de se stabiliser avant de mesurer.
await Future<void>.delayed(const Duration(milliseconds: 60));
if (!mounted) return;
setState(() {
_spotRect = _measure(targetContext, _step.spotPadding);
_ready = true;
});
}
Rect? _measure(BuildContext targetContext, double padding) {
final box = targetContext.findRenderObject() as RenderBox?;
if (box == null || !box.hasSize) return null;
final origin = box.localToGlobal(Offset.zero);
final screen = MediaQuery.of(context).size;
return Rect.fromLTWH(origin.dx, origin.dy, box.size.width, box.size.height)
.inflate(padding)
.intersect(Offset.zero & screen);
}
void _next() {
if (_isLast) {
_finish();
return;
}
setState(() {
_index++;
_ready = false;
_spotRect = null;
});
WidgetsBinding.instance.addPostFrameCallback((_) => _prepareStep());
}
void _finish() => widget.onFinished();
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final primary = theme.colorScheme.primary;
final spot = _spotRect;
return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, _) {
if (!didPop) _finish();
},
child: Material(
color: Colors.transparent,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: _ready ? _next : null,
child: Stack(
children: [
// Voile sombre percé autour de l'élément mis en avant.
Positioned.fill(
child: AnimatedBuilder(
animation: _pulseController,
builder: (context, _) => CustomPaint(
painter: _SpotlightPainter(
spot: spot,
circle: _step.shape == TutorialHighlightShape.circle,
pulse: _pulseController.value,
glowColor: primary,
),
),
),
),
// Main animée posée sur l'élément mis en avant.
if (spot != null && _step.gesture != TutorialGesture.none)
_buildHand(spot, primary),
// Bulle explicative.
if (_ready) _buildTooltip(context, spot, primary),
],
),
),
),
);
}
Widget _buildHand(Rect spot, Color primary) {
final handSize =
(spot.shortestSide * 1.4).clamp(96.0, 190.0).toDouble();
// Sur une grande zone (image plein écran), la main reste au centre ;
// sur un bouton, elle se cale sur le centre du bouton.
final center = spot.center;
return Positioned(
left: center.dx - handSize / 2,
top: center.dy - handSize / 2,
child: TutorialHand(
gesture: _step.gesture,
color: primary,
size: handSize,
),
);
}
Widget _buildTooltip(BuildContext context, Rect? spot, Color primary) {
final media = MediaQuery.of(context);
final screenHeight = media.size.height;
final card = _TutorialCard(
step: _step,
index: _index,
total: widget.steps.length,
primary: primary,
isLast: _isLast,
onNext: _next,
onSkip: _finish,
);
if (spot == null) {
return Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: card,
),
);
}
const gap = 22.0;
// Zone très large (image plein écran) : la bulle flotte en bas de l'écran
// pour laisser la main animée visible au centre.
if (spot.height > screenHeight * 0.55) {
return Positioned(
left: 16,
right: 16,
bottom: media.padding.bottom + 24,
child: card,
);
}
// Sinon, la bulle se place du côté le plus dégagé du spot.
final above =
_step.preferTooltipAbove ?? (spot.top > screenHeight - spot.bottom);
return Positioned(
left: 16,
right: 16,
top: above ? null : spot.bottom + gap,
bottom: above ? (screenHeight - spot.top + gap) : null,
child: card,
);
}
}
/// Carte explicative d'une étape (titre, texte, progression, boutons).
class _TutorialCard extends StatelessWidget {
final TutorialStep step;
final int index;
final int total;
final Color primary;
final bool isLast;
final VoidCallback onNext;
final VoidCallback onSkip;
const _TutorialCard({
required this.step,
required this.index,
required this.total,
required this.primary,
required this.isLast,
required this.onNext,
required this.onSkip,
});
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final surface = isDark ? const Color(0xFF121A26) : Colors.white;
final textPrimary =
isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary;
final textSecondary =
isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary;
return Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 460),
child: Container(
padding: const EdgeInsets.fromLTRB(18, 16, 18, 12),
decoration: BoxDecoration(
color: surface,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: primary.withValues(alpha: 0.35), width: 1.2),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: isDark ? 0.6 : 0.25),
blurRadius: 28,
offset: const Offset(0, 10),
),
BoxShadow(
color: primary.withValues(alpha: 0.18),
blurRadius: 24,
spreadRadius: -6,
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: primary.withValues(alpha: isDark ? 0.22 : 0.14),
borderRadius: BorderRadius.circular(10),
),
child: Icon(
step.icon ?? Icons.school_outlined,
color: primary,
size: 20,
),
),
const SizedBox(width: 12),
Expanded(
child: Text(
step.title,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w800,
color: textPrimary,
letterSpacing: -0.2,
),
),
),
Text(
'${index + 1}/$total',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: textSecondary,
),
),
],
),
const SizedBox(height: 10),
Text(
step.description,
style: TextStyle(
fontSize: 13.5,
height: 1.4,
color: textSecondary,
),
),
if (step.gesture != TutorialGesture.none) ...[
const SizedBox(height: 10),
Row(
children: [
Icon(Icons.gesture, size: 16, color: primary),
const SizedBox(width: 6),
Expanded(
child: Text(
_gestureHint(step.gesture),
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: primary,
),
),
),
],
),
],
const SizedBox(height: 12),
Row(
children: [
Row(
children: List.generate(total, (i) {
final active = i == index;
return AnimatedContainer(
duration: const Duration(milliseconds: 200),
margin: const EdgeInsets.only(right: 5),
width: active ? 18 : 6,
height: 6,
decoration: BoxDecoration(
color: active
? primary
: primary.withValues(alpha: 0.28),
borderRadius: BorderRadius.circular(3),
),
);
}),
),
const Spacer(),
TextButton(
onPressed: onSkip,
style: TextButton.styleFrom(foregroundColor: textSecondary),
child: const Text('Passer'),
),
const SizedBox(width: 4),
ElevatedButton(
onPressed: onNext,
style: ElevatedButton.styleFrom(
backgroundColor: primary,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(
horizontal: 18, vertical: 10),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Text(
isLast ? 'C\'est parti' : 'Suivant',
style: const TextStyle(fontWeight: FontWeight.w800),
),
),
],
),
],
),
),
),
);
}
static String _gestureHint(TutorialGesture gesture) {
switch (gesture) {
case TutorialGesture.tap:
return 'Appuyez une fois';
case TutorialGesture.doubleTap:
return 'Appuyez deux fois';
case TutorialGesture.longPress:
return 'Appui long';
case TutorialGesture.drag:
return 'Appui long puis glisser';
case TutorialGesture.pinch:
return 'Pincez avec deux doigts pour zoomer';
case TutorialGesture.swipeUp:
return 'Balayez vers le haut';
case TutorialGesture.swipeHorizontal:
return 'Balayez latéralement';
case TutorialGesture.none:
return '';
}
}
}
/// Voile sombre percé d'un trou de lumière, avec anneau pulsant.
class _SpotlightPainter extends CustomPainter {
final Rect? spot;
final bool circle;
final double pulse;
final Color glowColor;
_SpotlightPainter({
required this.spot,
required this.circle,
required this.pulse,
required this.glowColor,
});
@override
void paint(Canvas canvas, Size size) {
final scrim = Paint()..color = Colors.black.withValues(alpha: 0.76);
final screenPath = Path()..addRect(Offset.zero & size);
final target = spot;
if (target == null || target.isEmpty) {
canvas.drawPath(screenPath, scrim);
return;
}
final holePath = circle
? (Path()
..addOval(Rect.fromCircle(
center: target.center,
radius: target.longestSide / 2,
)))
: (Path()
..addRRect(RRect.fromRectAndRadius(
target,
const Radius.circular(18),
)));
canvas.drawPath(
Path.combine(PathOperation.difference, screenPath, holePath),
scrim,
);
// Halo diffus autour du trou.
canvas.drawPath(
holePath,
Paint()
..style = PaintingStyle.stroke
..strokeWidth = 10
..color = glowColor.withValues(alpha: 0.28)
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 10),
);
// Contour net.
canvas.drawPath(
holePath,
Paint()
..style = PaintingStyle.stroke
..strokeWidth = 2.4
..color = glowColor.withValues(alpha: 0.95),
);
// Anneau qui s'écarte en boucle pour attirer l'œil.
final ringPaint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 2.5
..color = glowColor.withValues(alpha: 0.5 * (1 - pulse));
final expansion = target.shortestSide * 0.10 * pulse;
if (circle) {
canvas.drawCircle(
target.center,
target.longestSide / 2 + expansion,
ringPaint,
);
} else {
canvas.drawRRect(
RRect.fromRectAndRadius(
target.inflate(expansion),
Radius.circular(18 + expansion),
),
ringPaint,
);
}
}
@override
bool shouldRepaint(_SpotlightPainter old) =>
old.spot != spot ||
old.pulse != pulse ||
old.circle != circle ||
old.glowColor != glowColor;
}
+2
View File
@@ -10,6 +10,7 @@ import 'data/repositories/session_repository.dart';
import 'services/score_calculator_service.dart'; import 'services/score_calculator_service.dart';
import 'services/grouping_analyzer_service.dart'; import 'services/grouping_analyzer_service.dart';
import 'features/session/session_provider.dart'; import 'features/session/session_provider.dart';
import 'features/tutorial/tutorial_provider.dart';
void main() async { void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
@@ -39,6 +40,7 @@ void main() async {
Provider<SessionRepository>(create: (_) => SessionRepository()), Provider<SessionRepository>(create: (_) => SessionRepository()),
ChangeNotifierProvider<ThemeProvider>(create: (_) => ThemeProvider()), ChangeNotifierProvider<ThemeProvider>(create: (_) => ThemeProvider()),
ChangeNotifierProvider<SessionProvider>(create: (_) => SessionProvider()), ChangeNotifierProvider<SessionProvider>(create: (_) => SessionProvider()),
ChangeNotifierProvider<TutorialProvider>(create: (_) => TutorialProvider()),
], ],
child: const BullyApp(), child: const BullyApp(),
), ),
+125 -38
View File
@@ -1,3 +1,4 @@
import 'dart:ui';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'features/home/home_screen.dart'; import 'features/home/home_screen.dart';
import 'features/history/history_screen.dart'; import 'features/history/history_screen.dart';
@@ -11,11 +12,13 @@ const int mainTabHistory = 1;
const int mainTabStats = 2; const int mainTabStats = 2;
const int mainTabGarage = 3; const int mainTabGarage = 3;
/// Clé du holder : permet de piloter l'onglet actif depuis n'importe quel /// Clé du holder : permet de piloter l'onglet actif depuis n'importe quel écran.
/// écran (ex. fin de session -> onglet Stats).
final GlobalKey<State<MainNavigationHolder>> mainNavKey = final GlobalKey<State<MainNavigationHolder>> mainNavKey =
GlobalKey<State<MainNavigationHolder>>(); GlobalKey<State<MainNavigationHolder>>();
/// Clé du dock flottant : permet au didacticiel de le mettre en avant.
final GlobalKey navigationDockKey = GlobalKey();
/// Ouvre l'onglet [index] de la navigation principale. /// Ouvre l'onglet [index] de la navigation principale.
void openMainTab(int index) { void openMainTab(int index) {
final state = mainNavKey.currentState; final state = mainNavKey.currentState;
@@ -32,9 +35,6 @@ class MainNavigationHolder extends StatefulWidget {
class _MainNavigationHolderState extends State<MainNavigationHolder> { class _MainNavigationHolderState extends State<MainNavigationHolder> {
int _selectedIndex = 0; int _selectedIndex = 0;
// Incrémentés à chaque ouverture de l'onglet correspondant pour forcer le
// rechargement (les écrans sont gardés vivants par l'IndexedStack et ne se
// rafraîchissent pas seuls).
int _statsTick = 0; int _statsTick = 0;
int _historyTick = 0; int _historyTick = 0;
int _homeTick = 0; int _homeTick = 0;
@@ -53,57 +53,144 @@ class _MainNavigationHolderState extends State<MainNavigationHolder> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final screens = [ final screens = [
HomeScreen(refreshTick: _homeTick), HomeScreen(refreshTick: _homeTick),
HistoryScreen(refreshTick: _historyTick), HistoryScreen(refreshTick: _historyTick),
StatisticsScreen(refreshTick: _statsTick), StatisticsScreen(refreshTick: _statsTick),
WeaponListScreen(refreshTick: _garageTick), WeaponListScreen(refreshTick: _garageTick),
]; ];
return Scaffold( return Scaffold(
extendBody: true,
body: IndexedStack( body: IndexedStack(
index: _selectedIndex, index: _selectedIndex,
children: screens, children: screens,
), ),
bottomNavigationBar: Container( bottomNavigationBar: _buildFloatingGlassDock(isDark),
);
}
Widget _buildFloatingGlassDock(bool isDark) {
final primaryColor = Theme.of(context).colorScheme.primary;
final navItems = [
(Icons.radar_outlined, Icons.radar, 'Accueil'),
(Icons.history_outlined, Icons.history_rounded, 'Historique'),
(Icons.insights_outlined, Icons.insights_rounded, 'Stats'),
(Icons.shield_outlined, Icons.shield, 'Armurerie'),
];
return SafeArea(
child: Container(
key: navigationDockKey,
margin: const EdgeInsets.fromLTRB(16, 0, 16, 12),
height: 68,
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(24),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: Colors.black.withValues(alpha: 0.1), color: Colors.black.withValues(alpha: isDark ? 0.45 : 0.12),
blurRadius: 10, blurRadius: 24,
offset: const Offset(0, -2), offset: const Offset(0, 8),
), ),
if (isDark)
BoxShadow(
color: primaryColor.withValues(alpha: 0.12),
blurRadius: 16,
spreadRadius: -4,
offset: const Offset(0, -2),
),
], ],
), ),
child: BottomNavigationBar( child: ClipRRect(
currentIndex: _selectedIndex, borderRadius: BorderRadius.circular(24),
onTap: selectTab, child: BackdropFilter(
type: BottomNavigationBarType.fixed, filter: ImageFilter.blur(sigmaX: 16, sigmaY: 16),
backgroundColor: Theme.of(context).cardColor, child: Container(
selectedItemColor: AppTheme.primaryColor, padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
unselectedItemColor: Colors.grey, decoration: BoxDecoration(
showUnselectedLabels: true, color: isDark
items: const [ ? const Color(0xFF101722).withValues(alpha: 0.78)
BottomNavigationBarItem( : Colors.white.withValues(alpha: 0.85),
icon: Icon(Icons.home_outlined), borderRadius: BorderRadius.circular(24),
activeIcon: Icon(Icons.home), border: Border.all(
label: 'Accueil', color: isDark
? Colors.white.withValues(alpha: 0.14)
: Colors.black.withValues(alpha: 0.08),
width: 1.2,
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: List.generate(navItems.length, (index) {
final item = navItems[index];
final isSelected = _selectedIndex == index;
return Expanded(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => selectTab(index),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
curve: Curves.easeOutCubic,
padding: const EdgeInsets.symmetric(vertical: 4),
decoration: BoxDecoration(
color: isSelected
? primaryColor.withValues(
alpha: isDark ? 0.22 : 0.15,
)
: Colors.transparent,
borderRadius: BorderRadius.circular(16),
border: isSelected
? Border.all(
color: primaryColor.withValues(
alpha: isDark ? 0.45 : 0.35,
),
width: 1,
)
: null,
),
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
isSelected ? item.$2 : item.$1,
size: 22,
color: isSelected
? primaryColor
: (isDark
? AppTheme.darkTextSecondary
: AppTheme.lightTextSecondary),
),
const SizedBox(height: 2),
Text(
item.$3,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 11,
fontWeight: isSelected
? FontWeight.w700
: FontWeight.w500,
color: isSelected
? primaryColor
: (isDark
? AppTheme.darkTextSecondary
: AppTheme.lightTextSecondary),
letterSpacing: 0.2,
),
),
],
),
),
),
);
}),
),
), ),
BottomNavigationBarItem( ),
icon: Icon(Icons.history_outlined),
activeIcon: Icon(Icons.history),
label: 'Historique',
),
BottomNavigationBarItem(
icon: Icon(Icons.analytics_outlined),
activeIcon: Icon(Icons.analytics),
label: 'Stats',
),
BottomNavigationBarItem(
icon: Icon(Icons.shield_outlined),
activeIcon: Icon(Icons.shield),
label: 'Armurerie',
),
],
), ),
), ),
); );
+53 -29
View File
@@ -2,7 +2,7 @@ import 'dart:convert';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'dart:io'; import 'dart:io';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:device_info_plus/device_info_plus.dart'; import 'package:image/image.dart' as img;
import 'package:crypto/crypto.dart'; import 'package:crypto/crypto.dart';
import '../data/models/shot.dart'; import '../data/models/shot.dart';
import '../data/models/target_type.dart'; import '../data/models/target_type.dart';
@@ -64,30 +64,37 @@ class AiExportResult {
} }
class AiExportService { class AiExportService {
/// Extrait les informations de l'appareil /// Retire toutes les metadonnees de la photo avant l'envoi.
Future<Map<String, dynamic>> _getDeviceInfo() async { ///
final DeviceInfoPlugin deviceInfoPlugin = DeviceInfoPlugin(); /// Une photo de cible prise au telephone embarque un bloc EXIF qui contient
Map<String, dynamic> deviceData = {'model': 'Unknown', 'os': 'Unknown'}; /// typiquement la position GPS du stand de tir, la date exacte et le modele
/// d'appareil. Rien de tout cela n'est utile au modele de detection.
///
/// L'orientation est d'abord appliquee physiquement aux pixels : les
/// coordonnees d'impact sont normalisees sur l'image telle qu'elle est
/// affichee dans l'app (Flutter applique l'orientation EXIF), donc supprimer
/// le tag sans redresser l'image ferait pivoter la photo par rapport a ses
/// propres annotations.
///
/// Retourne null si l'image est illisible.
@visibleForTesting
Uint8List? stripMetadata(Uint8List originalBytes) {
try { try {
if (Platform.isAndroid) { final decoded = img.decodeImage(originalBytes);
final androidInfo = await deviceInfoPlugin.androidInfo; if (decoded == null) return null;
deviceData['model'] = '${androidInfo.brand} ${androidInfo.model}';
deviceData['os'] = 'Android ${androidInfo.version.release}';
} else if (Platform.isIOS) {
final iosInfo = await deviceInfoPlugin.iosInfo;
deviceData['model'] = iosInfo.name;
deviceData['os'] = '${iosInfo.systemName} ${iosInfo.systemVersion}';
} else if (Platform.isWindows) {
final windowsInfo = await deviceInfoPlugin.windowsInfo;
deviceData['model'] = 'Windows PC';
deviceData['os'] = 'Windows ${windowsInfo.majorVersion}.${windowsInfo.minorVersion}';
}
} catch (e) {
debugPrint('Erreur lors de la récupération des infos appareil: $e');
}
return deviceData; final baked = img.bakeOrientation(decoded);
// bakeOrientation recopie tout l'EXIF sauf l'orientation : sans ce reset,
// le GPS survivrait au reencodage.
baked.exif = img.ExifData();
return img.encodeJpg(baked, quality: 90);
} catch (e) {
// Sur un fichier tronque, decodeImage leve au lieu de retourner null.
debugPrint('Photo illisible, export annule: $e');
return null;
}
} }
/// Exporte l'image et les données de plotting vers le serveur /// Exporte l'image et les données de plotting vers le serveur
@@ -100,15 +107,18 @@ class AiExportService {
required double targetRadius, required double targetRadius,
required List<Shot> shots, required List<Shot> shots,
int distanceMeters = 25, int distanceMeters = 25,
String weaponName = 'Unknown', String caliber = 'unknown',
int? expectedShots,
String? apiUrl, String? apiUrl,
}) async { }) async {
try { try {
final walletService = WalletIdentityService(); final walletService = WalletIdentityService();
final baseUrl = await walletService.getServerBaseUrl(); final rawBaseUrl = await walletService.getServerBaseUrl();
final baseUrl = rawBaseUrl.endsWith('/') ? rawBaseUrl.substring(0, rawBaseUrl.length - 1) : rawBaseUrl;
final effectiveUrl = apiUrl ?? '$baseUrl/api/upload'; final effectiveUrl = apiUrl ?? '$baseUrl/api/upload';
final url = Uri.parse(effectiveUrl); final url = Uri.parse(effectiveUrl);
final request = http.MultipartRequest('POST', url); final request = http.MultipartRequest('POST', url);
request.headers['X-API-KEY'] = WalletIdentityService.apiKey;
// 1. Prepare image // 1. Prepare image
final file = File(imagePath); final file = File(imagePath);
@@ -119,7 +129,13 @@ class AiExportService {
); );
} }
final deviceData = await _getDeviceInfo(); final sanitizedPhoto = stripMetadata(await file.readAsBytes());
if (sanitizedPhoto == null) {
return AiExportResult.error(
code: 'INVALID_IMAGE',
message: 'La photo de la cible est illisible et n\'a pas pu être envoyée.',
);
}
final corners = [ final corners = [
{"norm_x": targetCenterX - targetRadius, "norm_y": targetCenterY - targetRadius}, {"norm_x": targetCenterX - targetRadius, "norm_y": targetCenterY - targetRadius},
@@ -153,11 +169,15 @@ class AiExportService {
"session_id": sessionId, "session_id": sessionId,
"wallet_hash": walletHash, "wallet_hash": walletHash,
"timestamp": DateTime.now().toIso8601String(), "timestamp": DateTime.now().toIso8601String(),
"device_info": deviceData,
"target_metadata": { "target_metadata": {
"type": targetType.name, "type": targetType.name,
"distance_meters": distanceMeters, "distance_meters": distanceMeters,
"weapon": weaponName, "caliber": caliber,
// Nombre de coups prevus pour cette cible, null hors session.
// Un ecart avec le nombre d'impacts ne signifie pas que le
// marquage est faux (un coup peut etre parti hors papier) : c'est
// au tri du dataset d'en decider, pas au client.
"expected_shots": expectedShots,
}, },
"plotting": { "plotting": {
"target_corners": corners, "target_corners": corners,
@@ -167,7 +187,11 @@ class AiExportService {
request.fields['plotting'] = jsonEncode(plottingJson); request.fields['plotting'] = jsonEncode(plottingJson);
request.files.add( request.files.add(
await http.MultipartFile.fromPath('photo', imagePath), http.MultipartFile.fromBytes(
'photo',
sanitizedPhoto,
filename: 'target.jpg',
),
); );
final streamedResponse = await request.send().timeout( final streamedResponse = await request.send().timeout(
+101 -13
View File
@@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:sensors_plus/sensors_plus.dart'; import 'package:sensors_plus/sensors_plus.dart';
/// Statut de parallélisme retourné en temps réel. /// Statut de parallélisme retourné en temps réel.
@@ -14,31 +15,67 @@ enum ParallelismStatus {
misaligned, misaligned,
} }
/// Pose de prise de vue déduite de l'inclinaison de l'appareil.
enum TargetPose {
/// Cible accrochée verticalement : l'appareil est tenu droit.
wall,
/// Cible posée au sol : l'appareil est à plat, caméra vers le bas.
ground,
}
/// Données de parallélisme calculées à chaque frame capteur. /// Données de parallélisme calculées à chaque frame capteur.
class ParallelismData { class ParallelismData {
final ParallelismStatus status; final ParallelismStatus status;
/// Inclinaison avant/arrière en degrés (0° = parfaitement vertical). /// Pose détectée automatiquement, qui sert de référence aux écarts.
final TargetPose pose;
/// Inclinaison avant/arrière brute en degrés (0° = appareil vertical,
/// +90° = appareil à plat, caméra vers le sol).
final double pitchDegrees; final double pitchDegrees;
/// Inclinaison gauche/droite en degrés (0° = parfaitement droit). /// Inclinaison gauche/droite en degrés (0° = parfaitement droit).
final double rollDegrees; final double rollDegrees;
/// Écart de tangage par rapport à la pose détectée.
///
/// En pose [TargetPose.wall] il vaut exactement [pitchDegrees] ; en pose
/// [TargetPose.ground] il mesure l'écart aux +90° de l'appareil à plat.
/// C'est cette valeur qu'il faut afficher : le tangage brut vaudrait -90°
/// alors que le cadrage est parfait.
final double pitchDeviation;
const ParallelismData({ const ParallelismData({
required this.status, required this.status,
required this.pose,
required this.pitchDegrees, required this.pitchDegrees,
required this.rollDegrees, required this.rollDegrees,
required this.pitchDeviation,
}); });
bool get isAligned => status == ParallelismStatus.aligned; bool get isAligned => status == ParallelismStatus.aligned;
/// Écart latéral. Le roulis se mesure de la même façon dans les deux poses.
double get rollDeviation => rollDegrees;
@override @override
String toString() => String toString() =>
'ParallelismData(status: $status, pitch: ${pitchDegrees.toStringAsFixed(1)}°, roll: ${rollDegrees.toStringAsFixed(1)}°)'; 'ParallelismData(status: $status, pose: $pose, pitch: ${pitchDegrees.toStringAsFixed(1)}°, '
'écart: ${pitchDeviation.toStringAsFixed(1)}°, roll: ${rollDegrees.toStringAsFixed(1)}°)';
} }
/// Service de détection du parallélisme par accéléromètre. /// Service de détection du parallélisme par accéléromètre.
/// ///
/// Deux poses de prise de vue sont reconnues, et celle dont l'appareil est le
/// plus proche est retenue automatiquement :
///
/// [TargetPose.wall] — cible au mur, appareil vertical (tangage ≈ 0°)
/// [TargetPose.ground] — cible au sol, appareil à plat (tangage ≈ +90°)
///
/// Seul le tangage positif vaut pour la pose au sol : à plat écran vers le
/// haut, la caméra vise le plafond et le vert n'aurait aucun sens.
///
/// Implémente une hystérésis à deux seuils pour éviter le clignotement : /// Implémente une hystérésis à deux seuils pour éviter le clignotement :
/// ///
/// État actuel = misaligned → passe à aligned si angle < [alignThreshold] /// État actuel = misaligned → passe à aligned si angle < [alignThreshold]
@@ -58,6 +95,13 @@ class ParallelismService {
/// Doit être > alignThreshold pour créer la zone d'hystérésis. /// Doit être > alignThreshold pour créer la zone d'hystérésis.
final double misalignThreshold; final double misalignThreshold;
/// Écart minimum en faveur de l'autre pose pour basculer.
///
/// Les deux poses sont séparées de 90°, donc la bascule se joue vers 45° —
/// très loin des deux zones vertes. Cette marge évite seulement que
/// l'étiquette de pose clignote pile à la frontière.
static const double poseSwitchMargin = 5.0;
StreamSubscription<AccelerometerEvent>? _subscription; StreamSubscription<AccelerometerEvent>? _subscription;
final StreamController<ParallelismData> _controller = final StreamController<ParallelismData> _controller =
StreamController<ParallelismData>.broadcast(); StreamController<ParallelismData>.broadcast();
@@ -65,6 +109,9 @@ class ParallelismService {
/// État interne mémorisé entre deux frames (cœur de l'hystérésis). /// État interne mémorisé entre deux frames (cœur de l'hystérésis).
ParallelismStatus _currentStatus = ParallelismStatus.unknown; ParallelismStatus _currentStatus = ParallelismStatus.unknown;
/// Pose retenue à la frame précédente, pour l'hystérésis de pose.
TargetPose? _currentPose;
ParallelismService({ ParallelismService({
this.alignThreshold = 25.0, this.alignThreshold = 25.0,
this.misalignThreshold = 32.0, this.misalignThreshold = 32.0,
@@ -86,10 +133,13 @@ class ParallelismService {
// Simulateur ou capteur absent — on reste en "unknown" sans bloquer l'UI // Simulateur ou capteur absent — on reste en "unknown" sans bloquer l'UI
if (!_controller.isClosed) { if (!_controller.isClosed) {
_currentStatus = ParallelismStatus.unknown; _currentStatus = ParallelismStatus.unknown;
_currentPose = null;
_controller.add(const ParallelismData( _controller.add(const ParallelismData(
status: ParallelismStatus.unknown, status: ParallelismStatus.unknown,
pose: TargetPose.wall,
pitchDegrees: 0, pitchDegrees: 0,
rollDegrees: 0, rollDegrees: 0,
pitchDeviation: 0,
)); ));
} }
}, },
@@ -100,6 +150,7 @@ class ParallelismService {
_subscription?.cancel(); _subscription?.cancel();
_subscription = null; _subscription = null;
_currentStatus = ParallelismStatus.unknown; _currentStatus = ParallelismStatus.unknown;
_currentPose = null;
} }
void dispose() { void dispose() {
@@ -110,12 +161,18 @@ class ParallelismService {
void _onAccelerometerEvent(AccelerometerEvent event) { void _onAccelerometerEvent(AccelerometerEvent event) {
if (_controller.isClosed) return; if (_controller.isClosed) return;
final double gx = event.x; final data = evaluate(event.x, event.y, event.z);
final double gy = event.y; if (data != null) _controller.add(data);
final double gz = event.z; }
/// Calcule la pose et le statut depuis une mesure d'accéléromètre.
///
/// Met à jour l'état d'hystérésis, donc l'ordre des appels compte.
/// Retourne null sur une mesure aberrante, qu'il faut alors ignorer.
@visibleForTesting
ParallelismData? evaluate(double gx, double gy, double gz) {
final double magnitude = math.sqrt(gx * gx + gy * gy + gz * gz); final double magnitude = math.sqrt(gx * gx + gy * gy + gz * gz);
if (magnitude < 1.0) return; // Données aberrantes if (magnitude < 1.0) return null; // Données aberrantes
// Normalisation par la magnitude réelle (indépendant de g exact) // Normalisation par la magnitude réelle (indépendant de g exact)
final double nx = gx / magnitude; final double nx = gx / magnitude;
@@ -125,10 +182,39 @@ class ParallelismService {
final double pitchDeg = math.asin(nz.clamp(-1.0, 1.0)) * (180.0 / math.pi); final double pitchDeg = math.asin(nz.clamp(-1.0, 1.0)) * (180.0 / math.pi);
final double rollDeg = math.asin(nx.clamp(-1.0, 1.0)) * (180.0 / math.pi); final double rollDeg = math.asin(nx.clamp(-1.0, 1.0)) * (180.0 / math.pi);
// Le critère de couleur = le PIRE des deux angles affichés à l'écran. // ── Choix de la pose ────────────────────────────────────────────────────
// Ainsi ce que voit l'utilisateur (Pitch / Roll) correspond exactement // Cible au mur : le tangage idéal est 0°. Formule d'origine, inchangée.
// à la décision vert/orange : à 2° d'écart, on est largement dans le vert. final double wallPitchDeviation = pitchDeg;
final double worstAngle = math.max(pitchDeg.abs(), rollDeg.abs()); final double wallWorst = math.max(pitchDeg.abs(), rollDeg.abs());
// Cible au sol : le tangage idéal est +90° (caméra vers le bas). Écran vers
// le haut, le tangage vaut -90° et l'écart atteint 180° : la pose au sol ne
// peut alors jamais gagner, ce qui est exactement le comportement voulu.
final double groundPitchDeviation = pitchDeg - 90.0;
final double groundWorst =
math.max(groundPitchDeviation.abs(), rollDeg.abs());
if (_currentPose == null) {
_currentPose =
groundWorst < wallWorst ? TargetPose.ground : TargetPose.wall;
} else if (_currentPose == TargetPose.wall) {
if (groundWorst + poseSwitchMargin < wallWorst) {
_currentPose = TargetPose.ground;
}
} else {
if (wallWorst + poseSwitchMargin < groundWorst) {
_currentPose = TargetPose.wall;
}
}
final bool isGround = _currentPose == TargetPose.ground;
final double pitchDeviation =
isGround ? groundPitchDeviation : wallPitchDeviation;
// Le critère de couleur = le PIRE des deux écarts affichés à l'écran.
// Ainsi ce que voit l'utilisateur correspond exactement à la décision
// vert/orange : à 2° d'écart, on est largement dans le vert.
final double worstAngle = isGround ? groundWorst : wallWorst;
// ── Hystérésis ────────────────────────────────────────────────────────── // ── Hystérésis ──────────────────────────────────────────────────────────
// Premier appel : on décide selon alignThreshold uniquement // Premier appel : on décide selon alignThreshold uniquement
@@ -152,10 +238,12 @@ class ParallelismService {
} }
// ──────────────────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────────────────
_controller.add(ParallelismData( return ParallelismData(
status: _currentStatus, status: _currentStatus,
pose: _currentPose!,
pitchDegrees: pitchDeg, pitchDegrees: pitchDeg,
rollDegrees: rollDeg, rollDegrees: rollDeg,
)); pitchDeviation: pitchDeviation,
);
} }
} }
+46
View File
@@ -0,0 +1,46 @@
/// Persistance du didacticiel : mémorise les visites guidées déjà vues.
///
/// Chaque visite guidée (« tour ») possède un identifiant stocké dans les
/// SharedPreferences sous la forme `tutorial_done_<id>`. Réinitialiser le
/// didacticiel depuis les paramètres efface simplement ces clés.
library;
import 'package:shared_preferences/shared_preferences.dart';
/// Identifiants des visites guidées de l'application.
class TutorialTours {
const TutorialTours._();
/// Visite d'accueil : présentation générale et navigation.
static const String home = 'home';
/// Visite de l'éditeur d'impacts : tap, appui long et pincement pour zoomer.
static const String impactEditor = 'impact_editor';
/// Toutes les visites connues, dans l'ordre logique de découverte.
static const List<String> all = [home, impactEditor];
}
class TutorialService {
static const String _prefix = 'tutorial_done_';
Future<Set<String>> loadCompletedTours() async {
final prefs = await SharedPreferences.getInstance();
return TutorialTours.all
.where((id) => prefs.getBool('$_prefix$id') ?? false)
.toSet();
}
Future<void> markCompleted(String tourId) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('$_prefix$tourId', true);
}
/// Efface la mémoire du didacticiel : toutes les visites seront rejouées.
Future<void> resetAll() async {
final prefs = await SharedPreferences.getInstance();
for (final id in TutorialTours.all) {
await prefs.remove('$_prefix$id');
}
}
}
+12 -6
View File
@@ -44,17 +44,20 @@ class WalletIdentityService {
'bulk', 'bullet', 'bundle', 'bunker', 'burden', 'burger', 'burst', 'bus', 'business', 'butter' 'bulk', 'bullet', 'bundle', 'bunker', 'burden', 'burger', 'burst', 'bus', 'business', 'butter'
]; ];
/// Retourne l'URL de base du serveur configuré (ex: http://192.168.1.50:3000 ou http://10.0.2.2:3000) /// URL par défaut du serveur de production
static const String defaultServerUrl = 'https://backendia.kevlar.cloud';
/// Clé d'authentification API secrète pour le backend
static const String apiKey = 'bully_secret_api_key_2026_x89';
/// Retourne l'URL de base du serveur configuré (par défaut: https://backendia.kevlar.cloud)
Future<String> getServerBaseUrl() async { Future<String> getServerBaseUrl() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final customUrl = prefs.getString(_serverUrlKey); final customUrl = prefs.getString(_serverUrlKey);
if (customUrl != null && customUrl.trim().isNotEmpty) { if (customUrl != null && customUrl.trim().isNotEmpty) {
return customUrl.trim().replaceAll(RegExp(r'/api/upload/?$'), ''); return customUrl.trim().replaceAll(RegExp(r'/api/upload/?$'), '');
} }
if (Platform.isAndroid) { return defaultServerUrl;
return 'http://10.0.2.2:3000';
}
return 'http://localhost:3000';
} }
/// Définit une URL personnalisée pour le serveur IA /// Définit une URL personnalisée pour le serveur IA
@@ -115,7 +118,10 @@ class WalletIdentityService {
final walletHash = sha256.convert(phraseBytes).toString(); final walletHash = sha256.convert(phraseBytes).toString();
final baseUrl = await getServerBaseUrl(); final baseUrl = await getServerBaseUrl();
final res = await http.get(Uri.parse('$baseUrl/api/stats/$walletHash')).timeout( final res = await http.get(
Uri.parse('$baseUrl/api/stats/$walletHash'),
headers: {'X-API-KEY': apiKey},
).timeout(
const Duration(seconds: 4), const Duration(seconds: 4),
); );
if (res.statusCode == 200) { if (res.statusCode == 200) {
+127
View File
@@ -0,0 +1,127 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:bully/features/tutorial/tutorial_step.dart';
import 'package:bully/features/tutorial/widgets/tutorial_hand.dart';
import 'package:bully/features/tutorial/widgets/tutorial_overlay.dart';
/// Monte l'overlay au-dessus d'un faux écran contenant l'élément visé.
Future<GlobalKey> pumpOverlay(
WidgetTester tester, {
required List<TutorialStep> Function(GlobalKey targetKey) steps,
required VoidCallback onFinished,
}) async {
final targetKey = GlobalKey();
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Stack(
children: [
Center(
child: SizedBox(key: targetKey, width: 160, height: 48),
),
TutorialOverlay(steps: steps(targetKey), onFinished: onFinished),
],
),
),
),
);
// Laisse le temps à la mesure du spot (défilement + délai de stabilisation).
await tester.pump();
await tester.pump(const Duration(milliseconds: 500));
return targetKey;
}
void main() {
testWidgets('la visite avance d\'une étape à l\'autre puis se termine',
(WidgetTester tester) async {
var finished = false;
await pumpOverlay(
tester,
onFinished: () => finished = true,
steps: (targetKey) => [
TutorialStep(
targetKey: targetKey,
title: 'Démarrez une session',
description: 'Tout part d\'ici.',
gesture: TutorialGesture.tap,
),
TutorialStep(
targetKey: targetKey,
title: 'Zoomer sur la cible',
description: 'Écartez deux doigts.',
gesture: TutorialGesture.pinch,
),
],
);
expect(find.text('Démarrez une session'), findsOneWidget);
expect(find.text('1/2'), findsOneWidget);
// La main animée mime le geste attendu, posée sur l'élément mis en avant.
expect(find.byType(TutorialHand), findsOneWidget);
expect(find.text('Appuyez une fois'), findsOneWidget);
await tester.tap(find.text('Suivant'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 500));
expect(find.text('Zoomer sur la cible'), findsOneWidget);
expect(find.text('2/2'), findsOneWidget);
expect(find.text('Pincez avec deux doigts pour zoomer'), findsOneWidget);
expect(finished, isFalse);
await tester.tap(find.text('C\'est parti'));
await tester.pump();
expect(finished, isTrue);
});
testWidgets('« Passer » interrompt la visite immédiatement',
(WidgetTester tester) async {
var finished = false;
await pumpOverlay(
tester,
onFinished: () => finished = true,
steps: (targetKey) => [
TutorialStep(
targetKey: targetKey,
title: 'Première étape',
description: 'Description.',
),
const TutorialStep(
title: 'Seconde étape',
description: 'Description.',
),
],
);
await tester.tap(find.text('Passer'));
await tester.pump();
expect(finished, isTrue);
expect(find.text('Seconde étape'), findsNothing);
});
testWidgets('une étape sans cible s\'affiche comme carte centrée',
(WidgetTester tester) async {
await pumpOverlay(
tester,
onFinished: () {},
steps: (_) => [
const TutorialStep(
title: 'Bienvenue dans Bully',
description: 'Ce guide rapide vous montre l\'essentiel.',
),
],
);
expect(find.text('Bienvenue dans Bully'), findsOneWidget);
expect(find.text('C\'est parti'), findsOneWidget);
// Aucune cible : pas de main animée non plus.
expect(find.byType(TutorialHand), findsNothing);
});
}
+61
View File
@@ -0,0 +1,61 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:image/image.dart' as img;
import 'package:bully/services/ai_export_service.dart';
/// Construit un JPEG porteur de métadonnées EXIF comme le ferait un téléphone :
/// position GPS, modèle d'appareil et orientation.
Uint8List _photoWithExif({int orientation = 1}) {
final image = img.Image(width: 40, height: 20);
img.fill(image, color: img.ColorRgb8(120, 120, 120));
image.exif.imageIfd['Model'] = 'Pixel 8';
image.exif.imageIfd.orientation = orientation;
image.exif.gpsIfd['GPSLatitude'] = 48.8584;
image.exif.gpsIfd['GPSLongitude'] = 2.2945;
return img.encodeJpg(image);
}
void main() {
final service = AiExportService();
group('stripMetadata', () {
test('supprime le GPS et les autres métadonnées EXIF', () {
final original = _photoWithExif();
// Garde-fou : sans EXIF au départ, le test ne prouverait rien.
expect(img.decodeImage(original)!.exif.isEmpty, isFalse);
final stripped = service.stripMetadata(original);
final result = img.decodeImage(stripped!)!;
expect(result.exif.isEmpty, isTrue);
expect(result.exif.gpsIfd.isEmpty, isTrue);
});
test('applique l\'orientation aux pixels avant de retirer le tag', () {
// Orientation 6 = rotation de 90°, donc les dimensions s'inversent.
final original = _photoWithExif(orientation: 6);
final result = img.decodeImage(service.stripMetadata(original)!)!;
expect(result.width, 20);
expect(result.height, 40);
expect(result.exif.isEmpty, isTrue);
});
test('conserve les dimensions quand il n\'y a pas d\'orientation', () {
final result = img.decodeImage(service.stripMetadata(_photoWithExif())!)!;
expect(result.width, 40);
expect(result.height, 20);
});
test('retourne null sur une image illisible', () {
expect(service.stripMetadata(Uint8List.fromList([1, 2, 3, 4])), isNull);
});
});
}
+136
View File
@@ -0,0 +1,136 @@
import 'dart:math' as math;
import 'package:flutter_test/flutter_test.dart';
import 'package:bully/services/parallelism_service.dart';
/// Vecteur de gravité mesuré par l'accéléromètre pour une pose donnée.
///
/// [pitch] et [roll] sont exprimés comme dans le service : le tangage vaut 0°
/// appareil vertical et +90° appareil à plat caméra vers le sol.
({double x, double y, double z}) gravity({
double pitch = 0,
double roll = 0,
}) {
const double g = 9.81;
final double p = pitch * math.pi / 180.0;
final double r = roll * math.pi / 180.0;
final double nz = math.sin(p);
final double nx = math.sin(r);
// Ce qui reste va sur y, l'axe vertical de l'écran.
final double ny = math.sqrt(math.max(0.0, 1.0 - nz * nz - nx * nx));
return (x: nx * g, y: ny * g, z: nz * g);
}
ParallelismData evaluatePose(
ParallelismService service, {
double pitch = 0,
double roll = 0,
}) {
final v = gravity(pitch: pitch, roll: roll);
final data = service.evaluate(v.x, v.y, v.z);
expect(data, isNotNull, reason: 'mesure jugée aberrante à tort');
return data!;
}
void main() {
group('pose cible au mur (comportement existant)', () {
test('appareil vertical → aligné', () {
final data = evaluatePose(ParallelismService());
expect(data.pose, TargetPose.wall);
expect(data.status, ParallelismStatus.aligned);
expect(data.pitchDeviation, closeTo(0, 0.5));
});
test('l\'écart de tangage reste le tangage brut', () {
final data = evaluatePose(ParallelismService(), pitch: 10);
expect(data.pose, TargetPose.wall);
expect(data.pitchDeviation, closeTo(data.pitchDegrees, 0.001));
});
test('inclinaison au-delà du seuil → désaligné', () {
final data = evaluatePose(ParallelismService(), pitch: 40);
expect(data.pose, TargetPose.wall);
expect(data.status, ParallelismStatus.misaligned);
});
test('roulis excessif → désaligné même si le tangage est bon', () {
final data = evaluatePose(ParallelismService(), roll: 35);
expect(data.status, ParallelismStatus.misaligned);
});
});
group('pose cible au sol', () {
test('appareil à plat caméra vers le bas → aligné', () {
final data = evaluatePose(ParallelismService(), pitch: 90);
expect(data.pose, TargetPose.ground);
expect(data.status, ParallelismStatus.aligned);
expect(data.pitchDeviation, closeTo(0, 0.5));
});
test('l\'écart se mesure par rapport à +90°, pas à 0°', () {
final data = evaluatePose(ParallelismService(), pitch: 80);
expect(data.pose, TargetPose.ground);
expect(data.pitchDeviation, closeTo(-10, 0.5));
expect(data.status, ParallelismStatus.aligned);
});
test('à plat mais trop incliné → désaligné', () {
final data = evaluatePose(ParallelismService(), pitch: 50);
expect(data.status, ParallelismStatus.misaligned);
});
test('à plat écran vers le haut → jamais aligné', () {
// La caméra vise le plafond : le vert n'aurait aucun sens.
final data = evaluatePose(ParallelismService(), pitch: -90);
expect(data.pose, TargetPose.wall);
expect(data.status, ParallelismStatus.misaligned);
});
});
group('hystérésis', () {
test('le vert survit à un léger tremblement entre les deux seuils', () {
final service = ParallelismService();
expect(evaluatePose(service).status, ParallelismStatus.aligned);
// 28° est au-dessus du seuil d'entrée (25°) mais sous celui de sortie (32°).
expect(evaluatePose(service, pitch: 28).status, ParallelismStatus.aligned);
expect(evaluatePose(service, pitch: 40).status, ParallelismStatus.misaligned);
// Repasser sous 32° ne suffit pas : il faut redescendre sous 25°.
expect(evaluatePose(service, pitch: 28).status, ParallelismStatus.misaligned);
expect(evaluatePose(service, pitch: 20).status, ParallelismStatus.aligned);
});
test('la pose ne bascule pas pour un écart négligeable', () {
final service = ParallelismService();
expect(evaluatePose(service).pose, TargetPose.wall);
// Pile à la frontière des deux poses : on garde celle déjà retenue.
expect(evaluatePose(service, pitch: 45).pose, TargetPose.wall);
// Franchement du côté du sol : on bascule.
expect(evaluatePose(service, pitch: 70).pose, TargetPose.ground);
});
test('stop() remet la pose et le statut à zéro', () {
final service = ParallelismService();
expect(evaluatePose(service, pitch: 90).pose, TargetPose.ground);
service.stop();
expect(evaluatePose(service).pose, TargetPose.wall);
});
});
test('une mesure aberrante est ignorée', () {
expect(ParallelismService().evaluate(0, 0, 0), isNull);
});
}
+79
View File
@@ -0,0 +1,79 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:bully/features/tutorial/tutorial_provider.dart';
import 'package:bully/services/tutorial_service.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() => SharedPreferences.setMockInitialValues({}));
group('TutorialService', () {
test('aucune visite n\'est marquée vue à la première utilisation',
() async {
expect(await TutorialService().loadCompletedTours(), isEmpty);
});
test('mémorise puis réinitialise les visites vues', () async {
final service = TutorialService();
await service.markCompleted(TutorialTours.home);
expect(await service.loadCompletedTours(), {TutorialTours.home});
await service.markCompleted(TutorialTours.impactEditor);
expect(
await service.loadCompletedTours(),
{TutorialTours.home, TutorialTours.impactEditor},
);
await service.resetAll();
expect(await service.loadCompletedTours(), isEmpty);
});
});
group('TutorialProvider', () {
test('joue chaque visite une seule fois', () async {
final provider = TutorialProvider();
await provider.load();
expect(provider.isLoaded, isTrue);
expect(provider.shouldRun(TutorialTours.home), isTrue);
expect(provider.hasSeenIntro, isFalse);
await provider.complete(TutorialTours.home);
expect(provider.shouldRun(TutorialTours.home), isFalse);
expect(provider.hasSeenIntro, isTrue);
// Les autres visites restent disponibles.
expect(provider.shouldRun(TutorialTours.impactEditor), isTrue);
});
test('la réactivation depuis les paramètres rejoue toutes les visites',
() async {
final provider = TutorialProvider();
await provider.load();
await provider.complete(TutorialTours.home);
await provider.complete(TutorialTours.impactEditor);
await provider.restart();
expect(provider.shouldRun(TutorialTours.home), isTrue);
expect(provider.shouldRun(TutorialTours.impactEditor), isTrue);
// La remise à zéro est bien persistée.
expect(await TutorialService().loadCompletedTours(), isEmpty);
});
test('l\'état vu survit à un redémarrage de l\'application', () async {
final first = TutorialProvider();
await first.load();
await first.complete(TutorialTours.home);
final second = TutorialProvider();
await second.load();
expect(second.shouldRun(TutorialTours.home), isFalse);
expect(second.shouldRun(TutorialTours.impactEditor), isTrue);
});
});
}