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) <noreply@anthropic.com>
This commit is contained in:
@@ -206,11 +206,10 @@ class _GameScreenState extends ConsumerState<GameScreen>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An empty booster offers to get one. The rewarded-ad grant is wired in
|
/// Task 15: an empty booster offers a rewarded ad; on reward, grant +1.
|
||||||
/// Task 15.
|
|
||||||
Future<void> _offerBoosterAd(BoosterType type) async {
|
Future<void> _offerBoosterAd(BoosterType type) async {
|
||||||
final l10n = AppLocalizations.of(context)!;
|
final l10n = AppLocalizations.of(context)!;
|
||||||
await showDialog<bool>(
|
final confirmed = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
content: Text(l10n.boosterGetWithAd),
|
content: Text(l10n.boosterGetWithAd),
|
||||||
@@ -226,6 +225,14 @@ class _GameScreenState extends ConsumerState<GameScreen>
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
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
|
@override
|
||||||
|
|||||||
@@ -4,14 +4,16 @@
|
|||||||
// the board is laid out inside an Expanded/Center whose pixel geometry is not
|
// 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.
|
// 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
|
// Those paths are left to manual QA (see the agent report). What we CAN verify
|
||||||
// deterministically is the inventory side-effect: shuffle applies immediately
|
// deterministically is the inventory side-effects:
|
||||||
// and spends one booster.
|
// - 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/core/rng.dart';
|
||||||
import 'package:block_seasons/data/save_repository.dart';
|
import 'package:block_seasons/data/save_repository.dart';
|
||||||
import 'package:block_seasons/game/engine/piece_generator.dart';
|
import 'package:block_seasons/game/engine/piece_generator.dart';
|
||||||
import 'package:block_seasons/game/models/booster.dart';
|
import 'package:block_seasons/game/models/booster.dart';
|
||||||
import 'package:block_seasons/game/models/stage.dart';
|
import 'package:block_seasons/game/models/stage.dart';
|
||||||
import 'package:block_seasons/l10n/gen/app_localizations.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/state/providers.dart';
|
||||||
import 'package:block_seasons/ui/screens/game_screen.dart';
|
import 'package:block_seasons/ui/screens/game_screen.dart';
|
||||||
import 'package:flutter/material.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(
|
Widget _wrap(ProviderContainer c) => UncontrolledProviderScope(
|
||||||
container: c,
|
container: c,
|
||||||
child: MaterialApp(
|
child: MaterialApp(
|
||||||
@@ -49,11 +56,12 @@ Widget _wrap(ProviderContainer c) => UncontrolledProviderScope(
|
|||||||
void main() {
|
void main() {
|
||||||
TestWidgetsFlutterBinding.ensureInitialized();
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
Future<ProviderContainer> startedContainer() async {
|
Future<ProviderContainer> startedContainer({AdService? ad}) async {
|
||||||
SharedPreferences.setMockInitialValues({});
|
SharedPreferences.setMockInitialValues({});
|
||||||
final repo = SaveRepository(await SharedPreferences.getInstance());
|
final repo = SaveRepository(await SharedPreferences.getInstance());
|
||||||
final c = ProviderContainer(overrides: [
|
final c = ProviderContainer(overrides: [
|
||||||
saveRepositoryProvider.overrideWithValue(repo),
|
saveRepositoryProvider.overrideWithValue(repo),
|
||||||
|
if (ad != null) adServiceProvider.overrideWithValue(ad),
|
||||||
]);
|
]);
|
||||||
c.read(gameSessionProvider.notifier).startStage(
|
c.read(gameSessionProvider.notifier).startStage(
|
||||||
_stage(),
|
_stage(),
|
||||||
@@ -78,4 +86,27 @@ void main() {
|
|||||||
expect(c.read(boosterInventoryProvider)[BoosterType.shuffle], 0);
|
expect(c.read(boosterInventoryProvider)[BoosterType.shuffle], 0);
|
||||||
c.dispose();
|
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();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user