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