From 074a21ea2b3cf18e38bd49918d1a7c271de8c897 Mon Sep 17 00:00:00 2001 From: airkjw Date: Fri, 12 Jun 2026 13:35:31 +0900 Subject: [PATCH] feat: analytics abstraction with debug backend and game event wiring Co-Authored-By: Claude Fable 5 --- lib/services/analytics_service.dart | 64 +++++++++++++++++++++++ lib/state/providers.dart | 5 ++ lib/state/season_flow_notifier.dart | 4 ++ lib/state/tutorial_notifier.dart | 7 +-- lib/ui/screens/game_screen.dart | 35 ++++++++++++- lib/ui/screens/home_screen.dart | 1 + test/services/analytics_service_test.dart | 50 ++++++++++++++++++ 7 files changed, 161 insertions(+), 5 deletions(-) create mode 100644 lib/services/analytics_service.dart create mode 100644 test/services/analytics_service_test.dart diff --git a/lib/services/analytics_service.dart b/lib/services/analytics_service.dart new file mode 100644 index 0000000..5903454 --- /dev/null +++ b/lib/services/analytics_service.dart @@ -0,0 +1,64 @@ +import 'package:flutter/foundation.dart'; + +/// Where events land. Phase 4 ships the debug logger; the Firebase backend +/// plugs in here after the owner runs flutterfire configure. +abstract class AnalyticsBackend { + void logEvent(String name, Map params); +} + +class DebugAnalyticsBackend implements AnalyticsBackend { + @override + void logEvent(String name, Map params) { + debugPrint('[analytics] $name $params'); + } +} + +/// Typed event surface. Booleans are sent as 0/1 ints so every backend +/// (GA4 included) aggregates them the same way. +class AnalyticsService { + AnalyticsService(this._backend); + + final AnalyticsBackend _backend; + + void stageStart({required String seasonId, required String stageId}) { + _backend.logEvent('stage_start', { + 'season_id': seasonId, + 'stage_id': stageId, + }); + } + + void stageEnd({ + required String seasonId, + required String stageId, + required bool won, + required int stars, + required int score, + required int movesUsed, + }) { + _backend.logEvent('stage_end', { + 'season_id': seasonId, + 'stage_id': stageId, + 'won': won ? 1 : 0, + 'stars': stars, + 'score': score, + 'moves_used': movesUsed, + }); + } + + void endlessStart() => _backend.logEvent('endless_start', const {}); + + void endlessEnd({required int score, required bool isNewBest}) { + _backend.logEvent('endless_end', { + 'score': score, + 'new_best': isNewBest ? 1 : 0, + }); + } + + void rescueUsed({required String type}) { + _backend.logEvent('rescue_used', {'type': type}); + } + + void tutorialFinished({required bool skipped}) { + _backend.logEvent('tutorial_finished', {'skipped': skipped ? 1 : 0}); + } +} diff --git a/lib/state/providers.dart b/lib/state/providers.dart index c4b6003..cabff7f 100644 --- a/lib/state/providers.dart +++ b/lib/state/providers.dart @@ -4,6 +4,7 @@ import '../data/content_repository.dart'; import '../data/save_repository.dart'; import '../data/streak.dart'; import '../game/models/season.dart'; +import '../services/analytics_service.dart'; import '../services/audio_service.dart'; import 'endless_best_notifier.dart'; import 'game_session_notifier.dart'; @@ -66,6 +67,10 @@ final endlessBestProvider = NotifierProvider( EndlessBestNotifier.new, ); +final analyticsProvider = Provider( + (ref) => AnalyticsService(DebugAnalyticsBackend()), +); + /// 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/season_flow_notifier.dart b/lib/state/season_flow_notifier.dart index eede482..1536565 100644 --- a/lib/state/season_flow_notifier.dart +++ b/lib/state/season_flow_notifier.dart @@ -23,6 +23,10 @@ class SeasonFlowNotifier extends Notifier { void startSeasonStage(SeasonPack pack, int index) { state = SeasonFlow(pack: pack, index: index); ref.read(gameSessionProvider.notifier).startStage(pack.stages[index]); + ref.read(analyticsProvider).stageStart( + seasonId: pack.seasonId, + stageId: pack.stages[index].id, + ); } Future recordWin({required int stars, required int score}) async { diff --git a/lib/state/tutorial_notifier.dart b/lib/state/tutorial_notifier.dart index f6cb2c0..3b7313b 100644 --- a/lib/state/tutorial_notifier.dart +++ b/lib/state/tutorial_notifier.dart @@ -25,16 +25,17 @@ class TutorialNotifier extends Notifier { Future dismissHud() async { if (state != TutorialStep.explainHud) return; - await _finish(); + await _finish(skipped: false); } Future skip() async { if (state == null) return; - await _finish(); + await _finish(skipped: true); } - Future _finish() async { + Future _finish({required bool skipped}) async { state = null; + ref.read(analyticsProvider).tutorialFinished(skipped: skipped); await ref.read(saveRepositoryProvider).markTutorialDone(); } } diff --git a/lib/ui/screens/game_screen.dart b/lib/ui/screens/game_screen.dart index 695dbd9..618f152 100644 --- a/lib/ui/screens/game_screen.dart +++ b/lib/ui/screens/game_screen.dart @@ -160,6 +160,17 @@ class _GameScreenState extends ConsumerState ref .read(seasonFlowProvider.notifier) .recordWin(stars: next.starsEarned, score: next.score); + final flow = ref.read(seasonFlowProvider); + if (flow != null) { + ref.read(analyticsProvider).stageEnd( + seasonId: flow.pack.seasonId, + stageId: flow.stage.id, + won: true, + stars: next.starsEarned, + score: next.score, + movesUsed: next.moveLimit - next.movesLeft, + ); + } } final stackBox = _stackKey.currentContext?.findRenderObject() as RenderBox?; @@ -171,8 +182,22 @@ class _GameScreenState extends ConsumerState if (next.phase == GamePhase.lost && next.endless) { ref.read(endlessBestProvider.notifier).record(next.score).then((isNew) { if (mounted) setState(() => _endlessNewBest = isNew); + ref.read(analyticsProvider).endlessEnd(score: next.score, isNewBest: isNew); }); } + if (next.phase == GamePhase.lost && !next.endless) { + final flow = ref.read(seasonFlowProvider); + if (flow != null) { + ref.read(analyticsProvider).stageEnd( + seasonId: flow.pack.seasonId, + stageId: flow.stage.id, + won: false, + stars: 0, + score: next.score, + movesUsed: next.moveLimit - next.movesLeft, + ); + } + } if (next.phase == GamePhase.won || next.phase == GamePhase.lost) { ref.read(streakProvider.notifier).onStagePlayed(DateTime.now()); } @@ -367,7 +392,10 @@ class _GameScreenState extends ConsumerState l10n.outOfMoves, [ FilledButton( - onPressed: notifier.addExtraMoves, + onPressed: () { + ref.read(analyticsProvider).rescueUsed(type: 'extra_moves'); + notifier.addExtraMoves(); + }, child: Text(l10n.plusFiveMoves), ), TextButton( @@ -380,7 +408,10 @@ class _GameScreenState extends ConsumerState l10n.boardFull, [ FilledButton( - onPressed: notifier.useContinue, + onPressed: () { + ref.read(analyticsProvider).rescueUsed(type: 'continue'); + notifier.useContinue(); + }, child: Text(l10n.watchAdContinue), ), TextButton( diff --git a/lib/ui/screens/home_screen.dart b/lib/ui/screens/home_screen.dart index 67396f7..464ea9e 100644 --- a/lib/ui/screens/home_screen.dart +++ b/lib/ui/screens/home_screen.dart @@ -90,6 +90,7 @@ class HomeScreen extends ConsumerWidget { onPressed: () { if (!(ModalRoute.of(context)?.isCurrent ?? true)) return; ref.read(seasonFlowProvider.notifier).clear(); + ref.read(analyticsProvider).endlessStart(); ref.read(gameSessionProvider.notifier).startStage( StageConfig.endless( seed: DateTime.now().millisecondsSinceEpoch, diff --git a/test/services/analytics_service_test.dart b/test/services/analytics_service_test.dart new file mode 100644 index 0000000..58035bc --- /dev/null +++ b/test/services/analytics_service_test.dart @@ -0,0 +1,50 @@ +import 'package:block_seasons/services/analytics_service.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class _RecordingBackend implements AnalyticsBackend { + final events = <(String, Map)>[]; + + @override + void logEvent(String name, Map params) { + events.add((name, params)); + } +} + +void main() { + test('typed helpers produce stable event names and params', () { + final backend = _RecordingBackend(); + final analytics = AnalyticsService(backend); + + analytics.stageStart(seasonId: 'season_001', stageId: 's1'); + analytics.stageEnd( + seasonId: 'season_001', + stageId: 's1', + won: true, + stars: 3, + score: 1200, + movesUsed: 9, + ); + analytics.endlessStart(); + analytics.endlessEnd(score: 500, isNewBest: true); + analytics.rescueUsed(type: 'continue'); + analytics.tutorialFinished(skipped: false); + + expect(backend.events.map((e) => e.$1).toList(), [ + 'stage_start', + 'stage_end', + 'endless_start', + 'endless_end', + 'rescue_used', + 'tutorial_finished', + ]); + expect(backend.events[1].$2, { + 'season_id': 'season_001', + 'stage_id': 's1', + 'won': 1, + 'stars': 3, + 'score': 1200, + 'moves_used': 9, + }); + expect(backend.events[3].$2, {'score': 500, 'new_best': 1}); + }); +}