feat: analytics abstraction with debug backend and game event wiring
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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});
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user