4cda34f0b7
17 bite-sized TDD tasks: engine booster ops -> inventory/daily persistence -> pure daily-calendar -> notifiers -> analytics/l10n -> booster bar + targeting -> rewarded-ad grants -> daily popup -> integration. Verification is flutter test + analyze only (builds are owner-commanded). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1448 lines
46 KiB
Markdown
1448 lines
46 KiB
Markdown
# 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<SaveRepository> 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<String, dynamic>? ?? {};
|
||
for (final t in BoosterType.values) {
|
||
_boosters[t] = boosters[t.name] as int? ?? 0;
|
||
}
|
||
```
|
||
|
||
3. Add field near the other fields:
|
||
|
||
```dart
|
||
final Map<BoosterType, int> _boosters = {
|
||
for (final t in BoosterType.values) t: 0,
|
||
};
|
||
```
|
||
|
||
4. Add API:
|
||
|
||
```dart
|
||
int boosterCount(BoosterType type) => _boosters[type] ?? 0;
|
||
|
||
Future<void> 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<bool> 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<SaveRepository> 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<String, dynamic>?;
|
||
_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<void> 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<BoosterType, int> 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<ProviderContainer> 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<Map<BoosterType, int>> {
|
||
SaveRepository get _save => ref.read(saveRepositoryProvider);
|
||
|
||
@override
|
||
Map<BoosterType, int> build() => _snapshot();
|
||
|
||
Map<BoosterType, int> _snapshot() =>
|
||
{for (final t in BoosterType.values) t: _save.boosterCount(t)};
|
||
|
||
Future<void> grant(BoosterType type, [int n = 1]) async {
|
||
await _save.grantBooster(type, n);
|
||
state = _snapshot();
|
||
}
|
||
|
||
Future<bool> 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, Map<BoosterType, int>>(
|
||
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<ProviderContainer> 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<BoosterUseResult> useHammer(int x, int y) =>
|
||
_useBooster(BoosterType.hammer, () => _engine.useHammer(x, y));
|
||
|
||
Future<BoosterUseResult> useShuffle() =>
|
||
_useBooster(BoosterType.shuffle, () => _engine.useShuffle());
|
||
|
||
Future<BoosterUseResult> useLineBomb({int? row, int? col}) => _useBooster(
|
||
BoosterType.lineBomb,
|
||
() => _engine.useLineBomb(row: row, col: col),
|
||
);
|
||
|
||
Future<BoosterUseResult> _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<ProviderContainer> 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<DailyResolution> {
|
||
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<void> 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<DateTime Function()>((ref) => DateTime.now);
|
||
|
||
final dailyRewardProvider =
|
||
NotifierProvider<DailyRewardNotifier, DailyResolution>(
|
||
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<BoosterType, int> 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<bool> 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.
|