Compare commits

..

7 Commits

Author SHA1 Message Date
airkjw 412cc08167 fix(l10n): localize line-bomb row/column chooser labels
The line-bomb axis chooser hardcoded Korean 가로/세로; route them through
new boosterLineRow/boosterLineCol keys (EN: Row/Column).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 19:27:41 +09:00
airkjw 1a028b9852 feat(ui): 7-day daily-reward popup on home
Presentational DailyRewardSheet (7 cells, today highlighted, reward icons
from the calendar table, claim + watch-ad-2x buttons). HomeScreen becomes a
ConsumerStatefulWidget that shows it once per mount via a post-frame guard;
the 2x path grants the doubled reward only if the rewarded ad was earned,
else the base reward. Guards the throwing saveRepositoryProvider default so
a repo-less mount is a no-op.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 19:26:43 +09:00
airkjw b8bfa00196 feat(ui): rewarded-ad grant for an empty booster
Tapping an empty booster opens the get-one dialog; confirming watches a
rewarded ad and, on earn, grants +1 of that booster and logs booster_granted
(source: ad). Covered by a widget test using an uninitialized AdService whose
showRewarded() resolves true.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 12:41:27 +09:00
airkjw 1ba30028b5 feat(ui): booster bar targeting in the game screen
Mount BoosterBar below the tray (only while playing), guarded so legacy
GameScreen tests without a SaveRepository keep passing. Tapping a booster
arms targeting: shuffle applies immediately; hammer/line-bomb arm a board
tap (hammer clears a cell, line-bomb opens a row/column chooser). An empty
booster opens a get-one dialog (ad grant lands in Task 15).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 12:40:13 +09:00
airkjw a04bb3b847 feat(ui): presentational booster bar 2026-06-18 12:28:25 +09:00
airkjw 0517fabdbb feat(l10n): booster + daily-reward strings (EN/KO)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 12:26:31 +09:00
airkjw d0a2be15ba feat(analytics): booster + daily-reward events
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 12:24:02 +09:00
13 changed files with 652 additions and 8 deletions
+12 -1
View File
@@ -61,5 +61,16 @@
"adsRemovedThanks": "Ads removed — thank you!", "adsRemovedThanks": "Ads removed — thank you!",
"purchaseUnavailable": "Purchases are unavailable right now.", "purchaseUnavailable": "Purchases are unavailable right now.",
"soundAndVibration": "Sound & vibration", "soundAndVibration": "Sound & vibration",
"music": "Music" "music": "Music",
"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",
"boosterLineRow": "Row",
"boosterLineCol": "Column"
} }
+12 -1
View File
@@ -33,5 +33,16 @@
"adsRemovedThanks": "광고가 제거되었습니다 — 감사합니다!", "adsRemovedThanks": "광고가 제거되었습니다 — 감사합니다!",
"purchaseUnavailable": "지금은 구매를 사용할 수 없습니다.", "purchaseUnavailable": "지금은 구매를 사용할 수 없습니다.",
"soundAndVibration": "소리 및 진동", "soundAndVibration": "소리 및 진동",
"music": "음악" "music": "음악",
"boosterHammer": "해머",
"boosterShuffle": "셔플",
"boosterLineBomb": "줄 폭탄",
"boosterGetWithAd": "광고 보고 1개 받기",
"dailyRewardTitle": "출석 보상",
"dailyClaim": "받기",
"dailyDoubleWithAd": "광고 보고 2배",
"boosterTapTarget": "칸을 선택하세요",
"boosterTapLine": "줄을 선택하세요",
"boosterLineRow": "가로",
"boosterLineCol": "세로"
} }
+12
View File
@@ -61,4 +61,16 @@ class AnalyticsService {
void tutorialFinished({required bool skipped}) { void tutorialFinished({required bool skipped}) {
_backend.logEvent('tutorial_finished', {'skipped': skipped ? 1 : 0}); _backend.logEvent('tutorial_finished', {'skipped': skipped ? 1 : 0});
} }
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});
} }
+8
View File
@@ -30,6 +30,14 @@ class DailyRewardNotifier extends Notifier<DailyResolution> {
await inv.grant(entry.key, entry.value * (doubled ? 2 : 1)); await inv.grant(entry.key, entry.value * (doubled ? 2 : 1));
} }
await _save.recordDailyClaim(_cal.ymd(_now()), r.day); await _save.recordDailyClaim(_cal.ymd(_now()), r.day);
ref.read(analyticsProvider).dailyRewardClaimed(day: r.day, doubled: doubled);
for (final e in reward.entries) {
ref.read(analyticsProvider).boosterGranted(
type: e.key.name,
count: e.value * (doubled ? 2 : 1),
source: 'daily',
);
}
state = _resolve(); state = _resolve();
} }
} }
+1
View File
@@ -151,6 +151,7 @@ class GameSessionNotifier extends Notifier<GameViewState?> {
} }
if (!apply()) return BoosterUseResult.invalidTarget; if (!apply()) return BoosterUseResult.invalidTarget;
await inv.consume(type); await inv.consume(type);
ref.read(analyticsProvider).boosterUsed(type: type.name);
_publish(lastPlacement: null); _publish(lastPlacement: null);
return BoosterUseResult.success; return BoosterUseResult.success;
} }
+155 -4
View File
@@ -5,6 +5,7 @@ import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../game/engine/game_engine.dart'; import '../../game/engine/game_engine.dart';
import '../../game/models/booster.dart';
import '../../game/models/grid.dart'; import '../../game/models/grid.dart';
import '../../l10n/gen/app_localizations.dart'; import '../../l10n/gen/app_localizations.dart';
import '../../services/audio_service.dart'; import '../../services/audio_service.dart';
@@ -14,6 +15,7 @@ import '../theme/palette.dart';
import '../widgets/board_geometry.dart'; import '../widgets/board_geometry.dart';
import '../widgets/board_painter.dart'; import '../widgets/board_painter.dart';
import '../widgets/board_widget.dart'; import '../widgets/board_widget.dart';
import '../widgets/booster_bar.dart';
import '../widgets/effects_overlay.dart'; import '../widgets/effects_overlay.dart';
import '../widgets/hud_widget.dart'; import '../widgets/hud_widget.dart';
import '../widgets/piece_painter.dart'; import '../widgets/piece_painter.dart';
@@ -21,6 +23,9 @@ import '../widgets/season_background.dart';
import '../widgets/tray_widget.dart'; import '../widgets/tray_widget.dart';
import '../widgets/tutorial_overlay.dart'; import '../widgets/tutorial_overlay.dart';
/// Which line a line-bomb clears, chosen from the tapped cell's row or column.
enum _LineAxis { row, col }
/// Renders whatever session [gameSessionProvider] holds; callers start the /// Renders whatever session [gameSessionProvider] holds; callers start the
/// stage (via SeasonFlowNotifier) before navigating here. /// stage (via SeasonFlowNotifier) before navigating here.
class GameScreen extends ConsumerStatefulWidget { class GameScreen extends ConsumerStatefulWidget {
@@ -46,6 +51,10 @@ class _GameScreenState extends ConsumerState<GameScreen>
int? _dragIndex; int? _dragIndex;
Offset? _dragGlobal; Offset? _dragGlobal;
/// Non-null while a targeted booster is armed and waiting for a board tap.
/// Shuffle never sets this (it applies immediately).
BoosterType? _arming;
/// How far the dragged piece floats above the finger so it stays visible. /// How far the dragged piece floats above the finger so it stays visible.
static const double _lift = 70; static const double _lift = 70;
@@ -109,6 +118,123 @@ class _GameScreenState extends ConsumerState<GameScreen>
} }
} }
/// True when a [SaveRepository] is wired up. The default provider throws
/// until overridden (in app start and most tests); a couple of legacy widget
/// tests mount GameScreen without it, and the booster bar tolerates that.
bool _hasSaveRepository() {
try {
ref.read(saveRepositoryProvider);
return true;
} catch (_) {
return false;
}
}
// ---- Booster targeting ----
/// Tapping a booster button. Empty → offer a rewarded ad; shuffle applies
/// immediately; hammer/line-bomb arm targeting and show a hint.
Future<void> _onBoosterTap(BoosterType type) async {
if ((ref.read(boosterInventoryProvider)[type] ?? 0) <= 0) {
await _offerBoosterAd(type);
return;
}
if (type == BoosterType.shuffle) {
await ref.read(gameSessionProvider.notifier).useShuffle();
return;
}
// hammer / lineBomb need a board target.
setState(() => _arming = type);
final l10n = AppLocalizations.of(context)!;
final hint =
type == BoosterType.hammer ? l10n.boosterTapTarget : l10n.boosterTapLine;
if (!mounted) return;
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(SnackBar(
content: Text(hint),
duration: const Duration(seconds: 3),
));
}
/// Converts a board tap into a cell, then applies the armed booster.
Future<void> _onBoardTapUp(TapUpDetails details) async {
final armed = _arming;
if (armed == null) return;
final box = _boardBox;
if (box == null) return;
final local = box.globalToLocal(details.globalPosition);
final cell = BoardGeometry(boardSize: box.size.width).cellSize;
final x = (local.dx / cell).floor();
final y = (local.dy / cell).floor();
if (x < 0 || x >= GridState.size || y < 0 || y >= GridState.size) return;
final session = ref.read(gameSessionProvider.notifier);
if (armed == BoosterType.hammer) {
await session.useHammer(x, y);
if (mounted) setState(() => _arming = null);
} else if (armed == BoosterType.lineBomb) {
final axis = await _chooseLineAxis();
if (axis == _LineAxis.row) {
await session.useLineBomb(row: y);
} else if (axis == _LineAxis.col) {
await session.useLineBomb(col: x);
}
// A dismissed chooser cancels the use but still clears the armed state.
if (mounted) setState(() => _arming = null);
}
}
/// Small chooser for the line-bomb: clear the tapped row or column.
Future<_LineAxis?> _chooseLineAxis() {
final l10n = AppLocalizations.of(context)!;
return showDialog<_LineAxis>(
context: context,
builder: (context) => AlertDialog(
content: Text(l10n.boosterTapLine),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(_LineAxis.row),
child: Text('${l10n.boosterLineRow}'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(_LineAxis.col),
child: Text('${l10n.boosterLineCol}'),
),
],
),
);
}
/// Task 15: an empty booster offers a rewarded ad; on reward, grant +1.
Future<void> _offerBoosterAd(BoosterType type) async {
final l10n = AppLocalizations.of(context)!;
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
content: Text(l10n.boosterGetWithAd),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text(l10n.giveUp),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text(l10n.boosterGetWithAd),
),
],
),
);
if (confirmed != true) return;
final earned = await ref.read(adServiceProvider).showRewarded();
if (earned) {
await ref.read(boosterInventoryProvider.notifier).grant(type, 1);
ref
.read(analyticsProvider)
.boosterGranted(type: type.name, count: 1, source: 'ad');
}
}
@override @override
void dispose() { void dispose() {
_shake.dispose(); _shake.dispose();
@@ -247,6 +373,13 @@ class _GameScreenState extends ConsumerState<GameScreen>
final draggedTopLeft = _draggedPieceTopLeft(view); final draggedTopLeft = _draggedPieceTopLeft(view);
final boardBox = _boardBox; final boardBox = _boardBox;
// The booster bar needs the save-backed inventory. A few legacy widget
// tests mount GameScreen without a SaveRepository override; in that case
// the inventory provider throws, so only watch it (and mount the bar) when
// the repository is actually wired up.
final hasSave = _hasSaveRepository();
final boosterCounts = hasSave ? ref.watch(boosterInventoryProvider) : null;
final theme = ref.watch(activeThemeProvider); final theme = ref.watch(activeThemeProvider);
return Scaffold( return Scaffold(
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
@@ -286,10 +419,20 @@ class _GameScreenState extends ConsumerState<GameScreen>
return Transform.translate( return Transform.translate(
offset: Offset(dx, 0), child: child); offset: Offset(dx, 0), child: child);
}, },
child: BoardWidget( // While a targeted booster is armed, taps on the
key: _boardKey, // board pick a cell. When not arming, onTapUp
view: view, // returns immediately so it never steals the
ghost: ghost, // tray-drag placement gestures.
child: GestureDetector(
behavior: HitTestBehavior.deferToChild,
onTapUp: _arming == null
? null
: (details) => _onBoardTapUp(details),
child: BoardWidget(
key: _boardKey,
view: view,
ghost: ghost,
),
), ),
), ),
), ),
@@ -305,6 +448,14 @@ class _GameScreenState extends ConsumerState<GameScreen>
setState(() => _dragGlobal = global), setState(() => _dragGlobal = global),
onDragEnd: () => _onDragEnd(view), onDragEnd: () => _onDragEnd(view),
), ),
if (view.phase == GamePhase.playing &&
boosterCounts != null) ...[
const SizedBox(height: 8),
BoosterBar(
counts: boosterCounts,
onTap: _onBoosterTap,
),
],
], ],
), ),
), ),
+52 -2
View File
@@ -6,6 +6,7 @@ import '../../game/models/stage.dart';
import '../../l10n/gen/app_localizations.dart'; import '../../l10n/gen/app_localizations.dart';
import '../../state/providers.dart'; import '../../state/providers.dart';
import '../widgets/banner_ad_slot.dart'; import '../widgets/banner_ad_slot.dart';
import '../widgets/daily_reward_sheet.dart';
import '../widgets/fade_route.dart'; import '../widgets/fade_route.dart';
import '../widgets/pressable_scale.dart'; import '../widgets/pressable_scale.dart';
import '../widgets/season_background.dart'; import '../widgets/season_background.dart';
@@ -13,9 +14,14 @@ import 'game_screen.dart';
import 'season_map_screen.dart'; import 'season_map_screen.dart';
import 'settings_screen.dart'; import 'settings_screen.dart';
class HomeScreen extends ConsumerWidget { class HomeScreen extends ConsumerStatefulWidget {
const HomeScreen({super.key}); const HomeScreen({super.key});
@override
ConsumerState<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends ConsumerState<HomeScreen> {
static const _logoColors = [ static const _logoColors = [
Color(0xFFFF7EB3), Color(0xFFFF7EB3),
Color(0xFFFFD166), Color(0xFFFFD166),
@@ -23,8 +29,52 @@ class HomeScreen extends ConsumerWidget {
Color(0xFF7EDB9C), Color(0xFF7EDB9C),
]; ];
bool _dailyChecked = false;
@override @override
Widget build(BuildContext context, WidgetRef ref) { void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _maybeShowDailyReward());
}
/// Once per home mount, surface the 7-day login reward if it is claimable
/// today. Guards the deliberately-throwing [saveRepositoryProvider] default
/// so a repo-less mount (e.g. a widget test) is a no-op rather than a crash.
void _maybeShowDailyReward() {
if (_dailyChecked || !mounted) return;
_dailyChecked = true;
try {
ref.read(saveRepositoryProvider);
} catch (_) {
return;
}
final daily = ref.read(dailyRewardProvider);
if (!daily.claimable) return;
showDialog<void>(
context: context,
builder: (dialogContext) => Dialog(
child: DailyRewardSheet(
day: daily.day,
onClaim: (doubled) async {
final notifier = ref.read(dailyRewardProvider.notifier);
if (doubled) {
// Watch an ad to double; if no ad was earned the base reward is
// still granted so the player is never left empty-handed.
final earned = await ref.read(adServiceProvider).showRewarded();
await notifier.claim(doubled: earned);
} else {
await notifier.claim();
}
if (dialogContext.mounted) Navigator.of(dialogContext).pop();
},
),
),
);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
final streak = ref.watch(streakProvider); final streak = ref.watch(streakProvider);
final best = ref.watch(endlessBestProvider); final best = ref.watch(endlessBestProvider);
+57
View File
@@ -0,0 +1,57 @@
// 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'),
]),
),
);
}
}
+147
View File
@@ -0,0 +1,147 @@
// lib/ui/widgets/daily_reward_sheet.dart
import 'package:flutter/material.dart';
import '../../game/daily/daily_reward.dart';
import '../../game/models/booster.dart';
import '../../l10n/gen/app_localizations.dart';
/// Presentational 7-day login calendar. [day] is today's calendar position
/// (1..7); past days read as claimed, the future stays locked. [onClaim] fires
/// with `false` for a plain claim and `true` for the watch-an-ad "2×" claim.
class DailyRewardSheet extends StatelessWidget {
const DailyRewardSheet({super.key, required this.day, required this.onClaim});
final int day;
final void Function(bool doubled) onClaim;
static const _cal = DailyRewardCalendar();
static const _icons = {
BoosterType.hammer: Icons.gavel,
BoosterType.shuffle: Icons.shuffle,
BoosterType.lineBomb: Icons.clear_all,
};
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Padding(
padding: const EdgeInsets.all(20),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
l10n.dailyRewardTitle,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
Wrap(
spacing: 8,
runSpacing: 8,
alignment: WrapAlignment.center,
children: [
for (var d = 1; d <= DailyRewardCalendar.cycle; d++)
_DayCell(
key: ValueKey('daily_day_$d'),
day: d,
reward: _cal.rewardFor(d),
past: d < day,
today: d == day,
),
],
),
const SizedBox(height: 20),
FilledButton(
key: const ValueKey('daily_claim'),
onPressed: () => onClaim(false),
child: Text(l10n.dailyClaim),
),
const SizedBox(height: 4),
TextButton(
key: const ValueKey('daily_double'),
onPressed: () => onClaim(true),
child: Text(l10n.dailyDoubleWithAd),
),
],
),
);
}
}
class _DayCell extends StatelessWidget {
const _DayCell({
super.key,
required this.day,
required this.reward,
required this.past,
required this.today,
});
final int day;
final Map<BoosterType, int> reward;
final bool past;
final bool today;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Opacity(
opacity: past ? 0.45 : 1,
child: Container(
width: 66,
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 4),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
color: today
? scheme.primary.withValues(alpha: 0.25)
: Colors.white.withValues(alpha: 0.06),
border: today
? Border.all(color: scheme.primary, width: 2)
: null,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('$day', style: Theme.of(context).textTheme.labelMedium),
const SizedBox(height: 4),
Wrap(
spacing: 2,
runSpacing: 2,
alignment: WrapAlignment.center,
children: [
for (final entry in reward.entries)
_RewardIcon(
icon: DailyRewardSheet._icons[entry.key]!,
count: entry.value,
),
],
),
if (past)
const Padding(
padding: EdgeInsets.only(top: 2),
child: Icon(Icons.check_circle,
size: 14, color: Colors.greenAccent),
),
],
),
),
);
}
}
class _RewardIcon extends StatelessWidget {
const _RewardIcon({required this.icon, required this.count});
final IconData icon;
final int count;
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 14),
Text('$count', style: const TextStyle(fontSize: 11)),
],
);
}
}
+19
View File
@@ -47,4 +47,23 @@ void main() {
}); });
expect(backend.events[3].$2, {'score': 500, 'new_best': 1}); expect(backend.events[3].$2, {'score': 500, 'new_best': 1});
}); });
test('booster + daily events carry their fields', () {
final backend = _RecordingBackend();
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.map((e) => e.$1).toList(), [
'booster_used',
'booster_granted',
'daily_reward_claimed',
]);
expect(backend.events[0].$2, {'type': 'hammer'});
expect(backend.events[1].$2,
{'type': 'hammer', 'count': 2, 'source': 'daily'});
expect(backend.events[2].$2, {'day': 7, 'doubled': 1});
});
} }
+27
View File
@@ -0,0 +1,27 @@
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);
});
}
+38
View File
@@ -0,0 +1,38 @@
import 'package:block_seasons/l10n/gen/app_localizations.dart';
import 'package:block_seasons/ui/widgets/daily_reward_sheet.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
Widget wrap(Widget child) => MaterialApp(
locale: const Locale('en'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(body: Center(child: child)),
);
testWidgets('renders 7 day cells with today highlighted', (tester) async {
await tester.pumpWidget(wrap(
DailyRewardSheet(day: 3, onClaim: (_) {}),
));
for (var d = 1; d <= 7; d++) {
expect(find.byKey(ValueKey('daily_day_$d')), findsOneWidget,
reason: 'day $d cell');
}
});
testWidgets('claim and 2x buttons fire onClaim with the right flag',
(tester) async {
final claims = <bool>[];
await tester.pumpWidget(wrap(
DailyRewardSheet(day: 1, onClaim: claims.add),
));
await tester.tap(find.byKey(const ValueKey('daily_claim')));
expect(claims, [false]);
await tester.tap(find.byKey(const ValueKey('daily_double')));
expect(claims, [false, true]);
});
}
+112
View File
@@ -0,0 +1,112 @@
// Widget tests for the booster bar mounted in the game screen.
//
// Board-geometry taps (hammer / line-bomb cell selection) are NOT covered here:
// the board is laid out inside an Expanded/Center whose pixel geometry is not
// deterministic in a widget test, so a pixel-accurate cell tap is unreliable.
// Those paths are left to manual QA (see the agent report). What we CAN verify
// deterministically is the inventory side-effects:
// - shuffle applies immediately and spends one booster
// - tapping an empty booster shows the ad dialog whose confirm grants +1
import 'package:block_seasons/core/rng.dart';
import 'package:block_seasons/data/save_repository.dart';
import 'package:block_seasons/game/engine/piece_generator.dart';
import 'package:block_seasons/game/models/booster.dart';
import 'package:block_seasons/game/models/stage.dart';
import 'package:block_seasons/l10n/gen/app_localizations.dart';
import 'package:block_seasons/services/ad_service.dart';
import 'package:block_seasons/state/providers.dart';
import 'package:block_seasons/ui/screens/game_screen.dart';
import 'package:flutter/material.dart';
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': 'ui_b',
'seed': 1,
'moveLimit': 20,
'preset': [
{'x': 0, 'y': 0, 't': 'filled', 'c': 3},
],
'objectives': [
{'type': 'reachScore', 'target': 100000},
],
'stars': {
'two': {'movesLeft': 5},
'three': {'movesLeft': 10},
},
});
/// A real, uninitialized [AdService]: with no SDK loaded its [showRewarded]
/// takes the "no ad available -> grant the reward" path and resolves true
/// without touching the platform — exactly the rewarded-earn case we want.
AdService _earnAd() => AdService(adsRemoved: () => true);
Widget _wrap(ProviderContainer c) => UncontrolledProviderScope(
container: c,
child: MaterialApp(
debugShowCheckedModeBanner: false,
locale: const Locale('en'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const GameScreen(),
),
);
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
Future<ProviderContainer> startedContainer({AdService? ad}) async {
SharedPreferences.setMockInitialValues({});
final repo = SaveRepository(await SharedPreferences.getInstance());
final c = ProviderContainer(overrides: [
saveRepositoryProvider.overrideWithValue(repo),
if (ad != null) adServiceProvider.overrideWithValue(ad),
]);
c.read(gameSessionProvider.notifier).startStage(
_stage(),
generator: PieceGenerator(SeededRng(1)),
);
return c;
}
testWidgets('tapping shuffle applies immediately and spends one booster',
(tester) async {
final c = await startedContainer();
await c.read(boosterInventoryProvider.notifier).grant(BoosterType.shuffle);
expect(c.read(boosterInventoryProvider)[BoosterType.shuffle], 1);
await tester.pumpWidget(_wrap(c));
await tester.pump();
await tester.tap(find.byKey(const ValueKey('booster_shuffle')));
await tester.pump();
await tester.pump();
expect(c.read(boosterInventoryProvider)[BoosterType.shuffle], 0);
c.dispose();
});
testWidgets('tapping an empty booster offers an ad that grants +1',
(tester) async {
final c = await startedContainer(ad: _earnAd());
expect(c.read(boosterInventoryProvider)[BoosterType.hammer], 0);
await tester.pumpWidget(_wrap(c));
await tester.pump();
await tester.tap(find.byKey(const ValueKey('booster_hammer')));
await tester.pumpAndSettle();
// The dialog offers the "watch an ad to get one" action; confirm it.
final l10n = AppLocalizations.of(
tester.element(find.byType(GameScreen)),
)!;
await tester.tap(find.text(l10n.boosterGetWithAd).last);
await tester.pump();
await tester.pump();
expect(c.read(boosterInventoryProvider)[BoosterType.hammer], 1);
c.dispose();
});
}