# Boosters & Daily Reward Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add a lightweight booster economy (hammer / shuffle / line-bomb) earned via a 7-day login calendar and rewarded ads, used in a stage through a booster bar. **Architecture:** Pure-Dart booster ops live on `GameEngine` (grid-only mutation, no score/move/objective effects). A `BoosterType` enum + `SaveRepository` counts back a `BoosterInventoryNotifier`. A pure `DailyRewardCalendar` computes the claimable day; `DailyRewardNotifier` grants. `GameSessionNotifier` coordinates engine + inventory on use. UI adds a booster bar (targeting mode) and a daily popup. Rewarded ads reuse `AdService.showRewarded()`. **Tech Stack:** Flutter, Riverpod (plain Notifiers, no codegen), shared_preferences, google_mobile_ads, flutter gen-l10n, flutter_test. **Spec:** `docs/superpowers/specs/2026-06-18-boosters-daily-reward-design.md` **Conventions:** TDD (red → green). Run a single test file with `flutter test path`. Run all with `flutter test`. `flutter analyze` must stay clean. **Do NOT run `flutter build` / Xcode — builds are owner-commanded only** (see CLAUDE.md). Commit after each green task. --- ## File Structure - Create `lib/game/models/booster.dart` — `BoosterType` enum (pure). - Modify `lib/game/engine/game_engine.dart` — `useHammer`/`useShuffle`/`useLineBomb`. - Modify `lib/data/save_repository.dart` — booster counts + daily-claim persistence. - Create `lib/game/daily/daily_reward.dart` — pure `DailyRewardCalendar` + reward table. - Create `lib/state/booster_inventory_notifier.dart` — inventory Notifier. - Create `lib/state/daily_reward_notifier.dart` — daily Notifier. - Modify `lib/state/game_session_notifier.dart` — booster-use coordination + view fields. - Modify `lib/state/providers.dart` — new providers. - Create `lib/ui/widgets/booster_bar.dart` — booster bar + targeting state. - Modify `lib/ui/screens/game_screen.dart` — mount bar, targeting taps, 0-count ad dialog. - Create `lib/ui/widgets/daily_reward_sheet.dart` — 7-day popup. - Modify `lib/ui/screens/home_screen.dart` — show popup when claimable. - Modify `lib/services/analytics_service.dart` — booster/daily events. - Modify `lib/l10n/app_en.arb`, `lib/l10n/app_ko.arb` — strings. - Tests under `test/game/`, `test/data/`, `test/state/`, `test/ui/`. --- ## Task 1: Engine — `useHammer` **Files:** - Modify: `lib/game/engine/game_engine.dart` - Test: `test/game/engine/booster_test.dart` - [ ] **Step 1: Write the failing test** ```dart import 'package:block_seasons/game/engine/game_engine.dart'; import 'package:block_seasons/game/models/stage.dart'; import 'package:flutter_test/flutter_test.dart'; StageConfig _stage() => StageConfig.fromJson({ 'id': 'b_test', 'seed': 1, 'moveLimit': 20, 'preset': [ {'x': 0, 'y': 0, 't': 'filled', 'c': 3}, {'x': 1, 'y': 0, 't': 'filled', 'c': 4}, ], 'objectives': [ {'type': 'reachScore', 'target': 100000}, ], }); void main() { test('useHammer empties a filled cell without scoring or spending a move', () { final e = GameEngine(_stage()); final score0 = e.score; final moves0 = e.movesUsed; expect(e.grid.isOccupied(0, 0), isTrue); final ok = e.useHammer(0, 0); expect(ok, isTrue); expect(e.grid.isOccupied(0, 0), isFalse); expect(e.score, score0, reason: 'no points from a booster'); expect(e.movesUsed, moves0, reason: 'no move spent'); }); test('useHammer on an empty cell returns false and changes nothing', () { final e = GameEngine(_stage()); expect(e.grid.isOccupied(5, 5), isFalse); expect(e.useHammer(5, 5), isFalse); expect(e.grid.isOccupied(5, 5), isFalse); }); test('useHammer is rejected once the stage is won or lost', () { final e = GameEngine(_stage()); e.declineAndLose(); expect(e.useHammer(0, 0), isFalse); }); } ``` - [ ] **Step 2: Run test to verify it fails** Run: `flutter test test/game/engine/booster_test.dart` Expected: FAIL — `useHammer` not defined. - [ ] **Step 3: Write minimal implementation** Add to `GameEngine` (after `declineAndLose`): ```dart /// Booster: empties one filled cell. No move/score/combo/objective effect. /// Allowed only mid-attempt (playing or stuck). Returns false on an empty /// cell or finished attempt so the caller keeps the booster. bool useHammer(int x, int y) { if (_phase == GamePhase.won || _phase == GamePhase.lost) return false; if (!_grid.isOccupied(x, y)) return false; _grid = _grid.withCell(x, y, const Cell(CellType.empty)); _checkStuck(); return true; } ``` Ensure `cell.dart` (`Cell`, `CellType`) is imported — it already is. - [ ] **Step 4: Run test to verify it passes** Run: `flutter test test/game/engine/booster_test.dart` Expected: PASS (3 tests). - [ ] **Step 5: Commit** ```bash git add lib/game/engine/game_engine.dart test/game/engine/booster_test.dart git commit -m "feat(engine): hammer booster removes one cell, no scoring" ``` --- ## Task 2: Engine — `useShuffle` **Files:** - Modify: `lib/game/engine/game_engine.dart` - Test: `test/game/engine/booster_test.dart` (append) - [ ] **Step 1: Append the failing test** ```dart test('useShuffle replaces the tray without spending a move or scoring', () { final e = GameEngine(_stage()); final before = List.of(e.tray); final score0 = e.score; final moves0 = e.movesUsed; final ok = e.useShuffle(); expect(ok, isTrue); expect(e.tray.length, before.length); expect(e.score, score0); expect(e.movesUsed, moves0); }); test('useShuffle is rejected after the attempt finishes', () { final e = GameEngine(_stage()); e.declineAndLose(); expect(e.useShuffle(), isFalse); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `flutter test test/game/engine/booster_test.dart` Expected: FAIL — `useShuffle` not defined. - [ ] **Step 3: Write minimal implementation** ```dart /// Booster: re-deals the tray. No move/score effect. Re-checks stuck so a /// dead board with a hopeless tray can become playable again. bool useShuffle() { if (_phase == GamePhase.won || _phase == GamePhase.lost) return false; _tray = _generator.nextTray(_grid); _checkStuck(); return true; } ``` - [ ] **Step 4: Run test to verify it passes** Run: `flutter test test/game/engine/booster_test.dart` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add lib/game/engine/game_engine.dart test/game/engine/booster_test.dart git commit -m "feat(engine): shuffle booster re-deals the tray" ``` --- ## Task 3: Engine — `useLineBomb` **Files:** - Modify: `lib/game/engine/game_engine.dart` - Test: `test/game/engine/booster_test.dart` (append) - [ ] **Step 1: Append the failing test** ```dart test('useLineBomb(row:) empties that row, no scoring or move', () { final e = GameEngine(_stage()); // row 0 has cells at x=0,1 final score0 = e.score; final moves0 = e.movesUsed; final ok = e.useLineBomb(row: 0); expect(ok, isTrue); for (var x = 0; x < 8; x++) { expect(e.grid.isOccupied(x, 0), isFalse, reason: 'col $x'); } expect(e.score, score0); expect(e.movesUsed, moves0); }); test('useLineBomb(col:) empties that column', () { final e = GameEngine(_stage()); // (0,0) filled expect(e.useLineBomb(col: 0), isTrue); for (var y = 0; y < 8; y++) { expect(e.grid.isOccupied(0, y), isFalse, reason: 'row $y'); } }); test('useLineBomb requires exactly one of row/col', () { final e = GameEngine(_stage()); expect(e.useLineBomb(), isFalse); expect(e.useLineBomb(row: 0, col: 0), isFalse); }); test('useLineBomb is rejected after the attempt finishes', () { final e = GameEngine(_stage()); e.declineAndLose(); expect(e.useLineBomb(row: 0), isFalse); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `flutter test test/game/engine/booster_test.dart` Expected: FAIL — `useLineBomb` not defined. - [ ] **Step 3: Write minimal implementation** ```dart /// Booster: empties one row or one column (exactly one of [row]/[col]). /// No move/score/objective effect. Re-checks stuck. bool useLineBomb({int? row, int? col}) { if (_phase == GamePhase.won || _phase == GamePhase.lost) return false; if ((row == null) == (col == null)) return false; // need exactly one for (var i = 0; i < GridState.size; i++) { final x = col ?? i; final y = row ?? i; _grid = _grid.withCell(x, y, const Cell(CellType.empty)); } _checkStuck(); return true; } ``` - [ ] **Step 4: Run test to verify it passes** Run: `flutter test test/game/engine/booster_test.dart` Expected: PASS (all booster engine tests). - [ ] **Step 5: Commit** ```bash git add lib/game/engine/game_engine.dart test/game/engine/booster_test.dart git commit -m "feat(engine): line-bomb booster clears a row or column" ``` --- ## Task 4: `BoosterType` model **Files:** - Create: `lib/game/models/booster.dart` - [ ] **Step 1: Create the enum** (no behavior → no test; it is exercised by later tasks) ```dart // lib/game/models/booster.dart /// The three boosters the player can earn and spend. Pure model so the data /// and state layers can both reference it without a Flutter dependency. enum BoosterType { hammer, shuffle, lineBomb } ``` - [ ] **Step 2: Verify analyze is clean** Run: `flutter analyze` Expected: No issues. - [ ] **Step 3: Commit** ```bash git add lib/game/models/booster.dart git commit -m "feat(model): BoosterType enum" ``` --- ## Task 5: SaveRepository — booster inventory **Files:** - Modify: `lib/data/save_repository.dart` - Test: `test/data/save_repository_booster_test.dart` - [ ] **Step 1: Write the failing test** ```dart import 'package:block_seasons/data/save_repository.dart'; import 'package:block_seasons/game/models/booster.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); Future fresh() async { SharedPreferences.setMockInitialValues({}); return SaveRepository(await SharedPreferences.getInstance()); } test('booster counts start at zero', () async { final repo = await fresh(); for (final t in BoosterType.values) { expect(repo.boosterCount(t), 0); } }); test('grantBooster adds and persists across reload', () async { final repo = await fresh(); await repo.grantBooster(BoosterType.hammer, 2); expect(repo.boosterCount(BoosterType.hammer), 2); final reloaded = SaveRepository(await SharedPreferences.getInstance()); expect(reloaded.boosterCount(BoosterType.hammer), 2); }); test('consumeBooster decrements and returns false at zero', () async { final repo = await fresh(); await repo.grantBooster(BoosterType.shuffle, 1); expect(await repo.consumeBooster(BoosterType.shuffle), isTrue); expect(repo.boosterCount(BoosterType.shuffle), 0); expect(await repo.consumeBooster(BoosterType.shuffle), isFalse); }); } ``` - [ ] **Step 2: Run test to verify it fails** Run: `flutter test test/data/save_repository_booster_test.dart` Expected: FAIL — `boosterCount` not defined. - [ ] **Step 3: Write minimal implementation** In `save_repository.dart`: 1. Add import at top: `import '../game/models/booster.dart';` 2. Add field + parse in constructor (inside the `if (raw != null)` block, after the flags parsing): ```dart final boosters = json['boosters'] as Map? ?? {}; for (final t in BoosterType.values) { _boosters[t] = boosters[t.name] as int? ?? 0; } ``` 3. Add field near the other fields: ```dart final Map _boosters = { for (final t in BoosterType.values) t: 0, }; ``` 4. Add API: ```dart int boosterCount(BoosterType type) => _boosters[type] ?? 0; Future grantBooster(BoosterType type, [int n = 1]) { _boosters[type] = (_boosters[type] ?? 0) + n; return _flush(); } /// Spends one booster. Returns false (and changes nothing) when none are left. Future consumeBooster(BoosterType type) async { final have = _boosters[type] ?? 0; if (have <= 0) return false; _boosters[type] = have - 1; await _flush(); return true; } ``` 5. In `_flush()`'s `jsonEncode({...})`, add a key: ```dart 'boosters': {for (final t in BoosterType.values) t.name: _boosters[t]}, ``` - [ ] **Step 4: Run test to verify it passes** Run: `flutter test test/data/save_repository_booster_test.dart` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add lib/data/save_repository.dart test/data/save_repository_booster_test.dart git commit -m "feat(save): persist booster inventory" ``` --- ## Task 6: SaveRepository — daily-claim persistence **Files:** - Modify: `lib/data/save_repository.dart` - Test: `test/data/save_repository_daily_test.dart` - [ ] **Step 1: Write the failing test** ```dart import 'package:block_seasons/data/save_repository.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); Future fresh() async { SharedPreferences.setMockInitialValues({}); return SaveRepository(await SharedPreferences.getInstance()); } test('daily claim fields start empty', () async { final repo = await fresh(); expect(repo.dailyLastClaimedYmd, isNull); expect(repo.dailyCalendarDay, 0); }); test('recordDailyClaim persists day and ymd across reload', () async { final repo = await fresh(); await repo.recordDailyClaim('2026-06-18', 3); expect(repo.dailyLastClaimedYmd, '2026-06-18'); expect(repo.dailyCalendarDay, 3); final reloaded = SaveRepository(await SharedPreferences.getInstance()); expect(reloaded.dailyLastClaimedYmd, '2026-06-18'); expect(reloaded.dailyCalendarDay, 3); }); } ``` - [ ] **Step 2: Run test to verify it fails** Run: `flutter test test/data/save_repository_daily_test.dart` Expected: FAIL — `dailyLastClaimedYmd` not defined. - [ ] **Step 3: Write minimal implementation** 1. Add fields + parse (in constructor, after boosters parse): ```dart final daily = json['daily'] as Map?; _dailyLastClaimedYmd = daily?['lastYmd'] as String?; _dailyCalendarDay = daily?['day'] as int? ?? 0; ``` 2. Fields: ```dart String? _dailyLastClaimedYmd; int _dailyCalendarDay = 0; ``` 3. API: ```dart String? get dailyLastClaimedYmd => _dailyLastClaimedYmd; int get dailyCalendarDay => _dailyCalendarDay; Future recordDailyClaim(String ymd, int day) { _dailyLastClaimedYmd = ymd; _dailyCalendarDay = day; return _flush(); } ``` 4. In `_flush()`'s map, add: ```dart 'daily': {'lastYmd': _dailyLastClaimedYmd, 'day': _dailyCalendarDay}, ``` - [ ] **Step 4: Run test to verify it passes** Run: `flutter test test/data/save_repository_daily_test.dart` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add lib/data/save_repository.dart test/data/save_repository_daily_test.dart git commit -m "feat(save): persist daily-reward claim state" ``` --- ## Task 7: Pure `DailyRewardCalendar` **Files:** - Create: `lib/game/daily/daily_reward.dart` - Test: `test/game/daily/daily_reward_test.dart` This is pure logic: given the last-claimed ymd + stored day + today's ymd, compute whether today is claimable and which calendar day (1–7) applies, plus the reward table. Dates are passed as `DateTime` (date-only) to stay testable. - [ ] **Step 1: Write the failing test** ```dart import 'package:block_seasons/game/daily/daily_reward.dart'; import 'package:block_seasons/game/models/booster.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { final cal = DailyRewardCalendar(); DateTime d(int day) => DateTime(2026, 6, day); test('first ever claim is day 1', () { final r = cal.resolve(lastClaimedYmd: null, storedDay: 0, today: d(18)); expect(r.claimable, isTrue); expect(r.day, 1); }); test('claiming again the same day is not claimable', () { final r = cal.resolve( lastClaimedYmd: '2026-06-18', storedDay: 1, today: d(18)); expect(r.claimable, isFalse); }); test('a consecutive day advances the calendar', () { final r = cal.resolve( lastClaimedYmd: '2026-06-18', storedDay: 3, today: d(19)); expect(r.claimable, isTrue); expect(r.day, 4); }); test('day 7 wraps back to day 1', () { final r = cal.resolve( lastClaimedYmd: '2026-06-18', storedDay: 7, today: d(19)); expect(r.day, 1); }); test('a missed day resets to day 1', () { final r = cal.resolve( lastClaimedYmd: '2026-06-18', storedDay: 4, today: d(20)); expect(r.claimable, isTrue); expect(r.day, 1); }); test('reward table covers days 1..7 and day 7 is the jackpot', () { expect(cal.rewardFor(1), {BoosterType.hammer: 1}); expect(cal.rewardFor(7), {BoosterType.hammer: 2, BoosterType.shuffle: 2, BoosterType.lineBomb: 2}); }); test('ymd formats a date as YYYY-MM-DD', () { expect(cal.ymd(DateTime(2026, 6, 7)), '2026-06-07'); }); } ``` - [ ] **Step 2: Run test to verify it fails** Run: `flutter test test/game/daily/daily_reward_test.dart` Expected: FAIL — `DailyRewardCalendar` not defined. - [ ] **Step 3: Write minimal implementation** ```dart // lib/game/daily/daily_reward.dart import '../models/booster.dart'; /// The result of evaluating the calendar for "today". class DailyResolution { const DailyResolution({required this.claimable, required this.day}); final bool claimable; final int day; // 1..7, the day to show/grant } /// Pure 7-day login-calendar logic. No storage or clock — callers pass the /// persisted state and today's date so it is fully unit-testable. class DailyRewardCalendar { const DailyRewardCalendar(); static const int cycle = 7; String ymd(DateTime date) { final m = date.month.toString().padLeft(2, '0'); final d = date.day.toString().padLeft(2, '0'); return '${date.year}-$m-$d'; } DailyResolution resolve({ required String? lastClaimedYmd, required int storedDay, required DateTime today, }) { final todayYmd = ymd(today); if (lastClaimedYmd == todayYmd) { return DailyResolution(claimable: false, day: storedDay); } final yesterday = ymd(today.subtract(const Duration(days: 1))); final int day; if (lastClaimedYmd == yesterday) { day = (storedDay % cycle) + 1; // advance, wrapping 7 -> 1 } else { day = 1; // first ever, or a day was missed } return DailyResolution(claimable: true, day: day); } /// Boosters granted for a given calendar day (1..7). Tunable. Map rewardFor(int day) { switch (day) { case 1: return {BoosterType.hammer: 1}; case 2: return {BoosterType.shuffle: 1}; case 3: return {BoosterType.lineBomb: 1}; case 4: return {BoosterType.hammer: 1, BoosterType.shuffle: 1}; case 5: return {BoosterType.shuffle: 1, BoosterType.lineBomb: 1}; case 6: return {BoosterType.hammer: 1, BoosterType.lineBomb: 1}; case 7: default: return { BoosterType.hammer: 2, BoosterType.shuffle: 2, BoosterType.lineBomb: 2, }; } } } ``` - [ ] **Step 4: Run test to verify it passes** Run: `flutter test test/game/daily/daily_reward_test.dart` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add lib/game/daily/daily_reward.dart test/game/daily/daily_reward_test.dart git commit -m "feat(daily): pure 7-day login-calendar logic + reward table" ``` --- ## Task 8: `BoosterInventoryNotifier` **Files:** - Create: `lib/state/booster_inventory_notifier.dart` - Modify: `lib/state/providers.dart` - Test: `test/state/booster_inventory_test.dart` - [ ] **Step 1: Write the failing test** ```dart import 'package:block_seasons/data/save_repository.dart'; import 'package:block_seasons/game/models/booster.dart'; import 'package:block_seasons/state/booster_inventory_notifier.dart'; import 'package:block_seasons/state/providers.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); Future container() async { SharedPreferences.setMockInitialValues({}); final repo = SaveRepository(await SharedPreferences.getInstance()); return ProviderContainer( overrides: [saveRepositoryProvider.overrideWithValue(repo)], ); } test('exposes counts and updates on grant/consume', () async { final c = await container(); final notifier = c.read(boosterInventoryProvider.notifier); expect(c.read(boosterInventoryProvider)[BoosterType.hammer], 0); await notifier.grant(BoosterType.hammer, 2); expect(c.read(boosterInventoryProvider)[BoosterType.hammer], 2); expect(await notifier.consume(BoosterType.hammer), isTrue); expect(c.read(boosterInventoryProvider)[BoosterType.hammer], 1); }); } ``` - [ ] **Step 2: Run test to verify it fails** Run: `flutter test test/state/booster_inventory_test.dart` Expected: FAIL — `booster_inventory_notifier.dart` missing. - [ ] **Step 3: Write minimal implementation** ```dart // lib/state/booster_inventory_notifier.dart import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../data/save_repository.dart'; import '../game/models/booster.dart'; /// Live booster counts backed by [SaveRepository]. State is an immutable map. class BoosterInventoryNotifier extends Notifier> { SaveRepository get _save => ref.read(saveRepositoryProvider); @override Map build() => _snapshot(); Map _snapshot() => {for (final t in BoosterType.values) t: _save.boosterCount(t)}; Future grant(BoosterType type, [int n = 1]) async { await _save.grantBooster(type, n); state = _snapshot(); } Future consume(BoosterType type) async { final ok = await _save.consumeBooster(type); if (ok) state = _snapshot(); return ok; } } ``` Note: `saveRepositoryProvider` is already imported via `providers.dart`; this file imports `save_repository.dart` directly to read the provider value through `ref`. Add to `providers.dart` (with the other imports + providers): ```dart import 'booster_inventory_notifier.dart'; ``` ```dart final boosterInventoryProvider = NotifierProvider>( BoosterInventoryNotifier.new, ); ``` Also add `import '../game/models/booster.dart';` to `providers.dart` if not present. - [ ] **Step 4: Run test to verify it passes** Run: `flutter test test/state/booster_inventory_test.dart` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add lib/state/booster_inventory_notifier.dart lib/state/providers.dart test/state/booster_inventory_test.dart git commit -m "feat(state): BoosterInventoryNotifier" ``` --- ## Task 9: `GameSessionNotifier` — booster-use coordination **Files:** - Modify: `lib/state/game_session_notifier.dart` - Test: `test/state/game_session_booster_test.dart` Coordination rule: call the engine first; **consume inventory only on engine success**; never call the engine when the count is 0. Returns a small result so the UI knows whether to show the "watch ad" path. - [ ] **Step 1: Write the failing test** ```dart import 'package:block_seasons/data/save_repository.dart'; import 'package:block_seasons/game/engine/piece_generator.dart'; import 'package:block_seasons/core/rng.dart'; import 'package:block_seasons/game/models/booster.dart'; import 'package:block_seasons/game/models/stage.dart'; import 'package:block_seasons/state/booster_inventory_notifier.dart'; import 'package:block_seasons/state/providers.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; StageConfig _stage() => StageConfig.fromJson({ 'id': 'gs_b', 'seed': 1, 'moveLimit': 20, 'preset': [ {'x': 0, 'y': 0, 't': 'filled', 'c': 3}, ], 'objectives': [ {'type': 'reachScore', 'target': 100000}, ], }); void main() { TestWidgetsFlutterBinding.ensureInitialized(); Future container() async { SharedPreferences.setMockInitialValues({}); final repo = SaveRepository(await SharedPreferences.getInstance()); final c = ProviderContainer( overrides: [saveRepositoryProvider.overrideWithValue(repo)], ); c.read(gameSessionProvider.notifier) .startStage(_stage(), generator: PieceGenerator(SeededRng(1))); return c; } test('hammer consumes a booster only when one is owned and target valid', () async { final c = await container(); final session = c.read(gameSessionProvider.notifier); final inv = c.read(boosterInventoryProvider.notifier); // No booster -> noBooster, grid unchanged. expect(await session.useHammer(0, 0), BoosterUseResult.noBooster); await inv.grant(BoosterType.hammer, 1); expect(await session.useHammer(0, 0), BoosterUseResult.success); expect(c.read(boosterInventoryProvider)[BoosterType.hammer], 0); expect(c.read(gameSessionProvider)!.grid.isOccupied(0, 0), isFalse); }); test('an invalid target keeps the booster', () async { final c = await container(); final session = c.read(gameSessionProvider.notifier); await c.read(boosterInventoryProvider.notifier).grant(BoosterType.hammer, 1); expect(await session.useHammer(5, 5), BoosterUseResult.invalidTarget); expect(c.read(boosterInventoryProvider)[BoosterType.hammer], 1); }); } ``` - [ ] **Step 2: Run test to verify it fails** Run: `flutter test test/state/game_session_booster_test.dart` Expected: FAIL — `useHammer`/`BoosterUseResult` not defined. - [ ] **Step 3: Write minimal implementation** In `game_session_notifier.dart`: 1. Imports: `import '../game/models/booster.dart';` and `import 'booster_inventory_notifier.dart';` (and `providers.dart` access to `boosterInventoryProvider` — it is in the same library group; import what is needed). 2. Add the result enum near the top of the file: ```dart enum BoosterUseResult { success, noBooster, invalidTarget } ``` 3. Add methods to the notifier (the engine instance is held as `_engine`; confirm the field name when implementing and rebuild the view via the existing `_emit()`/`state = ...` mechanism this file already uses after a move): ```dart Future useHammer(int x, int y) => _useBooster(BoosterType.hammer, () => _engine.useHammer(x, y)); Future useShuffle() => _useBooster(BoosterType.shuffle, () => _engine.useShuffle()); Future useLineBomb({int? row, int? col}) => _useBooster( BoosterType.lineBomb, () => _engine.useLineBomb(row: row, col: col), ); Future _useBooster( BoosterType type, bool Function() apply) async { final inv = ref.read(boosterInventoryProvider.notifier); if (ref.read(boosterInventoryProvider)[type]! <= 0) { return BoosterUseResult.noBooster; } if (!apply()) return BoosterUseResult.invalidTarget; await inv.consume(type); _emitFromEngine(); // rebuild GameViewState from _engine (use existing helper) return BoosterUseResult.success; } ``` **Implementation note:** This file already rebuilds `state` (a `GameViewState`) from the engine after `tryPlace`. Reuse that exact rebuild path for `_emitFromEngine()` — do not invent a new one. Read the file first and call the same constructor/helper used after a successful placement. - [ ] **Step 4: Run test to verify it passes** Run: `flutter test test/state/game_session_booster_test.dart` Expected: PASS. - [ ] **Step 5: Run the full suite + analyze** Run: `flutter test` then `flutter analyze` Expected: all green, no issues. - [ ] **Step 6: Commit** ```bash git add lib/state/game_session_notifier.dart test/state/game_session_booster_test.dart git commit -m "feat(state): coordinate booster use with inventory in the session" ``` --- ## Task 10: `DailyRewardNotifier` **Files:** - Create: `lib/state/daily_reward_notifier.dart` - Modify: `lib/state/providers.dart` - Test: `test/state/daily_reward_notifier_test.dart` State: the current `DailyResolution` (claimable + day). `claim({bool doubled})` grants the day's boosters (×2 if doubled), records the claim, refreshes state. A `DateTime Function() now` is injected for testability (default `DateTime.now`). - [ ] **Step 1: Write the failing test** ```dart import 'package:block_seasons/data/save_repository.dart'; import 'package:block_seasons/game/models/booster.dart'; import 'package:block_seasons/state/booster_inventory_notifier.dart'; import 'package:block_seasons/state/daily_reward_notifier.dart'; import 'package:block_seasons/state/providers.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); Future container(DateTime today) async { SharedPreferences.setMockInitialValues({}); final repo = SaveRepository(await SharedPreferences.getInstance()); return ProviderContainer(overrides: [ saveRepositoryProvider.overrideWithValue(repo), dailyNowProvider.overrideWithValue(() => today), ]); } test('first day is claimable as day 1 and claim grants the reward', () async { final c = await container(DateTime(2026, 6, 18)); expect(c.read(dailyRewardProvider).claimable, isTrue); expect(c.read(dailyRewardProvider).day, 1); await c.read(dailyRewardProvider.notifier).claim(); expect(c.read(boosterInventoryProvider)[BoosterType.hammer], 1); // day 1 expect(c.read(dailyRewardProvider).claimable, isFalse); }); test('doubled claim grants twice the reward', () async { final c = await container(DateTime(2026, 6, 18)); await c.read(dailyRewardProvider.notifier).claim(doubled: true); expect(c.read(boosterInventoryProvider)[BoosterType.hammer], 2); }); } ``` - [ ] **Step 2: Run test to verify it fails** Run: `flutter test test/state/daily_reward_notifier_test.dart` Expected: FAIL — `daily_reward_notifier.dart` / `dailyNowProvider` missing. - [ ] **Step 3: Write minimal implementation** ```dart // lib/state/daily_reward_notifier.dart import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../data/save_repository.dart'; import '../game/daily/daily_reward.dart'; import '../game/models/booster.dart'; import 'booster_inventory_notifier.dart'; import 'providers.dart'; class DailyRewardNotifier extends Notifier { static const _cal = DailyRewardCalendar(); SaveRepository get _save => ref.read(saveRepositoryProvider); DateTime Function() get _now => ref.read(dailyNowProvider); @override DailyResolution build() => _resolve(); DailyResolution _resolve() => _cal.resolve( lastClaimedYmd: _save.dailyLastClaimedYmd, storedDay: _save.dailyCalendarDay, today: _now(), ); /// Grants the current day's reward (×2 if [doubled]) and records the claim. Future claim({bool doubled = false}) async { final r = state; if (!r.claimable) return; final reward = _cal.rewardFor(r.day); final inv = ref.read(boosterInventoryProvider.notifier); for (final entry in reward.entries) { await inv.grant(entry.key, entry.value * (doubled ? 2 : 1)); } await _save.recordDailyClaim(_cal.ymd(_now()), r.day); state = _resolve(); } } ``` Add to `providers.dart`: ```dart import 'daily_reward_notifier.dart'; ``` ```dart /// Injectable clock for the daily calendar (overridden in tests). final dailyNowProvider = Provider((ref) => DateTime.now); final dailyRewardProvider = NotifierProvider( DailyRewardNotifier.new, ); ``` Add `import '../game/daily/daily_reward.dart';` to `providers.dart` for `DailyResolution`. - [ ] **Step 4: Run test to verify it passes** Run: `flutter test test/state/daily_reward_notifier_test.dart` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add lib/state/daily_reward_notifier.dart lib/state/providers.dart test/state/daily_reward_notifier_test.dart git commit -m "feat(state): DailyRewardNotifier with injectable clock" ``` --- ## Task 11: Analytics events **Files:** - Modify: `lib/services/analytics_service.dart` - Test: `test/services/analytics_service_test.dart` (append; this test uses a fake backend already in the file — match its pattern) - [ ] **Step 1: Read the existing test** to copy its fake-backend setup, then append: ```dart test('booster + daily events carry their fields', () { final backend = _RecordingBackend(); // reuse the file's existing fake final a = AnalyticsService(backend); a.boosterUsed(type: 'hammer'); a.boosterGranted(type: 'hammer', count: 2, source: 'daily'); a.dailyRewardClaimed(day: 7, doubled: true); expect(backend.events, [ ('booster_used', {'type': 'hammer'}), ('booster_granted', {'type': 'hammer', 'count': 2, 'source': 'daily'}), ('daily_reward_claimed', {'day': 7, 'doubled': 1}), ]); }); ``` If the existing test file's fake records differently, adapt the assertion to its shape — the point is that each method forwards the named event + params. - [ ] **Step 2: Run test to verify it fails** Run: `flutter test test/services/analytics_service_test.dart` Expected: FAIL — methods not defined. - [ ] **Step 3: Write minimal implementation** (append to `AnalyticsService`) ```dart void boosterUsed({required String type}) => _backend.logEvent('booster_used', {'type': type}); void boosterGranted( {required String type, required int count, required String source}) => _backend.logEvent( 'booster_granted', {'type': type, 'count': count, 'source': source}); void dailyRewardClaimed({required int day, required bool doubled}) => _backend.logEvent( 'daily_reward_claimed', {'day': day, 'doubled': doubled ? 1 : 0}); ``` - [ ] **Step 4: Run test to verify it passes** Run: `flutter test test/services/analytics_service_test.dart` Expected: PASS. - [ ] **Step 5: Wire the calls** (no new behavior, just emit): - In `BoosterInventoryNotifier.consume` success path is the wrong layer; instead emit `boosterUsed` from `GameSessionNotifier._useBooster` on success, and `boosterGranted`/`dailyRewardClaimed` from `DailyRewardNotifier.claim` and from the rewarded-ad grant path (Task 14). Read `analyticsProvider` via `ref`. Add, in `_useBooster` after `await inv.consume(type)`: `ref.read(analyticsProvider).boosterUsed(type: type.name);` In `DailyRewardNotifier.claim` after recording: `ref.read(analyticsProvider).dailyRewardClaimed(day: r.day, doubled: doubled);` and per grant: `ref.read(analyticsProvider).boosterGranted(type: e.key.name, count: e.value*(doubled?2:1), source: 'daily');` - [ ] **Step 6: Run full suite + commit** Run: `flutter test` ```bash git add lib/services/analytics_service.dart lib/state/game_session_notifier.dart lib/state/daily_reward_notifier.dart test/services/analytics_service_test.dart git commit -m "feat(analytics): booster + daily-reward events" ``` --- ## Task 12: l10n strings **Files:** - Modify: `lib/l10n/app_en.arb`, `lib/l10n/app_ko.arb` - [ ] **Step 1: Add keys to BOTH arb files** (keys must match exactly; KO values translated). Add: ``` boosterHammer, boosterShuffle, boosterLineBomb (names) boosterGetWithAd ("Watch an ad to get one" / "광고 보고 1개 받기") dailyRewardTitle ("Daily Reward" / "출석 보상") dailyClaim ("Claim" / "받기") dailyDoubleWithAd ("Watch ad for 2x" / "광고 보고 2배") boosterTapTarget ("Tap a cell" / "칸을 선택하세요"), boosterTapLine ("Tap a row or column" / "줄을 선택하세요") ``` EN example entries: ```json "boosterHammer": "Hammer", "boosterShuffle": "Shuffle", "boosterLineBomb": "Line Bomb", "boosterGetWithAd": "Watch an ad to get one", "dailyRewardTitle": "Daily Reward", "dailyClaim": "Claim", "dailyDoubleWithAd": "Watch ad for 2×", "boosterTapTarget": "Tap a cell", "boosterTapLine": "Tap a row or column", ``` KO values: `해머`, `셔플`, `줄 폭탄`, `광고 보고 1개 받기`, `출석 보상`, `받기`, `광고 보고 2배`, `칸을 선택하세요`, `줄을 선택하세요`. - [ ] **Step 2: Regenerate + analyze** Run: `flutter gen-l10n` (or `flutter test` which triggers generation via the build) then `flutter analyze`. Expected: `AppLocalizations` exposes the new getters; no issues. - [ ] **Step 3: Commit** ```bash git add lib/l10n/app_en.arb lib/l10n/app_ko.arb git commit -m "feat(l10n): booster + daily-reward strings (EN/KO)" ``` --- ## Task 13: Booster bar widget **Files:** - Create: `lib/ui/widgets/booster_bar.dart` - Test: `test/ui/booster_bar_test.dart` A stateless row of three booster buttons (icon + count badge). It is presentational: it takes the counts map + an `onTap(BoosterType)` callback. Targeting state lives in the game screen (Task 14). - [ ] **Step 1: Write the failing widget test** ```dart import 'package:block_seasons/game/models/booster.dart'; import 'package:block_seasons/ui/widgets/booster_bar.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { testWidgets('renders three boosters with their counts', (tester) async { BoosterType? tapped; await tester.pumpWidget(MaterialApp( home: Scaffold( body: BoosterBar( counts: const { BoosterType.hammer: 3, BoosterType.shuffle: 0, BoosterType.lineBomb: 1, }, onTap: (t) => tapped = t, ), ), )); expect(find.text('3'), findsOneWidget); expect(find.text('1'), findsOneWidget); await tester.tap(find.byKey(const ValueKey('booster_hammer'))); expect(tapped, BoosterType.hammer); }); } ``` - [ ] **Step 2: Run test to verify it fails** Run: `flutter test test/ui/booster_bar_test.dart` Expected: FAIL — `BoosterBar` missing. - [ ] **Step 3: Implement the widget** ```dart // lib/ui/widgets/booster_bar.dart import 'package:flutter/material.dart'; import '../../game/models/booster.dart'; class BoosterBar extends StatelessWidget { const BoosterBar({super.key, required this.counts, required this.onTap}); final Map counts; final void Function(BoosterType) onTap; static const _icons = { BoosterType.hammer: Icons.gavel, BoosterType.shuffle: Icons.shuffle, BoosterType.lineBomb: Icons.clear_all, }; @override Widget build(BuildContext context) { return Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ for (final t in BoosterType.values) _BoosterButton( key: ValueKey('booster_${t.name}'), icon: _icons[t]!, count: counts[t] ?? 0, onTap: () => onTap(t), ), ], ); } } class _BoosterButton extends StatelessWidget { const _BoosterButton( {super.key, required this.icon, required this.count, required this.onTap}); final IconData icon; final int count; final VoidCallback onTap; @override Widget build(BuildContext context) { return InkWell( onTap: onTap, borderRadius: BorderRadius.circular(12), child: Padding( padding: const EdgeInsets.all(8), child: Column(mainAxisSize: MainAxisSize.min, children: [ Icon(icon, size: 28), const SizedBox(height: 2), Text('$count'), ]), ), ); } } ``` - [ ] **Step 4: Run test to verify it passes** → `flutter test test/ui/booster_bar_test.dart` (PASS) - [ ] **Step 5: Commit** ```bash git add lib/ui/widgets/booster_bar.dart test/ui/booster_bar_test.dart git commit -m "feat(ui): presentational booster bar" ``` --- ## Task 14: Mount bar in the game screen + targeting + 0-count ad **Files:** - Modify: `lib/ui/screens/game_screen.dart` - Test: `test/ui/game_screen_booster_test.dart` **Read `game_screen.dart` fully first.** Mount `BoosterBar` below the board, reading `boosterInventoryProvider`. Hold local targeting state (`BoosterType? _arming`). Behavior: - Tap a booster with count > 0 → set `_arming = type` and show a hint (`boosterTapTarget` for hammer, `boosterTapLine` for line-bomb). Shuffle applies immediately via `session.useShuffle()` (no targeting). - While arming **hammer**: a tap on a board cell calls `session.useHammer(x,y)`; clear `_arming`. - While arming **line-bomb**: show row handles (left edge) + column handles (top edge); tapping a row handle calls `session.useLineBomb(row: r)`, a column handle `useLineBomb(col: c)`; clear `_arming`. - Tap a booster with count 0 → show a dialog with `boosterGetWithAd`; on confirm, call the rewarded-ad grant (Task 15 helper) and grant +1 on reward. - [ ] **Step 1: Write the failing widget test** (targeting hammer path) ```dart // Pump GameScreen with an overridden saveRepository pre-granted 1 hammer and a // started stage that has a filled cell at a known position; tap the hammer // booster, then tap that cell; assert the cell becomes empty and the count // drops to 0. Mirror the ProviderContainer + _wrap pattern used in // test/tool/generate_store_screenshots_test.dart for mounting a screen. ``` Write the concrete test using that mounting pattern (real font load is NOT needed for logic assertions; pump + tap + read provider state). Assert: `expect(container.read(boosterInventoryProvider)[BoosterType.hammer], 0);` - [ ] **Step 2: Run → FAIL** (`flutter test test/ui/game_screen_booster_test.dart`) - [ ] **Step 3: Implement** the bar mount + targeting in `game_screen.dart` per the behavior list above. Keep the board's existing drag handling intact; targeting mode intercepts taps only while `_arming != null`. - [ ] **Step 4: Run → PASS**, then `flutter test` (full) + `flutter analyze`. - [ ] **Step 5: Commit** ```bash git add lib/ui/screens/game_screen.dart test/ui/game_screen_booster_test.dart git commit -m "feat(ui): booster bar targeting in the game screen" ``` --- ## Task 15: Rewarded-ad booster grants **Files:** - Modify: `lib/ui/screens/game_screen.dart` (0-count path) and a small helper - Test: covered via a fake `AdService` override in `test/ui/game_screen_booster_test.dart` - [ ] **Step 1: Write a failing test** where the 0-count booster tap, with an overridden `adServiceProvider` whose `showRewarded()` returns `true`, grants +1 of that booster. (Override `adServiceProvider` with a fake exposing `Future showRewarded()`.) - [ ] **Step 2: Run → FAIL** - [ ] **Step 3: Implement** — on confirming the "watch ad" dialog: ```dart 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'); } ``` - [ ] **Step 4: Run → PASS**, full suite green. - [ ] **Step 5: Commit** ```bash git add lib/ui/screens/game_screen.dart test/ui/game_screen_booster_test.dart git commit -m "feat(ui): rewarded-ad grant for an empty booster" ``` --- ## Task 16: Daily reward popup + home wiring **Files:** - Create: `lib/ui/widgets/daily_reward_sheet.dart` - Modify: `lib/ui/screens/home_screen.dart` - Test: `test/ui/daily_reward_sheet_test.dart` The sheet shows 7 cells (past = checked/dim, today = highlighted, future = locked), a Claim button, and a "2× with ad" button. Home shows it once when `dailyRewardProvider.claimable` is true on first build. - [ ] **Step 1: Write the failing widget test** — pump `DailyRewardSheet` with `day: 1, onClaim: (doubled) {...}`; assert 7 day cells render and tapping Claim invokes `onClaim(false)`, tapping the 2× button invokes `onClaim(true)`. - [ ] **Step 2: Run → FAIL** - [ ] **Step 3: Implement** `DailyRewardSheet` (presentational: `int day`, `void Function(bool doubled) onClaim`, uses the `DailyRewardCalendar.rewardFor` table to render each day's icons). Then in `home_screen.dart`, in a post-frame callback, if `ref.read(dailyRewardProvider).claimable`, show the sheet as a dialog; its callbacks call `ref.read(dailyRewardProvider.notifier).claim(doubled: ...)`, and for the 2× path gate the claim on `adServiceProvider.showRewarded()` first. - [ ] **Step 4: Run → PASS**, full suite + analyze clean. - [ ] **Step 5: Commit** ```bash git add lib/ui/widgets/daily_reward_sheet.dart lib/ui/screens/home_screen.dart test/ui/daily_reward_sheet_test.dart git commit -m "feat(ui): 7-day daily-reward popup on home" ``` --- ## Task 17: Integration pass **Files:** none new — verification + any glue fixes. - [ ] **Step 1:** Run the whole suite: `flutter test`. Expected: all green (existing 194 + new). Fix any regressions following TDD (failing test first). - [ ] **Step 2:** `flutter analyze` → No issues. - [ ] **Step 3:** Manual smoke checklist for the owner's eventual build (document only; **do not build here**): start a stage → use each booster → daily popup grants → 0-count booster offers ad. Note these in the commit body. - [ ] **Step 4: Commit** any glue fixes: ```bash git add -A git commit -m "test: integrate boosters + daily reward, full suite green" ``` --- ## Self-Review (completed by author) - **Spec coverage:** boosters 3× (T1–T3), model (T4), inventory persistence (T5), daily persistence (T6), daily logic (T7), inventory notifier (T8), use coordination (T9), daily notifier (T10), analytics (T11), l10n (T12), bar UI (T13), targeting + 0-count (T14), ad grant (T15), daily popup (T16), integration (T17). All spec sections mapped. - **No-credit rule:** enforced in engine tasks (no score/move/objective asserts). - **Type consistency:** `BoosterType`, `BoosterUseResult`, `DailyResolution`, `DailyRewardCalendar.{resolve,rewardFor,ymd}`, providers `boosterInventoryProvider`/`dailyRewardProvider`/`dailyNowProvider` used consistently across tasks. - **Owner build rule:** no `flutter build`/Xcode steps anywhere; verification is `flutter test` + `flutter analyze` only.