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/save_repository.dart';
|
||||||
import '../data/streak.dart';
|
import '../data/streak.dart';
|
||||||
import '../game/models/season.dart';
|
import '../game/models/season.dart';
|
||||||
|
import '../services/analytics_service.dart';
|
||||||
import '../services/audio_service.dart';
|
import '../services/audio_service.dart';
|
||||||
import 'endless_best_notifier.dart';
|
import 'endless_best_notifier.dart';
|
||||||
import 'game_session_notifier.dart';
|
import 'game_session_notifier.dart';
|
||||||
@@ -66,6 +67,10 @@ final endlessBestProvider = NotifierProvider<EndlessBestNotifier, int>(
|
|||||||
EndlessBestNotifier.new,
|
EndlessBestNotifier.new,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
final analyticsProvider = Provider<AnalyticsService>(
|
||||||
|
(ref) => AnalyticsService(DebugAnalyticsBackend()),
|
||||||
|
);
|
||||||
|
|
||||||
/// 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) {
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ class SeasonFlowNotifier extends Notifier<SeasonFlow?> {
|
|||||||
void startSeasonStage(SeasonPack pack, int index) {
|
void startSeasonStage(SeasonPack pack, int index) {
|
||||||
state = SeasonFlow(pack: pack, index: index);
|
state = SeasonFlow(pack: pack, index: index);
|
||||||
ref.read(gameSessionProvider.notifier).startStage(pack.stages[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 {
|
Future<void> recordWin({required int stars, required int score}) async {
|
||||||
|
|||||||
@@ -25,16 +25,17 @@ class TutorialNotifier extends Notifier<TutorialStep?> {
|
|||||||
|
|
||||||
Future<void> dismissHud() async {
|
Future<void> dismissHud() async {
|
||||||
if (state != TutorialStep.explainHud) return;
|
if (state != TutorialStep.explainHud) return;
|
||||||
await _finish();
|
await _finish(skipped: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> skip() async {
|
Future<void> skip() async {
|
||||||
if (state == null) return;
|
if (state == null) return;
|
||||||
await _finish();
|
await _finish(skipped: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _finish() async {
|
Future<void> _finish({required bool skipped}) async {
|
||||||
state = null;
|
state = null;
|
||||||
|
ref.read(analyticsProvider).tutorialFinished(skipped: skipped);
|
||||||
await ref.read(saveRepositoryProvider).markTutorialDone();
|
await ref.read(saveRepositoryProvider).markTutorialDone();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -160,6 +160,17 @@ class _GameScreenState extends ConsumerState<GameScreen>
|
|||||||
ref
|
ref
|
||||||
.read(seasonFlowProvider.notifier)
|
.read(seasonFlowProvider.notifier)
|
||||||
.recordWin(stars: next.starsEarned, score: next.score);
|
.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 =
|
final stackBox =
|
||||||
_stackKey.currentContext?.findRenderObject() as RenderBox?;
|
_stackKey.currentContext?.findRenderObject() as RenderBox?;
|
||||||
@@ -171,8 +182,22 @@ class _GameScreenState extends ConsumerState<GameScreen>
|
|||||||
if (next.phase == GamePhase.lost && next.endless) {
|
if (next.phase == GamePhase.lost && next.endless) {
|
||||||
ref.read(endlessBestProvider.notifier).record(next.score).then((isNew) {
|
ref.read(endlessBestProvider.notifier).record(next.score).then((isNew) {
|
||||||
if (mounted) setState(() => _endlessNewBest = 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) {
|
if (next.phase == GamePhase.won || next.phase == GamePhase.lost) {
|
||||||
ref.read(streakProvider.notifier).onStagePlayed(DateTime.now());
|
ref.read(streakProvider.notifier).onStagePlayed(DateTime.now());
|
||||||
}
|
}
|
||||||
@@ -367,7 +392,10 @@ class _GameScreenState extends ConsumerState<GameScreen>
|
|||||||
l10n.outOfMoves,
|
l10n.outOfMoves,
|
||||||
[
|
[
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: notifier.addExtraMoves,
|
onPressed: () {
|
||||||
|
ref.read(analyticsProvider).rescueUsed(type: 'extra_moves');
|
||||||
|
notifier.addExtraMoves();
|
||||||
|
},
|
||||||
child: Text(l10n.plusFiveMoves),
|
child: Text(l10n.plusFiveMoves),
|
||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
@@ -380,7 +408,10 @@ class _GameScreenState extends ConsumerState<GameScreen>
|
|||||||
l10n.boardFull,
|
l10n.boardFull,
|
||||||
[
|
[
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: notifier.useContinue,
|
onPressed: () {
|
||||||
|
ref.read(analyticsProvider).rescueUsed(type: 'continue');
|
||||||
|
notifier.useContinue();
|
||||||
|
},
|
||||||
child: Text(l10n.watchAdContinue),
|
child: Text(l10n.watchAdContinue),
|
||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ class HomeScreen extends ConsumerWidget {
|
|||||||
onPressed: () {
|
onPressed: () {
|
||||||
if (!(ModalRoute.of(context)?.isCurrent ?? true)) return;
|
if (!(ModalRoute.of(context)?.isCurrent ?? true)) return;
|
||||||
ref.read(seasonFlowProvider.notifier).clear();
|
ref.read(seasonFlowProvider.notifier).clear();
|
||||||
|
ref.read(analyticsProvider).endlessStart();
|
||||||
ref.read(gameSessionProvider.notifier).startStage(
|
ref.read(gameSessionProvider.notifier).startStage(
|
||||||
StageConfig.endless(
|
StageConfig.endless(
|
||||||
seed: DateTime.now().millisecondsSinceEpoch,
|
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