feat(ads): 네이티브 광고 적용 및 디버그 스위치 이동
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -21,6 +21,9 @@
|
||||
android:label="lunchpick"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
<meta-data
|
||||
android:name="com.google.android.gms.ads.APPLICATION_ID"
|
||||
android:value="${admobAppId}" />
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
|
||||
#include "Generated.xcconfig"
|
||||
|
||||
GAD_APPLICATION_ID=ca-app-pub-3940256099942544~1458002511
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
|
||||
#include "Generated.xcconfig"
|
||||
|
||||
GAD_APPLICATION_ID=ca-app-pub-3940256099942544~1458002511
|
||||
|
||||
@@ -54,5 +54,7 @@
|
||||
<string>맛집과의 거리 계산을 위해 위치 정보가 필요합니다.</string>
|
||||
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
|
||||
<string>맛집과의 거리 계산을 위해 위치 정보가 필요합니다.</string>
|
||||
<key>GADApplicationIdentifier</key>
|
||||
<string>$(GAD_APPLICATION_ID)</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -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';
|
||||
|
||||
28
lib/core/utils/ad_helper.dart
Normal file
28
lib/core/utils/ad_helper.dart
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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<CalendarScreen>
|
||||
_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<CalendarScreen>
|
||||
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<CalendarScreen>
|
||||
const SizedBox(height: 16),
|
||||
const NativeAdPlaceholder(
|
||||
margin: EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
height: 360,
|
||||
),
|
||||
_buildDayRecords(_selectedDay, isDark),
|
||||
],
|
||||
|
||||
@@ -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),
|
||||
|
||||
// 주간 통계 차트
|
||||
|
||||
@@ -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<RandomSelectionScreen> {
|
||||
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<RandomSelectionScreen> {
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
const NativeAdPlaceholder(
|
||||
margin: EdgeInsets.symmetric(vertical: 8),
|
||||
),
|
||||
// const NativeAdPlaceholder(
|
||||
// margin: EdgeInsets.symmetric(vertical: 8),
|
||||
// ),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -145,18 +145,33 @@ class _RestaurantListScreenState extends ConsumerState<RestaurantListScreen> {
|
||||
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,
|
||||
|
||||
@@ -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<SettingsScreen> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadSettings();
|
||||
if (kDebugMode) {
|
||||
Future.microtask(
|
||||
() => ref.read(debugTestDataNotifierProvider.notifier).initialize(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadSettings() async {
|
||||
@@ -343,6 +350,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
],
|
||||
),
|
||||
),
|
||||
if (kDebugMode) _buildDebugToolsCard(isDark),
|
||||
], isDark),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
@@ -444,6 +452,81 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
|
||||
@@ -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<ShareScreen> {
|
||||
bool _isScanning = false;
|
||||
List<ShareDevice>? _nearbyDevices;
|
||||
StreamSubscription<String>? _dataSubscription;
|
||||
ProviderSubscription<bool>? _debugPreviewSub;
|
||||
final _uuid = const Uuid();
|
||||
bool _debugPreviewEnabled = false;
|
||||
bool _debugPreviewProcessing = false;
|
||||
Timer? _debugPreviewTimer;
|
||||
|
||||
@override
|
||||
@@ -97,11 +98,25 @@ class _ShareScreenState extends ConsumerState<ShareScreen> {
|
||||
_dataSubscription = bluetoothService.onDataReceived.listen((payload) {
|
||||
_handleIncomingData(payload);
|
||||
});
|
||||
_debugPreviewEnabled = ref.read(debugSharePreviewProvider);
|
||||
if (_debugPreviewEnabled) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_handleDebugToggleChange(true);
|
||||
});
|
||||
}
|
||||
_debugPreviewSub = ref.listenManual<bool>(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<ShareScreen> {
|
||||
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<ShareScreen> {
|
||||
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<ShareScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
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<void> _toggleDebugPreview(bool enabled) async {
|
||||
if (_debugPreviewProcessing) return;
|
||||
Future<void> _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<void> _startDebugPreviewFlow() async {
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
final debugSharePreviewProvider = StateProvider<bool>((ref) => false);
|
||||
@@ -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<NativeAdPlaceholder> createState() => _NativeAdPlaceholderState();
|
||||
}
|
||||
|
||||
class _NativeAdPlaceholderState extends State<NativeAdPlaceholder> {
|
||||
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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
40
pubspec.lock
40
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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user