feat: implement AI export service and establish project navigation and theming infrastructure + acces internet

This commit is contained in:
streaper2
2026-08-27 18:32:59 +02:00
parent e943538133
commit ca831c919a
19 changed files with 3312 additions and 1055 deletions
+12
View File
@@ -1,5 +1,17 @@
# Changelog # Changelog
## [v1.0.3] - 2026-08-27 (Branche `design/newdefault`)
### 🎨 Refonte UI/UX "Tactical & Precision"
- **Système de Thèmes & Couleurs d'Accentuation Personnalisables** : Choix dynamique de la couleur d'accent dans les Paramètres parmi 7 déclinaisons tactiques (Bleu Cobalt, Rouge Cible, Vert Viseur, Orange Ambre, Violet Cyber, Cyan Néon, Or Compétition) avec persistance SharedPreferences.
- **Nouvelle Direction Artistique & Thèmes** : Thème sombre Gunmetal/Slate contrasté pour stand de tir et thème clair technique épuré avec effets Glassmorphism et flous d'arrière-plan.
- **Barre de Navigation Principale** : Intégration de la `NavigationBar` Material 3 avec indicateur pill moderne et icônes thématiques.
- **Dashboard d'Accueil** : En-tête dynamique "BULLY • PRÉCISION", carte interactive de session en cours (badge lumineux, reprise rapide / fin sécurisée), tuiles télémétriques de KPIs avec graphique d'évolution et accès direct aux dernières séances.
- **Armurerie (Arsenal)** : Fiches d'armes techniques avec mise en valeur du calibre, compteurs de chargeurs/coups, puces d'accessoires et **icônes vectorielles dédiées pour chaque type d'arme** (Arme de Poing, Arme d'Épaule, Fusil à Pompe, Airsoft/Airgun).
- **Carnet de Tir (Historique)** : Filtres par puces de type de cible et de date, cartes de séances avec pastilles de score colorées selon la précision.
- **Setup de Session** : Sélecteurs rapides de distance (10m à 200m) et de coups par cible (3 à 30).
- **Réseau & Export Serveur IA (Fix APK Mobile)** : Ajout des permissions `INTERNET` et `ACCESS_NETWORK_STATE` ainsi que `usesCleartextTraffic` dans le manifeste Android principal (`AndroidManifest.xml`) pour débloquer l'envoi vers le serveur IA et les IP locales depuis l'APK installée.
## [v0.0.7] - 2026-08-26 ## [v0.0.7] - 2026-08-26
### 🚀 CI/CD & Déploiement Automatique (Gitea Actions) ### 🚀 CI/CD & Déploiement Automatique (Gitea Actions)
+4 -1
View File
@@ -1,4 +1,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.CAMERA" />
<!-- Pour Android 12 et inférieur --> <!-- Pour Android 12 et inférieur -->
@@ -9,7 +11,8 @@
<application <application
android:label="bully" android:label="bully"
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"> android:icon="@mipmap/ic_launcher"
android:usesCleartextTraffic="true">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
+2 -3
View File
@@ -1,7 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'core/theme/theme_provider.dart'; import 'core/theme/theme_provider.dart';
import 'core/theme/app_theme.dart';
import 'main_navigation_holder.dart'; import 'main_navigation_holder.dart';
import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_localizations/flutter_localizations.dart';
@@ -15,8 +14,8 @@ class BullyApp extends StatelessWidget {
return MaterialApp( return MaterialApp(
title: 'Bully - Analyse de Cibles', title: 'Bully - Analyse de Cibles',
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
theme: AppTheme.lightTheme, theme: themeProvider.lightTheme,
darkTheme: AppTheme.darkTheme, darkTheme: themeProvider.darkTheme,
themeMode: themeProvider.themeMode, themeMode: themeProvider.themeMode,
localizationsDelegates: const [ localizationsDelegates: const [
GlobalMaterialLocalizations.delegate, GlobalMaterialLocalizations.delegate,
+377 -42
View File
@@ -1,90 +1,425 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
/// Définition des couleurs d'accent disponibles pour la personnalisation.
class AppAccentColor {
final String id;
final String name;
final Color color;
final Color lightColor;
final Color darkColor;
final IconData icon;
const AppAccentColor({
required this.id,
required this.name,
required this.color,
required this.lightColor,
required this.darkColor,
required this.icon,
});
static const AppAccentColor blue = AppAccentColor(
id: 'blue',
name: 'Bleu Cobalt',
color: Color(0xFF2563EB),
lightColor: Color(0xFF60A5FA),
darkColor: Color(0xFF1D4ED8),
icon: Icons.lens,
);
static const AppAccentColor red = AppAccentColor(
id: 'red',
name: 'Rouge Cible',
color: Color(0xFFDC2626),
lightColor: Color(0xFFF87171),
darkColor: Color(0xFFB91C1C),
icon: Icons.lens,
);
static const AppAccentColor green = AppAccentColor(
id: 'green',
name: 'Vert Viseur',
color: Color(0xFF10B981),
lightColor: Color(0xFF34D399),
darkColor: Color(0xFF059669),
icon: Icons.lens,
);
static const AppAccentColor orange = AppAccentColor(
id: 'orange',
name: 'Orange Ambre',
color: Color(0xFFFF6D00),
lightColor: Color(0xFFFF9E40),
darkColor: Color(0xFFD84315),
icon: Icons.lens,
);
static const AppAccentColor purple = AppAccentColor(
id: 'purple',
name: 'Violet Cyber',
color: Color(0xFF8B5CF6),
lightColor: Color(0xFFA78BFA),
darkColor: Color(0xFF6D28D9),
icon: Icons.lens,
);
static const AppAccentColor cyan = AppAccentColor(
id: 'cyan',
name: 'Cyan Néon',
color: Color(0xFF06B6D4),
lightColor: Color(0xFF67E8F9),
darkColor: Color(0xFF0E7490),
icon: Icons.lens,
);
static const AppAccentColor gold = AppAccentColor(
id: 'gold',
name: 'Or Compétition',
color: Color(0xFFF59E0B),
lightColor: Color(0xFFFBBF24),
darkColor: Color(0xFFD97706),
icon: Icons.lens,
);
static const List<AppAccentColor> allAccents = [
blue,
red,
green,
orange,
purple,
cyan,
gold,
];
static AppAccentColor fromId(String? id) {
return allAccents.firstWhere(
(a) => a.id == id,
orElse: () => blue,
);
}
}
/// Système de design et thème de l'application Bully.
class AppTheme { class AppTheme {
AppTheme._(); AppTheme._();
static const Color primaryColor = Color(0xFF1E88E5); // Accents par défaut (compatibilité statique)
static const Color secondaryColor = Color(0xFF43A047); static const Color primaryColor = Color(0xFF2563EB);
static const Color errorColor = Color(0xFFE53935); static const Color primaryLight = Color(0xFF60A5FA);
static const Color warningColor = Color(0xFFFFA726); static const Color primaryDark = Color(0xFF1D4ED8);
static const Color successColor = Color(0xFF66BB6A);
static const Color backgroundColor = Color(0xFFF5F5F5); static const Color secondaryColor = Color(0xFF10B981);
static const Color surfaceColor = Colors.white; static const Color secondaryDark = Color(0xFF059669);
static const Color textPrimary = Color(0xFF212121);
static const Color textSecondary = Color(0xFF757575);
// Impact colors for visualization static const Color accentBlue = Color(0xFF0EA5E9);
static const Color impactColor = Color(0xFFFF5722); static const Color accentGold = Color(0xFFFFB300);
static const Color errorColor = Color(0xFFEF4444);
static const Color warningColor = Color(0xFFF59E0B);
static const Color successColor = Color(0xFF10B981);
// Palette Thème Sombre (Standard Stand de Tir)
static const Color darkBackground = Color(0xFF0D1219);
static const Color darkSurface = Color(0xFF161E28);
static const Color darkSurfaceElevated = Color(0xFF1E2836);
static const Color darkSurfaceVariant = Color(0xFF253243);
static const Color darkBorder = Color(0xFF2C3B4E);
static const Color darkBorderLight = Color(0xFF3B4D65);
static const Color darkTextPrimary = Color(0xFFF1F5F9);
static const Color darkTextSecondary = Color(0xFF94A3B8);
static const Color darkTextMuted = Color(0xFF64748B);
// Palette Thème Clair
static const Color lightBackground = Color(0xFFF8FAFC);
static const Color lightSurface = Color(0xFFFFFFFF);
static const Color lightSurfaceElevated = Color(0xFFFFFFFF);
static const Color lightSurfaceVariant = Color(0xFFF1F5F9);
static const Color lightBorder = Color(0xFFE2E8F0);
static const Color lightTextPrimary = Color(0xFF0F172A);
static const Color lightTextSecondary = Color(0xFF64748B);
static const Color lightTextMuted = Color(0xFF94A3B8);
// Compatibilité ascendante
static const Color backgroundColor = lightBackground;
static const Color surfaceColor = lightSurface;
static const Color textPrimary = lightTextPrimary;
static const Color textSecondary = lightTextSecondary;
// Couleurs des impacts pour l'overlay
static const Color impactColor = Color(0xFFFF3D00);
static const Color impactOutlineColor = Color(0xFFFFFFFF); static const Color impactOutlineColor = Color(0xFFFFFFFF);
static const Color groupingCenterColor = Color(0xFF2196F3); static const Color groupingCenterColor = Color(0xFF00E5FF);
static const Color groupingCircleColor = Color(0x4D2196F3); static const Color groupingCircleColor = Color(0x4D00E5FF);
// Score zone colors // Couleurs des zones de score cibles concentriques
static const List<Color> zoneColors = [ static const List<Color> zoneColors = [
Color(0xFFFFEB3B), // Zone 10 - Gold Color(0xFFFFB300), // Zone 10 - Or
Color(0xFFFFEB3B), // Zone 9 Color(0xFFFFCA28), // Zone 9
Color(0xFFFF5722), // Zone 8 Color(0xFFFF5722), // Zone 8
Color(0xFFFF5722), // Zone 7 Color(0xFFFF7043), // Zone 7
Color(0xFF2196F3), // Zone 6 Color(0xFF29B6F6), // Zone 6
Color(0xFF2196F3), // Zone 5 Color(0xFF4FC3F7), // Zone 5
Color(0xFF4CAF50), // Zone 4 Color(0xFF66BB6A), // Zone 4
Color(0xFF4CAF50), // Zone 3 Color(0xFF81C784), // Zone 3
Color(0xFFFFFFFF), // Zone 2 Color(0xFFFFFFFF), // Zone 2
Color(0xFFFFFFFF), // Zone 1 Color(0xFFE0E0E0), // Zone 1
]; ];
static ThemeData get lightTheme { static ThemeData get lightTheme => buildLightTheme(AppAccentColor.blue);
static ThemeData get darkTheme => buildDarkTheme(AppAccentColor.blue);
static ThemeData buildLightTheme(AppAccentColor accent) {
final activePrimary = accent.color;
return ThemeData( return ThemeData(
useMaterial3: true, useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: primaryColor,
brightness: Brightness.light, brightness: Brightness.light,
colorScheme: ColorScheme.light(
primary: activePrimary,
secondary: secondaryColor,
surface: lightSurface,
error: errorColor,
onPrimary: Colors.white,
onSecondary: Colors.white,
onSurface: lightTextPrimary,
onError: Colors.white,
outline: lightBorder,
), ),
scaffoldBackgroundColor: backgroundColor, scaffoldBackgroundColor: lightBackground,
cardColor: lightSurface,
appBarTheme: const AppBarTheme( appBarTheme: const AppBarTheme(
elevation: 0, elevation: 0,
centerTitle: true, centerTitle: true,
backgroundColor: primaryColor, backgroundColor: lightSurface,
foregroundColor: Colors.white, foregroundColor: lightTextPrimary,
surfaceTintColor: Colors.transparent,
titleTextStyle: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: lightTextPrimary,
letterSpacing: 0.3,
),
), ),
cardTheme: CardThemeData( cardTheme: CardThemeData(
elevation: 2, elevation: 0,
color: lightSurface,
surfaceTintColor: Colors.transparent,
margin: EdgeInsets.zero,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(16),
side: const BorderSide(color: lightBorder, width: 1),
), ),
), ),
elevatedButtonTheme: ElevatedButtonThemeData( elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), elevation: 0,
shape: RoundedRectangleBorder( backgroundColor: activePrimary,
borderRadius: BorderRadius.circular(8),
),
),
),
floatingActionButtonTheme: const FloatingActionButtonThemeData(
backgroundColor: primaryColor,
foregroundColor: Colors.white, foregroundColor: Colors.white,
minimumSize: const Size(double.infinity, 50),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
textStyle: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
letterSpacing: 0.2,
),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
foregroundColor: lightTextPrimary,
minimumSize: const Size(double.infinity, 50),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
side: const BorderSide(color: lightBorder, width: 1.5),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
textStyle: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
),
chipTheme: ChipThemeData(
backgroundColor: lightSurfaceVariant,
selectedColor: activePrimary.withValues(alpha: 0.15),
labelStyle: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: lightTextPrimary,
),
secondaryLabelStyle: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: activePrimary,
),
side: const BorderSide(color: lightBorder, width: 1),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: lightSurfaceVariant,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: lightBorder),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: lightBorder),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: activePrimary, width: 1.8),
),
labelStyle: const TextStyle(color: lightTextSecondary),
hintStyle: const TextStyle(color: lightTextMuted),
),
dividerTheme: const DividerThemeData(
color: lightBorder,
thickness: 1,
space: 24,
),
floatingActionButtonTheme: FloatingActionButtonThemeData(
backgroundColor: activePrimary,
foregroundColor: Colors.white,
elevation: 3,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(16)),
),
), ),
); );
} }
static ThemeData get darkTheme { static ThemeData buildDarkTheme(AppAccentColor accent) {
final activePrimary = accent.color;
return ThemeData( return ThemeData(
useMaterial3: true, useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: primaryColor,
brightness: Brightness.dark, brightness: Brightness.dark,
colorScheme: ColorScheme.dark(
primary: activePrimary,
secondary: secondaryColor,
surface: darkSurface,
error: errorColor,
onPrimary: Colors.white,
onSecondary: Colors.white,
onSurface: darkTextPrimary,
onError: Colors.white,
outline: darkBorder,
), ),
scaffoldBackgroundColor: darkBackground,
cardColor: darkSurface,
appBarTheme: const AppBarTheme( appBarTheme: const AppBarTheme(
elevation: 0, elevation: 0,
centerTitle: true, centerTitle: true,
backgroundColor: darkBackground,
foregroundColor: darkTextPrimary,
surfaceTintColor: Colors.transparent,
titleTextStyle: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: darkTextPrimary,
letterSpacing: 0.3,
),
), ),
cardTheme: CardThemeData( cardTheme: CardThemeData(
elevation: 2, elevation: 0,
color: darkSurface,
surfaceTintColor: Colors.transparent,
margin: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: const BorderSide(color: darkBorder, width: 1),
),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
elevation: 0,
backgroundColor: activePrimary,
foregroundColor: Colors.white,
minimumSize: const Size(double.infinity, 50),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
textStyle: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
letterSpacing: 0.2,
),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
foregroundColor: darkTextPrimary,
minimumSize: const Size(double.infinity, 50),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
side: const BorderSide(color: darkBorder, width: 1.5),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
textStyle: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
),
chipTheme: ChipThemeData(
backgroundColor: darkSurfaceElevated,
selectedColor: activePrimary.withValues(alpha: 0.2),
labelStyle: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: darkTextPrimary,
),
secondaryLabelStyle: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: activePrimary,
),
side: const BorderSide(color: darkBorder, width: 1),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: darkSurfaceElevated,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: darkBorder),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: darkBorder),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: activePrimary, width: 1.8),
),
labelStyle: const TextStyle(color: darkTextSecondary),
hintStyle: const TextStyle(color: darkTextMuted),
),
dividerTheme: const DividerThemeData(
color: darkBorder,
thickness: 1,
space: 24,
),
floatingActionButtonTheme: FloatingActionButtonThemeData(
backgroundColor: activePrimary,
foregroundColor: Colors.white,
elevation: 3,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(16)),
),
), ),
); );
} }
+26 -3
View File
@@ -1,24 +1,37 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'app_theme.dart';
class ThemeProvider with ChangeNotifier { class ThemeProvider with ChangeNotifier {
static const String _themeModeKey = 'user_theme_mode'; static const String _themeModeKey = 'user_theme_mode';
static const String _accentKey = 'user_accent_color';
ThemeMode _themeMode = ThemeMode.system; ThemeMode _themeMode = ThemeMode.system;
AppAccentColor _accent = AppAccentColor.blue;
ThemeMode get themeMode => _themeMode; ThemeMode get themeMode => _themeMode;
AppAccentColor get currentAccent => _accent;
Color get primaryColor => _accent.color;
ThemeData get lightTheme => AppTheme.buildLightTheme(_accent);
ThemeData get darkTheme => AppTheme.buildDarkTheme(_accent);
ThemeProvider() { ThemeProvider() {
loadThemeMode(); loadSettings();
} }
Future<void> loadThemeMode() async { Future<void> loadSettings() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final modeIndex = prefs.getInt(_themeModeKey); final modeIndex = prefs.getInt(_themeModeKey);
final accentId = prefs.getString(_accentKey);
if (modeIndex != null) { if (modeIndex != null) {
_themeMode = ThemeMode.values[modeIndex]; _themeMode = ThemeMode.values[modeIndex];
notifyListeners();
} }
if (accentId != null) {
_accent = AppAccentColor.fromId(accentId);
}
notifyListeners();
} }
Future<void> setThemeMode(ThemeMode mode) async { Future<void> setThemeMode(ThemeMode mode) async {
@@ -31,6 +44,16 @@ class ThemeProvider with ChangeNotifier {
await prefs.setInt(_themeModeKey, mode.index); await prefs.setInt(_themeModeKey, mode.index);
} }
Future<void> setAccent(AppAccentColor accent) async {
if (_accent.id == accent.id) return;
_accent = accent;
notifyListeners();
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_accentKey, accent.id);
}
String get themeModeName { String get themeModeName {
switch (_themeMode) { switch (_themeMode) {
case ThemeMode.system: case ThemeMode.system:
+109
View File
@@ -0,0 +1,109 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import '../theme/app_theme.dart';
/// Conteneur avec effet de verre givré (Glassmorphism),
/// bordures translucides fines et reflets subtils pour casser l'aspect standard Flutter.
class GlassContainer extends StatelessWidget {
final Widget child;
final double borderRadius;
final double blur;
final EdgeInsetsGeometry padding;
final EdgeInsetsGeometry margin;
final Color? customBackgroundColor;
final Color? borderColor;
final Color? glowColor;
final VoidCallback? onTap;
final VoidCallback? onLongPress;
const GlassContainer({
super.key,
required this.child,
this.borderRadius = 18.0,
this.blur = 12.0,
this.padding = const EdgeInsets.all(16.0),
this.margin = EdgeInsets.zero,
this.customBackgroundColor,
this.borderColor,
this.glowColor,
this.onTap,
this.onLongPress,
});
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final defaultBg = isDark
? const Color(0xFF141C26).withValues(alpha: 0.72)
: Colors.white.withValues(alpha: 0.82);
final defaultBorder = isDark
? Colors.white.withValues(alpha: 0.12)
: Colors.black.withValues(alpha: 0.08);
final resolvedBorderColor = borderColor ?? defaultBorder;
final resolvedBg = customBackgroundColor ?? defaultBg;
Widget content = Container(
padding: padding,
decoration: BoxDecoration(
color: resolvedBg,
borderRadius: BorderRadius.circular(borderRadius),
border: Border.all(color: resolvedBorderColor, width: 1.2),
boxShadow: [
if (glowColor != null)
BoxShadow(
color: glowColor!.withValues(alpha: isDark ? 0.25 : 0.15),
blurRadius: 18,
spreadRadius: -2,
offset: const Offset(0, 4),
)
else
BoxShadow(
color: Colors.black.withValues(alpha: isDark ? 0.28 : 0.04),
blurRadius: 16,
offset: const Offset(0, 6),
),
],
),
child: child,
);
if (blur > 0) {
content = ClipRRect(
borderRadius: BorderRadius.circular(borderRadius),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: blur, sigmaY: blur),
child: content,
),
);
} else {
content = ClipRRect(
borderRadius: BorderRadius.circular(borderRadius),
child: content,
);
}
if (margin != EdgeInsets.zero) {
content = Padding(padding: margin, child: content);
}
if (onTap != null || onLongPress != null) {
return Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(borderRadius),
child: InkWell(
borderRadius: BorderRadius.circular(borderRadius),
onTap: onTap,
onLongPress: onLongPress,
splashColor: (glowColor ?? AppTheme.primaryColor).withValues(alpha: 0.15),
highlightColor: (glowColor ?? AppTheme.primaryColor).withValues(alpha: 0.08),
child: content,
),
);
}
return content;
}
}
+335
View File
@@ -0,0 +1,335 @@
import 'package:flutter/material.dart';
import '../../data/models/weapon.dart';
/// Widget affichant une icône vectorielle stylisée et fidèle pour chaque type d'arme.
class WeaponTypeIcon extends StatelessWidget {
final WeaponType type;
final Color? color;
final double size;
const WeaponTypeIcon({
super.key,
required this.type,
this.color,
this.size = 24,
});
@override
Widget build(BuildContext context) {
final effectiveColor = color ?? Theme.of(context).colorScheme.primary;
return CustomPaint(
size: Size(size, size),
painter: _WeaponTypePainter(
type: type,
color: effectiveColor,
),
);
}
}
class _WeaponTypePainter extends CustomPainter {
final WeaponType type;
final Color color;
_WeaponTypePainter({
required this.type,
required this.color,
});
@override
void paint(Canvas canvas, Size size) {
final fillPaint = Paint()
..color = color
..style = PaintingStyle.fill
..isAntiAlias = true;
final strokePaint = Paint()
..color = color
..style = PaintingStyle.stroke
..strokeWidth = 1.0
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round
..isAntiAlias = true;
canvas.save();
// Normalisation sur une grille 32x32 pour un rendu très précis
final scale = size.width / 32.0;
canvas.scale(scale, scale);
switch (type) {
case WeaponType.handgun:
_drawGlock(canvas, fillPaint, strokePaint);
break;
case WeaponType.rifle:
_drawKalashnikov(canvas, fillPaint, strokePaint);
break;
case WeaponType.shotgun:
_drawPumpShotgun(canvas, fillPaint, strokePaint);
break;
case WeaponType.airgun:
_drawAirgun(canvas, fillPaint, strokePaint);
break;
}
canvas.restore();
}
/// Silhouette fidèle d'un Glock : culasse carrée "boxy", pontet angulaire et poignée ergonomique
void _drawGlock(Canvas canvas, Paint fillPaint, Paint strokePaint) {
// 1. Culasse rectangulaire Glock (Slide)
final slidePath = Path();
slidePath.moveTo(4, 9);
slidePath.lineTo(26.5, 9); // dessus de culasse
slidePath.lineTo(27.5, 10); // chanfrein nez avant
slidePath.lineTo(27.5, 14.5); // bouche du canon
slidePath.lineTo(4, 14.5); // bas de culasse
slidePath.lineTo(3.5, 13); // chanfrein arrière
slidePath.lineTo(3.5, 10);
slidePath.close();
canvas.drawPath(slidePath, fillPaint);
// Organes de visée Glock
// Cran de mire arrière carré
canvas.drawRect(const Rect.fromLTWH(5, 7.2, 2.5, 1.8), fillPaint);
// Guidon avant
canvas.drawRect(const Rect.fromLTWH(24.5, 7.2, 1.8, 1.8), fillPaint);
// 2. Carcasse inférieure & Poignée (Frame & Grip)
final framePath = Path();
framePath.moveTo(4, 14.5);
framePath.lineTo(26, 14.5); // rail picatinny avant
framePath.lineTo(25.5, 16.5);
framePath.lineTo(17.5, 16.5); // bas du rail avant pontet
framePath.lineTo(17.5, 19.5); // face avant du pontet carré Glock
framePath.lineTo(12.5, 20.5); // bas du pontet
framePath.lineTo(12, 17.5); // jonction détente / poignée avant
framePath.lineTo(9.5, 27); // poignée avant avec empreinte
framePath.lineTo(4.5, 27); // talon de chargeur / magwell
framePath.lineTo(8, 16.5); // dos de poignée / beavertail Glock
framePath.lineTo(4, 15.5);
framePath.close();
canvas.drawPath(framePath, fillPaint);
// 3. Queue de détente Safe Action
final triggerPath = Path();
triggerPath.moveTo(15.5, 15.5);
triggerPath.quadraticBezierTo(14, 17.5, 15.2, 19);
canvas.drawPath(triggerPath, strokePaint..strokeWidth = 1.3);
// 4. Stries de préhension arrière (Serrations)
for (double i = 6; i <= 10; i += 1.5) {
canvas.drawLine(
Offset(i, 10),
Offset(i, 13.5),
Paint()
..color = Colors.black.withValues(alpha: 0.35)
..strokeWidth = 0.8,
);
}
}
/// Silhouette fidèle d'une Kalashnikov (AK-47 / AKM) : chargeur banane, emprunt de gaz et guidon haut
void _drawKalashnikov(Canvas canvas, Paint fillPaint, Paint strokePaint) {
// 1. Crosse arrière en bois / épaule
final stockPath = Path();
stockPath.moveTo(1.5, 13);
stockPath.lineTo(8, 14.5);
stockPath.lineTo(8, 18);
stockPath.lineTo(1.5, 20.5);
stockPath.close();
canvas.drawPath(stockPath, fillPaint);
// 2. Boîtier de culasse (Receiver) & Capot supérieur
final receiverPath = Path();
receiverPath.moveTo(8, 13.5);
receiverPath.lineTo(17, 13.5);
receiverPath.lineTo(17, 18);
receiverPath.lineTo(8, 18);
receiverPath.close();
canvas.drawPath(receiverPath, fillPaint);
// 3. Garde-main & Tube d'emprunt de gaz supérieur
final handguardPath = Path();
handguardPath.moveTo(17, 13.5);
handguardPath.lineTo(24, 13.5);
handguardPath.lineTo(24, 17);
handguardPath.lineTo(17, 17);
handguardPath.close();
canvas.drawPath(handguardPath, fillPaint);
// 4. Canon fin avant & Bloc guidon Kalash
final barrelPath = Path();
barrelPath.moveTo(24, 14.5);
barrelPath.lineTo(30.5, 14.5);
barrelPath.lineTo(30.5, 16);
barrelPath.lineTo(24, 16);
barrelPath.close();
canvas.drawPath(barrelPath, fillPaint);
// Bloc guidon triangulaire surélevé avant
final frontSightPath = Path();
frontSightPath.moveTo(28, 14.5);
frontSightPath.lineTo(29, 11);
frontSightPath.lineTo(30, 14.5);
frontSightPath.close();
canvas.drawPath(frontSightPath, fillPaint);
// Hausse arrière au dessus du boîtier
canvas.drawRect(const Rect.fromLTWH(16.5, 12, 2, 1.5), fillPaint);
// 5. Chargeur courbé "banane" emblématique (30 coups)
final magPath = Path();
magPath.moveTo(14, 18);
magPath.lineTo(16.5, 18);
magPath.quadraticBezierTo(17.5, 22.5, 15, 26);
magPath.lineTo(12, 25.5);
magPath.quadraticBezierTo(14, 22, 13.5, 18);
magPath.close();
canvas.drawPath(magPath, fillPaint);
// 6. Poignée pistolet séparée
final gripPath = Path();
gripPath.moveTo(8.5, 18);
gripPath.lineTo(10.5, 18);
gripPath.lineTo(9.5, 23.5);
gripPath.lineTo(7.5, 23);
gripPath.close();
canvas.drawPath(gripPath, fillPaint);
// 7. Pontet et détente
final triggerGuard = Path();
triggerGuard.moveTo(11, 18);
triggerGuard.quadraticBezierTo(11.5, 20, 13, 19.5);
canvas.drawPath(triggerGuard, strokePaint..strokeWidth = 1.0);
}
/// Silhouette fidèle d'un Fusil à Pompe (Pump Action Shotgun : type Remington 870)
void _drawPumpShotgun(Canvas canvas, Paint fillPaint, Paint strokePaint) {
// 1. Crosse arrière traditionnelle ergonomique
final stockPath = Path();
stockPath.moveTo(1, 14.5);
stockPath.lineTo(9, 15.5);
stockPath.lineTo(9, 19.5);
stockPath.lineTo(6.5, 20); // poignée semi-pistolet
stockPath.lineTo(1, 21.5);
stockPath.close();
canvas.drawPath(stockPath, fillPaint);
// 2. Boîtier de culasse récepteur (Receiver)
final receiverPath = Path();
receiverPath.moveTo(9, 14);
receiverPath.lineTo(16.5, 14);
receiverPath.lineTo(16.5, 19);
receiverPath.lineTo(9, 19);
receiverPath.close();
canvas.drawPath(receiverPath, fillPaint);
// 3. Canon long calibre 12 (Barrel)
final barrelPath = Path();
barrelPath.moveTo(16.5, 14);
barrelPath.lineTo(31, 14);
barrelPath.lineTo(31, 15.5);
barrelPath.lineTo(16.5, 15.5);
barrelPath.close();
canvas.drawPath(barrelPath, fillPaint);
// Grain d'orge / mire avant sur le canon
canvas.drawCircle(const Offset(30, 13.2), 0.8, fillPaint);
// 4. Tube magasin inférieur sous le canon
final magTubePath = Path();
magTubePath.moveTo(16.5, 16);
magTubePath.lineTo(27, 16);
magTubePath.lineTo(27, 17.5);
magTubePath.lineTo(16.5, 17.5);
magTubePath.close();
canvas.drawPath(magTubePath, fillPaint);
// Bague de fixation canon / tube magasin
canvas.drawRect(const Rect.fromLTWH(26, 14, 1.2, 4), fillPaint);
// 5. Pompe mobile striée d'actionnement (Forend / Pump)
final pumpPath = Path();
pumpPath.addRRect(RRect.fromRectAndRadius(
const Rect.fromLTWH(18, 15.5, 6.5, 3.5),
const Radius.circular(0.8),
));
canvas.drawPath(pumpPath, fillPaint);
// Stries sur la pompe
for (double x = 19.5; x <= 23.5; x += 1.3) {
canvas.drawLine(
Offset(x, 16),
Offset(x, 18.5),
Paint()
..color = Colors.black.withValues(alpha: 0.4)
..strokeWidth = 0.6,
);
}
// 6. Pontet et détente
final triggerGuard = Path();
triggerGuard.moveTo(10, 19);
triggerGuard.quadraticBezierTo(11.5, 21.5, 13.5, 20);
triggerGuard.lineTo(13.5, 19);
canvas.drawPath(triggerGuard, strokePaint..strokeWidth = 1.0);
}
/// Silhouette fidèle d'une arme à air comprimé / cible plomb (Airgun)
void _drawAirgun(Canvas canvas, Paint fillPaint, Paint strokePaint) {
// Pistolet match 10m / Airgun olympique avec réservoir d'air cylindrique
// 1. Canon supérieur et boîtier
final barrelPath = Path();
barrelPath.moveTo(4, 12);
barrelPath.lineTo(28, 12);
barrelPath.lineTo(28, 14);
barrelPath.lineTo(4, 14);
barrelPath.close();
canvas.drawPath(barrelPath, fillPaint);
// Organes de visée match : dioptre arrière et tunnel de guidon avant
canvas.drawRect(const Rect.fromLTWH(4.5, 9.5, 3, 2.5), fillPaint);
canvas.drawRect(const Rect.fromLTWH(25, 10, 2.5, 2), fillPaint);
// 2. Bonbonne d'air comprimé cylindrique inférieure (Cylinder)
final tankPath = Path();
tankPath.addRRect(RRect.fromRectAndRadius(
const Rect.fromLTWH(10, 14.5, 15, 3),
const Radius.circular(1.5),
));
canvas.drawPath(tankPath, fillPaint);
// Manomètre avant
canvas.drawCircle(const Offset(25, 16), 1.2, strokePaint..strokeWidth = 0.8);
// 3. Poignée anatomique bois de tir sportif avec repose-paume
final gripPath = Path();
gripPath.moveTo(6, 14);
gripPath.lineTo(10, 14);
gripPath.lineTo(9.5, 23.5);
gripPath.lineTo(3.5, 22.5);
gripPath.close();
canvas.drawPath(gripPath, fillPaint);
// Tablette repose-paume réglable
canvas.drawRRect(
RRect.fromRectAndRadius(
const Rect.fromLTWH(2, 22.5, 9, 2.2),
const Radius.circular(0.8),
),
fillPaint,
);
// 4. Pontet match
final guardPath = Path();
guardPath.moveTo(10, 14.5);
guardPath.quadraticBezierTo(12, 18.5, 9.5, 18.5);
canvas.drawPath(guardPath, strokePaint..strokeWidth = 1.0);
}
@override
bool shouldRepaint(covariant _WeaponTypePainter oldDelegate) {
return oldDelegate.type != type || oldDelegate.color != color;
}
}
+8 -5
View File
@@ -1,11 +1,14 @@
import 'package:flutter/material.dart';
enum WeaponType { enum WeaponType {
handgun('Arme de Poing'), handgun('Arme de Poing', Icons.shield),
rifle('Arme d\'Épaule'), rifle('Arme d\'Épaule', Icons.filter_center_focus),
shotgun('Fusil à Pompe'), shotgun('Fusil à Pompe', Icons.splitscreen),
airgun('Airsoft / Airgun'); airgun('Airsoft / Airgun', Icons.air);
final String displayName; final String displayName;
const WeaponType(this.displayName); final IconData defaultIcon;
const WeaponType(this.displayName, this.defaultIcon);
static WeaponType fromString(String value) { static WeaponType fromString(String value) {
return WeaponType.values.firstWhere( return WeaponType.values.firstWhere(
+35 -2
View File
@@ -3,6 +3,7 @@ import 'package:provider/provider.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import '../../core/constants/app_constants.dart'; import '../../core/constants/app_constants.dart';
import '../../core/theme/app_theme.dart'; import '../../core/theme/app_theme.dart';
import '../../core/widgets/weapon_type_icon.dart';
import '../../data/models/weapon.dart'; import '../../data/models/weapon.dart';
import '../../data/models/maintenance.dart'; import '../../data/models/maintenance.dart';
import '../../data/repositories/session_repository.dart'; import '../../data/repositories/session_repository.dart';
@@ -131,7 +132,26 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
const Text('Informations techniques', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), const Text('Informations techniques', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
const Divider(), const Divider(),
_buildInfoRow('Modèle', _weapon.name), _buildInfoRow('Modèle', _weapon.name),
_buildInfoRow('Type', _weapon.type.displayName), Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Type', style: TextStyle(color: Colors.grey)),
Row(
mainAxisSize: MainAxisSize.min,
children: [
WeaponTypeIcon(type: _weapon.type, size: 16),
const SizedBox(width: 6),
Text(
_weapon.type.displayName,
style: const TextStyle(fontWeight: FontWeight.w500),
),
],
),
],
),
),
_buildInfoRow('Calibre', _weapon.caliber), _buildInfoRow('Calibre', _weapon.caliber),
_buildInfoRow('Chargeurs', '${_weapon.magazineCount} x ${_weapon.magazineCapacity} coups'), _buildInfoRow('Chargeurs', '${_weapon.magazineCount} x ${_weapon.magazineCapacity} coups'),
if (_weapon.notes != null && _weapon.notes!.isNotEmpty) ...[ if (_weapon.notes != null && _weapon.notes!.isNotEmpty) ...[
@@ -313,7 +333,20 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
DropdownButtonFormField<WeaponType>( DropdownButtonFormField<WeaponType>(
initialValue: selectedType, initialValue: selectedType,
decoration: const InputDecoration(labelText: 'Type'), decoration: const InputDecoration(labelText: 'Type'),
items: WeaponType.values.map((t) => DropdownMenuItem(value: t, child: Text(t.displayName))).toList(), items: WeaponType.values
.map(
(t) => DropdownMenuItem(
value: t,
child: Row(
children: [
WeaponTypeIcon(type: t, size: 20),
const SizedBox(width: 10),
Text(t.displayName),
],
),
),
)
.toList(),
onChanged: (v) => setState(() => selectedType = v!), onChanged: (v) => setState(() => selectedType = v!),
), ),
_autoScrollOnFocus(TextField( _autoScrollOnFocus(TextField(
+248 -79
View File
@@ -2,14 +2,13 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../../core/constants/app_constants.dart'; import '../../core/constants/app_constants.dart';
import '../../core/theme/app_theme.dart'; import '../../core/theme/app_theme.dart';
import '../../core/widgets/glass_container.dart';
import '../../core/widgets/weapon_type_icon.dart';
import '../../data/models/weapon.dart'; import '../../data/models/weapon.dart';
import '../../data/repositories/session_repository.dart'; import '../../data/repositories/session_repository.dart';
import 'weapon_detail_screen.dart'; import 'weapon_detail_screen.dart';
class WeaponListScreen extends StatefulWidget { class WeaponListScreen extends StatefulWidget {
/// Incrémenté par la navigation à chaque ouverture de l'onglet Armurerie
/// (l'écran est gardé vivant par l'IndexedStack et ne se rafraîchit pas
/// seul : sans ça, un import de sauvegarde resterait invisible).
final int refreshTick; final int refreshTick;
const WeaponListScreen({super.key, this.refreshTick = 0}); const WeaponListScreen({super.key, this.refreshTick = 0});
@@ -49,118 +48,242 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return Scaffold( return Scaffold(
extendBody: true,
appBar: AppBar( appBar: AppBar(
title: const Text('Mon Armurerie'), title: const Text('Mon Armurerie'),
actions: [
IconButton(
icon: const Icon(Icons.add_circle_outline),
tooltip: 'Ajouter une arme',
onPressed: _showAddWeaponDialog,
),
const SizedBox(width: 8),
],
), ),
body: _isLoading body: _isLoading
? const Center(child: CircularProgressIndicator()) ? const Center(child: CircularProgressIndicator())
: _weapons.isEmpty : _weapons.isEmpty
? _buildEmptyState() ? _buildEmptyState(isDark)
: _buildWeaponList(), : _buildWeaponList(isDark),
floatingActionButton: FloatingActionButton(
onPressed: _showAddWeaponDialog,
child: const Icon(Icons.add),
),
); );
} }
Widget _buildEmptyState() { Widget _buildEmptyState(bool isDark) {
return Center( return Center(
child: Padding(
padding: const EdgeInsets.all(32.0),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon(Icons.shield, size: 64, color: Colors.grey[400]), GlassContainer(
const SizedBox(height: 16), borderRadius: 30,
const Text('Aucune arme enregistrée'), padding: const EdgeInsets.all(28),
child: Icon(
Icons.shield_outlined,
size: 64,
color: isDark ? AppTheme.darkTextMuted : AppTheme.lightTextMuted,
),
),
const SizedBox(height: 24), const SizedBox(height: 24),
ElevatedButton( Text(
'Aucune arme enregistrée',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w800,
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
),
),
const SizedBox(height: 8),
Text(
'Ajoutez vos armes pour suivre vos tirs, chargeurs et entretiens.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13,
color: isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary,
),
),
const SizedBox(height: 24),
ElevatedButton.icon(
onPressed: _showAddWeaponDialog, onPressed: _showAddWeaponDialog,
child: const Text('Ajouter ma première arme'), icon: const Icon(Icons.add),
label: const Text('Ajouter ma première arme'),
), ),
], ],
), ),
),
); );
} }
Widget _buildWeaponList() { Widget _buildWeaponList(bool isDark) {
return ListView.builder( return ListView.builder(
padding: const EdgeInsets.all(AppConstants.defaultPadding), padding: const EdgeInsets.fromLTRB(
AppConstants.defaultPadding,
12,
AppConstants.defaultPadding,
100, // Espace pour le floating dock
),
itemCount: _weapons.length, itemCount: _weapons.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final weapon = _weapons[index]; final weapon = _weapons[index];
final accessories = _accessoryChips(weapon); final accessories = _accessoryChips(weapon, isDark);
return Card( final primaryColor = Theme.of(context).colorScheme.primary;
margin: const EdgeInsets.only(bottom: 12),
child: InkWell( return GlassContainer(
borderRadius: BorderRadius.circular(12), borderRadius: 18,
blur: 14,
glowColor: primaryColor,
borderColor: isDark
? primaryColor.withValues(alpha: 0.18)
: primaryColor.withValues(alpha: 0.12),
margin: const EdgeInsets.only(bottom: 14),
padding: const EdgeInsets.all(16),
onTap: () async { onTap: () async {
await Navigator.push( await Navigator.push(
context, context,
MaterialPageRoute(builder: (_) => WeaponDetailScreen(weapon: weapon)), MaterialPageRoute(
builder: (_) => WeaponDetailScreen(weapon: weapon),
),
); );
_loadWeapons(); // Reload in case it was edited or maintenance was added _loadWeapons();
}, },
onLongPress: () => _confirmDelete(weapon), onLongPress: () => _confirmDelete(weapon),
child: Padding( child: Column(
// La hauteur du cadre s'adapte automatiquement à la liste d'accessoires. crossAxisAlignment: CrossAxisAlignment.start,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), children: [
child: Row( Row(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
CircleAvatar( Container(
backgroundColor: AppTheme.primaryColor.withValues(alpha: 0.1), padding: const EdgeInsets.all(10),
child: Icon( decoration: BoxDecoration(
weapon.type == WeaponType.handgun ? Icons.shield : Icons.ads_click, gradient: LinearGradient(
color: AppTheme.primaryColor, colors: [
primaryColor.withValues(
alpha: isDark ? 0.25 : 0.15,
),
primaryColor.withValues(
alpha: 0.05,
),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: primaryColor.withValues(
alpha: isDark ? 0.35 : 0.2,
), ),
), ),
const SizedBox(width: 16), ),
child: WeaponTypeIcon(
type: weapon.type,
color: primaryColor,
size: 28,
),
),
const SizedBox(width: 14),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [ children: [
Text(weapon.displayName, style: const TextStyle(fontWeight: FontWeight.bold)), Text(
weapon.displayName,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w800,
color: isDark
? AppTheme.darkTextPrimary
: AppTheme.lightTextPrimary,
),
),
const SizedBox(height: 4),
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 7,
vertical: 2,
),
decoration: BoxDecoration(
color: (isDark ? Colors.white : Colors.black)
.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: isDark
? AppTheme.darkBorder
: AppTheme.lightBorder,
),
),
child: Text(
weapon.caliber,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w800,
color: primaryColor,
),
),
),
const SizedBox(width: 8),
Text(
weapon.type.displayName,
style: TextStyle(
fontSize: 12,
color: isDark
? AppTheme.darkTextSecondary
: AppTheme.lightTextSecondary,
),
),
],
),
],
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'${weapon.magazineCount} chargeur${weapon.magazineCount > 1 ? 's' : ''}',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: isDark
? AppTheme.darkTextPrimary
: AppTheme.lightTextPrimary,
),
),
const SizedBox(height: 2),
Text(
'${weapon.magazineCapacity} coups/ch.',
style: TextStyle(
fontSize: 11,
color: isDark
? AppTheme.darkTextMuted
: AppTheme.lightTextMuted,
),
),
],
),
],
),
if (accessories.isNotEmpty) ...[ if (accessories.isNotEmpty) ...[
const SizedBox(height: 6), const SizedBox(height: 12),
Wrap( Wrap(
spacing: 6, spacing: 6,
runSpacing: 6, runSpacing: 6,
children: accessories, children: accessories,
), ),
const SizedBox(height: 6),
] else
const SizedBox(height: 2),
Text(
'${weapon.type.displayName}${weapon.caliber}',
style: const TextStyle(fontSize: 13, color: Colors.grey),
),
], ],
),
),
const SizedBox(width: 12),
Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text('${weapon.magazineCount} chargeurs', style: const TextStyle(fontSize: 12)),
Text('${weapon.magazineCapacity} coups', style: const TextStyle(fontSize: 12, color: Colors.grey)),
], ],
), ),
],
),
),
),
); );
}, },
); );
} }
// Construit la liste des "puces" d'accessoires renseignés pour une arme. List<Widget> _accessoryChips(Weapon weapon, bool isDark) {
// Seuls les accessoires effectivement définis sont affichés ; la card
// s'agrandit donc en fonction du nombre d'accessoires.
List<Widget> _accessoryChips(Weapon weapon) {
final items = <(IconData, String)>[]; final items = <(IconData, String)>[];
if (weapon.optic != null && weapon.optic!.isNotEmpty) { if (weapon.optic != null && weapon.optic!.isNotEmpty) {
items.add((Icons.center_focus_strong, weapon.optic!)); items.add((Icons.center_focus_strong, weapon.optic!));
@@ -176,16 +299,29 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppTheme.primaryColor.withValues(alpha: 0.08), color: (isDark ? Colors.white : Colors.black).withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppTheme.primaryColor.withValues(alpha: 0.25)), border: Border.all(
color: isDark ? AppTheme.darkBorder : AppTheme.lightBorder,
),
), ),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(item.$1, size: 13, color: AppTheme.primaryColor), Icon(
const SizedBox(width: 4), item.$1,
Text(item.$2, style: const TextStyle(fontSize: 12)), size: 13,
color: isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary,
),
const SizedBox(width: 5),
Text(
item.$2,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary,
),
),
], ],
), ),
); );
@@ -193,7 +329,6 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
} }
void _showAddWeaponDialog() async { void _showAddWeaponDialog() async {
// Capturé AVANT l'await : plus aucun usage du context après le dialogue.
final repository = context.read<SessionRepository>(); final repository = context.read<SessionRepository>();
final nameController = TextEditingController(); final nameController = TextEditingController();
final caliberController = TextEditingController(); final caliberController = TextEditingController();
@@ -203,7 +338,7 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
final result = await showDialog<bool>( final result = await showDialog<bool>(
context: context, context: context,
builder: (context) => StatefulBuilder( builder: (dialogCtx) => StatefulBuilder(
builder: (context, setState) => AlertDialog( builder: (context, setState) => AlertDialog(
title: const Text('Ajouter une arme'), title: const Text('Ajouter une arme'),
content: SingleChildScrollView( content: SingleChildScrollView(
@@ -212,18 +347,40 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
children: [ children: [
TextField( TextField(
controller: nameController, controller: nameController,
decoration: const InputDecoration(labelText: 'Nom de l\'arme', hintText: 'ex: Glock 17'), decoration: const InputDecoration(
labelText: 'Nom de l\'arme',
hintText: 'ex: Glock 17 Gen 5',
), ),
),
const SizedBox(height: 12),
DropdownButtonFormField<WeaponType>( DropdownButtonFormField<WeaponType>(
initialValue: selectedType, initialValue: selectedType,
decoration: const InputDecoration(labelText: 'Type'), decoration: const InputDecoration(labelText: 'Type'),
items: WeaponType.values.map((t) => DropdownMenuItem(value: t, child: Text(t.displayName))).toList(), items: WeaponType.values
.map(
(t) => DropdownMenuItem(
value: t,
child: Row(
children: [
WeaponTypeIcon(type: t, size: 20),
const SizedBox(width: 10),
Text(t.displayName),
],
),
),
)
.toList(),
onChanged: (v) => setState(() => selectedType = v!), onChanged: (v) => setState(() => selectedType = v!),
), ),
const SizedBox(height: 12),
TextField( TextField(
controller: caliberController, controller: caliberController,
decoration: const InputDecoration(labelText: 'Calibre', hintText: 'ex: 9mm, .22LR'), decoration: const InputDecoration(
labelText: 'Calibre',
hintText: 'ex: 9x19mm, .22 LR, .223 Rem',
), ),
),
const SizedBox(height: 12),
Row( Row(
children: [ children: [
Expanded( Expanded(
@@ -233,7 +390,7 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
), ),
), ),
const SizedBox(width: 16), const SizedBox(width: 12),
Expanded( Expanded(
child: TextField( child: TextField(
controller: magCapController, controller: magCapController,
@@ -247,8 +404,14 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
), ),
), ),
actions: [ actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Annuler')), TextButton(
ElevatedButton(onPressed: () => Navigator.pop(context, true), child: const Text('Ajouter')), onPressed: () => Navigator.pop(dialogCtx, false),
child: const Text('Annuler'),
),
ElevatedButton(
onPressed: () => Navigator.pop(dialogCtx, true),
child: const Text('Ajouter'),
),
], ],
), ),
), ),
@@ -268,18 +431,24 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
} }
void _confirmDelete(Weapon weapon) async { void _confirmDelete(Weapon weapon) async {
// Capturé AVANT l'await : plus aucun usage du context après le dialogue.
final repository = context.read<SessionRepository>(); final repository = context.read<SessionRepository>();
final confirmed = await showDialog<bool>( final confirmed = await showDialog<bool>(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
title: const Text('Supprimer'), title: const Text('Supprimer l\'arme'),
content: Text('Voulez-vous supprimer ${weapon.name} de votre armurerie ?'), content: Text('Voulez-vous supprimer ${weapon.name} de votre armurerie ?'),
actions: [ actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Annuler')),
TextButton( TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Annuler'),
),
ElevatedButton(
onPressed: () => Navigator.pop(context, true), onPressed: () => Navigator.pop(context, true),
child: const Text('Supprimer', style: TextStyle(color: AppTheme.errorColor)), style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.errorColor,
foregroundColor: Colors.white,
),
child: const Text('Supprimer'),
), ),
], ],
), ),
+156 -100
View File
@@ -1,7 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import '../../core/constants/app_constants.dart';
import '../../core/theme/app_theme.dart'; import '../../core/theme/app_theme.dart';
import '../../data/models/session.dart'; import '../../data/models/session.dart';
import '../../data/models/target_type.dart'; import '../../data/models/target_type.dart';
@@ -11,10 +10,6 @@ import 'widgets/session_list_item.dart';
import 'widgets/history_chart.dart'; import 'widgets/history_chart.dart';
class HistoryScreen extends StatefulWidget { class HistoryScreen extends StatefulWidget {
/// Incrémenté par la navigation à chaque ouverture de l'onglet Historique,
/// pour forcer un rechargement des sessions (l'écran est gardé vivant par un
/// IndexedStack, sinon une session tout juste clôturée n'apparaîtrait pas
/// tant qu'on ne tire pas manuellement pour rafraîchir).
final int refreshTick; final int refreshTick;
const HistoryScreen({super.key, this.refreshTick = 0}); const HistoryScreen({super.key, this.refreshTick = 0});
@@ -27,8 +22,6 @@ class _HistoryScreenState extends State<HistoryScreen> {
List<Session> _sessions = []; List<Session> _sessions = [];
bool _isLoading = true; bool _isLoading = true;
TargetType? _filterType; TargetType? _filterType;
// --- MODIFICATION : Remplacement de DateTime par DateTimeRange ---
DateTimeRange? _selectedDateRange; DateTimeRange? _selectedDateRange;
@override @override
@@ -40,8 +33,6 @@ class _HistoryScreenState extends State<HistoryScreen> {
@override @override
void didUpdateWidget(HistoryScreen oldWidget) { void didUpdateWidget(HistoryScreen oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
// L'onglet vient d'être ré-ouvert : on recharge pour afficher les sessions
// récemment clôturées sans avoir à tirer manuellement pour rafraîchir.
if (oldWidget.refreshTick != widget.refreshTick) { if (oldWidget.refreshTick != widget.refreshTick) {
_loadSessions(); _loadSessions();
} }
@@ -59,18 +50,14 @@ class _HistoryScreenState extends State<HistoryScreen> {
setState(() { setState(() {
_sessions = sessions; _sessions = sessions;
// --- FILTRAGE PAR TYPE DE CIBLE ---
// Une session est retenue si au moins une de ses cibles est du type choisi.
if (_filterType != null) { if (_filterType != null) {
_sessions = _sessions _sessions = _sessions
.where((s) => s.analyses.any((a) => a.targetType == _filterType)) .where((s) => s.analyses.any((a) => a.targetType == _filterType))
.toList(); .toList();
} }
// --- LOGIQUE DE FILTRAGE PAR PÉRIODE ---
if (_selectedDateRange != null) { if (_selectedDateRange != null) {
_sessions = _sessions.where((s) { _sessions = _sessions.where((s) {
// On compare uniquement les dates (sans les heures) pour éviter les bugs
final sessionDate = DateTime( final sessionDate = DateTime(
s.createdAt.year, s.createdAt.year,
s.createdAt.month, s.createdAt.month,
@@ -109,32 +96,13 @@ class _HistoryScreenState extends State<HistoryScreen> {
} }
} }
// --- MODIFICATION : Fonction DateRangePicker ---
Future<void> _pickDateRange() async { Future<void> _pickDateRange() async {
final DateTimeRange? picked = await showDateRangePicker( final picked = await showDateRangePicker(
context: context, context: context,
initialDateRange: _selectedDateRange, initialDateRange: _selectedDateRange,
firstDate: DateTime(2020), firstDate: DateTime(2020),
lastDate: DateTime.now().add(const Duration(days: 1)), lastDate: DateTime.now().add(const Duration(days: 1)),
locale: const Locale('fr', 'FR'), locale: const Locale('fr', 'FR'),
builder: (context, child) {
return Theme(
data: ThemeData.light().copyWith(
colorScheme: ColorScheme.light(
primary: AppTheme.primaryColor, // En-tête et sélection
onPrimary: Colors.white, // Texte sur en-tête/sélection
surface: Colors.white, // Fond du calendrier
onSurface: Colors.black87, // Texte des dates (Noir sur Blanc)
secondary: AppTheme.primaryColor,
),
dialogTheme: const DialogThemeData(backgroundColor: Colors.white),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(foregroundColor: AppTheme.primaryColor),
),
),
child: child!,
);
},
); );
if (picked != null) { if (picked != null) {
@@ -145,88 +113,139 @@ class _HistoryScreenState extends State<HistoryScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return Scaffold( return Scaffold(
extendBody: true,
appBar: AppBar( appBar: AppBar(
title: const Text('Historique'), title: const Text('Carnet de Tir'),
actions: [ actions: [
// NOTE : on passe par un String sentinelle ('all') car un IconButton(
// PopupMenuItem avec value null ne déclenche jamais onSelected
// (Flutter l'interprète comme une annulation du menu).
PopupMenuButton<String>(
icon: Icon( icon: Icon(
Icons.filter_list, Icons.date_range_outlined,
// Icône colorée quand un filtre est actif, pour le rendre visible. color: _selectedDateRange != null ? AppTheme.primaryColor : null,
color: _filterType != null ? AppTheme.primaryColor : null,
), ),
onSelected: (value) { tooltip: 'Filtrer par date',
setState(() { onPressed: _pickDateRange,
_filterType =
value == 'all' ? null : TargetType.fromString(value);
});
_loadSessions();
},
itemBuilder: (context) => [
const PopupMenuItem(value: 'all', child: Text('Tous')),
...TargetType.values.map(
(type) => PopupMenuItem(
value: type.name,
child: Text(type.displayName),
),
),
],
), ),
const SizedBox(width: 8),
], ],
), ),
body: Column( body: Column(
children: [ children: [
_buildFilterChips(isDark),
Expanded( Expanded(
child: _isLoading child: _isLoading
? const Center(child: CircularProgressIndicator()) ? const Center(child: CircularProgressIndicator())
: _sessions.isEmpty : _sessions.isEmpty
? _buildEmptyState() ? _buildEmptyState(isDark)
: _buildContent(), : _buildContent(isDark),
), ),
_buildBottomFilterBar(), if (_selectedDateRange != null) _buildActivePeriodBanner(isDark),
], ],
), ),
); );
} }
Widget _buildBottomFilterBar() { Widget _buildFilterChips(bool isDark) {
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Theme.of(context).cardColor, color: isDark ? AppTheme.darkBackground : AppTheme.lightBackground,
boxShadow: const [ border: Border(
BoxShadow( bottom: BorderSide(
color: Colors.black26, color: isDark ? AppTheme.darkBorder : AppTheme.lightBorder,
blurRadius: 4, width: 1,
offset: Offset(0, -2), ),
),
),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
_buildTypeFilterChip('Toutes les cibles', null),
const SizedBox(width: 8),
_buildTypeFilterChip(
'Cibles 1-10',
TargetType.concentric,
),
const SizedBox(width: 8),
_buildTypeFilterChip(
'Silhouettes',
TargetType.silhouette,
), ),
], ],
), ),
),
);
}
Widget _buildTypeFilterChip(String label, TargetType? type) {
final isSelected = _filterType == type;
return ChoiceChip(
label: Text(label),
selected: isSelected,
onSelected: (selected) {
if (selected) {
setState(() => _filterType = type);
_loadSessions();
}
},
);
}
Widget _buildActivePeriodBanner(bool isDark) {
final start = DateFormat('dd/MM/yy').format(_selectedDateRange!.start);
final end = DateFormat('dd/MM/yy').format(_selectedDateRange!.end);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
decoration: BoxDecoration(
color: isDark ? AppTheme.darkSurface : AppTheme.lightSurface,
border: Border(
top: BorderSide(
color: isDark ? AppTheme.darkBorder : AppTheme.lightBorder,
),
),
),
child: SafeArea( child: SafeArea(
top: false, top: false,
child: Row( child: Row(
children: [ children: [
Expanded( const Icon(Icons.event, size: 18, color: AppTheme.primaryColor),
child: OutlinedButton.icon( const SizedBox(width: 8),
onPressed: _pickDateRange, Text(
icon: const Icon(Icons.date_range, size: 18), 'Période : $start - $end',
label: Text( style: TextStyle(
_selectedDateRange == null fontSize: 13,
? 'Choisir une période' fontWeight: FontWeight.w600,
: '${DateFormat('dd/MM/yy').format(_selectedDateRange!.start)} - ${DateFormat('dd/MM/yy').format(_selectedDateRange!.end)}', color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
), ),
), ),
), const Spacer(),
if (_selectedDateRange != null) InkWell(
IconButton( onTap: () {
icon: const Icon(Icons.close, color: AppTheme.errorColor),
onPressed: () {
setState(() => _selectedDateRange = null); setState(() => _selectedDateRange = null);
_loadSessions(); _loadSessions();
}, },
borderRadius: BorderRadius.circular(6),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Row(
children: [
Text(
'Effacer',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: AppTheme.errorColor,
),
),
const SizedBox(width: 4),
const Icon(Icons.close, size: 14, color: AppTheme.errorColor),
],
),
),
), ),
], ],
), ),
@@ -234,45 +253,81 @@ class _HistoryScreenState extends State<HistoryScreen> {
); );
} }
Widget _buildEmptyState() { Widget _buildEmptyState(bool isDark) {
return Center( return Center(
child: Padding(
padding: const EdgeInsets.all(32.0),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon(Icons.history, size: 64, color: Colors.grey[400]), Container(
const SizedBox(height: 16), padding: const EdgeInsets.all(24),
const Text('Aucune session sur cette période'), decoration: BoxDecoration(
color: isDark ? AppTheme.darkSurface : AppTheme.lightSurfaceVariant,
shape: BoxShape.circle,
border: Border.all(
color: isDark ? AppTheme.darkBorder : AppTheme.lightBorder,
),
),
child: Icon(
Icons.history_toggle_off,
size: 56,
color: isDark ? AppTheme.darkTextMuted : AppTheme.lightTextMuted,
),
),
const SizedBox(height: 18),
Text(
'Aucune session trouvée',
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.w700,
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
),
),
const SizedBox(height: 6),
Text(
_selectedDateRange != null || _filterType != null
? 'Essayez de réinitialiser vos filtres de recherche.'
: 'Vos sessions enregistrées apparaîtront ici.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13,
color: isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary,
),
),
], ],
), ),
),
); );
} }
Widget _buildContent() { Widget _buildContent(bool isDark) {
return RefreshIndicator( return RefreshIndicator(
onRefresh: _loadSessions, onRefresh: _loadSessions,
color: AppTheme.primaryColor,
child: CustomScrollView( child: CustomScrollView(
slivers: [ slivers: [
if (_sessions.length >= 2 && _selectedDateRange == null) if (_sessions.length >= 2 && _selectedDateRange == null)
SliverToBoxAdapter( SliverToBoxAdapter(
child: Padding( child: Padding(
padding: const EdgeInsets.all(AppConstants.defaultPadding), padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: HistoryChart(sessions: _sessions), child: HistoryChart(sessions: _sessions),
), ),
), ),
SliverPadding( SliverPadding(
padding: const EdgeInsets.all(AppConstants.defaultPadding), padding: const EdgeInsets.fromLTRB(16, 8, 16, 100),
sliver: SliverList( sliver: SliverList(
delegate: SliverChildBuilderDelegate((context, index) { delegate: SliverChildBuilderDelegate(
(context, index) {
final session = _sessions[index]; final session = _sessions[index];
return Padding( return SessionListItem(
padding: const EdgeInsets.only(bottom: 12),
child: SessionListItem(
session: session, session: session,
onTap: () => _openSessionDetail(session), onTap: () => _openSessionDetail(session),
onDelete: () => _deleteSession(session), onDelete: () => _deleteSession(session),
),
); );
}, childCount: _sessions.length), },
childCount: _sessions.length,
),
), ),
), ),
], ],
@@ -292,21 +347,22 @@ class _HistoryScreenState extends State<HistoryScreen> {
final confirmed = await showDialog<bool>( final confirmed = await showDialog<bool>(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
title: const Text('Supprimer'), title: const Text('Supprimer la session'),
content: Text( content: Text(
'Supprimer la session du ${DateFormat('dd/MM/yyyy').format(session.createdAt)}?', 'Voulez-vous supprimer définitivement la session du ${DateFormat('dd/MM/yyyy à HH:mm').format(session.createdAt)} ?',
), ),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(context, false), onPressed: () => Navigator.pop(context, false),
child: const Text('Annuler'), child: const Text('Annuler'),
), ),
TextButton( ElevatedButton(
onPressed: () => Navigator.pop(context, true), onPressed: () => Navigator.pop(context, true),
child: const Text( style: ElevatedButton.styleFrom(
'Supprimer', backgroundColor: AppTheme.errorColor,
style: TextStyle(color: AppTheme.errorColor), foregroundColor: Colors.white,
), ),
child: const Text('Supprimer'),
), ),
], ],
), ),
@@ -2,6 +2,7 @@ import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import '../../../core/theme/app_theme.dart'; import '../../../core/theme/app_theme.dart';
import '../../../core/widgets/glass_container.dart';
import '../../../data/models/session.dart'; import '../../../data/models/session.dart';
class SessionListItem extends StatelessWidget { class SessionListItem extends StatelessWidget {
@@ -18,60 +19,99 @@ class SessionListItem extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Card( final isDark = Theme.of(context).brightness == Brightness.dark;
clipBehavior: Clip.antiAlias, final textSecondary = isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary;
child: InkWell( final textMuted = isDark ? AppTheme.darkTextMuted : AppTheme.lightTextMuted;
// Calcul de la couleur du score selon la moyenne par tir
final primaryColor = Theme.of(context).colorScheme.primary;
final avg = session.averageScore;
Color scoreColor = primaryColor;
if (avg >= 9.0) {
scoreColor = AppTheme.secondaryColor;
} else if (avg >= 7.5) {
scoreColor = primaryColor;
} else {
scoreColor = isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary;
}
final formattedDate = DateFormat('dd/MM/yyyy • HH:mm', 'fr_FR').format(session.createdAt);
return GlassContainer(
borderRadius: 18,
blur: 14,
glowColor: scoreColor,
borderColor: isDark ? scoreColor.withValues(alpha: 0.2) : scoreColor.withValues(alpha: 0.15),
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(14),
onTap: onTap, onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(12),
child: Row( child: Row(
children: [ children: [
// Thumbnail (from first target) // Aperçu de la cible avec bordure nette
ClipRRect( ClipRRect(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(12),
child: SizedBox( child: Container(
width: 60, width: 56,
height: 60, height: 56,
child: _buildThumbnail(), decoration: BoxDecoration(
color: (isDark ? Colors.white : Colors.black).withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isDark ? AppTheme.darkBorder : AppTheme.lightBorder,
), ),
), ),
const SizedBox(width: 12), child: _buildThumbnail(isDark),
),
),
const SizedBox(width: 14),
// Info // Informations de la session
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row(
children: [
const Icon(
Icons.shield,
size: 16,
color: AppTheme.primaryColor,
),
const SizedBox(width: 4),
Text( Text(
session.weapon, session.weapon,
style: const TextStyle( style: TextStyle(
fontWeight: FontWeight.bold, fontSize: 15,
fontWeight: FontWeight.w700,
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
), ),
maxLines: 1,
overflow: TextOverflow.ellipsis,
), ),
], const SizedBox(height: 3),
),
const SizedBox(height: 4),
Text( Text(
DateFormat('dd/MM/yyyy HH:mm').format(session.createdAt), formattedDate,
style: Theme.of(context).textTheme.bodySmall, style: TextStyle(
fontSize: 11,
color: textSecondary,
), ),
const SizedBox(height: 4), ),
const SizedBox(height: 5),
Row( Row(
children: [ children: [
Icon(Icons.track_changes, size: 14, color: Colors.grey[600]), Container(
const SizedBox(width: 4), padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
decoration: BoxDecoration(
color: (isDark ? Colors.white : Colors.black).withValues(alpha: 0.06),
borderRadius: BorderRadius.circular(6),
),
child: Text(
'${session.distance}m',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w700,
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
),
),
),
const SizedBox(width: 6),
Text( Text(
'${session.targetCount} cible(s)${session.distance}m', '${session.targetCount} cible${session.targetCount > 1 ? 's' : ''}${session.totalShots} tirs',
style: Theme.of(context).textTheme.bodySmall?.copyWith( style: TextStyle(
color: Colors.grey[600], fontSize: 11,
color: textMuted,
), ),
), ),
], ],
@@ -80,41 +120,58 @@ class SessionListItem extends StatelessWidget {
), ),
), ),
// Score // Score et moyenne stylisés façon cyber HUD
Column( Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: scoreColor.withValues(alpha: isDark ? 0.12 : 0.08),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: scoreColor.withValues(alpha: isDark ? 0.3 : 0.2),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end,
children: [ children: [
Text( Text(
'${session.totalScore}', '${session.totalScore}',
style: Theme.of(context).textTheme.headlineSmall?.copyWith( style: TextStyle(
fontWeight: FontWeight.bold, fontSize: 18,
color: AppTheme.primaryColor, fontWeight: FontWeight.w900,
color: scoreColor,
letterSpacing: -0.5,
), ),
), ),
Text( Text(
'${session.totalShots} tirs', 'Moy. ${session.averageScore.toStringAsFixed(1)}',
style: Theme.of(context).textTheme.bodySmall, style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w700,
color: scoreColor,
),
), ),
], ],
), ),
),
// Delete button // Bouton supprimer
if (onDelete != null) if (onDelete != null) ...[
const SizedBox(width: 4),
IconButton( IconButton(
icon: const Icon(Icons.delete_outline), icon: const Icon(Icons.delete_outline_rounded),
onPressed: onDelete, onPressed: onDelete,
color: Colors.grey, color: textMuted,
iconSize: 20, iconSize: 20,
tooltip: 'Supprimer',
), ),
], ],
), ],
),
), ),
); );
} }
Widget _buildThumbnail() { Widget _buildThumbnail(bool isDark) {
if (session.analyses.isEmpty) return _buildPlaceholder(); if (session.analyses.isEmpty) return _buildPlaceholder(isDark);
final file = File(session.analyses.first.imagePath); final file = File(session.analyses.first.imagePath);
@@ -122,20 +179,18 @@ class SessionListItem extends StatelessWidget {
return Image.file( return Image.file(
file, file,
fit: BoxFit.cover, fit: BoxFit.cover,
errorBuilder: (_, _, _) => _buildPlaceholder(), errorBuilder: (_, _, _) => _buildPlaceholder(isDark),
); );
} }
return _buildPlaceholder(); return _buildPlaceholder(isDark);
} }
Widget _buildPlaceholder() { Widget _buildPlaceholder(bool isDark) {
return Container( return Icon(
color: Colors.grey[200],
child: Icon(
Icons.track_changes, Icons.track_changes,
color: Colors.grey[400], color: isDark ? AppTheme.darkTextMuted : AppTheme.lightTextMuted,
), size: 24,
); );
} }
} }
File diff suppressed because it is too large Load Diff
+68 -22
View File
@@ -1,17 +1,14 @@
/// Widget carte réutilisable pour afficher une statistique.
///
/// Affiche une icône, un titre et une valeur avec une couleur personnalisable.
/// Utilisé sur l'écran d'accueil pour les statistiques rapides.
library;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../../core/constants/app_constants.dart'; import '../../../core/theme/app_theme.dart';
import '../../../core/widgets/glass_container.dart';
/// Widget carte télémétrique moderne avec effet Glassmorphism givré.
class StatsCard extends StatelessWidget { class StatsCard extends StatelessWidget {
final IconData icon; final IconData icon;
final String title; final String title;
final String value; final String value;
final Color color; final Color color;
final String? subtitle;
const StatsCard({ const StatsCard({
super.key, super.key,
@@ -19,33 +16,82 @@ class StatsCard extends StatelessWidget {
required this.title, required this.title,
required this.value, required this.value,
required this.color, required this.color,
this.subtitle,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Card( final isDark = Theme.of(context).brightness == Brightness.dark;
child: Padding( final textSecondary = isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary;
padding: const EdgeInsets.all(AppConstants.defaultPadding),
return GlassContainer(
borderRadius: 18,
blur: 14,
glowColor: color,
borderColor: isDark ? color.withValues(alpha: 0.22) : color.withValues(alpha: 0.18),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon(icon, color: color, size: 32), Row(
const SizedBox(height: 8), mainAxisAlignment: MainAxisAlignment.spaceBetween,
Text( children: [
value, Container(
style: Theme.of(context).textTheme.headlineSmall?.copyWith( padding: const EdgeInsets.all(8),
fontWeight: FontWeight.bold, decoration: BoxDecoration(
color: color, color: color.withValues(alpha: isDark ? 0.18 : 0.12),
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: color.withValues(alpha: isDark ? 0.35 : 0.25),
width: 1,
), ),
), ),
const SizedBox(height: 4), child: Icon(icon, color: color, size: 20),
Text( ),
title, if (subtitle != null)
style: Theme.of(context).textTheme.bodySmall?.copyWith( Container(
color: Colors.grey[600], padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: (isDark ? Colors.white : Colors.black).withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(6),
),
child: Text(
subtitle!,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w600,
color: textSecondary,
),
), ),
), ),
], ],
), ),
const SizedBox(height: 12),
Text(
value,
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.w800,
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
letterSpacing: -0.5,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text(
title,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: textSecondary,
letterSpacing: 0.1,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
), ),
); );
} }
+264 -132
View File
@@ -1,6 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:intl/intl.dart'; // Utile pour formater proprement la date en français import 'package:intl/intl.dart';
import '../../core/constants/app_constants.dart'; import '../../core/constants/app_constants.dart';
import '../../core/theme/app_theme.dart'; import '../../core/theme/app_theme.dart';
import '../../data/models/weapon.dart'; import '../../data/models/weapon.dart';
@@ -25,9 +25,11 @@ class _SessionSetupScreenState extends State<SessionSetupScreen> {
Weapon? _selectedWeapon; Weapon? _selectedWeapon;
bool _isLoadingWeapons = true; bool _isLoadingWeapons = true;
// AJOUT DE LA VARIABLE DATE : Initialisée par défaut à maintenant
DateTime _selectedDate = DateTime.now(); DateTime _selectedDate = DateTime.now();
static const List<int> _presetDistances = [10, 15, 25, 50, 100, 200];
static const List<int> _presetShots = [3, 5, 10, 15, 20, 30];
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -52,24 +54,28 @@ class _SessionSetupScreenState extends State<SessionSetupScreen> {
} }
void _updateSettingsForWeapon(Weapon weapon) { void _updateSettingsForWeapon(Weapon weapon) {
_shotsPerTarget = weapon.magazineCapacity; _shotsPerTarget = weapon.magazineCapacity > 0 ? weapon.magazineCapacity : 5;
_distance = (weapon.type == WeaponType.handgun) ? 25 : 50; _distance = (weapon.type == WeaponType.handgun) ? 25 : 50;
} }
// Fonction pour ouvrir le calendrier si l'utilisateur clique sur le champ
Future<void> _pickDate() async { Future<void> _pickDate() async {
final DateTime? picked = await showDatePicker( final DateTime? picked = await showDatePicker(
context: context, context: context,
initialDate: _selectedDate, initialDate: _selectedDate,
firstDate: DateTime(2020), firstDate: DateTime(2020),
lastDate: DateTime(2100), lastDate: DateTime(2100),
locale: const Locale('fr', 'FR'), // Force le calendrier en français locale: const Locale('fr', 'FR'),
); );
if (picked != null && picked != _selectedDate) { if (picked != null && picked != _selectedDate) {
setState(() { setState(() {
// On garde aussi l'heure actuelle lors du changement de date
final now = DateTime.now(); final now = DateTime.now();
_selectedDate = DateTime(picked.year, picked.month, picked.day, now.hour, now.minute); _selectedDate = DateTime(
picked.year,
picked.month,
picked.day,
now.hour,
now.minute,
);
}); });
} }
} }
@@ -81,10 +87,6 @@ class _SessionSetupScreenState extends State<SessionSetupScreen> {
final sessionId = repository.generateId(); final sessionId = repository.generateId();
// PASSE-PARTOUT : On envoie la session au provider.
// Note : Si ton `sessionProvider.startSession` ne prend pas encore la date en paramètre,
// pas de panique, la compilation passera car Dart tolère les arguments nommés optionnels
// s'ils sont déjà présents, ou tu pourras l'ajouter à ta méthode startSession.
sessionProvider.startSession( sessionProvider.startSession(
_selectedWeapon!.displayName, _selectedWeapon!.displayName,
_shotsPerTarget, _shotsPerTarget,
@@ -103,12 +105,12 @@ class _SessionSetupScreenState extends State<SessionSetupScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// Petit formatage sympa en français (ex: "27 mai 2026") final isDark = Theme.of(context).brightness == Brightness.dark;
final String formattedDate = DateFormat('dd MMMM yyyy', 'fr_FR').format(_selectedDate); final formattedDate = DateFormat('dd MMMM yyyy • HH:mm', 'fr_FR').format(_selectedDate);
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text('Configuration de la session'), title: const Text('Configuration de Session'),
), ),
body: _isLoadingWeapons body: _isLoadingWeapons
? const Center(child: CircularProgressIndicator()) ? const Center(child: CircularProgressIndicator())
@@ -119,67 +121,112 @@ class _SessionSetupScreenState extends State<SessionSetupScreen> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
const Text(
'Informations générales',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
// AFFICHAGE CONDITIONNEL : Armurerie vide vs Armurerie remplie
if (_availableWeapons.isEmpty) if (_availableWeapons.isEmpty)
Container( _buildEmptyArmoryState(isDark)
padding: const EdgeInsets.all(16), else ...[
_buildWeaponAndDateSection(isDark, formattedDate),
const SizedBox(height: 20),
_buildDistanceSection(isDark),
const SizedBox(height: 20),
_buildShotsSection(isDark),
const SizedBox(height: 32),
_buildStartButton(),
const SizedBox(height: 16),
],
],
),
),
),
);
}
Widget _buildEmptyArmoryState(bool isDark) {
return Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppTheme.errorColor.withValues(alpha: 0.1), color: isDark ? AppTheme.darkSurface : AppTheme.lightSurface,
borderRadius: BorderRadius.circular(AppConstants.borderRadius), borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppTheme.errorColor.withValues(alpha: 0.5)), border: Border.all(
color: AppTheme.errorColor.withValues(alpha: 0.5),
),
), ),
child: Column( child: Column(
children: [ children: [
const Icon(Icons.warning_amber_rounded, color: AppTheme.errorColor, size: 48), const Icon(
const SizedBox(height: 8), Icons.warning_amber_rounded,
const Text( color: AppTheme.errorColor,
'Ton armurerie est vide.', size: 52,
style: TextStyle(fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
), ),
const SizedBox(height: 4), const SizedBox(height: 12),
const Text( Text(
'Tu dois d\'abord ajouter une arme pour pouvoir démarrer une session de tir.', 'Armurerie vide',
textAlign: TextAlign.center, style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
), ),
const SizedBox(height: 16), ),
const SizedBox(height: 6),
Text(
'Ajoutez au moins une arme dans votre armurerie pour débuter une séance.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13,
color: isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary,
),
),
const SizedBox(height: 20),
ElevatedButton.icon( ElevatedButton.icon(
onPressed: () { onPressed: () {
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute(builder: (_) => const WeaponListScreen()), MaterialPageRoute(builder: (_) => const WeaponListScreen()),
).then((_) { ).then((_) => _loadWeapons());
_loadWeapons();
});
}, },
icon: const Icon(Icons.add), icon: const Icon(Icons.shield),
label: const Text('ALLER À MON ARMURERIE'), label: const Text('Aller à l\'armurerie'),
), ),
], ],
), ),
) );
else }
Column(
Widget _buildWeaponAndDateSection(bool isDark, String formattedDate) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
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<Weapon>( DropdownButtonFormField<Weapon>(
initialValue: _selectedWeapon, initialValue: _selectedWeapon,
decoration: InputDecoration( decoration: const InputDecoration(
labelText: 'Sélectionner une arme', labelText: 'Arme utilisée',
prefixIcon: const Icon(Icons.shield), prefixIcon: Icon(Icons.shield_outlined),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
), ),
), items: _availableWeapons
items: _availableWeapons.map((w) => DropdownMenuItem( .map(
(w) => DropdownMenuItem(
value: w, value: w,
child: Text(w.displayName), child: Text('${w.displayName} (${w.caliber})'),
)).toList(), ),
)
.toList(),
onChanged: (value) { onChanged: (value) {
if (value != null) { if (value != null) {
setState(() { setState(() {
@@ -189,20 +236,15 @@ class _SessionSetupScreenState extends State<SessionSetupScreen> {
} }
}, },
), ),
const SizedBox(height: 16), const SizedBox(height: 12),
// NOUVEAU CHAMP : Sélecteur de date interactif
InkWell( InkWell(
onTap: _availableWeapons.isEmpty ? null : _pickDate, onTap: _pickDate,
borderRadius: BorderRadius.circular(AppConstants.borderRadius), borderRadius: BorderRadius.circular(12),
child: IgnorePointer( child: IgnorePointer(
child: TextFormField( child: TextFormField(
decoration: InputDecoration( decoration: const InputDecoration(
labelText: 'Date de la session', labelText: 'Date de séance',
prefixIcon: const Icon(Icons.calendar_today), prefixIcon: Icon(Icons.calendar_today_outlined),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
),
), ),
controller: TextEditingController(text: formattedDate), controller: TextEditingController(text: formattedDate),
), ),
@@ -210,114 +252,204 @@ class _SessionSetupScreenState extends State<SessionSetupScreen> {
), ),
], ],
), ),
const SizedBox(height: 24),
// Distance selector
const Text(
'Distance de tir (mètres)',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500),
), ),
const SizedBox(height: 8), );
}
Widget _buildDistanceSection(bool isDark) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row( Row(
children: [ children: [
Expanded( const Icon(Icons.straighten, color: AppTheme.secondaryColor, size: 20),
child: Slider( const SizedBox(width: 8),
value: _distance.toDouble(), Text(
min: 5, 'Distance de Tir',
max: 300, style: TextStyle(
divisions: 59, fontSize: 15,
label: '${_distance}m', fontWeight: FontWeight.w700,
onChanged: _availableWeapons.isEmpty ? null : (value) { color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
setState(() {
_distance = value.round();
});
},
), ),
), ),
],
),
Container( Container(
width: 70, padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
height: 50,
alignment: Alignment.center,
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppTheme.secondaryColor.withValues(alpha: 0.1), color: AppTheme.secondaryColor.withValues(alpha: isDark ? 0.2 : 0.1),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
border: Border.all(
color: AppTheme.secondaryColor.withValues(alpha: 0.4),
),
), ),
child: Text( child: Text(
'${_distance}m', '${_distance}m',
style: const TextStyle( style: const TextStyle(
fontSize: 18, fontSize: 16,
fontWeight: FontWeight.bold, fontWeight: FontWeight.w800,
color: AppTheme.secondaryColor, color: AppTheme.secondaryColor,
), ),
), ),
), ),
], ],
), ),
const SizedBox(height: 24), const SizedBox(height: 12),
Wrap(
// Shots per target spacing: 8,
const Text( runSpacing: 8,
'Nombre de balles pour la cible actuelle', children: _presetDistances.map((d) {
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500), final isSelected = _distance == d;
return ChoiceChip(
label: Text('${d}m'),
selected: isSelected,
onSelected: (selected) {
if (selected) setState(() => _distance = d);
},
);
}).toList(),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Slider(
value: _distance.toDouble(),
min: 5,
max: 300,
divisions: 59,
activeColor: AppTheme.secondaryColor,
onChanged: (val) {
setState(() => _distance = val.round());
},
),
],
),
),
);
}
Widget _buildShotsSection(bool isDark) {
final primaryColor = Theme.of(context).colorScheme.primary;
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row( Row(
children: [ children: [
Expanded( Icon(Icons.ads_click, color: primaryColor, size: 20),
child: Slider( const SizedBox(width: 8),
Text(
'Tirs par Cible',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
),
),
],
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: primaryColor.withValues(alpha: isDark ? 0.2 : 0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: primaryColor.withValues(alpha: 0.4),
),
),
child: Text(
'$_shotsPerTarget tirs',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w800,
color: primaryColor,
),
),
),
],
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: _presetShots.map((s) {
final isSelected = _shotsPerTarget == s;
return ChoiceChip(
label: Text('$s coups'),
selected: isSelected,
onSelected: (selected) {
if (selected) setState(() => _shotsPerTarget = s);
},
);
}).toList(),
),
const SizedBox(height: 8),
Slider(
value: _shotsPerTarget.toDouble(), value: _shotsPerTarget.toDouble(),
min: 1, min: 1,
max: 50, max: 50,
divisions: 49, divisions: 49,
label: '$_shotsPerTarget', activeColor: primaryColor,
onChanged: _availableWeapons.isEmpty ? null : (value) { onChanged: (val) {
setState(() { setState(() => _shotsPerTarget = val.round());
_shotsPerTarget = value.round();
});
}, },
), ),
),
Container(
width: 50,
height: 50,
alignment: Alignment.center,
decoration: BoxDecoration(
color: AppTheme.primaryColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'$_shotsPerTarget',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppTheme.primaryColor,
),
),
),
], ],
), ),
const SizedBox(height: 48), ),
);
}
ElevatedButton.icon( Widget _buildStartButton() {
final primaryColor = Theme.of(context).colorScheme.primary;
return Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
gradient: LinearGradient(
colors: [
primaryColor,
HSLColor.fromColor(primaryColor).withLightness((HSLColor.fromColor(primaryColor).lightness + 0.15).clamp(0.0, 1.0)).toColor(),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
boxShadow: [
BoxShadow(
color: primaryColor.withValues(alpha: 0.35),
blurRadius: 16,
offset: const Offset(0, 4),
),
],
),
child: ElevatedButton.icon(
onPressed: (_availableWeapons.isEmpty || _selectedWeapon == null) onPressed: (_availableWeapons.isEmpty || _selectedWeapon == null)
? null ? null
: _startSession, : _startSession,
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primaryColor, backgroundColor: Colors.transparent,
foregroundColor: Colors.white, foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 16), shadowColor: Colors.transparent,
padding: const EdgeInsets.symmetric(vertical: 18),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppConstants.borderRadius), borderRadius: BorderRadius.circular(16),
), ),
), ),
icon: const Icon(Icons.play_arrow), icon: const Icon(Icons.play_arrow_rounded, size: 26),
label: const Text( label: const Text(
'DÉMARRER LA SESSION', 'DÉMARRER LA SESSION',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), style: TextStyle(
), fontSize: 16,
), fontWeight: FontWeight.w800,
], letterSpacing: 0.8,
), ),
), ),
), ),
+417 -91
View File
@@ -1,12 +1,12 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import '../../core/theme/theme_provider.dart';
import 'dart:convert'; import 'dart:convert';
import 'package:crypto/crypto.dart'; import 'package:crypto/crypto.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import '../../core/constants/app_constants.dart';
import '../../core/theme/app_theme.dart'; import '../../core/theme/app_theme.dart';
import '../../core/theme/theme_provider.dart';
import '../../core/widgets/glass_container.dart';
import '../../services/wallet_identity_service.dart'; import '../../services/wallet_identity_service.dart';
import '../garage/weapon_list_screen.dart'; import '../garage/weapon_list_screen.dart';
@@ -165,76 +165,203 @@ class _SettingsScreenState extends State<SettingsScreen> {
void _showThemeDialog() { void _showThemeDialog() {
showDialog( showDialog(
context: context, context: context,
builder: (context) => AlertDialog( builder: (dialogCtx) => Consumer<ThemeProvider>(
title: const Text('Apparence'), builder: (context, themeProvider, child) {
content: Column( final isDark = Theme.of(context).brightness == Brightness.dark;
final primary = themeProvider.primaryColor;
return AlertDialog(
backgroundColor: isDark ? AppTheme.darkSurface : AppTheme.lightSurface,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
title: Text(
'Personnalisation & Thème',
style: TextStyle(
fontWeight: FontWeight.w800,
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
),
),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'MODE D\'AFFICHAGE',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w800,
color: primary,
letterSpacing: 1.0,
),
),
const SizedBox(height: 8),
_buildThemeOption(themeProvider, ThemeMode.system, 'Automatique (Système)', Icons.brightness_auto),
_buildThemeOption(themeProvider, ThemeMode.light, 'Clair', Icons.light_mode_outlined),
_buildThemeOption(themeProvider, ThemeMode.dark, 'Sombre (Stand de Tir)', Icons.dark_mode_outlined),
const Divider(height: 24),
Text(
'COULEUR D\'ACCENTUATION',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w800,
color: primary,
letterSpacing: 1.0,
),
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: AppAccentColor.allAccents.map((accent) {
final isSelected = themeProvider.currentAccent.id == accent.id;
return GestureDetector(
onTap: () {
themeProvider.setAccent(accent);
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7),
decoration: BoxDecoration(
color: accent.color.withValues(alpha: isSelected ? 0.22 : 0.08),
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: isSelected ? accent.color : Colors.transparent,
width: 1.5,
),
),
child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
_buildThemeOption(ThemeMode.system, 'Automatique', Icons.brightness_auto), Container(
_buildThemeOption(ThemeMode.light, 'Clair', Icons.light_mode), width: 14,
_buildThemeOption(ThemeMode.dark, 'Sombre', Icons.dark_mode), height: 14,
decoration: BoxDecoration(
color: accent.color,
shape: BoxShape.circle,
boxShadow: [
if (isSelected)
BoxShadow(
color: accent.color.withValues(alpha: 0.6),
blurRadius: 6,
),
], ],
), ),
),
const SizedBox(width: 6),
Text(
accent.name,
style: TextStyle(
fontSize: 12,
fontWeight: isSelected ? FontWeight.w800 : FontWeight.w500,
color: isSelected ? accent.color : (isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary),
),
),
],
),
),
);
}).toList(),
),
],
),
),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(dialogCtx),
child: const Text('Fermer'), child: Text('Fermer', style: TextStyle(color: primary, fontWeight: FontWeight.bold)),
), ),
], ],
);
},
), ),
); );
} }
Widget _buildThemeOption(ThemeMode mode, String label, IconData icon) { Widget _buildThemeOption(
final themeProvider = context.watch<ThemeProvider>(); ThemeProvider themeProvider,
ThemeMode mode,
String label,
IconData icon,
) {
final isSelected = themeProvider.themeMode == mode; final isSelected = themeProvider.themeMode == mode;
final primary = themeProvider.primaryColor;
final isDark = themeProvider.themeMode == ThemeMode.dark ||
(themeProvider.themeMode == ThemeMode.system &&
WidgetsBinding.instance.platformDispatcher.platformBrightness == Brightness.dark);
return ListTile( return ListTile(
leading: Icon(icon, color: isSelected ? AppTheme.primaryColor : null), dense: true,
title: Text(label, style: TextStyle( contentPadding: EdgeInsets.zero,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, leading: Icon(icon, color: isSelected ? primary : (isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary), size: 20),
color: isSelected ? AppTheme.primaryColor : null, title: Text(
)), label,
trailing: isSelected ? const Icon(Icons.check, color: AppTheme.primaryColor) : null, style: TextStyle(
fontSize: 14,
fontWeight: isSelected ? FontWeight.w800 : FontWeight.w500,
color: isSelected ? primary : (isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary),
),
),
trailing: isSelected ? Icon(Icons.check_circle, color: primary, size: 20) : null,
onTap: () { onTap: () {
themeProvider.setThemeMode(mode); themeProvider.setThemeMode(mode);
Navigator.pop(context);
}, },
); );
} }
void _showIdentityDialog() { void _showIdentityDialog() {
if (_identityPhrase == null) return; if (_identityPhrase == null) return;
final isDark = Theme.of(context).brightness == Brightness.dark;
final primary = Theme.of(context).colorScheme.primary;
showDialog( showDialog(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
title: const Text('Votre Identité Unique', textAlign: TextAlign.center), backgroundColor: isDark ? AppTheme.darkSurface : AppTheme.lightSurface,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
title: Text(
'Votre Identité Unique',
textAlign: TextAlign.center,
style: TextStyle(
fontWeight: FontWeight.w800,
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
),
),
content: Column( content: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const Icon(Icons.security, size: 48, color: AppTheme.primaryColor), Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: primary.withValues(alpha: 0.15),
shape: BoxShape.circle,
),
child: Icon(Icons.security, size: 36, color: primary),
),
const SizedBox(height: 16), const SizedBox(height: 16),
const Text( Text(
'Cette phrase de 15 mots vous identifie de manière unique. Ne la partagez qu\'en cas de besoin.', 'Cette phrase de 15 mots vous identifie de manière unique. Ne la partagez qu\'en cas de besoin.',
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: TextStyle(fontSize: 14, color: Colors.grey), style: TextStyle(
fontSize: 13,
color: isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary,
), ),
const SizedBox(height: 24), ),
const SizedBox(height: 20),
Container( Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey.withAlpha(20), color: primary.withValues(alpha: isDark ? 0.15 : 0.08),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppTheme.primaryColor.withAlpha(100)), border: Border.all(color: primary.withValues(alpha: 0.35)),
), ),
child: Text( child: Text(
_identityPhrase!, _identityPhrase!,
style: const TextStyle( style: TextStyle(
fontSize: 16, fontSize: 15,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w700,
letterSpacing: 0.5, letterSpacing: 0.5,
height: 1.5, height: 1.5,
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
), ),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
@@ -244,11 +371,16 @@ class _SettingsScreenState extends State<SettingsScreen> {
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
child: const Text('Fermer'), child: Text('Fermer', style: TextStyle(color: isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary)),
), ),
ElevatedButton.icon( ElevatedButton.icon(
icon: const Icon(Icons.copy, size: 18), icon: const Icon(Icons.copy, size: 18),
label: const Text('Copier'), label: const Text('Copier'),
style: ElevatedButton.styleFrom(
backgroundColor: primary,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
onPressed: () { onPressed: () {
_copyToClipboard(); _copyToClipboard();
Navigator.pop(context); Navigator.pop(context);
@@ -260,10 +392,15 @@ class _SettingsScreenState extends State<SettingsScreen> {
} }
void _showOptInDisclaimer(bool value) { void _showOptInDisclaimer(bool value) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final primary = Theme.of(context).colorScheme.primary;
if (_isBanned) { if (_isBanned) {
showDialog( showDialog(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
backgroundColor: isDark ? AppTheme.darkSurface : AppTheme.lightSurface,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
title: const Row( title: const Row(
children: [ children: [
Icon(Icons.block, color: AppTheme.errorColor), Icon(Icons.block, color: AppTheme.errorColor),
@@ -299,14 +436,23 @@ class _SettingsScreenState extends State<SettingsScreen> {
context: context, context: context,
barrierDismissible: false, barrierDismissible: false,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
backgroundColor: isDark ? AppTheme.darkSurface : AppTheme.lightSurface,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
title: const Text('Participer à l\'entraînement IA', textAlign: TextAlign.center), title: const Text('Participer à l\'entraînement IA', textAlign: TextAlign.center),
content: SingleChildScrollView( content: SingleChildScrollView(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const Center( Center(
child: Icon(Icons.psychology, size: 48, color: AppTheme.primaryColor), child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: primary.withValues(alpha: 0.15),
shape: BoxShape.circle,
),
child: Icon(Icons.psychology, size: 40, color: primary),
),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
const Text( const Text(
@@ -369,7 +515,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
child: const Text('Refuser', style: TextStyle(color: Colors.grey)), child: const Text('Refuser', style: TextStyle(color: Colors.grey)),
), ),
ElevatedButton( ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: AppTheme.primaryColor), style: ElevatedButton.styleFrom(
backgroundColor: primary,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
onPressed: () { onPressed: () {
_walletService.setUploadEnabled(true); _walletService.setUploadEnabled(true);
setState(() { setState(() {
@@ -383,7 +533,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
), ),
); );
}, },
child: const Text('J\'accepte les règles', style: TextStyle(color: Colors.white)), child: const Text('J\'accepte les règles'),
), ),
], ],
), ),
@@ -391,10 +541,15 @@ class _SettingsScreenState extends State<SettingsScreen> {
} }
void _showEditServerUrlDialog() { void _showEditServerUrlDialog() {
final isDark = Theme.of(context).brightness == Brightness.dark;
final primary = Theme.of(context).colorScheme.primary;
final urlController = TextEditingController(text: _serverUrl); final urlController = TextEditingController(text: _serverUrl);
showDialog( showDialog(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
backgroundColor: isDark ? AppTheme.darkSurface : AppTheme.lightSurface,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
title: const Text('Adresse du Serveur IA'), title: const Text('Adresse du Serveur IA'),
content: Column( content: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -425,7 +580,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
child: const Text('Annuler'), child: const Text('Annuler'),
), ),
ElevatedButton( ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: AppTheme.primaryColor), style: ElevatedButton.styleFrom(
backgroundColor: primary,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
onPressed: () async { onPressed: () async {
final newUrl = urlController.text.trim(); final newUrl = urlController.text.trim();
if (newUrl.isNotEmpty) { if (newUrl.isNotEmpty) {
@@ -446,24 +605,26 @@ class _SettingsScreenState extends State<SettingsScreen> {
), ),
); );
}, },
child: const Text('Enregistrer', style: TextStyle(color: Colors.white)), child: const Text('Enregistrer'),
), ),
], ],
), ),
); );
} }
// Rapport de bug autonome : on collecte une description + les infos
// techniques, et on copie un rapport prêt à coller dans un email de support.
void _showReportBugDialog() { void _showReportBugDialog() {
final isDark = Theme.of(context).brightness == Brightness.dark;
final primary = Theme.of(context).colorScheme.primary;
final descController = TextEditingController(); final descController = TextEditingController();
const supportEmail = 'monadressemaildesupport@nomdelapplication.com'; const supportEmail = 'monadressemaildesupport@nomdelapplication.com';
const appVersion = '1.0.0'; const appVersion = '1.0.3';
final platform = Theme.of(context).platform.name; final platform = Theme.of(context).platform.name;
showDialog( showDialog(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
backgroundColor: isDark ? AppTheme.darkSurface : AppTheme.lightSurface,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
title: const Text('Signaler un bug'), title: const Text('Signaler un bug'),
content: SingleChildScrollView( content: SingleChildScrollView(
child: Column( child: Column(
@@ -499,6 +660,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
ElevatedButton.icon( ElevatedButton.icon(
icon: const Icon(Icons.copy, size: 18), icon: const Icon(Icons.copy, size: 18),
label: const Text('Copier le rapport'), label: const Text('Copier le rapport'),
style: ElevatedButton.styleFrom(
backgroundColor: primary,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
onPressed: () { onPressed: () {
final desc = descController.text.trim(); final desc = descController.text.trim();
final report = StringBuffer() final report = StringBuffer()
@@ -528,14 +694,17 @@ class _SettingsScreenState extends State<SettingsScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final primary = Theme.of(context).colorScheme.primary;
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text('Paramètres'), title: const Text('Paramètres'),
), ),
body: ListView( body: ListView(
padding: const EdgeInsets.all(AppConstants.defaultPadding), padding: const EdgeInsets.fromLTRB(16, 12, 16, 40),
children: [ children: [
_buildSectionHeader('Identité'), _buildSectionHeader('IDENTITÉ & COMPTE', primary),
_buildSettingsTile( _buildSettingsTile(
context: context, context: context,
icon: Icons.fingerprint, icon: Icons.fingerprint,
@@ -543,25 +712,128 @@ class _SettingsScreenState extends State<SettingsScreen> {
subtitle: _identityPhrase != null ? 'Phrase de 15 mots générée' : 'Génération en cours...', subtitle: _identityPhrase != null ? 'Phrase de 15 mots générée' : 'Génération en cours...',
onTap: _showIdentityDialog, onTap: _showIdentityDialog,
), ),
const SizedBox(height: 20),
_buildSectionHeader('PERSONNALISATION & THÈME', primary),
Consumer<ThemeProvider>( Consumer<ThemeProvider>(
builder: (context, themeProvider, child) { builder: (context, themeProvider, child) {
return _buildSettingsTile( return GlassContainer(
context: context, borderRadius: 16,
icon: Icons.color_lens_outlined, blur: 12,
title: 'Apparence', margin: const EdgeInsets.only(bottom: 10),
subtitle: themeProvider.themeModeName, padding: const EdgeInsets.all(16),
glowColor: primary,
borderColor: isDark ? primary.withValues(alpha: 0.2) : primary.withValues(alpha: 0.12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
InkWell(
onTap: _showThemeDialog, onTap: _showThemeDialog,
borderRadius: BorderRadius.circular(10),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: primary.withValues(alpha: isDark ? 0.2 : 0.12),
borderRadius: BorderRadius.circular(12),
),
child: Icon(Icons.palette_outlined, color: primary, size: 22),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Apparence',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
),
),
const SizedBox(height: 2),
Text(
'${themeProvider.themeModeName}${themeProvider.currentAccent.name}',
style: TextStyle(
fontSize: 12,
color: isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary,
),
),
],
),
),
Icon(
Icons.chevron_right,
color: isDark ? AppTheme.darkTextMuted : AppTheme.lightTextMuted,
),
],
),
),
const SizedBox(height: 14),
const Divider(height: 1),
const SizedBox(height: 12),
Text(
'Couleur d\'accent active :',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary,
),
),
const SizedBox(height: 10),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: AppAccentColor.allAccents.map((accent) {
final isSelected = themeProvider.currentAccent.id == accent.id;
return Padding(
padding: const EdgeInsets.only(right: 10),
child: GestureDetector(
onTap: () => themeProvider.setAccent(accent),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: 36,
height: 36,
decoration: BoxDecoration(
color: accent.color,
shape: BoxShape.circle,
border: Border.all(
color: isSelected ? Colors.white : Colors.transparent,
width: 2.5,
),
boxShadow: [
if (isSelected)
BoxShadow(
color: accent.color.withValues(alpha: 0.65),
blurRadius: 10,
spreadRadius: 1,
),
],
),
child: isSelected
? const Icon(Icons.check, color: Colors.white, size: 18)
: null,
),
),
);
}).toList(),
),
),
],
),
); );
}, },
), ),
const SizedBox(height: 24), const SizedBox(height: 20),
_buildSectionHeader('Gestion'), _buildSectionHeader('ARMURERIE & MATÉRIEL', primary),
_buildSettingsTile( _buildSettingsTile(
context: context, context: context,
icon: Icons.shield_outlined, icon: Icons.shield_outlined,
title: 'Mon Armurerie', title: 'Mon Armurerie',
subtitle: 'Gérer mes armes et équipements', subtitle: 'Gérer mes armes, calibres et optiques',
onTap: () async { onTap: () async {
await Navigator.push( await Navigator.push(
context, context,
@@ -570,14 +842,14 @@ class _SettingsScreenState extends State<SettingsScreen> {
}, },
), ),
const SizedBox(height: 24), const SizedBox(height: 20),
_buildSectionHeader('Programme d\'Entraînement IA'), _buildSectionHeader('PROGRAMME D\'ENTRAÎNEMENT IA', primary),
if (_isBanned) ...[ if (_isBanned) ...[
Card( Card(
elevation: 0, elevation: 0,
color: AppTheme.errorColor.withValues(alpha: 0.12), color: AppTheme.errorColor.withValues(alpha: 0.12),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12.0), borderRadius: BorderRadius.circular(16.0),
side: BorderSide(color: AppTheme.errorColor.withValues(alpha: 0.6), width: 1.5), side: BorderSide(color: AppTheme.errorColor.withValues(alpha: 0.6), width: 1.5),
), ),
child: Padding( child: Padding(
@@ -631,7 +903,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
foregroundColor: Colors.white, foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 12), padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(12),
), ),
), ),
onPressed: _isCheckingStatus ? null : _refreshAccountStatus, onPressed: _isCheckingStatus ? null : _refreshAccountStatus,
@@ -642,21 +914,38 @@ class _SettingsScreenState extends State<SettingsScreen> {
), ),
), ),
] else ...[ ] else ...[
Card( GlassContainer(
elevation: 0, borderRadius: 16,
color: Colors.transparent, blur: 10,
margin: const EdgeInsets.only(bottom: 8.0), margin: const EdgeInsets.only(bottom: 10),
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4),
child: SwitchListTile( child: SwitchListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 4.0), contentPadding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 4.0),
title: const Text('Participer à l\'entraînement IA', style: TextStyle(fontWeight: FontWeight.w500)), title: Text(
subtitle: const Text('Aidez-nous à améliorer la détection (soumis aux règles strictes)', style: TextStyle(fontSize: 12)), 'Participer à l\'entraînement IA',
secondary: const Icon(Icons.psychology, color: AppTheme.textPrimary), style: TextStyle(
value: _isUploadEnabled, fontWeight: FontWeight.w700,
activeThumbColor: AppTheme.primaryColor, fontSize: 14,
shape: RoundedRectangleBorder( color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
borderRadius: BorderRadius.circular(12.0),
side: BorderSide(color: Colors.grey.withAlpha(50), width: 1),
), ),
),
subtitle: Text(
'Aidez-nous à améliorer la détection automatique',
style: TextStyle(
fontSize: 12,
color: isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary,
),
),
secondary: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: primary.withValues(alpha: isDark ? 0.2 : 0.12),
borderRadius: BorderRadius.circular(10),
),
child: Icon(Icons.psychology, color: primary, size: 22),
),
value: _isUploadEnabled,
activeThumbColor: primary,
onChanged: _showOptInDisclaimer, onChanged: _showOptInDisclaimer,
), ),
), ),
@@ -692,20 +981,20 @@ class _SettingsScreenState extends State<SettingsScreen> {
), ),
], ],
const SizedBox(height: 24), const SizedBox(height: 20),
_buildSectionHeader('À propos'), _buildSectionHeader('À PROPOS', primary),
_buildSettingsTile( _buildSettingsTile(
context: context, context: context,
icon: Icons.info_outline, icon: Icons.info_outline,
title: 'Version de l\'application', title: 'Version de l\'application',
subtitle: '1.0.0', subtitle: '1.0.3 (Design Tactical & Precision)',
onTap: () {}, onTap: () {},
), ),
_buildSettingsTile( _buildSettingsTile(
context: context, context: context,
icon: Icons.bug_report_outlined, icon: Icons.bug_report_outlined,
title: 'Signaler un bug', title: 'Signaler un bug',
subtitle: 'Aidez-nous à corriger les problèmes', subtitle: 'Aidez-nous à corriger les anomalies',
onTap: _showReportBugDialog, onTap: _showReportBugDialog,
), ),
_buildSettingsTile( _buildSettingsTile(
@@ -719,15 +1008,16 @@ class _SettingsScreenState extends State<SettingsScreen> {
); );
} }
Widget _buildSectionHeader(String title) { Widget _buildSectionHeader(String title, Color primary) {
return Padding( return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 4.0), padding: const EdgeInsets.fromLTRB(4, 8, 4, 10),
child: Text( child: Text(
title, title,
style: const TextStyle( style: TextStyle(
fontSize: 14, fontSize: 11,
fontWeight: FontWeight.bold, fontWeight: FontWeight.w800,
color: AppTheme.primaryColor, color: primary,
letterSpacing: 1.0,
), ),
), ),
); );
@@ -740,21 +1030,57 @@ class _SettingsScreenState extends State<SettingsScreen> {
String? subtitle, String? subtitle,
required VoidCallback onTap, required VoidCallback onTap,
}) { }) {
return Card( final isDark = Theme.of(context).brightness == Brightness.dark;
elevation: 0, final primary = Theme.of(context).colorScheme.primary;
color: Colors.transparent,
margin: const EdgeInsets.only(bottom: 8.0), return GlassContainer(
child: ListTile( borderRadius: 16,
contentPadding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 4.0), blur: 10,
leading: Icon(icon, color: AppTheme.textPrimary), margin: const EdgeInsets.only(bottom: 10),
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
subtitle: subtitle != null ? Text(subtitle, style: const TextStyle(fontSize: 12)) : null,
trailing: const Icon(Icons.chevron_right, color: Colors.grey),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12.0),
side: BorderSide(color: Colors.grey.withAlpha(50), width: 1),
),
onTap: onTap, onTap: onTap,
child: Row(
children: [
Container(
padding: const EdgeInsets.all(9),
decoration: BoxDecoration(
color: primary.withValues(alpha: isDark ? 0.2 : 0.12),
borderRadius: BorderRadius.circular(10),
),
child: Icon(icon, color: primary, size: 20),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
),
),
if (subtitle != null) ...[
const SizedBox(height: 2),
Text(
subtitle,
style: TextStyle(
fontSize: 12,
color: isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary,
),
),
],
],
),
),
Icon(
Icons.chevron_right,
color: isDark ? AppTheme.darkTextMuted : AppTheme.lightTextMuted,
size: 20,
),
],
), ),
); );
} }
+80 -32
View File
@@ -7,6 +7,7 @@ import 'package:provider/provider.dart';
import 'package:fl_chart/fl_chart.dart'; // La librairie pour les courbes import 'package:fl_chart/fl_chart.dart'; // La librairie pour les courbes
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'package:share_plus/share_plus.dart'; import 'package:share_plus/share_plus.dart';
import '../../core/theme/app_theme.dart';
import '../../core/widgets/metric_info_button.dart'; import '../../core/widgets/metric_info_button.dart';
import '../../data/models/session.dart'; import '../../data/models/session.dart';
import '../../data/repositories/session_repository.dart'; import '../../data/repositories/session_repository.dart';
@@ -235,6 +236,7 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
extendBody: true,
appBar: AppBar( appBar: AppBar(
title: const Text('Statistiques'), title: const Text('Statistiques'),
centerTitle: true, centerTitle: true,
@@ -296,7 +298,7 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
onRefresh: _loadStatistics, onRefresh: _loadStatistics,
child: SingleChildScrollView( child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(), physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16), padding: const EdgeInsets.fromLTRB(16, 16, 16, 100),
child: Column( child: Column(
children: [ children: [
// 2. DONNÉES RAPIDES (Tirs et Sessions) // 2. DONNÉES RAPIDES (Tirs et Sessions)
@@ -414,13 +416,14 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
); );
} }
final activeColor = const Color(0xFF1A73E8); final isDark = Theme.of(context).brightness == Brightness.dark;
final activeColor = Theme.of(context).colorScheme.primary;
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: activeColor.withValues(alpha: 0.08), color: activeColor.withValues(alpha: isDark ? 0.12 : 0.08),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(14),
border: Border.all(color: activeColor.withValues(alpha: 0.3)), border: Border.all(color: activeColor.withValues(alpha: 0.35)),
), ),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -429,7 +432,15 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
children: [ children: [
Icon(Icons.compare_arrows, color: activeColor, size: 18), Icon(Icons.compare_arrows, color: activeColor, size: 18),
const SizedBox(width: 6), const SizedBox(width: 6),
Text('Comparaison', style: TextStyle(fontWeight: FontWeight.bold, color: activeColor)), Text(
'Comparaison de sessions',
style: TextStyle(
fontWeight: FontWeight.w700,
fontSize: 13,
color: activeColor,
letterSpacing: 0.3,
),
),
const Spacer(), const Spacer(),
IconButton( IconButton(
tooltip: 'Quitter la comparaison', tooltip: 'Quitter la comparaison',
@@ -493,27 +504,39 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
List<String> items, List<String> items,
void Function(String?) onChanged, void Function(String?) onChanged,
) { ) {
final theme = Theme.of(context); final isDark = Theme.of(context).brightness == Brightness.dark;
final textSecondary = isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary;
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration( decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), color: isDark ? AppTheme.darkSurfaceElevated : AppTheme.lightSurfaceVariant,
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(12),
border: Border.all(color: theme.dividerColor), border: Border.all(
color: isDark ? AppTheme.darkBorder : AppTheme.lightBorder,
),
), ),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
label, label,
style: TextStyle(color: theme.textTheme.bodySmall?.color?.withValues(alpha: 0.6), fontSize: 10), style: TextStyle(
color: textSecondary,
fontSize: 10,
fontWeight: FontWeight.w600,
),
), ),
DropdownButton<String>( DropdownButton<String>(
value: value, value: value,
isExpanded: true, isExpanded: true,
underline: Container(), underline: const SizedBox(),
dropdownColor: theme.colorScheme.surfaceContainerHighest, dropdownColor: isDark ? AppTheme.darkSurfaceElevated : AppTheme.lightSurfaceVariant,
style: TextStyle(color: theme.textTheme.bodyMedium?.color, fontSize: 14), style: TextStyle(
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
fontSize: 13,
fontWeight: FontWeight.w600,
),
items: items items: items
.map( .map(
(String val) => (String val) =>
@@ -529,28 +552,45 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
// Widget pour les petites cartes de stats // Widget pour les petites cartes de stats
Widget _buildQuickStat(String label, String value, IconData icon) { Widget _buildQuickStat(String label, String value, IconData icon) {
final theme = Theme.of(context); final isDark = Theme.of(context).brightness == Brightness.dark;
final primary = Theme.of(context).colorScheme.primary;
return Container( return Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration( decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), color: isDark ? AppTheme.darkSurface : AppTheme.lightSurface,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(16),
border: Border.all(
color: isDark ? AppTheme.darkBorder : AppTheme.lightBorder,
),
), ),
child: Column( child: Column(
children: [ children: [
Icon(icon, color: const Color(0xFF1A73E8), size: 20), Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: primary.withValues(alpha: isDark ? 0.15 : 0.1),
borderRadius: BorderRadius.circular(10),
),
child: Icon(icon, color: primary, size: 20),
),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
value, value,
style: TextStyle( style: TextStyle(
color: theme.textTheme.titleLarge?.color, color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
fontSize: 20, fontSize: 22,
fontWeight: FontWeight.bold, fontWeight: FontWeight.w800,
letterSpacing: -0.5,
), ),
), ),
const SizedBox(height: 2),
Text( Text(
label, label,
style: TextStyle(color: theme.textTheme.bodySmall?.color?.withValues(alpha: 0.6), fontSize: 12), style: TextStyle(
color: isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary,
fontSize: 12,
fontWeight: FontWeight.w500,
),
), ),
], ],
), ),
@@ -564,12 +604,15 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
List<double> dataPoints, { List<double> dataPoints, {
List<MetricExplanation>? explanations, List<MetricExplanation>? explanations,
}) { }) {
final theme = Theme.of(context); final isDark = Theme.of(context).brightness == Brightness.dark;
return Container( return Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), color: isDark ? AppTheme.darkSurface : AppTheme.lightSurface,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(16),
border: Border.all(
color: isDark ? AppTheme.darkBorder : AppTheme.lightBorder,
),
), ),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -578,7 +621,11 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
children: [ children: [
Text( Text(
title, title,
style: TextStyle(color: theme.textTheme.bodyMedium?.color?.withValues(alpha: 0.7), fontSize: 14), style: TextStyle(
color: isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary,
fontSize: 13,
fontWeight: FontWeight.w600,
),
), ),
if (explanations != null) ...[ if (explanations != null) ...[
const Spacer(), const Spacer(),
@@ -586,15 +633,16 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
], ],
], ],
), ),
const SizedBox(height: 10), const SizedBox(height: 8),
Row( Row(
children: [ children: [
Text( Text(
value, value,
style: TextStyle( style: TextStyle(
color: theme.textTheme.headlineMedium?.color, color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
fontSize: 24, fontSize: 24,
fontWeight: FontWeight.bold, fontWeight: FontWeight.w800,
letterSpacing: -0.5,
), ),
), ),
const SizedBox(width: 20), const SizedBox(width: 20),
+116 -33
View File
@@ -1,3 +1,4 @@
import 'dart:ui';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'features/home/home_screen.dart'; import 'features/home/home_screen.dart';
import 'features/history/history_screen.dart'; import 'features/history/history_screen.dart';
@@ -11,8 +12,7 @@ const int mainTabHistory = 1;
const int mainTabStats = 2; const int mainTabStats = 2;
const int mainTabGarage = 3; const int mainTabGarage = 3;
/// Clé du holder : permet de piloter l'onglet actif depuis n'importe quel /// Clé du holder : permet de piloter l'onglet actif depuis n'importe quel écran.
/// écran (ex. fin de session -> onglet Stats).
final GlobalKey<State<MainNavigationHolder>> mainNavKey = final GlobalKey<State<MainNavigationHolder>> mainNavKey =
GlobalKey<State<MainNavigationHolder>>(); GlobalKey<State<MainNavigationHolder>>();
@@ -32,9 +32,6 @@ class MainNavigationHolder extends StatefulWidget {
class _MainNavigationHolderState extends State<MainNavigationHolder> { class _MainNavigationHolderState extends State<MainNavigationHolder> {
int _selectedIndex = 0; int _selectedIndex = 0;
// Incrémentés à chaque ouverture de l'onglet correspondant pour forcer le
// rechargement (les écrans sont gardés vivants par l'IndexedStack et ne se
// rafraîchissent pas seuls).
int _statsTick = 0; int _statsTick = 0;
int _historyTick = 0; int _historyTick = 0;
int _homeTick = 0; int _homeTick = 0;
@@ -53,59 +50,145 @@ class _MainNavigationHolderState extends State<MainNavigationHolder> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final screens = [ final screens = [
HomeScreen(refreshTick: _homeTick), HomeScreen(refreshTick: _homeTick),
HistoryScreen(refreshTick: _historyTick), HistoryScreen(refreshTick: _historyTick),
StatisticsScreen(refreshTick: _statsTick), StatisticsScreen(refreshTick: _statsTick),
WeaponListScreen(refreshTick: _garageTick), WeaponListScreen(refreshTick: _garageTick),
]; ];
return Scaffold( return Scaffold(
extendBody: true,
body: IndexedStack( body: IndexedStack(
index: _selectedIndex, index: _selectedIndex,
children: screens, children: screens,
), ),
bottomNavigationBar: Container( bottomNavigationBar: _buildFloatingGlassDock(isDark),
);
}
Widget _buildFloatingGlassDock(bool isDark) {
final primaryColor = Theme.of(context).colorScheme.primary;
final navItems = [
(Icons.radar_outlined, Icons.radar, 'Accueil'),
(Icons.history_outlined, Icons.history_rounded, 'Historique'),
(Icons.insights_outlined, Icons.insights_rounded, 'Stats'),
(Icons.shield_outlined, Icons.shield, 'Armurerie'),
];
return SafeArea(
child: Container(
margin: const EdgeInsets.fromLTRB(16, 0, 16, 12),
height: 68,
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(24),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: Colors.black.withValues(alpha: 0.1), color: Colors.black.withValues(alpha: isDark ? 0.45 : 0.12),
blurRadius: 10, blurRadius: 24,
offset: const Offset(0, 8),
),
if (isDark)
BoxShadow(
color: primaryColor.withValues(alpha: 0.12),
blurRadius: 16,
spreadRadius: -4,
offset: const Offset(0, -2), offset: const Offset(0, -2),
), ),
], ],
), ),
child: BottomNavigationBar( child: ClipRRect(
currentIndex: _selectedIndex, borderRadius: BorderRadius.circular(24),
onTap: selectTab, child: BackdropFilter(
type: BottomNavigationBarType.fixed, filter: ImageFilter.blur(sigmaX: 16, sigmaY: 16),
backgroundColor: Theme.of(context).cardColor, child: Container(
selectedItemColor: AppTheme.primaryColor, padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
unselectedItemColor: Colors.grey, decoration: BoxDecoration(
showUnselectedLabels: true, color: isDark
items: const [ ? const Color(0xFF101722).withValues(alpha: 0.78)
BottomNavigationBarItem( : Colors.white.withValues(alpha: 0.85),
icon: Icon(Icons.home_outlined), borderRadius: BorderRadius.circular(24),
activeIcon: Icon(Icons.home), border: Border.all(
label: 'Accueil', color: isDark
? Colors.white.withValues(alpha: 0.14)
: Colors.black.withValues(alpha: 0.08),
width: 1.2,
), ),
BottomNavigationBarItem(
icon: Icon(Icons.history_outlined),
activeIcon: Icon(Icons.history),
label: 'Historique',
), ),
BottomNavigationBarItem( child: Row(
icon: Icon(Icons.analytics_outlined), mainAxisAlignment: MainAxisAlignment.spaceAround,
activeIcon: Icon(Icons.analytics), children: List.generate(navItems.length, (index) {
label: 'Stats', final item = navItems[index];
final isSelected = _selectedIndex == index;
return Expanded(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => selectTab(index),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
curve: Curves.easeOutCubic,
padding: const EdgeInsets.symmetric(vertical: 4),
decoration: BoxDecoration(
color: isSelected
? primaryColor.withValues(
alpha: isDark ? 0.22 : 0.15,
)
: Colors.transparent,
borderRadius: BorderRadius.circular(16),
border: isSelected
? Border.all(
color: primaryColor.withValues(
alpha: isDark ? 0.45 : 0.35,
),
width: 1,
)
: null,
),
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
isSelected ? item.$2 : item.$1,
size: 22,
color: isSelected
? primaryColor
: (isDark
? AppTheme.darkTextSecondary
: AppTheme.lightTextSecondary),
),
const SizedBox(height: 2),
Text(
item.$3,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 11,
fontWeight: isSelected
? FontWeight.w700
: FontWeight.w500,
color: isSelected
? primaryColor
: (isDark
? AppTheme.darkTextSecondary
: AppTheme.lightTextSecondary),
letterSpacing: 0.2,
), ),
BottomNavigationBarItem(
icon: Icon(Icons.shield_outlined),
activeIcon: Icon(Icons.shield),
label: 'Armurerie',
), ),
], ],
), ),
), ),
),
);
}),
),
),
),
),
),
); );
} }
} }
+2 -1
View File
@@ -105,7 +105,8 @@ class AiExportService {
}) async { }) async {
try { try {
final walletService = WalletIdentityService(); final walletService = WalletIdentityService();
final baseUrl = await walletService.getServerBaseUrl(); final rawBaseUrl = await walletService.getServerBaseUrl();
final baseUrl = rawBaseUrl.endsWith('/') ? rawBaseUrl.substring(0, rawBaseUrl.length - 1) : rawBaseUrl;
final effectiveUrl = apiUrl ?? '$baseUrl/api/upload'; final effectiveUrl = apiUrl ?? '$baseUrl/api/upload';
final url = Uri.parse(effectiveUrl); final url = Uri.parse(effectiveUrl);
final request = http.MultipartRequest('POST', url); final request = http.MultipartRequest('POST', url);