From 40c2204d7b0471487ae438812412c3ac669083fe Mon Sep 17 00:00:00 2001 From: airkjw Date: Sat, 13 Jun 2026 14:13:29 +0900 Subject: [PATCH] feat(ads): home/map banner slot (hidden when removed or unloaded) Co-Authored-By: Claude Sonnet 4.6 --- lib/ui/screens/home_screen.dart | 158 +++++++++++----------- lib/ui/screens/season_map_screen.dart | 182 ++++++++++++++------------ lib/ui/widgets/banner_ad_slot.dart | 57 ++++++++ 3 files changed, 236 insertions(+), 161 deletions(-) create mode 100644 lib/ui/widgets/banner_ad_slot.dart diff --git a/lib/ui/screens/home_screen.dart b/lib/ui/screens/home_screen.dart index aa85a2d..870d6f0 100644 --- a/lib/ui/screens/home_screen.dart +++ b/lib/ui/screens/home_screen.dart @@ -5,6 +5,7 @@ import '../../game/models/season.dart'; import '../../game/models/stage.dart'; import '../../l10n/gen/app_localizations.dart'; import '../../state/providers.dart'; +import '../widgets/banner_ad_slot.dart'; import '../widgets/season_background.dart'; import 'game_screen.dart'; import 'season_map_screen.dart'; @@ -32,83 +33,90 @@ class HomeScreen extends ConsumerWidget { children: [ const SeasonBackground(theme: SeasonTheme.fallback), SafeArea( - 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, - ), - 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, + child: 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), + ), + if (streak.current > 0) ...[ + const SizedBox(height: 10), + Chip( + avatar: const Icon( + Icons.local_fire_department, + color: Colors.deepOrange, + size: 20, ), - ); - 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), - ), + 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, + ), + ); + 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(), + ], ), ), ], diff --git a/lib/ui/screens/season_map_screen.dart b/lib/ui/screens/season_map_screen.dart index 8e02b49..8088cba 100644 --- a/lib/ui/screens/season_map_screen.dart +++ b/lib/ui/screens/season_map_screen.dart @@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../game/models/season.dart'; import '../../state/providers.dart'; import '../theme/palette.dart'; +import '../widgets/banner_ad_slot.dart'; import '../widgets/map_layout.dart'; import '../widgets/season_background.dart'; import '../widgets/tile_painter.dart'; @@ -74,100 +75,109 @@ class _JourneyMapState extends ConsumerState<_JourneyMap> { return Scaffold( backgroundColor: Colors.transparent, - body: Stack( - fit: StackFit.expand, - children: [ - SeasonBackground(theme: pack.theme), - LayoutBuilder( - builder: (context, constraints) { - final layout = MapLayout(width: constraints.maxWidth); - final count = pack.stages.length; - if (!_autoScrolled) { - WidgetsBinding.instance.addPostFrameCallback((_) => - _autoScrollTo( - layout, unlocked, count, constraints.maxHeight)); - } - return SingleChildScrollView( - controller: _scroll, - reverse: true, - child: SizedBox( - width: constraints.maxWidth, - height: layout.heightFor(count), - child: Stack( - children: [ - CustomPaint( - size: Size( - constraints.maxWidth, layout.heightFor(count)), - painter: - _PathPainter(layout: layout, count: count), - ), - for (var i = 0; i < count; i++) - _node( - context, - layout, - i, - count, - unlocked, - repo.progressFor(pack.seasonId, ids[i])?.stars ?? 0, - colors, - seasonComplete, - ), - ], - ), - ), - ); - }, - ), - // Glass header. - Positioned( - top: 0, - left: 0, - right: 0, - child: Container( - padding: EdgeInsets.only( - top: MediaQuery.of(context).padding.top + 6, - bottom: 12, - left: 8, - right: 16, - ), - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Colors.black.withValues(alpha: 0.45), - Colors.transparent, - ], - ), - ), - child: Row( + body: SafeArea( + child: Column( + children: [ + Expanded( + child: Stack( + fit: StackFit.expand, children: [ - IconButton( - icon: const Icon(Icons.arrow_back, color: Colors.white), - onPressed: () => Navigator.of(context).pop(), + SeasonBackground(theme: pack.theme), + LayoutBuilder( + builder: (context, constraints) { + final layout = MapLayout(width: constraints.maxWidth); + final count = pack.stages.length; + if (!_autoScrolled) { + WidgetsBinding.instance.addPostFrameCallback((_) => + _autoScrollTo( + layout, unlocked, count, constraints.maxHeight)); + } + return SingleChildScrollView( + controller: _scroll, + reverse: true, + child: SizedBox( + width: constraints.maxWidth, + height: layout.heightFor(count), + child: Stack( + children: [ + CustomPaint( + size: Size( + constraints.maxWidth, layout.heightFor(count)), + painter: + _PathPainter(layout: layout, count: count), + ), + for (var i = 0; i < count; i++) + _node( + context, + layout, + i, + count, + unlocked, + repo.progressFor(pack.seasonId, ids[i])?.stars ?? 0, + colors, + seasonComplete, + ), + ], + ), + ), + ); + }, ), - Expanded( - child: Text( - pack.titleFor(locale), - style: const TextStyle( - fontSize: 20, - fontWeight: FontWeight.w800, - color: Colors.white, + // Glass header. + Positioned( + top: 0, + left: 0, + right: 0, + child: Container( + padding: const EdgeInsets.only( + top: 6, + bottom: 12, + left: 8, + right: 16, + ), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.black.withValues(alpha: 0.45), + Colors.transparent, + ], + ), + ), + child: Row( + children: [ + IconButton( + icon: const Icon(Icons.arrow_back, color: Colors.white), + onPressed: () => Navigator.of(context).pop(), + ), + Expanded( + child: Text( + pack.titleFor(locale), + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.w800, + color: Colors.white, + ), + ), + ), + Text( + '★ $totalStars/${pack.stages.length * 3}', + style: const TextStyle( + color: Colors.amber, + fontWeight: FontWeight.w700, + ), + ), + ], ), - ), - ), - Text( - '★ $totalStars/${pack.stages.length * 3}', - style: const TextStyle( - color: Colors.amber, - fontWeight: FontWeight.w700, ), ), ], ), ), - ), - ], + const BannerAdSlot(), + ], + ), ), ); } diff --git a/lib/ui/widgets/banner_ad_slot.dart b/lib/ui/widgets/banner_ad_slot.dart new file mode 100644 index 0000000..b81dc5a --- /dev/null +++ b/lib/ui/widgets/banner_ad_slot.dart @@ -0,0 +1,57 @@ +// lib/ui/widgets/banner_ad_slot.dart +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:google_mobile_ads/google_mobile_ads.dart'; + +import '../../state/providers.dart'; + +/// A self-loading 320x50 banner for the home/map screens. Renders nothing +/// when ads are removed or the banner has not loaded — never reserves blank +/// space and never appears on the game screen. +class BannerAdSlot extends ConsumerStatefulWidget { + const BannerAdSlot({super.key}); + + @override + ConsumerState createState() => _BannerAdSlotState(); +} + +class _BannerAdSlotState extends ConsumerState { + BannerAd? _ad; + bool _loaded = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_ad != null) return; + if (ref.read(adsRemovedProvider)) return; + final ad = ref.read(adServiceProvider).createBanner( + listener: BannerAdListener( + onAdLoaded: (_) { + if (mounted) setState(() => _loaded = true); + }, + onAdFailedToLoad: (ad, _) => ad.dispose(), + ), + ); + if (ad == null) return; + _ad = ad; + } + + @override + void dispose() { + _ad?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final ad = _ad; + if (ad == null || !_loaded || ref.watch(adsRemovedProvider)) { + return const SizedBox.shrink(); + } + return SizedBox( + width: ad.size.width.toDouble(), + height: ad.size.height.toDouble(), + child: AdWidget(ad: ad), + ); + } +}