feat: analytics abstraction with debug backend and game event wiring

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 13:35:31 +09:00
parent 6d2d97bfcc
commit 074a21ea2b
7 changed files with 161 additions and 5 deletions
+64
View File
@@ -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<String, Object> params);
}
class DebugAnalyticsBackend implements AnalyticsBackend {
@override
void logEvent(String name, Map<String, Object> 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});
}
}
+5
View File
@@ -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, int>(
EndlessBestNotifier.new,
);
final analyticsProvider = Provider<AnalyticsService>(
(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<SeasonTheme>((ref) {
+4
View File
@@ -23,6 +23,10 @@ class SeasonFlowNotifier extends Notifier<SeasonFlow?> {
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<void> recordWin({required int stars, required int score}) async {
+4 -3
View File
@@ -25,16 +25,17 @@ class TutorialNotifier extends Notifier<TutorialStep?> {
Future<void> dismissHud() async {
if (state != TutorialStep.explainHud) return;
await _finish();
await _finish(skipped: false);
}
Future<void> skip() async {
if (state == null) return;
await _finish();
await _finish(skipped: true);
}
Future<void> _finish() async {
Future<void> _finish({required bool skipped}) async {
state = null;
ref.read(analyticsProvider).tutorialFinished(skipped: skipped);
await ref.read(saveRepositoryProvider).markTutorialDone();
}
}
+33 -2
View File
@@ -160,6 +160,17 @@ class _GameScreenState extends ConsumerState<GameScreen>
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<GameScreen>
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<GameScreen>
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<GameScreen>
l10n.boardFull,
[
FilledButton(
onPressed: notifier.useContinue,
onPressed: () {
ref.read(analyticsProvider).rescueUsed(type: 'continue');
notifier.useContinue();
},
child: Text(l10n.watchAdContinue),
),
TextButton(
+1
View File
@@ -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,
+50
View File
@@ -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<String, Object>)>[];
@override
void logEvent(String name, Map<String, Object> 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});
});
}