Compare commits
2 Commits
997c2f53a0
...
d111b5dd62
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d111b5dd62 | ||
|
|
b944f6967d |
123
doc/ads.md
Normal file
123
doc/ads.md
Normal file
@@ -0,0 +1,123 @@
|
||||
# AdMob 미디에이션 네이티브 광고 네트워크 (Android)
|
||||
|
||||
아래 네트워크들은 AdMob 미디에이션을 통해 Android에서 네이티브(Native) 광고를 지원합니다. 실제 지원 범위(포맷/통합 방식)는 지역/계정/버전 등에 따라 달라질 수 있으므로 AdMob 콘솔에서 해당 미디에이션 그룹의 포맷 선택 가능 여부로 최종 확인하세요.
|
||||
|
||||
## 권장 후보
|
||||
- Meta Audience Network (FAN)
|
||||
- 통합: Bidding 전용
|
||||
- 포맷: Native, Native Banner
|
||||
- 문서: https://developers.google.com/admob/android/mediation/meta
|
||||
- InMobi
|
||||
- 통합: Waterfall(네이티브는 Waterfall만), Bidding(다른 포맷)
|
||||
- 포맷: Native
|
||||
- 문서: https://developers.google.com/admob/android/mediation/inmobi
|
||||
- Pangle (ByteDance/TikTok)
|
||||
- 통합: Bidding + Waterfall
|
||||
- 포맷: Native
|
||||
- 문서: https://developers.google.com/admob/android/mediation/pangle
|
||||
- Mintegral
|
||||
- 통합: Bidding + Waterfall
|
||||
- 포맷: Native
|
||||
- 메모: 네이티브는 “Native (Custom Rendering)” 선택 지침이 있음
|
||||
- 문서: https://developers.google.com/admob/android/mediation/mintegral
|
||||
- DT Exchange (Fyber)
|
||||
- 통합: Waterfall, Bidding(클로즈드 베타)
|
||||
- 포맷: Native
|
||||
- 문서: https://developers.google.com/admob/android/mediation/dt-exchange
|
||||
- Moloco
|
||||
- 통합: Bidding
|
||||
- 포맷: Native
|
||||
- 문서: https://developers.google.com/admob/android/mediation/moloco
|
||||
- ironSource Ads
|
||||
- 통합: Waterfall(네이티브는 Waterfall만)
|
||||
- 포맷: Native
|
||||
- 문서: https://developers.google.com/admob/android/mediation/ironsource
|
||||
- Unity Ads
|
||||
- 통합: Waterfall, Bidding(오픈 베타)
|
||||
- 포맷: Native
|
||||
- 문서: https://developers.google.com/admob/android/mediation/unity
|
||||
- LINE Ads Platform (일본 중심)
|
||||
- 통합: Bidding(네이티브는 클로즈드 베타)
|
||||
- 포맷: Native
|
||||
- 문서: https://developers.google.com/admob/android/mediation/line
|
||||
- myTarget (RU/CIS 중심)
|
||||
- 통합: Waterfall
|
||||
- 포맷: Native
|
||||
- 문서: https://developers.google.com/admob/android/mediation/mytarget
|
||||
|
||||
## 참고 및 주의사항
|
||||
- 지역성/수요: Pangle(아시아), LINE(일본), myTarget(RU/CIS) 등은 지역별 수요 차이가 큼. 타겟 지역 기준으로 우선순위 구성 권장.
|
||||
- 통합 방식: 일부는 네이티브가 Waterfall만 지원(InMobi, ironSource), 일부는 Bidding만(Meta), 혼합 지원(Pangle, Mintegral, Unity). 비딩/워터폴 여부에 따라 콘솔 설정이 상이함.
|
||||
- SDK/어댑터: Android Gradle에 각 네트워크 SDK/어댑터 추가가 필요하며, AdMob UI에서 해당 네트워크를 미디에이션 그룹의 “Native” 포맷으로 매핑해야 함. 개인정보/동의 메시징(US State Privacy, GDPR 등)도 파트너 추가 필요.
|
||||
- 템플릿/표시: 대부분 Unified Native 기반 에셋을 제공하나 네트워크별 에셋 세트가 달라 `NativeTemplateStyle` 기반 템플릿 레이아웃 조정이 필요할 수 있음.
|
||||
- AppLovin 유의: 문서상 포맷 표에 Native가 보이더라도 어댑터 변경 이력에 “Native 지원 제거”가 기록되어 있습니다. 실제 지원은 AdMob 콘솔(미디에이션 그룹)에서 포맷 선택 가능 여부로 재확인하세요. 문서: https://developers.google.com/admob/android/mediation/applovin
|
||||
- Flutter 연동: `google_mobile_ads`의 `NativeAd` 로드/리스너/`AdWidget` 사용 패턴은 동일. 네트워크 추가는 네이티브(Android) 쪽 SDK/어댑터 및 콘솔 설정이 핵심.
|
||||
|
||||
## 빠른 적용 체크리스트
|
||||
- [ ] 타겟 지역에 맞는 네트워크 선정(2~5개)
|
||||
- [ ] Android 의존성 추가(네트워크 SDK/어댑터)
|
||||
- [ ] AdMob 콘솔: 미디에이션 그룹 생성(포맷=Native), 각 네트워크 매핑
|
||||
- [ ] 테스트 모드/테스트 광고 확인(네트워크별 테스트 설정 있음)
|
||||
- [ ] 앱 내 네이티브 광고 UI 검수(템플릿/에셋 배치, 정책 준수)
|
||||
|
||||
---
|
||||
|
||||
## 지역별 우선순위 제안(예시)
|
||||
아래는 일반적인 트래픽·수요 기준의 스타트 세트 예시입니다. 실제 퍼포먼스는 앱 카테고리/유저 페르소나/국가별 규제에 따라 달라질 수 있으므로 A/B로 조합을 검증하세요.
|
||||
|
||||
- 한국/일본(KR/JP)
|
||||
- 1군: Meta(FAN, Bidding) + Pangle(Bidding/Waterfall) + LINE(JP, Bidding/Closed Beta for Native)
|
||||
- 보강: Mintegral, InMobi, Unity
|
||||
- 북미/유럽(NA/EU)
|
||||
- 1군: Meta(FAN) + InMobi + Unity + Chartboost
|
||||
- 보강: DT Exchange(Fyber), Moloco
|
||||
- 동남아/인도(SEA/IN)
|
||||
- 1군: InMobi + Pangle + Mintegral + Meta(FAN)
|
||||
- 보강: Unity, DT Exchange
|
||||
- CIS/러시아권
|
||||
- 1군: myTarget
|
||||
- 보강: Mintegral, Unity
|
||||
|
||||
참고: Chartboost는 네이티브 포맷 지원. 지역/장르에 따라 성과 편차가 있어 NA/EU 게임 카테고리에서 보강용으로 고려.
|
||||
|
||||
문서:
|
||||
- Chartboost: https://developers.google.com/admob/android/mediation/chartboost
|
||||
|
||||
---
|
||||
|
||||
## Android Gradle 의존성(예시)
|
||||
Flutter에서 `google_mobile_ads`를 사용해도, 미디에이션 파트너의 Android SDK/어댑터는 Gradle에 직접 추가해야 합니다. 아래 스니펫은 예시이며, “정확한 최신 버전”은 각 네트워크 문서의 Adapter 섹션(Changelog/Artifacts)에서 확인 후 고정하세요.
|
||||
|
||||
프로젝트 수준 `settings.gradle`/리포지토리 설정은 기본 `google()`/`mavenCentral()`이면 충분합니다.
|
||||
|
||||
`android/app/build.gradle` (dependencies 블록)
|
||||
|
||||
```gradle
|
||||
dependencies {
|
||||
// Google Mobile Ads SDK (보통 어댑터가 transitive로 끌어오지만 명시해도 무방)
|
||||
implementation 'com.google.android.gms:play-services-ads:24.6.0' // 최신 권장 버전으로 교체
|
||||
|
||||
// Mediation adapters (예시 버전; 실제 최신 버전으로 교체)
|
||||
implementation 'com.google.ads.mediation:facebook:6.16.0.0' // Meta Audience Network
|
||||
implementation 'com.google.ads.mediation:pangle:5.5.0.4.0' // Pangle
|
||||
implementation 'com.google.ads.mediation:mintegral:16.5.91.1' // Mintegral
|
||||
implementation 'com.google.ads.mediation:inmobi:10.6.3.0' // InMobi
|
||||
implementation 'com.google.ads.mediation:fyber:8.3.8.0' // DT Exchange(Fyber)
|
||||
implementation 'com.google.ads.mediation:moloco:3.8.0.0' // Moloco
|
||||
implementation 'com.google.ads.mediation:ironsource:8.5.0.1' // ironSource
|
||||
implementation 'com.google.ads.mediation:unity:4.16.0.1' // Unity Ads
|
||||
implementation 'com.google.ads.mediation:mytarget:5.20.0.0' // myTarget
|
||||
// implementation 'com.google.ads.mediation:chartboost:<version>' // Chartboost (필요 시)
|
||||
}
|
||||
```
|
||||
|
||||
버전 확인 팁:
|
||||
- 각 네트워크 가이드의 “Supported integrations and ad formats”/“Changelog”에서 최소/최신 어댑터 버전 확인
|
||||
- Maven Central에서 `com.google.ads.mediation:<artifact>` 검색하여 최신 릴리스 확인
|
||||
- AdMob 콘솔에서 해당 네트워크 추가 시 표시되는 가이드/버전 주석 참조
|
||||
|
||||
설정 체크:
|
||||
- ProGuard/R8 규칙이 필요한 네트워크의 경우 가이드에 명시된 keep 규칙 추가
|
||||
- COPPA/유럽·미국 주 개인정보법 관련 consent 전달(UMP SDK 또는 자체 메시징) 및 파트너 동기화
|
||||
- 테스트: 네트워크 콘솔에서 테스트 모드 또는 테스트 디바이스 ID 설정 후 실제 단말에서 `NativeAd` 로드 확인
|
||||
|
||||
@@ -8,6 +8,9 @@ import '../services/sms_service.dart';
|
||||
import '../services/subscription_url_matcher.dart';
|
||||
import '../widgets/common/snackbar/app_snackbar.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../utils/billing_date_util.dart';
|
||||
import '../utils/business_day_util.dart';
|
||||
import 'package:permission_handler/permission_handler.dart' as permission;
|
||||
|
||||
/// AddSubscriptionScreen의 비즈니스 로직을 관리하는 Controller
|
||||
class AddSubscriptionController {
|
||||
@@ -104,6 +107,26 @@ class AddSubscriptionController {
|
||||
scrollOffset = scrollController.offset;
|
||||
});
|
||||
|
||||
// 언어별 기본 통화 설정
|
||||
try {
|
||||
final lang = Localizations.localeOf(context).languageCode;
|
||||
switch (lang) {
|
||||
case 'ko':
|
||||
currency = 'KRW';
|
||||
break;
|
||||
case 'ja':
|
||||
currency = 'JPY';
|
||||
break;
|
||||
case 'zh':
|
||||
currency = 'CNY';
|
||||
break;
|
||||
default:
|
||||
currency = 'USD';
|
||||
}
|
||||
} catch (_) {
|
||||
// Localizations가 아직 준비되지 않은 경우 기본값 유지
|
||||
}
|
||||
|
||||
// 애니메이션 시작
|
||||
animationController!.forward();
|
||||
}
|
||||
@@ -284,25 +307,55 @@ class AddSubscriptionController {
|
||||
setState(() => isLoading = true);
|
||||
|
||||
try {
|
||||
final ctx = context;
|
||||
if (!await SMSService.hasSMSPermission()) {
|
||||
final granted = await SMSService.requestSMSPermission();
|
||||
if (!ctx.mounted) return;
|
||||
if (!granted) {
|
||||
if (context.mounted) {
|
||||
AppSnackBar.showError(
|
||||
context: context,
|
||||
message: AppLocalizations.of(context).smsPermissionRequired,
|
||||
);
|
||||
if (ctx.mounted) {
|
||||
// 영구 거부 여부 확인 후 설정 화면 안내
|
||||
final status = await permission.Permission.sms.status;
|
||||
if (!ctx.mounted) return;
|
||||
if (status.isPermanentlyDenied) {
|
||||
await showDialog(
|
||||
context: ctx,
|
||||
builder: (_) => AlertDialog(
|
||||
title: Text(AppLocalizations.of(ctx).smsPermissionRequired),
|
||||
content:
|
||||
Text(AppLocalizations.of(ctx).permanentlyDeniedMessage),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(),
|
||||
child: Text(AppLocalizations.of(ctx).cancel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
await permission.openAppSettings();
|
||||
if (ctx.mounted) Navigator.of(ctx).pop();
|
||||
},
|
||||
child: Text(AppLocalizations.of(ctx).openSettings),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
} else {
|
||||
AppSnackBar.showError(
|
||||
context: ctx,
|
||||
message: AppLocalizations.of(ctx).smsPermissionRequired,
|
||||
);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
final subscriptions = await SMSService.scanSubscriptions();
|
||||
if (!ctx.mounted) return;
|
||||
if (subscriptions.isEmpty) {
|
||||
if (context.mounted) {
|
||||
if (ctx.mounted) {
|
||||
AppSnackBar.showWarning(
|
||||
context: context,
|
||||
message: AppLocalizations.of(context).noSubscriptionSmsFound,
|
||||
context: ctx,
|
||||
message: AppLocalizations.of(ctx).noSubscriptionSmsFound,
|
||||
);
|
||||
}
|
||||
return;
|
||||
@@ -434,12 +487,22 @@ class AddSubscriptionController {
|
||||
double.tryParse(eventPriceController.text.replaceAll(',', ''));
|
||||
}
|
||||
|
||||
// 선택일이 오늘(또는 과거)이면 결제 주기에 맞춰 다음 회차로 보정하여 저장 + 영업일 이월
|
||||
final originalDateOnly = DateTime(
|
||||
nextBillingDate!.year,
|
||||
nextBillingDate!.month,
|
||||
nextBillingDate!.day,
|
||||
);
|
||||
var adjustedNext =
|
||||
BillingDateUtil.ensureFutureDate(originalDateOnly, billingCycle);
|
||||
adjustedNext = BusinessDayUtil.nextBusinessDay(adjustedNext);
|
||||
|
||||
await Provider.of<SubscriptionProvider>(context, listen: false)
|
||||
.addSubscription(
|
||||
serviceName: serviceNameController.text.trim(),
|
||||
monthlyCost: monthlyCost,
|
||||
billingCycle: billingCycle,
|
||||
nextBillingDate: nextBillingDate!,
|
||||
nextBillingDate: adjustedNext,
|
||||
websiteUrl: websiteUrlController.text.trim(),
|
||||
categoryId: selectedCategoryId,
|
||||
currency: currency,
|
||||
@@ -449,6 +512,16 @@ class AddSubscriptionController {
|
||||
eventPrice: eventPrice,
|
||||
);
|
||||
|
||||
// 자동 보정이 발생했으면 안내
|
||||
if (adjustedNext.isAfter(originalDateOnly)) {
|
||||
if (context.mounted) {
|
||||
AppSnackBar.showInfo(
|
||||
context: context,
|
||||
message: '다음 결제 예정일로 저장됨',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (context.mounted) {
|
||||
Navigator.pop(context, true); // 성공 여부 반환
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import '../services/sms_scan/subscription_converter.dart';
|
||||
import '../services/sms_scan/subscription_filter.dart';
|
||||
import '../providers/subscription_provider.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:permission_handler/permission_handler.dart' as permission;
|
||||
import '../utils/logger.dart';
|
||||
import '../providers/navigation_provider.dart';
|
||||
import '../providers/category_provider.dart';
|
||||
@@ -57,6 +59,33 @@ class SmsScanController extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
// Android에서 SMS 권한 확인 및 요청
|
||||
final ctx = context;
|
||||
if (!kIsWeb) {
|
||||
final smsStatus = await permission.Permission.sms.status;
|
||||
if (!smsStatus.isGranted) {
|
||||
if (smsStatus.isPermanentlyDenied) {
|
||||
// 설정 유도 다이얼로그 표시
|
||||
if (!ctx.mounted) return;
|
||||
await _showPermissionSettingsDialog(ctx);
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
final req = await permission.Permission.sms.request();
|
||||
if (!ctx.mounted) return;
|
||||
if (!req.isGranted) {
|
||||
// 거부됨: 안내 후 종료
|
||||
if (!ctx.mounted) return;
|
||||
_errorMessage = AppLocalizations.of(ctx).smsPermissionRequired;
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SMS 스캔 실행
|
||||
Log.i('SMS 스캔 시작');
|
||||
final scannedSubscriptionModels =
|
||||
@@ -139,6 +168,30 @@ class SmsScanController extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _showPermissionSettingsDialog(BuildContext context) async {
|
||||
final loc = AppLocalizations.of(context);
|
||||
await showDialog(
|
||||
context: context,
|
||||
builder: (_) => AlertDialog(
|
||||
title: Text(loc.smsPermissionRequired),
|
||||
content: Text(loc.permanentlyDeniedMessage),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text(loc.cancel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
await permission.openAppSettings();
|
||||
if (context.mounted) Navigator.of(context).pop();
|
||||
},
|
||||
child: Text(loc.openSettings),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> addCurrentSubscription(BuildContext context) async {
|
||||
if (_currentIndex >= _scannedSubscriptions.length) return;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user