From b8bfa0019698b737bff82260573159a27c26b810 Mon Sep 17 00:00:00 2001 From: airkjw Date: Thu, 18 Jun 2026 12:41:27 +0900 Subject: [PATCH] feat(ui): rewarded-ad grant for an empty booster Tapping an empty booster opens the get-one dialog; confirming watches a rewarded ad and, on earn, grants +1 of that booster and logs booster_granted (source: ad). Covered by a widget test using an uninitialized AdService whose showRewarded() resolves true. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/ui/screens/game_screen.dart | 13 +++++++--- test/ui/game_screen_booster_test.dart | 37 ++++++++++++++++++++++++--- 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/lib/ui/screens/game_screen.dart b/lib/ui/screens/game_screen.dart index 8d67dd0..a5208d1 100644 --- a/lib/ui/screens/game_screen.dart +++ b/lib/ui/screens/game_screen.dart @@ -206,11 +206,10 @@ class _GameScreenState extends ConsumerState ); } - /// An empty booster offers to get one. The rewarded-ad grant is wired in - /// Task 15. + /// Task 15: an empty booster offers a rewarded ad; on reward, grant +1. Future _offerBoosterAd(BoosterType type) async { final l10n = AppLocalizations.of(context)!; - await showDialog( + final confirmed = await showDialog( context: context, builder: (context) => AlertDialog( content: Text(l10n.boosterGetWithAd), @@ -226,6 +225,14 @@ class _GameScreenState extends ConsumerState ], ), ); + if (confirmed != true) return; + final earned = await ref.read(adServiceProvider).showRewarded(); + if (earned) { + await ref.read(boosterInventoryProvider.notifier).grant(type, 1); + ref + .read(analyticsProvider) + .boosterGranted(type: type.name, count: 1, source: 'ad'); + } } @override diff --git a/test/ui/game_screen_booster_test.dart b/test/ui/game_screen_booster_test.dart index 7bae090..61a188e 100644 --- a/test/ui/game_screen_booster_test.dart +++ b/test/ui/game_screen_booster_test.dart @@ -4,14 +4,16 @@ // the board is laid out inside an Expanded/Center whose pixel geometry is not // deterministic in a widget test, so a pixel-accurate cell tap is unreliable. // Those paths are left to manual QA (see the agent report). What we CAN verify -// deterministically is the inventory side-effect: shuffle applies immediately -// and spends one booster. +// deterministically is the inventory side-effects: +// - shuffle applies immediately and spends one booster +// - tapping an empty booster shows the ad dialog whose confirm grants +1 import 'package:block_seasons/core/rng.dart'; import 'package:block_seasons/data/save_repository.dart'; import 'package:block_seasons/game/engine/piece_generator.dart'; import 'package:block_seasons/game/models/booster.dart'; import 'package:block_seasons/game/models/stage.dart'; import 'package:block_seasons/l10n/gen/app_localizations.dart'; +import 'package:block_seasons/services/ad_service.dart'; import 'package:block_seasons/state/providers.dart'; import 'package:block_seasons/ui/screens/game_screen.dart'; import 'package:flutter/material.dart'; @@ -35,6 +37,11 @@ StageConfig _stage() => StageConfig.fromJson({ }, }); +/// A real, uninitialized [AdService]: with no SDK loaded its [showRewarded] +/// takes the "no ad available -> grant the reward" path and resolves true +/// without touching the platform — exactly the rewarded-earn case we want. +AdService _earnAd() => AdService(adsRemoved: () => true); + Widget _wrap(ProviderContainer c) => UncontrolledProviderScope( container: c, child: MaterialApp( @@ -49,11 +56,12 @@ Widget _wrap(ProviderContainer c) => UncontrolledProviderScope( void main() { TestWidgetsFlutterBinding.ensureInitialized(); - Future startedContainer() async { + Future startedContainer({AdService? ad}) async { SharedPreferences.setMockInitialValues({}); final repo = SaveRepository(await SharedPreferences.getInstance()); final c = ProviderContainer(overrides: [ saveRepositoryProvider.overrideWithValue(repo), + if (ad != null) adServiceProvider.overrideWithValue(ad), ]); c.read(gameSessionProvider.notifier).startStage( _stage(), @@ -78,4 +86,27 @@ void main() { expect(c.read(boosterInventoryProvider)[BoosterType.shuffle], 0); c.dispose(); }); + + testWidgets('tapping an empty booster offers an ad that grants +1', + (tester) async { + final c = await startedContainer(ad: _earnAd()); + expect(c.read(boosterInventoryProvider)[BoosterType.hammer], 0); + + await tester.pumpWidget(_wrap(c)); + await tester.pump(); + + await tester.tap(find.byKey(const ValueKey('booster_hammer'))); + await tester.pumpAndSettle(); + + // The dialog offers the "watch an ad to get one" action; confirm it. + final l10n = AppLocalizations.of( + tester.element(find.byType(GameScreen)), + )!; + await tester.tap(find.text(l10n.boosterGetWithAd).last); + await tester.pump(); + await tester.pump(); + + expect(c.read(boosterInventoryProvider)[BoosterType.hammer], 1); + c.dispose(); + }); }