From 70f87ab8f2dd0bc206ab248199818a6e3b544777 Mon Sep 17 00:00:00 2001 From: airkjw Date: Sat, 13 Jun 2026 14:17:28 +0900 Subject: [PATCH] feat(iap): settings screen with remove-ads purchase and restore Co-Authored-By: Claude Sonnet 4.6 --- lib/l10n/app_en.arb | 7 +- lib/l10n/app_ko.arb | 7 +- lib/ui/screens/home_screen.dart | 167 +++++++++++++++------------- lib/ui/screens/settings_screen.dart | 58 ++++++++++ 4 files changed, 161 insertions(+), 78 deletions(-) create mode 100644 lib/ui/screens/settings_screen.dart diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index d6efbb6..8c2659d 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -54,5 +54,10 @@ }, "newBest": "NEW BEST!", "adventure": "Adventure", - "classic": "Classic" + "classic": "Classic", + "removeAds": "Remove ads", + "removeAdsDescription": "Removes banners and full-screen ads. Reward ads stay available.", + "restorePurchases": "Restore purchases", + "adsRemovedThanks": "Ads removed — thank you!", + "purchaseUnavailable": "Purchases are unavailable right now." } diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 0afe322..a4f2705 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -26,5 +26,10 @@ "bestScore": "최고 {score}", "newBest": "신기록!", "adventure": "어드벤처", - "classic": "클래식" + "classic": "클래식", + "removeAds": "광고 제거", + "removeAdsDescription": "배너와 전면 광고를 제거합니다. 보상형 광고는 계속 사용할 수 있습니다.", + "restorePurchases": "구매 복원", + "adsRemovedThanks": "광고가 제거되었습니다 — 감사합니다!", + "purchaseUnavailable": "지금은 구매를 사용할 수 없습니다." } diff --git a/lib/ui/screens/home_screen.dart b/lib/ui/screens/home_screen.dart index 870d6f0..2891e60 100644 --- a/lib/ui/screens/home_screen.dart +++ b/lib/ui/screens/home_screen.dart @@ -9,6 +9,7 @@ import '../widgets/banner_ad_slot.dart'; import '../widgets/season_background.dart'; import 'game_screen.dart'; import 'season_map_screen.dart'; +import 'settings_screen.dart'; class HomeScreen extends ConsumerWidget { const HomeScreen({super.key}); @@ -33,89 +34,103 @@ class HomeScreen extends ConsumerWidget { children: [ const SeasonBackground(theme: SeasonTheme.fallback), SafeArea( - child: Column( + child: Stack( children: [ - Expanded( - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - _logoMark(), - const SizedBox(height: 18), - Text( - l10n.appTitle, - style: Theme.of(context) - .textTheme - .displaySmall - ?.copyWith(fontWeight: FontWeight.w900), - ), - if (streak.current > 0) ...[ - const SizedBox(height: 10), - Chip( - avatar: const Icon( - Icons.local_fire_department, - color: Colors.deepOrange, - size: 20, + Column( + children: [ + Expanded( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _logoMark(), + const SizedBox(height: 18), + Text( + l10n.appTitle, + style: Theme.of(context) + .textTheme + .displaySmall + ?.copyWith(fontWeight: FontWeight.w900), ), - label: Text( - '${streak.current}', - style: Theme.of(context).textTheme.titleMedium, - ), - ), - ], - const SizedBox(height: 44), - FilledButton( - style: FilledButton.styleFrom( - padding: const EdgeInsets.symmetric( - horizontal: 56, vertical: 18), - textStyle: Theme.of(context).textTheme.titleLarge, - ), - onPressed: () { - if (!(ModalRoute.of(context)?.isCurrent ?? true)) return; - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => const SeasonMapScreen()), - ); - }, - child: Text(l10n.adventure), - ), - const SizedBox(height: 14), - OutlinedButton( - style: OutlinedButton.styleFrom( - padding: const EdgeInsets.symmetric( - horizontal: 40, vertical: 14), - textStyle: Theme.of(context).textTheme.titleMedium, - ), - onPressed: () { - if (!(ModalRoute.of(context)?.isCurrent ?? true)) return; - ref.read(seasonFlowProvider.notifier).clear(); - ref.read(analyticsProvider).endlessStart(); - ref.read(gameSessionProvider.notifier).startStage( - StageConfig.endless( - seed: DateTime.now().millisecondsSinceEpoch, - ), + if (streak.current > 0) ...[ + const SizedBox(height: 10), + Chip( + avatar: const Icon( + Icons.local_fire_department, + color: Colors.deepOrange, + size: 20, + ), + label: Text( + '${streak.current}', + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + const SizedBox(height: 44), + FilledButton( + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 56, vertical: 18), + textStyle: Theme.of(context).textTheme.titleLarge, + ), + onPressed: () { + if (!(ModalRoute.of(context)?.isCurrent ?? true)) return; + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const SeasonMapScreen()), ); - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => const GameScreen()), - ); - }, - child: Text(l10n.classic), - ), - if (best > 0) ...[ - const SizedBox(height: 10), - Text( - l10n.bestScore(best), - style: TextStyle( - color: Colors.white.withValues(alpha: 0.55), + }, + child: Text(l10n.adventure), ), - ), - ], - ], + const SizedBox(height: 14), + OutlinedButton( + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 40, vertical: 14), + textStyle: Theme.of(context).textTheme.titleMedium, + ), + onPressed: () { + if (!(ModalRoute.of(context)?.isCurrent ?? true)) return; + ref.read(seasonFlowProvider.notifier).clear(); + ref.read(analyticsProvider).endlessStart(); + ref.read(gameSessionProvider.notifier).startStage( + StageConfig.endless( + seed: DateTime.now().millisecondsSinceEpoch, + ), + ); + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const GameScreen()), + ); + }, + child: Text(l10n.classic), + ), + if (best > 0) ...[ + const SizedBox(height: 10), + Text( + l10n.bestScore(best), + style: TextStyle( + color: Colors.white.withValues(alpha: 0.55), + ), + ), + ], + ], + ), + ), + ), + const BannerAdSlot(), + ], + ), + Positioned( + top: 8, + right: 8, + child: IconButton( + icon: const Icon(Icons.settings, color: Colors.white70), + onPressed: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const SettingsScreen()), ), ), ), - const BannerAdSlot(), ], ), ), diff --git a/lib/ui/screens/settings_screen.dart b/lib/ui/screens/settings_screen.dart new file mode 100644 index 0000000..e4269af --- /dev/null +++ b/lib/ui/screens/settings_screen.dart @@ -0,0 +1,58 @@ +// lib/ui/screens/settings_screen.dart +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../l10n/gen/app_localizations.dart'; +import '../../state/providers.dart'; + +class SettingsScreen extends ConsumerWidget { + const SettingsScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = AppLocalizations.of(context)!; + final adsRemoved = ref.watch(adsRemovedProvider); + final iap = ref.read(iapServiceProvider); + + ref.listen(adsRemovedProvider, (prev, next) { + if (next && !(prev ?? false)) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l10n.adsRemovedThanks)), + ); + } + }); + + return Scaffold( + appBar: AppBar(title: Text(l10n.settings)), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + ListTile( + title: Text(l10n.removeAds), + subtitle: Text(l10n.removeAdsDescription), + trailing: adsRemoved + ? const Icon(Icons.check_circle, color: Colors.green) + : Text(iap.product?.price ?? ''), + onTap: adsRemoved + ? null + : () async { + if (!iap.available || iap.product == null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l10n.purchaseUnavailable)), + ); + return; + } + await iap.buyRemoveAds(); + }, + ), + const Divider(), + ListTile( + leading: const Icon(Icons.restore), + title: Text(l10n.restorePurchases), + onTap: () => iap.restorePurchases(), + ), + ], + ), + ); + } +}