Compare commits
19
Commits
v0.0.7
..
b789df8dac
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b789df8dac | ||
|
|
25caf6ddf8 | ||
|
|
a9651588bb | ||
|
|
32582aba3d | ||
|
|
32143c5bb1 | ||
|
|
6ee0839db5 | ||
|
|
8804d8b6a1 | ||
|
|
869835f234 | ||
|
|
3d9e574309 | ||
|
|
3f79252bb5 | ||
|
|
f6f134f2a5 | ||
|
|
56e88c3e06 | ||
|
|
0ab9043d82 | ||
|
|
eeb857d452 | ||
|
|
6dc525c0db | ||
|
|
96cc487b55 | ||
|
|
8dc6542603 | ||
|
|
e943538133 | ||
|
|
7f1fa2d80b |
@@ -19,12 +19,19 @@ jobs:
|
||||
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)
|
||||
- name: 🔨 Compilation de l'APK (Conteneur Flutter / Android SDK avec Caches)
|
||||
run: |
|
||||
echo "🚀 Démarrage de la compilation Flutter dans Docker..."
|
||||
# Utilisation du volume partagé /data du conteneur runner
|
||||
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"
|
||||
|
||||
@@ -1,5 +1,40 @@
|
||||
# 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
|
||||
|
||||
### Ajouté
|
||||
|
||||
@@ -98,6 +98,19 @@ flutter test --coverage
|
||||
- Visualisation des sessions passées
|
||||
- Suppression de sessions
|
||||
|
||||
### Didacticiel (visite guidée)
|
||||
- Démarre automatiquement à la **première utilisation** sur l'écran d'accueil :
|
||||
voile sombre, « trou de lumière » autour de l'élément à découvrir et bulle
|
||||
explicative (bouton nouvelle session, télémétrie, barre de navigation, réglages)
|
||||
- Visite dédiée à la **première ouverture de l'éditeur d'impacts** : ajouter,
|
||||
déplacer, **pincer pour zoomer**, valider
|
||||
- **Main animée** qui mime le geste attendu (tap, appui long, glisser,
|
||||
pincement à deux doigts, balayage), dessinée au CustomPainter — aucun asset
|
||||
- « Passer » interrompt la visite, un tap n'importe où passe à l'étape suivante ;
|
||||
chaque visite n'est jouée qu'une fois (mémorisée dans les SharedPreferences)
|
||||
- **Paramètres > Aide & didacticiel > Revoir le didacticiel** : réinitialise
|
||||
toutes les visites, qui rejouent dès le retour sur l'écran concerné
|
||||
|
||||
### Interface utilisateur
|
||||
- Thème sombre adapté au tir
|
||||
- Support multilingue (Français)
|
||||
@@ -135,3 +148,9 @@ history_chart.dart Graphique d'évolution des 10 dernières sessions
|
||||
statistics_screen.dart Écran statistiques avec filtrage par période
|
||||
heat_map_widget.dart Heat map avec gradient bleu→rouge
|
||||
backup_service.dart Export/import JSON des sessions, stats et armurerie
|
||||
tutorial_service.dart Persistance des visites guidées déjà vues (SharedPreferences)
|
||||
tutorial_provider.dart État du didacticiel : visites à jouer, réactivation
|
||||
tutorial_coach.dart Lance une visite guidée par-dessus l'écran courant
|
||||
tutorial_step.dart Modèle d'étape : cible, texte, geste, forme du spot
|
||||
tutorial_overlay.dart Voile sombre, trou de lumière et bulle explicative
|
||||
tutorial_hand.dart Main animée qui mime le geste (tap, appui long, pincement…)
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<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" />
|
||||
|
||||
<!-- Pour Android 12 et inférieur -->
|
||||
@@ -9,7 +11,8 @@
|
||||
<application
|
||||
android:label="bully"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:usesCleartextTraffic="true">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
|
||||
+2
-3
@@ -1,7 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'core/theme/theme_provider.dart';
|
||||
import 'core/theme/app_theme.dart';
|
||||
import 'main_navigation_holder.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
|
||||
@@ -15,8 +14,8 @@ class BullyApp extends StatelessWidget {
|
||||
return MaterialApp(
|
||||
title: 'Bully - Analyse de Cibles',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: AppTheme.lightTheme,
|
||||
darkTheme: AppTheme.darkTheme,
|
||||
theme: themeProvider.lightTheme,
|
||||
darkTheme: themeProvider.darkTheme,
|
||||
themeMode: themeProvider.themeMode,
|
||||
localizationsDelegates: const [
|
||||
GlobalMaterialLocalizations.delegate,
|
||||
|
||||
+375
-40
@@ -1,89 +1,424 @@
|
||||
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 {
|
||||
AppTheme._();
|
||||
|
||||
static const Color primaryColor = Color(0xFF1E88E5);
|
||||
static const Color secondaryColor = Color(0xFF43A047);
|
||||
static const Color errorColor = Color(0xFFE53935);
|
||||
static const Color warningColor = Color(0xFFFFA726);
|
||||
static const Color successColor = Color(0xFF66BB6A);
|
||||
// Accents par défaut (compatibilité statique)
|
||||
static const Color primaryColor = Color(0xFF2563EB);
|
||||
static const Color primaryLight = Color(0xFF60A5FA);
|
||||
static const Color primaryDark = Color(0xFF1D4ED8);
|
||||
|
||||
static const Color backgroundColor = Color(0xFFF5F5F5);
|
||||
static const Color surfaceColor = Colors.white;
|
||||
static const Color textPrimary = Color(0xFF212121);
|
||||
static const Color textSecondary = Color(0xFF757575);
|
||||
static const Color secondaryColor = Color(0xFF10B981);
|
||||
static const Color secondaryDark = Color(0xFF059669);
|
||||
|
||||
// Impact colors for visualization
|
||||
static const Color impactColor = Color(0xFFFF5722);
|
||||
static const Color accentBlue = Color(0xFF0EA5E9);
|
||||
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 groupingCenterColor = Color(0xFF2196F3);
|
||||
static const Color groupingCircleColor = Color(0x4D2196F3);
|
||||
static const Color groupingCenterColor = Color(0xFF00E5FF);
|
||||
static const Color groupingCircleColor = Color(0x4D00E5FF);
|
||||
|
||||
// Score zone colors
|
||||
// Couleurs des zones de score cibles concentriques
|
||||
static const List<Color> zoneColors = [
|
||||
Color(0xFFFFEB3B), // Zone 10 - Gold
|
||||
Color(0xFFFFEB3B), // Zone 9
|
||||
Color(0xFFFFB300), // Zone 10 - Or
|
||||
Color(0xFFFFCA28), // Zone 9
|
||||
Color(0xFFFF5722), // Zone 8
|
||||
Color(0xFFFF5722), // Zone 7
|
||||
Color(0xFF2196F3), // Zone 6
|
||||
Color(0xFF2196F3), // Zone 5
|
||||
Color(0xFF4CAF50), // Zone 4
|
||||
Color(0xFF4CAF50), // Zone 3
|
||||
Color(0xFFFF7043), // Zone 7
|
||||
Color(0xFF29B6F6), // Zone 6
|
||||
Color(0xFF4FC3F7), // Zone 5
|
||||
Color(0xFF66BB6A), // Zone 4
|
||||
Color(0xFF81C784), // Zone 3
|
||||
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(
|
||||
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(
|
||||
elevation: 0,
|
||||
centerTitle: true,
|
||||
backgroundColor: primaryColor,
|
||||
foregroundColor: Colors.white,
|
||||
backgroundColor: lightSurface,
|
||||
foregroundColor: lightTextPrimary,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
titleTextStyle: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: lightTextPrimary,
|
||||
letterSpacing: 0.3,
|
||||
),
|
||||
),
|
||||
cardTheme: CardThemeData(
|
||||
elevation: 2,
|
||||
elevation: 0,
|
||||
color: lightSurface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
margin: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: const BorderSide(color: lightBorder, width: 1),
|
||||
),
|
||||
),
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||
elevation: 0,
|
||||
backgroundColor: activePrimary,
|
||||
foregroundColor: Colors.white,
|
||||
minimumSize: const Size(double.infinity, 50),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
textStyle: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.2,
|
||||
),
|
||||
),
|
||||
),
|
||||
floatingActionButtonTheme: const FloatingActionButtonThemeData(
|
||||
backgroundColor: primaryColor,
|
||||
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(
|
||||
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(
|
||||
elevation: 0,
|
||||
centerTitle: true,
|
||||
backgroundColor: darkBackground,
|
||||
foregroundColor: darkTextPrimary,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
titleTextStyle: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: darkTextPrimary,
|
||||
letterSpacing: 0.3,
|
||||
),
|
||||
),
|
||||
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(
|
||||
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,36 +1,59 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'app_theme.dart';
|
||||
|
||||
class ThemeProvider with ChangeNotifier {
|
||||
static const String _themeModeKey = 'user_theme_mode';
|
||||
|
||||
static const String _accentKey = 'user_accent_color';
|
||||
|
||||
ThemeMode _themeMode = ThemeMode.system;
|
||||
AppAccentColor _accent = AppAccentColor.blue;
|
||||
|
||||
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() {
|
||||
loadThemeMode();
|
||||
loadSettings();
|
||||
}
|
||||
|
||||
Future<void> loadThemeMode() async {
|
||||
Future<void> loadSettings() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final modeIndex = prefs.getInt(_themeModeKey);
|
||||
final accentId = prefs.getString(_accentKey);
|
||||
|
||||
if (modeIndex != null) {
|
||||
_themeMode = ThemeMode.values[modeIndex];
|
||||
notifyListeners();
|
||||
}
|
||||
if (accentId != null) {
|
||||
_accent = AppAccentColor.fromId(accentId);
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setThemeMode(ThemeMode mode) async {
|
||||
if (_themeMode == mode) return;
|
||||
|
||||
|
||||
_themeMode = mode;
|
||||
notifyListeners();
|
||||
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setInt(_themeModeKey, mode.index);
|
||||
}
|
||||
|
||||
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 {
|
||||
switch (_themeMode) {
|
||||
case ThemeMode.system:
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
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 innerContent = Container(
|
||||
padding: padding,
|
||||
decoration: BoxDecoration(
|
||||
color: resolvedBg,
|
||||
borderRadius: BorderRadius.circular(borderRadius),
|
||||
border: Border.all(color: resolvedBorderColor, width: 1.2),
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
|
||||
Widget content = blur > 0
|
||||
? ClipRRect(
|
||||
borderRadius: BorderRadius.circular(borderRadius),
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: blur, sigmaY: blur),
|
||||
child: innerContent,
|
||||
),
|
||||
)
|
||||
: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(borderRadius),
|
||||
child: innerContent,
|
||||
);
|
||||
|
||||
content = Container(
|
||||
margin: margin,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(borderRadius),
|
||||
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: 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 {
|
||||
handgun('Arme de Poing'),
|
||||
rifle('Arme d\'Épaule'),
|
||||
shotgun('Fusil à Pompe'),
|
||||
airgun('Airsoft / Airgun');
|
||||
handgun('Arme de Poing', Icons.shield),
|
||||
rifle('Arme d\'Épaule', Icons.filter_center_focus),
|
||||
shotgun('Fusil à Pompe', Icons.splitscreen),
|
||||
airgun('Airsoft / Airgun', Icons.air);
|
||||
|
||||
final String displayName;
|
||||
const WeaponType(this.displayName);
|
||||
final IconData defaultIcon;
|
||||
const WeaponType(this.displayName, this.defaultIcon);
|
||||
|
||||
static WeaponType fromString(String value) {
|
||||
return WeaponType.values.firstWhere(
|
||||
|
||||
@@ -239,12 +239,16 @@ class AnalysisProvider extends ChangeNotifier {
|
||||
}
|
||||
|
||||
/// Exporte l'image et le json vers le backend IA.
|
||||
/// [sessionId], [distance] et [weapon] proviennent du SessionProvider de
|
||||
/// [sessionId], [distance] et [caliber] proviennent du SessionProvider de
|
||||
/// l'écran appelant, pour que le dataset contienne les vraies métadonnées.
|
||||
///
|
||||
/// Le nom de l'arme n'est volontairement pas transmis : il est souvent
|
||||
/// personnalisé par l'utilisateur et n'apporte rien au modèle de détection.
|
||||
Future<AiExportResult> exportToAiBackend({
|
||||
String? sessionId,
|
||||
int? distance,
|
||||
String? weapon,
|
||||
String? caliber,
|
||||
int? expectedShots,
|
||||
}) async {
|
||||
if (_imagePath == null || _targetType == null) {
|
||||
_errorMessage = "Impossible d'exporter : image ou type de cible manquant.";
|
||||
@@ -269,7 +273,8 @@ class AnalysisProvider extends ChangeNotifier {
|
||||
targetRadius: _targetRadius,
|
||||
shots: _shots,
|
||||
distanceMeters: distance ?? 25,
|
||||
weaponName: weapon ?? 'Unknown',
|
||||
caliber: caliber ?? 'unknown',
|
||||
expectedShots: expectedShots,
|
||||
);
|
||||
|
||||
_state = AnalysisState.success;
|
||||
|
||||
@@ -784,7 +784,7 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
|
||||
// qu'elle ressorte sur le bouton.
|
||||
color: Colors.white,
|
||||
),
|
||||
label: 'TERMINER TOUT ET EXPORTER',
|
||||
label: 'TERMINER ET CONTRIBUER À L\'IA',
|
||||
color: AppTheme.warningColor,
|
||||
onPressed: () =>
|
||||
_finishSession(context, provider, export: true),
|
||||
@@ -942,7 +942,12 @@ class _AnalysisScreenContentState extends State<_AnalysisScreenContent> {
|
||||
exportResult = await provider.exportToAiBackend(
|
||||
sessionId: sessionProvider.activeSessionId,
|
||||
distance: sessionProvider.distance,
|
||||
weapon: sessionProvider.currentWeapon,
|
||||
caliber: sessionProvider.currentWeaponCaliber,
|
||||
// Hors session, shotsPerTarget vaut sa valeur par defaut : mieux
|
||||
// vaut ne rien annoncer qu'annoncer un nombre faux.
|
||||
expectedShots: sessionProvider.isSessionActive
|
||||
? sessionProvider.shotsPerTarget
|
||||
: null,
|
||||
);
|
||||
messenger.hideCurrentSnackBar();
|
||||
}
|
||||
|
||||
@@ -25,6 +25,9 @@ import 'package:provider/provider.dart';
|
||||
|
||||
import '../../core/theme/app_theme.dart';
|
||||
import '../../data/models/shot.dart';
|
||||
import '../../services/tutorial_service.dart';
|
||||
import '../tutorial/tutorial_coach.dart';
|
||||
import '../tutorial/tutorial_step.dart';
|
||||
import 'analysis_provider.dart';
|
||||
import 'widgets/target_overlay.dart';
|
||||
|
||||
@@ -43,12 +46,71 @@ class _ImpactEditorScreenState extends State<ImpactEditorScreen> {
|
||||
double _currentZoomScale = 1.0;
|
||||
String? _movingShotId;
|
||||
|
||||
// Clés du didacticiel : zone de travail et bouton de validation.
|
||||
final GlobalKey _tutoCanvasKey = GlobalKey();
|
||||
final GlobalKey _tutoValidateKey = GlobalKey();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_transformationController.addListener(_onTransformChanged);
|
||||
// Première ouverture de l'éditeur : on montre les gestes disponibles.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
TutorialCoach.maybeStart(
|
||||
context,
|
||||
tourId: TutorialTours.impactEditor,
|
||||
stepsBuilder: _buildEditorTutorialSteps,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
List<TutorialStep> _buildEditorTutorialSteps() => [
|
||||
TutorialStep(
|
||||
targetKey: _tutoCanvasKey,
|
||||
title: 'Ajouter un impact',
|
||||
description:
|
||||
'Touchez la cible à l\'endroit de l\'impact : il est ajouté '
|
||||
'immédiatement, même collé à un impact déjà placé. Le score est '
|
||||
'calculé automatiquement selon la zone touchée.',
|
||||
gesture: TutorialGesture.tap,
|
||||
icon: Icons.add_location_alt_outlined,
|
||||
spotPadding: 0,
|
||||
),
|
||||
TutorialStep(
|
||||
targetKey: _tutoCanvasKey,
|
||||
title: 'Déplacer un impact',
|
||||
description:
|
||||
'Appui long sur un impact, puis glissez le doigt pour l\'ajuster '
|
||||
'au millimètre. L\'impact reste visible au-dessus du doigt.',
|
||||
gesture: TutorialGesture.drag,
|
||||
icon: Icons.open_with,
|
||||
spotPadding: 0,
|
||||
),
|
||||
TutorialStep(
|
||||
targetKey: _tutoCanvasKey,
|
||||
title: 'Zoomer sur la cible',
|
||||
description:
|
||||
'Écartez deux doigts pour zoomer (jusqu\'à 12×) et placer vos '
|
||||
'impacts avec précision ; rapprochez-les pour dézoomer. À un '
|
||||
'doigt, vous faites glisser l\'image.',
|
||||
gesture: TutorialGesture.pinch,
|
||||
icon: Icons.zoom_in,
|
||||
spotPadding: 0,
|
||||
),
|
||||
TutorialStep(
|
||||
targetKey: _tutoValidateKey,
|
||||
title: 'Valider vos impacts',
|
||||
description:
|
||||
'VALIDER renvoie vers la synthèse avec les scores et le '
|
||||
'groupement. La corbeille, à gauche, efface tous les impacts '
|
||||
'sans toucher à la calibration.',
|
||||
gesture: TutorialGesture.tap,
|
||||
icon: Icons.check_circle_outline,
|
||||
spotPadding: 8,
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_transformationController.removeListener(_onTransformChanged);
|
||||
@@ -123,6 +185,7 @@ class _ImpactEditorScreenState extends State<ImpactEditorScreen> {
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
FloatingActionButton.extended(
|
||||
key: _tutoValidateKey,
|
||||
heroTag: 'validate_impacts',
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
backgroundColor: AppTheme.primaryColor,
|
||||
@@ -147,6 +210,7 @@ class _ImpactEditorScreenState extends State<ImpactEditorScreen> {
|
||||
|
||||
// Zone image plein écran : InteractiveViewer dans un body nu.
|
||||
Expanded(
|
||||
key: _tutoCanvasKey,
|
||||
child: InteractiveViewer(
|
||||
transformationController: _transformationController,
|
||||
minScale: 1.0,
|
||||
|
||||
@@ -169,8 +169,9 @@ class _CaptureScreenState extends State<CaptureScreen>
|
||||
final prev = _parallelismData;
|
||||
final bool changed = prev == null ||
|
||||
prev.status != data.status ||
|
||||
prev.pitchDegrees.toStringAsFixed(1) !=
|
||||
data.pitchDegrees.toStringAsFixed(1) ||
|
||||
prev.pose != data.pose ||
|
||||
prev.pitchDeviation.toStringAsFixed(1) !=
|
||||
data.pitchDeviation.toStringAsFixed(1) ||
|
||||
prev.rollDegrees.toStringAsFixed(1) !=
|
||||
data.rollDegrees.toStringAsFixed(1);
|
||||
|
||||
@@ -218,16 +219,21 @@ class _CaptureScreenState extends State<CaptureScreen>
|
||||
return 'ALIGNEZ LA CIBLE DANS LE CADRE';
|
||||
}
|
||||
|
||||
final bool onGround = _parallelismData!.pose == TargetPose.ground;
|
||||
|
||||
// Aligné → message de validation (avec bonus si la cible est détectée)
|
||||
if (_parallelismData!.isAligned) {
|
||||
return _targetReady
|
||||
? 'PARFAIT — CIBLE DÉTECTÉE, PRÊT'
|
||||
if (_targetReady) return 'PARFAIT — CIBLE DÉTECTÉE, PRÊT';
|
||||
return onGround
|
||||
? 'CIBLE AU SOL — PRÊT À PHOTOGRAPHIER'
|
||||
: 'PARALLÈLE OK — PRÊT À PHOTOGRAPHIER';
|
||||
}
|
||||
|
||||
// Mal aligné → message directif selon l'axe le plus dévié
|
||||
final double pitch = _parallelismData!.pitchDegrees;
|
||||
final double roll = _parallelismData!.rollDegrees;
|
||||
// Mal aligné → message directif selon l'axe le plus dévié.
|
||||
// On raisonne sur les écarts à la pose détectée, pas sur le tangage brut :
|
||||
// à plat, celui-ci vaut -90° alors que le cadrage peut être parfait.
|
||||
final double pitch = _parallelismData!.pitchDeviation;
|
||||
final double roll = _parallelismData!.rollDeviation;
|
||||
|
||||
if (pitch.abs() >= roll.abs()) {
|
||||
return pitch > 0
|
||||
@@ -776,15 +782,25 @@ class _CaptureScreenState extends State<CaptureScreen>
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
data.pose == TargetPose.ground ? 'CIBLE AU SOL' : 'CIBLE AU MUR',
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
_buildAngleRow(
|
||||
label: 'Pitch',
|
||||
value: data.pitchDegrees,
|
||||
value: data.pitchDeviation,
|
||||
color: color,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
_buildAngleRow(
|
||||
label: 'Roll ',
|
||||
value: data.rollDegrees,
|
||||
value: data.rollDeviation,
|
||||
color: color,
|
||||
),
|
||||
],
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:provider/provider.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../../core/constants/app_constants.dart';
|
||||
import '../../core/theme/app_theme.dart';
|
||||
import '../../core/widgets/weapon_type_icon.dart';
|
||||
import '../../data/models/weapon.dart';
|
||||
import '../../data/models/maintenance.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 Divider(),
|
||||
_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('Chargeurs', '${_weapon.magazineCount} x ${_weapon.magazineCapacity} coups'),
|
||||
if (_weapon.notes != null && _weapon.notes!.isNotEmpty) ...[
|
||||
@@ -313,7 +333,20 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
|
||||
DropdownButtonFormField<WeaponType>(
|
||||
initialValue: selectedType,
|
||||
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!),
|
||||
),
|
||||
_autoScrollOnFocus(TextField(
|
||||
|
||||
@@ -2,14 +2,13 @@ import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../core/constants/app_constants.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/repositories/session_repository.dart';
|
||||
import 'weapon_detail_screen.dart';
|
||||
|
||||
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;
|
||||
|
||||
const WeaponListScreen({super.key, this.refreshTick = 0});
|
||||
@@ -49,118 +48,242 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
return Scaffold(
|
||||
extendBody: true,
|
||||
appBar: AppBar(
|
||||
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
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _weapons.isEmpty
|
||||
? _buildEmptyState()
|
||||
: _buildWeaponList(),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: _showAddWeaponDialog,
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
? _buildEmptyState(isDark)
|
||||
: _buildWeaponList(isDark),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyState() {
|
||||
Widget _buildEmptyState(bool isDark) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.shield, size: 64, color: Colors.grey[400]),
|
||||
const SizedBox(height: 16),
|
||||
const Text('Aucune arme enregistrée'),
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton(
|
||||
onPressed: _showAddWeaponDialog,
|
||||
child: const Text('Ajouter ma première arme'),
|
||||
),
|
||||
],
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
GlassContainer(
|
||||
borderRadius: 30,
|
||||
padding: const EdgeInsets.all(28),
|
||||
child: Icon(
|
||||
Icons.shield_outlined,
|
||||
size: 64,
|
||||
color: isDark ? AppTheme.darkTextMuted : AppTheme.lightTextMuted,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Aucune arme enregistrée',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Ajoutez vos armes pour suivre vos tirs, chargeurs et entretiens.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton.icon(
|
||||
onPressed: _showAddWeaponDialog,
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Ajouter ma première arme'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildWeaponList() {
|
||||
Widget _buildWeaponList(bool isDark) {
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.all(AppConstants.defaultPadding),
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
AppConstants.defaultPadding,
|
||||
12,
|
||||
AppConstants.defaultPadding,
|
||||
100, // Espace pour le floating dock
|
||||
),
|
||||
itemCount: _weapons.length,
|
||||
itemBuilder: (context, index) {
|
||||
final weapon = _weapons[index];
|
||||
final accessories = _accessoryChips(weapon);
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
onTap: () async {
|
||||
await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => WeaponDetailScreen(weapon: weapon)),
|
||||
);
|
||||
_loadWeapons(); // Reload in case it was edited or maintenance was added
|
||||
},
|
||||
onLongPress: () => _confirmDelete(weapon),
|
||||
child: Padding(
|
||||
// La hauteur du cadre s'adapte automatiquement à la liste d'accessoires.
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
final accessories = _accessoryChips(weapon, isDark);
|
||||
final primaryColor = Theme.of(context).colorScheme.primary;
|
||||
|
||||
return GlassContainer(
|
||||
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 {
|
||||
await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => WeaponDetailScreen(weapon: weapon),
|
||||
),
|
||||
);
|
||||
_loadWeapons();
|
||||
},
|
||||
onLongPress: () => _confirmDelete(weapon),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
CircleAvatar(
|
||||
backgroundColor: AppTheme.primaryColor.withValues(alpha: 0.1),
|
||||
child: Icon(
|
||||
weapon.type == WeaponType.handgun ? Icons.shield : Icons.ads_click,
|
||||
color: AppTheme.primaryColor,
|
||||
Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
primaryColor.withValues(
|
||||
alpha: isDark ? 0.25 : 0.15,
|
||||
),
|
||||
primaryColor.withValues(
|
||||
alpha: 0.05,
|
||||
),
|
||||
],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(
|
||||
color: primaryColor.withValues(
|
||||
alpha: isDark ? 0.35 : 0.2,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: WeaponTypeIcon(
|
||||
type: weapon.type,
|
||||
color: primaryColor,
|
||||
size: 28,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(weapon.displayName, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
if (accessories.isNotEmpty) ...[
|
||||
const SizedBox(height: 6),
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
children: accessories,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
] else
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'${weapon.type.displayName} • ${weapon.caliber}',
|
||||
style: const TextStyle(fontSize: 13, color: Colors.grey),
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
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)),
|
||||
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) ...[
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
children: accessories,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Construit la liste des "puces" d'accessoires renseignés pour une arme.
|
||||
// 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) {
|
||||
List<Widget> _accessoryChips(Weapon weapon, bool isDark) {
|
||||
final items = <(IconData, String)>[];
|
||||
if (weapon.optic != null && weapon.optic!.isNotEmpty) {
|
||||
items.add((Icons.center_focus_strong, weapon.optic!));
|
||||
@@ -176,16 +299,29 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primaryColor.withValues(alpha: 0.08),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: AppTheme.primaryColor.withValues(alpha: 0.25)),
|
||||
color: (isDark ? Colors.white : Colors.black).withValues(alpha: 0.05),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: isDark ? AppTheme.darkBorder : AppTheme.lightBorder,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(item.$1, size: 13, color: AppTheme.primaryColor),
|
||||
const SizedBox(width: 4),
|
||||
Text(item.$2, style: const TextStyle(fontSize: 12)),
|
||||
Icon(
|
||||
item.$1,
|
||||
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 {
|
||||
// Capturé AVANT l'await : plus aucun usage du context après le dialogue.
|
||||
final repository = context.read<SessionRepository>();
|
||||
final nameController = TextEditingController();
|
||||
final caliberController = TextEditingController();
|
||||
@@ -203,7 +338,7 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
|
||||
|
||||
final result = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => StatefulBuilder(
|
||||
builder: (dialogCtx) => StatefulBuilder(
|
||||
builder: (context, setState) => AlertDialog(
|
||||
title: const Text('Ajouter une arme'),
|
||||
content: SingleChildScrollView(
|
||||
@@ -212,18 +347,40 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
|
||||
children: [
|
||||
TextField(
|
||||
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>(
|
||||
initialValue: selectedType,
|
||||
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!),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
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(
|
||||
children: [
|
||||
Expanded(
|
||||
@@ -233,7 +390,7 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
|
||||
keyboardType: TextInputType.number,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: magCapController,
|
||||
@@ -247,8 +404,14 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Annuler')),
|
||||
ElevatedButton(onPressed: () => Navigator.pop(context, true), child: const Text('Ajouter')),
|
||||
TextButton(
|
||||
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 {
|
||||
// Capturé AVANT l'await : plus aucun usage du context après le dialogue.
|
||||
final repository = context.read<SessionRepository>();
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Supprimer'),
|
||||
title: const Text('Supprimer l\'arme'),
|
||||
content: Text('Voulez-vous supprimer ${weapon.name} de votre armurerie ?'),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Annuler')),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Annuler'),
|
||||
),
|
||||
ElevatedButton(
|
||||
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:provider/provider.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../../core/constants/app_constants.dart';
|
||||
import '../../core/theme/app_theme.dart';
|
||||
import '../../data/models/session.dart';
|
||||
import '../../data/models/target_type.dart';
|
||||
@@ -11,10 +10,6 @@ import 'widgets/session_list_item.dart';
|
||||
import 'widgets/history_chart.dart';
|
||||
|
||||
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;
|
||||
|
||||
const HistoryScreen({super.key, this.refreshTick = 0});
|
||||
@@ -27,8 +22,6 @@ class _HistoryScreenState extends State<HistoryScreen> {
|
||||
List<Session> _sessions = [];
|
||||
bool _isLoading = true;
|
||||
TargetType? _filterType;
|
||||
|
||||
// --- MODIFICATION : Remplacement de DateTime par DateTimeRange ---
|
||||
DateTimeRange? _selectedDateRange;
|
||||
|
||||
@override
|
||||
@@ -40,8 +33,6 @@ class _HistoryScreenState extends State<HistoryScreen> {
|
||||
@override
|
||||
void didUpdateWidget(HistoryScreen 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) {
|
||||
_loadSessions();
|
||||
}
|
||||
@@ -59,18 +50,14 @@ class _HistoryScreenState extends State<HistoryScreen> {
|
||||
setState(() {
|
||||
_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) {
|
||||
_sessions = _sessions
|
||||
.where((s) => s.analyses.any((a) => a.targetType == _filterType))
|
||||
.toList();
|
||||
}
|
||||
|
||||
// --- LOGIQUE DE FILTRAGE PAR PÉRIODE ---
|
||||
if (_selectedDateRange != null) {
|
||||
_sessions = _sessions.where((s) {
|
||||
// On compare uniquement les dates (sans les heures) pour éviter les bugs
|
||||
final sessionDate = DateTime(
|
||||
s.createdAt.year,
|
||||
s.createdAt.month,
|
||||
@@ -109,32 +96,13 @@ class _HistoryScreenState extends State<HistoryScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
// --- MODIFICATION : Fonction DateRangePicker ---
|
||||
Future<void> _pickDateRange() async {
|
||||
final DateTimeRange? picked = await showDateRangePicker(
|
||||
final picked = await showDateRangePicker(
|
||||
context: context,
|
||||
initialDateRange: _selectedDateRange,
|
||||
firstDate: DateTime(2020),
|
||||
lastDate: DateTime.now().add(const Duration(days: 1)),
|
||||
locale: const Locale('fr', 'FR'),
|
||||
builder: (context, child) {
|
||||
return Theme(
|
||||
data: ThemeData.light().copyWith(
|
||||
colorScheme: ColorScheme.light(
|
||||
primary: AppTheme.primaryColor, // En-tête et sélection
|
||||
onPrimary: Colors.white, // Texte sur en-tête/sélection
|
||||
surface: Colors.white, // Fond du calendrier
|
||||
onSurface: Colors.black87, // Texte des dates (Noir sur Blanc)
|
||||
secondary: AppTheme.primaryColor,
|
||||
),
|
||||
dialogTheme: const DialogThemeData(backgroundColor: Colors.white),
|
||||
textButtonTheme: TextButtonThemeData(
|
||||
style: TextButton.styleFrom(foregroundColor: AppTheme.primaryColor),
|
||||
),
|
||||
),
|
||||
child: child!,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
if (picked != null) {
|
||||
@@ -145,134 +113,221 @@ class _HistoryScreenState extends State<HistoryScreen> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
return Scaffold(
|
||||
extendBody: true,
|
||||
appBar: AppBar(
|
||||
title: const Text('Historique'),
|
||||
title: const Text('Carnet de Tir'),
|
||||
actions: [
|
||||
// NOTE : on passe par un String sentinelle ('all') car un
|
||||
// PopupMenuItem avec value null ne déclenche jamais onSelected
|
||||
// (Flutter l'interprète comme une annulation du menu).
|
||||
PopupMenuButton<String>(
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
Icons.filter_list,
|
||||
// Icône colorée quand un filtre est actif, pour le rendre visible.
|
||||
color: _filterType != null ? AppTheme.primaryColor : null,
|
||||
Icons.date_range_outlined,
|
||||
color: _selectedDateRange != null ? AppTheme.primaryColor : null,
|
||||
),
|
||||
onSelected: (value) {
|
||||
setState(() {
|
||||
_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),
|
||||
),
|
||||
),
|
||||
],
|
||||
tooltip: 'Filtrer par date',
|
||||
onPressed: _pickDateRange,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
_buildFilterChips(isDark),
|
||||
Expanded(
|
||||
child: _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _sessions.isEmpty
|
||||
? _buildEmptyState()
|
||||
: _buildContent(),
|
||||
? _buildEmptyState(isDark)
|
||||
: _buildContent(isDark),
|
||||
),
|
||||
_buildBottomFilterBar(),
|
||||
if (_selectedDateRange != null) _buildActivePeriodBanner(isDark),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBottomFilterBar() {
|
||||
Widget _buildFilterChips(bool isDark) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).cardColor,
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Colors.black26,
|
||||
blurRadius: 4,
|
||||
offset: Offset(0, -2),
|
||||
color: isDark ? AppTheme.darkBackground : AppTheme.lightBackground,
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: isDark ? AppTheme.darkBorder : AppTheme.lightBorder,
|
||||
width: 1,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _pickDateRange,
|
||||
icon: const Icon(Icons.date_range, size: 18),
|
||||
label: Text(
|
||||
_selectedDateRange == null
|
||||
? 'Choisir une période'
|
||||
: '${DateFormat('dd/MM/yy').format(_selectedDateRange!.start)} - ${DateFormat('dd/MM/yy').format(_selectedDateRange!.end)}',
|
||||
),
|
||||
),
|
||||
_buildTypeFilterChip('Toutes les cibles', null),
|
||||
const SizedBox(width: 8),
|
||||
_buildTypeFilterChip(
|
||||
'Cibles 1-10',
|
||||
TargetType.concentric,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_buildTypeFilterChip(
|
||||
'Silhouettes',
|
||||
TargetType.silhouette,
|
||||
),
|
||||
if (_selectedDateRange != null)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, color: AppTheme.errorColor),
|
||||
onPressed: () {
|
||||
setState(() => _selectedDateRange = null);
|
||||
_loadSessions();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyState() {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.history, size: 64, color: Colors.grey[400]),
|
||||
const SizedBox(height: 16),
|
||||
const Text('Aucune session sur cette période'),
|
||||
],
|
||||
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(
|
||||
top: false,
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.event, size: 18, color: AppTheme.primaryColor),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Période : $start - $end',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
InkWell(
|
||||
onTap: () {
|
||||
setState(() => _selectedDateRange = null);
|
||||
_loadSessions();
|
||||
},
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'Effacer',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.errorColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
const Icon(Icons.close, size: 14, color: AppTheme.errorColor),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContent() {
|
||||
Widget _buildEmptyState(bool isDark) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? AppTheme.darkSurface : AppTheme.lightSurfaceVariant,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: isDark ? AppTheme.darkBorder : AppTheme.lightBorder,
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.history_toggle_off,
|
||||
size: 56,
|
||||
color: isDark ? AppTheme.darkTextMuted : AppTheme.lightTextMuted,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
Text(
|
||||
'Aucune session trouvée',
|
||||
style: TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
_selectedDateRange != null || _filterType != null
|
||||
? 'Essayez de réinitialiser vos filtres de recherche.'
|
||||
: 'Vos sessions enregistrées apparaîtront ici.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContent(bool isDark) {
|
||||
return RefreshIndicator(
|
||||
onRefresh: _loadSessions,
|
||||
color: AppTheme.primaryColor,
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
if (_sessions.length >= 2 && _selectedDateRange == null)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppConstants.defaultPadding),
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: HistoryChart(sessions: _sessions),
|
||||
),
|
||||
),
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.all(AppConstants.defaultPadding),
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 100),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
final session = _sessions[index];
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: SessionListItem(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
final session = _sessions[index];
|
||||
return SessionListItem(
|
||||
session: session,
|
||||
onTap: () => _openSessionDetail(session),
|
||||
onDelete: () => _deleteSession(session),
|
||||
),
|
||||
);
|
||||
}, childCount: _sessions.length),
|
||||
);
|
||||
},
|
||||
childCount: _sessions.length,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -292,21 +347,22 @@ class _HistoryScreenState extends State<HistoryScreen> {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Supprimer'),
|
||||
title: const Text('Supprimer la session'),
|
||||
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: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Annuler'),
|
||||
),
|
||||
TextButton(
|
||||
ElevatedButton(
|
||||
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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../../core/widgets/glass_container.dart';
|
||||
import '../../../data/models/session.dart';
|
||||
|
||||
class SessionListItem extends StatelessWidget {
|
||||
@@ -18,124 +19,178 @@ class SessionListItem extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Row(
|
||||
children: [
|
||||
// Thumbnail (from first target)
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: SizedBox(
|
||||
width: 60,
|
||||
height: 60,
|
||||
child: _buildThumbnail(),
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final textSecondary = isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary;
|
||||
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,
|
||||
child: Row(
|
||||
children: [
|
||||
// Aperçu de la cible avec bordure nette
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
color: (isDark ? Colors.white : Colors.black).withValues(alpha: 0.05),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: isDark ? AppTheme.darkBorder : AppTheme.lightBorder,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
child: _buildThumbnail(isDark),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
|
||||
// Info
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
// Informations de la session
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
session.weapon,
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
formattedDate,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
Row(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.shield,
|
||||
size: 16,
|
||||
color: AppTheme.primaryColor,
|
||||
Container(
|
||||
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: 4),
|
||||
Text(
|
||||
session.weapon,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
DateFormat('dd/MM/yyyy HH:mm').format(session.createdAt),
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.track_changes, size: 14, color: Colors.grey[600]),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${session.targetCount} cible(s) • ${session.distance}m',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
],
|
||||
'${session.targetCount} cible${session.targetCount > 1 ? 's' : ''} • ${session.totalShots} tirs',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: textMuted,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Score
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
'${session.totalScore}',
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppTheme.primaryColor,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${session.totalShots} tirs',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// Delete button
|
||||
if (onDelete != null)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
onPressed: onDelete,
|
||||
color: Colors.grey,
|
||||
iconSize: 20,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Score et moyenne stylisés façon cyber HUD
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: scoreColor.withValues(alpha: isDark ? 0.12 : 0.08),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: scoreColor.withValues(alpha: isDark ? 0.3 : 0.2),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
'${session.totalScore}',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: scoreColor,
|
||||
letterSpacing: -0.5,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'Moy. ${session.averageScore.toStringAsFixed(1)}',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: scoreColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Bouton supprimer
|
||||
if (onDelete != null) ...[
|
||||
const SizedBox(width: 4),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete_outline_rounded),
|
||||
onPressed: onDelete,
|
||||
color: textMuted,
|
||||
iconSize: 20,
|
||||
tooltip: 'Supprimer',
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildThumbnail() {
|
||||
if (session.analyses.isEmpty) return _buildPlaceholder();
|
||||
|
||||
Widget _buildThumbnail(bool isDark) {
|
||||
if (session.analyses.isEmpty) return _buildPlaceholder(isDark);
|
||||
|
||||
final file = File(session.analyses.first.imagePath);
|
||||
|
||||
if (file.existsSync()) {
|
||||
return Image.file(
|
||||
file,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, _, _) => _buildPlaceholder(),
|
||||
errorBuilder: (_, _, _) => _buildPlaceholder(isDark),
|
||||
);
|
||||
}
|
||||
|
||||
return _buildPlaceholder();
|
||||
return _buildPlaceholder(isDark);
|
||||
}
|
||||
|
||||
Widget _buildPlaceholder() {
|
||||
return Container(
|
||||
color: Colors.grey[200],
|
||||
child: Icon(
|
||||
Icons.track_changes,
|
||||
color: Colors.grey[400],
|
||||
),
|
||||
Widget _buildPlaceholder(bool isDark) {
|
||||
return Icon(
|
||||
Icons.track_changes,
|
||||
color: isDark ? AppTheme.darkTextMuted : AppTheme.lightTextMuted,
|
||||
size: 24,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+891
-296
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 '../../../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 {
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String value;
|
||||
final Color color;
|
||||
final String? subtitle;
|
||||
|
||||
const StatsCard({
|
||||
super.key,
|
||||
@@ -19,33 +16,82 @@ class StatsCard extends StatelessWidget {
|
||||
required this.title,
|
||||
required this.value,
|
||||
required this.color,
|
||||
this.subtitle,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppConstants.defaultPadding),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(icon, color: color, size: 32),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
value,
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: color,
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final textSecondary = isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary;
|
||||
|
||||
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(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: isDark ? 0.18 : 0.12),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(
|
||||
color: color.withValues(alpha: isDark ? 0.35 : 0.25),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Icon(icon, color: color, size: 20),
|
||||
),
|
||||
if (subtitle != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: (isDark ? Colors.white : Colors.black).withValues(alpha: 0.05),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
subtitle!,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
|
||||
letterSpacing: -0.5,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ class SessionProvider extends ChangeNotifier {
|
||||
DateTime? get sessionDate => _sessionDate;
|
||||
String? _currentWeaponName;
|
||||
String? _currentWeaponId;
|
||||
String? _currentWeaponCaliber;
|
||||
int _shotsPerTarget = 5;
|
||||
int _distance = 25;
|
||||
String? _activeSessionId;
|
||||
@@ -14,6 +15,11 @@ class SessionProvider extends ChangeNotifier {
|
||||
|
||||
String? get currentWeapon => _currentWeaponName;
|
||||
String? get currentWeaponId => _currentWeaponId;
|
||||
|
||||
/// Calibre de l'arme de la session. Contrairement au nom d'arme, il ne
|
||||
/// designe pas l'utilisateur : c'est la seule donnee d'arme envoyee au
|
||||
/// backend d'entrainement (le diametre des trous en depend).
|
||||
String? get currentWeaponCaliber => _currentWeaponCaliber;
|
||||
int get shotsPerTarget => _shotsPerTarget;
|
||||
int get distance => _distance;
|
||||
bool get isSessionActive => _isSessionActive;
|
||||
@@ -23,9 +29,10 @@ class SessionProvider extends ChangeNotifier {
|
||||
int get totalSessionScore => _currentAnalyses.fold(0, (sum, a) => sum + a.totalScore);
|
||||
int get targetCount => _currentAnalyses.length;
|
||||
|
||||
void startSession(String weaponName, int shots, String sessionId, {String? weaponId, int distance = 25, DateTime? date}) {
|
||||
void startSession(String weaponName, int shots, String sessionId, {String? weaponId, String? caliber, int distance = 25, DateTime? date}) {
|
||||
_currentWeaponName = weaponName;
|
||||
_currentWeaponId = weaponId;
|
||||
_currentWeaponCaliber = caliber;
|
||||
_shotsPerTarget = shots;
|
||||
_distance = distance;
|
||||
_sessionDate = date ?? DateTime.now();
|
||||
@@ -45,6 +52,7 @@ class SessionProvider extends ChangeNotifier {
|
||||
_activeSessionId = null;
|
||||
_currentWeaponId = null;
|
||||
_currentWeaponName = null;
|
||||
_currentWeaponCaliber = null;
|
||||
_currentAnalyses.clear();
|
||||
_distance = 25;
|
||||
notifyListeners();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:intl/intl.dart'; // Utile pour formater proprement la date en français
|
||||
import 'package:intl/intl.dart';
|
||||
import '../../core/constants/app_constants.dart';
|
||||
import '../../core/theme/app_theme.dart';
|
||||
import '../../data/models/weapon.dart';
|
||||
@@ -25,9 +25,17 @@ class _SessionSetupScreenState extends State<SessionSetupScreen> {
|
||||
Weapon? _selectedWeapon;
|
||||
bool _isLoadingWeapons = true;
|
||||
|
||||
// AJOUT DE LA VARIABLE DATE : Initialisée par défaut à maintenant
|
||||
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
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -42,8 +50,21 @@ class _SessionSetupScreenState extends State<SessionSetupScreen> {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_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!);
|
||||
}
|
||||
_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) {
|
||||
_shotsPerTarget = weapon.magazineCapacity;
|
||||
_shotsPerTarget = weapon.magazineCapacity > 0 ? weapon.magazineCapacity : 5;
|
||||
_distance = (weapon.type == WeaponType.handgun) ? 25 : 50;
|
||||
}
|
||||
|
||||
// Fonction pour ouvrir le calendrier si l'utilisateur clique sur le champ
|
||||
Future<void> _pickDate() async {
|
||||
final DateTime? picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: _selectedDate,
|
||||
firstDate: DateTime(2020),
|
||||
lastDate: DateTime(2100),
|
||||
locale: const Locale('fr', 'FR'), // Force le calendrier en français
|
||||
locale: const Locale('fr', 'FR'),
|
||||
);
|
||||
if (picked != null && picked != _selectedDate) {
|
||||
setState(() {
|
||||
// On garde aussi l'heure actuelle lors du changement de date
|
||||
final now = DateTime.now();
|
||||
_selectedDate = DateTime(picked.year, picked.month, picked.day, now.hour, now.minute);
|
||||
_selectedDate = DateTime(
|
||||
picked.year,
|
||||
picked.month,
|
||||
picked.day,
|
||||
now.hour,
|
||||
now.minute,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -81,15 +135,12 @@ class _SessionSetupScreenState extends State<SessionSetupScreen> {
|
||||
|
||||
final sessionId = repository.generateId();
|
||||
|
||||
// PASSE-PARTOUT : On envoie la session au provider.
|
||||
// Note : Si ton `sessionProvider.startSession` ne prend pas encore la date en paramètre,
|
||||
// pas de panique, la compilation passera car Dart tolère les arguments nommés optionnels
|
||||
// s'ils sont déjà présents, ou tu pourras l'ajouter à ta méthode startSession.
|
||||
sessionProvider.startSession(
|
||||
_selectedWeapon!.displayName,
|
||||
_shotsPerTarget,
|
||||
sessionId,
|
||||
weaponId: _selectedWeapon!.id,
|
||||
caliber: _selectedWeapon!.caliber,
|
||||
distance: _distance,
|
||||
date: _selectedDate,
|
||||
);
|
||||
@@ -103,221 +154,380 @@ class _SessionSetupScreenState extends State<SessionSetupScreen> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Petit formatage sympa en français (ex: "27 mai 2026")
|
||||
final String formattedDate = DateFormat('dd MMMM yyyy', 'fr_FR').format(_selectedDate);
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final formattedDate = DateFormat('dd MMMM yyyy • HH:mm', 'fr_FR').format(_selectedDate);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Configuration de la session'),
|
||||
title: const Text('Configuration de Session'),
|
||||
),
|
||||
body: _isLoadingWeapons
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(AppConstants.defaultPadding),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Text(
|
||||
'Informations générales',
|
||||
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// AFFICHAGE CONDITIONNEL : Armurerie vide vs Armurerie remplie
|
||||
if (_availableWeapons.isEmpty)
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.errorColor.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
|
||||
border: Border.all(color: AppTheme.errorColor.withValues(alpha: 0.5)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
const Icon(Icons.warning_amber_rounded, color: AppTheme.errorColor, size: 48),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'Ton armurerie est vide.',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
const Text(
|
||||
'Tu dois d\'abord ajouter une arme pour pouvoir démarrer une session de tir.',
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
padding: const EdgeInsets.all(AppConstants.defaultPadding),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (_availableWeapons.isEmpty)
|
||||
_buildEmptyArmoryState(isDark)
|
||||
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),
|
||||
ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const WeaponListScreen()),
|
||||
).then((_) {
|
||||
_loadWeapons();
|
||||
});
|
||||
},
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('ALLER À MON ARMURERIE'),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyArmoryState(bool isDark) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? AppTheme.darkSurface : AppTheme.lightSurface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: AppTheme.errorColor.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.warning_amber_rounded,
|
||||
color: AppTheme.errorColor,
|
||||
size: 52,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Armurerie vide',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'Ajoutez au moins une arme dans votre armurerie pour débuter une séance.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
ElevatedButton.icon(
|
||||
onPressed: _openArmory,
|
||||
icon: const Icon(Icons.shield),
|
||||
label: const Text('Aller à l\'armurerie'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildWeaponAndDateSection(bool isDark, String formattedDate) {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.tune, color: AppTheme.primaryColor, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Arme & Date',
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
DropdownButtonFormField<String>(
|
||||
key: _weaponFieldKey,
|
||||
initialValue: _selectedWeapon?.id,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Arme utilisée',
|
||||
prefixIcon: Icon(Icons.shield_outlined),
|
||||
),
|
||||
items: [
|
||||
..._availableWeapons.map(
|
||||
(w) => DropdownMenuItem(
|
||||
value: w.id,
|
||||
child: Text('${w.displayName} (${w.caliber})'),
|
||||
),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: _addWeaponValue,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.add_circle_outline,
|
||||
size: 18,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Ajouter une nouvelle arme',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
else
|
||||
Column(
|
||||
children: [
|
||||
DropdownButtonFormField<Weapon>(
|
||||
initialValue: _selectedWeapon,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Sélectionner une arme',
|
||||
prefixIcon: const Icon(Icons.shield),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
|
||||
),
|
||||
),
|
||||
items: _availableWeapons.map((w) => DropdownMenuItem(
|
||||
value: w,
|
||||
child: Text(w.displayName),
|
||||
)).toList(),
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
setState(() {
|
||||
_selectedWeapon = value;
|
||||
_updateSettingsForWeapon(value);
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
),
|
||||
],
|
||||
onChanged: (value) {
|
||||
if (value == null) return;
|
||||
|
||||
// NOUVEAU CHAMP : Sélecteur de date interactif
|
||||
InkWell(
|
||||
onTap: _availableWeapons.isEmpty ? null : _pickDate,
|
||||
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
|
||||
child: IgnorePointer(
|
||||
child: TextFormField(
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Date de la session',
|
||||
prefixIcon: const Icon(Icons.calendar_today),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
|
||||
),
|
||||
),
|
||||
controller: TextEditingController(text: formattedDate),
|
||||
),
|
||||
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(() {
|
||||
_selectedWeapon = weapon;
|
||||
_updateSettingsForWeapon(weapon);
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
InkWell(
|
||||
onTap: _pickDate,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: IgnorePointer(
|
||||
child: TextFormField(
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Date de séance',
|
||||
prefixIcon: Icon(Icons.calendar_today_outlined),
|
||||
),
|
||||
controller: TextEditingController(text: formattedDate),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDistanceSection(bool isDark) {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.straighten, color: AppTheme.secondaryColor, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Distance de Tir',
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Distance selector
|
||||
const Text(
|
||||
'Distance de tir (mètres)',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Slider(
|
||||
value: _distance.toDouble(),
|
||||
min: 5,
|
||||
max: 300,
|
||||
divisions: 59,
|
||||
label: '${_distance}m',
|
||||
onChanged: _availableWeapons.isEmpty ? null : (value) {
|
||||
setState(() {
|
||||
_distance = value.round();
|
||||
});
|
||||
},
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.secondaryColor.withValues(alpha: isDark ? 0.2 : 0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: AppTheme.secondaryColor.withValues(alpha: 0.4),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
width: 70,
|
||||
height: 50,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.secondaryColor.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Text(
|
||||
'${_distance}m',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: AppTheme.secondaryColor,
|
||||
),
|
||||
child: Text(
|
||||
'${_distance}m',
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppTheme.secondaryColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Shots per target
|
||||
const Text(
|
||||
'Nombre de balles pour la cible actuelle',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Slider(
|
||||
value: _shotsPerTarget.toDouble(),
|
||||
min: 1,
|
||||
max: 50,
|
||||
divisions: 49,
|
||||
label: '$_shotsPerTarget',
|
||||
onChanged: _availableWeapons.isEmpty ? null : (value) {
|
||||
setState(() {
|
||||
_shotsPerTarget = value.round();
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
Container(
|
||||
width: 50,
|
||||
height: 50,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primaryColor.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
'$_shotsPerTarget',
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppTheme.primaryColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
|
||||
ElevatedButton.icon(
|
||||
onPressed: (_availableWeapons.isEmpty || _selectedWeapon == null)
|
||||
? null
|
||||
: _startSession,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppTheme.primaryColor,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
|
||||
),
|
||||
),
|
||||
icon: const Icon(Icons.play_arrow),
|
||||
label: const Text(
|
||||
'DÉMARRER LA SESSION',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: _presetDistances.map((d) {
|
||||
final isSelected = _distance == d;
|
||||
return ChoiceChip(
|
||||
label: Text('${d}m'),
|
||||
selected: isSelected,
|
||||
onSelected: (selected) {
|
||||
if (selected) setState(() => _distance = d);
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Slider(
|
||||
value: _distance.toDouble(),
|
||||
min: 5,
|
||||
max: 300,
|
||||
divisions: 59,
|
||||
activeColor: AppTheme.secondaryColor,
|
||||
onChanged: (val) {
|
||||
setState(() => _distance = val.round());
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildShotsSection(bool isDark) {
|
||||
final primaryColor = Theme.of(context).colorScheme.primary;
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.ads_click, color: primaryColor, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Tirs par Cible',
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: primaryColor.withValues(alpha: isDark ? 0.2 : 0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: primaryColor.withValues(alpha: 0.4),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'$_shotsPerTarget tirs',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: primaryColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: _presetShots.map((s) {
|
||||
final isSelected = _shotsPerTarget == s;
|
||||
return ChoiceChip(
|
||||
label: Text('$s coups'),
|
||||
selected: isSelected,
|
||||
onSelected: (selected) {
|
||||
if (selected) setState(() => _shotsPerTarget = s);
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Slider(
|
||||
value: _shotsPerTarget.toDouble(),
|
||||
min: 1,
|
||||
max: 50,
|
||||
divisions: 49,
|
||||
activeColor: primaryColor,
|
||||
onChanged: (val) {
|
||||
setState(() => _shotsPerTarget = val.round());
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStartButton() {
|
||||
final primaryColor = Theme.of(context).colorScheme.primary;
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
primaryColor,
|
||||
HSLColor.fromColor(primaryColor).withLightness((HSLColor.fromColor(primaryColor).lightness + 0.15).clamp(0.0, 1.0)).toColor(),
|
||||
],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: primaryColor.withValues(alpha: 0.35),
|
||||
blurRadius: 16,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: (_availableWeapons.isEmpty || _selectedWeapon == null)
|
||||
? null
|
||||
: _startSession,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.transparent,
|
||||
foregroundColor: Colors.white,
|
||||
shadowColor: Colors.transparent,
|
||||
padding: const EdgeInsets.symmetric(vertical: 18),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
),
|
||||
icon: const Icon(Icons.play_arrow_rounded, size: 26),
|
||||
label: const Text(
|
||||
'DÉMARRER LA SESSION',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: 0.8,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import '../../core/theme/theme_provider.dart';
|
||||
import 'dart:convert';
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import '../../core/constants/app_constants.dart';
|
||||
import '../../core/theme/app_theme.dart';
|
||||
import '../../core/theme/theme_provider.dart';
|
||||
import '../../core/widgets/glass_container.dart';
|
||||
import '../../services/wallet_identity_service.dart';
|
||||
import '../garage/weapon_list_screen.dart';
|
||||
import '../tutorial/tutorial_provider.dart';
|
||||
|
||||
class SettingsScreen extends StatefulWidget {
|
||||
const SettingsScreen({super.key});
|
||||
@@ -165,76 +166,203 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
void _showThemeDialog() {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Apparence'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildThemeOption(ThemeMode.system, 'Automatique', Icons.brightness_auto),
|
||||
_buildThemeOption(ThemeMode.light, 'Clair', Icons.light_mode),
|
||||
_buildThemeOption(ThemeMode.dark, 'Sombre', Icons.dark_mode),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Fermer'),
|
||||
),
|
||||
],
|
||||
builder: (dialogCtx) => Consumer<ThemeProvider>(
|
||||
builder: (context, themeProvider, child) {
|
||||
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,
|
||||
children: [
|
||||
Container(
|
||||
width: 14,
|
||||
height: 14,
|
||||
decoration: BoxDecoration(
|
||||
color: accent.color,
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
if (isSelected)
|
||||
BoxShadow(
|
||||
color: accent.color.withValues(alpha: 0.6),
|
||||
blurRadius: 6,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
accent.name,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: isSelected ? FontWeight.w800 : FontWeight.w500,
|
||||
color: isSelected ? accent.color : (isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogCtx),
|
||||
child: Text('Fermer', style: TextStyle(color: primary, fontWeight: FontWeight.bold)),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildThemeOption(ThemeMode mode, String label, IconData icon) {
|
||||
final themeProvider = context.watch<ThemeProvider>();
|
||||
Widget _buildThemeOption(
|
||||
ThemeProvider themeProvider,
|
||||
ThemeMode mode,
|
||||
String label,
|
||||
IconData icon,
|
||||
) {
|
||||
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(
|
||||
leading: Icon(icon, color: isSelected ? AppTheme.primaryColor : null),
|
||||
title: Text(label, style: TextStyle(
|
||||
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
|
||||
color: isSelected ? AppTheme.primaryColor : null,
|
||||
)),
|
||||
trailing: isSelected ? const Icon(Icons.check, color: AppTheme.primaryColor) : null,
|
||||
dense: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: Icon(icon, color: isSelected ? primary : (isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary), size: 20),
|
||||
title: Text(
|
||||
label,
|
||||
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: () {
|
||||
themeProvider.setThemeMode(mode);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _showIdentityDialog() {
|
||||
if (_identityPhrase == null) return;
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final primary = Theme.of(context).colorScheme.primary;
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
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(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
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 Text(
|
||||
Text(
|
||||
'Cette phrase de 15 mots vous identifie de manière unique. Ne la partagez qu\'en cas de besoin.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 14, color: Colors.grey),
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const SizedBox(height: 20),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.withAlpha(20),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: AppTheme.primaryColor.withAlpha(100)),
|
||||
color: primary.withValues(alpha: isDark ? 0.15 : 0.08),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: primary.withValues(alpha: 0.35)),
|
||||
),
|
||||
child: Text(
|
||||
_identityPhrase!,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.5,
|
||||
height: 1.5,
|
||||
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
@@ -244,11 +372,16 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Fermer'),
|
||||
child: Text('Fermer', style: TextStyle(color: isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary)),
|
||||
),
|
||||
ElevatedButton.icon(
|
||||
icon: const Icon(Icons.copy, size: 18),
|
||||
label: const Text('Copier'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: primary,
|
||||
foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
||||
),
|
||||
onPressed: () {
|
||||
_copyToClipboard();
|
||||
Navigator.pop(context);
|
||||
@@ -260,10 +393,15 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
}
|
||||
|
||||
void _showOptInDisclaimer(bool value) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final primary = Theme.of(context).colorScheme.primary;
|
||||
|
||||
if (_isBanned) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
backgroundColor: isDark ? AppTheme.darkSurface : AppTheme.lightSurface,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
title: const Row(
|
||||
children: [
|
||||
Icon(Icons.block, color: AppTheme.errorColor),
|
||||
@@ -299,14 +437,23 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
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),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Center(
|
||||
child: Icon(Icons.psychology, size: 48, color: AppTheme.primaryColor),
|
||||
Center(
|
||||
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 Text(
|
||||
@@ -316,7 +463,23 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
const Text(
|
||||
'🔒 Anonymat Garanti : L\'envoi est totalement anonymisé. Votre identité est remplacée par un hash cryptographique.',
|
||||
'📤 Ce qui est envoyé : la photo de la cible, la position des '
|
||||
'impacts que vous avez placés, la distance de tir, le calibre et '
|
||||
'le nombre de coups prévus.',
|
||||
style: TextStyle(fontSize: 13),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
const Text(
|
||||
'🚫 Ce qui ne l\'est pas : la géolocalisation de la photo (retirée '
|
||||
'avant l\'envoi), le nom de votre arme, et le modèle de votre appareil.',
|
||||
style: TextStyle(fontSize: 13),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
const Text(
|
||||
'🔒 Pseudonymat : votre identité est remplacée par un hash '
|
||||
'cryptographique. Il reste le même d\'un envoi à l\'autre, afin de '
|
||||
'rattacher vos contributions à votre compte (récompenses, '
|
||||
'modération, suppression sur demande).',
|
||||
style: TextStyle(fontSize: 13),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
@@ -369,7 +532,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
child: const Text('Refuser', style: TextStyle(color: Colors.grey)),
|
||||
),
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(backgroundColor: AppTheme.primaryColor),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: primary,
|
||||
foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
||||
),
|
||||
onPressed: () {
|
||||
_walletService.setUploadEnabled(true);
|
||||
setState(() {
|
||||
@@ -383,7 +550,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
),
|
||||
);
|
||||
},
|
||||
child: const Text('J\'accepte les règles', style: TextStyle(color: Colors.white)),
|
||||
child: const Text('J\'accepte les règles'),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -391,10 +558,15 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
}
|
||||
|
||||
void _showEditServerUrlDialog() {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final primary = Theme.of(context).colorScheme.primary;
|
||||
final urlController = TextEditingController(text: _serverUrl);
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
backgroundColor: isDark ? AppTheme.darkSurface : AppTheme.lightSurface,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
title: const Text('Adresse du Serveur IA'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -425,7 +597,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
child: const Text('Annuler'),
|
||||
),
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(backgroundColor: AppTheme.primaryColor),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: primary,
|
||||
foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
||||
),
|
||||
onPressed: () async {
|
||||
final newUrl = urlController.text.trim();
|
||||
if (newUrl.isNotEmpty) {
|
||||
@@ -446,24 +622,26 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
),
|
||||
);
|
||||
},
|
||||
child: const Text('Enregistrer', style: TextStyle(color: Colors.white)),
|
||||
child: const Text('Enregistrer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Rapport de bug autonome : on collecte une description + les infos
|
||||
// techniques, et on copie un rapport prêt à coller dans un email de support.
|
||||
void _showReportBugDialog() {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final primary = Theme.of(context).colorScheme.primary;
|
||||
final descController = TextEditingController();
|
||||
const supportEmail = 'monadressemaildesupport@nomdelapplication.com';
|
||||
const appVersion = '1.0.0';
|
||||
const appVersion = '1.0.3';
|
||||
final platform = Theme.of(context).platform.name;
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
backgroundColor: isDark ? AppTheme.darkSurface : AppTheme.lightSurface,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
title: const Text('Signaler un bug'),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
@@ -499,6 +677,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
ElevatedButton.icon(
|
||||
icon: const Icon(Icons.copy, size: 18),
|
||||
label: const Text('Copier le rapport'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: primary,
|
||||
foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
||||
),
|
||||
onPressed: () {
|
||||
final desc = descController.text.trim();
|
||||
final report = StringBuffer()
|
||||
@@ -526,16 +709,39 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
/// Réactive le didacticiel : toutes les visites guidées sont remises à zéro
|
||||
/// et celle de l'accueil redémarre dès le retour sur l'écran principal.
|
||||
Future<void> _restartTutorial() async {
|
||||
final tutorial = context.read<TutorialProvider>();
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final navigator = Navigator.of(context);
|
||||
|
||||
await tutorial.restart();
|
||||
if (!mounted) return;
|
||||
|
||||
navigator.pop();
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Didacticiel réactivé : la visite guidée redémarre.'),
|
||||
backgroundColor: AppTheme.successColor,
|
||||
duration: Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final primary = Theme.of(context).colorScheme.primary;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Paramètres'),
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(AppConstants.defaultPadding),
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 40),
|
||||
children: [
|
||||
_buildSectionHeader('Identité'),
|
||||
_buildSectionHeader('IDENTITÉ & COMPTE', primary),
|
||||
_buildSettingsTile(
|
||||
context: context,
|
||||
icon: Icons.fingerprint,
|
||||
@@ -543,25 +749,128 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
subtitle: _identityPhrase != null ? 'Phrase de 15 mots générée' : 'Génération en cours...',
|
||||
onTap: _showIdentityDialog,
|
||||
),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
_buildSectionHeader('PERSONNALISATION & THÈME', primary),
|
||||
Consumer<ThemeProvider>(
|
||||
builder: (context, themeProvider, child) {
|
||||
return _buildSettingsTile(
|
||||
context: context,
|
||||
icon: Icons.color_lens_outlined,
|
||||
title: 'Apparence',
|
||||
subtitle: themeProvider.themeModeName,
|
||||
onTap: _showThemeDialog,
|
||||
return GlassContainer(
|
||||
borderRadius: 16,
|
||||
blur: 12,
|
||||
margin: const EdgeInsets.only(bottom: 10),
|
||||
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,
|
||||
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),
|
||||
_buildSectionHeader('Gestion'),
|
||||
const SizedBox(height: 20),
|
||||
_buildSectionHeader('ARMURERIE & MATÉRIEL', primary),
|
||||
_buildSettingsTile(
|
||||
context: context,
|
||||
icon: Icons.shield_outlined,
|
||||
title: 'Mon Armurerie',
|
||||
subtitle: 'Gérer mes armes et équipements',
|
||||
subtitle: 'Gérer mes armes, calibres et optiques',
|
||||
onTap: () async {
|
||||
await Navigator.push(
|
||||
context,
|
||||
@@ -570,14 +879,14 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
_buildSectionHeader('Programme d\'Entraînement IA'),
|
||||
const SizedBox(height: 20),
|
||||
_buildSectionHeader('PROGRAMME D\'ENTRAÎNEMENT IA', primary),
|
||||
if (_isBanned) ...[
|
||||
Card(
|
||||
elevation: 0,
|
||||
color: AppTheme.errorColor.withValues(alpha: 0.12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12.0),
|
||||
borderRadius: BorderRadius.circular(16.0),
|
||||
side: BorderSide(color: AppTheme.errorColor.withValues(alpha: 0.6), width: 1.5),
|
||||
),
|
||||
child: Padding(
|
||||
@@ -631,7 +940,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
onPressed: _isCheckingStatus ? null : _refreshAccountStatus,
|
||||
@@ -642,21 +951,38 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
),
|
||||
),
|
||||
] else ...[
|
||||
Card(
|
||||
elevation: 0,
|
||||
color: Colors.transparent,
|
||||
margin: const EdgeInsets.only(bottom: 8.0),
|
||||
GlassContainer(
|
||||
borderRadius: 16,
|
||||
blur: 10,
|
||||
margin: const EdgeInsets.only(bottom: 10),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4),
|
||||
child: SwitchListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 4.0),
|
||||
title: const Text('Participer à l\'entraînement IA', style: TextStyle(fontWeight: FontWeight.w500)),
|
||||
subtitle: const Text('Aidez-nous à améliorer la détection (soumis aux règles strictes)', style: TextStyle(fontSize: 12)),
|
||||
secondary: const Icon(Icons.psychology, color: AppTheme.textPrimary),
|
||||
value: _isUploadEnabled,
|
||||
activeThumbColor: AppTheme.primaryColor,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12.0),
|
||||
side: BorderSide(color: Colors.grey.withAlpha(50), width: 1),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 4.0),
|
||||
title: Text(
|
||||
'Participer à l\'entraînement IA',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w700,
|
||||
fontSize: 14,
|
||||
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
|
||||
),
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
@@ -692,20 +1018,36 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 24),
|
||||
_buildSectionHeader('À propos'),
|
||||
const SizedBox(height: 20),
|
||||
_buildSectionHeader('AIDE & DIDACTICIEL', primary),
|
||||
Consumer<TutorialProvider>(
|
||||
builder: (context, tutorial, child) {
|
||||
return _buildSettingsTile(
|
||||
context: context,
|
||||
icon: Icons.school_outlined,
|
||||
title: 'Revoir le didacticiel',
|
||||
subtitle: tutorial.hasSeenIntro
|
||||
? 'Rejouer la visite guidée de l\'application'
|
||||
: 'Visite guidée en attente sur l\'écran d\'accueil',
|
||||
onTap: _restartTutorial,
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
_buildSectionHeader('À PROPOS', primary),
|
||||
_buildSettingsTile(
|
||||
context: context,
|
||||
icon: Icons.info_outline,
|
||||
title: 'Version de l\'application',
|
||||
subtitle: '1.0.0',
|
||||
subtitle: '1.0.3 (Design Tactical & Precision)',
|
||||
onTap: () {},
|
||||
),
|
||||
_buildSettingsTile(
|
||||
context: context,
|
||||
icon: Icons.bug_report_outlined,
|
||||
title: 'Signaler un bug',
|
||||
subtitle: 'Aidez-nous à corriger les problèmes',
|
||||
subtitle: 'Aidez-nous à corriger les anomalies',
|
||||
onTap: _showReportBugDialog,
|
||||
),
|
||||
_buildSettingsTile(
|
||||
@@ -719,15 +1061,16 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSectionHeader(String title) {
|
||||
Widget _buildSectionHeader(String title, Color primary) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 4.0),
|
||||
padding: const EdgeInsets.fromLTRB(4, 8, 4, 10),
|
||||
child: Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppTheme.primaryColor,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: primary,
|
||||
letterSpacing: 1.0,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -740,21 +1083,57 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
String? subtitle,
|
||||
required VoidCallback onTap,
|
||||
}) {
|
||||
return Card(
|
||||
elevation: 0,
|
||||
color: Colors.transparent,
|
||||
margin: const EdgeInsets.only(bottom: 8.0),
|
||||
child: ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 4.0),
|
||||
leading: Icon(icon, color: AppTheme.textPrimary),
|
||||
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||
subtitle: subtitle != null ? Text(subtitle, style: const TextStyle(fontSize: 12)) : null,
|
||||
trailing: const Icon(Icons.chevron_right, color: Colors.grey),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12.0),
|
||||
side: BorderSide(color: Colors.grey.withAlpha(50), width: 1),
|
||||
),
|
||||
onTap: onTap,
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final primary = Theme.of(context).colorScheme.primary;
|
||||
|
||||
return GlassContainer(
|
||||
borderRadius: 16,
|
||||
blur: 10,
|
||||
margin: const EdgeInsets.only(bottom: 10),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||
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:path/path.dart' as p;
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import '../../core/theme/app_theme.dart';
|
||||
import '../../core/widgets/metric_info_button.dart';
|
||||
import '../../data/models/session.dart';
|
||||
import '../../data/repositories/session_repository.dart';
|
||||
@@ -235,6 +236,7 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
extendBody: true,
|
||||
appBar: AppBar(
|
||||
title: const Text('Statistiques'),
|
||||
centerTitle: true,
|
||||
@@ -296,7 +298,7 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
|
||||
onRefresh: _loadStatistics,
|
||||
child: SingleChildScrollView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 100),
|
||||
child: Column(
|
||||
children: [
|
||||
// 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(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: activeColor.withValues(alpha: 0.08),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: activeColor.withValues(alpha: 0.3)),
|
||||
color: activeColor.withValues(alpha: isDark ? 0.12 : 0.08),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: activeColor.withValues(alpha: 0.35)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -429,7 +432,15 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
|
||||
children: [
|
||||
Icon(Icons.compare_arrows, color: activeColor, size: 18),
|
||||
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(),
|
||||
IconButton(
|
||||
tooltip: 'Quitter la comparaison',
|
||||
@@ -493,27 +504,39 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
|
||||
List<String> items,
|
||||
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(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: theme.dividerColor),
|
||||
color: isDark ? AppTheme.darkSurfaceElevated : AppTheme.lightSurfaceVariant,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: isDark ? AppTheme.darkBorder : AppTheme.lightBorder,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
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>(
|
||||
value: value,
|
||||
isExpanded: true,
|
||||
underline: Container(),
|
||||
dropdownColor: theme.colorScheme.surfaceContainerHighest,
|
||||
style: TextStyle(color: theme.textTheme.bodyMedium?.color, fontSize: 14),
|
||||
underline: const SizedBox(),
|
||||
dropdownColor: isDark ? AppTheme.darkSurfaceElevated : AppTheme.lightSurfaceVariant,
|
||||
style: TextStyle(
|
||||
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
items: items
|
||||
.map(
|
||||
(String val) =>
|
||||
@@ -529,28 +552,45 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
|
||||
|
||||
// Widget pour les petites cartes de stats
|
||||
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(
|
||||
padding: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
color: isDark ? AppTheme.darkSurface : AppTheme.lightSurface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: isDark ? AppTheme.darkBorder : AppTheme.lightBorder,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
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),
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
color: theme.textTheme.titleLarge?.color,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -0.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
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<MetricExplanation>? explanations,
|
||||
}) {
|
||||
final theme = Theme.of(context);
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
color: isDark ? AppTheme.darkSurface : AppTheme.lightSurface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: isDark ? AppTheme.darkBorder : AppTheme.lightBorder,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -578,7 +621,11 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
|
||||
children: [
|
||||
Text(
|
||||
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) ...[
|
||||
const Spacer(),
|
||||
@@ -586,15 +633,16 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
color: theme.textTheme.headlineMedium?.color,
|
||||
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -0.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 20),
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/// Point d'entrée du didacticiel : lance une visite guidée par-dessus l'écran
|
||||
/// courant (route transparente) et mémorise qu'elle a été vue.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'tutorial_provider.dart';
|
||||
import 'tutorial_step.dart';
|
||||
import 'widgets/tutorial_overlay.dart';
|
||||
|
||||
class TutorialCoach {
|
||||
const TutorialCoach._();
|
||||
|
||||
static bool _isShowing = false;
|
||||
|
||||
/// Une visite est-elle déjà affichée ? (évite les doubles déclenchements)
|
||||
static bool get isShowing => _isShowing;
|
||||
|
||||
/// Joue la visite [tourId] uniquement si elle n'a jamais été vue.
|
||||
///
|
||||
/// [stepsBuilder] n'est évalué que si la visite doit réellement démarrer :
|
||||
/// les clés des widgets sont ainsi lues au dernier moment.
|
||||
static Future<void> maybeStart(
|
||||
BuildContext context, {
|
||||
required String tourId,
|
||||
required List<TutorialStep> Function() stepsBuilder,
|
||||
}) async {
|
||||
if (_isShowing) return;
|
||||
if (!context.read<TutorialProvider>().shouldRun(tourId)) return;
|
||||
await start(context, tourId: tourId, steps: stepsBuilder());
|
||||
}
|
||||
|
||||
/// Joue la visite [tourId], même si elle a déjà été vue.
|
||||
static Future<void> start(
|
||||
BuildContext context, {
|
||||
required String tourId,
|
||||
required List<TutorialStep> steps,
|
||||
}) async {
|
||||
if (_isShowing || steps.isEmpty) return;
|
||||
|
||||
final provider = context.read<TutorialProvider>();
|
||||
final navigator = Navigator.of(context, rootNavigator: true);
|
||||
_isShowing = true;
|
||||
|
||||
try {
|
||||
await navigator.push(
|
||||
PageRouteBuilder<void>(
|
||||
opaque: false,
|
||||
barrierDismissible: false,
|
||||
barrierColor: Colors.transparent,
|
||||
transitionDuration: const Duration(milliseconds: 240),
|
||||
reverseTransitionDuration: const Duration(milliseconds: 180),
|
||||
pageBuilder: (routeContext, animation, _) => FadeTransition(
|
||||
opacity: animation,
|
||||
child: TutorialOverlay(
|
||||
steps: steps,
|
||||
onFinished: () => Navigator.of(routeContext).pop(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
_isShowing = false;
|
||||
}
|
||||
|
||||
await provider.complete(tourId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/// État global du didacticiel : quelles visites guidées restent à jouer.
|
||||
///
|
||||
/// Le provider est chargé au démarrage ; tant que les préférences ne sont pas
|
||||
/// lues, aucune visite n'est déclenchée (évite un flash d'overlay au lancement).
|
||||
library;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../../services/tutorial_service.dart';
|
||||
|
||||
class TutorialProvider with ChangeNotifier {
|
||||
TutorialProvider({TutorialService? service})
|
||||
: _service = service ?? TutorialService() {
|
||||
load();
|
||||
}
|
||||
|
||||
final TutorialService _service;
|
||||
|
||||
final Set<String> _completed = {};
|
||||
bool _loaded = false;
|
||||
|
||||
/// `true` une fois les préférences lues.
|
||||
bool get isLoaded => _loaded;
|
||||
|
||||
/// `true` si l'utilisateur a déjà terminé (ou passé) la visite d'accueil.
|
||||
bool get hasSeenIntro => _completed.contains(TutorialTours.home);
|
||||
|
||||
/// Lit les visites déjà vues. Les visites terminées pendant le chargement
|
||||
/// sont conservées (le résultat est fusionné, jamais écrasé).
|
||||
Future<void> load() async {
|
||||
_completed.addAll(await _service.loadCompletedTours());
|
||||
_loaded = true;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Faut-il jouer la visite [tourId] ?
|
||||
bool shouldRun(String tourId) => _loaded && !_completed.contains(tourId);
|
||||
|
||||
/// Marque une visite comme vue (terminée ou passée) : elle ne rejouera plus.
|
||||
Future<void> complete(String tourId) async {
|
||||
if (_completed.add(tourId)) {
|
||||
notifyListeners();
|
||||
await _service.markCompleted(tourId);
|
||||
}
|
||||
}
|
||||
|
||||
/// Relance le didacticiel depuis les paramètres : toutes les visites
|
||||
/// redeviennent disponibles et rejoueront dès l'affichage de leur écran.
|
||||
Future<void> restart() async {
|
||||
await _service.resetAll();
|
||||
_completed.clear();
|
||||
_loaded = true;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/// Modèle d'une étape de didacticiel (visite guidée).
|
||||
///
|
||||
/// Une étape met en avant un élément de l'écran (le « spot ») grâce à une
|
||||
/// [GlobalKey] posée sur le widget concerné, et affiche une bulle explicative
|
||||
/// accompagnée, si besoin, d'une main animée qui mime le geste attendu
|
||||
/// (tap, appui long, pincement…) comme dans les applications mobiles.
|
||||
library;
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// Geste mimé par la main animée pendant l'étape.
|
||||
enum TutorialGesture {
|
||||
/// Aucune main animée : simple explication.
|
||||
none,
|
||||
|
||||
/// Un appui simple.
|
||||
tap,
|
||||
|
||||
/// Deux appuis rapprochés.
|
||||
doubleTap,
|
||||
|
||||
/// Un appui maintenu.
|
||||
longPress,
|
||||
|
||||
/// Un appui maintenu suivi d'un déplacement du doigt.
|
||||
drag,
|
||||
|
||||
/// Deux doigts qui s'écartent puis se rapprochent (zoom).
|
||||
pinch,
|
||||
|
||||
/// Un doigt qui balaie l'écran vers le haut.
|
||||
swipeUp,
|
||||
|
||||
/// Un doigt qui balaie l'écran horizontalement.
|
||||
swipeHorizontal,
|
||||
}
|
||||
|
||||
/// Forme du trou de lumière découpé dans le voile sombre.
|
||||
enum TutorialHighlightShape { rounded, circle }
|
||||
|
||||
class TutorialStep {
|
||||
/// Clé posée sur le widget à mettre en avant.
|
||||
///
|
||||
/// `null` (ou clé non montée) => l'étape s'affiche comme une carte centrée,
|
||||
/// utile pour les messages d'accueil et de fin.
|
||||
final GlobalKey? targetKey;
|
||||
|
||||
final String title;
|
||||
final String description;
|
||||
|
||||
/// Geste mimé par la main animée.
|
||||
final TutorialGesture gesture;
|
||||
|
||||
final TutorialHighlightShape shape;
|
||||
|
||||
/// Marge ajoutée autour du widget mis en avant.
|
||||
final double spotPadding;
|
||||
|
||||
/// Icône affichée dans la bulle explicative.
|
||||
final IconData? icon;
|
||||
|
||||
/// Force la position de la bulle (`true` = au-dessus du spot).
|
||||
/// Par défaut, la bulle se place automatiquement du côté le plus dégagé.
|
||||
final bool? preferTooltipAbove;
|
||||
|
||||
const TutorialStep({
|
||||
this.targetKey,
|
||||
required this.title,
|
||||
required this.description,
|
||||
this.gesture = TutorialGesture.none,
|
||||
this.shape = TutorialHighlightShape.rounded,
|
||||
this.spotPadding = 8,
|
||||
this.icon,
|
||||
this.preferTooltipAbove,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
/// Main animée du didacticiel : mime le geste attendu par l'utilisateur
|
||||
/// (tap, appui long, glisser, pincement pour zoomer, balayage) comme dans les
|
||||
/// tutoriels des applications mobiles.
|
||||
///
|
||||
/// Tout est dessiné au CustomPainter : pas d'asset, la couleur suit l'accent
|
||||
/// du thème et le rendu reste lisible sur fond clair comme sur fond sombre
|
||||
/// grâce au cœur blanc entouré d'un halo coloré.
|
||||
library;
|
||||
|
||||
import 'dart:math' as math;
|
||||
import 'package:flutter/material.dart';
|
||||
import '../tutorial_step.dart';
|
||||
|
||||
class TutorialHand extends StatefulWidget {
|
||||
final TutorialGesture gesture;
|
||||
final Color color;
|
||||
final double size;
|
||||
|
||||
const TutorialHand({
|
||||
super.key,
|
||||
required this.gesture,
|
||||
required this.color,
|
||||
this.size = 96,
|
||||
});
|
||||
|
||||
@override
|
||||
State<TutorialHand> createState() => _TutorialHandState();
|
||||
}
|
||||
|
||||
class _TutorialHandState extends State<TutorialHand>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: _durationFor(widget.gesture),
|
||||
)..repeat();
|
||||
|
||||
static Duration _durationFor(TutorialGesture gesture) {
|
||||
switch (gesture) {
|
||||
case TutorialGesture.pinch:
|
||||
return const Duration(milliseconds: 2400);
|
||||
case TutorialGesture.longPress:
|
||||
case TutorialGesture.drag:
|
||||
return const Duration(milliseconds: 2200);
|
||||
case TutorialGesture.swipeUp:
|
||||
case TutorialGesture.swipeHorizontal:
|
||||
return const Duration(milliseconds: 1800);
|
||||
default:
|
||||
return const Duration(milliseconds: 1500);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(TutorialHand oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.gesture != widget.gesture) {
|
||||
_controller
|
||||
..stop()
|
||||
..duration = _durationFor(widget.gesture)
|
||||
..repeat();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (widget.gesture == TutorialGesture.none) return const SizedBox.shrink();
|
||||
|
||||
return IgnorePointer(
|
||||
child: SizedBox(
|
||||
width: widget.size,
|
||||
height: widget.size,
|
||||
child: AnimatedBuilder(
|
||||
animation: _controller,
|
||||
builder: (context, _) => CustomPaint(
|
||||
painter: _GesturePainter(
|
||||
t: _controller.value,
|
||||
gesture: widget.gesture,
|
||||
color: widget.color,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GesturePainter extends CustomPainter {
|
||||
final double t;
|
||||
final TutorialGesture gesture;
|
||||
final Color color;
|
||||
|
||||
_GesturePainter({
|
||||
required this.t,
|
||||
required this.gesture,
|
||||
required this.color,
|
||||
});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final center = Offset(size.width / 2, size.height / 2);
|
||||
|
||||
switch (gesture) {
|
||||
case TutorialGesture.none:
|
||||
return;
|
||||
case TutorialGesture.tap:
|
||||
_paintTap(canvas, size, center, pulses: 1);
|
||||
case TutorialGesture.doubleTap:
|
||||
_paintTap(canvas, size, center, pulses: 2);
|
||||
case TutorialGesture.longPress:
|
||||
_paintLongPress(canvas, size, center);
|
||||
case TutorialGesture.drag:
|
||||
_paintDrag(canvas, size, center);
|
||||
case TutorialGesture.pinch:
|
||||
_paintPinch(canvas, size, center);
|
||||
case TutorialGesture.swipeUp:
|
||||
_paintSwipe(canvas, size, center, vertical: true);
|
||||
case TutorialGesture.swipeHorizontal:
|
||||
_paintSwipe(canvas, size, center, vertical: false);
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- Utilitaires
|
||||
|
||||
/// Onde triangulaire adoucie : 0 -> 1 -> 0 sur un cycle.
|
||||
double _wave(double x) => Curves.easeInOut
|
||||
.transform((x < 0.5 ? x * 2 : (1 - x) * 2).clamp(0.0, 1.0));
|
||||
|
||||
/// Doigt posé sur l'écran : cœur blanc + halo coloré.
|
||||
void _paintFingertip(
|
||||
Canvas canvas,
|
||||
Offset position,
|
||||
double radius, {
|
||||
double opacity = 1.0,
|
||||
}) {
|
||||
canvas.drawCircle(
|
||||
position,
|
||||
radius * 2.1,
|
||||
Paint()
|
||||
..color = color.withValues(alpha: 0.22 * opacity)
|
||||
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 8),
|
||||
);
|
||||
canvas.drawCircle(
|
||||
position,
|
||||
radius,
|
||||
Paint()..color = Colors.white.withValues(alpha: 0.95 * opacity),
|
||||
);
|
||||
canvas.drawCircle(
|
||||
position,
|
||||
radius,
|
||||
Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 2.4
|
||||
..color = color.withValues(alpha: 0.9 * opacity),
|
||||
);
|
||||
}
|
||||
|
||||
/// Ondes concentriques émises au moment du contact.
|
||||
void _paintRipple(
|
||||
Canvas canvas,
|
||||
Offset position,
|
||||
double progress,
|
||||
double maxRadius,
|
||||
) {
|
||||
if (progress <= 0 || progress >= 1) return;
|
||||
canvas.drawCircle(
|
||||
position,
|
||||
6 + progress * maxRadius,
|
||||
Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 2.5 * (1 - progress) + 0.6
|
||||
..color = color.withValues(alpha: 0.55 * (1 - progress)),
|
||||
);
|
||||
}
|
||||
|
||||
/// Main « pointeur » (icône Material) dont l'index touche [tip].
|
||||
void _paintHand(
|
||||
Canvas canvas,
|
||||
Offset tip,
|
||||
double glyphSize, {
|
||||
double press = 0,
|
||||
}) {
|
||||
final painter = TextPainter(
|
||||
textDirection: TextDirection.ltr,
|
||||
text: TextSpan(
|
||||
text: String.fromCharCode(Icons.touch_app_rounded.codePoint),
|
||||
style: TextStyle(
|
||||
fontSize: glyphSize,
|
||||
fontFamily: Icons.touch_app_rounded.fontFamily,
|
||||
package: Icons.touch_app_rounded.fontPackage,
|
||||
color: Colors.white.withValues(alpha: 0.96),
|
||||
shadows: [
|
||||
Shadow(
|
||||
color: Colors.black.withValues(alpha: 0.55),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 3),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)..layout();
|
||||
|
||||
// Le bout de l'index de l'icône se situe environ à 32 % / 16 % du glyphe.
|
||||
final origin = tip -
|
||||
Offset(painter.width * 0.32, painter.height * 0.16) +
|
||||
Offset(glyphSize * 0.03 * press, glyphSize * 0.06 * press);
|
||||
painter.paint(canvas, origin);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Gestes
|
||||
|
||||
void _paintTap(
|
||||
Canvas canvas,
|
||||
Size size,
|
||||
Offset center, {
|
||||
required int pulses,
|
||||
}) {
|
||||
final cycle = (t * pulses) % 1.0;
|
||||
final press = _wave((cycle / 0.4).clamp(0.0, 1.0));
|
||||
|
||||
_paintRipple(canvas, center, cycle, size.width * 0.34);
|
||||
_paintRipple(
|
||||
canvas, center, (cycle - 0.25).clamp(0.0, 1.0), size.width * 0.34);
|
||||
_paintFingertip(canvas, center, size.width * 0.055 + 2 * press,
|
||||
opacity: 0.35 + 0.65 * press);
|
||||
_paintHand(canvas, center, size.width * 0.58, press: press);
|
||||
}
|
||||
|
||||
void _paintLongPress(Canvas canvas, Size size, Offset center) {
|
||||
final hold = Curves.easeOut.transform((t / 0.75).clamp(0.0, 1.0));
|
||||
final radius = size.width * 0.24;
|
||||
|
||||
canvas.drawCircle(
|
||||
center,
|
||||
radius,
|
||||
Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 3
|
||||
..color = Colors.white.withValues(alpha: 0.22),
|
||||
);
|
||||
canvas.drawArc(
|
||||
Rect.fromCircle(center: center, radius: radius),
|
||||
-math.pi / 2,
|
||||
2 * math.pi * hold,
|
||||
false,
|
||||
Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 3.4
|
||||
..strokeCap = StrokeCap.round
|
||||
..color = color.withValues(alpha: 0.95),
|
||||
);
|
||||
|
||||
_paintFingertip(canvas, center, size.width * 0.06);
|
||||
_paintHand(canvas, center, size.width * 0.58, press: 1);
|
||||
}
|
||||
|
||||
void _paintDrag(Canvas canvas, Size size, Offset center) {
|
||||
// 0 -> 0.35 : appui maintenu ; puis déplacement aller-retour du doigt.
|
||||
final hold = Curves.easeOut.transform((t / 0.35).clamp(0.0, 1.0));
|
||||
final travel =
|
||||
t <= 0.35 ? 0.0 : _wave(((t - 0.35) / 0.65).clamp(0.0, 1.0));
|
||||
final start = center - Offset(size.width * 0.16, 0);
|
||||
final position = start + Offset(size.width * 0.32 * travel, 0);
|
||||
|
||||
canvas.drawLine(
|
||||
start,
|
||||
position,
|
||||
Paint()
|
||||
..strokeWidth = 3
|
||||
..strokeCap = StrokeCap.round
|
||||
..color = color.withValues(alpha: 0.35),
|
||||
);
|
||||
|
||||
if (hold < 1) {
|
||||
canvas.drawArc(
|
||||
Rect.fromCircle(center: start, radius: size.width * 0.16),
|
||||
-math.pi / 2,
|
||||
2 * math.pi * hold,
|
||||
false,
|
||||
Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 3
|
||||
..strokeCap = StrokeCap.round
|
||||
..color = color.withValues(alpha: 0.9),
|
||||
);
|
||||
}
|
||||
|
||||
_paintFingertip(canvas, position, size.width * 0.06);
|
||||
_paintHand(canvas, position, size.width * 0.58, press: 1);
|
||||
}
|
||||
|
||||
void _paintPinch(Canvas canvas, Size size, Offset center) {
|
||||
// Les deux doigts s'écartent (zoom avant) puis se rapprochent.
|
||||
final spread = _wave(t);
|
||||
final direction = Offset(math.cos(-math.pi / 4), math.sin(-math.pi / 4));
|
||||
final distance = size.width * (0.12 + 0.24 * spread);
|
||||
final first = center + direction * distance;
|
||||
final second = center - direction * distance;
|
||||
final radius = size.width * 0.075;
|
||||
|
||||
// Ligne pointillée reliant les deux doigts.
|
||||
final dashPaint = Paint()
|
||||
..strokeWidth = 2
|
||||
..strokeCap = StrokeCap.round
|
||||
..color = color.withValues(alpha: 0.45);
|
||||
const dashes = 9;
|
||||
for (int i = 0; i < dashes; i++) {
|
||||
if (i.isOdd) continue;
|
||||
final a = Offset.lerp(second, first, i / dashes)!;
|
||||
final b = Offset.lerp(second, first, (i + 1) / dashes)!;
|
||||
canvas.drawLine(a, b, dashPaint);
|
||||
}
|
||||
|
||||
// Chevrons indiquant le sens de l'écartement.
|
||||
_paintChevron(canvas, first, direction, size.width * 0.06, spread);
|
||||
_paintChevron(canvas, second, -direction, size.width * 0.06, spread);
|
||||
|
||||
_paintFingertip(canvas, first, radius);
|
||||
_paintFingertip(canvas, second, radius);
|
||||
}
|
||||
|
||||
void _paintChevron(
|
||||
Canvas canvas,
|
||||
Offset tip,
|
||||
Offset direction,
|
||||
double length,
|
||||
double spread,
|
||||
) {
|
||||
final base = tip + direction * (length * 1.6);
|
||||
final angle = math.atan2(direction.dy, direction.dx);
|
||||
final paint = Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 2.6
|
||||
..strokeCap = StrokeCap.round
|
||||
..color = color.withValues(alpha: 0.25 + 0.6 * spread);
|
||||
|
||||
for (final sign in [-1, 1]) {
|
||||
final branch = angle + sign * 2.5;
|
||||
canvas.drawLine(
|
||||
base,
|
||||
base + Offset(math.cos(branch), math.sin(branch)) * length,
|
||||
paint,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _paintSwipe(
|
||||
Canvas canvas,
|
||||
Size size,
|
||||
Offset center, {
|
||||
required bool vertical,
|
||||
}) {
|
||||
final progress = Curves.easeInOut.transform((t / 0.75).clamp(0.0, 1.0));
|
||||
final fade = t > 0.75 ? 1 - ((t - 0.75) / 0.25) : 1.0;
|
||||
final axis = vertical ? const Offset(0, -1) : const Offset(1, 0);
|
||||
final amplitude = size.width * 0.28;
|
||||
final start = center - axis * amplitude;
|
||||
final position = start + axis * (2 * amplitude * progress);
|
||||
|
||||
canvas.drawLine(
|
||||
start,
|
||||
position,
|
||||
Paint()
|
||||
..strokeWidth = 3
|
||||
..strokeCap = StrokeCap.round
|
||||
..color = color.withValues(alpha: 0.32 * fade),
|
||||
);
|
||||
_paintFingertip(canvas, position, size.width * 0.06, opacity: fade);
|
||||
_paintHand(canvas, position, size.width * 0.58, press: 1);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_GesturePainter old) =>
|
||||
old.t != t || old.gesture != gesture || old.color != color;
|
||||
}
|
||||
@@ -0,0 +1,536 @@
|
||||
/// Voile du didacticiel : assombrit l'écran, découpe un « trou de lumière »
|
||||
/// autour de l'élément à découvrir, y anime une main qui mime le geste attendu
|
||||
/// et affiche une bulle explicative avec la progression.
|
||||
///
|
||||
/// L'overlay est purement visuel : il n'exécute pas l'action à la place de
|
||||
/// l'utilisateur. On avance d'une étape en touchant l'écran ou le bouton
|
||||
/// « Suivant » ; « Passer » interrompt la visite.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../tutorial_step.dart';
|
||||
import 'tutorial_hand.dart';
|
||||
|
||||
class TutorialOverlay extends StatefulWidget {
|
||||
final List<TutorialStep> steps;
|
||||
|
||||
/// Appelé à la fin de la visite (terminée ou passée).
|
||||
final VoidCallback onFinished;
|
||||
|
||||
const TutorialOverlay({
|
||||
super.key,
|
||||
required this.steps,
|
||||
required this.onFinished,
|
||||
});
|
||||
|
||||
@override
|
||||
State<TutorialOverlay> createState() => _TutorialOverlayState();
|
||||
}
|
||||
|
||||
class _TutorialOverlayState extends State<TutorialOverlay>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _pulseController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 1600),
|
||||
)..repeat();
|
||||
|
||||
int _index = 0;
|
||||
Rect? _spotRect;
|
||||
late bool _ready = widget.steps.isNotEmpty && widget.steps[0].targetKey == null;
|
||||
|
||||
TutorialStep get _step => widget.steps[_index];
|
||||
bool get _isLast => _index == widget.steps.length - 1;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _prepareStep());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pulseController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Amène l'élément visé à l'écran puis mesure sa position.
|
||||
Future<void> _prepareStep() async {
|
||||
final targetContext = _step.targetKey?.currentContext;
|
||||
|
||||
if (targetContext == null || !targetContext.mounted) {
|
||||
if (mounted) setState(() { _spotRect = null; _ready = true; });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await Scrollable.ensureVisible(
|
||||
targetContext,
|
||||
alignment: 0.35,
|
||||
duration: const Duration(milliseconds: 320),
|
||||
curve: Curves.easeOutCubic,
|
||||
);
|
||||
} catch (_) {
|
||||
// Ignorer si l'élément n'est pas dans un widget scrollable
|
||||
}
|
||||
|
||||
// Laisse le temps au défilement de se stabiliser avant de mesurer.
|
||||
await Future<void>.delayed(const Duration(milliseconds: 60));
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() {
|
||||
_spotRect = _measure(targetContext, _step.spotPadding);
|
||||
_ready = true;
|
||||
});
|
||||
}
|
||||
|
||||
Rect? _measure(BuildContext targetContext, double padding) {
|
||||
if (!targetContext.mounted) return null;
|
||||
final renderObject = targetContext.findRenderObject();
|
||||
if (renderObject is! RenderBox || !renderObject.hasSize) return null;
|
||||
try {
|
||||
final origin = renderObject.localToGlobal(Offset.zero);
|
||||
final screen = MediaQuery.of(context).size;
|
||||
return Rect.fromLTWH(origin.dx, origin.dy, renderObject.size.width, renderObject.size.height)
|
||||
.inflate(padding)
|
||||
.intersect(Offset.zero & screen);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
void _next() {
|
||||
if (_isLast) {
|
||||
_finish();
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_index++;
|
||||
_ready = widget.steps[_index].targetKey == null;
|
||||
_spotRect = null;
|
||||
});
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _prepareStep());
|
||||
}
|
||||
|
||||
void _finish() => widget.onFinished();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final primary = theme.colorScheme.primary;
|
||||
final spot = _spotRect;
|
||||
|
||||
return PopScope(
|
||||
canPop: false,
|
||||
onPopInvokedWithResult: (didPop, _) {
|
||||
if (!didPop) _finish();
|
||||
},
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: _ready ? _next : null,
|
||||
child: Stack(
|
||||
children: [
|
||||
// Voile sombre percé autour de l'élément mis en avant.
|
||||
Positioned.fill(
|
||||
child: AnimatedBuilder(
|
||||
animation: _pulseController,
|
||||
builder: (context, _) => CustomPaint(
|
||||
painter: _SpotlightPainter(
|
||||
spot: spot,
|
||||
circle: _step.shape == TutorialHighlightShape.circle,
|
||||
pulse: _pulseController.value,
|
||||
glowColor: primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Main animée posée sur l'élément mis en avant.
|
||||
if (spot != null && _step.gesture != TutorialGesture.none)
|
||||
_buildHand(spot, primary),
|
||||
|
||||
// Bulle explicative.
|
||||
if (_ready) _buildTooltip(context, spot, primary),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHand(Rect spot, Color primary) {
|
||||
final handSize =
|
||||
(spot.shortestSide * 1.4).clamp(96.0, 190.0).toDouble();
|
||||
// Sur une grande zone (image plein écran), la main reste au centre ;
|
||||
// sur un bouton, elle se cale sur le centre du bouton.
|
||||
final center = spot.center;
|
||||
return Positioned(
|
||||
left: center.dx - handSize / 2,
|
||||
top: center.dy - handSize / 2,
|
||||
child: TutorialHand(
|
||||
gesture: _step.gesture,
|
||||
color: primary,
|
||||
size: handSize,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTooltip(BuildContext context, Rect? spot, Color primary) {
|
||||
final media = MediaQuery.of(context);
|
||||
final screenHeight = media.size.height;
|
||||
|
||||
final card = _TutorialCard(
|
||||
step: _step,
|
||||
index: _index,
|
||||
total: widget.steps.length,
|
||||
primary: primary,
|
||||
isLast: _isLast,
|
||||
onNext: _next,
|
||||
onSkip: _finish,
|
||||
);
|
||||
|
||||
if (spot == null) {
|
||||
return Positioned.fill(
|
||||
child: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Center(
|
||||
child: card,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const gap = 22.0;
|
||||
|
||||
// Zone très large (image plein écran) : la bulle flotte en bas de l'écran
|
||||
// pour laisser la main animée visible au centre.
|
||||
if (spot.height > screenHeight * 0.55) {
|
||||
final available = (screenHeight * 0.4).clamp(140.0, screenHeight);
|
||||
return Positioned(
|
||||
left: 16,
|
||||
right: 16,
|
||||
bottom: media.padding.bottom + 24,
|
||||
child: SafeArea(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxHeight: available),
|
||||
child: Center(
|
||||
child: card,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Sinon, la bulle se place du côté le plus dégagé du spot.
|
||||
final above =
|
||||
_step.preferTooltipAbove ?? (spot.top > screenHeight - spot.bottom);
|
||||
|
||||
final available = above
|
||||
? (spot.top - gap - media.padding.top).clamp(140.0, screenHeight)
|
||||
: (screenHeight - spot.bottom - gap - media.padding.bottom).clamp(140.0, screenHeight);
|
||||
|
||||
return Positioned(
|
||||
left: 16,
|
||||
right: 16,
|
||||
top: above ? null : (spot.bottom + gap),
|
||||
bottom: above ? (screenHeight - spot.top + gap) : null,
|
||||
child: SafeArea(
|
||||
top: !above,
|
||||
bottom: above,
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxHeight: available),
|
||||
child: Center(
|
||||
child: card,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Carte explicative d'une étape (titre, texte, progression, boutons).
|
||||
class _TutorialCard extends StatelessWidget {
|
||||
final TutorialStep step;
|
||||
final int index;
|
||||
final int total;
|
||||
final Color primary;
|
||||
final bool isLast;
|
||||
final VoidCallback onNext;
|
||||
final VoidCallback onSkip;
|
||||
|
||||
const _TutorialCard({
|
||||
required this.step,
|
||||
required this.index,
|
||||
required this.total,
|
||||
required this.primary,
|
||||
required this.isLast,
|
||||
required this.onNext,
|
||||
required this.onSkip,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final surface = isDark ? const Color(0xFF121A26) : Colors.white;
|
||||
final textPrimary =
|
||||
isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary;
|
||||
final textSecondary =
|
||||
isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary;
|
||||
|
||||
return ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 460),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.fromLTRB(18, 16, 18, 12),
|
||||
decoration: BoxDecoration(
|
||||
color: surface,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: primary.withValues(alpha: 0.35), width: 1.2),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: isDark ? 0.6 : 0.25),
|
||||
blurRadius: 28,
|
||||
offset: const Offset(0, 10),
|
||||
),
|
||||
BoxShadow(
|
||||
color: primary.withValues(alpha: 0.18),
|
||||
blurRadius: 24,
|
||||
spreadRadius: -6,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: primary.withValues(alpha: isDark ? 0.22 : 0.14),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Icon(
|
||||
step.icon ?? Icons.school_outlined,
|
||||
color: primary,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
step.title,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: textPrimary,
|
||||
letterSpacing: -0.2,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'${index + 1}/$total',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
step.description,
|
||||
style: TextStyle(
|
||||
fontSize: 13.5,
|
||||
height: 1.4,
|
||||
color: textSecondary,
|
||||
),
|
||||
),
|
||||
if (step.gesture != TutorialGesture.none) ...[
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.gesture, size: 16, color: primary),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_gestureHint(step.gesture),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
...List.generate(total, (i) {
|
||||
final active = i == index;
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
margin: const EdgeInsets.only(right: 5),
|
||||
width: active ? 18 : 6,
|
||||
height: 6,
|
||||
decoration: BoxDecoration(
|
||||
color: active
|
||||
? primary
|
||||
: primary.withValues(alpha: 0.28),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
);
|
||||
}),
|
||||
const Spacer(),
|
||||
TextButton(
|
||||
onPressed: onSkip,
|
||||
style: TextButton.styleFrom(foregroundColor: textSecondary),
|
||||
child: const Text('Passer'),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
FilledButton(
|
||||
onPressed: onNext,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: primary,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 18, vertical: 10),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
isLast ? 'C\'est parti' : 'Suivant',
|
||||
style: const TextStyle(fontWeight: FontWeight.w800),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static String _gestureHint(TutorialGesture gesture) {
|
||||
switch (gesture) {
|
||||
case TutorialGesture.tap:
|
||||
return 'Appuyez une fois';
|
||||
case TutorialGesture.doubleTap:
|
||||
return 'Appuyez deux fois';
|
||||
case TutorialGesture.longPress:
|
||||
return 'Appui long';
|
||||
case TutorialGesture.drag:
|
||||
return 'Appui long puis glisser';
|
||||
case TutorialGesture.pinch:
|
||||
return 'Pincez avec deux doigts pour zoomer';
|
||||
case TutorialGesture.swipeUp:
|
||||
return 'Balayez vers le haut';
|
||||
case TutorialGesture.swipeHorizontal:
|
||||
return 'Balayez latéralement';
|
||||
case TutorialGesture.none:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Voile sombre percé d'un trou de lumière, avec anneau pulsant.
|
||||
class _SpotlightPainter extends CustomPainter {
|
||||
final Rect? spot;
|
||||
final bool circle;
|
||||
final double pulse;
|
||||
final Color glowColor;
|
||||
|
||||
_SpotlightPainter({
|
||||
required this.spot,
|
||||
required this.circle,
|
||||
required this.pulse,
|
||||
required this.glowColor,
|
||||
});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final scrim = Paint()..color = Colors.black.withValues(alpha: 0.76);
|
||||
final screenPath = Path()..addRect(Offset.zero & size);
|
||||
final target = spot;
|
||||
|
||||
if (target == null || target.isEmpty) {
|
||||
canvas.drawPath(screenPath, scrim);
|
||||
return;
|
||||
}
|
||||
|
||||
final holePath = circle
|
||||
? (Path()
|
||||
..addOval(Rect.fromCircle(
|
||||
center: target.center,
|
||||
radius: target.longestSide / 2,
|
||||
)))
|
||||
: (Path()
|
||||
..addRRect(RRect.fromRectAndRadius(
|
||||
target,
|
||||
const Radius.circular(18),
|
||||
)));
|
||||
|
||||
canvas.drawPath(
|
||||
Path.combine(PathOperation.difference, screenPath, holePath),
|
||||
scrim,
|
||||
);
|
||||
|
||||
// Halo diffus autour du trou.
|
||||
canvas.drawPath(
|
||||
holePath,
|
||||
Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 10
|
||||
..color = glowColor.withValues(alpha: 0.28)
|
||||
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 10),
|
||||
);
|
||||
|
||||
// Contour net.
|
||||
canvas.drawPath(
|
||||
holePath,
|
||||
Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 2.4
|
||||
..color = glowColor.withValues(alpha: 0.95),
|
||||
);
|
||||
|
||||
// Anneau qui s'écarte en boucle pour attirer l'œil.
|
||||
final ringPaint = Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 2.5
|
||||
..color = glowColor.withValues(alpha: 0.5 * (1 - pulse));
|
||||
final expansion = target.shortestSide * 0.10 * pulse;
|
||||
|
||||
if (circle) {
|
||||
canvas.drawCircle(
|
||||
target.center,
|
||||
target.longestSide / 2 + expansion,
|
||||
ringPaint,
|
||||
);
|
||||
} else {
|
||||
canvas.drawRRect(
|
||||
RRect.fromRectAndRadius(
|
||||
target.inflate(expansion),
|
||||
Radius.circular(18 + expansion),
|
||||
),
|
||||
ringPaint,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_SpotlightPainter old) =>
|
||||
old.spot != spot ||
|
||||
old.pulse != pulse ||
|
||||
old.circle != circle ||
|
||||
old.glowColor != glowColor;
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import 'data/repositories/session_repository.dart';
|
||||
import 'services/score_calculator_service.dart';
|
||||
import 'services/grouping_analyzer_service.dart';
|
||||
import 'features/session/session_provider.dart';
|
||||
import 'features/tutorial/tutorial_provider.dart';
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
@@ -39,6 +40,7 @@ void main() async {
|
||||
Provider<SessionRepository>(create: (_) => SessionRepository()),
|
||||
ChangeNotifierProvider<ThemeProvider>(create: (_) => ThemeProvider()),
|
||||
ChangeNotifierProvider<SessionProvider>(create: (_) => SessionProvider()),
|
||||
ChangeNotifierProvider<TutorialProvider>(create: (_) => TutorialProvider()),
|
||||
],
|
||||
child: const BullyApp(),
|
||||
),
|
||||
|
||||
+125
-38
@@ -1,3 +1,4 @@
|
||||
import 'dart:ui';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'features/home/home_screen.dart';
|
||||
import 'features/history/history_screen.dart';
|
||||
@@ -11,11 +12,13 @@ const int mainTabHistory = 1;
|
||||
const int mainTabStats = 2;
|
||||
const int mainTabGarage = 3;
|
||||
|
||||
/// Clé du holder : permet de piloter l'onglet actif depuis n'importe quel
|
||||
/// écran (ex. fin de session -> onglet Stats).
|
||||
/// Clé du holder : permet de piloter l'onglet actif depuis n'importe quel écran.
|
||||
final GlobalKey<State<MainNavigationHolder>> mainNavKey =
|
||||
GlobalKey<State<MainNavigationHolder>>();
|
||||
|
||||
/// Clé du dock flottant : permet au didacticiel de le mettre en avant.
|
||||
final GlobalKey navigationDockKey = GlobalKey();
|
||||
|
||||
/// Ouvre l'onglet [index] de la navigation principale.
|
||||
void openMainTab(int index) {
|
||||
final state = mainNavKey.currentState;
|
||||
@@ -32,9 +35,6 @@ class MainNavigationHolder extends StatefulWidget {
|
||||
class _MainNavigationHolderState extends State<MainNavigationHolder> {
|
||||
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 _historyTick = 0;
|
||||
int _homeTick = 0;
|
||||
@@ -53,57 +53,144 @@ class _MainNavigationHolderState extends State<MainNavigationHolder> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
final screens = [
|
||||
HomeScreen(refreshTick: _homeTick),
|
||||
HistoryScreen(refreshTick: _historyTick),
|
||||
StatisticsScreen(refreshTick: _statsTick),
|
||||
WeaponListScreen(refreshTick: _garageTick),
|
||||
];
|
||||
|
||||
return Scaffold(
|
||||
extendBody: true,
|
||||
body: IndexedStack(
|
||||
index: _selectedIndex,
|
||||
children: screens,
|
||||
),
|
||||
bottomNavigationBar: Container(
|
||||
bottomNavigationBar: _buildFloatingGlassDock(isDark),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFloatingGlassDock(bool isDark) {
|
||||
final primaryColor = Theme.of(context).colorScheme.primary;
|
||||
final navItems = [
|
||||
(Icons.radar_outlined, Icons.radar, 'Accueil'),
|
||||
(Icons.history_outlined, Icons.history_rounded, 'Historique'),
|
||||
(Icons.insights_outlined, Icons.insights_rounded, 'Stats'),
|
||||
(Icons.shield_outlined, Icons.shield, 'Armurerie'),
|
||||
];
|
||||
|
||||
return SafeArea(
|
||||
child: Container(
|
||||
key: navigationDockKey,
|
||||
margin: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
height: 68,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.1),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, -2),
|
||||
color: Colors.black.withValues(alpha: isDark ? 0.45 : 0.12),
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: BottomNavigationBar(
|
||||
currentIndex: _selectedIndex,
|
||||
onTap: selectTab,
|
||||
type: BottomNavigationBarType.fixed,
|
||||
backgroundColor: Theme.of(context).cardColor,
|
||||
selectedItemColor: AppTheme.primaryColor,
|
||||
unselectedItemColor: Colors.grey,
|
||||
showUnselectedLabels: true,
|
||||
items: const [
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.home_outlined),
|
||||
activeIcon: Icon(Icons.home),
|
||||
label: 'Accueil',
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 16, sigmaY: 16),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark
|
||||
? const Color(0xFF101722).withValues(alpha: 0.78)
|
||||
: Colors.white.withValues(alpha: 0.85),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(
|
||||
color: isDark
|
||||
? Colors.white.withValues(alpha: 0.14)
|
||||
: Colors.black.withValues(alpha: 0.08),
|
||||
width: 1.2,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: List.generate(navItems.length, (index) {
|
||||
final item = navItems[index];
|
||||
final isSelected = _selectedIndex == index;
|
||||
|
||||
return Expanded(
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => selectTab(index),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeOutCubic,
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? primaryColor.withValues(
|
||||
alpha: isDark ? 0.22 : 0.15,
|
||||
)
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: isSelected
|
||||
? Border.all(
|
||||
color: primaryColor.withValues(
|
||||
alpha: isDark ? 0.45 : 0.35,
|
||||
),
|
||||
width: 1,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
isSelected ? item.$2 : item.$1,
|
||||
size: 22,
|
||||
color: isSelected
|
||||
? primaryColor
|
||||
: (isDark
|
||||
? AppTheme.darkTextSecondary
|
||||
: AppTheme.lightTextSecondary),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
item.$3,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: isSelected
|
||||
? FontWeight.w700
|
||||
: FontWeight.w500,
|
||||
color: isSelected
|
||||
? primaryColor
|
||||
: (isDark
|
||||
? AppTheme.darkTextSecondary
|
||||
: AppTheme.lightTextSecondary),
|
||||
letterSpacing: 0.2,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.history_outlined),
|
||||
activeIcon: Icon(Icons.history),
|
||||
label: 'Historique',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.analytics_outlined),
|
||||
activeIcon: Icon(Icons.analytics),
|
||||
label: 'Stats',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.shield_outlined),
|
||||
activeIcon: Icon(Icons.shield),
|
||||
label: 'Armurerie',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@ import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'dart:io';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:image/image.dart' as img;
|
||||
import 'package:crypto/crypto.dart';
|
||||
import '../data/models/shot.dart';
|
||||
import '../data/models/target_type.dart';
|
||||
@@ -64,30 +64,37 @@ class AiExportResult {
|
||||
}
|
||||
|
||||
class AiExportService {
|
||||
/// Extrait les informations de l'appareil
|
||||
Future<Map<String, dynamic>> _getDeviceInfo() async {
|
||||
final DeviceInfoPlugin deviceInfoPlugin = DeviceInfoPlugin();
|
||||
Map<String, dynamic> deviceData = {'model': 'Unknown', 'os': 'Unknown'};
|
||||
|
||||
/// Retire toutes les metadonnees de la photo avant l'envoi.
|
||||
///
|
||||
/// Une photo de cible prise au telephone embarque un bloc EXIF qui contient
|
||||
/// typiquement la position GPS du stand de tir, la date exacte et le modele
|
||||
/// d'appareil. Rien de tout cela n'est utile au modele de detection.
|
||||
///
|
||||
/// L'orientation est d'abord appliquee physiquement aux pixels : les
|
||||
/// coordonnees d'impact sont normalisees sur l'image telle qu'elle est
|
||||
/// affichee dans l'app (Flutter applique l'orientation EXIF), donc supprimer
|
||||
/// le tag sans redresser l'image ferait pivoter la photo par rapport a ses
|
||||
/// propres annotations.
|
||||
///
|
||||
/// Retourne null si l'image est illisible.
|
||||
@visibleForTesting
|
||||
Uint8List? stripMetadata(Uint8List originalBytes) {
|
||||
try {
|
||||
if (Platform.isAndroid) {
|
||||
final androidInfo = await deviceInfoPlugin.androidInfo;
|
||||
deviceData['model'] = '${androidInfo.brand} ${androidInfo.model}';
|
||||
deviceData['os'] = 'Android ${androidInfo.version.release}';
|
||||
} else if (Platform.isIOS) {
|
||||
final iosInfo = await deviceInfoPlugin.iosInfo;
|
||||
deviceData['model'] = iosInfo.name;
|
||||
deviceData['os'] = '${iosInfo.systemName} ${iosInfo.systemVersion}';
|
||||
} else if (Platform.isWindows) {
|
||||
final windowsInfo = await deviceInfoPlugin.windowsInfo;
|
||||
deviceData['model'] = 'Windows PC';
|
||||
deviceData['os'] = 'Windows ${windowsInfo.majorVersion}.${windowsInfo.minorVersion}';
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la récupération des infos appareil: $e');
|
||||
}
|
||||
final decoded = img.decodeImage(originalBytes);
|
||||
if (decoded == null) return null;
|
||||
|
||||
return deviceData;
|
||||
final baked = img.bakeOrientation(decoded);
|
||||
|
||||
// bakeOrientation recopie tout l'EXIF sauf l'orientation : sans ce reset,
|
||||
// le GPS survivrait au reencodage.
|
||||
baked.exif = img.ExifData();
|
||||
|
||||
return img.encodeJpg(baked, quality: 90);
|
||||
} catch (e) {
|
||||
// Sur un fichier tronque, decodeImage leve au lieu de retourner null.
|
||||
debugPrint('Photo illisible, export annule: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Exporte l'image et les données de plotting vers le serveur
|
||||
@@ -100,12 +107,14 @@ class AiExportService {
|
||||
required double targetRadius,
|
||||
required List<Shot> shots,
|
||||
int distanceMeters = 25,
|
||||
String weaponName = 'Unknown',
|
||||
String caliber = 'unknown',
|
||||
int? expectedShots,
|
||||
String? apiUrl,
|
||||
}) async {
|
||||
try {
|
||||
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 url = Uri.parse(effectiveUrl);
|
||||
final request = http.MultipartRequest('POST', url);
|
||||
@@ -120,7 +129,13 @@ class AiExportService {
|
||||
);
|
||||
}
|
||||
|
||||
final deviceData = await _getDeviceInfo();
|
||||
final sanitizedPhoto = stripMetadata(await file.readAsBytes());
|
||||
if (sanitizedPhoto == null) {
|
||||
return AiExportResult.error(
|
||||
code: 'INVALID_IMAGE',
|
||||
message: 'La photo de la cible est illisible et n\'a pas pu être envoyée.',
|
||||
);
|
||||
}
|
||||
|
||||
final corners = [
|
||||
{"norm_x": targetCenterX - targetRadius, "norm_y": targetCenterY - targetRadius},
|
||||
@@ -154,11 +169,15 @@ class AiExportService {
|
||||
"session_id": sessionId,
|
||||
"wallet_hash": walletHash,
|
||||
"timestamp": DateTime.now().toIso8601String(),
|
||||
"device_info": deviceData,
|
||||
"target_metadata": {
|
||||
"type": targetType.name,
|
||||
"distance_meters": distanceMeters,
|
||||
"weapon": weaponName,
|
||||
"caliber": caliber,
|
||||
// Nombre de coups prevus pour cette cible, null hors session.
|
||||
// Un ecart avec le nombre d'impacts ne signifie pas que le
|
||||
// marquage est faux (un coup peut etre parti hors papier) : c'est
|
||||
// au tri du dataset d'en decider, pas au client.
|
||||
"expected_shots": expectedShots,
|
||||
},
|
||||
"plotting": {
|
||||
"target_corners": corners,
|
||||
@@ -168,7 +187,11 @@ class AiExportService {
|
||||
|
||||
request.fields['plotting'] = jsonEncode(plottingJson);
|
||||
request.files.add(
|
||||
await http.MultipartFile.fromPath('photo', imagePath),
|
||||
http.MultipartFile.fromBytes(
|
||||
'photo',
|
||||
sanitizedPhoto,
|
||||
filename: 'target.jpg',
|
||||
),
|
||||
);
|
||||
|
||||
final streamedResponse = await request.send().timeout(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:sensors_plus/sensors_plus.dart';
|
||||
|
||||
/// Statut de parallélisme retourné en temps réel.
|
||||
@@ -14,31 +15,67 @@ enum ParallelismStatus {
|
||||
misaligned,
|
||||
}
|
||||
|
||||
/// Pose de prise de vue déduite de l'inclinaison de l'appareil.
|
||||
enum TargetPose {
|
||||
/// Cible accrochée verticalement : l'appareil est tenu droit.
|
||||
wall,
|
||||
|
||||
/// Cible posée au sol : l'appareil est à plat, caméra vers le bas.
|
||||
ground,
|
||||
}
|
||||
|
||||
/// Données de parallélisme calculées à chaque frame capteur.
|
||||
class ParallelismData {
|
||||
final ParallelismStatus status;
|
||||
|
||||
/// Inclinaison avant/arrière en degrés (0° = parfaitement vertical).
|
||||
/// Pose détectée automatiquement, qui sert de référence aux écarts.
|
||||
final TargetPose pose;
|
||||
|
||||
/// Inclinaison avant/arrière brute en degrés (0° = appareil vertical,
|
||||
/// +90° = appareil à plat, caméra vers le sol).
|
||||
final double pitchDegrees;
|
||||
|
||||
/// Inclinaison gauche/droite en degrés (0° = parfaitement droit).
|
||||
final double rollDegrees;
|
||||
|
||||
/// Écart de tangage par rapport à la pose détectée.
|
||||
///
|
||||
/// En pose [TargetPose.wall] il vaut exactement [pitchDegrees] ; en pose
|
||||
/// [TargetPose.ground] il mesure l'écart aux +90° de l'appareil à plat.
|
||||
/// C'est cette valeur qu'il faut afficher : le tangage brut vaudrait -90°
|
||||
/// alors que le cadrage est parfait.
|
||||
final double pitchDeviation;
|
||||
|
||||
const ParallelismData({
|
||||
required this.status,
|
||||
required this.pose,
|
||||
required this.pitchDegrees,
|
||||
required this.rollDegrees,
|
||||
required this.pitchDeviation,
|
||||
});
|
||||
|
||||
bool get isAligned => status == ParallelismStatus.aligned;
|
||||
|
||||
/// Écart latéral. Le roulis se mesure de la même façon dans les deux poses.
|
||||
double get rollDeviation => rollDegrees;
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'ParallelismData(status: $status, pitch: ${pitchDegrees.toStringAsFixed(1)}°, roll: ${rollDegrees.toStringAsFixed(1)}°)';
|
||||
'ParallelismData(status: $status, pose: $pose, pitch: ${pitchDegrees.toStringAsFixed(1)}°, '
|
||||
'écart: ${pitchDeviation.toStringAsFixed(1)}°, roll: ${rollDegrees.toStringAsFixed(1)}°)';
|
||||
}
|
||||
|
||||
/// Service de détection du parallélisme par accéléromètre.
|
||||
///
|
||||
/// Deux poses de prise de vue sont reconnues, et celle dont l'appareil est le
|
||||
/// plus proche est retenue automatiquement :
|
||||
///
|
||||
/// [TargetPose.wall] — cible au mur, appareil vertical (tangage ≈ 0°)
|
||||
/// [TargetPose.ground] — cible au sol, appareil à plat (tangage ≈ +90°)
|
||||
///
|
||||
/// Seul le tangage positif vaut pour la pose au sol : à plat écran vers le
|
||||
/// haut, la caméra vise le plafond et le vert n'aurait aucun sens.
|
||||
///
|
||||
/// Implémente une hystérésis à deux seuils pour éviter le clignotement :
|
||||
///
|
||||
/// État actuel = misaligned → passe à aligned si angle < [alignThreshold]
|
||||
@@ -58,6 +95,13 @@ class ParallelismService {
|
||||
/// Doit être > alignThreshold pour créer la zone d'hystérésis.
|
||||
final double misalignThreshold;
|
||||
|
||||
/// Écart minimum en faveur de l'autre pose pour basculer.
|
||||
///
|
||||
/// Les deux poses sont séparées de 90°, donc la bascule se joue vers 45° —
|
||||
/// très loin des deux zones vertes. Cette marge évite seulement que
|
||||
/// l'étiquette de pose clignote pile à la frontière.
|
||||
static const double poseSwitchMargin = 5.0;
|
||||
|
||||
StreamSubscription<AccelerometerEvent>? _subscription;
|
||||
final StreamController<ParallelismData> _controller =
|
||||
StreamController<ParallelismData>.broadcast();
|
||||
@@ -65,6 +109,9 @@ class ParallelismService {
|
||||
/// État interne mémorisé entre deux frames (cœur de l'hystérésis).
|
||||
ParallelismStatus _currentStatus = ParallelismStatus.unknown;
|
||||
|
||||
/// Pose retenue à la frame précédente, pour l'hystérésis de pose.
|
||||
TargetPose? _currentPose;
|
||||
|
||||
ParallelismService({
|
||||
this.alignThreshold = 25.0,
|
||||
this.misalignThreshold = 32.0,
|
||||
@@ -86,10 +133,13 @@ class ParallelismService {
|
||||
// Simulateur ou capteur absent — on reste en "unknown" sans bloquer l'UI
|
||||
if (!_controller.isClosed) {
|
||||
_currentStatus = ParallelismStatus.unknown;
|
||||
_currentPose = null;
|
||||
_controller.add(const ParallelismData(
|
||||
status: ParallelismStatus.unknown,
|
||||
pose: TargetPose.wall,
|
||||
pitchDegrees: 0,
|
||||
rollDegrees: 0,
|
||||
pitchDeviation: 0,
|
||||
));
|
||||
}
|
||||
},
|
||||
@@ -100,6 +150,7 @@ class ParallelismService {
|
||||
_subscription?.cancel();
|
||||
_subscription = null;
|
||||
_currentStatus = ParallelismStatus.unknown;
|
||||
_currentPose = null;
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
@@ -110,12 +161,18 @@ class ParallelismService {
|
||||
void _onAccelerometerEvent(AccelerometerEvent event) {
|
||||
if (_controller.isClosed) return;
|
||||
|
||||
final double gx = event.x;
|
||||
final double gy = event.y;
|
||||
final double gz = event.z;
|
||||
final data = evaluate(event.x, event.y, event.z);
|
||||
if (data != null) _controller.add(data);
|
||||
}
|
||||
|
||||
/// Calcule la pose et le statut depuis une mesure d'accéléromètre.
|
||||
///
|
||||
/// Met à jour l'état d'hystérésis, donc l'ordre des appels compte.
|
||||
/// Retourne null sur une mesure aberrante, qu'il faut alors ignorer.
|
||||
@visibleForTesting
|
||||
ParallelismData? evaluate(double gx, double gy, double gz) {
|
||||
final double magnitude = math.sqrt(gx * gx + gy * gy + gz * gz);
|
||||
if (magnitude < 1.0) return; // Données aberrantes
|
||||
if (magnitude < 1.0) return null; // Données aberrantes
|
||||
|
||||
// Normalisation par la magnitude réelle (indépendant de g exact)
|
||||
final double nx = gx / magnitude;
|
||||
@@ -125,10 +182,39 @@ class ParallelismService {
|
||||
final double pitchDeg = math.asin(nz.clamp(-1.0, 1.0)) * (180.0 / math.pi);
|
||||
final double rollDeg = math.asin(nx.clamp(-1.0, 1.0)) * (180.0 / math.pi);
|
||||
|
||||
// Le critère de couleur = le PIRE des deux angles affichés à l'écran.
|
||||
// Ainsi ce que voit l'utilisateur (Pitch / Roll) correspond exactement
|
||||
// à la décision vert/orange : à 2° d'écart, on est largement dans le vert.
|
||||
final double worstAngle = math.max(pitchDeg.abs(), rollDeg.abs());
|
||||
// ── Choix de la pose ────────────────────────────────────────────────────
|
||||
// Cible au mur : le tangage idéal est 0°. Formule d'origine, inchangée.
|
||||
final double wallPitchDeviation = pitchDeg;
|
||||
final double wallWorst = math.max(pitchDeg.abs(), rollDeg.abs());
|
||||
|
||||
// Cible au sol : le tangage idéal est +90° (caméra vers le bas). Écran vers
|
||||
// le haut, le tangage vaut -90° et l'écart atteint 180° : la pose au sol ne
|
||||
// peut alors jamais gagner, ce qui est exactement le comportement voulu.
|
||||
final double groundPitchDeviation = pitchDeg - 90.0;
|
||||
final double groundWorst =
|
||||
math.max(groundPitchDeviation.abs(), rollDeg.abs());
|
||||
|
||||
if (_currentPose == null) {
|
||||
_currentPose =
|
||||
groundWorst < wallWorst ? TargetPose.ground : TargetPose.wall;
|
||||
} else if (_currentPose == TargetPose.wall) {
|
||||
if (groundWorst + poseSwitchMargin < wallWorst) {
|
||||
_currentPose = TargetPose.ground;
|
||||
}
|
||||
} else {
|
||||
if (wallWorst + poseSwitchMargin < groundWorst) {
|
||||
_currentPose = TargetPose.wall;
|
||||
}
|
||||
}
|
||||
|
||||
final bool isGround = _currentPose == TargetPose.ground;
|
||||
final double pitchDeviation =
|
||||
isGround ? groundPitchDeviation : wallPitchDeviation;
|
||||
|
||||
// Le critère de couleur = le PIRE des deux écarts affichés à l'écran.
|
||||
// Ainsi ce que voit l'utilisateur correspond exactement à la décision
|
||||
// vert/orange : à 2° d'écart, on est largement dans le vert.
|
||||
final double worstAngle = isGround ? groundWorst : wallWorst;
|
||||
|
||||
// ── Hystérésis ──────────────────────────────────────────────────────────
|
||||
// Premier appel : on décide selon alignThreshold uniquement
|
||||
@@ -152,10 +238,12 @@ class ParallelismService {
|
||||
}
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
_controller.add(ParallelismData(
|
||||
return ParallelismData(
|
||||
status: _currentStatus,
|
||||
pose: _currentPose!,
|
||||
pitchDegrees: pitchDeg,
|
||||
rollDegrees: rollDeg,
|
||||
));
|
||||
pitchDeviation: pitchDeviation,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/// Persistance du didacticiel : mémorise les visites guidées déjà vues.
|
||||
///
|
||||
/// Chaque visite guidée (« tour ») possède un identifiant stocké dans les
|
||||
/// SharedPreferences sous la forme `tutorial_done_<id>`. Réinitialiser le
|
||||
/// didacticiel depuis les paramètres efface simplement ces clés.
|
||||
library;
|
||||
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// Identifiants des visites guidées de l'application.
|
||||
class TutorialTours {
|
||||
const TutorialTours._();
|
||||
|
||||
/// Visite d'accueil : présentation générale et navigation.
|
||||
static const String home = 'home';
|
||||
|
||||
/// Visite de l'éditeur d'impacts : tap, appui long et pincement pour zoomer.
|
||||
static const String impactEditor = 'impact_editor';
|
||||
|
||||
/// Toutes les visites connues, dans l'ordre logique de découverte.
|
||||
static const List<String> all = [home, impactEditor];
|
||||
}
|
||||
|
||||
class TutorialService {
|
||||
static const String _prefix = 'tutorial_done_';
|
||||
|
||||
Future<Set<String>> loadCompletedTours() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return TutorialTours.all
|
||||
.where((id) => prefs.getBool('$_prefix$id') ?? false)
|
||||
.toSet();
|
||||
}
|
||||
|
||||
Future<void> markCompleted(String tourId) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool('$_prefix$tourId', true);
|
||||
}
|
||||
|
||||
/// Efface la mémoire du didacticiel : toutes les visites seront rejouées.
|
||||
Future<void> resetAll() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
for (final id in TutorialTours.all) {
|
||||
await prefs.remove('$_prefix$id');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||
|
||||
import 'package:bully/app.dart';
|
||||
import 'package:bully/core/theme/theme_provider.dart';
|
||||
import 'package:bully/data/repositories/session_repository.dart';
|
||||
import 'package:bully/features/session/session_provider.dart';
|
||||
import 'package:bully/features/tutorial/tutorial_provider.dart';
|
||||
import 'package:bully/services/grouping_analyzer_service.dart';
|
||||
import 'package:bully/services/score_calculator_service.dart';
|
||||
import 'package:bully/services/tutorial_service.dart';
|
||||
|
||||
void main() {
|
||||
setUpAll(() {
|
||||
sqfliteFfiInit();
|
||||
databaseFactory = databaseFactoryFfi;
|
||||
FlutterError.onError = (details) {
|
||||
// ignore: avoid_print
|
||||
print('CAUGHT_FLUTTER_ERROR: ${details.exceptionAsString()}');
|
||||
// ignore: avoid_print
|
||||
print('CAUGHT_STACK: ${details.stack}');
|
||||
};
|
||||
});
|
||||
|
||||
testWidgets('BullyApp démarre et affiche HomeScreen + Tutorial sans erreur',
|
||||
(WidgetTester tester) async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final repository = SessionRepository();
|
||||
|
||||
await tester.pumpWidget(
|
||||
MultiProvider(
|
||||
providers: [
|
||||
Provider<ScoreCalculatorService>(
|
||||
create: (_) => ScoreCalculatorService(),
|
||||
),
|
||||
Provider<GroupingAnalyzerService>(
|
||||
create: (_) => GroupingAnalyzerService(),
|
||||
),
|
||||
Provider<SessionRepository>.value(value: repository),
|
||||
ChangeNotifierProvider<ThemeProvider>(create: (_) => ThemeProvider()),
|
||||
ChangeNotifierProvider<SessionProvider>(
|
||||
create: (_) => SessionProvider()),
|
||||
ChangeNotifierProvider<TutorialProvider>(
|
||||
create: (_) => TutorialProvider(service: TutorialService())),
|
||||
],
|
||||
child: const BullyApp(),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
|
||||
expect(find.text('BULLY'), findsOneWidget);
|
||||
expect(find.text('Bienvenue dans Bully'), findsOneWidget);
|
||||
|
||||
// Passer à l'étape suivante
|
||||
await tester.tap(find.text('Suivant'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
|
||||
expect(find.text('Démarrez une session'), findsOneWidget);
|
||||
|
||||
// Passer
|
||||
await tester.tap(find.text('Passer'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
|
||||
expect(find.text('Démarrez une session'), findsNothing);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:bully/features/tutorial/tutorial_step.dart';
|
||||
import 'package:bully/features/tutorial/widgets/tutorial_hand.dart';
|
||||
import 'package:bully/features/tutorial/widgets/tutorial_overlay.dart';
|
||||
|
||||
/// Monte l'overlay au-dessus d'un faux écran contenant l'élément visé.
|
||||
Future<GlobalKey> pumpOverlay(
|
||||
WidgetTester tester, {
|
||||
required List<TutorialStep> Function(GlobalKey targetKey) steps,
|
||||
required VoidCallback onFinished,
|
||||
}) async {
|
||||
final targetKey = GlobalKey();
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Stack(
|
||||
children: [
|
||||
Center(
|
||||
child: SizedBox(key: targetKey, width: 160, height: 48),
|
||||
),
|
||||
TutorialOverlay(steps: steps(targetKey), onFinished: onFinished),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Laisse le temps à la mesure du spot (défilement + délai de stabilisation).
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
return targetKey;
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('la visite avance d\'une étape à l\'autre puis se termine',
|
||||
(WidgetTester tester) async {
|
||||
var finished = false;
|
||||
|
||||
await pumpOverlay(
|
||||
tester,
|
||||
onFinished: () => finished = true,
|
||||
steps: (targetKey) => [
|
||||
TutorialStep(
|
||||
targetKey: targetKey,
|
||||
title: 'Démarrez une session',
|
||||
description: 'Tout part d\'ici.',
|
||||
gesture: TutorialGesture.tap,
|
||||
),
|
||||
TutorialStep(
|
||||
targetKey: targetKey,
|
||||
title: 'Zoomer sur la cible',
|
||||
description: 'Écartez deux doigts.',
|
||||
gesture: TutorialGesture.pinch,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
expect(find.text('Démarrez une session'), findsOneWidget);
|
||||
expect(find.text('1/2'), findsOneWidget);
|
||||
// La main animée mime le geste attendu, posée sur l'élément mis en avant.
|
||||
expect(find.byType(TutorialHand), findsOneWidget);
|
||||
expect(find.text('Appuyez une fois'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Suivant'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
|
||||
expect(find.text('Zoomer sur la cible'), findsOneWidget);
|
||||
expect(find.text('2/2'), findsOneWidget);
|
||||
expect(find.text('Pincez avec deux doigts pour zoomer'), findsOneWidget);
|
||||
expect(finished, isFalse);
|
||||
|
||||
await tester.tap(find.text('C\'est parti'));
|
||||
await tester.pump();
|
||||
|
||||
expect(finished, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('« Passer » interrompt la visite immédiatement',
|
||||
(WidgetTester tester) async {
|
||||
var finished = false;
|
||||
|
||||
await pumpOverlay(
|
||||
tester,
|
||||
onFinished: () => finished = true,
|
||||
steps: (targetKey) => [
|
||||
TutorialStep(
|
||||
targetKey: targetKey,
|
||||
title: 'Première étape',
|
||||
description: 'Description.',
|
||||
),
|
||||
const TutorialStep(
|
||||
title: 'Seconde étape',
|
||||
description: 'Description.',
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Passer'));
|
||||
await tester.pump();
|
||||
|
||||
expect(finished, isTrue);
|
||||
expect(find.text('Seconde étape'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('une étape sans cible s\'affiche comme carte centrée',
|
||||
(WidgetTester tester) async {
|
||||
await pumpOverlay(
|
||||
tester,
|
||||
onFinished: () {},
|
||||
steps: (_) => [
|
||||
const TutorialStep(
|
||||
title: 'Bienvenue dans Bully',
|
||||
description: 'Ce guide rapide vous montre l\'essentiel.',
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
expect(find.text('Bienvenue dans Bully'), findsOneWidget);
|
||||
expect(find.text('C\'est parti'), findsOneWidget);
|
||||
// Aucune cible : pas de main animée non plus.
|
||||
expect(find.byType(TutorialHand), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('l\'overlay se rend correctement dans un Navigator push modal',
|
||||
(WidgetTester tester) async {
|
||||
final targetKey = GlobalKey();
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Builder(
|
||||
builder: (context) => Scaffold(
|
||||
body: Column(
|
||||
children: [
|
||||
SizedBox(key: targetKey, width: 200, height: 60),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).push(
|
||||
PageRouteBuilder<void>(
|
||||
opaque: false,
|
||||
pageBuilder: (routeCtx, _, __) => TutorialOverlay(
|
||||
steps: [
|
||||
const TutorialStep(
|
||||
title: 'Bienvenue',
|
||||
description: 'Texte introductif',
|
||||
),
|
||||
TutorialStep(
|
||||
targetKey: targetKey,
|
||||
title: 'Bouton',
|
||||
description: 'Texte bouton',
|
||||
gesture: TutorialGesture.tap,
|
||||
),
|
||||
],
|
||||
onFinished: () => Navigator.of(routeCtx).pop(),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: const Text('Ouvrir'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Ouvrir'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
|
||||
expect(find.text('Bienvenue'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Suivant'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
|
||||
expect(find.text('Bouton'), findsOneWidget);
|
||||
expect(find.byType(TutorialHand), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:image/image.dart' as img;
|
||||
|
||||
import 'package:bully/services/ai_export_service.dart';
|
||||
|
||||
/// Construit un JPEG porteur de métadonnées EXIF comme le ferait un téléphone :
|
||||
/// position GPS, modèle d'appareil et orientation.
|
||||
Uint8List _photoWithExif({int orientation = 1}) {
|
||||
final image = img.Image(width: 40, height: 20);
|
||||
img.fill(image, color: img.ColorRgb8(120, 120, 120));
|
||||
|
||||
image.exif.imageIfd['Model'] = 'Pixel 8';
|
||||
image.exif.imageIfd.orientation = orientation;
|
||||
image.exif.gpsIfd['GPSLatitude'] = 48.8584;
|
||||
image.exif.gpsIfd['GPSLongitude'] = 2.2945;
|
||||
|
||||
return img.encodeJpg(image);
|
||||
}
|
||||
|
||||
void main() {
|
||||
final service = AiExportService();
|
||||
|
||||
group('stripMetadata', () {
|
||||
test('supprime le GPS et les autres métadonnées EXIF', () {
|
||||
final original = _photoWithExif();
|
||||
|
||||
// Garde-fou : sans EXIF au départ, le test ne prouverait rien.
|
||||
expect(img.decodeImage(original)!.exif.isEmpty, isFalse);
|
||||
|
||||
final stripped = service.stripMetadata(original);
|
||||
final result = img.decodeImage(stripped!)!;
|
||||
|
||||
expect(result.exif.isEmpty, isTrue);
|
||||
expect(result.exif.gpsIfd.isEmpty, isTrue);
|
||||
});
|
||||
|
||||
test('applique l\'orientation aux pixels avant de retirer le tag', () {
|
||||
// Orientation 6 = rotation de 90°, donc les dimensions s'inversent.
|
||||
final original = _photoWithExif(orientation: 6);
|
||||
|
||||
final result = img.decodeImage(service.stripMetadata(original)!)!;
|
||||
|
||||
expect(result.width, 20);
|
||||
expect(result.height, 40);
|
||||
expect(result.exif.isEmpty, isTrue);
|
||||
});
|
||||
|
||||
test('conserve les dimensions quand il n\'y a pas d\'orientation', () {
|
||||
final result = img.decodeImage(service.stripMetadata(_photoWithExif())!)!;
|
||||
|
||||
expect(result.width, 40);
|
||||
expect(result.height, 20);
|
||||
});
|
||||
|
||||
test('retourne null sur une image illisible', () {
|
||||
expect(service.stripMetadata(Uint8List.fromList([1, 2, 3, 4])), isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:bully/services/parallelism_service.dart';
|
||||
|
||||
/// Vecteur de gravité mesuré par l'accéléromètre pour une pose donnée.
|
||||
///
|
||||
/// [pitch] et [roll] sont exprimés comme dans le service : le tangage vaut 0°
|
||||
/// appareil vertical et +90° appareil à plat caméra vers le sol.
|
||||
({double x, double y, double z}) gravity({
|
||||
double pitch = 0,
|
||||
double roll = 0,
|
||||
}) {
|
||||
const double g = 9.81;
|
||||
final double p = pitch * math.pi / 180.0;
|
||||
final double r = roll * math.pi / 180.0;
|
||||
|
||||
final double nz = math.sin(p);
|
||||
final double nx = math.sin(r);
|
||||
// Ce qui reste va sur y, l'axe vertical de l'écran.
|
||||
final double ny = math.sqrt(math.max(0.0, 1.0 - nz * nz - nx * nx));
|
||||
|
||||
return (x: nx * g, y: ny * g, z: nz * g);
|
||||
}
|
||||
|
||||
ParallelismData evaluatePose(
|
||||
ParallelismService service, {
|
||||
double pitch = 0,
|
||||
double roll = 0,
|
||||
}) {
|
||||
final v = gravity(pitch: pitch, roll: roll);
|
||||
final data = service.evaluate(v.x, v.y, v.z);
|
||||
expect(data, isNotNull, reason: 'mesure jugée aberrante à tort');
|
||||
return data!;
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('pose cible au mur (comportement existant)', () {
|
||||
test('appareil vertical → aligné', () {
|
||||
final data = evaluatePose(ParallelismService());
|
||||
|
||||
expect(data.pose, TargetPose.wall);
|
||||
expect(data.status, ParallelismStatus.aligned);
|
||||
expect(data.pitchDeviation, closeTo(0, 0.5));
|
||||
});
|
||||
|
||||
test('l\'écart de tangage reste le tangage brut', () {
|
||||
final data = evaluatePose(ParallelismService(), pitch: 10);
|
||||
|
||||
expect(data.pose, TargetPose.wall);
|
||||
expect(data.pitchDeviation, closeTo(data.pitchDegrees, 0.001));
|
||||
});
|
||||
|
||||
test('inclinaison au-delà du seuil → désaligné', () {
|
||||
final data = evaluatePose(ParallelismService(), pitch: 40);
|
||||
|
||||
expect(data.pose, TargetPose.wall);
|
||||
expect(data.status, ParallelismStatus.misaligned);
|
||||
});
|
||||
|
||||
test('roulis excessif → désaligné même si le tangage est bon', () {
|
||||
final data = evaluatePose(ParallelismService(), roll: 35);
|
||||
|
||||
expect(data.status, ParallelismStatus.misaligned);
|
||||
});
|
||||
});
|
||||
|
||||
group('pose cible au sol', () {
|
||||
test('appareil à plat caméra vers le bas → aligné', () {
|
||||
final data = evaluatePose(ParallelismService(), pitch: 90);
|
||||
|
||||
expect(data.pose, TargetPose.ground);
|
||||
expect(data.status, ParallelismStatus.aligned);
|
||||
expect(data.pitchDeviation, closeTo(0, 0.5));
|
||||
});
|
||||
|
||||
test('l\'écart se mesure par rapport à +90°, pas à 0°', () {
|
||||
final data = evaluatePose(ParallelismService(), pitch: 80);
|
||||
|
||||
expect(data.pose, TargetPose.ground);
|
||||
expect(data.pitchDeviation, closeTo(-10, 0.5));
|
||||
expect(data.status, ParallelismStatus.aligned);
|
||||
});
|
||||
|
||||
test('à plat mais trop incliné → désaligné', () {
|
||||
final data = evaluatePose(ParallelismService(), pitch: 50);
|
||||
|
||||
expect(data.status, ParallelismStatus.misaligned);
|
||||
});
|
||||
|
||||
test('à plat écran vers le haut → jamais aligné', () {
|
||||
// La caméra vise le plafond : le vert n'aurait aucun sens.
|
||||
final data = evaluatePose(ParallelismService(), pitch: -90);
|
||||
|
||||
expect(data.pose, TargetPose.wall);
|
||||
expect(data.status, ParallelismStatus.misaligned);
|
||||
});
|
||||
});
|
||||
|
||||
group('hystérésis', () {
|
||||
test('le vert survit à un léger tremblement entre les deux seuils', () {
|
||||
final service = ParallelismService();
|
||||
|
||||
expect(evaluatePose(service).status, ParallelismStatus.aligned);
|
||||
// 28° est au-dessus du seuil d'entrée (25°) mais sous celui de sortie (32°).
|
||||
expect(evaluatePose(service, pitch: 28).status, ParallelismStatus.aligned);
|
||||
expect(evaluatePose(service, pitch: 40).status, ParallelismStatus.misaligned);
|
||||
// Repasser sous 32° ne suffit pas : il faut redescendre sous 25°.
|
||||
expect(evaluatePose(service, pitch: 28).status, ParallelismStatus.misaligned);
|
||||
expect(evaluatePose(service, pitch: 20).status, ParallelismStatus.aligned);
|
||||
});
|
||||
|
||||
test('la pose ne bascule pas pour un écart négligeable', () {
|
||||
final service = ParallelismService();
|
||||
|
||||
expect(evaluatePose(service).pose, TargetPose.wall);
|
||||
// Pile à la frontière des deux poses : on garde celle déjà retenue.
|
||||
expect(evaluatePose(service, pitch: 45).pose, TargetPose.wall);
|
||||
// Franchement du côté du sol : on bascule.
|
||||
expect(evaluatePose(service, pitch: 70).pose, TargetPose.ground);
|
||||
});
|
||||
|
||||
test('stop() remet la pose et le statut à zéro', () {
|
||||
final service = ParallelismService();
|
||||
|
||||
expect(evaluatePose(service, pitch: 90).pose, TargetPose.ground);
|
||||
service.stop();
|
||||
expect(evaluatePose(service).pose, TargetPose.wall);
|
||||
});
|
||||
});
|
||||
|
||||
test('une mesure aberrante est ignorée', () {
|
||||
expect(ParallelismService().evaluate(0, 0, 0), isNull);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'package:bully/features/tutorial/tutorial_provider.dart';
|
||||
import 'package:bully/services/tutorial_service.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUp(() => SharedPreferences.setMockInitialValues({}));
|
||||
|
||||
group('TutorialService', () {
|
||||
test('aucune visite n\'est marquée vue à la première utilisation',
|
||||
() async {
|
||||
expect(await TutorialService().loadCompletedTours(), isEmpty);
|
||||
});
|
||||
|
||||
test('mémorise puis réinitialise les visites vues', () async {
|
||||
final service = TutorialService();
|
||||
|
||||
await service.markCompleted(TutorialTours.home);
|
||||
expect(await service.loadCompletedTours(), {TutorialTours.home});
|
||||
|
||||
await service.markCompleted(TutorialTours.impactEditor);
|
||||
expect(
|
||||
await service.loadCompletedTours(),
|
||||
{TutorialTours.home, TutorialTours.impactEditor},
|
||||
);
|
||||
|
||||
await service.resetAll();
|
||||
expect(await service.loadCompletedTours(), isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('TutorialProvider', () {
|
||||
test('joue chaque visite une seule fois', () async {
|
||||
final provider = TutorialProvider();
|
||||
await provider.load();
|
||||
|
||||
expect(provider.isLoaded, isTrue);
|
||||
expect(provider.shouldRun(TutorialTours.home), isTrue);
|
||||
expect(provider.hasSeenIntro, isFalse);
|
||||
|
||||
await provider.complete(TutorialTours.home);
|
||||
|
||||
expect(provider.shouldRun(TutorialTours.home), isFalse);
|
||||
expect(provider.hasSeenIntro, isTrue);
|
||||
// Les autres visites restent disponibles.
|
||||
expect(provider.shouldRun(TutorialTours.impactEditor), isTrue);
|
||||
});
|
||||
|
||||
test('la réactivation depuis les paramètres rejoue toutes les visites',
|
||||
() async {
|
||||
final provider = TutorialProvider();
|
||||
await provider.load();
|
||||
await provider.complete(TutorialTours.home);
|
||||
await provider.complete(TutorialTours.impactEditor);
|
||||
|
||||
await provider.restart();
|
||||
|
||||
expect(provider.shouldRun(TutorialTours.home), isTrue);
|
||||
expect(provider.shouldRun(TutorialTours.impactEditor), isTrue);
|
||||
// La remise à zéro est bien persistée.
|
||||
expect(await TutorialService().loadCompletedTours(), isEmpty);
|
||||
});
|
||||
|
||||
test('l\'état vu survit à un redémarrage de l\'application', () async {
|
||||
final first = TutorialProvider();
|
||||
await first.load();
|
||||
await first.complete(TutorialTours.home);
|
||||
|
||||
final second = TutorialProvider();
|
||||
await second.load();
|
||||
|
||||
expect(second.shouldRun(TutorialTours.home), isFalse);
|
||||
expect(second.shouldRun(TutorialTours.impactEditor), isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user