Files
submanager/lib/controllers/sms_scan_controller.dart
JiWoong Sul d37f66d526 feat(settings): SMS 읽기 권한 상태/요청 위젯 추가 (Android)
- 설정 화면에 SMS 권한 카드 추가: 상태 표시(허용/미허용/영구 거부), 권한 요청/설정 이동 지원\n- 기존 알림 권한 카드 스타일과 일관성 유지

feat(permissions): 최초 실행 시 SMS 권한 온보딩 화면 추가 및 Splash에서 라우팅 (Android)

- 권한 필요 이유/수집 범위 현지화 문구 추가\n- 거부/영구거부 케이스 처리 및 설정 이동

chore(codex): AGENTS.md/체크 스크립트/CI/프롬프트 템플릿 추가

- AGENTS.md, scripts/check.sh, scripts/fix.sh, .github/workflows/flutter_ci.yml, .claude/agents/codex.md, 문서 템플릿 추가

refactor(logging): 경로별 print 제거 후 경량 로거(Log) 도입

- SMS 스캐너/컨트롤러, URL 매처, 데이터 리포지토리, 내비게이션, 메모리/성능 유틸 등 핵심 경로 치환

feat(exchange): 환율 API URL을 --dart-define로 오버라이드 가능 + 폴백 로깅 강화

test: URL 매처/환율 스모크 테스트 추가

chore(android): RECEIVE_SMS 권한 제거 (READ_SMS만 유지)

fix(lints): dart fix + 수동 정리로 경고 대폭 감소, 비동기 context(mounted) 보강

fix(deprecations):\n- flutter_local_notifications의 androidAllowWhileIdle → androidScheduleMode 전환\n- WillPopScope → PopScope 교체

i18n: SMS 권한 온보딩/설정 문구 현지화 키 추가
2025-09-07 21:32:16 +09:00

241 lines
7.9 KiB
Dart

