feat(ads): 네이티브 광고 적용 및 디버그 스위치 이동

This commit is contained in:
JiWoong Sul
2025-12-03 17:25:00 +09:00
parent 5cae033977
commit d733bf664b
19 changed files with 461 additions and 168 deletions

View File

@@ -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),
],

View File

@@ -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),
// 주간 통계 차트

View File

@@ -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),
// ),
],
),
),

View File

@@ -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,

View File

@@ -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;

View File

@@ -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 {

View File

@@ -0,0 +1,3 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
final debugSharePreviewProvider = StateProvider<bool>((ref) => false);

View File

@@ -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),
),
],
);
}
}