feat: implement AI export service and establish project navigation and theming infrastructure + acces internet
Build & Release Android APK / build-apk (push) Successful in 23m34s

This commit is contained in:
streaper2
2026-08-27 18:32:59 +02:00
parent e943538133
commit 8dc6542603
19 changed files with 3312 additions and 1055 deletions
+35 -2
View File
@@ -3,6 +3,7 @@ import 'package:provider/provider.dart';
import 'package:intl/intl.dart';
import '../../core/constants/app_constants.dart';
import '../../core/theme/app_theme.dart';
import '../../core/widgets/weapon_type_icon.dart';
import '../../data/models/weapon.dart';
import '../../data/models/maintenance.dart';
import '../../data/repositories/session_repository.dart';
@@ -131,7 +132,26 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
const Text('Informations techniques', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
const Divider(),
_buildInfoRow('Modèle', _weapon.name),
_buildInfoRow('Type', _weapon.type.displayName),
Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Type', style: TextStyle(color: Colors.grey)),
Row(
mainAxisSize: MainAxisSize.min,
children: [
WeaponTypeIcon(type: _weapon.type, size: 16),
const SizedBox(width: 6),
Text(
_weapon.type.displayName,
style: const TextStyle(fontWeight: FontWeight.w500),
),
],
),
],
),
),
_buildInfoRow('Calibre', _weapon.caliber),
_buildInfoRow('Chargeurs', '${_weapon.magazineCount} x ${_weapon.magazineCapacity} coups'),
if (_weapon.notes != null && _weapon.notes!.isNotEmpty) ...[
@@ -313,7 +333,20 @@ class _WeaponDetailScreenState extends State<WeaponDetailScreen> {
DropdownButtonFormField<WeaponType>(
initialValue: selectedType,
decoration: const InputDecoration(labelText: 'Type'),
items: WeaponType.values.map((t) => DropdownMenuItem(value: t, child: Text(t.displayName))).toList(),
items: WeaponType.values
.map(
(t) => DropdownMenuItem(
value: t,
child: Row(
children: [
WeaponTypeIcon(type: t, size: 20),
const SizedBox(width: 10),
Text(t.displayName),
],
),
),
)
.toList(),
onChanged: (v) => setState(() => selectedType = v!),
),
_autoScrollOnFocus(TextField(
+257 -88
View File
@@ -2,14 +2,13 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../core/constants/app_constants.dart';
import '../../core/theme/app_theme.dart';
import '../../core/widgets/glass_container.dart';
import '../../core/widgets/weapon_type_icon.dart';
import '../../data/models/weapon.dart';
import '../../data/repositories/session_repository.dart';
import 'weapon_detail_screen.dart';
class WeaponListScreen extends StatefulWidget {
/// Incrémenté par la navigation à chaque ouverture de l'onglet Armurerie
/// (l'écran est gardé vivant par l'IndexedStack et ne se rafraîchit pas
/// seul : sans ça, un import de sauvegarde resterait invisible).
final int refreshTick;
const WeaponListScreen({super.key, this.refreshTick = 0});
@@ -49,118 +48,242 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return Scaffold(
extendBody: true,
appBar: AppBar(
title: const Text('Mon Armurerie'),
actions: [
IconButton(
icon: const Icon(Icons.add_circle_outline),
tooltip: 'Ajouter une arme',
onPressed: _showAddWeaponDialog,
),
const SizedBox(width: 8),
],
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: _weapons.isEmpty
? _buildEmptyState()
: _buildWeaponList(),
floatingActionButton: FloatingActionButton(
onPressed: _showAddWeaponDialog,
child: const Icon(Icons.add),
),
? _buildEmptyState(isDark)
: _buildWeaponList(isDark),
);
}
Widget _buildEmptyState() {
Widget _buildEmptyState(bool isDark) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.shield, size: 64, color: Colors.grey[400]),
const SizedBox(height: 16),
const Text('Aucune arme enregistrée'),
const SizedBox(height: 24),
ElevatedButton(
onPressed: _showAddWeaponDialog,
child: const Text('Ajouter ma première arme'),
),
],
child: Padding(
padding: const EdgeInsets.all(32.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
GlassContainer(
borderRadius: 30,
padding: const EdgeInsets.all(28),
child: Icon(
Icons.shield_outlined,
size: 64,
color: isDark ? AppTheme.darkTextMuted : AppTheme.lightTextMuted,
),
),
const SizedBox(height: 24),
Text(
'Aucune arme enregistrée',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w800,
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
),
),
const SizedBox(height: 8),
Text(
'Ajoutez vos armes pour suivre vos tirs, chargeurs et entretiens.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13,
color: isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary,
),
),
const SizedBox(height: 24),
ElevatedButton.icon(
onPressed: _showAddWeaponDialog,
icon: const Icon(Icons.add),
label: const Text('Ajouter ma première arme'),
),
],
),
),
);
}
Widget _buildWeaponList() {
Widget _buildWeaponList(bool isDark) {
return ListView.builder(
padding: const EdgeInsets.all(AppConstants.defaultPadding),
padding: const EdgeInsets.fromLTRB(
AppConstants.defaultPadding,
12,
AppConstants.defaultPadding,
100, // Espace pour le floating dock
),
itemCount: _weapons.length,
itemBuilder: (context, index) {
final weapon = _weapons[index];
final accessories = _accessoryChips(weapon);
return Card(
margin: const EdgeInsets.only(bottom: 12),
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () async {
await Navigator.push(
context,
MaterialPageRoute(builder: (_) => WeaponDetailScreen(weapon: weapon)),
);
_loadWeapons(); // Reload in case it was edited or maintenance was added
},
onLongPress: () => _confirmDelete(weapon),
child: Padding(
// La hauteur du cadre s'adapte automatiquement à la liste d'accessoires.
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
final accessories = _accessoryChips(weapon, isDark);
final primaryColor = Theme.of(context).colorScheme.primary;
return GlassContainer(
borderRadius: 18,
blur: 14,
glowColor: primaryColor,
borderColor: isDark
? primaryColor.withValues(alpha: 0.18)
: primaryColor.withValues(alpha: 0.12),
margin: const EdgeInsets.only(bottom: 14),
padding: const EdgeInsets.all(16),
onTap: () async {
await Navigator.push(
context,
MaterialPageRoute(
builder: (_) => WeaponDetailScreen(weapon: weapon),
),
);
_loadWeapons();
},
onLongPress: () => _confirmDelete(weapon),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
CircleAvatar(
backgroundColor: AppTheme.primaryColor.withValues(alpha: 0.1),
child: Icon(
weapon.type == WeaponType.handgun ? Icons.shield : Icons.ads_click,
color: AppTheme.primaryColor,
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
primaryColor.withValues(
alpha: isDark ? 0.25 : 0.15,
),
primaryColor.withValues(
alpha: 0.05,
),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: primaryColor.withValues(
alpha: isDark ? 0.35 : 0.2,
),
),
),
child: WeaponTypeIcon(
type: weapon.type,
color: primaryColor,
size: 28,
),
),
const SizedBox(width: 16),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(weapon.displayName, style: const TextStyle(fontWeight: FontWeight.bold)),
if (accessories.isNotEmpty) ...[
const SizedBox(height: 6),
Wrap(
spacing: 6,
runSpacing: 6,
children: accessories,
),
const SizedBox(height: 6),
] else
const SizedBox(height: 2),
Text(
'${weapon.type.displayName}${weapon.caliber}',
style: const TextStyle(fontSize: 13, color: Colors.grey),
weapon.displayName,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w800,
color: isDark
? AppTheme.darkTextPrimary
: AppTheme.lightTextPrimary,
),
),
const SizedBox(height: 4),
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 7,
vertical: 2,
),
decoration: BoxDecoration(
color: (isDark ? Colors.white : Colors.black)
.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: isDark
? AppTheme.darkBorder
: AppTheme.lightBorder,
),
),
child: Text(
weapon.caliber,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w800,
color: primaryColor,
),
),
),
const SizedBox(width: 8),
Text(
weapon.type.displayName,
style: TextStyle(
fontSize: 12,
color: isDark
? AppTheme.darkTextSecondary
: AppTheme.lightTextSecondary,
),
),
],
),
],
),
),
const SizedBox(width: 12),
Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text('${weapon.magazineCount} chargeurs', style: const TextStyle(fontSize: 12)),
Text('${weapon.magazineCapacity} coups', style: const TextStyle(fontSize: 12, color: Colors.grey)),
Text(
'${weapon.magazineCount} chargeur${weapon.magazineCount > 1 ? 's' : ''}',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: isDark
? AppTheme.darkTextPrimary
: AppTheme.lightTextPrimary,
),
),
const SizedBox(height: 2),
Text(
'${weapon.magazineCapacity} coups/ch.',
style: TextStyle(
fontSize: 11,
color: isDark
? AppTheme.darkTextMuted
: AppTheme.lightTextMuted,
),
),
],
),
],
),
),
if (accessories.isNotEmpty) ...[
const SizedBox(height: 12),
Wrap(
spacing: 6,
runSpacing: 6,
children: accessories,
),
],
],
),
);
},
);
}
// Construit la liste des "puces" d'accessoires renseignés pour une arme.
// Seuls les accessoires effectivement définis sont affichés ; la card
// s'agrandit donc en fonction du nombre d'accessoires.
List<Widget> _accessoryChips(Weapon weapon) {
List<Widget> _accessoryChips(Weapon weapon, bool isDark) {
final items = <(IconData, String)>[];
if (weapon.optic != null && weapon.optic!.isNotEmpty) {
items.add((Icons.center_focus_strong, weapon.optic!));
@@ -176,16 +299,29 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: AppTheme.primaryColor.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: AppTheme.primaryColor.withValues(alpha: 0.25)),
color: (isDark ? Colors.white : Colors.black).withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: isDark ? AppTheme.darkBorder : AppTheme.lightBorder,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(item.$1, size: 13, color: AppTheme.primaryColor),
const SizedBox(width: 4),
Text(item.$2, style: const TextStyle(fontSize: 12)),
Icon(
item.$1,
size: 13,
color: isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary,
),
const SizedBox(width: 5),
Text(
item.$2,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary,
),
),
],
),
);
@@ -193,7 +329,6 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
}
void _showAddWeaponDialog() async {
// Capturé AVANT l'await : plus aucun usage du context après le dialogue.
final repository = context.read<SessionRepository>();
final nameController = TextEditingController();
final caliberController = TextEditingController();
@@ -203,7 +338,7 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
final result = await showDialog<bool>(
context: context,
builder: (context) => StatefulBuilder(
builder: (dialogCtx) => StatefulBuilder(
builder: (context, setState) => AlertDialog(
title: const Text('Ajouter une arme'),
content: SingleChildScrollView(
@@ -212,18 +347,40 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
children: [
TextField(
controller: nameController,
decoration: const InputDecoration(labelText: 'Nom de l\'arme', hintText: 'ex: Glock 17'),
decoration: const InputDecoration(
labelText: 'Nom de l\'arme',
hintText: 'ex: Glock 17 Gen 5',
),
),
const SizedBox(height: 12),
DropdownButtonFormField<WeaponType>(
initialValue: selectedType,
decoration: const InputDecoration(labelText: 'Type'),
items: WeaponType.values.map((t) => DropdownMenuItem(value: t, child: Text(t.displayName))).toList(),
items: WeaponType.values
.map(
(t) => DropdownMenuItem(
value: t,
child: Row(
children: [
WeaponTypeIcon(type: t, size: 20),
const SizedBox(width: 10),
Text(t.displayName),
],
),
),
)
.toList(),
onChanged: (v) => setState(() => selectedType = v!),
),
const SizedBox(height: 12),
TextField(
controller: caliberController,
decoration: const InputDecoration(labelText: 'Calibre', hintText: 'ex: 9mm, .22LR'),
decoration: const InputDecoration(
labelText: 'Calibre',
hintText: 'ex: 9x19mm, .22 LR, .223 Rem',
),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
@@ -233,7 +390,7 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
keyboardType: TextInputType.number,
),
),
const SizedBox(width: 16),
const SizedBox(width: 12),
Expanded(
child: TextField(
controller: magCapController,
@@ -247,8 +404,14 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
),
),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Annuler')),
ElevatedButton(onPressed: () => Navigator.pop(context, true), child: const Text('Ajouter')),
TextButton(
onPressed: () => Navigator.pop(dialogCtx, false),
child: const Text('Annuler'),
),
ElevatedButton(
onPressed: () => Navigator.pop(dialogCtx, true),
child: const Text('Ajouter'),
),
],
),
),
@@ -268,18 +431,24 @@ class _WeaponListScreenState extends State<WeaponListScreen> {
}
void _confirmDelete(Weapon weapon) async {
// Capturé AVANT l'await : plus aucun usage du context après le dialogue.
final repository = context.read<SessionRepository>();
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Supprimer'),
title: const Text('Supprimer l\'arme'),
content: Text('Voulez-vous supprimer ${weapon.name} de votre armurerie ?'),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Annuler')),
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Annuler'),
),
ElevatedButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Supprimer', style: TextStyle(color: AppTheme.errorColor)),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.errorColor,
foregroundColor: Colors.white,
),
child: const Text('Supprimer'),
),
],
),
+171 -115
View File
@@ -1,7 +1,6 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:intl/intl.dart';
import '../../core/constants/app_constants.dart';
import '../../core/theme/app_theme.dart';
import '../../data/models/session.dart';
import '../../data/models/target_type.dart';
@@ -11,10 +10,6 @@ import 'widgets/session_list_item.dart';
import 'widgets/history_chart.dart';
class HistoryScreen extends StatefulWidget {
/// Incrémenté par la navigation à chaque ouverture de l'onglet Historique,
/// pour forcer un rechargement des sessions (l'écran est gardé vivant par un
/// IndexedStack, sinon une session tout juste clôturée n'apparaîtrait pas
/// tant qu'on ne tire pas manuellement pour rafraîchir).
final int refreshTick;
const HistoryScreen({super.key, this.refreshTick = 0});
@@ -27,8 +22,6 @@ class _HistoryScreenState extends State<HistoryScreen> {
List<Session> _sessions = [];
bool _isLoading = true;
TargetType? _filterType;
// --- MODIFICATION : Remplacement de DateTime par DateTimeRange ---
DateTimeRange? _selectedDateRange;
@override
@@ -40,8 +33,6 @@ class _HistoryScreenState extends State<HistoryScreen> {
@override
void didUpdateWidget(HistoryScreen oldWidget) {
super.didUpdateWidget(oldWidget);
// L'onglet vient d'être ré-ouvert : on recharge pour afficher les sessions
// récemment clôturées sans avoir à tirer manuellement pour rafraîchir.
if (oldWidget.refreshTick != widget.refreshTick) {
_loadSessions();
}
@@ -59,18 +50,14 @@ class _HistoryScreenState extends State<HistoryScreen> {
setState(() {
_sessions = sessions;
// --- FILTRAGE PAR TYPE DE CIBLE ---
// Une session est retenue si au moins une de ses cibles est du type choisi.
if (_filterType != null) {
_sessions = _sessions
.where((s) => s.analyses.any((a) => a.targetType == _filterType))
.toList();
}
// --- LOGIQUE DE FILTRAGE PAR PÉRIODE ---
if (_selectedDateRange != null) {
_sessions = _sessions.where((s) {
// On compare uniquement les dates (sans les heures) pour éviter les bugs
final sessionDate = DateTime(
s.createdAt.year,
s.createdAt.month,
@@ -109,32 +96,13 @@ class _HistoryScreenState extends State<HistoryScreen> {
}
}
// --- MODIFICATION : Fonction DateRangePicker ---
Future<void> _pickDateRange() async {
final DateTimeRange? picked = await showDateRangePicker(
final picked = await showDateRangePicker(
context: context,
initialDateRange: _selectedDateRange,
firstDate: DateTime(2020),
lastDate: DateTime.now().add(const Duration(days: 1)),
locale: const Locale('fr', 'FR'),
builder: (context, child) {
return Theme(
data: ThemeData.light().copyWith(
colorScheme: ColorScheme.light(
primary: AppTheme.primaryColor, // En-tête et sélection
onPrimary: Colors.white, // Texte sur en-tête/sélection
surface: Colors.white, // Fond du calendrier
onSurface: Colors.black87, // Texte des dates (Noir sur Blanc)
secondary: AppTheme.primaryColor,
),
dialogTheme: const DialogThemeData(backgroundColor: Colors.white),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(foregroundColor: AppTheme.primaryColor),
),
),
child: child!,
);
},
);
if (picked != null) {
@@ -145,134 +113,221 @@ class _HistoryScreenState extends State<HistoryScreen> {
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return Scaffold(
extendBody: true,
appBar: AppBar(
title: const Text('Historique'),
title: const Text('Carnet de Tir'),
actions: [
// NOTE : on passe par un String sentinelle ('all') car un
// PopupMenuItem avec value null ne déclenche jamais onSelected
// (Flutter l'interprète comme une annulation du menu).
PopupMenuButton<String>(
IconButton(
icon: Icon(
Icons.filter_list,
// Icône colorée quand un filtre est actif, pour le rendre visible.
color: _filterType != null ? AppTheme.primaryColor : null,
Icons.date_range_outlined,
color: _selectedDateRange != null ? AppTheme.primaryColor : null,
),
onSelected: (value) {
setState(() {
_filterType =
value == 'all' ? null : TargetType.fromString(value);
});
_loadSessions();
},
itemBuilder: (context) => [
const PopupMenuItem(value: 'all', child: Text('Tous')),
...TargetType.values.map(
(type) => PopupMenuItem(
value: type.name,
child: Text(type.displayName),
),
),
],
tooltip: 'Filtrer par date',
onPressed: _pickDateRange,
),
const SizedBox(width: 8),
],
),
body: Column(
children: [
_buildFilterChips(isDark),
Expanded(
child: _isLoading
? const Center(child: CircularProgressIndicator())
: _sessions.isEmpty
? _buildEmptyState()
: _buildContent(),
? _buildEmptyState(isDark)
: _buildContent(isDark),
),
_buildBottomFilterBar(),
if (_selectedDateRange != null) _buildActivePeriodBanner(isDark),
],
),
);
}
Widget _buildBottomFilterBar() {
Widget _buildFilterChips(bool isDark) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: Theme.of(context).cardColor,
boxShadow: const [
BoxShadow(
color: Colors.black26,
blurRadius: 4,
offset: Offset(0, -2),
color: isDark ? AppTheme.darkBackground : AppTheme.lightBackground,
border: Border(
bottom: BorderSide(
color: isDark ? AppTheme.darkBorder : AppTheme.lightBorder,
width: 1,
),
],
),
),
child: SafeArea(
top: false,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: _pickDateRange,
icon: const Icon(Icons.date_range, size: 18),
label: Text(
_selectedDateRange == null
? 'Choisir une période'
: '${DateFormat('dd/MM/yy').format(_selectedDateRange!.start)} - ${DateFormat('dd/MM/yy').format(_selectedDateRange!.end)}',
),
),
_buildTypeFilterChip('Toutes les cibles', null),
const SizedBox(width: 8),
_buildTypeFilterChip(
'Cibles 1-10',
TargetType.concentric,
),
const SizedBox(width: 8),
_buildTypeFilterChip(
'Silhouettes',
TargetType.silhouette,
),
if (_selectedDateRange != null)
IconButton(
icon: const Icon(Icons.close, color: AppTheme.errorColor),
onPressed: () {
setState(() => _selectedDateRange = null);
_loadSessions();
},
),
],
),
),
);
}
Widget _buildEmptyState() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.history, size: 64, color: Colors.grey[400]),
const SizedBox(height: 16),
const Text('Aucune session sur cette période'),
],
Widget _buildTypeFilterChip(String label, TargetType? type) {
final isSelected = _filterType == type;
return ChoiceChip(
label: Text(label),
selected: isSelected,
onSelected: (selected) {
if (selected) {
setState(() => _filterType = type);
_loadSessions();
}
},
);
}
Widget _buildActivePeriodBanner(bool isDark) {
final start = DateFormat('dd/MM/yy').format(_selectedDateRange!.start);
final end = DateFormat('dd/MM/yy').format(_selectedDateRange!.end);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
decoration: BoxDecoration(
color: isDark ? AppTheme.darkSurface : AppTheme.lightSurface,
border: Border(
top: BorderSide(
color: isDark ? AppTheme.darkBorder : AppTheme.lightBorder,
),
),
),
child: SafeArea(
top: false,
child: Row(
children: [
const Icon(Icons.event, size: 18, color: AppTheme.primaryColor),
const SizedBox(width: 8),
Text(
'Période : $start - $end',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
),
),
const Spacer(),
InkWell(
onTap: () {
setState(() => _selectedDateRange = null);
_loadSessions();
},
borderRadius: BorderRadius.circular(6),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Row(
children: [
Text(
'Effacer',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: AppTheme.errorColor,
),
),
const SizedBox(width: 4),
const Icon(Icons.close, size: 14, color: AppTheme.errorColor),
],
),
),
),
],
),
),
);
}
Widget _buildContent() {
Widget _buildEmptyState(bool isDark) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: isDark ? AppTheme.darkSurface : AppTheme.lightSurfaceVariant,
shape: BoxShape.circle,
border: Border.all(
color: isDark ? AppTheme.darkBorder : AppTheme.lightBorder,
),
),
child: Icon(
Icons.history_toggle_off,
size: 56,
color: isDark ? AppTheme.darkTextMuted : AppTheme.lightTextMuted,
),
),
const SizedBox(height: 18),
Text(
'Aucune session trouvée',
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.w700,
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
),
),
const SizedBox(height: 6),
Text(
_selectedDateRange != null || _filterType != null
? 'Essayez de réinitialiser vos filtres de recherche.'
: 'Vos sessions enregistrées apparaîtront ici.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13,
color: isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary,
),
),
],
),
),
);
}
Widget _buildContent(bool isDark) {
return RefreshIndicator(
onRefresh: _loadSessions,
color: AppTheme.primaryColor,
child: CustomScrollView(
slivers: [
if (_sessions.length >= 2 && _selectedDateRange == null)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(AppConstants.defaultPadding),
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: HistoryChart(sessions: _sessions),
),
),
SliverPadding(
padding: const EdgeInsets.all(AppConstants.defaultPadding),
padding: const EdgeInsets.fromLTRB(16, 8, 16, 100),
sliver: SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
final session = _sessions[index];
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: SessionListItem(
delegate: SliverChildBuilderDelegate(
(context, index) {
final session = _sessions[index];
return SessionListItem(
session: session,
onTap: () => _openSessionDetail(session),
onDelete: () => _deleteSession(session),
),
);
}, childCount: _sessions.length),
);
},
childCount: _sessions.length,
),
),
),
],
@@ -292,21 +347,22 @@ class _HistoryScreenState extends State<HistoryScreen> {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Supprimer'),
title: const Text('Supprimer la session'),
content: Text(
'Supprimer la session du ${DateFormat('dd/MM/yyyy').format(session.createdAt)}?',
'Voulez-vous supprimer définitivement la session du ${DateFormat('dd/MM/yyyy à HH:mm').format(session.createdAt)} ?',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Annuler'),
),
TextButton(
ElevatedButton(
onPressed: () => Navigator.pop(context, true),
child: const Text(
'Supprimer',
style: TextStyle(color: AppTheme.errorColor),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.errorColor,
foregroundColor: Colors.white,
),
child: const Text('Supprimer'),
),
],
),
@@ -2,6 +2,7 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/widgets/glass_container.dart';
import '../../../data/models/session.dart';
class SessionListItem extends StatelessWidget {
@@ -18,124 +19,178 @@ class SessionListItem extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Card(
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
// Thumbnail (from first target)
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: SizedBox(
width: 60,
height: 60,
child: _buildThumbnail(),
final isDark = Theme.of(context).brightness == Brightness.dark;
final textSecondary = isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary;
final textMuted = isDark ? AppTheme.darkTextMuted : AppTheme.lightTextMuted;
// Calcul de la couleur du score selon la moyenne par tir
final primaryColor = Theme.of(context).colorScheme.primary;
final avg = session.averageScore;
Color scoreColor = primaryColor;
if (avg >= 9.0) {
scoreColor = AppTheme.secondaryColor;
} else if (avg >= 7.5) {
scoreColor = primaryColor;
} else {
scoreColor = isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary;
}
final formattedDate = DateFormat('dd/MM/yyyy • HH:mm', 'fr_FR').format(session.createdAt);
return GlassContainer(
borderRadius: 18,
blur: 14,
glowColor: scoreColor,
borderColor: isDark ? scoreColor.withValues(alpha: 0.2) : scoreColor.withValues(alpha: 0.15),
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(14),
onTap: onTap,
child: Row(
children: [
// Aperçu de la cible avec bordure nette
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: (isDark ? Colors.white : Colors.black).withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isDark ? AppTheme.darkBorder : AppTheme.lightBorder,
),
),
const SizedBox(width: 12),
child: _buildThumbnail(isDark),
),
),
const SizedBox(width: 14),
// Info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
// Informations de la session
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
session.weapon,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 3),
Text(
formattedDate,
style: TextStyle(
fontSize: 11,
color: textSecondary,
),
),
const SizedBox(height: 5),
Row(
children: [
Row(
children: [
const Icon(
Icons.shield,
size: 16,
color: AppTheme.primaryColor,
Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
decoration: BoxDecoration(
color: (isDark ? Colors.white : Colors.black).withValues(alpha: 0.06),
borderRadius: BorderRadius.circular(6),
),
child: Text(
'${session.distance}m',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w700,
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
),
const SizedBox(width: 4),
Text(
session.weapon,
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
],
),
),
const SizedBox(height: 4),
const SizedBox(width: 6),
Text(
DateFormat('dd/MM/yyyy HH:mm').format(session.createdAt),
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 4),
Row(
children: [
Icon(Icons.track_changes, size: 14, color: Colors.grey[600]),
const SizedBox(width: 4),
Text(
'${session.targetCount} cible(s) • ${session.distance}m',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.grey[600],
),
),
],
'${session.targetCount} cible${session.targetCount > 1 ? 's' : ''}${session.totalShots} tirs',
style: TextStyle(
fontSize: 11,
color: textMuted,
),
),
],
),
),
// Score
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'${session.totalScore}',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: AppTheme.primaryColor,
),
),
Text(
'${session.totalShots} tirs',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
// Delete button
if (onDelete != null)
IconButton(
icon: const Icon(Icons.delete_outline),
onPressed: onDelete,
color: Colors.grey,
iconSize: 20,
),
],
],
),
),
),
// Score et moyenne stylisés façon cyber HUD
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: scoreColor.withValues(alpha: isDark ? 0.12 : 0.08),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: scoreColor.withValues(alpha: isDark ? 0.3 : 0.2),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'${session.totalScore}',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w900,
color: scoreColor,
letterSpacing: -0.5,
),
),
Text(
'Moy. ${session.averageScore.toStringAsFixed(1)}',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w700,
color: scoreColor,
),
),
],
),
),
// Bouton supprimer
if (onDelete != null) ...[
const SizedBox(width: 4),
IconButton(
icon: const Icon(Icons.delete_outline_rounded),
onPressed: onDelete,
color: textMuted,
iconSize: 20,
tooltip: 'Supprimer',
),
],
],
),
);
}
Widget _buildThumbnail() {
if (session.analyses.isEmpty) return _buildPlaceholder();
Widget _buildThumbnail(bool isDark) {
if (session.analyses.isEmpty) return _buildPlaceholder(isDark);
final file = File(session.analyses.first.imagePath);
if (file.existsSync()) {
return Image.file(
file,
fit: BoxFit.cover,
errorBuilder: (_, _, _) => _buildPlaceholder(),
errorBuilder: (_, _, _) => _buildPlaceholder(isDark),
);
}
return _buildPlaceholder();
return _buildPlaceholder(isDark);
}
Widget _buildPlaceholder() {
return Container(
color: Colors.grey[200],
child: Icon(
Icons.track_changes,
color: Colors.grey[400],
),
Widget _buildPlaceholder(bool isDark) {
return Icon(
Icons.track_changes,
color: isDark ? AppTheme.darkTextMuted : AppTheme.lightTextMuted,
size: 24,
);
}
}
File diff suppressed because it is too large Load Diff
+73 -27
View File
@@ -1,17 +1,14 @@
/// Widget carte réutilisable pour afficher une statistique.
///
/// Affiche une icône, un titre et une valeur avec une couleur personnalisable.
/// Utilisé sur l'écran d'accueil pour les statistiques rapides.
library;
import 'package:flutter/material.dart';
import '../../../core/constants/app_constants.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/widgets/glass_container.dart';
/// Widget carte télémétrique moderne avec effet Glassmorphism givré.
class StatsCard extends StatelessWidget {
final IconData icon;
final String title;
final String value;
final Color color;
final String? subtitle;
const StatsCard({
super.key,
@@ -19,33 +16,82 @@ class StatsCard extends StatelessWidget {
required this.title,
required this.value,
required this.color,
this.subtitle,
});
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(AppConstants.defaultPadding),
child: Column(
children: [
Icon(icon, color: color, size: 32),
const SizedBox(height: 8),
Text(
value,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: color,
final isDark = Theme.of(context).brightness == Brightness.dark;
final textSecondary = isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary;
return GlassContainer(
borderRadius: 18,
blur: 14,
glowColor: color,
borderColor: isDark ? color.withValues(alpha: 0.22) : color.withValues(alpha: 0.18),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: color.withValues(alpha: isDark ? 0.18 : 0.12),
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: color.withValues(alpha: isDark ? 0.35 : 0.25),
width: 1,
),
),
child: Icon(icon, color: color, size: 20),
),
if (subtitle != null)
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: (isDark ? Colors.white : Colors.black).withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(6),
),
child: Text(
subtitle!,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w600,
color: textSecondary,
),
),
),
],
),
const SizedBox(height: 12),
Text(
value,
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.w800,
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
letterSpacing: -0.5,
),
const SizedBox(height: 4),
Text(
title,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.grey[600],
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text(
title,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: textSecondary,
letterSpacing: 0.1,
),
],
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
);
}
+339 -207
View File
@@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:intl/intl.dart'; // Utile pour formater proprement la date en français
import 'package:intl/intl.dart';
import '../../core/constants/app_constants.dart';
import '../../core/theme/app_theme.dart';
import '../../data/models/weapon.dart';
@@ -25,9 +25,11 @@ class _SessionSetupScreenState extends State<SessionSetupScreen> {
Weapon? _selectedWeapon;
bool _isLoadingWeapons = true;
// AJOUT DE LA VARIABLE DATE : Initialisée par défaut à maintenant
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
void initState() {
super.initState();
@@ -52,24 +54,28 @@ class _SessionSetupScreenState extends State<SessionSetupScreen> {
}
void _updateSettingsForWeapon(Weapon weapon) {
_shotsPerTarget = weapon.magazineCapacity;
_shotsPerTarget = weapon.magazineCapacity > 0 ? weapon.magazineCapacity : 5;
_distance = (weapon.type == WeaponType.handgun) ? 25 : 50;
}
// Fonction pour ouvrir le calendrier si l'utilisateur clique sur le champ
Future<void> _pickDate() async {
final DateTime? picked = await showDatePicker(
context: context,
initialDate: _selectedDate,
firstDate: DateTime(2020),
lastDate: DateTime(2100),
locale: const Locale('fr', 'FR'), // Force le calendrier en français
locale: const Locale('fr', 'FR'),
);
if (picked != null && picked != _selectedDate) {
setState(() {
// On garde aussi l'heure actuelle lors du changement de date
final now = DateTime.now();
_selectedDate = DateTime(picked.year, picked.month, picked.day, now.hour, now.minute);
_selectedDate = DateTime(
picked.year,
picked.month,
picked.day,
now.hour,
now.minute,
);
});
}
}
@@ -81,10 +87,6 @@ class _SessionSetupScreenState extends State<SessionSetupScreen> {
final sessionId = repository.generateId();
// PASSE-PARTOUT : On envoie la session au provider.
// Note : Si ton `sessionProvider.startSession` ne prend pas encore la date en paramètre,
// pas de panique, la compilation passera car Dart tolère les arguments nommés optionnels
// s'ils sont déjà présents, ou tu pourras l'ajouter à ta méthode startSession.
sessionProvider.startSession(
_selectedWeapon!.displayName,
_shotsPerTarget,
@@ -103,221 +105,351 @@ class _SessionSetupScreenState extends State<SessionSetupScreen> {
@override
Widget build(BuildContext context) {
// Petit formatage sympa en français (ex: "27 mai 2026")
final String formattedDate = DateFormat('dd MMMM yyyy', 'fr_FR').format(_selectedDate);
final isDark = Theme.of(context).brightness == Brightness.dark;
final formattedDate = DateFormat('dd MMMM yyyy • HH:mm', 'fr_FR').format(_selectedDate);
return Scaffold(
appBar: AppBar(
title: const Text('Configuration de la session'),
title: const Text('Configuration de Session'),
),
body: _isLoadingWeapons
? const Center(child: CircularProgressIndicator())
: SingleChildScrollView(
padding: const EdgeInsets.all(AppConstants.defaultPadding),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
'Informations générales',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
// AFFICHAGE CONDITIONNEL : Armurerie vide vs Armurerie remplie
if (_availableWeapons.isEmpty)
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppTheme.errorColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
border: Border.all(color: AppTheme.errorColor.withValues(alpha: 0.5)),
),
child: Column(
children: [
const Icon(Icons.warning_amber_rounded, color: AppTheme.errorColor, size: 48),
const SizedBox(height: 8),
const Text(
'Ton armurerie est vide.',
style: TextStyle(fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
const SizedBox(height: 4),
const Text(
'Tu dois d\'abord ajouter une arme pour pouvoir démarrer une session de tir.',
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(builder: (_) => const WeaponListScreen()),
).then((_) {
_loadWeapons();
});
},
icon: const Icon(Icons.add),
label: const Text('ALLER À MON ARMURERIE'),
),
],
),
)
else
Column(
padding: const EdgeInsets.all(AppConstants.defaultPadding),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
DropdownButtonFormField<Weapon>(
initialValue: _selectedWeapon,
decoration: InputDecoration(
labelText: 'Sélectionner une arme',
prefixIcon: const Icon(Icons.shield),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
),
),
items: _availableWeapons.map((w) => DropdownMenuItem(
value: w,
child: Text(w.displayName),
)).toList(),
onChanged: (value) {
if (value != null) {
setState(() {
_selectedWeapon = value;
_updateSettingsForWeapon(value);
});
}
},
),
const SizedBox(height: 16),
if (_availableWeapons.isEmpty)
_buildEmptyArmoryState(isDark)
else ...[
_buildWeaponAndDateSection(isDark, formattedDate),
const SizedBox(height: 20),
_buildDistanceSection(isDark),
const SizedBox(height: 20),
_buildShotsSection(isDark),
const SizedBox(height: 32),
_buildStartButton(),
const SizedBox(height: 16),
],
],
),
),
),
);
}
// NOUVEAU CHAMP : Sélecteur de date interactif
InkWell(
onTap: _availableWeapons.isEmpty ? null : _pickDate,
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
child: IgnorePointer(
child: TextFormField(
decoration: InputDecoration(
labelText: 'Date de la session',
prefixIcon: const Icon(Icons.calendar_today),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
),
),
controller: TextEditingController(text: formattedDate),
),
Widget _buildEmptyArmoryState(bool isDark) {
return Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: isDark ? AppTheme.darkSurface : AppTheme.lightSurface,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: AppTheme.errorColor.withValues(alpha: 0.5),
),
),
child: Column(
children: [
const Icon(
Icons.warning_amber_rounded,
color: AppTheme.errorColor,
size: 52,
),
const SizedBox(height: 12),
Text(
'Armurerie vide',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
),
),
const SizedBox(height: 6),
Text(
'Ajoutez au moins une arme dans votre armurerie pour débuter une séance.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13,
color: isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary,
),
),
const SizedBox(height: 20),
ElevatedButton.icon(
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(builder: (_) => const WeaponListScreen()),
).then((_) => _loadWeapons());
},
icon: const Icon(Icons.shield),
label: const Text('Aller à l\'armurerie'),
),
],
),
);
}
Widget _buildWeaponAndDateSection(bool isDark, String formattedDate) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.tune, color: AppTheme.primaryColor, size: 20),
const SizedBox(width: 8),
Text(
'Arme & Date',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
),
),
],
),
const SizedBox(height: 16),
DropdownButtonFormField<Weapon>(
initialValue: _selectedWeapon,
decoration: const InputDecoration(
labelText: 'Arme utilisée',
prefixIcon: Icon(Icons.shield_outlined),
),
items: _availableWeapons
.map(
(w) => DropdownMenuItem(
value: w,
child: Text('${w.displayName} (${w.caliber})'),
),
)
.toList(),
onChanged: (value) {
if (value != null) {
setState(() {
_selectedWeapon = value;
_updateSettingsForWeapon(value);
});
}
},
),
const SizedBox(height: 12),
InkWell(
onTap: _pickDate,
borderRadius: BorderRadius.circular(12),
child: IgnorePointer(
child: TextFormField(
decoration: const InputDecoration(
labelText: 'Date de séance',
prefixIcon: Icon(Icons.calendar_today_outlined),
),
controller: TextEditingController(text: formattedDate),
),
),
),
],
),
),
);
}
Widget _buildDistanceSection(bool isDark) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
const Icon(Icons.straighten, color: AppTheme.secondaryColor, size: 20),
const SizedBox(width: 8),
Text(
'Distance de Tir',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
),
),
],
),
const SizedBox(height: 24),
// Distance selector
const Text(
'Distance de tir (mètres)',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: Slider(
value: _distance.toDouble(),
min: 5,
max: 300,
divisions: 59,
label: '${_distance}m',
onChanged: _availableWeapons.isEmpty ? null : (value) {
setState(() {
_distance = value.round();
});
},
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: AppTheme.secondaryColor.withValues(alpha: isDark ? 0.2 : 0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: AppTheme.secondaryColor.withValues(alpha: 0.4),
),
),
Container(
width: 70,
height: 50,
alignment: Alignment.center,
decoration: BoxDecoration(
color: AppTheme.secondaryColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
child: Text(
'${_distance}m',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w800,
color: AppTheme.secondaryColor,
),
child: Text(
'${_distance}m',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppTheme.secondaryColor,
),
),
),
],
),
const SizedBox(height: 24),
// Shots per target
const Text(
'Nombre de balles pour la cible actuelle',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: Slider(
value: _shotsPerTarget.toDouble(),
min: 1,
max: 50,
divisions: 49,
label: '$_shotsPerTarget',
onChanged: _availableWeapons.isEmpty ? null : (value) {
setState(() {
_shotsPerTarget = value.round();
});
},
),
),
Container(
width: 50,
height: 50,
alignment: Alignment.center,
decoration: BoxDecoration(
color: AppTheme.primaryColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'$_shotsPerTarget',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppTheme.primaryColor,
),
),
),
],
),
const SizedBox(height: 48),
ElevatedButton.icon(
onPressed: (_availableWeapons.isEmpty || _selectedWeapon == null)
? null
: _startSession,
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primaryColor,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
),
),
icon: const Icon(Icons.play_arrow),
label: const Text(
'DÉMARRER LA SESSION',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
],
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: _presetDistances.map((d) {
final isSelected = _distance == d;
return ChoiceChip(
label: Text('${d}m'),
selected: isSelected,
onSelected: (selected) {
if (selected) setState(() => _distance = d);
},
);
}).toList(),
),
const SizedBox(height: 8),
Slider(
value: _distance.toDouble(),
min: 5,
max: 300,
divisions: 59,
activeColor: AppTheme.secondaryColor,
onChanged: (val) {
setState(() => _distance = val.round());
},
),
],
),
),
);
}
Widget _buildShotsSection(bool isDark) {
final primaryColor = Theme.of(context).colorScheme.primary;
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Icon(Icons.ads_click, color: primaryColor, size: 20),
const SizedBox(width: 8),
Text(
'Tirs par Cible',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
),
),
],
),
),
],
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: primaryColor.withValues(alpha: isDark ? 0.2 : 0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: primaryColor.withValues(alpha: 0.4),
),
),
child: Text(
'$_shotsPerTarget tirs',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w800,
color: primaryColor,
),
),
),
],
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: _presetShots.map((s) {
final isSelected = _shotsPerTarget == s;
return ChoiceChip(
label: Text('$s coups'),
selected: isSelected,
onSelected: (selected) {
if (selected) setState(() => _shotsPerTarget = s);
},
);
}).toList(),
),
const SizedBox(height: 8),
Slider(
value: _shotsPerTarget.toDouble(),
min: 1,
max: 50,
divisions: 49,
activeColor: primaryColor,
onChanged: (val) {
setState(() => _shotsPerTarget = val.round());
},
),
],
),
),
);
}
Widget _buildStartButton() {
final primaryColor = Theme.of(context).colorScheme.primary;
return Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
gradient: LinearGradient(
colors: [
primaryColor,
HSLColor.fromColor(primaryColor).withLightness((HSLColor.fromColor(primaryColor).lightness + 0.15).clamp(0.0, 1.0)).toColor(),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
boxShadow: [
BoxShadow(
color: primaryColor.withValues(alpha: 0.35),
blurRadius: 16,
offset: const Offset(0, 4),
),
],
),
child: ElevatedButton.icon(
onPressed: (_availableWeapons.isEmpty || _selectedWeapon == null)
? null
: _startSession,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.transparent,
foregroundColor: Colors.white,
shadowColor: Colors.transparent,
padding: const EdgeInsets.symmetric(vertical: 18),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
),
icon: const Icon(Icons.play_arrow_rounded, size: 26),
label: const Text(
'DÉMARRER LA SESSION',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w800,
letterSpacing: 0.8,
),
),
),
+428 -102
View File
@@ -1,12 +1,12 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:flutter/services.dart';
import '../../core/theme/theme_provider.dart';
import 'dart:convert';
import 'package:crypto/crypto.dart';
import 'package:http/http.dart' as http;
import '../../core/constants/app_constants.dart';
import '../../core/theme/app_theme.dart';
import '../../core/theme/theme_provider.dart';
import '../../core/widgets/glass_container.dart';
import '../../services/wallet_identity_service.dart';
import '../garage/weapon_list_screen.dart';
@@ -165,76 +165,203 @@ class _SettingsScreenState extends State<SettingsScreen> {
void _showThemeDialog() {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Apparence'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildThemeOption(ThemeMode.system, 'Automatique', Icons.brightness_auto),
_buildThemeOption(ThemeMode.light, 'Clair', Icons.light_mode),
_buildThemeOption(ThemeMode.dark, 'Sombre', Icons.dark_mode),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Fermer'),
),
],
builder: (dialogCtx) => Consumer<ThemeProvider>(
builder: (context, themeProvider, child) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final primary = themeProvider.primaryColor;
return AlertDialog(
backgroundColor: isDark ? AppTheme.darkSurface : AppTheme.lightSurface,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
title: Text(
'Personnalisation & Thème',
style: TextStyle(
fontWeight: FontWeight.w800,
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
),
),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'MODE D\'AFFICHAGE',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w800,
color: primary,
letterSpacing: 1.0,
),
),
const SizedBox(height: 8),
_buildThemeOption(themeProvider, ThemeMode.system, 'Automatique (Système)', Icons.brightness_auto),
_buildThemeOption(themeProvider, ThemeMode.light, 'Clair', Icons.light_mode_outlined),
_buildThemeOption(themeProvider, ThemeMode.dark, 'Sombre (Stand de Tir)', Icons.dark_mode_outlined),
const Divider(height: 24),
Text(
'COULEUR D\'ACCENTUATION',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w800,
color: primary,
letterSpacing: 1.0,
),
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: AppAccentColor.allAccents.map((accent) {
final isSelected = themeProvider.currentAccent.id == accent.id;
return GestureDetector(
onTap: () {
themeProvider.setAccent(accent);
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7),
decoration: BoxDecoration(
color: accent.color.withValues(alpha: isSelected ? 0.22 : 0.08),
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: isSelected ? accent.color : Colors.transparent,
width: 1.5,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 14,
height: 14,
decoration: BoxDecoration(
color: accent.color,
shape: BoxShape.circle,
boxShadow: [
if (isSelected)
BoxShadow(
color: accent.color.withValues(alpha: 0.6),
blurRadius: 6,
),
],
),
),
const SizedBox(width: 6),
Text(
accent.name,
style: TextStyle(
fontSize: 12,
fontWeight: isSelected ? FontWeight.w800 : FontWeight.w500,
color: isSelected ? accent.color : (isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary),
),
),
],
),
),
);
}).toList(),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogCtx),
child: Text('Fermer', style: TextStyle(color: primary, fontWeight: FontWeight.bold)),
),
],
);
},
),
);
}
Widget _buildThemeOption(ThemeMode mode, String label, IconData icon) {
final themeProvider = context.watch<ThemeProvider>();
Widget _buildThemeOption(
ThemeProvider themeProvider,
ThemeMode mode,
String label,
IconData icon,
) {
final isSelected = themeProvider.themeMode == mode;
final primary = themeProvider.primaryColor;
final isDark = themeProvider.themeMode == ThemeMode.dark ||
(themeProvider.themeMode == ThemeMode.system &&
WidgetsBinding.instance.platformDispatcher.platformBrightness == Brightness.dark);
return ListTile(
leading: Icon(icon, color: isSelected ? AppTheme.primaryColor : null),
title: Text(label, style: TextStyle(
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
color: isSelected ? AppTheme.primaryColor : null,
)),
trailing: isSelected ? const Icon(Icons.check, color: AppTheme.primaryColor) : null,
dense: true,
contentPadding: EdgeInsets.zero,
leading: Icon(icon, color: isSelected ? primary : (isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary), size: 20),
title: Text(
label,
style: TextStyle(
fontSize: 14,
fontWeight: isSelected ? FontWeight.w800 : FontWeight.w500,
color: isSelected ? primary : (isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary),
),
),
trailing: isSelected ? Icon(Icons.check_circle, color: primary, size: 20) : null,
onTap: () {
themeProvider.setThemeMode(mode);
Navigator.pop(context);
},
);
}
void _showIdentityDialog() {
if (_identityPhrase == null) return;
final isDark = Theme.of(context).brightness == Brightness.dark;
final primary = Theme.of(context).colorScheme.primary;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Votre Identité Unique', textAlign: TextAlign.center),
backgroundColor: isDark ? AppTheme.darkSurface : AppTheme.lightSurface,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
title: Text(
'Votre Identité Unique',
textAlign: TextAlign.center,
style: TextStyle(
fontWeight: FontWeight.w800,
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
),
),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.security, size: 48, color: AppTheme.primaryColor),
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: primary.withValues(alpha: 0.15),
shape: BoxShape.circle,
),
child: Icon(Icons.security, size: 36, color: primary),
),
const SizedBox(height: 16),
const Text(
Text(
'Cette phrase de 15 mots vous identifie de manière unique. Ne la partagez qu\'en cas de besoin.',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 14, color: Colors.grey),
style: TextStyle(
fontSize: 13,
color: isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary,
),
),
const SizedBox(height: 24),
const SizedBox(height: 20),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.grey.withAlpha(20),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppTheme.primaryColor.withAlpha(100)),
color: primary.withValues(alpha: isDark ? 0.15 : 0.08),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: primary.withValues(alpha: 0.35)),
),
child: Text(
_identityPhrase!,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
letterSpacing: 0.5,
height: 1.5,
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
),
textAlign: TextAlign.center,
),
@@ -244,11 +371,16 @@ class _SettingsScreenState extends State<SettingsScreen> {
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Fermer'),
child: Text('Fermer', style: TextStyle(color: isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary)),
),
ElevatedButton.icon(
icon: const Icon(Icons.copy, size: 18),
label: const Text('Copier'),
style: ElevatedButton.styleFrom(
backgroundColor: primary,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
onPressed: () {
_copyToClipboard();
Navigator.pop(context);
@@ -260,10 +392,15 @@ class _SettingsScreenState extends State<SettingsScreen> {
}
void _showOptInDisclaimer(bool value) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final primary = Theme.of(context).colorScheme.primary;
if (_isBanned) {
showDialog(
context: context,
builder: (context) => AlertDialog(
backgroundColor: isDark ? AppTheme.darkSurface : AppTheme.lightSurface,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
title: const Row(
children: [
Icon(Icons.block, color: AppTheme.errorColor),
@@ -299,14 +436,23 @@ class _SettingsScreenState extends State<SettingsScreen> {
context: context,
barrierDismissible: false,
builder: (context) => AlertDialog(
backgroundColor: isDark ? AppTheme.darkSurface : AppTheme.lightSurface,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
title: const Text('Participer à l\'entraînement IA', textAlign: TextAlign.center),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Center(
child: Icon(Icons.psychology, size: 48, color: AppTheme.primaryColor),
Center(
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: primary.withValues(alpha: 0.15),
shape: BoxShape.circle,
),
child: Icon(Icons.psychology, size: 40, color: primary),
),
),
const SizedBox(height: 16),
const Text(
@@ -369,7 +515,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
child: const Text('Refuser', style: TextStyle(color: Colors.grey)),
),
ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: AppTheme.primaryColor),
style: ElevatedButton.styleFrom(
backgroundColor: primary,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
onPressed: () {
_walletService.setUploadEnabled(true);
setState(() {
@@ -383,7 +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() {
final isDark = Theme.of(context).brightness == Brightness.dark;
final primary = Theme.of(context).colorScheme.primary;
final urlController = TextEditingController(text: _serverUrl);
showDialog(
context: context,
builder: (context) => AlertDialog(
backgroundColor: isDark ? AppTheme.darkSurface : AppTheme.lightSurface,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
title: const Text('Adresse du Serveur IA'),
content: Column(
mainAxisSize: MainAxisSize.min,
@@ -425,7 +580,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
child: const Text('Annuler'),
),
ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: AppTheme.primaryColor),
style: ElevatedButton.styleFrom(
backgroundColor: primary,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
onPressed: () async {
final newUrl = urlController.text.trim();
if (newUrl.isNotEmpty) {
@@ -446,24 +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() {
final isDark = Theme.of(context).brightness == Brightness.dark;
final primary = Theme.of(context).colorScheme.primary;
final descController = TextEditingController();
const supportEmail = 'monadressemaildesupport@nomdelapplication.com';
const appVersion = '1.0.0';
const appVersion = '1.0.3';
final platform = Theme.of(context).platform.name;
showDialog(
context: context,
builder: (context) => AlertDialog(
backgroundColor: isDark ? AppTheme.darkSurface : AppTheme.lightSurface,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
title: const Text('Signaler un bug'),
content: SingleChildScrollView(
child: Column(
@@ -499,6 +660,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
ElevatedButton.icon(
icon: const Icon(Icons.copy, size: 18),
label: const Text('Copier le rapport'),
style: ElevatedButton.styleFrom(
backgroundColor: primary,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
onPressed: () {
final desc = descController.text.trim();
final report = StringBuffer()
@@ -528,14 +694,17 @@ class _SettingsScreenState extends State<SettingsScreen> {
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final primary = Theme.of(context).colorScheme.primary;
return Scaffold(
appBar: AppBar(
title: const Text('Paramètres'),
),
body: ListView(
padding: const EdgeInsets.all(AppConstants.defaultPadding),
padding: const EdgeInsets.fromLTRB(16, 12, 16, 40),
children: [
_buildSectionHeader('Identité'),
_buildSectionHeader('IDENTITÉ & COMPTE', primary),
_buildSettingsTile(
context: context,
icon: Icons.fingerprint,
@@ -543,25 +712,128 @@ class _SettingsScreenState extends State<SettingsScreen> {
subtitle: _identityPhrase != null ? 'Phrase de 15 mots générée' : 'Génération en cours...',
onTap: _showIdentityDialog,
),
const SizedBox(height: 20),
_buildSectionHeader('PERSONNALISATION & THÈME', primary),
Consumer<ThemeProvider>(
builder: (context, themeProvider, child) {
return _buildSettingsTile(
context: context,
icon: Icons.color_lens_outlined,
title: 'Apparence',
subtitle: themeProvider.themeModeName,
onTap: _showThemeDialog,
return GlassContainer(
borderRadius: 16,
blur: 12,
margin: const EdgeInsets.only(bottom: 10),
padding: const EdgeInsets.all(16),
glowColor: primary,
borderColor: isDark ? primary.withValues(alpha: 0.2) : primary.withValues(alpha: 0.12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
InkWell(
onTap: _showThemeDialog,
borderRadius: BorderRadius.circular(10),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: primary.withValues(alpha: isDark ? 0.2 : 0.12),
borderRadius: BorderRadius.circular(12),
),
child: Icon(Icons.palette_outlined, color: primary, size: 22),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Apparence',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
),
),
const SizedBox(height: 2),
Text(
'${themeProvider.themeModeName}${themeProvider.currentAccent.name}',
style: TextStyle(
fontSize: 12,
color: isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary,
),
),
],
),
),
Icon(
Icons.chevron_right,
color: isDark ? AppTheme.darkTextMuted : AppTheme.lightTextMuted,
),
],
),
),
const SizedBox(height: 14),
const Divider(height: 1),
const SizedBox(height: 12),
Text(
'Couleur d\'accent active :',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary,
),
),
const SizedBox(height: 10),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: AppAccentColor.allAccents.map((accent) {
final isSelected = themeProvider.currentAccent.id == accent.id;
return Padding(
padding: const EdgeInsets.only(right: 10),
child: GestureDetector(
onTap: () => themeProvider.setAccent(accent),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: 36,
height: 36,
decoration: BoxDecoration(
color: accent.color,
shape: BoxShape.circle,
border: Border.all(
color: isSelected ? Colors.white : Colors.transparent,
width: 2.5,
),
boxShadow: [
if (isSelected)
BoxShadow(
color: accent.color.withValues(alpha: 0.65),
blurRadius: 10,
spreadRadius: 1,
),
],
),
child: isSelected
? const Icon(Icons.check, color: Colors.white, size: 18)
: null,
),
),
);
}).toList(),
),
),
],
),
);
},
),
const SizedBox(height: 24),
_buildSectionHeader('Gestion'),
const SizedBox(height: 20),
_buildSectionHeader('ARMURERIE & MATÉRIEL', primary),
_buildSettingsTile(
context: context,
icon: Icons.shield_outlined,
title: 'Mon Armurerie',
subtitle: 'Gérer mes armes et équipements',
subtitle: 'Gérer mes armes, calibres et optiques',
onTap: () async {
await Navigator.push(
context,
@@ -570,14 +842,14 @@ class _SettingsScreenState extends State<SettingsScreen> {
},
),
const SizedBox(height: 24),
_buildSectionHeader('Programme d\'Entraînement IA'),
const SizedBox(height: 20),
_buildSectionHeader('PROGRAMME D\'ENTRAÎNEMENT IA', primary),
if (_isBanned) ...[
Card(
elevation: 0,
color: AppTheme.errorColor.withValues(alpha: 0.12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12.0),
borderRadius: BorderRadius.circular(16.0),
side: BorderSide(color: AppTheme.errorColor.withValues(alpha: 0.6), width: 1.5),
),
child: Padding(
@@ -631,7 +903,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
borderRadius: BorderRadius.circular(12),
),
),
onPressed: _isCheckingStatus ? null : _refreshAccountStatus,
@@ -642,21 +914,38 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
),
] else ...[
Card(
elevation: 0,
color: Colors.transparent,
margin: const EdgeInsets.only(bottom: 8.0),
GlassContainer(
borderRadius: 16,
blur: 10,
margin: const EdgeInsets.only(bottom: 10),
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4),
child: SwitchListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 4.0),
title: const Text('Participer à l\'entraînement IA', style: TextStyle(fontWeight: FontWeight.w500)),
subtitle: const Text('Aidez-nous à améliorer la détection (soumis aux règles strictes)', style: TextStyle(fontSize: 12)),
secondary: const Icon(Icons.psychology, color: AppTheme.textPrimary),
value: _isUploadEnabled,
activeThumbColor: AppTheme.primaryColor,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12.0),
side: BorderSide(color: Colors.grey.withAlpha(50), width: 1),
contentPadding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 4.0),
title: Text(
'Participer à l\'entraînement IA',
style: TextStyle(
fontWeight: FontWeight.w700,
fontSize: 14,
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
),
),
subtitle: Text(
'Aidez-nous à améliorer la détection automatique',
style: TextStyle(
fontSize: 12,
color: isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary,
),
),
secondary: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: primary.withValues(alpha: isDark ? 0.2 : 0.12),
borderRadius: BorderRadius.circular(10),
),
child: Icon(Icons.psychology, color: primary, size: 22),
),
value: _isUploadEnabled,
activeThumbColor: primary,
onChanged: _showOptInDisclaimer,
),
),
@@ -692,20 +981,20 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
],
const SizedBox(height: 24),
_buildSectionHeader('À propos'),
const SizedBox(height: 20),
_buildSectionHeader('À PROPOS', primary),
_buildSettingsTile(
context: context,
icon: Icons.info_outline,
title: 'Version de l\'application',
subtitle: '1.0.0',
subtitle: '1.0.3 (Design Tactical & Precision)',
onTap: () {},
),
_buildSettingsTile(
context: context,
icon: Icons.bug_report_outlined,
title: 'Signaler un bug',
subtitle: 'Aidez-nous à corriger les problèmes',
subtitle: 'Aidez-nous à corriger les anomalies',
onTap: _showReportBugDialog,
),
_buildSettingsTile(
@@ -719,15 +1008,16 @@ class _SettingsScreenState extends State<SettingsScreen> {
);
}
Widget _buildSectionHeader(String title) {
Widget _buildSectionHeader(String title, Color primary) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 4.0),
padding: const EdgeInsets.fromLTRB(4, 8, 4, 10),
child: Text(
title,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: AppTheme.primaryColor,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w800,
color: primary,
letterSpacing: 1.0,
),
),
);
@@ -740,21 +1030,57 @@ class _SettingsScreenState extends State<SettingsScreen> {
String? subtitle,
required VoidCallback onTap,
}) {
return Card(
elevation: 0,
color: Colors.transparent,
margin: const EdgeInsets.only(bottom: 8.0),
child: ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 4.0),
leading: Icon(icon, color: AppTheme.textPrimary),
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)),
subtitle: subtitle != null ? Text(subtitle, style: const TextStyle(fontSize: 12)) : null,
trailing: const Icon(Icons.chevron_right, color: Colors.grey),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12.0),
side: BorderSide(color: Colors.grey.withAlpha(50), width: 1),
),
onTap: onTap,
final isDark = Theme.of(context).brightness == Brightness.dark;
final primary = Theme.of(context).colorScheme.primary;
return GlassContainer(
borderRadius: 16,
blur: 10,
margin: const EdgeInsets.only(bottom: 10),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
onTap: onTap,
child: Row(
children: [
Container(
padding: const EdgeInsets.all(9),
decoration: BoxDecoration(
color: primary.withValues(alpha: isDark ? 0.2 : 0.12),
borderRadius: BorderRadius.circular(10),
),
child: Icon(icon, color: primary, size: 20),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
),
),
if (subtitle != null) ...[
const SizedBox(height: 2),
Text(
subtitle,
style: TextStyle(
fontSize: 12,
color: isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary,
),
),
],
],
),
),
Icon(
Icons.chevron_right,
color: isDark ? AppTheme.darkTextMuted : AppTheme.lightTextMuted,
size: 20,
),
],
),
);
}
+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:path/path.dart' as p;
import 'package:share_plus/share_plus.dart';
import '../../core/theme/app_theme.dart';
import '../../core/widgets/metric_info_button.dart';
import '../../data/models/session.dart';
import '../../data/repositories/session_repository.dart';
@@ -235,6 +236,7 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
@override
Widget build(BuildContext context) {
return Scaffold(
extendBody: true,
appBar: AppBar(
title: const Text('Statistiques'),
centerTitle: true,
@@ -296,7 +298,7 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
onRefresh: _loadStatistics,
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16),
padding: const EdgeInsets.fromLTRB(16, 16, 16, 100),
child: Column(
children: [
// 2. DONNÉES RAPIDES (Tirs et Sessions)
@@ -414,13 +416,14 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
);
}
final activeColor = const Color(0xFF1A73E8);
final isDark = Theme.of(context).brightness == Brightness.dark;
final activeColor = Theme.of(context).colorScheme.primary;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration(
color: activeColor.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: activeColor.withValues(alpha: 0.3)),
color: activeColor.withValues(alpha: isDark ? 0.12 : 0.08),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: activeColor.withValues(alpha: 0.35)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -429,7 +432,15 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
children: [
Icon(Icons.compare_arrows, color: activeColor, size: 18),
const SizedBox(width: 6),
Text('Comparaison', style: TextStyle(fontWeight: FontWeight.bold, color: activeColor)),
Text(
'Comparaison de sessions',
style: TextStyle(
fontWeight: FontWeight.w700,
fontSize: 13,
color: activeColor,
letterSpacing: 0.3,
),
),
const Spacer(),
IconButton(
tooltip: 'Quitter la comparaison',
@@ -493,27 +504,39 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
List<String> items,
void Function(String?) onChanged,
) {
final theme = Theme.of(context);
final isDark = Theme.of(context).brightness == Brightness.dark;
final textSecondary = isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: theme.dividerColor),
color: isDark ? AppTheme.darkSurfaceElevated : AppTheme.lightSurfaceVariant,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isDark ? AppTheme.darkBorder : AppTheme.lightBorder,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(color: theme.textTheme.bodySmall?.color?.withValues(alpha: 0.6), fontSize: 10),
style: TextStyle(
color: textSecondary,
fontSize: 10,
fontWeight: FontWeight.w600,
),
),
DropdownButton<String>(
value: value,
isExpanded: true,
underline: Container(),
dropdownColor: theme.colorScheme.surfaceContainerHighest,
style: TextStyle(color: theme.textTheme.bodyMedium?.color, fontSize: 14),
underline: const SizedBox(),
dropdownColor: isDark ? AppTheme.darkSurfaceElevated : AppTheme.lightSurfaceVariant,
style: TextStyle(
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
fontSize: 13,
fontWeight: FontWeight.w600,
),
items: items
.map(
(String val) =>
@@ -529,28 +552,45 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
// Widget pour les petites cartes de stats
Widget _buildQuickStat(String label, String value, IconData icon) {
final theme = Theme.of(context);
final isDark = Theme.of(context).brightness == Brightness.dark;
final primary = Theme.of(context).colorScheme.primary;
return Container(
padding: const EdgeInsets.all(16),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(12),
color: isDark ? AppTheme.darkSurface : AppTheme.lightSurface,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: isDark ? AppTheme.darkBorder : AppTheme.lightBorder,
),
),
child: Column(
children: [
Icon(icon, color: const Color(0xFF1A73E8), size: 20),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: primary.withValues(alpha: isDark ? 0.15 : 0.1),
borderRadius: BorderRadius.circular(10),
),
child: Icon(icon, color: primary, size: 20),
),
const SizedBox(height: 8),
Text(
value,
style: TextStyle(
color: theme.textTheme.titleLarge?.color,
fontSize: 20,
fontWeight: FontWeight.bold,
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
fontSize: 22,
fontWeight: FontWeight.w800,
letterSpacing: -0.5,
),
),
const SizedBox(height: 2),
Text(
label,
style: TextStyle(color: theme.textTheme.bodySmall?.color?.withValues(alpha: 0.6), fontSize: 12),
style: TextStyle(
color: isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
],
),
@@ -564,12 +604,15 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
List<double> dataPoints, {
List<MetricExplanation>? explanations,
}) {
final theme = Theme.of(context);
final isDark = Theme.of(context).brightness == Brightness.dark;
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(12),
color: isDark ? AppTheme.darkSurface : AppTheme.lightSurface,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: isDark ? AppTheme.darkBorder : AppTheme.lightBorder,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -578,7 +621,11 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
children: [
Text(
title,
style: TextStyle(color: theme.textTheme.bodyMedium?.color?.withValues(alpha: 0.7), fontSize: 14),
style: TextStyle(
color: isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary,
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
if (explanations != null) ...[
const Spacer(),
@@ -586,15 +633,16 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
],
],
),
const SizedBox(height: 10),
const SizedBox(height: 8),
Row(
children: [
Text(
value,
style: TextStyle(
color: theme.textTheme.headlineMedium?.color,
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
fontSize: 24,
fontWeight: FontWeight.bold,
fontWeight: FontWeight.w800,
letterSpacing: -0.5,
),
),
const SizedBox(width: 20),