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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
32143c5bb1
commit
32582aba3d
@@ -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<GlobalKey> pumpOverlay(
|
||||
WidgetTester tester, {
|
||||
required List<TutorialStep> 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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user