From d733bf664b731a999e79db4555cb93a226217176 Mon Sep 17 00:00:00 2001 From: JiWoong Sul Date: Wed, 3 Dec 2025 17:25:00 +0900 Subject: [PATCH] =?UTF-8?q?feat(ads):=20=EB=84=A4=EC=9D=B4=ED=8B=B0?= =?UTF-8?q?=EB=B8=8C=20=EA=B4=91=EA=B3=A0=20=EC=A0=81=EC=9A=A9=20=EB=B0=8F?= =?UTF-8?q?=20=EB=94=94=EB=B2=84=EA=B7=B8=20=EC=8A=A4=EC=9C=84=EC=B9=98=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- android/app/build.gradle.kts | 4 + android/app/src/main/AndroidManifest.xml | 3 + ios/Flutter/Debug.xcconfig | 2 + ios/Flutter/Release.xcconfig | 2 + ios/Runner/Info.plist | 2 + lib/core/constants/app_constants.dart | 8 + lib/core/utils/ad_helper.dart | 28 +++ lib/main.dart | 6 + .../pages/calendar/calendar_screen.dart | 13 +- .../calendar/widgets/visit_statistics.dart | 8 +- .../random_selection_screen.dart | 103 +++++---- .../restaurant_list_screen.dart | 21 +- .../pages/settings/settings_screen.dart | 83 ++++++++ .../pages/share/share_screen.dart | 100 ++------- .../debug_share_preview_provider.dart | 3 + .../widgets/native_ad_placeholder.dart | 198 ++++++++++++++++-- macos/Flutter/GeneratedPluginRegistrant.swift | 2 + pubspec.lock | 40 ++++ pubspec.yaml | 3 + 19 files changed, 461 insertions(+), 168 deletions(-) create mode 100644 lib/core/utils/ad_helper.dart create mode 100644 lib/presentation/providers/debug_share_preview_provider.dart diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 032e711..3d67b97 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -29,6 +29,10 @@ android { targetSdk = flutter.targetSdkVersion versionCode = flutter.versionCode versionName = flutter.versionName + + manifestPlaceholders["admobAppId"] = + project.findProperty("ADMOB_APP_ID") + ?: "ca-app-pub-3940256099942544~3347511713" } buildTypes { diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 4a830fc..8061962 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -21,6 +21,9 @@ android:label="lunchpick" android:name="${applicationName}" android:icon="@mipmap/ic_launcher"> + 맛집과의 거리 계산을 위해 위치 정보가 필요합니다. NSLocationAlwaysAndWhenInUseUsageDescription 맛집과의 거리 계산을 위해 위치 정보가 필요합니다. + GADApplicationIdentifier + $(GAD_APPLICATION_ID) diff --git a/lib/core/constants/app_constants.dart b/lib/core/constants/app_constants.dart index 52372e7..a0a4e3d 100644 --- a/lib/core/constants/app_constants.dart +++ b/lib/core/constants/app_constants.dart @@ -19,6 +19,14 @@ class AppConstants { static const String iosAdAppId = 'ca-app-pub-3940256099942544~1458002511'; static const String interstitialAdUnitId = 'ca-app-pub-3940256099942544/1033173712'; + static const String androidNativeAdUnitId = + 'ca-app-pub-6691216385521068/7939870622'; + static const String iosNativeAdUnitId = + 'ca-app-pub-6691216385521068/7939870622'; + static const String testAndroidNativeAdUnitId = + 'ca-app-pub-3940256099942544/2247696110'; + static const String testIosNativeAdUnitId = + 'ca-app-pub-3940256099942544/3986624511'; // Hive Box Names static const String restaurantBox = 'restaurants'; diff --git a/lib/core/utils/ad_helper.dart b/lib/core/utils/ad_helper.dart new file mode 100644 index 0000000..1f57674 --- /dev/null +++ b/lib/core/utils/ad_helper.dart @@ -0,0 +1,28 @@ +import 'package:flutter/foundation.dart'; + +import '../constants/app_constants.dart'; + +class AdHelper { + static bool get isMobilePlatform { + if (kIsWeb) return false; + return defaultTargetPlatform == TargetPlatform.android || + defaultTargetPlatform == TargetPlatform.iOS; + } + + static String get nativeAdUnitId { + if (!isMobilePlatform) { + throw UnsupportedError('Native ads are only supported on mobile.'); + } + + final isIOS = defaultTargetPlatform == TargetPlatform.iOS; + if (isIOS) { + if (kDebugMode) { + return AppConstants.testIosNativeAdUnitId; + } + return AppConstants.iosNativeAdUnitId; + } + + // Android는 디버그/릴리즈 모두 실제 광고 단위 ID 사용 + return AppConstants.androidNativeAdUnitId; + } +} diff --git a/lib/main.dart b/lib/main.dart index d04b58a..ce11dec 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -4,11 +4,13 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:hive_flutter/hive_flutter.dart'; import 'package:adaptive_theme/adaptive_theme.dart'; import 'package:go_router/go_router.dart'; +import 'package:google_mobile_ads/google_mobile_ads.dart'; import 'package:timezone/data/latest_all.dart' as tz; import 'core/constants/app_colors.dart'; import 'core/constants/app_constants.dart'; import 'core/services/notification_service.dart'; +import 'core/utils/ad_helper.dart'; import 'domain/entities/restaurant.dart'; import 'domain/entities/visit_record.dart'; import 'domain/entities/recommendation_record.dart'; @@ -20,6 +22,10 @@ import 'data/sample/sample_data_initializer.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); + if (AdHelper.isMobilePlatform) { + await MobileAds.instance.initialize(); + } + // Initialize timezone tz.initializeTimeZones(); diff --git a/lib/presentation/pages/calendar/calendar_screen.dart b/lib/presentation/pages/calendar/calendar_screen.dart index 92a0368..585cd28 100644 --- a/lib/presentation/pages/calendar/calendar_screen.dart +++ b/lib/presentation/pages/calendar/calendar_screen.dart @@ -1,4 +1,3 @@ -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:table_calendar/table_calendar.dart'; @@ -7,13 +6,11 @@ import '../../../core/constants/app_typography.dart'; import '../../../domain/entities/recommendation_record.dart'; import '../../../domain/entities/visit_record.dart'; import '../../providers/recommendation_provider.dart'; -import '../../providers/debug_test_data_provider.dart'; import '../../providers/visit_provider.dart'; import '../../widgets/native_ad_placeholder.dart'; import 'widgets/visit_record_card.dart'; import 'widgets/recommendation_record_card.dart'; import 'widgets/visit_statistics.dart'; -import 'widgets/debug_test_data_banner.dart'; class CalendarScreen extends ConsumerStatefulWidget { const CalendarScreen({super.key}); @@ -36,11 +33,6 @@ class _CalendarScreenState extends ConsumerState _selectedDay = DateTime.now(); _focusedDay = DateTime.now(); _tabController = TabController(length: 2, vsync: this); - if (kDebugMode) { - Future.microtask( - () => ref.read(debugTestDataNotifierProvider.notifier).initialize(), - ); - } } @override @@ -115,10 +107,6 @@ class _CalendarScreenState extends ConsumerState constraints: BoxConstraints(minHeight: constraints.maxHeight), child: Column( children: [ - if (kDebugMode) - const DebugTestDataBanner( - margin: EdgeInsets.fromLTRB(16, 16, 16, 8), - ), Card( margin: const EdgeInsets.all(16), color: isDark @@ -238,6 +226,7 @@ class _CalendarScreenState extends ConsumerState const SizedBox(height: 16), const NativeAdPlaceholder( margin: EdgeInsets.fromLTRB(16, 0, 16, 16), + height: 360, ), _buildDayRecords(_selectedDay, isDark), ], diff --git a/lib/presentation/pages/calendar/widgets/visit_statistics.dart b/lib/presentation/pages/calendar/widgets/visit_statistics.dart index 1100556..cf0cf25 100644 --- a/lib/presentation/pages/calendar/widgets/visit_statistics.dart +++ b/lib/presentation/pages/calendar/widgets/visit_statistics.dart @@ -1,11 +1,9 @@ -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:lunchpick/core/constants/app_colors.dart'; import 'package:lunchpick/core/constants/app_typography.dart'; import 'package:lunchpick/presentation/providers/visit_provider.dart'; import 'package:lunchpick/presentation/providers/restaurant_provider.dart'; -import 'package:lunchpick/presentation/pages/calendar/widgets/debug_test_data_banner.dart'; import 'package:lunchpick/presentation/widgets/native_ad_placeholder.dart'; class VisitStatistics extends ConsumerWidget { @@ -35,15 +33,11 @@ class VisitStatistics extends ConsumerWidget { padding: const EdgeInsets.all(16), child: Column( children: [ - if (kDebugMode) ...[ - const DebugTestDataBanner(margin: EdgeInsets.zero), - const SizedBox(height: 12), - ], // 이번 달 통계 _buildMonthlyStats(monthlyStatsAsync, isDark), const SizedBox(height: 16), - const NativeAdPlaceholder(), + const NativeAdPlaceholder(height: 360), const SizedBox(height: 16), // 주간 통계 차트 diff --git a/lib/presentation/pages/random_selection/random_selection_screen.dart b/lib/presentation/pages/random_selection/random_selection_screen.dart index 6d7c3f2..5f1cdb5 100644 --- a/lib/presentation/pages/random_selection/random_selection_screen.dart +++ b/lib/presentation/pages/random_selection/random_selection_screen.dart @@ -13,7 +13,6 @@ import '../../providers/location_provider.dart'; import '../../providers/recommendation_provider.dart'; import '../../providers/restaurant_provider.dart'; import '../../providers/weather_provider.dart'; -import '../../widgets/native_ad_placeholder.dart'; import 'widgets/recommendation_result_dialog.dart'; class RandomSelectionScreen extends ConsumerStatefulWidget { @@ -133,51 +132,65 @@ class _RandomSelectionScreenState extends ConsumerState { child: Consumer( builder: (context, ref, child) { final weatherAsync = ref.watch(weatherProvider); + const double sectionHeight = 112; return weatherAsync.when( - data: (weather) => Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - _buildWeatherData('지금', weather.current, isDark), - Container( - width: 1, - height: 50, - color: isDark - ? AppColors.darkDivider - : AppColors.lightDivider, - ), - _buildWeatherData('1시간 후', weather.nextHour, isDark), - ], - ), - loading: () => const Center( - child: CircularProgressIndicator( - color: AppColors.lightPrimary, + data: (weather) => SizedBox( + height: sectionHeight, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _buildWeatherData('지금', weather.current, isDark), + Container( + width: 1, + height: 50, + color: isDark + ? AppColors.darkDivider + : AppColors.lightDivider, + ), + _buildWeatherData( + '1시간 후', + weather.nextHour, + isDark, + ), + ], ), ), - error: (_, __) => Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - _buildWeatherInfo( - '지금', - Icons.wb_sunny, - '맑음', - 20, - isDark, + loading: () => const SizedBox( + height: sectionHeight, + child: Center( + child: CircularProgressIndicator( + color: AppColors.lightPrimary, ), - Container( - width: 1, - height: 50, - color: isDark - ? AppColors.darkDivider - : AppColors.lightDivider, - ), - _buildWeatherInfo( - '1시간 후', - Icons.wb_sunny, - '맑음', - 22, - isDark, - ), - ], + ), + ), + error: (_, __) => SizedBox( + height: sectionHeight, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _buildWeatherInfo( + '지금', + Icons.wb_sunny, + '맑음', + 20, + isDark, + ), + Container( + width: 1, + height: 50, + color: isDark + ? AppColors.darkDivider + : AppColors.lightDivider, + ), + _buildWeatherInfo( + '1시간 후', + Icons.wb_sunny, + '맑음', + 22, + isDark, + ), + ], + ), ), ); }, @@ -385,9 +398,9 @@ class _RandomSelectionScreenState extends ConsumerState { ), const SizedBox(height: 16), - const NativeAdPlaceholder( - margin: EdgeInsets.symmetric(vertical: 8), - ), + // const NativeAdPlaceholder( + // margin: EdgeInsets.symmetric(vertical: 8), + // ), ], ), ), diff --git a/lib/presentation/pages/restaurant_list/restaurant_list_screen.dart b/lib/presentation/pages/restaurant_list/restaurant_list_screen.dart index d880406..73fb438 100644 --- a/lib/presentation/pages/restaurant_list/restaurant_list_screen.dart +++ b/lib/presentation/pages/restaurant_list/restaurant_list_screen.dart @@ -145,18 +145,33 @@ class _RestaurantListScreenState extends ConsumerState { return _buildEmptyState(isDark); } + const adInterval = 6; // 5리스트 후 1광고 + const adOffset = 5; // 1~5 리스트 이후 6 광고 시작 + final adCount = (items.length ~/ adOffset); + final totalCount = items.length + adCount; + return ListView.builder( - itemCount: items.length + 1, + itemCount: totalCount, itemBuilder: (context, index) { - if (index == 0) { + final isAdIndex = + index >= adOffset && + (index - adOffset) % adInterval == 0; + if (isAdIndex) { return const NativeAdPlaceholder( margin: EdgeInsets.symmetric( horizontal: 16, vertical: 8, ), + height: 200, // 카드 높이와 비슷한 중간 사이즈 ); } - final item = items[index - 1]; + + final adsBefore = index < adOffset + ? 0 + : ((index - adOffset) ~/ adInterval) + 1; + final itemIndex = index - adsBefore; + final item = items[itemIndex]; + return RestaurantCard( restaurant: item.restaurant, distanceKm: item.distanceKm, diff --git a/lib/presentation/pages/settings/settings_screen.dart b/lib/presentation/pages/settings/settings_screen.dart index 1c5a1c7..1181284 100644 --- a/lib/presentation/pages/settings/settings_screen.dart +++ b/lib/presentation/pages/settings/settings_screen.dart @@ -5,6 +5,8 @@ import 'package:adaptive_theme/adaptive_theme.dart'; import 'package:permission_handler/permission_handler.dart'; import '../../../core/constants/app_colors.dart'; import '../../../core/constants/app_typography.dart'; +import '../../providers/debug_share_preview_provider.dart'; +import '../../providers/debug_test_data_provider.dart'; import '../../providers/settings_provider.dart'; import '../../providers/notification_provider.dart'; @@ -24,6 +26,11 @@ class _SettingsScreenState extends ConsumerState { void initState() { super.initState(); _loadSettings(); + if (kDebugMode) { + Future.microtask( + () => ref.read(debugTestDataNotifierProvider.notifier).initialize(), + ); + } } Future _loadSettings() async { @@ -343,6 +350,7 @@ class _SettingsScreenState extends ConsumerState { ], ), ), + if (kDebugMode) _buildDebugToolsCard(isDark), ], isDark), const SizedBox(height: 24), @@ -444,6 +452,81 @@ class _SettingsScreenState extends ConsumerState { } } + Widget _buildDebugToolsCard(bool isDark) { + final sharePreviewEnabled = ref.watch(debugSharePreviewProvider); + final testDataState = ref.watch(debugTestDataNotifierProvider); + final testDataNotifier = ref.read(debugTestDataNotifierProvider.notifier); + + return Card( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + color: isDark ? AppColors.darkSurface : AppColors.lightSurface, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: Column( + children: [ + ListTile( + leading: const Icon( + Icons.wifi_tethering, + color: AppColors.lightPrimary, + ), + title: const Text('공유 테스트 모드'), + subtitle: const Text('광고·권한 없이 디버그 샘플 코드/기기를 표시'), + trailing: Switch.adaptive( + value: sharePreviewEnabled, + onChanged: (value) { + ref.read(debugSharePreviewProvider.notifier).state = value; + }, + activeColor: AppColors.lightPrimary, + ), + ), + const Divider(height: 1), + ListTile( + leading: const Icon( + Icons.science_outlined, + color: AppColors.lightPrimary, + ), + title: const Text('기록/통계 테스트 데이터'), + subtitle: Text( + testDataState.isEnabled + ? '테스트 데이터가 적용되었습니다 (디버그 전용)' + : '디버그 빌드에서만 사용 가능합니다.', + ), + trailing: testDataState.isProcessing + ? const SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Switch.adaptive( + value: testDataState.isEnabled, + onChanged: (value) async { + if (value) { + await testDataNotifier.enableTestData(); + } else { + await testDataNotifier.disableTestData(); + } + }, + activeColor: AppColors.lightPrimary, + ), + ), + if (testDataState.errorMessage != null) + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + testDataState.errorMessage!, + style: AppTypography.caption(isDark).copyWith( + color: AppColors.lightError, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ], + ), + ); + } + void _showPermissionDialog(String permissionName) { final isDark = Theme.of(context).brightness == Brightness.dark; diff --git a/lib/presentation/pages/share/share_screen.dart b/lib/presentation/pages/share/share_screen.dart index 7f630a8..4844b57 100644 --- a/lib/presentation/pages/share/share_screen.dart +++ b/lib/presentation/pages/share/share_screen.dart @@ -12,6 +12,7 @@ import 'package:lunchpick/domain/entities/restaurant.dart'; import 'package:lunchpick/domain/entities/share_device.dart'; import 'package:lunchpick/presentation/providers/ad_provider.dart'; import 'package:lunchpick/presentation/providers/bluetooth_provider.dart'; +import 'package:lunchpick/presentation/providers/debug_share_preview_provider.dart'; import 'package:lunchpick/presentation/providers/restaurant_provider.dart'; import 'package:lunchpick/presentation/widgets/native_ad_placeholder.dart'; import 'package:uuid/uuid.dart'; @@ -85,9 +86,9 @@ class _ShareScreenState extends ConsumerState { bool _isScanning = false; List? _nearbyDevices; StreamSubscription? _dataSubscription; + ProviderSubscription? _debugPreviewSub; final _uuid = const Uuid(); bool _debugPreviewEnabled = false; - bool _debugPreviewProcessing = false; Timer? _debugPreviewTimer; @override @@ -97,11 +98,25 @@ class _ShareScreenState extends ConsumerState { _dataSubscription = bluetoothService.onDataReceived.listen((payload) { _handleIncomingData(payload); }); + _debugPreviewEnabled = ref.read(debugSharePreviewProvider); + if (_debugPreviewEnabled) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _handleDebugToggleChange(true); + }); + } + _debugPreviewSub = ref.listenManual(debugSharePreviewProvider, ( + previous, + next, + ) { + if (previous == next) return; + _handleDebugToggleChange(next); + }); } @override void dispose() { _dataSubscription?.cancel(); + _debugPreviewSub?.close(); ref.read(bluetoothServiceProvider).stopListening(); _debugPreviewTimer?.cancel(); super.dispose(); @@ -132,10 +147,6 @@ class _ShareScreenState extends ConsumerState { mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - if (kDebugMode) ...[ - _buildDebugToggle(isDark), - const SizedBox(height: 16), - ], _ShareCard( isDark: isDark, icon: Icons.upload_rounded, @@ -146,7 +157,7 @@ class _ShareScreenState extends ConsumerState { child: _buildSendSection(isDark), ), const SizedBox(height: 16), - const NativeAdPlaceholder(), + const NativeAdPlaceholder(height: 220), const SizedBox(height: 16), _ShareCard( isDark: isDark, @@ -577,87 +588,16 @@ class _ShareScreenState extends ConsumerState { ); } - Widget _buildDebugToggle(bool isDark) { - return Card( - color: isDark ? AppColors.darkSurface : AppColors.lightSurface, - elevation: 1, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - child: Padding( - padding: const EdgeInsets.all(14), - child: Row( - children: [ - Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: AppColors.lightPrimary.withOpacity(0.1), - shape: BoxShape.circle, - ), - child: const Icon( - Icons.science_outlined, - color: AppColors.lightPrimary, - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '테스트 토글 (디버그 전용)', - style: AppTypography.body1( - isDark, - ).copyWith(fontWeight: FontWeight.w600), - ), - const SizedBox(height: 4), - Text( - _debugPreviewEnabled - ? '샘플 코드·기기와 수신 데이터가 자동으로 표시됩니다.' - : '토글을 켜면 광고/권한 없이 공유 UI를 미리 볼 수 있습니다.', - style: AppTypography.caption(isDark), - ), - ], - ), - ), - const SizedBox(width: 8), - if (_debugPreviewProcessing) - const SizedBox( - width: 22, - height: 22, - child: CircularProgressIndicator(strokeWidth: 2), - ), - const SizedBox(width: 8), - Switch.adaptive( - value: _debugPreviewEnabled, - onChanged: _debugPreviewProcessing - ? null - : (value) { - _toggleDebugPreview(value); - }, - activeColor: AppColors.lightPrimary, - ), - ], - ), - ), - ); - } - - Future _toggleDebugPreview(bool enabled) async { - if (_debugPreviewProcessing) return; + Future _handleDebugToggleChange(bool enabled) async { + if (!mounted) return; setState(() { - _debugPreviewProcessing = true; + _debugPreviewEnabled = enabled; }); - if (enabled) { await _startDebugPreviewFlow(); } else { _stopDebugPreviewFlow(); } - - if (!mounted) return; - setState(() { - _debugPreviewEnabled = enabled; - _debugPreviewProcessing = false; - }); } Future _startDebugPreviewFlow() async { diff --git a/lib/presentation/providers/debug_share_preview_provider.dart b/lib/presentation/providers/debug_share_preview_provider.dart new file mode 100644 index 0000000..d144ddc --- /dev/null +++ b/lib/presentation/providers/debug_share_preview_provider.dart @@ -0,0 +1,3 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +final debugSharePreviewProvider = StateProvider((ref) => false); diff --git a/lib/presentation/widgets/native_ad_placeholder.dart b/lib/presentation/widgets/native_ad_placeholder.dart index 1da124b..3d777b4 100644 --- a/lib/presentation/widgets/native_ad_placeholder.dart +++ b/lib/presentation/widgets/native_ad_placeholder.dart @@ -1,48 +1,204 @@ +import 'dart:async'; +import 'dart:math'; + import 'package:flutter/material.dart'; +import 'package:google_mobile_ads/google_mobile_ads.dart'; import 'package:lunchpick/core/constants/app_colors.dart'; import 'package:lunchpick/core/constants/app_typography.dart'; +import 'package:lunchpick/core/utils/ad_helper.dart'; -/// 네이티브 광고(Native Ad) 플레이스홀더 -class NativeAdPlaceholder extends StatelessWidget { +/// 실제 네이티브 광고(Native Ad)를 표시하는 영역. +/// 광고 미지원 플랫폼이나 로드 실패 시 이전 플레이스홀더 스타일을 유지한다. +class NativeAdPlaceholder extends StatefulWidget { final EdgeInsetsGeometry? margin; final double height; + final Duration refreshInterval; - const NativeAdPlaceholder({super.key, this.margin, this.height = 120}); + const NativeAdPlaceholder({ + super.key, + this.margin, + this.height = 200, + this.refreshInterval = const Duration(minutes: 2), + }); + + @override + State createState() => _NativeAdPlaceholderState(); +} + +class _NativeAdPlaceholderState extends State { + NativeAd? _nativeAd; + Timer? _refreshTimer; + bool _isLoading = false; + bool _isLoaded = false; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _loadAd(); + }); + } + + @override + void didUpdateWidget(covariant NativeAdPlaceholder oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.refreshInterval != oldWidget.refreshInterval && _isLoaded) { + _scheduleRefresh(); + } + } + + @override + void dispose() { + _refreshTimer?.cancel(); + _nativeAd?.dispose(); + super.dispose(); + } + + void _loadAd() { + if (!AdHelper.isMobilePlatform) return; + if (!mounted) return; + + _refreshTimer?.cancel(); + _nativeAd?.dispose(); + + setState(() { + _isLoading = true; + _isLoaded = false; + }); + + final adUnitId = AdHelper.nativeAdUnitId; + _nativeAd = NativeAd( + adUnitId: adUnitId, + request: const AdRequest(), + nativeTemplateStyle: _buildTemplateStyle(), + listener: NativeAdListener( + onAdLoaded: (ad) { + if (!mounted) { + ad.dispose(); + return; + } + setState(() { + _isLoaded = true; + _isLoading = false; + }); + _scheduleRefresh(); + }, + onAdFailedToLoad: (ad, error) { + ad.dispose(); + if (!mounted) return; + setState(() { + _isLoading = false; + _isLoaded = false; + }); + _scheduleRefresh(retry: true); + }, + onAdClicked: (ad) => _scheduleRefresh(), + onAdOpened: (ad) => _scheduleRefresh(), + ), + )..load(); + } + + void _scheduleRefresh({bool retry = false}) { + _refreshTimer?.cancel(); + if (!mounted) return; + final delay = retry ? const Duration(seconds: 30) : widget.refreshInterval; + _refreshTimer = Timer(delay, _loadAd); + } + + NativeTemplateStyle _buildTemplateStyle() { + final isDark = Theme.of(context).brightness == Brightness.dark; + return NativeTemplateStyle( + templateType: TemplateType.medium, + mainBackgroundColor: isDark ? AppColors.darkSurface : Colors.white, + cornerRadius: 12, + callToActionTextStyle: NativeTemplateTextStyle( + textColor: Colors.white, + backgroundColor: AppColors.lightPrimary, + style: NativeTemplateFontStyle.bold, + ), + primaryTextStyle: NativeTemplateTextStyle( + textColor: isDark ? Colors.white : Colors.black87, + style: NativeTemplateFontStyle.bold, + ), + secondaryTextStyle: NativeTemplateTextStyle( + textColor: isDark ? Colors.white70 : Colors.black54, + style: NativeTemplateFontStyle.normal, + ), + ); + } @override Widget build(BuildContext context) { final isDark = Theme.of(context).brightness == Brightness.dark; + if (!AdHelper.isMobilePlatform) { + return _buildPlaceholder(isDark, isLoading: false); + } + + return AnimatedSwitcher( + duration: const Duration(milliseconds: 250), + child: _isLoaded && _nativeAd != null + ? _buildAdView(isDark) + : _buildPlaceholder(isDark, isLoading: _isLoading), + ); + } + + Widget _buildAdView(bool isDark) { + final containerHeight = max(widget.height, 200.0); return Container( - margin: margin ?? EdgeInsets.zero, - padding: const EdgeInsets.all(16), - height: height, + key: const ValueKey('nativeAdLoaded'), + margin: widget.margin ?? EdgeInsets.zero, + height: containerHeight, width: double.infinity, - decoration: BoxDecoration( - color: isDark ? AppColors.darkSurface : Colors.white, - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: isDark ? AppColors.darkDivider : AppColors.lightDivider, - width: 2, - ), - boxShadow: [ - BoxShadow( - color: (isDark ? Colors.black : Colors.grey).withOpacity(0.08), - blurRadius: 8, - offset: const Offset(0, 4), - ), - ], + decoration: _decoration(isDark), + child: ClipRRect( + borderRadius: BorderRadius.circular(10), + child: AdWidget(ad: _nativeAd!), ), + ); + } + + Widget _buildPlaceholder(bool isDark, {required bool isLoading}) { + final containerHeight = max(widget.height, 200.0); + return Container( + key: const ValueKey('nativeAdPlaceholder'), + margin: widget.margin ?? EdgeInsets.zero, + padding: const EdgeInsets.all(16), + height: containerHeight, + width: double.infinity, + decoration: _decoration(isDark), child: Center( child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ const Icon(Icons.ad_units, color: AppColors.lightPrimary, size: 24), const SizedBox(width: 8), - Text('광고 영역', style: AppTypography.heading2(isDark)), + Text( + isLoading ? '광고 불러오는 중...' : '광고 영역', + style: AppTypography.heading2(isDark), + ), ], ), ), ); } + + BoxDecoration _decoration(bool isDark) { + return BoxDecoration( + color: isDark ? AppColors.darkSurface : Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isDark ? AppColors.darkDivider : AppColors.lightDivider, + width: 2, + ), + boxShadow: [ + BoxShadow( + color: (isDark ? Colors.black : Colors.grey).withOpacity(0.08), + blurRadius: 8, + offset: const Offset(0, 4), + ), + ], + ); + } } diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 87d6e14..87e9e26 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -12,6 +12,7 @@ import path_provider_foundation import share_plus import shared_preferences_foundation import url_launcher_macos +import webview_flutter_wkwebview func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FlutterBluePlusPlugin.register(with: registry.registrar(forPlugin: "FlutterBluePlusPlugin")) @@ -21,4 +22,5 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) + WebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "WebViewFlutterPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index bc40447..463fee8 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -480,6 +480,14 @@ packages: url: "https://pub.dev" source: hosted version: "13.2.5" + google_mobile_ads: + dependency: "direct main" + description: + name: google_mobile_ads + sha256: "0d4a3744b5e8ed1b8be6a1b452d309f811688855a497c6113fc4400f922db603" + url: "https://pub.dev" + source: hosted + version: "5.3.1" graphs: dependency: transitive description: @@ -1205,6 +1213,38 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" + webview_flutter: + dependency: transitive + description: + name: webview_flutter + sha256: c3e4fe614b1c814950ad07186007eff2f2e5dd2935eba7b9a9a1af8e5885f1ba + url: "https://pub.dev" + source: hosted + version: "4.13.0" + webview_flutter_android: + dependency: transitive + description: + name: webview_flutter_android + sha256: "6ff028d8b8829b4e2482f7821dfb0b039f91c34a07075ed06f36172aa7afabda" + url: "https://pub.dev" + source: hosted + version: "4.10.10" + webview_flutter_platform_interface: + dependency: transitive + description: + name: webview_flutter_platform_interface + sha256: "63d26ee3aca7256a83ccb576a50272edd7cfc80573a4305caa98985feb493ee0" + url: "https://pub.dev" + source: hosted + version: "2.14.0" + webview_flutter_wkwebview: + dependency: transitive + description: + name: webview_flutter_wkwebview + sha256: a57b76a081bed3bf3a71a486bdf83642b00f1a7342043d50367cea68f338b1af + url: "https://pub.dev" + source: hosted + version: "3.23.4" win32: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 14f33ec..42de9ef 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -76,6 +76,9 @@ dependencies: timezone: ^0.9.2 # 시간대 처리 workmanager: ^0.8.0 # 백그라운드 작업 (Android) + # 광고 + google_mobile_ads: ^5.1.0 + dev_dependencies: flutter_test: sdk: flutter