From 8dc65426035a6785c3a13c6908ec99ff914bbc08 Mon Sep 17 00:00:00 2001 From: streaper2 Date: Thu, 27 Aug 2026 18:32:59 +0200 Subject: [PATCH] feat: implement AI export service and establish project navigation and theming infrastructure + acces internet --- CHANGELOG.md | 12 + android/app/src/main/AndroidManifest.xml | 5 +- lib/app.dart | 5 +- lib/core/theme/app_theme.dart | 415 ++++++- lib/core/theme/theme_provider.dart | 35 +- lib/core/widgets/glass_container.dart | 109 ++ lib/core/widgets/weapon_type_icon.dart | 335 +++++ lib/data/models/weapon.dart | 13 +- lib/features/garage/weapon_detail_screen.dart | 37 +- lib/features/garage/weapon_list_screen.dart | 345 ++++-- lib/features/history/history_screen.dart | 286 +++-- .../history/widgets/session_list_item.dart | 239 ++-- lib/features/home/home_screen.dart | 1081 ++++++++++++----- lib/features/home/widgets/stats_card.dart | 100 +- .../session/session_setup_screen.dart | 546 +++++---- lib/features/settings/settings_screen.dart | 530 ++++++-- .../statistics/statistics_screen.dart | 112 +- lib/main_navigation_holder.dart | 159 ++- lib/services/ai_export_service.dart | 3 +- 19 files changed, 3312 insertions(+), 1055 deletions(-) create mode 100644 lib/core/widgets/glass_container.dart create mode 100644 lib/core/widgets/weapon_type_icon.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 1249c8ba..4a63ccbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # 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) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index f8435084..5b243a2b 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,6 @@ + + @@ -9,7 +11,8 @@ + android:icon="@mipmap/ic_launcher" + android:usesCleartextTraffic="true"> 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 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)), ), ), ); diff --git a/lib/core/theme/theme_provider.dart b/lib/core/theme/theme_provider.dart index 4df9997e..09d0c631 100644 --- a/lib/core/theme/theme_provider.dart +++ b/lib/core/theme/theme_provider.dart @@ -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 loadThemeMode() async { + Future 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 setThemeMode(ThemeMode mode) async { if (_themeMode == mode) return; - + _themeMode = mode; notifyListeners(); - + final prefs = await SharedPreferences.getInstance(); await prefs.setInt(_themeModeKey, mode.index); } + Future 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: diff --git a/lib/core/widgets/glass_container.dart b/lib/core/widgets/glass_container.dart new file mode 100644 index 00000000..69631ffb --- /dev/null +++ b/lib/core/widgets/glass_container.dart @@ -0,0 +1,109 @@ +import 'dart:ui'; +import 'package:flutter/material.dart'; +import '../theme/app_theme.dart'; + +/// Conteneur avec effet de verre givré (Glassmorphism), +/// bordures translucides fines et reflets subtils pour casser l'aspect standard Flutter. +class GlassContainer extends StatelessWidget { + final Widget child; + final double borderRadius; + final double blur; + final EdgeInsetsGeometry padding; + final EdgeInsetsGeometry margin; + final Color? customBackgroundColor; + final Color? borderColor; + final Color? glowColor; + final VoidCallback? onTap; + final VoidCallback? onLongPress; + + const GlassContainer({ + super.key, + required this.child, + this.borderRadius = 18.0, + this.blur = 12.0, + this.padding = const EdgeInsets.all(16.0), + this.margin = EdgeInsets.zero, + this.customBackgroundColor, + this.borderColor, + this.glowColor, + this.onTap, + this.onLongPress, + }); + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + + final defaultBg = isDark + ? const Color(0xFF141C26).withValues(alpha: 0.72) + : Colors.white.withValues(alpha: 0.82); + + final defaultBorder = isDark + ? Colors.white.withValues(alpha: 0.12) + : Colors.black.withValues(alpha: 0.08); + + final resolvedBorderColor = borderColor ?? defaultBorder; + final resolvedBg = customBackgroundColor ?? defaultBg; + + Widget content = Container( + padding: padding, + decoration: BoxDecoration( + color: resolvedBg, + borderRadius: BorderRadius.circular(borderRadius), + border: Border.all(color: resolvedBorderColor, width: 1.2), + boxShadow: [ + if (glowColor != null) + BoxShadow( + color: glowColor!.withValues(alpha: isDark ? 0.25 : 0.15), + blurRadius: 18, + spreadRadius: -2, + offset: const Offset(0, 4), + ) + else + BoxShadow( + color: Colors.black.withValues(alpha: isDark ? 0.28 : 0.04), + blurRadius: 16, + offset: const Offset(0, 6), + ), + ], + ), + child: child, + ); + + if (blur > 0) { + content = ClipRRect( + borderRadius: BorderRadius.circular(borderRadius), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: blur, sigmaY: blur), + child: content, + ), + ); + } else { + content = ClipRRect( + borderRadius: BorderRadius.circular(borderRadius), + child: content, + ); + } + + if (margin != EdgeInsets.zero) { + content = Padding(padding: margin, child: content); + } + + if (onTap != null || onLongPress != null) { + return Material( + color: Colors.transparent, + borderRadius: BorderRadius.circular(borderRadius), + child: InkWell( + borderRadius: BorderRadius.circular(borderRadius), + onTap: onTap, + onLongPress: onLongPress, + splashColor: (glowColor ?? AppTheme.primaryColor).withValues(alpha: 0.15), + highlightColor: (glowColor ?? AppTheme.primaryColor).withValues(alpha: 0.08), + child: content, + ), + ); + } + + return content; + } +} diff --git a/lib/core/widgets/weapon_type_icon.dart b/lib/core/widgets/weapon_type_icon.dart new file mode 100644 index 00000000..07117ded --- /dev/null +++ b/lib/core/widgets/weapon_type_icon.dart @@ -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; + } +} diff --git a/lib/data/models/weapon.dart b/lib/data/models/weapon.dart index ad93182a..963490b9 100644 --- a/lib/data/models/weapon.dart +++ b/lib/data/models/weapon.dart @@ -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( diff --git a/lib/features/garage/weapon_detail_screen.dart b/lib/features/garage/weapon_detail_screen.dart index 64616191..1cd73436 100644 --- a/lib/features/garage/weapon_detail_screen.dart +++ b/lib/features/garage/weapon_detail_screen.dart @@ -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 { 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 { DropdownButtonFormField( 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( diff --git a/lib/features/garage/weapon_list_screen.dart b/lib/features/garage/weapon_list_screen.dart index 9536fe56..5f6e618a 100644 --- a/lib/features/garage/weapon_list_screen.dart +++ b/lib/features/garage/weapon_list_screen.dart @@ -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 { @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 _accessoryChips(Weapon weapon) { + List _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 { 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 { } void _showAddWeaponDialog() async { - // Capturé AVANT l'await : plus aucun usage du context après le dialogue. final repository = context.read(); final nameController = TextEditingController(); final caliberController = TextEditingController(); @@ -203,7 +338,7 @@ class _WeaponListScreenState extends State { final result = await showDialog( 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 { 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( 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 { keyboardType: TextInputType.number, ), ), - const SizedBox(width: 16), + const SizedBox(width: 12), Expanded( child: TextField( controller: magCapController, @@ -247,8 +404,14 @@ class _WeaponListScreenState extends State { ), ), 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 { } void _confirmDelete(Weapon weapon) async { - // Capturé AVANT l'await : plus aucun usage du context après le dialogue. final repository = context.read(); final confirmed = await showDialog( 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'), ), ], ), diff --git a/lib/features/history/history_screen.dart b/lib/features/history/history_screen.dart index c16662b2..e18bf819 100644 --- a/lib/features/history/history_screen.dart +++ b/lib/features/history/history_screen.dart @@ -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 { List _sessions = []; bool _isLoading = true; TargetType? _filterType; - - // --- MODIFICATION : Remplacement de DateTime par DateTimeRange --- DateTimeRange? _selectedDateRange; @override @@ -40,8 +33,6 @@ class _HistoryScreenState extends State { @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 { 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 { } } - // --- MODIFICATION : Fonction DateRangePicker --- Future _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 { @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( + 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 { final confirmed = await showDialog( 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'), ), ], ), diff --git a/lib/features/history/widgets/session_list_item.dart b/lib/features/history/widgets/session_list_item.dart index 67835c9b..aee2c467 100644 --- a/lib/features/history/widgets/session_list_item.dart +++ b/lib/features/history/widgets/session_list_item.dart @@ -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, ); } } diff --git a/lib/features/home/home_screen.dart b/lib/features/home/home_screen.dart index 5eb28c6a..c14fc0fc 100644 --- a/lib/features/home/home_screen.dart +++ b/lib/features/home/home_screen.dart @@ -1,23 +1,22 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import 'package:fl_chart/fl_chart.dart'; +import 'package:intl/intl.dart'; import '../../main_navigation_holder.dart'; -import '../../core/constants/app_constants.dart'; import '../../core/theme/app_theme.dart'; +import '../../core/widgets/glass_container.dart'; +import '../../data/models/session.dart'; import '../../data/repositories/session_repository.dart'; import '../capture/capture_screen.dart'; import '../history/history_screen.dart'; -import '../statistics/statistics_screen.dart'; -import 'package:fl_chart/fl_chart.dart'; -import '../../data/models/session.dart'; -import '../settings/settings_screen.dart'; +import '../history/session_detail_screen.dart'; import '../session/session_setup_screen.dart'; import '../session/session_provider.dart'; +import '../settings/settings_screen.dart'; +import '../statistics/statistics_screen.dart'; import 'widgets/stats_card.dart'; class HomeScreen extends StatefulWidget { - /// Incrémenté par la navigation à chaque ouverture de l'onglet Accueil - /// (l'écran est gardé vivant par l'IndexedStack : sans ça, un import de - /// sauvegarde resterait invisible sur le dashboard). final int refreshTick; const HomeScreen({super.key, this.refreshTick = 0}); @@ -62,8 +61,6 @@ class _HomeScreenState extends State { void _onSessionChanged() { final isActive = _sessionProvider?.isSessionActive ?? false; - // Quand une session vient de se terminer, on rafraîchit les stats - // automatiquement pour prendre en compte la dernière session. if (_wasSessionActive && !isActive) { _loadStats(); } @@ -92,192 +89,510 @@ class _HomeScreenState extends State { @override Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final primaryColor = Theme.of(context).colorScheme.primary; + final textSecondary = isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary; + return Scaffold( + extendBody: true, appBar: AppBar( - leading: const Center( - child: Text( - 'v1.0.2', - style: TextStyle(fontSize: 12, color: Colors.white70), + titleSpacing: 16, + leading: Padding( + padding: const EdgeInsets.only(left: 16), + child: Center( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: (isDark ? Colors.white : Colors.black).withValues(alpha: 0.06), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: isDark ? AppTheme.darkBorder : AppTheme.lightBorder, + ), + ), + child: Text( + 'v1.0.3', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + color: textSecondary, + letterSpacing: 0.5, + ), + ), + ), ), ), - title: const Text('Bully'), + leadingWidth: 72, + title: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: primaryColor.withValues(alpha: 0.18), + borderRadius: BorderRadius.circular(8), + ), + child: Icon(Icons.gps_fixed, color: primaryColor, size: 18), + ), + const SizedBox(width: 10), + const Text( + 'BULLY', + style: TextStyle( + fontWeight: FontWeight.w900, + letterSpacing: 2.0, + fontSize: 20, + ), + ), + ], + ), actions: [ IconButton( - icon: const Icon(Icons.settings), + icon: const Icon(Icons.settings_outlined), onPressed: () => _navigateToSettings(context), tooltip: 'Paramètres', ), + const SizedBox(width: 8), ], ), - body: RefreshIndicator( - onRefresh: _loadStats, - child: SingleChildScrollView( - physics: const AlwaysScrollableScrollPhysics(), - padding: const EdgeInsets.all(AppConstants.defaultPadding), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _buildHeader(), - const SizedBox(height: AppConstants.largePadding), - _buildMainActionButton(context), - const SizedBox(height: AppConstants.largePadding), - if (_isLoading) - const Center(child: CircularProgressIndicator()) - else if (_stats != null) - _buildStatsSection(), - ], + body: Stack( + children: [ + // Ambiance lumineuse d'arrière-plan (lueurs dégradées) + if (isDark) ...[ + Positioned( + top: -60, + right: -60, + child: Container( + width: 260, + height: 260, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: RadialGradient( + colors: [ + primaryColor.withValues(alpha: 0.18), + Colors.transparent, + ], + ), + ), + ), + ), + Positioned( + top: 300, + left: -80, + child: Container( + width: 220, + height: 220, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: RadialGradient( + colors: [ + AppTheme.secondaryColor.withValues(alpha: 0.09), + Colors.transparent, + ], + ), + ), + ), + ), + ], + + RefreshIndicator( + onRefresh: _loadStats, + color: primaryColor, + child: SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + // Padding bas important pour laisser respirer le dock flottant + padding: const EdgeInsets.fromLTRB(16, 8, 16, 100), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildHeader(isDark, primaryColor), + const SizedBox(height: 16), + _buildMainActionSection(context, isDark, primaryColor), + const SizedBox(height: 22), + if (_isLoading) + const Padding( + padding: EdgeInsets.symmetric(vertical: 40), + child: Center(child: CircularProgressIndicator()), + ) + else if (_stats != null) ...[ + _buildStatsSection(isDark, primaryColor), + const SizedBox(height: 22), + _buildRecentSessionsSection(isDark, primaryColor), + ], + ], + ), + ), ), - ), + ], ), ); } - Widget _buildHeader() { - return Column( - children: [ - Container( - padding: const EdgeInsets.all(24), - decoration: BoxDecoration( - color: AppTheme.primaryColor.withValues(alpha: 0.1), - shape: BoxShape.circle, + Widget _buildHeader(bool isDark, Color primaryColor) { + return GlassContainer( + borderRadius: 20, + blur: 16, + glowColor: primaryColor, + borderColor: isDark + ? primaryColor.withValues(alpha: 0.25) + : primaryColor.withValues(alpha: 0.15), + padding: const EdgeInsets.all(18), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + 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, + ), + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: primaryColor.withValues(alpha: 0.35), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + child: const Icon( + Icons.track_changes, + size: 28, + color: Colors.white, + ), ), - child: const Icon( - Icons.track_changes, - size: 64, - color: AppTheme.primaryColor, + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: primaryColor.withValues(alpha: isDark ? 0.25 : 0.15), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + 'BALISTIQUE & CIBLES', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w800, + letterSpacing: 1.2, + color: primaryColor, + ), + ), + ), + const SizedBox(width: 6), + Container( + width: 6, + height: 6, + decoration: const BoxDecoration( + color: AppTheme.secondaryColor, + shape: BoxShape.circle, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Télémétrie de Tir', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w800, + color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary, + letterSpacing: -0.3, + ), + ), + const SizedBox(height: 2), + Text( + 'Analysez vos groupements et scores', + style: TextStyle( + fontSize: 12, + color: isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary, + ), + ), + ], + ), ), - ), - const SizedBox(height: 16), - Text( - 'Analyse de Cibles', - style: Theme.of( - context, - ).textTheme.headlineMedium?.copyWith(fontWeight: FontWeight.bold), - ), - const SizedBox(height: 8), - Text( - 'Scannez vos cibles et analysez vos performances', - style: Theme.of( - context, - ).textTheme.bodyLarge?.copyWith(color: AppTheme.textSecondary), - textAlign: TextAlign.center, - ), - ], + ], + ), ); } - Widget _buildMainActionButton(BuildContext context) { + Widget _buildMainActionSection(BuildContext context, bool isDark, Color primaryColor) { final sessionProvider = context.watch(); final isSessionActive = sessionProvider.isSessionActive; - // Si aucune session n'est active, on affiche juste le bouton de départ habituel if (!isSessionActive) { - return ElevatedButton.icon( - onPressed: () => _navigateToSessionSetup(context), - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.primaryColor, - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(vertical: 20), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppConstants.borderRadius), + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + gradient: LinearGradient( + colors: [ + HSLColor.fromColor(primaryColor).withLightness((HSLColor.fromColor(primaryColor).lightness - 0.1).clamp(0.0, 1.0)).toColor(), + 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: isDark ? 0.45 : 0.3), + blurRadius: 20, + spreadRadius: -2, + offset: const Offset(0, 8), + ), + ], ), - icon: const Icon(Icons.add_circle_outline, size: 28), - label: const Text( - 'Nouvelle session', - style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + child: ClipRRect( + borderRadius: BorderRadius.circular(20), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () => _navigateToSessionSetup(context), + splashColor: Colors.white24, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 18, horizontal: 20), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.25), + shape: BoxShape.circle, + ), + child: const Icon(Icons.add, color: Colors.white, size: 22), + ), + const SizedBox(width: 14), + const Text( + 'NOUVELLE SESSION DE TIR', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w900, + color: Colors.white, + letterSpacing: 1.0, + ), + ), + ], + ), + ), + ), + ), ), ); } - // MODIFICATION : Alignement sur une seule ligne (80% / 20%) avec une croix pour l'annulation - return Row( - children: [ - // 80% de largeur pour continuer la session - Expanded( - flex: 8, - child: ElevatedButton.icon( - onPressed: () => _navigateToSessionSetup(context), - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.secondaryColor, - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(vertical: 20), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppConstants.borderRadius), - ), - ), - icon: const Icon(Icons.play_circle_outline, size: 28), - label: const Text( - 'Continuer la session', - style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - ), - const SizedBox(width: 12), - // 20% de largeur pour terminer/annuler la session (Bouton Croix) - Expanded( - flex: 2, - child: OutlinedButton( - onPressed: () { - // Boite de dialogue de confirmation pour éviter les erreurs - showDialog( - context: context, - builder: (BuildContext context) { - return AlertDialog( - title: const Text('Terminer la session ?'), - content: const Text('Êtes-vous sûr de vouloir clôturer la session en cours ?'), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('ANNULER'), + // Session en cours : Glass Card dynamique avec halo vert + return GlassContainer( + borderRadius: 20, + blur: 16, + glowColor: AppTheme.secondaryColor, + borderColor: AppTheme.secondaryColor.withValues(alpha: isDark ? 0.45 : 0.35), + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: AppTheme.secondaryColor.withValues(alpha: isDark ? 0.22 : 0.15), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: AppTheme.secondaryColor.withValues(alpha: 0.4), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 8, + height: 8, + decoration: const BoxDecoration( + color: AppTheme.secondaryColor, + shape: BoxShape.circle, ), - TextButton( - onPressed: () { - Navigator.pop(context); - // Clôture propre de la session dans le State global - context.read().endSession(); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Session terminée avec succès !'), - backgroundColor: AppTheme.successColor, - ), - ); - // Fin de session : on bascule sur les statistiques. - openMainTab(mainTabStats); - }, - child: const Text('TERMINER', style: TextStyle(color: Colors.redAccent)), + ), + const SizedBox(width: 6), + const Text( + 'SESSION EN COURS', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w800, + color: AppTheme.secondaryColor, + letterSpacing: 0.8, + ), + ), + ], + ), + ), + const Spacer(), + if (sessionProvider.currentWeapon != null) + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: (isDark ? Colors.white : Colors.black).withValues(alpha: 0.06), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + sessionProvider.currentWeapon!, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary, + ), + ), + ), + ], + ), + const SizedBox(height: 14), + Row( + children: [ + Expanded( + flex: 4, + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(14), + gradient: const LinearGradient( + colors: [Color(0xFF10B981), Color(0xFF059669)], + ), + boxShadow: [ + BoxShadow( + color: AppTheme.secondaryColor.withValues(alpha: 0.3), + blurRadius: 12, + offset: const Offset(0, 4), ), ], - ); - }, - ); - }, - style: OutlinedButton.styleFrom( - foregroundColor: Colors.redAccent, - side: const BorderSide(color: Colors.redAccent, width: 1.5), - padding: const EdgeInsets.symmetric(vertical: 20), // Même hauteur que le bouton continuer - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppConstants.borderRadius), + ), + child: ElevatedButton.icon( + onPressed: () => _navigateToSessionSetup(context), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.transparent, + foregroundColor: Colors.white, + shadowColor: Colors.transparent, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + icon: const Icon(Icons.play_arrow_rounded, size: 24), + label: const Text( + 'Reprendre la session', + style: TextStyle(fontSize: 14, fontWeight: FontWeight.w800), + ), + ), + ), ), - ), - child: const Icon(Icons.close, size: 28), + const SizedBox(width: 10), + Expanded( + flex: 1, + child: OutlinedButton( + onPressed: () => _confirmEndSession(context), + style: OutlinedButton.styleFrom( + foregroundColor: AppTheme.errorColor, + side: BorderSide( + color: AppTheme.errorColor.withValues(alpha: 0.7), + width: 1.4, + ), + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + child: const Icon(Icons.close, size: 20), + ), + ), + ], ), - ), - ], + ], + ), ); } - Widget _buildStatsSection() { + void _confirmEndSession(BuildContext context) { + showDialog( + context: context, + builder: (BuildContext dialogContext) { + return AlertDialog( + title: const Text('Clôturer la session ?'), + content: const Text( + 'Êtes-vous sûr de vouloir terminer la session en cours ? Vos tirs actuels seront enregistrés.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: const Text('Annuler'), + ), + ElevatedButton( + onPressed: () { + Navigator.pop(dialogContext); + context.read().endSession(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Session terminée avec succès'), + backgroundColor: AppTheme.successColor, + ), + ); + openMainTab(mainTabStats); + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.errorColor, + foregroundColor: Colors.white, + ), + child: const Text('Terminer'), + ), + ], + ); + }, + ); + } + + Widget _buildStatsSection(bool isDark, Color primaryColor) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - 'Statistiques', - style: Theme.of( - context, - ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Télémétrie Globale', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w800, + color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary, + letterSpacing: 0.2, + ), + ), + InkWell( + onTap: () => _navigateToStatistics(context), + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + child: Row( + children: [ + Text( + 'Détails', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: primaryColor, + ), + ), + Icon(Icons.chevron_right, size: 18, color: primaryColor), + ], + ), + ), + ), + ], ), const SizedBox(height: 12), Row( @@ -285,87 +600,354 @@ class _HomeScreenState extends State { Expanded( child: InkWell( onTap: () => _navigateToStatistics(context), - borderRadius: BorderRadius.circular(AppConstants.borderRadius), + borderRadius: BorderRadius.circular(18), child: StatsCard( - icon: Icons.assessment, + icon: Icons.assignment_outlined, title: 'Sessions', value: '${_stats!['totalSessions']}', - color: AppTheme.primaryColor, + color: primaryColor, ), ), ), const SizedBox(width: 12), Expanded( child: StatsCard( - icon: Icons.gps_fixed, - title: 'Tirs', + icon: Icons.track_changes, + title: 'Tirs analysés', value: '${_stats!['totalShots']}', color: AppTheme.secondaryColor, ), ), ], ), - Padding( - padding: const EdgeInsets.symmetric(vertical: 16.0), - child: Container( - height: 150, - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Theme.of(context).cardColor, - borderRadius: BorderRadius.circular(AppConstants.borderRadius), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.05), - blurRadius: 10, - offset: const Offset(0, 4), - ), - ], - ), - child: _recentSessions.length < 2 - ? const Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.show_chart, size: 40, color: Colors.grey), - Text( - 'Pas assez de données', - style: TextStyle(color: Colors.grey, fontSize: 12), - ), - ], - ), - ) - : _buildHomeTrendChart(), - ), - ), + const SizedBox(height: 12), Row( children: [ Expanded( - child: InkWell( - onTap: () => _navigateToHistory(context), - borderRadius: BorderRadius.circular(AppConstants.borderRadius), - child: StatsCard( - icon: Icons.trending_up, - title: 'Historique', - value: (_stats!['averageScore'] as double).toStringAsFixed(1), - color: AppTheme.warningColor, - ), + child: StatsCard( + icon: Icons.pie_chart_outline, + title: 'Score moyen', + value: '${_stats!['averageScore'].toStringAsFixed(1)} pts', + subtitle: 'sur 10 max', + color: AppTheme.accentGold, ), ), const SizedBox(width: 12), Expanded( child: StatsCard( - icon: Icons.emoji_events, + icon: Icons.star_border, title: 'Meilleur score', - value: '${_stats!['bestScore']}', - color: AppTheme.successColor, + value: '${_stats!['bestScore']} pts', + color: AppTheme.secondaryColor, ), ), ], ), + const SizedBox(height: 14), + GlassContainer( + borderRadius: 20, + blur: 16, + borderColor: isDark ? Colors.white.withValues(alpha: 0.1) : Colors.black.withValues(alpha: 0.06), + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Progression récente', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary, + ), + ), + 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( + '7 dernières séances', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: isDark ? AppTheme.darkTextMuted : AppTheme.lightTextMuted, + ), + ), + ), + ], + ), + const SizedBox(height: 12), + SizedBox( + height: 120, + child: _recentSessions.length < 2 + ? Center( + child: Text( + '2 séances minimum pour afficher la courbe', + style: TextStyle( + color: isDark ? AppTheme.darkTextMuted : AppTheme.lightTextMuted, + fontSize: 12, + ), + ), + ) + : _buildHomeTrendChart(isDark, primaryColor), + ), + ], + ), + ), ], ); } + Widget _buildHomeTrendChart(bool isDark, Color primaryColor) { + final sorted = List.from(_recentSessions) + ..sort((a, b) => a.createdAt.compareTo(b.createdAt)); + final display = sorted.length > 7 ? sorted.sublist(sorted.length - 7) : sorted; + + final gridColor = isDark ? Colors.white.withValues(alpha: 0.06) : Colors.black.withValues(alpha: 0.04); + final textColor = isDark ? AppTheme.darkTextMuted : AppTheme.lightTextMuted; + + return LineChart( + LineChartData( + gridData: FlGridData( + show: true, + drawVerticalLine: false, + getDrawingHorizontalLine: (value) => FlLine( + color: gridColor, + strokeWidth: 1, + ), + ), + titlesData: FlTitlesData( + show: true, + rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), + topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 20, + interval: 1, + getTitlesWidget: (value, meta) { + final index = value.toInt(); + if (index < 0 || index >= display.length) return const SizedBox(); + final date = display[index].createdAt; + return Padding( + padding: const EdgeInsets.only(top: 4.0), + child: Text( + '${date.day}/${date.month}', + style: TextStyle(fontSize: 10, color: textColor), + ), + ); + }, + ), + ), + leftTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 24, + interval: 2, + getTitlesWidget: (value, meta) { + if (value < 0 || value > 10) return const SizedBox(); + return Text( + value.toInt().toString(), + style: TextStyle(fontSize: 10, color: textColor), + ); + }, + ), + ), + ), + borderData: FlBorderData(show: false), + lineBarsData: [ + LineChartBarData( + spots: display.asMap().entries.map((e) { + return FlSpot(e.key.toDouble(), e.value.averageScore); + }).toList(), + isCurved: true, + curveSmoothness: 0.35, + color: primaryColor, + barWidth: 3, + isStrokeCapRound: true, + dotData: FlDotData( + show: true, + getDotPainter: (spot, percent, barData, index) => FlDotCirclePainter( + radius: 3.5, + color: Colors.white, + strokeWidth: 2, + strokeColor: primaryColor, + ), + ), + belowBarData: BarAreaData( + show: true, + gradient: LinearGradient( + colors: [ + primaryColor.withValues(alpha: isDark ? 0.35 : 0.2), + primaryColor.withValues(alpha: 0.0), + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + ), + ), + ], + ), + ); + } + + Widget _buildRecentSessionsSection(bool isDark, Color primaryColor) { + if (_recentSessions.isEmpty) return const SizedBox(); + + final sessionsToShow = _recentSessions.take(3).toList(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Dernières Séances', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w800, + color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary, + letterSpacing: 0.2, + ), + ), + InkWell( + onTap: () => _navigateToHistory(context), + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + child: Text( + 'Voir tout (${_recentSessions.length})', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: primaryColor, + ), + ), + ), + ), + ], + ), + const SizedBox(height: 12), + ...sessionsToShow.map((session) => _buildRecentSessionTile(session, isDark, primaryColor)), + ], + ); + } + + Widget _buildRecentSessionTile(Session session, bool isDark, Color primaryColor) { + final dateFormat = DateFormat('dd MMM yyyy • HH:mm', 'fr_FR'); + final formattedDate = dateFormat.format(session.createdAt); + + return GlassContainer( + borderRadius: 16, + blur: 12, + margin: const EdgeInsets.only(bottom: 10), + padding: const EdgeInsets.all(14), + onTap: () async { + await Navigator.push( + context, + MaterialPageRoute( + builder: (_) => SessionDetailScreen(session: session), + ), + ); + _loadStats(); + }, + child: Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + primaryColor.withValues(alpha: 0.25), + primaryColor.withValues(alpha: 0.08), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: primaryColor.withValues(alpha: 0.35), + ), + ), + child: Center( + child: Text( + '${session.totalScore}', + style: TextStyle( + fontSize: 17, + fontWeight: FontWeight.w900, + color: primaryColor, + ), + ), + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + session.weapon, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + formattedDate, + style: TextStyle( + fontSize: 11, + color: isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary, + ), + ), + ], + ), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: (isDark ? Colors.white : Colors.black).withValues(alpha: 0.06), + borderRadius: BorderRadius.circular(6), + border: Border.all( + color: isDark ? AppTheme.darkBorder : AppTheme.lightBorder, + ), + ), + child: Text( + '${session.distance}m', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary, + ), + ), + ), + const SizedBox(height: 4), + Text( + '${session.totalShots} tirs', + style: TextStyle( + fontSize: 11, + color: isDark ? AppTheme.darkTextMuted : AppTheme.lightTextMuted, + ), + ), + ], + ), + ], + ), + ); + } + void _navigateToSessionSetup(BuildContext context) async { final sessionProvider = context.read(); if (sessionProvider.isSessionActive) { @@ -398,99 +980,6 @@ class _HomeScreenState extends State { _loadStats(); } - Widget _buildHomeTrendChart() { - final sorted = List.from(_recentSessions) - ..sort((a, b) => a.createdAt.compareTo(b.createdAt)); - final display = - sorted.length > 7 ? sorted.sublist(sorted.length - 7) : sorted; - - return LineChart( - LineChartData( - gridData: FlGridData( - show: true, - drawVerticalLine: false, - getDrawingHorizontalLine: (value) => FlLine( - color: Colors.grey.withValues(alpha: 0.1), - strokeWidth: 1, - ), - ), - titlesData: FlTitlesData( - show: true, - rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), - topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), - bottomTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: true, - reservedSize: 22, - interval: 1, - getTitlesWidget: (value, meta) { - final index = value.toInt(); - if (index < 0 || index >= display.length) return const SizedBox(); - final date = display[index].createdAt; - return Padding( - padding: const EdgeInsets.only(top: 4.0), - child: Text( - '${date.day}/${date.month}', - style: TextStyle( - fontSize: 9, - color: Colors.grey.withValues(alpha: 0.8), - ), - ), - ); - }, - ), - ), - leftTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: true, - reservedSize: 28, - getTitlesWidget: (value, meta) { - return Text( - value.toInt().toString(), - style: TextStyle( - fontSize: 9, - color: Colors.grey.withValues(alpha: 0.8), - ), - ); - }, - ), - ), - ), - borderData: FlBorderData( - show: true, - border: Border( - bottom: BorderSide(color: Colors.grey.withValues(alpha: 0.2)), - left: BorderSide(color: Colors.grey.withValues(alpha: 0.2)), - ), - ), - lineBarsData: [ - LineChartBarData( - spots: display.asMap().entries.map((e) { - return FlSpot(e.key.toDouble(), e.value.averageScore); - }).toList(), - isCurved: true, - color: AppTheme.primaryColor, - barWidth: 3, - isStrokeCapRound: true, - dotData: FlDotData( - show: true, - getDotPainter: (spot, percent, barData, index) => FlDotCirclePainter( - radius: 3, - color: Colors.white, - strokeWidth: 2, - strokeColor: AppTheme.primaryColor, - ), - ), - belowBarData: BarAreaData( - show: true, - color: AppTheme.primaryColor.withValues(alpha: 0.1), - ), - ), - ], - ), - ); - } - void _navigateToSettings(BuildContext context) async { await Navigator.push( context, diff --git a/lib/features/home/widgets/stats_card.dart b/lib/features/home/widgets/stats_card.dart index 1dade941..b70ffec7 100644 --- a/lib/features/home/widgets/stats_card.dart +++ b/lib/features/home/widgets/stats_card.dart @@ -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, + ), + ], ), ); } diff --git a/lib/features/session/session_setup_screen.dart b/lib/features/session/session_setup_screen.dart index 434b5bc1..68657c48 100644 --- a/lib/features/session/session_setup_screen.dart +++ b/lib/features/session/session_setup_screen.dart @@ -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,11 @@ class _SessionSetupScreenState extends State { Weapon? _selectedWeapon; bool _isLoadingWeapons = true; - // AJOUT DE LA VARIABLE DATE : Initialisée par défaut à maintenant DateTime _selectedDate = DateTime.now(); + static const List _presetDistances = [10, 15, 25, 50, 100, 200]; + static const List _presetShots = [3, 5, 10, 15, 20, 30]; + @override void initState() { super.initState(); @@ -52,24 +54,28 @@ class _SessionSetupScreenState extends State { } 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 _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,10 +87,6 @@ class _SessionSetupScreenState extends State { 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, @@ -103,221 +105,351 @@ class _SessionSetupScreenState extends State { @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, - ), - const SizedBox(height: 16), - ElevatedButton.icon( - onPressed: () { - Navigator.push( - context, - MaterialPageRoute(builder: (_) => const WeaponListScreen()), - ).then((_) { - _loadWeapons(); - }); - }, - icon: const Icon(Icons.add), - label: const Text('ALLER À MON ARMURERIE'), - ), - ], - ), - ) - else - Column( + padding: const EdgeInsets.all(AppConstants.defaultPadding), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - DropdownButtonFormField( - 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), + 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), + ], + ], + ), + ), + ), + ); + } - // 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), - ), + 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: () { + Navigator.push( + context, + MaterialPageRoute(builder: (_) => const WeaponListScreen()), + ).then((_) => _loadWeapons()); + }, + 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( + initialValue: _selectedWeapon, + decoration: const InputDecoration( + labelText: 'Arme utilisée', + prefixIcon: Icon(Icons.shield_outlined), + ), + items: _availableWeapons + .map( + (w) => DropdownMenuItem( + value: w, + child: Text('${w.displayName} (${w.caliber})'), + ), + ) + .toList(), + onChanged: (value) { + if (value != null) { + setState(() { + _selectedWeapon = value; + _updateSettingsForWeapon(value); + }); + } + }, + ), + 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, ), ), ), diff --git a/lib/features/settings/settings_screen.dart b/lib/features/settings/settings_screen.dart index 2f840ac3..bc2bf375 100644 --- a/lib/features/settings/settings_screen.dart +++ b/lib/features/settings/settings_screen.dart @@ -1,12 +1,12 @@ 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'; @@ -165,76 +165,203 @@ class _SettingsScreenState extends State { 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( + 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(); + 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 +371,16 @@ class _SettingsScreenState extends State { 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 +392,15 @@ class _SettingsScreenState extends State { } 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 +436,23 @@ class _SettingsScreenState extends State { 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( @@ -369,7 +515,11 @@ class _SettingsScreenState extends State { 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 +533,7 @@ class _SettingsScreenState extends State { ), ); }, - child: const Text('J\'accepte les règles', style: TextStyle(color: Colors.white)), + child: const Text('J\'accepte les règles'), ), ], ), @@ -391,10 +541,15 @@ class _SettingsScreenState extends State { } 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 +580,11 @@ class _SettingsScreenState extends State { 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 +605,26 @@ class _SettingsScreenState extends State { ), ); }, - 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 +660,11 @@ class _SettingsScreenState extends State { 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() @@ -528,14 +694,17 @@ class _SettingsScreenState extends State { @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 +712,128 @@ class _SettingsScreenState extends State { 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( 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 +842,14 @@ class _SettingsScreenState extends State { }, ), - 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 +903,7 @@ class _SettingsScreenState extends State { 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 +914,38 @@ class _SettingsScreenState extends State { ), ), ] 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 +981,20 @@ class _SettingsScreenState extends State { ), ], - const SizedBox(height: 24), - _buildSectionHeader('À propos'), + 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 +1008,16 @@ class _SettingsScreenState extends State { ); } - 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 +1030,57 @@ class _SettingsScreenState extends State { 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, + ), + ], ), ); } diff --git a/lib/features/statistics/statistics_screen.dart b/lib/features/statistics/statistics_screen.dart index 13ac461e..536d62ab 100644 --- a/lib/features/statistics/statistics_screen.dart +++ b/lib/features/statistics/statistics_screen.dart @@ -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 { @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 { 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 { ); } - 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 { 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 { List 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( 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 { // 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 { List dataPoints, { List? 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 { 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 { ], ], ), - 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), diff --git a/lib/main_navigation_holder.dart b/lib/main_navigation_holder.dart index fa1e7f53..436a57d5 100644 --- a/lib/main_navigation_holder.dart +++ b/lib/main_navigation_holder.dart @@ -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,8 +12,7 @@ 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> mainNavKey = GlobalKey>(); @@ -32,9 +32,6 @@ class MainNavigationHolder extends StatefulWidget { class _MainNavigationHolderState extends State { 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 +50,143 @@ class _MainNavigationHolderState extends State { @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( + 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', - ), - ], + ), ), ), ); diff --git a/lib/services/ai_export_service.dart b/lib/services/ai_export_service.dart index c16929d4..7db259b6 100644 --- a/lib/services/ai_export_service.dart +++ b/lib/services/ai_export_service.dart @@ -105,7 +105,8 @@ class AiExportService { }) 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);