From 32582aba3ddf4318266b91a97339341b07155a24 Mon Sep 17 00:00:00 2001 From: qlionbleusam Date: Sat, 29 Aug 2026 11:45:51 +0200 Subject: [PATCH] feat(tutorial): guide the first launch with spotlighted steps and gesture hands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CLAUDE.md | 19 + .../analysis/impact_editor_screen.dart | 64 +++ lib/features/home/home_screen.dart | 108 +++- lib/features/settings/settings_screen.dart | 37 ++ lib/features/tutorial/tutorial_coach.dart | 68 +++ lib/features/tutorial/tutorial_provider.dart | 54 ++ lib/features/tutorial/tutorial_step.dart | 76 +++ .../tutorial/widgets/tutorial_hand.dart | 379 +++++++++++++ .../tutorial/widgets/tutorial_overlay.dart | 504 ++++++++++++++++++ lib/main.dart | 2 + lib/main_navigation_holder.dart | 4 + lib/services/tutorial_service.dart | 46 ++ test/features/tutorial_overlay_test.dart | 127 +++++ test/services/tutorial_service_test.dart | 79 +++ 14 files changed, 1566 insertions(+), 1 deletion(-) create mode 100644 lib/features/tutorial/tutorial_coach.dart create mode 100644 lib/features/tutorial/tutorial_provider.dart create mode 100644 lib/features/tutorial/tutorial_step.dart create mode 100644 lib/features/tutorial/widgets/tutorial_hand.dart create mode 100644 lib/features/tutorial/widgets/tutorial_overlay.dart create mode 100644 lib/services/tutorial_service.dart create mode 100644 test/features/tutorial_overlay_test.dart create mode 100644 test/services/tutorial_service_test.dart diff --git a/CLAUDE.md b/CLAUDE.md index 40c6561d..e5708969 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -98,6 +98,19 @@ flutter test --coverage - Visualisation des sessions passées - Suppression de sessions +### Didacticiel (visite guidée) +- Démarre automatiquement à la **première utilisation** sur l'écran d'accueil : + voile sombre, « trou de lumière » autour de l'élément à découvrir et bulle + explicative (bouton nouvelle session, télémétrie, barre de navigation, réglages) +- Visite dédiée à la **première ouverture de l'éditeur d'impacts** : ajouter, + déplacer, **pincer pour zoomer**, valider +- **Main animée** qui mime le geste attendu (tap, appui long, glisser, + pincement à deux doigts, balayage), dessinée au CustomPainter — aucun asset +- « Passer » interrompt la visite, un tap n'importe où passe à l'étape suivante ; + chaque visite n'est jouée qu'une fois (mémorisée dans les SharedPreferences) +- **Paramètres > Aide & didacticiel > Revoir le didacticiel** : réinitialise + toutes les visites, qui rejouent dès le retour sur l'écran concerné + ### Interface utilisateur - Thème sombre adapté au tir - Support multilingue (Français) @@ -135,3 +148,9 @@ history_chart.dart Graphique d'évolution des 10 dernières sessions statistics_screen.dart Écran statistiques avec filtrage par période heat_map_widget.dart Heat map avec gradient bleu→rouge backup_service.dart Export/import JSON des sessions, stats et armurerie +tutorial_service.dart Persistance des visites guidées déjà vues (SharedPreferences) +tutorial_provider.dart État du didacticiel : visites à jouer, réactivation +tutorial_coach.dart Lance une visite guidée par-dessus l'écran courant +tutorial_step.dart Modèle d'étape : cible, texte, geste, forme du spot +tutorial_overlay.dart Voile sombre, trou de lumière et bulle explicative +tutorial_hand.dart Main animée qui mime le geste (tap, appui long, pincement…) diff --git a/lib/features/analysis/impact_editor_screen.dart b/lib/features/analysis/impact_editor_screen.dart index 45dccacf..b38572c2 100644 --- a/lib/features/analysis/impact_editor_screen.dart +++ b/lib/features/analysis/impact_editor_screen.dart @@ -25,6 +25,9 @@ import 'package:provider/provider.dart'; import '../../core/theme/app_theme.dart'; import '../../data/models/shot.dart'; +import '../../services/tutorial_service.dart'; +import '../tutorial/tutorial_coach.dart'; +import '../tutorial/tutorial_step.dart'; import 'analysis_provider.dart'; import 'widgets/target_overlay.dart'; @@ -43,12 +46,71 @@ class _ImpactEditorScreenState extends State { double _currentZoomScale = 1.0; String? _movingShotId; + // Clés du didacticiel : zone de travail et bouton de validation. + final GlobalKey _tutoCanvasKey = GlobalKey(); + final GlobalKey _tutoValidateKey = GlobalKey(); + @override void initState() { super.initState(); _transformationController.addListener(_onTransformChanged); + // Première ouverture de l'éditeur : on montre les gestes disponibles. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + TutorialCoach.maybeStart( + context, + tourId: TutorialTours.impactEditor, + stepsBuilder: _buildEditorTutorialSteps, + ); + }); } + List _buildEditorTutorialSteps() => [ + TutorialStep( + targetKey: _tutoCanvasKey, + title: 'Ajouter un impact', + description: + 'Touchez la cible à l\'endroit de l\'impact : il est ajouté ' + 'immédiatement, même collé à un impact déjà placé. Le score est ' + 'calculé automatiquement selon la zone touchée.', + gesture: TutorialGesture.tap, + icon: Icons.add_location_alt_outlined, + spotPadding: 0, + ), + TutorialStep( + targetKey: _tutoCanvasKey, + title: 'Déplacer un impact', + description: + 'Appui long sur un impact, puis glissez le doigt pour l\'ajuster ' + 'au millimètre. L\'impact reste visible au-dessus du doigt.', + gesture: TutorialGesture.drag, + icon: Icons.open_with, + spotPadding: 0, + ), + TutorialStep( + targetKey: _tutoCanvasKey, + title: 'Zoomer sur la cible', + description: + 'Écartez deux doigts pour zoomer (jusqu\'à 12×) et placer vos ' + 'impacts avec précision ; rapprochez-les pour dézoomer. À un ' + 'doigt, vous faites glisser l\'image.', + gesture: TutorialGesture.pinch, + icon: Icons.zoom_in, + spotPadding: 0, + ), + TutorialStep( + targetKey: _tutoValidateKey, + title: 'Valider vos impacts', + description: + 'VALIDER renvoie vers la synthèse avec les scores et le ' + 'groupement. La corbeille, à gauche, efface tous les impacts ' + 'sans toucher à la calibration.', + gesture: TutorialGesture.tap, + icon: Icons.check_circle_outline, + spotPadding: 8, + ), + ]; + @override void dispose() { _transformationController.removeListener(_onTransformChanged); @@ -123,6 +185,7 @@ class _ImpactEditorScreenState extends State { ), const SizedBox(width: 12), FloatingActionButton.extended( + key: _tutoValidateKey, heroTag: 'validate_impacts', onPressed: () => Navigator.pop(context, true), backgroundColor: AppTheme.primaryColor, @@ -147,6 +210,7 @@ class _ImpactEditorScreenState extends State { // Zone image plein écran : InteractiveViewer dans un body nu. Expanded( + key: _tutoCanvasKey, child: InteractiveViewer( transformationController: _transformationController, minScale: 1.0, diff --git a/lib/features/home/home_screen.dart b/lib/features/home/home_screen.dart index c14fc0fc..a5aecddf 100644 --- a/lib/features/home/home_screen.dart +++ b/lib/features/home/home_screen.dart @@ -14,6 +14,10 @@ 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 { @@ -33,10 +37,18 @@ class _HomeScreenState extends State { 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 @@ -57,6 +69,89 @@ class _HomeScreenState extends State { _sessionProvider!.addListener(_onSessionChanged); _wasSessionActive = provider.isSessionActive; } + + final tutorial = context.read(); + 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 _buildHomeTutorialSteps() { + final isSessionActive = context.read().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() { @@ -70,6 +165,7 @@ class _HomeScreenState extends State { @override void dispose() { _sessionProvider?.removeListener(_onSessionChanged); + _tutorialProvider?.removeListener(_onTutorialChanged); super.dispose(); } @@ -146,6 +242,7 @@ class _HomeScreenState extends State { ), actions: [ IconButton( + key: _tutoSettingsKey, icon: const Icon(Icons.settings_outlined), onPressed: () => _navigateToSettings(context), tooltip: 'Paramètres', @@ -205,7 +302,10 @@ class _HomeScreenState extends State { children: [ _buildHeader(isDark, primaryColor), const SizedBox(height: 16), - _buildMainActionSection(context, isDark, primaryColor), + KeyedSubtree( + key: _tutoActionKey, + child: _buildMainActionSection(context, isDark, primaryColor), + ), const SizedBox(height: 22), if (_isLoading) const Padding( @@ -596,6 +696,7 @@ class _HomeScreenState extends State { ), const SizedBox(height: 12), Row( + key: _tutoStatsKey, children: [ Expanded( child: InkWell( @@ -985,6 +1086,11 @@ class _HomeScreenState extends State { 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.delayed(const Duration(milliseconds: 350)); + _maybeStartTutorial(); } } \ No newline at end of file diff --git a/lib/features/settings/settings_screen.dart b/lib/features/settings/settings_screen.dart index 2105c9ed..d962651f 100644 --- a/lib/features/settings/settings_screen.dart +++ b/lib/features/settings/settings_screen.dart @@ -9,6 +9,7 @@ import '../../core/theme/theme_provider.dart'; import '../../core/widgets/glass_container.dart'; import '../../services/wallet_identity_service.dart'; import '../garage/weapon_list_screen.dart'; +import '../tutorial/tutorial_provider.dart'; class SettingsScreen extends StatefulWidget { const SettingsScreen({super.key}); @@ -708,6 +709,26 @@ class _SettingsScreenState extends State { ); } + /// Réactive le didacticiel : toutes les visites guidées sont remises à zéro + /// et celle de l'accueil redémarre dès le retour sur l'écran principal. + Future _restartTutorial() async { + final tutorial = context.read(); + final messenger = ScaffoldMessenger.of(context); + final navigator = Navigator.of(context); + + await tutorial.restart(); + if (!mounted) return; + + navigator.pop(); + messenger.showSnackBar( + const SnackBar( + content: Text('Didacticiel réactivé : la visite guidée redémarre.'), + backgroundColor: AppTheme.successColor, + duration: Duration(seconds: 3), + ), + ); + } + @override Widget build(BuildContext context) { final isDark = Theme.of(context).brightness == Brightness.dark; @@ -997,6 +1018,22 @@ class _SettingsScreenState extends State { ), ], + const SizedBox(height: 20), + _buildSectionHeader('AIDE & DIDACTICIEL', primary), + Consumer( + builder: (context, tutorial, child) { + return _buildSettingsTile( + context: context, + icon: Icons.school_outlined, + title: 'Revoir le didacticiel', + subtitle: tutorial.hasSeenIntro + ? 'Rejouer la visite guidée de l\'application' + : 'Visite guidée en attente sur l\'écran d\'accueil', + onTap: _restartTutorial, + ); + }, + ), + const SizedBox(height: 20), _buildSectionHeader('À PROPOS', primary), _buildSettingsTile( diff --git a/lib/features/tutorial/tutorial_coach.dart b/lib/features/tutorial/tutorial_coach.dart new file mode 100644 index 00000000..a7ca80b6 --- /dev/null +++ b/lib/features/tutorial/tutorial_coach.dart @@ -0,0 +1,68 @@ +/// Point d'entrée du didacticiel : lance une visite guidée par-dessus l'écran +/// courant (route transparente) et mémorise qu'elle a été vue. +library; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'tutorial_provider.dart'; +import 'tutorial_step.dart'; +import 'widgets/tutorial_overlay.dart'; + +class TutorialCoach { + const TutorialCoach._(); + + static bool _isShowing = false; + + /// Une visite est-elle déjà affichée ? (évite les doubles déclenchements) + static bool get isShowing => _isShowing; + + /// Joue la visite [tourId] uniquement si elle n'a jamais été vue. + /// + /// [stepsBuilder] n'est évalué que si la visite doit réellement démarrer : + /// les clés des widgets sont ainsi lues au dernier moment. + static Future maybeStart( + BuildContext context, { + required String tourId, + required List Function() stepsBuilder, + }) async { + if (_isShowing) return; + if (!context.read().shouldRun(tourId)) return; + await start(context, tourId: tourId, steps: stepsBuilder()); + } + + /// Joue la visite [tourId], même si elle a déjà été vue. + static Future start( + BuildContext context, { + required String tourId, + required List steps, + }) async { + if (_isShowing || steps.isEmpty) return; + + final provider = context.read(); + final navigator = Navigator.of(context, rootNavigator: true); + _isShowing = true; + + try { + await navigator.push( + PageRouteBuilder( + opaque: false, + barrierDismissible: false, + barrierColor: Colors.transparent, + transitionDuration: const Duration(milliseconds: 240), + reverseTransitionDuration: const Duration(milliseconds: 180), + pageBuilder: (routeContext, animation, _) => FadeTransition( + opacity: animation, + child: TutorialOverlay( + steps: steps, + onFinished: () => Navigator.of(routeContext).pop(), + ), + ), + ), + ); + } finally { + _isShowing = false; + } + + await provider.complete(tourId); + } +} diff --git a/lib/features/tutorial/tutorial_provider.dart b/lib/features/tutorial/tutorial_provider.dart new file mode 100644 index 00000000..996ad052 --- /dev/null +++ b/lib/features/tutorial/tutorial_provider.dart @@ -0,0 +1,54 @@ +/// État global du didacticiel : quelles visites guidées restent à jouer. +/// +/// Le provider est chargé au démarrage ; tant que les préférences ne sont pas +/// lues, aucune visite n'est déclenchée (évite un flash d'overlay au lancement). +library; + +import 'package:flutter/foundation.dart'; +import '../../services/tutorial_service.dart'; + +class TutorialProvider with ChangeNotifier { + TutorialProvider({TutorialService? service}) + : _service = service ?? TutorialService() { + load(); + } + + final TutorialService _service; + + final Set _completed = {}; + bool _loaded = false; + + /// `true` une fois les préférences lues. + bool get isLoaded => _loaded; + + /// `true` si l'utilisateur a déjà terminé (ou passé) la visite d'accueil. + bool get hasSeenIntro => _completed.contains(TutorialTours.home); + + /// Lit les visites déjà vues. Les visites terminées pendant le chargement + /// sont conservées (le résultat est fusionné, jamais écrasé). + Future load() async { + _completed.addAll(await _service.loadCompletedTours()); + _loaded = true; + notifyListeners(); + } + + /// Faut-il jouer la visite [tourId] ? + bool shouldRun(String tourId) => _loaded && !_completed.contains(tourId); + + /// Marque une visite comme vue (terminée ou passée) : elle ne rejouera plus. + Future complete(String tourId) async { + if (_completed.add(tourId)) { + notifyListeners(); + await _service.markCompleted(tourId); + } + } + + /// Relance le didacticiel depuis les paramètres : toutes les visites + /// redeviennent disponibles et rejoueront dès l'affichage de leur écran. + Future restart() async { + await _service.resetAll(); + _completed.clear(); + _loaded = true; + notifyListeners(); + } +} diff --git a/lib/features/tutorial/tutorial_step.dart b/lib/features/tutorial/tutorial_step.dart new file mode 100644 index 00000000..d6e58bb3 --- /dev/null +++ b/lib/features/tutorial/tutorial_step.dart @@ -0,0 +1,76 @@ +/// Modèle d'une étape de didacticiel (visite guidée). +/// +/// Une étape met en avant un élément de l'écran (le « spot ») grâce à une +/// [GlobalKey] posée sur le widget concerné, et affiche une bulle explicative +/// accompagnée, si besoin, d'une main animée qui mime le geste attendu +/// (tap, appui long, pincement…) comme dans les applications mobiles. +library; + +import 'package:flutter/widgets.dart'; + +/// Geste mimé par la main animée pendant l'étape. +enum TutorialGesture { + /// Aucune main animée : simple explication. + none, + + /// Un appui simple. + tap, + + /// Deux appuis rapprochés. + doubleTap, + + /// Un appui maintenu. + longPress, + + /// Un appui maintenu suivi d'un déplacement du doigt. + drag, + + /// Deux doigts qui s'écartent puis se rapprochent (zoom). + pinch, + + /// Un doigt qui balaie l'écran vers le haut. + swipeUp, + + /// Un doigt qui balaie l'écran horizontalement. + swipeHorizontal, +} + +/// Forme du trou de lumière découpé dans le voile sombre. +enum TutorialHighlightShape { rounded, circle } + +class TutorialStep { + /// Clé posée sur le widget à mettre en avant. + /// + /// `null` (ou clé non montée) => l'étape s'affiche comme une carte centrée, + /// utile pour les messages d'accueil et de fin. + final GlobalKey? targetKey; + + final String title; + final String description; + + /// Geste mimé par la main animée. + final TutorialGesture gesture; + + final TutorialHighlightShape shape; + + /// Marge ajoutée autour du widget mis en avant. + final double spotPadding; + + /// Icône affichée dans la bulle explicative. + final IconData? icon; + + /// Force la position de la bulle (`true` = au-dessus du spot). + /// Par défaut, la bulle se place automatiquement du côté le plus dégagé. + final bool? preferTooltipAbove; + + const TutorialStep({ + this.targetKey, + required this.title, + required this.description, + this.gesture = TutorialGesture.none, + this.shape = TutorialHighlightShape.rounded, + this.spotPadding = 8, + this.icon, + this.preferTooltipAbove, + }); +} diff --git a/lib/features/tutorial/widgets/tutorial_hand.dart b/lib/features/tutorial/widgets/tutorial_hand.dart new file mode 100644 index 00000000..00f62953 --- /dev/null +++ b/lib/features/tutorial/widgets/tutorial_hand.dart @@ -0,0 +1,379 @@ +/// Main animée du didacticiel : mime le geste attendu par l'utilisateur +/// (tap, appui long, glisser, pincement pour zoomer, balayage) comme dans les +/// tutoriels des applications mobiles. +/// +/// Tout est dessiné au CustomPainter : pas d'asset, la couleur suit l'accent +/// du thème et le rendu reste lisible sur fond clair comme sur fond sombre +/// grâce au cœur blanc entouré d'un halo coloré. +library; + +import 'dart:math' as math; +import 'package:flutter/material.dart'; +import '../tutorial_step.dart'; + +class TutorialHand extends StatefulWidget { + final TutorialGesture gesture; + final Color color; + final double size; + + const TutorialHand({ + super.key, + required this.gesture, + required this.color, + this.size = 96, + }); + + @override + State createState() => _TutorialHandState(); +} + +class _TutorialHandState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller = AnimationController( + vsync: this, + duration: _durationFor(widget.gesture), + )..repeat(); + + static Duration _durationFor(TutorialGesture gesture) { + switch (gesture) { + case TutorialGesture.pinch: + return const Duration(milliseconds: 2400); + case TutorialGesture.longPress: + case TutorialGesture.drag: + return const Duration(milliseconds: 2200); + case TutorialGesture.swipeUp: + case TutorialGesture.swipeHorizontal: + return const Duration(milliseconds: 1800); + default: + return const Duration(milliseconds: 1500); + } + } + + @override + void didUpdateWidget(TutorialHand oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.gesture != widget.gesture) { + _controller + ..stop() + ..duration = _durationFor(widget.gesture) + ..repeat(); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (widget.gesture == TutorialGesture.none) return const SizedBox.shrink(); + + return IgnorePointer( + child: SizedBox( + width: widget.size, + height: widget.size, + child: AnimatedBuilder( + animation: _controller, + builder: (context, _) => CustomPaint( + painter: _GesturePainter( + t: _controller.value, + gesture: widget.gesture, + color: widget.color, + ), + ), + ), + ), + ); + } +} + +class _GesturePainter extends CustomPainter { + final double t; + final TutorialGesture gesture; + final Color color; + + _GesturePainter({ + required this.t, + required this.gesture, + required this.color, + }); + + @override + void paint(Canvas canvas, Size size) { + final center = Offset(size.width / 2, size.height / 2); + + switch (gesture) { + case TutorialGesture.none: + return; + case TutorialGesture.tap: + _paintTap(canvas, size, center, pulses: 1); + case TutorialGesture.doubleTap: + _paintTap(canvas, size, center, pulses: 2); + case TutorialGesture.longPress: + _paintLongPress(canvas, size, center); + case TutorialGesture.drag: + _paintDrag(canvas, size, center); + case TutorialGesture.pinch: + _paintPinch(canvas, size, center); + case TutorialGesture.swipeUp: + _paintSwipe(canvas, size, center, vertical: true); + case TutorialGesture.swipeHorizontal: + _paintSwipe(canvas, size, center, vertical: false); + } + } + + // --------------------------------------------------------------- Utilitaires + + /// Onde triangulaire adoucie : 0 -> 1 -> 0 sur un cycle. + double _wave(double x) => Curves.easeInOut + .transform((x < 0.5 ? x * 2 : (1 - x) * 2).clamp(0.0, 1.0)); + + /// Doigt posé sur l'écran : cœur blanc + halo coloré. + void _paintFingertip( + Canvas canvas, + Offset position, + double radius, { + double opacity = 1.0, + }) { + canvas.drawCircle( + position, + radius * 2.1, + Paint() + ..color = color.withValues(alpha: 0.22 * opacity) + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 8), + ); + canvas.drawCircle( + position, + radius, + Paint()..color = Colors.white.withValues(alpha: 0.95 * opacity), + ); + canvas.drawCircle( + position, + radius, + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 2.4 + ..color = color.withValues(alpha: 0.9 * opacity), + ); + } + + /// Ondes concentriques émises au moment du contact. + void _paintRipple( + Canvas canvas, + Offset position, + double progress, + double maxRadius, + ) { + if (progress <= 0 || progress >= 1) return; + canvas.drawCircle( + position, + 6 + progress * maxRadius, + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 2.5 * (1 - progress) + 0.6 + ..color = color.withValues(alpha: 0.55 * (1 - progress)), + ); + } + + /// Main « pointeur » (icône Material) dont l'index touche [tip]. + void _paintHand( + Canvas canvas, + Offset tip, + double glyphSize, { + double press = 0, + }) { + final painter = TextPainter( + textDirection: TextDirection.ltr, + text: TextSpan( + text: String.fromCharCode(Icons.touch_app_rounded.codePoint), + style: TextStyle( + fontSize: glyphSize, + fontFamily: Icons.touch_app_rounded.fontFamily, + package: Icons.touch_app_rounded.fontPackage, + color: Colors.white.withValues(alpha: 0.96), + shadows: [ + Shadow( + color: Colors.black.withValues(alpha: 0.55), + blurRadius: 8, + offset: const Offset(0, 3), + ), + ], + ), + ), + )..layout(); + + // Le bout de l'index de l'icône se situe environ à 32 % / 16 % du glyphe. + final origin = tip - + Offset(painter.width * 0.32, painter.height * 0.16) + + Offset(glyphSize * 0.03 * press, glyphSize * 0.06 * press); + painter.paint(canvas, origin); + } + + // ------------------------------------------------------------------ Gestes + + void _paintTap( + Canvas canvas, + Size size, + Offset center, { + required int pulses, + }) { + final cycle = (t * pulses) % 1.0; + final press = _wave((cycle / 0.4).clamp(0.0, 1.0)); + + _paintRipple(canvas, center, cycle, size.width * 0.34); + _paintRipple( + canvas, center, (cycle - 0.25).clamp(0.0, 1.0), size.width * 0.34); + _paintFingertip(canvas, center, size.width * 0.055 + 2 * press, + opacity: 0.35 + 0.65 * press); + _paintHand(canvas, center, size.width * 0.58, press: press); + } + + void _paintLongPress(Canvas canvas, Size size, Offset center) { + final hold = Curves.easeOut.transform((t / 0.75).clamp(0.0, 1.0)); + final radius = size.width * 0.24; + + canvas.drawCircle( + center, + radius, + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 3 + ..color = Colors.white.withValues(alpha: 0.22), + ); + canvas.drawArc( + Rect.fromCircle(center: center, radius: radius), + -math.pi / 2, + 2 * math.pi * hold, + false, + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 3.4 + ..strokeCap = StrokeCap.round + ..color = color.withValues(alpha: 0.95), + ); + + _paintFingertip(canvas, center, size.width * 0.06); + _paintHand(canvas, center, size.width * 0.58, press: 1); + } + + void _paintDrag(Canvas canvas, Size size, Offset center) { + // 0 -> 0.35 : appui maintenu ; puis déplacement aller-retour du doigt. + final hold = Curves.easeOut.transform((t / 0.35).clamp(0.0, 1.0)); + final travel = + t <= 0.35 ? 0.0 : _wave(((t - 0.35) / 0.65).clamp(0.0, 1.0)); + final start = center - Offset(size.width * 0.16, 0); + final position = start + Offset(size.width * 0.32 * travel, 0); + + canvas.drawLine( + start, + position, + Paint() + ..strokeWidth = 3 + ..strokeCap = StrokeCap.round + ..color = color.withValues(alpha: 0.35), + ); + + if (hold < 1) { + canvas.drawArc( + Rect.fromCircle(center: start, radius: size.width * 0.16), + -math.pi / 2, + 2 * math.pi * hold, + false, + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 3 + ..strokeCap = StrokeCap.round + ..color = color.withValues(alpha: 0.9), + ); + } + + _paintFingertip(canvas, position, size.width * 0.06); + _paintHand(canvas, position, size.width * 0.58, press: 1); + } + + void _paintPinch(Canvas canvas, Size size, Offset center) { + // Les deux doigts s'écartent (zoom avant) puis se rapprochent. + final spread = _wave(t); + final direction = Offset(math.cos(-math.pi / 4), math.sin(-math.pi / 4)); + final distance = size.width * (0.12 + 0.24 * spread); + final first = center + direction * distance; + final second = center - direction * distance; + final radius = size.width * 0.075; + + // Ligne pointillée reliant les deux doigts. + final dashPaint = Paint() + ..strokeWidth = 2 + ..strokeCap = StrokeCap.round + ..color = color.withValues(alpha: 0.45); + const dashes = 9; + for (int i = 0; i < dashes; i++) { + if (i.isOdd) continue; + final a = Offset.lerp(second, first, i / dashes)!; + final b = Offset.lerp(second, first, (i + 1) / dashes)!; + canvas.drawLine(a, b, dashPaint); + } + + // Chevrons indiquant le sens de l'écartement. + _paintChevron(canvas, first, direction, size.width * 0.06, spread); + _paintChevron(canvas, second, -direction, size.width * 0.06, spread); + + _paintFingertip(canvas, first, radius); + _paintFingertip(canvas, second, radius); + } + + void _paintChevron( + Canvas canvas, + Offset tip, + Offset direction, + double length, + double spread, + ) { + final base = tip + direction * (length * 1.6); + final angle = math.atan2(direction.dy, direction.dx); + final paint = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 2.6 + ..strokeCap = StrokeCap.round + ..color = color.withValues(alpha: 0.25 + 0.6 * spread); + + for (final sign in [-1, 1]) { + final branch = angle + sign * 2.5; + canvas.drawLine( + base, + base + Offset(math.cos(branch), math.sin(branch)) * length, + paint, + ); + } + } + + void _paintSwipe( + Canvas canvas, + Size size, + Offset center, { + required bool vertical, + }) { + final progress = Curves.easeInOut.transform((t / 0.75).clamp(0.0, 1.0)); + final fade = t > 0.75 ? 1 - ((t - 0.75) / 0.25) : 1.0; + final axis = vertical ? const Offset(0, -1) : const Offset(1, 0); + final amplitude = size.width * 0.28; + final start = center - axis * amplitude; + final position = start + axis * (2 * amplitude * progress); + + canvas.drawLine( + start, + position, + Paint() + ..strokeWidth = 3 + ..strokeCap = StrokeCap.round + ..color = color.withValues(alpha: 0.32 * fade), + ); + _paintFingertip(canvas, position, size.width * 0.06, opacity: fade); + _paintHand(canvas, position, size.width * 0.58, press: 1); + } + + @override + bool shouldRepaint(_GesturePainter old) => + old.t != t || old.gesture != gesture || old.color != color; +} diff --git a/lib/features/tutorial/widgets/tutorial_overlay.dart b/lib/features/tutorial/widgets/tutorial_overlay.dart new file mode 100644 index 00000000..31b47959 --- /dev/null +++ b/lib/features/tutorial/widgets/tutorial_overlay.dart @@ -0,0 +1,504 @@ +/// Voile du didacticiel : assombrit l'écran, découpe un « trou de lumière » +/// autour de l'élément à découvrir, y anime une main qui mime le geste attendu +/// et affiche une bulle explicative avec la progression. +/// +/// L'overlay est purement visuel : il n'exécute pas l'action à la place de +/// l'utilisateur. On avance d'une étape en touchant l'écran ou le bouton +/// « Suivant » ; « Passer » interrompt la visite. +library; + +import 'package:flutter/material.dart'; +import '../../../core/theme/app_theme.dart'; +import '../tutorial_step.dart'; +import 'tutorial_hand.dart'; + +class TutorialOverlay extends StatefulWidget { + final List steps; + + /// Appelé à la fin de la visite (terminée ou passée). + final VoidCallback onFinished; + + const TutorialOverlay({ + super.key, + required this.steps, + required this.onFinished, + }); + + @override + State createState() => _TutorialOverlayState(); +} + +class _TutorialOverlayState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _pulseController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1600), + )..repeat(); + + int _index = 0; + Rect? _spotRect; + bool _ready = false; + + TutorialStep get _step => widget.steps[_index]; + bool get _isLast => _index == widget.steps.length - 1; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => _prepareStep()); + } + + @override + void dispose() { + _pulseController.dispose(); + super.dispose(); + } + + /// Amène l'élément visé à l'écran puis mesure sa position. + Future _prepareStep() async { + final targetContext = _step.targetKey?.currentContext; + + if (targetContext == null || !targetContext.mounted) { + if (mounted) setState(() { _spotRect = null; _ready = true; }); + return; + } + + await Scrollable.ensureVisible( + targetContext, + alignment: 0.35, + duration: const Duration(milliseconds: 320), + curve: Curves.easeOutCubic, + ); + // Laisse le temps au défilement de se stabiliser avant de mesurer. + await Future.delayed(const Duration(milliseconds: 60)); + if (!mounted) return; + + setState(() { + _spotRect = _measure(targetContext, _step.spotPadding); + _ready = true; + }); + } + + Rect? _measure(BuildContext targetContext, double padding) { + final box = targetContext.findRenderObject() as RenderBox?; + if (box == null || !box.hasSize) return null; + final origin = box.localToGlobal(Offset.zero); + final screen = MediaQuery.of(context).size; + return Rect.fromLTWH(origin.dx, origin.dy, box.size.width, box.size.height) + .inflate(padding) + .intersect(Offset.zero & screen); + } + + void _next() { + if (_isLast) { + _finish(); + return; + } + setState(() { + _index++; + _ready = false; + _spotRect = null; + }); + WidgetsBinding.instance.addPostFrameCallback((_) => _prepareStep()); + } + + void _finish() => widget.onFinished(); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final primary = theme.colorScheme.primary; + final spot = _spotRect; + + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, _) { + if (!didPop) _finish(); + }, + child: Material( + color: Colors.transparent, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: _ready ? _next : null, + child: Stack( + children: [ + // Voile sombre percé autour de l'élément mis en avant. + Positioned.fill( + child: AnimatedBuilder( + animation: _pulseController, + builder: (context, _) => CustomPaint( + painter: _SpotlightPainter( + spot: spot, + circle: _step.shape == TutorialHighlightShape.circle, + pulse: _pulseController.value, + glowColor: primary, + ), + ), + ), + ), + + // Main animée posée sur l'élément mis en avant. + if (spot != null && _step.gesture != TutorialGesture.none) + _buildHand(spot, primary), + + // Bulle explicative. + if (_ready) _buildTooltip(context, spot, primary), + ], + ), + ), + ), + ); + } + + Widget _buildHand(Rect spot, Color primary) { + final handSize = + (spot.shortestSide * 1.4).clamp(96.0, 190.0).toDouble(); + // Sur une grande zone (image plein écran), la main reste au centre ; + // sur un bouton, elle se cale sur le centre du bouton. + final center = spot.center; + return Positioned( + left: center.dx - handSize / 2, + top: center.dy - handSize / 2, + child: TutorialHand( + gesture: _step.gesture, + color: primary, + size: handSize, + ), + ); + } + + Widget _buildTooltip(BuildContext context, Rect? spot, Color primary) { + final media = MediaQuery.of(context); + final screenHeight = media.size.height; + + final card = _TutorialCard( + step: _step, + index: _index, + total: widget.steps.length, + primary: primary, + isLast: _isLast, + onNext: _next, + onSkip: _finish, + ); + + if (spot == null) { + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: card, + ), + ); + } + + const gap = 22.0; + + // Zone très large (image plein écran) : la bulle flotte en bas de l'écran + // pour laisser la main animée visible au centre. + if (spot.height > screenHeight * 0.55) { + return Positioned( + left: 16, + right: 16, + bottom: media.padding.bottom + 24, + child: card, + ); + } + + // Sinon, la bulle se place du côté le plus dégagé du spot. + final above = + _step.preferTooltipAbove ?? (spot.top > screenHeight - spot.bottom); + + return Positioned( + left: 16, + right: 16, + top: above ? null : spot.bottom + gap, + bottom: above ? (screenHeight - spot.top + gap) : null, + child: card, + ); + } +} + +/// Carte explicative d'une étape (titre, texte, progression, boutons). +class _TutorialCard extends StatelessWidget { + final TutorialStep step; + final int index; + final int total; + final Color primary; + final bool isLast; + final VoidCallback onNext; + final VoidCallback onSkip; + + const _TutorialCard({ + required this.step, + required this.index, + required this.total, + required this.primary, + required this.isLast, + required this.onNext, + required this.onSkip, + }); + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final surface = isDark ? const Color(0xFF121A26) : Colors.white; + final textPrimary = + isDark ? AppTheme.darkTextPrimary : AppTheme.lightTextPrimary; + final textSecondary = + isDark ? AppTheme.darkTextSecondary : AppTheme.lightTextSecondary; + + return Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 460), + child: Container( + padding: const EdgeInsets.fromLTRB(18, 16, 18, 12), + decoration: BoxDecoration( + color: surface, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: primary.withValues(alpha: 0.35), width: 1.2), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: isDark ? 0.6 : 0.25), + blurRadius: 28, + offset: const Offset(0, 10), + ), + BoxShadow( + color: primary.withValues(alpha: 0.18), + blurRadius: 24, + spreadRadius: -6, + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: primary.withValues(alpha: isDark ? 0.22 : 0.14), + borderRadius: BorderRadius.circular(10), + ), + child: Icon( + step.icon ?? Icons.school_outlined, + color: primary, + size: 20, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + step.title, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w800, + color: textPrimary, + letterSpacing: -0.2, + ), + ), + ), + Text( + '${index + 1}/$total', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + color: textSecondary, + ), + ), + ], + ), + const SizedBox(height: 10), + Text( + step.description, + style: TextStyle( + fontSize: 13.5, + height: 1.4, + color: textSecondary, + ), + ), + if (step.gesture != TutorialGesture.none) ...[ + const SizedBox(height: 10), + Row( + children: [ + Icon(Icons.gesture, size: 16, color: primary), + const SizedBox(width: 6), + Expanded( + child: Text( + _gestureHint(step.gesture), + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + color: primary, + ), + ), + ), + ], + ), + ], + const SizedBox(height: 12), + Row( + children: [ + Row( + children: List.generate(total, (i) { + final active = i == index; + return AnimatedContainer( + duration: const Duration(milliseconds: 200), + margin: const EdgeInsets.only(right: 5), + width: active ? 18 : 6, + height: 6, + decoration: BoxDecoration( + color: active + ? primary + : primary.withValues(alpha: 0.28), + borderRadius: BorderRadius.circular(3), + ), + ); + }), + ), + const Spacer(), + TextButton( + onPressed: onSkip, + style: TextButton.styleFrom(foregroundColor: textSecondary), + child: const Text('Passer'), + ), + const SizedBox(width: 4), + ElevatedButton( + onPressed: onNext, + style: ElevatedButton.styleFrom( + backgroundColor: primary, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric( + horizontal: 18, vertical: 10), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: Text( + isLast ? 'C\'est parti' : 'Suivant', + style: const TextStyle(fontWeight: FontWeight.w800), + ), + ), + ], + ), + ], + ), + ), + ), + ); + } + + static String _gestureHint(TutorialGesture gesture) { + switch (gesture) { + case TutorialGesture.tap: + return 'Appuyez une fois'; + case TutorialGesture.doubleTap: + return 'Appuyez deux fois'; + case TutorialGesture.longPress: + return 'Appui long'; + case TutorialGesture.drag: + return 'Appui long puis glisser'; + case TutorialGesture.pinch: + return 'Pincez avec deux doigts pour zoomer'; + case TutorialGesture.swipeUp: + return 'Balayez vers le haut'; + case TutorialGesture.swipeHorizontal: + return 'Balayez latéralement'; + case TutorialGesture.none: + return ''; + } + } +} + +/// Voile sombre percé d'un trou de lumière, avec anneau pulsant. +class _SpotlightPainter extends CustomPainter { + final Rect? spot; + final bool circle; + final double pulse; + final Color glowColor; + + _SpotlightPainter({ + required this.spot, + required this.circle, + required this.pulse, + required this.glowColor, + }); + + @override + void paint(Canvas canvas, Size size) { + final scrim = Paint()..color = Colors.black.withValues(alpha: 0.76); + final screenPath = Path()..addRect(Offset.zero & size); + final target = spot; + + if (target == null || target.isEmpty) { + canvas.drawPath(screenPath, scrim); + return; + } + + final holePath = circle + ? (Path() + ..addOval(Rect.fromCircle( + center: target.center, + radius: target.longestSide / 2, + ))) + : (Path() + ..addRRect(RRect.fromRectAndRadius( + target, + const Radius.circular(18), + ))); + + canvas.drawPath( + Path.combine(PathOperation.difference, screenPath, holePath), + scrim, + ); + + // Halo diffus autour du trou. + canvas.drawPath( + holePath, + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 10 + ..color = glowColor.withValues(alpha: 0.28) + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 10), + ); + + // Contour net. + canvas.drawPath( + holePath, + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 2.4 + ..color = glowColor.withValues(alpha: 0.95), + ); + + // Anneau qui s'écarte en boucle pour attirer l'œil. + final ringPaint = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 2.5 + ..color = glowColor.withValues(alpha: 0.5 * (1 - pulse)); + final expansion = target.shortestSide * 0.10 * pulse; + + if (circle) { + canvas.drawCircle( + target.center, + target.longestSide / 2 + expansion, + ringPaint, + ); + } else { + canvas.drawRRect( + RRect.fromRectAndRadius( + target.inflate(expansion), + Radius.circular(18 + expansion), + ), + ringPaint, + ); + } + } + + @override + bool shouldRepaint(_SpotlightPainter old) => + old.spot != spot || + old.pulse != pulse || + old.circle != circle || + old.glowColor != glowColor; +} diff --git a/lib/main.dart b/lib/main.dart index 967aee2c..55ef99cf 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -10,6 +10,7 @@ import 'data/repositories/session_repository.dart'; import 'services/score_calculator_service.dart'; import 'services/grouping_analyzer_service.dart'; import 'features/session/session_provider.dart'; +import 'features/tutorial/tutorial_provider.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -39,6 +40,7 @@ void main() async { Provider(create: (_) => SessionRepository()), ChangeNotifierProvider(create: (_) => ThemeProvider()), ChangeNotifierProvider(create: (_) => SessionProvider()), + ChangeNotifierProvider(create: (_) => TutorialProvider()), ], child: const BullyApp(), ), diff --git a/lib/main_navigation_holder.dart b/lib/main_navigation_holder.dart index 436a57d5..c669c318 100644 --- a/lib/main_navigation_holder.dart +++ b/lib/main_navigation_holder.dart @@ -16,6 +16,9 @@ const int mainTabGarage = 3; final GlobalKey> mainNavKey = GlobalKey>(); +/// Clé du dock flottant : permet au didacticiel de le mettre en avant. +final GlobalKey navigationDockKey = GlobalKey(); + /// Ouvre l'onglet [index] de la navigation principale. void openMainTab(int index) { final state = mainNavKey.currentState; @@ -80,6 +83,7 @@ class _MainNavigationHolderState extends State { return SafeArea( child: Container( + key: navigationDockKey, margin: const EdgeInsets.fromLTRB(16, 0, 16, 12), height: 68, decoration: BoxDecoration( diff --git a/lib/services/tutorial_service.dart b/lib/services/tutorial_service.dart new file mode 100644 index 00000000..8b21f2e8 --- /dev/null +++ b/lib/services/tutorial_service.dart @@ -0,0 +1,46 @@ +/// Persistance du didacticiel : mémorise les visites guidées déjà vues. +/// +/// Chaque visite guidée (« tour ») possède un identifiant stocké dans les +/// SharedPreferences sous la forme `tutorial_done_`. Réinitialiser le +/// didacticiel depuis les paramètres efface simplement ces clés. +library; + +import 'package:shared_preferences/shared_preferences.dart'; + +/// Identifiants des visites guidées de l'application. +class TutorialTours { + const TutorialTours._(); + + /// Visite d'accueil : présentation générale et navigation. + static const String home = 'home'; + + /// Visite de l'éditeur d'impacts : tap, appui long et pincement pour zoomer. + static const String impactEditor = 'impact_editor'; + + /// Toutes les visites connues, dans l'ordre logique de découverte. + static const List all = [home, impactEditor]; +} + +class TutorialService { + static const String _prefix = 'tutorial_done_'; + + Future> loadCompletedTours() async { + final prefs = await SharedPreferences.getInstance(); + return TutorialTours.all + .where((id) => prefs.getBool('$_prefix$id') ?? false) + .toSet(); + } + + Future markCompleted(String tourId) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool('$_prefix$tourId', true); + } + + /// Efface la mémoire du didacticiel : toutes les visites seront rejouées. + Future resetAll() async { + final prefs = await SharedPreferences.getInstance(); + for (final id in TutorialTours.all) { + await prefs.remove('$_prefix$id'); + } + } +} diff --git a/test/features/tutorial_overlay_test.dart b/test/features/tutorial_overlay_test.dart new file mode 100644 index 00000000..f9d086b7 --- /dev/null +++ b/test/features/tutorial_overlay_test.dart @@ -0,0 +1,127 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:bully/features/tutorial/tutorial_step.dart'; +import 'package:bully/features/tutorial/widgets/tutorial_hand.dart'; +import 'package:bully/features/tutorial/widgets/tutorial_overlay.dart'; + +/// Monte l'overlay au-dessus d'un faux écran contenant l'élément visé. +Future pumpOverlay( + WidgetTester tester, { + required List Function(GlobalKey targetKey) steps, + required VoidCallback onFinished, +}) async { + final targetKey = GlobalKey(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Stack( + children: [ + Center( + child: SizedBox(key: targetKey, width: 160, height: 48), + ), + TutorialOverlay(steps: steps(targetKey), onFinished: onFinished), + ], + ), + ), + ), + ); + + // Laisse le temps à la mesure du spot (défilement + délai de stabilisation). + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + return targetKey; +} + +void main() { + testWidgets('la visite avance d\'une étape à l\'autre puis se termine', + (WidgetTester tester) async { + var finished = false; + + await pumpOverlay( + tester, + onFinished: () => finished = true, + steps: (targetKey) => [ + TutorialStep( + targetKey: targetKey, + title: 'Démarrez une session', + description: 'Tout part d\'ici.', + gesture: TutorialGesture.tap, + ), + TutorialStep( + targetKey: targetKey, + title: 'Zoomer sur la cible', + description: 'Écartez deux doigts.', + gesture: TutorialGesture.pinch, + ), + ], + ); + + expect(find.text('Démarrez une session'), findsOneWidget); + expect(find.text('1/2'), findsOneWidget); + // La main animée mime le geste attendu, posée sur l'élément mis en avant. + expect(find.byType(TutorialHand), findsOneWidget); + expect(find.text('Appuyez une fois'), findsOneWidget); + + await tester.tap(find.text('Suivant')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + expect(find.text('Zoomer sur la cible'), findsOneWidget); + expect(find.text('2/2'), findsOneWidget); + expect(find.text('Pincez avec deux doigts pour zoomer'), findsOneWidget); + expect(finished, isFalse); + + await tester.tap(find.text('C\'est parti')); + await tester.pump(); + + expect(finished, isTrue); + }); + + testWidgets('« Passer » interrompt la visite immédiatement', + (WidgetTester tester) async { + var finished = false; + + await pumpOverlay( + tester, + onFinished: () => finished = true, + steps: (targetKey) => [ + TutorialStep( + targetKey: targetKey, + title: 'Première étape', + description: 'Description.', + ), + const TutorialStep( + title: 'Seconde étape', + description: 'Description.', + ), + ], + ); + + await tester.tap(find.text('Passer')); + await tester.pump(); + + expect(finished, isTrue); + expect(find.text('Seconde étape'), findsNothing); + }); + + testWidgets('une étape sans cible s\'affiche comme carte centrée', + (WidgetTester tester) async { + await pumpOverlay( + tester, + onFinished: () {}, + steps: (_) => [ + const TutorialStep( + title: 'Bienvenue dans Bully', + description: 'Ce guide rapide vous montre l\'essentiel.', + ), + ], + ); + + expect(find.text('Bienvenue dans Bully'), findsOneWidget); + expect(find.text('C\'est parti'), findsOneWidget); + // Aucune cible : pas de main animée non plus. + expect(find.byType(TutorialHand), findsNothing); + }); +} diff --git a/test/services/tutorial_service_test.dart b/test/services/tutorial_service_test.dart new file mode 100644 index 00000000..1d5b72c6 --- /dev/null +++ b/test/services/tutorial_service_test.dart @@ -0,0 +1,79 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:bully/features/tutorial/tutorial_provider.dart'; +import 'package:bully/services/tutorial_service.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() => SharedPreferences.setMockInitialValues({})); + + group('TutorialService', () { + test('aucune visite n\'est marquée vue à la première utilisation', + () async { + expect(await TutorialService().loadCompletedTours(), isEmpty); + }); + + test('mémorise puis réinitialise les visites vues', () async { + final service = TutorialService(); + + await service.markCompleted(TutorialTours.home); + expect(await service.loadCompletedTours(), {TutorialTours.home}); + + await service.markCompleted(TutorialTours.impactEditor); + expect( + await service.loadCompletedTours(), + {TutorialTours.home, TutorialTours.impactEditor}, + ); + + await service.resetAll(); + expect(await service.loadCompletedTours(), isEmpty); + }); + }); + + group('TutorialProvider', () { + test('joue chaque visite une seule fois', () async { + final provider = TutorialProvider(); + await provider.load(); + + expect(provider.isLoaded, isTrue); + expect(provider.shouldRun(TutorialTours.home), isTrue); + expect(provider.hasSeenIntro, isFalse); + + await provider.complete(TutorialTours.home); + + expect(provider.shouldRun(TutorialTours.home), isFalse); + expect(provider.hasSeenIntro, isTrue); + // Les autres visites restent disponibles. + expect(provider.shouldRun(TutorialTours.impactEditor), isTrue); + }); + + test('la réactivation depuis les paramètres rejoue toutes les visites', + () async { + final provider = TutorialProvider(); + await provider.load(); + await provider.complete(TutorialTours.home); + await provider.complete(TutorialTours.impactEditor); + + await provider.restart(); + + expect(provider.shouldRun(TutorialTours.home), isTrue); + expect(provider.shouldRun(TutorialTours.impactEditor), isTrue); + // La remise à zéro est bien persistée. + expect(await TutorialService().loadCompletedTours(), isEmpty); + }); + + test('l\'état vu survit à un redémarrage de l\'application', () async { + final first = TutorialProvider(); + await first.load(); + await first.complete(TutorialTours.home); + + final second = TutorialProvider(); + await second.load(); + + expect(second.shouldRun(TutorialTours.home), isFalse); + expect(second.shouldRun(TutorialTours.impactEditor), isTrue); + }); + }); +}