Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6dc525c0db | ||
|
|
96cc487b55 | ||
|
|
8dc6542603 | ||
|
|
e943538133 | ||
|
|
7f1fa2d80b | ||
|
|
a2f7bfc158 | ||
|
|
d0a7700d02 | ||
|
|
9b52623ebe | ||
|
|
ab12e07847 | ||
|
|
98b9f1cd4c | ||
|
|
e889456bfa | ||
|
|
c0177b19e3 | ||
|
|
99abf60b52 | ||
|
|
6e09ea25dd | ||
|
|
9a429d476d | ||
|
|
7923d1b2b2 | ||
|
|
7525c7e368 | ||
|
|
e111f76731 | ||
|
|
5d7d5e6b54 | ||
|
|
bc77462c27 | ||
|
|
4437a1f436 |
@@ -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."
|
||||||
@@ -4,18 +4,27 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
paths:
|
||||||
|
- 'backendia/**'
|
||||||
|
- 'docker-compose.prod.yml'
|
||||||
|
- '.gitea/workflows/deploy.yaml'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
runs-on: ubuntu-latest
|
# Correspond au label 'docker:host' de votre runner
|
||||||
|
runs-on: docker
|
||||||
steps:
|
steps:
|
||||||
- name: 📥 Récupération du code
|
- name: 📥 Récupération du code
|
||||||
uses: actions/checkout@v4
|
run: |
|
||||||
|
echo "📥 Récupération du code..."
|
||||||
|
git clone --depth 1 --branch ${{ github.ref_name }} "https://${{ github.actor }}:${{ secrets.GITHUB_TOKEN }}@git.kevlar.cloud/${{ github.repository }}.git" .
|
||||||
|
|
||||||
- name: 🚀 Build et Déploiement Docker
|
- name: 🚀 Build et Déploiement Docker
|
||||||
run: |
|
run: |
|
||||||
echo "🚀 Démarrage du déploiement..."
|
echo "🚀 Démarrage du déploiement..."
|
||||||
docker compose -f docker-compose.prod.yml up -d --build --remove-orphans
|
docker rm -f backendia-prod 2>/dev/null || true
|
||||||
|
docker compose -f docker-compose.prod.yml up -d --build --remove-orphans --force-recreate
|
||||||
echo "🧹 Nettoyage des anciennes images inutilisées..."
|
echo "🧹 Nettoyage des anciennes images inutilisées..."
|
||||||
docker image prune -f
|
docker image prune -f
|
||||||
echo "✅ Déploiement terminé avec succès !"
|
echo "✅ Déploiement terminé avec succès !"
|
||||||
|
|||||||
@@ -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é
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}`, {
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
+35
-5
@@ -36,6 +36,7 @@ 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.serialize(() => {
|
||||||
db.run(`CREATE TABLE IF NOT EXISTS user_stats (
|
db.run(`CREATE TABLE IF NOT EXISTS user_stats (
|
||||||
wallet_hash TEXT PRIMARY KEY,
|
wallet_hash TEXT PRIMARY KEY,
|
||||||
photo_count INTEGER DEFAULT 0,
|
photo_count INTEGER DEFAULT 0,
|
||||||
@@ -83,10 +84,13 @@ const db = new sqlite3.Database(dbPath, (err) => {
|
|||||||
"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');
|
||||||
|
const dev = process.env.NODE_ENV !== 'production';
|
||||||
|
|
||||||
|
let nextApp;
|
||||||
|
try {
|
||||||
|
const next = require('next');
|
||||||
|
nextApp = next({ dev, dir: dashboardDir });
|
||||||
|
} catch (e) {
|
||||||
|
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(`=================================`);
|
||||||
console.log(`Serveur Backend IA démarré`);
|
console.log(`Serveur Backend IA & Dashboard démarré`);
|
||||||
console.log(`Port: ${PORT}`);
|
console.log(`Port: ${PORT}`);
|
||||||
console.log(`Dossiers:`);
|
console.log(`Dossiers:`);
|
||||||
console.log(` - Images: ${imagesDir}`);
|
console.log(` - Images: ${imagesDir}`);
|
||||||
console.log(` - Data : ${dataDir}`);
|
console.log(` - Data : ${dataDir}`);
|
||||||
console.log(` - Export: ${exportsDir}`);
|
console.log(` - Export: ${exportsDir}`);
|
||||||
console.log(`=================================`);
|
console.log(`=================================`);
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
startServer();
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
name: backendia
|
||||||
|
|
||||||
services:
|
services:
|
||||||
backendia:
|
backendia:
|
||||||
build:
|
build:
|
||||||
@@ -6,7 +8,7 @@ services:
|
|||||||
container_name: backendia-prod
|
container_name: backendia-prod
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:3000:3000"
|
- "3005:3000"
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- PORT=3000
|
- PORT=3000
|
||||||
|
|||||||
+2
-3
@@ -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,
|
||||||
|
|||||||
+377
-42
@@ -1,90 +1,425 @@
|
|||||||
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(
|
|
||||||
seedColor: primaryColor,
|
|
||||||
brightness: Brightness.light,
|
brightness: Brightness.light,
|
||||||
|
colorScheme: ColorScheme.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,
|
||||||
shape: RoundedRectangleBorder(
|
backgroundColor: activePrimary,
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
floatingActionButtonTheme: const FloatingActionButtonThemeData(
|
|
||||||
backgroundColor: primaryColor,
|
|
||||||
foregroundColor: Colors.white,
|
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: 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,
|
||||||
|
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(
|
|
||||||
seedColor: primaryColor,
|
|
||||||
brightness: Brightness.dark,
|
brightness: Brightness.dark,
|
||||||
|
colorScheme: ColorScheme.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(
|
||||||
|
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(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
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),
|
||||||
|
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)),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,37 @@
|
|||||||
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 {
|
||||||
@@ -31,6 +44,16 @@ class ThemeProvider with ChangeNotifier {
|
|||||||
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:
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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: Padding(
|
||||||
|
padding: const EdgeInsets.all(32.0),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.shield, size: 64, color: Colors.grey[400]),
|
GlassContainer(
|
||||||
const SizedBox(height: 16),
|
borderRadius: 30,
|
||||||
const Text('Aucune arme enregistrée'),
|
padding: const EdgeInsets.all(28),
|
||||||
|
child: Icon(
|
||||||
|
Icons.shield_outlined,
|
||||||
|
size: 64,
|
||||||
|
color: isDark ? AppTheme.darkTextMuted : AppTheme.lightTextMuted,
|
||||||
|
),
|
||||||
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
ElevatedButton(
|
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,
|
onPressed: _showAddWeaponDialog,
|
||||||
child: const Text('Ajouter ma première arme'),
|
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,
|
||||||
|
blur: 14,
|
||||||
|
glowColor: primaryColor,
|
||||||
|
borderColor: isDark
|
||||||
|
? primaryColor.withValues(alpha: 0.18)
|
||||||
|
: primaryColor.withValues(alpha: 0.12),
|
||||||
|
margin: const EdgeInsets.only(bottom: 14),
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
await Navigator.push(
|
await Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(builder: (_) => WeaponDetailScreen(weapon: weapon)),
|
MaterialPageRoute(
|
||||||
|
builder: (_) => WeaponDetailScreen(weapon: weapon),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
_loadWeapons(); // Reload in case it was edited or maintenance was added
|
_loadWeapons();
|
||||||
},
|
},
|
||||||
onLongPress: () => _confirmDelete(weapon),
|
onLongPress: () => _confirmDelete(weapon),
|
||||||
child: Padding(
|
child: Column(
|
||||||
// La hauteur du cadre s'adapte automatiquement à la liste d'accessoires.
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
children: [
|
||||||
child: Row(
|
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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 16),
|
),
|
||||||
|
child: WeaponTypeIcon(
|
||||||
|
type: weapon.type,
|
||||||
|
color: primaryColor,
|
||||||
|
size: 28,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
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)),
|
Text(
|
||||||
|
weapon.displayName,
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'${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) ...[
|
if (accessories.isNotEmpty) ...[
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 12),
|
||||||
Wrap(
|
Wrap(
|
||||||
spacing: 6,
|
spacing: 6,
|
||||||
runSpacing: 6,
|
runSpacing: 6,
|
||||||
children: accessories,
|
children: accessories,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 6),
|
|
||||||
] else
|
|
||||||
const SizedBox(height: 2),
|
|
||||||
Text(
|
|
||||||
'${weapon.type.displayName} • ${weapon.caliber}',
|
|
||||||
style: const TextStyle(fontSize: 13, color: Colors.grey),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
|
||||||
children: [
|
|
||||||
Text('${weapon.magazineCount} chargeurs', style: const TextStyle(fontSize: 12)),
|
|
||||||
Text('${weapon.magazineCapacity} coups', style: const TextStyle(fontSize: 12, color: Colors.grey)),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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,88 +113,139 @@ 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: SingleChildScrollView(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
_buildTypeFilterChip('Toutes les cibles', null),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_buildTypeFilterChip(
|
||||||
|
'Cibles 1-10',
|
||||||
|
TargetType.concentric,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_buildTypeFilterChip(
|
||||||
|
'Silhouettes',
|
||||||
|
TargetType.silhouette,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTypeFilterChip(String label, TargetType? type) {
|
||||||
|
final isSelected = _filterType == type;
|
||||||
|
return ChoiceChip(
|
||||||
|
label: Text(label),
|
||||||
|
selected: isSelected,
|
||||||
|
onSelected: (selected) {
|
||||||
|
if (selected) {
|
||||||
|
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(
|
child: SafeArea(
|
||||||
top: false,
|
top: false,
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
const Icon(Icons.event, size: 18, color: AppTheme.primaryColor),
|
||||||
child: OutlinedButton.icon(
|
const SizedBox(width: 8),
|
||||||
onPressed: _pickDateRange,
|
Text(
|
||||||
icon: const Icon(Icons.date_range, size: 18),
|
'Période : $start - $end',
|
||||||
label: Text(
|
style: TextStyle(
|
||||||
_selectedDateRange == null
|
fontSize: 13,
|
||||||
? 'Choisir une période'
|
fontWeight: FontWeight.w600,
|
||||||
: '${DateFormat('dd/MM/yy').format(_selectedDateRange!.start)} - ${DateFormat('dd/MM/yy').format(_selectedDateRange!.end)}',
|
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
const Spacer(),
|
||||||
if (_selectedDateRange != null)
|
InkWell(
|
||||||
IconButton(
|
onTap: () {
|
||||||
icon: const Icon(Icons.close, color: AppTheme.errorColor),
|
|
||||||
onPressed: () {
|
|
||||||
setState(() => _selectedDateRange = null);
|
setState(() => _selectedDateRange = null);
|
||||||
_loadSessions();
|
_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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -234,45 +253,81 @@ class _HistoryScreenState extends State<HistoryScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildEmptyState() {
|
Widget _buildEmptyState(bool isDark) {
|
||||||
return Center(
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(32.0),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.history, size: 64, color: Colors.grey[400]),
|
Container(
|
||||||
const SizedBox(height: 16),
|
padding: const EdgeInsets.all(24),
|
||||||
const Text('Aucune session sur cette période'),
|
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() {
|
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(
|
||||||
|
(context, index) {
|
||||||
final session = _sessions[index];
|
final session = _sessions[index];
|
||||||
return Padding(
|
return SessionListItem(
|
||||||
padding: const EdgeInsets.only(bottom: 12),
|
|
||||||
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,60 +19,99 @@ 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;
|
||||||
|
|
||||||
|
// Calcul de la couleur du score selon la moyenne par tir
|
||||||
|
final primaryColor = Theme.of(context).colorScheme.primary;
|
||||||
|
final avg = session.averageScore;
|
||||||
|
Color scoreColor = primaryColor;
|
||||||
|
if (avg >= 9.0) {
|
||||||
|
scoreColor = AppTheme.secondaryColor;
|
||||||
|
} else if (avg >= 7.5) {
|
||||||
|
scoreColor = primaryColor;
|
||||||
|
} else {
|
||||||
|
scoreColor = isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary;
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
onTap: onTap,
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(12),
|
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
// Thumbnail (from first target)
|
// Aperçu de la cible avec bordure nette
|
||||||
ClipRRect(
|
ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(12),
|
||||||
child: SizedBox(
|
child: Container(
|
||||||
width: 60,
|
width: 56,
|
||||||
height: 60,
|
height: 56,
|
||||||
child: _buildThumbnail(),
|
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: [
|
children: [
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
const Icon(
|
|
||||||
Icons.shield,
|
|
||||||
size: 16,
|
|
||||||
color: AppTheme.primaryColor,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
Text(
|
Text(
|
||||||
session.weapon,
|
session.weapon,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontWeight: FontWeight.bold,
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
|
||||||
),
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
],
|
const SizedBox(height: 3),
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
Text(
|
||||||
DateFormat('dd/MM/yyyy HH:mm').format(session.createdAt),
|
formattedDate,
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
color: textSecondary,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
),
|
||||||
|
const SizedBox(height: 5),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.track_changes, size: 14, color: Colors.grey[600]),
|
Container(
|
||||||
const SizedBox(width: 4),
|
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: (isDark ? Colors.white : Colors.black).withValues(alpha: 0.06),
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'${session.distance}m',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
Text(
|
Text(
|
||||||
'${session.targetCount} cible(s) • ${session.distance}m',
|
'${session.targetCount} cible${session.targetCount > 1 ? 's' : ''} • ${session.totalShots} tirs',
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
style: TextStyle(
|
||||||
color: Colors.grey[600],
|
fontSize: 11,
|
||||||
|
color: textMuted,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -80,41 +120,58 @@ class SessionListItem extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Score
|
// Score et moyenne stylisés façon cyber HUD
|
||||||
Column(
|
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,
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'${session.totalScore}',
|
'${session.totalScore}',
|
||||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
style: TextStyle(
|
||||||
fontWeight: FontWeight.bold,
|
fontSize: 18,
|
||||||
color: AppTheme.primaryColor,
|
fontWeight: FontWeight.w900,
|
||||||
|
color: scoreColor,
|
||||||
|
letterSpacing: -0.5,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
'${session.totalShots} tirs',
|
'Moy. ${session.averageScore.toStringAsFixed(1)}',
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
style: TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: scoreColor,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
|
||||||
// Delete button
|
// Bouton supprimer
|
||||||
if (onDelete != null)
|
if (onDelete != null) ...[
|
||||||
|
const SizedBox(width: 4),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.delete_outline),
|
icon: const Icon(Icons.delete_outline_rounded),
|
||||||
onPressed: onDelete,
|
onPressed: onDelete,
|
||||||
color: Colors.grey,
|
color: textMuted,
|
||||||
iconSize: 20,
|
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);
|
||||||
|
|
||||||
@@ -122,20 +179,18 @@ class SessionListItem extends StatelessWidget {
|
|||||||
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],
|
|
||||||
child: Icon(
|
|
||||||
Icons.track_changes,
|
Icons.track_changes,
|
||||||
color: Colors.grey[400],
|
color: isDark ? AppTheme.darkTextMuted : AppTheme.lightTextMuted,
|
||||||
),
|
size: 24,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+747
-258
File diff suppressed because it is too large
Load Diff
@@ -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),
|
|
||||||
|
return GlassContainer(
|
||||||
|
borderRadius: 18,
|
||||||
|
blur: 14,
|
||||||
|
glowColor: color,
|
||||||
|
borderColor: isDark ? color.withValues(alpha: 0.22) : color.withValues(alpha: 0.18),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||||
child: Column(
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Icon(icon, color: color, size: 32),
|
Row(
|
||||||
const SizedBox(height: 8),
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
Text(
|
children: [
|
||||||
value,
|
Container(
|
||||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
padding: const EdgeInsets.all(8),
|
||||||
fontWeight: FontWeight.bold,
|
decoration: BoxDecoration(
|
||||||
color: color,
|
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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
child: Icon(icon, color: color, size: 20),
|
||||||
Text(
|
),
|
||||||
title,
|
if (subtitle != null)
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
Container(
|
||||||
color: Colors.grey[600],
|
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,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: textSecondary,
|
||||||
|
letterSpacing: 0.1,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,10 +135,6 @@ 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,
|
||||||
@@ -103,12 +153,12 @@ 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())
|
||||||
@@ -119,90 +169,159 @@ class _SessionSetupScreenState extends State<SessionSetupScreen> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
const Text(
|
|
||||||
'Informations générales',
|
|
||||||
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
|
|
||||||
// AFFICHAGE CONDITIONNEL : Armurerie vide vs Armurerie remplie
|
|
||||||
if (_availableWeapons.isEmpty)
|
if (_availableWeapons.isEmpty)
|
||||||
Container(
|
_buildEmptyArmoryState(isDark)
|
||||||
padding: const EdgeInsets.all(16),
|
else ...[
|
||||||
|
_buildWeaponAndDateSection(isDark, formattedDate),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
_buildDistanceSection(isDark),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
_buildShotsSection(isDark),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
_buildStartButton(),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildEmptyArmoryState(bool isDark) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppTheme.errorColor.withValues(alpha: 0.1),
|
color: isDark ? AppTheme.darkSurface : AppTheme.lightSurface,
|
||||||
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
|
borderRadius: BorderRadius.circular(16),
|
||||||
border: Border.all(color: AppTheme.errorColor.withValues(alpha: 0.5)),
|
border: Border.all(
|
||||||
|
color: AppTheme.errorColor.withValues(alpha: 0.5),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.warning_amber_rounded, color: AppTheme.errorColor, size: 48),
|
const Icon(
|
||||||
const SizedBox(height: 8),
|
Icons.warning_amber_rounded,
|
||||||
const Text(
|
color: AppTheme.errorColor,
|
||||||
'Ton armurerie est vide.',
|
size: 52,
|
||||||
style: TextStyle(fontWeight: FontWeight.bold),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 12),
|
||||||
const Text(
|
Text(
|
||||||
'Tu dois d\'abord ajouter une arme pour pouvoir démarrer une session de tir.',
|
'Armurerie vide',
|
||||||
textAlign: TextAlign.center,
|
style: TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
),
|
||||||
|
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(
|
ElevatedButton.icon(
|
||||||
onPressed: () {
|
onPressed: _openArmory,
|
||||||
Navigator.push(
|
icon: const Icon(Icons.shield),
|
||||||
context,
|
label: const Text('Aller à l\'armurerie'),
|
||||||
MaterialPageRoute(builder: (_) => const WeaponListScreen()),
|
|
||||||
).then((_) {
|
|
||||||
_loadWeapons();
|
|
||||||
});
|
|
||||||
},
|
|
||||||
icon: const Icon(Icons.add),
|
|
||||||
label: const Text('ALLER À MON ARMURERIE'),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
)
|
);
|
||||||
else
|
}
|
||||||
Column(
|
|
||||||
|
Widget _buildWeaponAndDateSection(bool isDark, String formattedDate) {
|
||||||
|
return Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
DropdownButtonFormField<Weapon>(
|
Row(
|
||||||
initialValue: _selectedWeapon,
|
children: [
|
||||||
decoration: InputDecoration(
|
const Icon(Icons.tune, color: AppTheme.primaryColor, size: 20),
|
||||||
labelText: 'Sélectionner une arme',
|
const SizedBox(width: 8),
|
||||||
prefixIcon: const Icon(Icons.shield),
|
Text(
|
||||||
border: OutlineInputBorder(
|
'Arme & Date',
|
||||||
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
|
style: TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
items: _availableWeapons.map((w) => DropdownMenuItem(
|
],
|
||||||
value: w,
|
),
|
||||||
child: Text(w.displayName),
|
const SizedBox(height: 16),
|
||||||
)).toList(),
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
if (value != null) {
|
if (value == null) return;
|
||||||
|
|
||||||
|
if (value == _addWeaponValue) {
|
||||||
|
// L'item d'action n'est pas une arme : on restaure aussitot
|
||||||
|
// la selection precedente avant d'ouvrir l'armurerie.
|
||||||
|
_weaponFieldKey.currentState?.didChange(_selectedWeapon?.id);
|
||||||
|
_openArmory();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final weapon in _availableWeapons) {
|
||||||
|
if (weapon.id == value) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_selectedWeapon = value;
|
_selectedWeapon = weapon;
|
||||||
_updateSettingsForWeapon(value);
|
_updateSettingsForWeapon(weapon);
|
||||||
});
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
// NOUVEAU CHAMP : Sélecteur de date interactif
|
|
||||||
InkWell(
|
InkWell(
|
||||||
onTap: _availableWeapons.isEmpty ? null : _pickDate,
|
onTap: _pickDate,
|
||||||
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
|
borderRadius: BorderRadius.circular(12),
|
||||||
child: IgnorePointer(
|
child: IgnorePointer(
|
||||||
child: TextFormField(
|
child: TextFormField(
|
||||||
decoration: InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: 'Date de la session',
|
labelText: 'Date de séance',
|
||||||
prefixIcon: const Icon(Icons.calendar_today),
|
prefixIcon: Icon(Icons.calendar_today_outlined),
|
||||||
border: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
controller: TextEditingController(text: formattedDate),
|
controller: TextEditingController(text: formattedDate),
|
||||||
),
|
),
|
||||||
@@ -210,114 +329,204 @@ class _SessionSetupScreenState extends State<SessionSetupScreen> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
|
|
||||||
// Distance selector
|
|
||||||
const Text(
|
|
||||||
'Distance de tir (mètres)',
|
|
||||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
const Icon(Icons.straighten, color: AppTheme.secondaryColor, size: 20),
|
||||||
child: Slider(
|
const SizedBox(width: 8),
|
||||||
value: _distance.toDouble(),
|
Text(
|
||||||
min: 5,
|
'Distance de Tir',
|
||||||
max: 300,
|
style: TextStyle(
|
||||||
divisions: 59,
|
fontSize: 15,
|
||||||
label: '${_distance}m',
|
fontWeight: FontWeight.w700,
|
||||||
onChanged: _availableWeapons.isEmpty ? null : (value) {
|
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
|
||||||
setState(() {
|
|
||||||
_distance = value.round();
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
Container(
|
Container(
|
||||||
width: 70,
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||||
height: 50,
|
|
||||||
alignment: Alignment.center,
|
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppTheme.secondaryColor.withValues(alpha: 0.1),
|
color: AppTheme.secondaryColor.withValues(alpha: isDark ? 0.2 : 0.1),
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(
|
||||||
|
color: AppTheme.secondaryColor.withValues(alpha: 0.4),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
'${_distance}m',
|
'${_distance}m',
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 18,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.w800,
|
||||||
color: AppTheme.secondaryColor,
|
color: AppTheme.secondaryColor,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 12),
|
||||||
|
Wrap(
|
||||||
// Shots per target
|
spacing: 8,
|
||||||
const Text(
|
runSpacing: 8,
|
||||||
'Nombre de balles pour la cible actuelle',
|
children: _presetDistances.map((d) {
|
||||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500),
|
final isSelected = _distance == d;
|
||||||
|
return ChoiceChip(
|
||||||
|
label: Text('${d}m'),
|
||||||
|
selected: isSelected,
|
||||||
|
onSelected: (selected) {
|
||||||
|
if (selected) setState(() => _distance = d);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
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(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Icon(Icons.ads_click, color: primaryColor, size: 20),
|
||||||
child: Slider(
|
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(),
|
value: _shotsPerTarget.toDouble(),
|
||||||
min: 1,
|
min: 1,
|
||||||
max: 50,
|
max: 50,
|
||||||
divisions: 49,
|
divisions: 49,
|
||||||
label: '$_shotsPerTarget',
|
activeColor: primaryColor,
|
||||||
onChanged: _availableWeapons.isEmpty ? null : (value) {
|
onChanged: (val) {
|
||||||
setState(() {
|
setState(() => _shotsPerTarget = val.round());
|
||||||
_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(
|
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)
|
onPressed: (_availableWeapons.isEmpty || _selectedWeapon == null)
|
||||||
? null
|
? null
|
||||||
: _startSession,
|
: _startSession,
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: AppTheme.primaryColor,
|
backgroundColor: Colors.transparent,
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
shadowColor: Colors.transparent,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 18),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
|
borderRadius: BorderRadius.circular(16),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
icon: const Icon(Icons.play_arrow),
|
icon: const Icon(Icons.play_arrow_rounded, size: 26),
|
||||||
label: const Text(
|
label: const Text(
|
||||||
'DÉMARRER LA SESSION',
|
'DÉMARRER LA SESSION',
|
||||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
style: TextStyle(
|
||||||
),
|
fontSize: 16,
|
||||||
),
|
fontWeight: FontWeight.w800,
|
||||||
],
|
letterSpacing: 0.8,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
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';
|
||||||
|
|
||||||
@@ -26,7 +26,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 +59,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 +165,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;
|
||||||
|
final primary = themeProvider.primaryColor;
|
||||||
|
|
||||||
|
return AlertDialog(
|
||||||
|
backgroundColor: isDark ? AppTheme.darkSurface : AppTheme.lightSurface,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||||
|
title: Text(
|
||||||
|
'Personnalisation & Thème',
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
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,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
_buildThemeOption(ThemeMode.system, 'Automatique', Icons.brightness_auto),
|
Container(
|
||||||
_buildThemeOption(ThemeMode.light, 'Clair', Icons.light_mode),
|
width: 14,
|
||||||
_buildThemeOption(ThemeMode.dark, 'Sombre', Icons.dark_mode),
|
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: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(context),
|
onPressed: () => Navigator.pop(dialogCtx),
|
||||||
child: const Text('Fermer'),
|
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 +371,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 +392,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 +436,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(
|
||||||
@@ -366,7 +515,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 +533,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 +541,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 +580,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 +605,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 +660,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()
|
||||||
@@ -525,14 +694,17 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
|
|
||||||
@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 +712,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),
|
||||||
|
glowColor: primary,
|
||||||
|
borderColor: isDark ? primary.withValues(alpha: 0.2) : primary.withValues(alpha: 0.12),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
InkWell(
|
||||||
onTap: _showThemeDialog,
|
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 +842,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 +903,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 +914,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 +981,20 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 20),
|
||||||
_buildSectionHeader('À propos'),
|
_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 +1008,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 +1030,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,
|
|
||||||
trailing: const Icon(Icons.chevron_right, color: Colors.grey),
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(12.0),
|
|
||||||
side: BorderSide(color: Colors.grey.withAlpha(50), width: 1),
|
|
||||||
),
|
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(9),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
+116
-33
@@ -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,8 +12,7 @@ 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>>();
|
||||||
|
|
||||||
@@ -32,9 +32,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,59 +50,145 @@ 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(
|
||||||
|
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, 8),
|
||||||
|
),
|
||||||
|
if (isDark)
|
||||||
|
BoxShadow(
|
||||||
|
color: primaryColor.withValues(alpha: 0.12),
|
||||||
|
blurRadius: 16,
|
||||||
|
spreadRadius: -4,
|
||||||
offset: const Offset(0, -2),
|
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,
|
||||||
),
|
),
|
||||||
BottomNavigationBarItem(
|
|
||||||
icon: Icon(Icons.history_outlined),
|
|
||||||
activeIcon: Icon(Icons.history),
|
|
||||||
label: 'Historique',
|
|
||||||
),
|
),
|
||||||
BottomNavigationBarItem(
|
child: Row(
|
||||||
icon: Icon(Icons.analytics_outlined),
|
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||||
activeIcon: Icon(Icons.analytics),
|
children: List.generate(navItems.length, (index) {
|
||||||
label: 'Stats',
|
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.shield_outlined),
|
|
||||||
activeIcon: Icon(Icons.shield),
|
|
||||||
label: 'Armurerie',
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,10 +105,12 @@ class AiExportService {
|
|||||||
}) 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);
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user