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>
This commit is contained in:
2026-06-18 19:26:43 +09:00
parent b8bfa00196
commit 1a028b9852
3 changed files with 237 additions and 2 deletions
+52 -2
View File
@@ -6,6 +6,7 @@ import '../../game/models/stage.dart';
import '../../l10n/gen/app_localizations.dart';
import '../../state/providers.dart';
import '../widgets/banner_ad_slot.dart';
import '../widgets/daily_reward_sheet.dart';
import '../widgets/fade_route.dart';
import '../widgets/pressable_scale.dart';
import '../widgets/season_background.dart';
@@ -13,9 +14,14 @@ import 'game_screen.dart';
import 'season_map_screen.dart';
import 'settings_screen.dart';
class HomeScreen extends ConsumerWidget {
class HomeScreen extends ConsumerStatefulWidget {
const HomeScreen({super.key});
@override
ConsumerState<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends ConsumerState<HomeScreen> {
static const _logoColors = [
Color(0xFFFF7EB3),
Color(0xFFFFD166),
@@ -23,8 +29,52 @@ class HomeScreen extends ConsumerWidget {
Color(0xFF7EDB9C),
];
bool _dailyChecked = false;
@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 streak = ref.watch(streakProvider);
final best = ref.watch(endlessBestProvider);
+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)),
],
);
}
}
+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]);
});
}