Files
impact/lib/features/home/home_screen.dart
qlionbleusamandClaude Opus 5 32582aba3d feat(tutorial): guide the first launch with spotlighted steps and gesture hands
Ajoute un didacticiel joué à la première utilisation : un voile sombre perce un
trou de lumière autour de l'élément à découvrir, une main animée mime le geste
attendu (tap, appui long, glisser, pincement à deux doigts pour zoomer) et une
bulle explique l'étape avec sa progression.

- visite d'accueil : nouvelle session, télémétrie, barre de navigation, réglages
- visite de l'éditeur d'impacts : ajouter, déplacer, zoomer au pincement, valider
- chaque visite n'est jouée qu'une fois (SharedPreferences)
- Paramètres > Aide & didacticiel > Revoir le didacticiel : réinitialise tout et
  relance la visite au retour sur l'écran concerné

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 11:46:02 +02:00

1096 lines
37 KiB
Dart

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:intl/intl.dart';
import '../../main_navigation_holder.dart';
import '../../core/theme/app_theme.dart';
import '../../core/widgets/glass_container.dart';
import '../../data/models/session.dart';
import '../../data/repositories/session_repository.dart';
import '../capture/capture_screen.dart';
import '../history/history_screen.dart';
import '../history/session_detail_screen.dart';
import '../session/session_setup_screen.dart';
import '../session/session_provider.dart';
import '../settings/settings_screen.dart';
import '../statistics/statistics_screen.dart';
import '../tutorial/tutorial_coach.dart';
import '../tutorial/tutorial_provider.dart';
import '../tutorial/tutorial_step.dart';
import '../../services/tutorial_service.dart';
import 'widgets/stats_card.dart';
class HomeScreen extends StatefulWidget {
final int refreshTick;
const HomeScreen({super.key, this.refreshTick = 0});
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
Map<String, dynamic>? _stats;
List<Session> _recentSessions = [];
bool _isLoading = true;
SessionProvider? _sessionProvider;
bool _wasSessionActive = false;
// Clés utilisées par le didacticiel pour mettre les éléments en avant.
final GlobalKey _tutoActionKey = GlobalKey();
final GlobalKey _tutoStatsKey = GlobalKey();
final GlobalKey _tutoSettingsKey = GlobalKey();
TutorialProvider? _tutorialProvider;
@override
void initState() {
super.initState();
_loadStats();
// Première utilisation : la visite guidée démarre dès le premier rendu.
WidgetsBinding.instance.addPostFrameCallback((_) => _maybeStartTutorial());
}
@override
void didUpdateWidget(HomeScreen oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.refreshTick != widget.refreshTick) {
_loadStats();
}
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
final provider = context.read<SessionProvider>();
if (provider != _sessionProvider) {
_sessionProvider?.removeListener(_onSessionChanged);
_sessionProvider = provider;
_sessionProvider!.addListener(_onSessionChanged);
_wasSessionActive = provider.isSessionActive;
}
final tutorial = context.read<TutorialProvider>();
if (tutorial != _tutorialProvider) {
_tutorialProvider?.removeListener(_onTutorialChanged);
_tutorialProvider = tutorial;
_tutorialProvider!.addListener(_onTutorialChanged);
}
}
/// Les préférences du didacticiel sont lues de façon asynchrone : on
/// retente le démarrage dès qu'elles sont disponibles.
void _onTutorialChanged() {
if (!mounted) return;
WidgetsBinding.instance.addPostFrameCallback((_) => _maybeStartTutorial());
}
/// Lance la visite guidée de l'accueil si elle n'a pas encore été vue
/// (première utilisation, ou didacticiel relancé depuis les paramètres).
void _maybeStartTutorial() {
if (!mounted) return;
if (!(ModalRoute.of(context)?.isCurrent ?? false)) return;
TutorialCoach.maybeStart(
context,
tourId: TutorialTours.home,
stepsBuilder: _buildHomeTutorialSteps,
);
}
List<TutorialStep> _buildHomeTutorialSteps() {
final isSessionActive = context.read<SessionProvider>().isSessionActive;
return [
const TutorialStep(
title: 'Bienvenue dans Bully',
description:
'Ce guide rapide vous montre comment enregistrer une séance, '
'placer vos impacts et suivre vos progrès.\n'
'Touchez l\'écran pour passer à l\'étape suivante.',
icon: Icons.waving_hand_outlined,
),
TutorialStep(
targetKey: _tutoActionKey,
title: isSessionActive ? 'Votre session en cours' : 'Démarrez une session',
description: isSessionActive
? 'Reprenez la session en cours pour photographier une nouvelle '
'cible, ou clôturez-la avec le bouton rouge.'
: 'Tout part d\'ici : créez une session, choisissez votre arme, '
'puis photographiez votre cible.',
gesture: TutorialGesture.tap,
icon: Icons.add_circle_outline,
spotPadding: 10,
),
TutorialStep(
targetKey: _tutoStatsKey,
title: 'Votre télémétrie',
description:
'Sessions, tirs analysés, score moyen et meilleur score se mettent '
'à jour après chaque séance. « Détails » ouvre les statistiques '
'complètes.',
icon: Icons.insights_outlined,
),
TutorialStep(
targetKey: navigationDockKey,
title: 'La barre de navigation',
description:
'Accueil, Historique, Statistiques et Armurerie : vos quatre '
'espaces de travail, accessibles à tout moment.',
gesture: TutorialGesture.tap,
icon: Icons.dashboard_customize_outlined,
spotPadding: 6,
),
TutorialStep(
targetKey: _tutoSettingsKey,
title: 'Paramètres & aide',
description:
'Thème, armurerie, sauvegarde… et le bouton « Revoir le '
'didacticiel » pour rejouer ce guide quand vous le souhaitez.',
gesture: TutorialGesture.tap,
shape: TutorialHighlightShape.circle,
icon: Icons.settings_outlined,
spotPadding: 6,
),
];
}
void _onSessionChanged() {
final isActive = _sessionProvider?.isSessionActive ?? false;
if (_wasSessionActive && !isActive) {
_loadStats();
}
_wasSessionActive = isActive;
}
@override
void dispose() {
_sessionProvider?.removeListener(_onSessionChanged);
_tutorialProvider?.removeListener(_onTutorialChanged);
super.dispose();
}
Future<void> _loadStats() async {
final repository = context.read<SessionRepository>();
final stats = await repository.getStatistics();
final sessions = await repository.getAllSessions();
if (mounted) {
setState(() {
_stats = stats;
_recentSessions = sessions;
_isLoading = false;
});
}
}
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final primaryColor = Theme.of(context).colorScheme.primary;
final textSecondary = isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary;
return Scaffold(
extendBody: true,
appBar: AppBar(
titleSpacing: 16,
leading: Padding(
padding: const EdgeInsets.only(left: 16),
child: Center(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: (isDark ? Colors.white : Colors.black).withValues(alpha: 0.06),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: isDark ? AppTheme.darkBorder : AppTheme.lightBorder,
),
),
child: Text(
'v1.0.3',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
color: textSecondary,
letterSpacing: 0.5,
),
),
),
),
),
leadingWidth: 72,
title: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: primaryColor.withValues(alpha: 0.18),
borderRadius: BorderRadius.circular(8),
),
child: Icon(Icons.gps_fixed, color: primaryColor, size: 18),
),
const SizedBox(width: 10),
const Text(
'BULLY',
style: TextStyle(
fontWeight: FontWeight.w900,
letterSpacing: 2.0,
fontSize: 20,
),
),
],
),
actions: [
IconButton(
key: _tutoSettingsKey,
icon: const Icon(Icons.settings_outlined),
onPressed: () => _navigateToSettings(context),
tooltip: 'Paramètres',
),
const SizedBox(width: 8),
],
),
body: Stack(
children: [
// Ambiance lumineuse d'arrière-plan (lueurs dégradées)
if (isDark) ...[
Positioned(
top: -60,
right: -60,
child: Container(
width: 260,
height: 260,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: RadialGradient(
colors: [
primaryColor.withValues(alpha: 0.18),
Colors.transparent,
],
),
),
),
),
Positioned(
top: 300,
left: -80,
child: Container(
width: 220,
height: 220,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: RadialGradient(
colors: [
AppTheme.secondaryColor.withValues(alpha: 0.09),
Colors.transparent,
],
),
),
),
),
],
RefreshIndicator(
onRefresh: _loadStats,
color: primaryColor,
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
// Padding bas important pour laisser respirer le dock flottant
padding: const EdgeInsets.fromLTRB(16, 8, 16, 100),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildHeader(isDark, primaryColor),
const SizedBox(height: 16),
KeyedSubtree(
key: _tutoActionKey,
child: _buildMainActionSection(context, isDark, primaryColor),
),
const SizedBox(height: 22),
if (_isLoading)
const Padding(
padding: EdgeInsets.symmetric(vertical: 40),
child: Center(child: CircularProgressIndicator()),
)
else if (_stats != null) ...[
_buildStatsSection(isDark, primaryColor),
const SizedBox(height: 22),
_buildRecentSessionsSection(isDark, primaryColor),
],
],
),
),
),
],
),
);
}
Widget _buildHeader(bool isDark, Color primaryColor) {
return GlassContainer(
borderRadius: 20,
blur: 16,
glowColor: primaryColor,
borderColor: isDark
? primaryColor.withValues(alpha: 0.25)
: primaryColor.withValues(alpha: 0.15),
padding: const EdgeInsets.all(18),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
primaryColor,
HSLColor.fromColor(primaryColor).withLightness(
(HSLColor.fromColor(primaryColor).lightness + 0.15).clamp(0.0, 1.0),
).toColor(),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: primaryColor.withValues(alpha: 0.35),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
child: const Icon(
Icons.track_changes,
size: 28,
color: Colors.white,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: primaryColor.withValues(alpha: isDark ? 0.25 : 0.15),
borderRadius: BorderRadius.circular(6),
),
child: Text(
'BALISTIQUE & CIBLES',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w800,
letterSpacing: 1.2,
color: primaryColor,
),
),
),
const SizedBox(width: 6),
Container(
width: 6,
height: 6,
decoration: const BoxDecoration(
color: AppTheme.secondaryColor,
shape: BoxShape.circle,
),
),
],
),
const SizedBox(height: 4),
Text(
'Télémétrie de Tir',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w800,
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
letterSpacing: -0.3,
),
),
const SizedBox(height: 2),
Text(
'Analysez vos groupements et scores',
style: TextStyle(
fontSize: 12,
color: isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary,
),
),
],
),
),
],
),
);
}
Widget _buildMainActionSection(BuildContext context, bool isDark, Color primaryColor) {
final sessionProvider = context.watch<SessionProvider>();
final isSessionActive = sessionProvider.isSessionActive;
if (!isSessionActive) {
return Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
gradient: LinearGradient(
colors: [
HSLColor.fromColor(primaryColor).withLightness((HSLColor.fromColor(primaryColor).lightness - 0.1).clamp(0.0, 1.0)).toColor(),
primaryColor,
HSLColor.fromColor(primaryColor).withLightness((HSLColor.fromColor(primaryColor).lightness + 0.15).clamp(0.0, 1.0)).toColor(),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
boxShadow: [
BoxShadow(
color: primaryColor.withValues(alpha: isDark ? 0.45 : 0.3),
blurRadius: 20,
spreadRadius: -2,
offset: const Offset(0, 8),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: () => _navigateToSessionSetup(context),
splashColor: Colors.white24,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 18, horizontal: 20),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.25),
shape: BoxShape.circle,
),
child: const Icon(Icons.add, color: Colors.white, size: 22),
),
const SizedBox(width: 14),
const Text(
'NOUVELLE SESSION DE TIR',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w900,
color: Colors.white,
letterSpacing: 1.0,
),
),
],
),
),
),
),
),
);
}
// Session en cours : Glass Card dynamique avec halo vert
return GlassContainer(
borderRadius: 20,
blur: 16,
glowColor: AppTheme.secondaryColor,
borderColor: AppTheme.secondaryColor.withValues(alpha: isDark ? 0.45 : 0.35),
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: AppTheme.secondaryColor.withValues(alpha: isDark ? 0.22 : 0.15),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: AppTheme.secondaryColor.withValues(alpha: 0.4),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 8,
height: 8,
decoration: const BoxDecoration(
color: AppTheme.secondaryColor,
shape: BoxShape.circle,
),
),
const SizedBox(width: 6),
const Text(
'SESSION EN COURS',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w800,
color: AppTheme.secondaryColor,
letterSpacing: 0.8,
),
),
],
),
),
const Spacer(),
if (sessionProvider.currentWeapon != null)
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: (isDark ? Colors.white : Colors.black).withValues(alpha: 0.06),
borderRadius: BorderRadius.circular(6),
),
child: Text(
sessionProvider.currentWeapon!,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
),
),
),
],
),
const SizedBox(height: 14),
Row(
children: [
Expanded(
flex: 4,
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14),
gradient: const LinearGradient(
colors: [Color(0xFF10B981), Color(0xFF059669)],
),
boxShadow: [
BoxShadow(
color: AppTheme.secondaryColor.withValues(alpha: 0.3),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
child: ElevatedButton.icon(
onPressed: () => _navigateToSessionSetup(context),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.transparent,
foregroundColor: Colors.white,
shadowColor: Colors.transparent,
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
),
icon: const Icon(Icons.play_arrow_rounded, size: 24),
label: const Text(
'Reprendre la session',
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w800),
),
),
),
),
const SizedBox(width: 10),
Expanded(
flex: 1,
child: OutlinedButton(
onPressed: () => _confirmEndSession(context),
style: OutlinedButton.styleFrom(
foregroundColor: AppTheme.errorColor,
side: BorderSide(
color: AppTheme.errorColor.withValues(alpha: 0.7),
width: 1.4,
),
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
),
child: const Icon(Icons.close, size: 20),
),
),
],
),
],
),
);
}
void _confirmEndSession(BuildContext context) {
showDialog(
context: context,
builder: (BuildContext dialogContext) {
return AlertDialog(
title: const Text('Clôturer la session ?'),
content: const Text(
'Êtes-vous sûr de vouloir terminer la session en cours ? Vos tirs actuels seront enregistrés.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: const Text('Annuler'),
),
ElevatedButton(
onPressed: () {
Navigator.pop(dialogContext);
context.read<SessionProvider>().endSession();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Session terminée avec succès'),
backgroundColor: AppTheme.successColor,
),
);
openMainTab(mainTabStats);
},
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.errorColor,
foregroundColor: Colors.white,
),
child: const Text('Terminer'),
),
],
);
},
);
}
Widget _buildStatsSection(bool isDark, Color primaryColor) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Télémétrie Globale',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w800,
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
letterSpacing: 0.2,
),
),
InkWell(
onTap: () => _navigateToStatistics(context),
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
child: Row(
children: [
Text(
'Détails',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: primaryColor,
),
),
Icon(Icons.chevron_right, size: 18, color: primaryColor),
],
),
),
),
],
),
const SizedBox(height: 12),
Row(
key: _tutoStatsKey,
children: [
Expanded(
child: InkWell(
onTap: () => _navigateToStatistics(context),
borderRadius: BorderRadius.circular(18),
child: StatsCard(
icon: Icons.assignment_outlined,
title: 'Sessions',
value: '${_stats!['totalSessions']}',
color: primaryColor,
),
),
),
const SizedBox(width: 12),
Expanded(
child: StatsCard(
icon: Icons.track_changes,
title: 'Tirs analysés',
value: '${_stats!['totalShots']}',
color: AppTheme.secondaryColor,
),
),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: StatsCard(
icon: Icons.pie_chart_outline,
title: 'Score moyen',
value: '${_stats!['averageScore'].toStringAsFixed(1)} pts',
subtitle: 'sur 10 max',
color: AppTheme.accentGold,
),
),
const SizedBox(width: 12),
Expanded(
child: StatsCard(
icon: Icons.star_border,
title: 'Meilleur score',
value: '${_stats!['bestScore']} pts',
color: AppTheme.secondaryColor,
),
),
],
),
const SizedBox(height: 14),
GlassContainer(
borderRadius: 20,
blur: 16,
borderColor: isDark ? Colors.white.withValues(alpha: 0.1) : Colors.black.withValues(alpha: 0.06),
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Progression récente',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: (isDark ? Colors.white : Colors.black).withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(6),
),
child: Text(
'7 dernières séances',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w600,
color: isDark ? AppTheme.darkTextMuted : AppTheme.lightTextMuted,
),
),
),
],
),
const SizedBox(height: 12),
SizedBox(
height: 120,
child: _recentSessions.length < 2
? Center(
child: Text(
'2 séances minimum pour afficher la courbe',
style: TextStyle(
color: isDark ? AppTheme.darkTextMuted : AppTheme.lightTextMuted,
fontSize: 12,
),
),
)
: _buildHomeTrendChart(isDark, primaryColor),
),
],
),
),
],
);
}
Widget _buildHomeTrendChart(bool isDark, Color primaryColor) {
final sorted = List<Session>.from(_recentSessions)
..sort((a, b) => a.createdAt.compareTo(b.createdAt));
final display = sorted.length > 7 ? sorted.sublist(sorted.length - 7) : sorted;
final gridColor = isDark ? Colors.white.withValues(alpha: 0.06) : Colors.black.withValues(alpha: 0.04);
final textColor = isDark ? AppTheme.darkTextMuted : AppTheme.lightTextMuted;
return LineChart(
LineChartData(
gridData: FlGridData(
show: true,
drawVerticalLine: false,
getDrawingHorizontalLine: (value) => FlLine(
color: gridColor,
strokeWidth: 1,
),
),
titlesData: FlTitlesData(
show: true,
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 20,
interval: 1,
getTitlesWidget: (value, meta) {
final index = value.toInt();
if (index < 0 || index >= display.length) return const SizedBox();
final date = display[index].createdAt;
return Padding(
padding: const EdgeInsets.only(top: 4.0),
child: Text(
'${date.day}/${date.month}',
style: TextStyle(fontSize: 10, color: textColor),
),
);
},
),
),
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 24,
interval: 2,
getTitlesWidget: (value, meta) {
if (value < 0 || value > 10) return const SizedBox();
return Text(
value.toInt().toString(),
style: TextStyle(fontSize: 10, color: textColor),
);
},
),
),
),
borderData: FlBorderData(show: false),
lineBarsData: [
LineChartBarData(
spots: display.asMap().entries.map((e) {
return FlSpot(e.key.toDouble(), e.value.averageScore);
}).toList(),
isCurved: true,
curveSmoothness: 0.35,
color: primaryColor,
barWidth: 3,
isStrokeCapRound: true,
dotData: FlDotData(
show: true,
getDotPainter: (spot, percent, barData, index) => FlDotCirclePainter(
radius: 3.5,
color: Colors.white,
strokeWidth: 2,
strokeColor: primaryColor,
),
),
belowBarData: BarAreaData(
show: true,
gradient: LinearGradient(
colors: [
primaryColor.withValues(alpha: isDark ? 0.35 : 0.2),
primaryColor.withValues(alpha: 0.0),
],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
),
],
),
);
}
Widget _buildRecentSessionsSection(bool isDark, Color primaryColor) {
if (_recentSessions.isEmpty) return const SizedBox();
final sessionsToShow = _recentSessions.take(3).toList();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Dernières Séances',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w800,
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
letterSpacing: 0.2,
),
),
InkWell(
onTap: () => _navigateToHistory(context),
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
child: Text(
'Voir tout (${_recentSessions.length})',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: primaryColor,
),
),
),
),
],
),
const SizedBox(height: 12),
...sessionsToShow.map((session) => _buildRecentSessionTile(session, isDark, primaryColor)),
],
);
}
Widget _buildRecentSessionTile(Session session, bool isDark, Color primaryColor) {
final dateFormat = DateFormat('dd MMM yyyy • HH:mm', 'fr_FR');
final formattedDate = dateFormat.format(session.createdAt);
return GlassContainer(
borderRadius: 16,
blur: 12,
margin: const EdgeInsets.only(bottom: 10),
padding: const EdgeInsets.all(14),
onTap: () async {
await Navigator.push(
context,
MaterialPageRoute(
builder: (_) => SessionDetailScreen(session: session),
),
);
_loadStats();
},
child: Row(
children: [
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
primaryColor.withValues(alpha: 0.25),
primaryColor.withValues(alpha: 0.08),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: primaryColor.withValues(alpha: 0.35),
),
),
child: Center(
child: Text(
'${session.totalScore}',
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.w900,
color: primaryColor,
),
),
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
session.weapon,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text(
formattedDate,
style: TextStyle(
fontSize: 11,
color: isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary,
),
),
],
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: (isDark ? Colors.white : Colors.black).withValues(alpha: 0.06),
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: isDark ? AppTheme.darkBorder : AppTheme.lightBorder,
),
),
child: Text(
'${session.distance}m',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
color: isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary,
),
),
),
const SizedBox(height: 4),
Text(
'${session.totalShots} tirs',
style: TextStyle(
fontSize: 11,
color: isDark ? AppTheme.darkTextMuted : AppTheme.lightTextMuted,
),
),
],
),
],
),
);
}
void _navigateToSessionSetup(BuildContext context) async {
final sessionProvider = context.read<SessionProvider>();
if (sessionProvider.isSessionActive) {
await Navigator.push(
context,
MaterialPageRoute(builder: (_) => const CaptureScreen()),
);
} else {
await Navigator.push(
context,
MaterialPageRoute(builder: (_) => const SessionSetupScreen()),
);
}
_loadStats();
}
void _navigateToHistory(BuildContext context) async {
await Navigator.push(
context,
MaterialPageRoute(builder: (_) => const HistoryScreen()),
);
_loadStats();
}
void _navigateToStatistics(BuildContext context) async {
await Navigator.push(
context,
MaterialPageRoute(builder: (_) => const StatisticsScreen()),
);
_loadStats();
}
void _navigateToSettings(BuildContext context) async {
await Navigator.push(
context,
MaterialPageRoute(builder: (_) => const SettingsScreen()),
);
if (!mounted) return;
_loadStats();
// Si le didacticiel a été relancé depuis les paramètres, il redémarre
// une fois la transition de retour terminée.
await Future<void>.delayed(const Duration(milliseconds: 350));
_maybeStartTutorial();
}
}