From 3d1f3b30c7f956a16fd406db25f16dcc1bc3f9d4 Mon Sep 17 00:00:00 2001 From: airkjw Date: Thu, 11 Jun 2026 22:50:49 +0900 Subject: [PATCH] feat: tutorial step state machine with persistence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds TutorialNotifier (dragPiece → clearLine → explainHud → null) backed by SaveRepository.markTutorialDone(); out-of-order events are silently ignored. Registers tutorialProvider in providers.dart. Co-Authored-By: Claude Sonnet 4.6 --- lib/state/providers.dart | 5 +++ lib/state/tutorial_notifier.dart | 40 +++++++++++++++++ test/state/tutorial_notifier_test.dart | 60 ++++++++++++++++++++++++++ 3 files changed, 105 insertions(+) create mode 100644 lib/state/tutorial_notifier.dart create mode 100644 test/state/tutorial_notifier_test.dart diff --git a/lib/state/providers.dart b/lib/state/providers.dart index 2245813..66f7790 100644 --- a/lib/state/providers.dart +++ b/lib/state/providers.dart @@ -9,6 +9,7 @@ import 'game_session_notifier.dart'; import 'progress_notifier.dart'; import 'season_flow_notifier.dart'; import 'streak_notifier.dart'; +import 'tutorial_notifier.dart'; final gameSessionProvider = NotifierProvider( @@ -46,6 +47,10 @@ final streakProvider = NotifierProvider( StreakNotifier.new, ); +final tutorialProvider = NotifierProvider( + TutorialNotifier.new, +); + /// The visual theme of whatever season is in play; fallback outside seasons /// (home, endless). Pure model — UI converts via ThemeColors. final activeThemeProvider = Provider((ref) { diff --git a/lib/state/tutorial_notifier.dart b/lib/state/tutorial_notifier.dart new file mode 100644 index 0000000..f6cb2c0 --- /dev/null +++ b/lib/state/tutorial_notifier.dart @@ -0,0 +1,40 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'providers.dart'; + +enum TutorialStep { dragPiece, clearLine, explainHud } + +/// First-play guided tutorial. State null = inactive. Events arriving in the +/// wrong step are ignored, so engine wiring can fire them unconditionally. +class TutorialNotifier extends Notifier { + @override + TutorialStep? build() => null; + + void start() { + if (ref.read(saveRepositoryProvider).tutorialDone) return; + state = TutorialStep.dragPiece; + } + + void onPlaced() { + if (state == TutorialStep.dragPiece) state = TutorialStep.clearLine; + } + + void onLineCleared() { + if (state == TutorialStep.clearLine) state = TutorialStep.explainHud; + } + + Future dismissHud() async { + if (state != TutorialStep.explainHud) return; + await _finish(); + } + + Future skip() async { + if (state == null) return; + await _finish(); + } + + Future _finish() async { + state = null; + await ref.read(saveRepositoryProvider).markTutorialDone(); + } +} diff --git a/test/state/tutorial_notifier_test.dart b/test/state/tutorial_notifier_test.dart new file mode 100644 index 0000000..b721f62 --- /dev/null +++ b/test/state/tutorial_notifier_test.dart @@ -0,0 +1,60 @@ +import 'package:block_seasons/data/save_repository.dart'; +import 'package:block_seasons/state/providers.dart'; +import 'package:block_seasons/state/tutorial_notifier.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + late ProviderContainer container; + late SaveRepository repo; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + repo = SaveRepository(await SharedPreferences.getInstance()); + container = ProviderContainer( + overrides: [saveRepositoryProvider.overrideWithValue(repo)], + ); + }); + + tearDown(() => container.dispose()); + + test('inactive by default', () { + expect(container.read(tutorialProvider), isNull); + }); + + test('happy path: drag -> clear -> hud -> done, persists flag', () async { + final n = container.read(tutorialProvider.notifier); + n.start(); + expect(container.read(tutorialProvider), TutorialStep.dragPiece); + n.onPlaced(); + expect(container.read(tutorialProvider), TutorialStep.clearLine); + n.onLineCleared(); + expect(container.read(tutorialProvider), TutorialStep.explainHud); + await n.dismissHud(); + expect(container.read(tutorialProvider), isNull); + expect(repo.tutorialDone, isTrue); + }); + + test('out-of-order events are ignored', () { + final n = container.read(tutorialProvider.notifier); + n.start(); + n.onLineCleared(); // not in clearLine step yet + expect(container.read(tutorialProvider), TutorialStep.dragPiece); + }); + + test('skip finishes from any step and persists', () async { + final n = container.read(tutorialProvider.notifier); + n.start(); + await n.skip(); + expect(container.read(tutorialProvider), isNull); + expect(repo.tutorialDone, isTrue); + }); + + test('start is a no-op when tutorial already done', () async { + await repo.markTutorialDone(); + final n = container.read(tutorialProvider.notifier); + n.start(); + expect(container.read(tutorialProvider), isNull); + }); +}