import 'package:flutter/material.dart';
import '../services/sms_scanner.dart';
import '../models/subscription.dart';
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 '../utils/logger.dart';
import '../providers/navigation_provider.dart';
import '../providers/category_provider.dart';
import '../l10n/app_localizations.dart';
class SmsScanController extends ChangeNotifier {
// 상태 관리
bool _isLoading = false;
bool get isLoading => _isLoading;
String? _errorMessage;
String? get errorMessage => _errorMessage;
List<Subscription> _scannedSubscriptions = [];
List<Subscription> get scannedSubscriptions => _scannedSubscriptions;
int _currentIndex = 0;
int get currentIndex => _currentIndex;
String? _selectedCategoryId;
String? get selectedCategoryId => _selectedCategoryId;
final TextEditingController websiteUrlController = TextEditingController();
// 의존성
final SmsScanner _smsScanner = SmsScanner();
final SubscriptionConverter _converter = SubscriptionConverter();
final SubscriptionFilter _filter = SubscriptionFilter();
@override
void dispose() {
websiteUrlController.dispose();
super.dispose();
}
void setSelectedCategoryId(String? categoryId) {
_selectedCategoryId = categoryId;
notifyListeners();
}
void resetWebsiteUrl() {
websiteUrlController.text = '';
}
Future<void> scanSms(BuildContext context) async {
_isLoading = true;
_errorMessage = null;
_scannedSubscriptions = [];
_currentIndex = 0;
notifyListeners();
try {
// SMS 스캔 실행
Log.i('SMS 스캔 시작');
final scannedSubscriptionModels =
await _smsScanner.scanForSubscriptions();
Log.d('스캔된 구독: ${scannedSubscriptionModels.length}');
if (scannedSubscriptionModels.isNotEmpty) {
Log.d(
'첫 번째 구독: ${scannedSubscriptionModels[0].serviceName}, 반복 횟수: ${scannedSubscriptionModels[0].repeatCount}');
}
if (!context.mounted) return;
if (scannedSubscriptionModels.isEmpty) {
Log.i('스캔된 구독이 없음');
_errorMessage = AppLocalizations.of(context).subscriptionNotFound;
_isLoading = false;
notifyListeners();
return;
}
// SubscriptionModel을 Subscription으로 변환
final scannedSubscriptions =
_converter.convertModelsToSubscriptions(scannedSubscriptionModels);
// 2회 이상 반복 결제된 구독만 필터링
final repeatSubscriptions =
_filter.filterByRepeatCount(scannedSubscriptions, 2);
Log.d('반복 결제된 구독: ${repeatSubscriptions.length}');
if (repeatSubscriptions.isNotEmpty) {
Log.d(
'첫 번째 반복 구독: ${repeatSubscriptions[0].serviceName}, 반복 횟수: ${repeatSubscriptions[0].repeatCount}');
}
if (repeatSubscriptions.isEmpty) {
Log.i('반복 결제된 구독이 없음');
_errorMessage = AppLocalizations.of(context).repeatSubscriptionNotFound;
_isLoading = false;
notifyListeners();
return;
}
// 구독 목록 가져오기
final provider =
Provider.of<SubscriptionProvider>(context, listen: false);
final existingSubscriptions = provider.subscriptions;
Log.d('기존 구독: ${existingSubscriptions.length}');
// 중복 구독 필터링
final filteredSubscriptions =
_filter.filterDuplicates(repeatSubscriptions, existingSubscriptions);
Log.d('중복 제거 후 구독: ${filteredSubscriptions.length}');
if (filteredSubscriptions.isNotEmpty) {
Log.d(
'첫 번째 필터링된 구독: ${filteredSubscriptions[0].serviceName}, 반복 횟수: ${filteredSubscriptions[0].repeatCount}');
}
// 중복 제거 후 신규 구독이 없는 경우
if (filteredSubscriptions.isEmpty) {
Log.i('중복 제거 후 신규 구독이 없음');
_isLoading = false;
notifyListeners();
return;
}
_scannedSubscriptions = filteredSubscriptions;
_isLoading = false;
websiteUrlController.text = ''; // URL 입력 필드 초기화
notifyListeners();
} catch (e) {
Log.e('SMS 스캔 중 오류 발생', e);
if (context.mounted) {
_errorMessage =
AppLocalizations.of(context).smsScanErrorWithMessage(e.toString());
_isLoading = false;
notifyListeners();
}
}
}
Future<void> addCurrentSubscription(BuildContext context) async {
if (_currentIndex >= _scannedSubscriptions.length) return;
final subscription = _scannedSubscriptions[_currentIndex];
try {
final provider =
Provider.of<SubscriptionProvider>(context, listen: false);
final categoryProvider =
Provider.of<CategoryProvider>(context, listen: false);
final finalCategoryId = _selectedCategoryId ??
subscription.category ??
getDefaultCategoryId(categoryProvider);
// websiteUrl 처리
final websiteUrl = websiteUrlController.text.trim().isNotEmpty
? websiteUrlController.text.trim()
: subscription.websiteUrl;
Log.d(
'구독 추가 시도: ${subscription.serviceName}, 카테고리: $finalCategoryId, URL: $websiteUrl');
// addSubscription 호출
await provider.addSubscription(
serviceName: subscription.serviceName,
monthlyCost: subscription.monthlyCost,
billingCycle: subscription.billingCycle,
nextBillingDate: subscription.nextBillingDate,
websiteUrl: websiteUrl,
isAutoDetected: true,
repeatCount: subscription.repeatCount,
lastPaymentDate: subscription.lastPaymentDate,
categoryId: finalCategoryId,
currency: subscription.currency,
);
Log.i('구독 추가 성공: ${subscription.serviceName}');
if (!context.mounted) return;
moveToNextSubscription(context);
} catch (e) {
Log.e('구독 추가 중 오류 발생', e);
// 오류가 있어도 다음 구독으로 이동
if (!context.mounted) return;
moveToNextSubscription(context);
}
}
void skipCurrentSubscription(BuildContext context) {
final subscription = _scannedSubscriptions[_currentIndex];
Log.i('구독 건너뛰기: ${subscription.serviceName}');
moveToNextSubscription(context);
}
void moveToNextSubscription(BuildContext context) {
_currentIndex++;
websiteUrlController.text = ''; // URL 입력 필드 초기화
_selectedCategoryId = null; // 카테고리 선택 초기화
// 모든 구독을 처리했으면 홈 화면으로 이동
if (_currentIndex >= _scannedSubscriptions.length) {
navigateToHome(context);
}
notifyListeners();
}
void navigateToHome(BuildContext context) {
// NavigationProvider를 사용하여 홈 화면으로 이동
final navigationProvider =
Provider.of<NavigationProvider>(context, listen: false);
navigationProvider.updateCurrentIndex(0);
}
void resetState() {
_scannedSubscriptions = [];
_currentIndex = 0;
_errorMessage = null;
notifyListeners();
}
String getDefaultCategoryId(CategoryProvider categoryProvider) {
final otherCategory = categoryProvider.categories.firstWhere(
(cat) => cat.name == 'other',
orElse: () => categoryProvider.categories.first,
);
Log.d('기본 카테고리 설정: ${otherCategory.name} (ID: ${otherCategory.id})');
return otherCategory.id;
}
void initializeWebsiteUrl() {
if (_currentIndex < _scannedSubscriptions.length) {
final currentSub = _scannedSubscriptions[_currentIndex];
if (websiteUrlController.text.isEmpty && currentSub.websiteUrl != null) {
websiteUrlController.text = currentSub.websiteUrl!;
}
}
}
}