Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 412cc08167 | |||
| 1a028b9852 | |||
| b8bfa00196 | |||
| 1ba30028b5 | |||
| a04bb3b847 | |||
| 0517fabdbb | |||
| d0a2be15ba |
+12
-1
@@ -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
@@ -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": "세로"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,6 +419,15 @@ class _GameScreenState extends ConsumerState<GameScreen>
|
|||||||
return Transform.translate(
|
return Transform.translate(
|
||||||
offset: Offset(dx, 0), child: child);
|
offset: Offset(dx, 0), child: child);
|
||||||
},
|
},
|
||||||
|
// While a targeted booster is armed, taps on the
|
||||||
|
// board pick a cell. When not arming, onTapUp
|
||||||
|
// returns immediately so it never steals the
|
||||||
|
// tray-drag placement gestures.
|
||||||
|
child: GestureDetector(
|
||||||
|
behavior: HitTestBehavior.deferToChild,
|
||||||
|
onTapUp: _arming == null
|
||||||
|
? null
|
||||||
|
: (details) => _onBoardTapUp(details),
|
||||||
child: BoardWidget(
|
child: BoardWidget(
|
||||||
key: _boardKey,
|
key: _boardKey,
|
||||||
view: view,
|
view: view,
|
||||||
@@ -294,6 +436,7 @@ class _GameScreenState extends ConsumerState<GameScreen>
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
TrayWidget(
|
TrayWidget(
|
||||||
tray: view.tray,
|
tray: view.tray,
|
||||||
draggingIndex: _dragIndex,
|
draggingIndex: _dragIndex,
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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]);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user