feat: tutorial step state machine with persistence
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 <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,7 @@ import 'game_session_notifier.dart';
|
|||||||
import 'progress_notifier.dart';
|
import 'progress_notifier.dart';
|
||||||
import 'season_flow_notifier.dart';
|
import 'season_flow_notifier.dart';
|
||||||
import 'streak_notifier.dart';
|
import 'streak_notifier.dart';
|
||||||
|
import 'tutorial_notifier.dart';
|
||||||
|
|
||||||
final gameSessionProvider =
|
final gameSessionProvider =
|
||||||
NotifierProvider<GameSessionNotifier, GameViewState?>(
|
NotifierProvider<GameSessionNotifier, GameViewState?>(
|
||||||
@@ -46,6 +47,10 @@ final streakProvider = NotifierProvider<StreakNotifier, StreakState>(
|
|||||||
StreakNotifier.new,
|
StreakNotifier.new,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
final tutorialProvider = NotifierProvider<TutorialNotifier, TutorialStep?>(
|
||||||
|
TutorialNotifier.new,
|
||||||
|
);
|
||||||
|
|
||||||
/// The visual theme of whatever season is in play; fallback outside seasons
|
/// The visual theme of whatever season is in play; fallback outside seasons
|
||||||
/// (home, endless). Pure model — UI converts via ThemeColors.
|
/// (home, endless). Pure model — UI converts via ThemeColors.
|
||||||
final activeThemeProvider = Provider<SeasonTheme>((ref) {
|
final activeThemeProvider = Provider<SeasonTheme>((ref) {
|
||||||
|
|||||||
@@ -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<TutorialStep?> {
|
||||||
|
@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<void> dismissHud() async {
|
||||||
|
if (state != TutorialStep.explainHud) return;
|
||||||
|
await _finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> skip() async {
|
||||||
|
if (state == null) return;
|
||||||
|
await _finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _finish() async {
|
||||||
|
state = null;
|
||||||
|
await ref.read(saveRepositoryProvider).markTutorialDone();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user