import 'dart:async'; import 'dart:math'; import 'package:flutter/foundation.dart'; 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 StatefulWidget { final EdgeInsetsGeometry? margin; final double height; final Duration refreshInterval; final bool enabled; const NativeAdPlaceholder({ super.key, this.margin, this.height = 360, this.refreshInterval = const Duration(minutes: 2), this.enabled = true, }); @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 || !widget.enabled) return; _loadAd(); }); } @override void didUpdateWidget(covariant NativeAdPlaceholder oldWidget) { super.didUpdateWidget(oldWidget); if (!widget.enabled) { _disposeAd(); setState(() { _isLoading = false; _isLoaded = false; }); return; } if (!oldWidget.enabled && widget.enabled) { _loadAd(); return; } if (widget.refreshInterval != oldWidget.refreshInterval && _isLoaded) { _scheduleRefresh(); } } @override void dispose() { _disposeAd(); super.dispose(); } void _disposeAd() { _refreshTimer?.cancel(); _refreshTimer = null; _nativeAd?.dispose(); _nativeAd = null; } void _loadAd() { if (!widget.enabled) return; 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; if (!widget.enabled) 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: 0, 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; final containerHeight = _resolveContainerHeight(); if (!AdHelper.isMobilePlatform || !widget.enabled) { return _buildPlaceholder( isDark, containerHeight: containerHeight, isLoading: false, ); } return AnimatedSwitcher( duration: const Duration(milliseconds: 250), child: _isLoaded && _nativeAd != null ? _buildAdView(isDark, containerHeight) : _buildPlaceholder( isDark, containerHeight: containerHeight, isLoading: _isLoading, ), ); } Widget _buildAdView(bool isDark, double containerHeight) { return Container( key: const ValueKey('nativeAdLoaded'), margin: widget.margin ?? EdgeInsets.zero, height: containerHeight, width: double.infinity, decoration: _decoration(isDark), child: AdWidget(ad: _nativeAd!), ); } Widget _buildPlaceholder( bool isDark, { required double containerHeight, required bool isLoading, }) { 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( isLoading ? '광고 불러오는 중...' : '광고 영역', style: AppTypography.heading2(isDark), ), ], ), ), ); } double _resolveContainerHeight() { final platformMinHeight = switch (defaultTargetPlatform) { TargetPlatform.iOS => 402.0, TargetPlatform.android => 350.0, _ => 320.0, }; // 네이티브 템플릿(Medium) 기본 레이아웃이 플랫폼별로 350~402dp를 요구해 // 호출자가 지정한 높이보다 작을 경우 클리핑을 막기 위해 최소 높이를 강제한다. return max(widget.height, platformMinHeight); } BoxDecoration _decoration(bool isDark) { return BoxDecoration( color: isDark ? AppColors.darkSurface : Colors.white, 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), ), ], ); } }