feat: SMS 스캔 전면광고 및 Isolate 버그 수정

## 전면 광고 (AdService)
- AdService 클래스 신규 생성 (lunchpick 패턴 참조)
- Completer 패턴으로 광고 완료 대기 구현
- 로딩 오버레이로 앱 foreground 상태 유지
- 몰입형 모드 (immersiveSticky) 적용
- iOS 테스트 광고 ID 설정

## SMS 스캔 버그 수정
- Isolate 내 Flutter 바인딩 접근 오류 해결
- _isoExtractServiceNameFromSender()에서 하드코딩 사용
- 로딩 위젯 화면 정중앙 배치 수정

## 문서 및 설정
- CLAUDE.md 최적화 (글로벌 규칙 중복 제거)
- Claude Code Skills 5개 추가
  - flutter-build: 빌드/분석
  - hive-model: Hive 모델 관리
  - release-deploy: 릴리즈 배포
  - sms-scanner: SMS 스캔 디버깅
  - admob: 광고 구현

## 버전
- 1.0.1+2 → 1.0.1+3
This commit is contained in:
JiWoong Sul
2025-12-08 18:14:52 +09:00
parent bac4acf9a3
commit 83c43fb61f
12 changed files with 639 additions and 434 deletions

View File

@@ -1,21 +1,22 @@
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:provider/provider.dart';
import 'package:permission_handler/permission_handler.dart' as permission;
import 'dart:io' show Platform;
import '../services/sms_scanner.dart';
import '../models/subscription.dart';
import '../models/payment_card_suggestion.dart';
import '../services/ad_service.dart';
import '../services/sms_scan/subscription_converter.dart';
import '../services/sms_scan/subscription_filter.dart';
import '../services/sms_scan/sms_scan_result.dart';
import '../models/subscription.dart';
import '../models/payment_card_suggestion.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';
import '../l10n/app_localizations.dart';
import '../providers/payment_card_provider.dart';
import 'package:google_mobile_ads/google_mobile_ads.dart';
import 'dart:io' show Platform;
import '../l10n/app_localizations.dart';
import '../utils/logger.dart';
class SmsScanController extends ChangeNotifier {
// 상태 관리
@@ -47,10 +48,9 @@ class SmsScanController extends ChangeNotifier {
final SmsScanner _smsScanner = SmsScanner();
final SubscriptionConverter _converter = SubscriptionConverter();
final SubscriptionFilter _filter = SubscriptionFilter();
final AdService _adService = AdService();
bool _forceServiceNameEditing = false;
bool get isServiceNameEditable => _forceServiceNameEditing;
bool _isAdInProgress = false;
bool get isAdInProgress => _isAdInProgress;
@override
void dispose() {
@@ -87,69 +87,26 @@ class SmsScanController extends ChangeNotifier {
notifyListeners();
}
/// SMS 스캔 시작 (전면 광고 표시 후 스캔 진행)
Future<void> startScan(BuildContext context) async {
if (_isLoading) return;
_isAdInProgress = true;
notifyListeners();
// 웹/비지원 플랫폼은 바로 스캔
if (kIsWeb || !(Platform.isAndroid || Platform.isIOS)) {
_isAdInProgress = false;
notifyListeners();
await scanSms(context);
return;
}
// 전면 광고 로드 및 노출 후 스캔 진행
try {
await InterstitialAd.load(
adUnitId: _interstitialAdUnitId(),
request: const AdRequest(),
adLoadCallback: InterstitialAdLoadCallback(
onAdLoaded: (ad) {
ad.fullScreenContentCallback = FullScreenContentCallback(
onAdDismissedFullScreenContent: (ad) {
ad.dispose();
_startSmsScanIfMounted(context);
},
onAdFailedToShowFullScreenContent: (ad, error) {
ad.dispose();
_fallbackAfterDelay(context);
},
);
ad.show();
},
onAdFailedToLoad: (error) {
_fallbackAfterDelay(context);
},
),
);
} catch (e) {
Log.e('전면 광고 로드 중 오류, 바로 스캔 진행', e);
if (!context.mounted) return;
_fallbackAfterDelay(context);
}
}
// 광고 표시 (완료까지 대기)
// 광고 실패해도 스캔 진행 (사용자 경험 우선)
await _adService.showInterstitialAd(context);
String _interstitialAdUnitId() {
if (Platform.isAndroid || Platform.isIOS) {
return 'ca-app-pub-6691216385521068~6638409932';
}
return '';
}
Future<void> _startSmsScanIfMounted(BuildContext context) async {
if (!context.mounted) return;
_isAdInProgress = false;
notifyListeners();
// 광고 완료 후 SMS 스캔 실행
await scanSms(context);
}
Future<void> _fallbackAfterDelay(BuildContext context) async {
await Future.delayed(const Duration(seconds: 5));
if (!context.mounted) return;
await _startSmsScanIfMounted(context);
}
Future<void> scanSms(BuildContext context) async {
_isLoading = true;
_errorMessage = null;
@@ -157,6 +114,11 @@ class SmsScanController extends ChangeNotifier {
_currentIndex = 0;
notifyListeners();
await _performSmsScan(context);
}
/// 실제 SMS 스캔 수행 (로딩 상태는 이미 설정되어 있음)
Future<void> _performSmsScan(BuildContext context) async {
try {
// Android에서 SMS 권한 확인 및 요청
final ctx = context;
@@ -399,13 +361,14 @@ class SmsScanController extends ChangeNotifier {
return otherCategory.id;
}
void initializeWebsiteUrl() {
void initializeWebsiteUrl(BuildContext context) {
if (_currentIndex < _scannedSubscriptions.length) {
final currentSub = _scannedSubscriptions[_currentIndex];
if (websiteUrlController.text.isEmpty && currentSub.websiteUrl != null) {
websiteUrlController.text = currentSub.websiteUrl!;
}
if (_shouldEnableServiceNameEditing(currentSub)) {
final unknownLabel = _unknownServiceLabel(context);
if (_shouldEnableServiceNameEditing(currentSub, unknownLabel)) {
if (serviceNameController.text != currentSub.serviceName) {
serviceNameController.clear();
}

View File

@@ -47,7 +47,7 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
@override
void didChangeDependencies() {
super.didChangeDependencies();
_controller.initializeWebsiteUrl();
_controller.initializeWebsiteUrl(context);
}
Widget _buildContent() {
@@ -161,83 +161,26 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
@override
Widget build(BuildContext context) {
return Stack(
children: [
SingleChildScrollView(
controller: _scrollController,
padding: EdgeInsets.zero,
child: Padding(
padding: const EdgeInsets.only(top: UIConstants.pageTopPadding),
child: Column(
children: [
_buildContent(),
// FloatingNavigationBar를 위한 충분한 하단 여백
SizedBox(
height: 120 + MediaQuery.of(context).padding.bottom,
),
],
// 로딩 중일 때는 화면 정중앙에 표시
if (_controller.isLoading) {
return const ScanLoadingWidget();
}
return SingleChildScrollView(
controller: _scrollController,
padding: EdgeInsets.zero,
child: Padding(
padding: const EdgeInsets.only(top: UIConstants.pageTopPadding),
child: Column(
children: [
_buildContent(),
// FloatingNavigationBar를 위한 충분한 하단 여백
SizedBox(
height: 120 + MediaQuery.of(context).padding.bottom,
),
),
],
),
if (_controller.isAdInProgress)
Positioned.fill(
child: IgnorePointer(
child: Stack(
children: [
Container(
color: Theme.of(context)
.colorScheme
.surface
.withValues(alpha: 0.4),
),
Center(
child: DecoratedBox(
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.surfaceContainerHighest
.withValues(alpha: 0.9),
borderRadius: BorderRadius.circular(16),
),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 20, vertical: 16),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
Theme.of(context).colorScheme.primary,
),
),
),
const SizedBox(width: 12),
Text(
AppLocalizations.of(context).scanningMessages,
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(
color:
Theme.of(context).colorScheme.onSurface,
fontWeight: FontWeight.w700,
),
textAlign: TextAlign.center,
),
],
),
),
),
),
],
),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,195 @@
import 'dart:async';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:google_mobile_ads/google_mobile_ads.dart';
import 'dart:io' show Platform;
import '../utils/logger.dart';
/// 전면 광고(Interstitial Ad) 서비스
/// lunchpick 프로젝트의 AdService 패턴을 참조하여 구현
class AdService {
InterstitialAd? _interstitialAd;
Completer<bool>? _loadingCompleter;
/// 모바일 플랫폼 여부 확인
bool get _isMobilePlatform {
if (kIsWeb) return false;
return Platform.isAndroid || Platform.isIOS;
}
/// 전면 광고 Unit ID 반환
String get _interstitialAdUnitId {
if (Platform.isAndroid) {
return 'ca-app-pub-6691216385521068/5281562472';
} else if (Platform.isIOS) {
// iOS 테스트 광고 ID
return 'ca-app-pub-3940256099942544/1033173712';
}
return '';
}
/// 광고를 로드하고 표시한 뒤 완료 여부를 반환
/// true: 광고 시청 완료 또는 미지원 플랫폼
/// false: 광고 로드/표시 실패
Future<bool> showInterstitialAd(BuildContext context) async {
if (!_isMobilePlatform) return true;
Log.i('광고 표시 시작');
// 1. 로딩 오버레이 표시 (앱이 foreground 상태 유지)
final closeLoading = _showLoadingOverlay(context);
// 2. 몰입형 모드 진입
await _enterImmersiveMode();
// 3. 광고 로드
final loaded = await _ensureAdLoaded();
// 4. 로딩 오버레이 닫기
closeLoading();
if (!loaded) {
Log.w('광고 로드 실패, 건너뜀');
await _restoreSystemUi();
return false;
}
final ad = _interstitialAd;
if (ad == null) {
Log.w('광고 인스턴스 없음, 건너뜀');
await _restoreSystemUi();
return false;
}
// 현재 광고를 null로 설정 (다음 광고 미리로드)
_interstitialAd = null;
final completer = Completer<bool>();
ad.fullScreenContentCallback = FullScreenContentCallback(
onAdShowedFullScreenContent: (ad) {
Log.i('전면 광고 표시됨 (콜백)');
},
onAdDismissedFullScreenContent: (ad) {
Log.i('전면 광고 닫힘 (콜백)');
ad.dispose();
_preload();
unawaited(_restoreSystemUi());
if (!completer.isCompleted) {
completer.complete(true);
}
},
onAdFailedToShowFullScreenContent: (ad, error) {
Log.e('전면 광고 표시 실패 (콜백)', error);
ad.dispose();
_preload();
unawaited(_restoreSystemUi());
if (!completer.isCompleted) {
completer.complete(false);
}
},
);
// 전체 화면으로 표시하도록 immersive 모드 설정
ad.setImmersiveMode(true);
Log.i('ad.show() 호출 직전');
try {
ad.show();
Log.i('ad.show() 호출 완료');
} catch (e) {
Log.e('광고 show() 호출 실패', e);
unawaited(_restoreSystemUi());
if (!completer.isCompleted) {
completer.complete(false);
}
return false;
}
// 타임아웃 설정 (15초 후 자동 건너뜀)
return completer.future.timeout(
const Duration(seconds: 15),
onTimeout: () {
Log.w('광고 표시 타임아웃, 건너뜀');
unawaited(_restoreSystemUi());
return false;
},
);
}
/// 로딩 오버레이 표시 (앱이 foreground 상태 유지)
VoidCallback _showLoadingOverlay(BuildContext context) {
final navigator = Navigator.of(context, rootNavigator: true);
showDialog<void>(
context: context,
barrierDismissible: false,
barrierColor: Colors.black.withValues(alpha: 0.35),
builder: (_) => const Center(child: CircularProgressIndicator()),
);
return () {
if (navigator.mounted && navigator.canPop()) {
navigator.pop();
}
};
}
/// 몰입형 모드 진입 (상하단 시스템 UI 숨김)
Future<void> _enterImmersiveMode() async {
try {
await SystemChrome.setEnabledSystemUIMode(
SystemUiMode.immersiveSticky,
overlays: [],
);
} catch (_) {}
}
/// UI 복구
Future<void> _restoreSystemUi() async {
try {
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
} catch (_) {}
}
/// 광고 로드 보장 (이미 로드된 경우 즉시 반환)
Future<bool> _ensureAdLoaded() async {
if (_interstitialAd != null) return true;
if (_loadingCompleter != null) {
return _loadingCompleter!.future;
}
final completer = Completer<bool>();
_loadingCompleter = completer;
Log.i('전면 광고 로드 시작: $_interstitialAdUnitId');
InterstitialAd.load(
adUnitId: _interstitialAdUnitId,
request: const AdRequest(),
adLoadCallback: InterstitialAdLoadCallback(
onAdLoaded: (ad) {
Log.i('전면 광고 로드 성공');
_interstitialAd = ad;
completer.complete(true);
_loadingCompleter = null;
},
onAdFailedToLoad: (error) {
Log.e('전면 광고 로드 실패', error);
completer.complete(false);
_loadingCompleter = null;
},
),
);
return completer.future;
}
/// 다음 광고 미리로드
void _preload() {
if (_interstitialAd != null || _loadingCompleter != null) return;
_ensureAdLoaded();
}
}

View File

@@ -518,7 +518,8 @@ String _isoExtractServiceName(String body, String sender) {
String _isoExtractServiceNameFromSender(String sender) {
if (RegExp(r'^\d+$').hasMatch(sender)) {
return _unknownServiceLabel();
// Isolate에서 실행되므로 하드코딩 사용 (Flutter 바인딩 접근 불가)
return 'Unknown service';
}
return sender.replaceAll(RegExp(r'[^\w\s가-힣]'), '').trim();
}

View File

@@ -7,12 +7,11 @@ class ScanLoadingWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
return SizedBox.expand(
return Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(
color: Theme.of(context).colorScheme.primary,