From 1a028b9852e8cc3bb65557c5ddb3c9a614573793 Mon Sep 17 00:00:00 2001 From: airkjw Date: Thu, 18 Jun 2026 19:26:43 +0900 Subject: [PATCH] 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) --- lib/ui/screens/home_screen.dart | 54 ++++++++- lib/ui/widgets/daily_reward_sheet.dart | 147 +++++++++++++++++++++++++ test/ui/daily_reward_sheet_test.dart | 38 +++++++ 3 files changed, 237 insertions(+), 2 deletions(-) create mode 100644 lib/ui/widgets/daily_reward_sheet.dart create mode 100644 test/ui/daily_reward_sheet_test.dart diff --git a/lib/ui/screens/home_screen.dart b/lib/ui/screens/home_screen.dart index 6c87d53..b579ad3 100644 --- a/lib/ui/screens/home_screen.dart +++ b/lib/ui/screens/home_screen.dart @@ -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 createState() => _HomeScreenState(); +} + +class _HomeScreenState extends ConsumerState { 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( + 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); diff --git a/lib/ui/widgets/daily_reward_sheet.dart b/lib/ui/widgets/daily_reward_sheet.dart new file mode 100644 index 0000000..e956c97 --- /dev/null +++ b/lib/ui/widgets/daily_reward_sheet.dart @@ -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 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)), + ], + ); + } +} diff --git a/test/ui/daily_reward_sheet_test.dart b/test/ui/daily_reward_sheet_test.dart new file mode 100644 index 0000000..5bdf1f5 --- /dev/null +++ b/test/ui/daily_reward_sheet_test.dart @@ -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 = []; + 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]); + }); +}