- ExchangeRateService에 JPY, CNY 환율 지원 추가 - 구독 서비스별 다국어 표시 이름 지원 - 분석 화면 차트 및 UI/UX 개선 - 설정 화면 전면 리팩토링 - SMS 스캔 기능 사용성 개선 - 전체 앱 다국어 번역 확대 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
938 lines
33 KiB
Dart
938 lines
33 KiB
Dart
import 'package:flutter/material.dart';
|
|
import '../services/sms_scanner.dart';
|
|
import '../providers/subscription_provider.dart';
|
|
import '../providers/navigation_provider.dart';
|
|
import '../providers/locale_provider.dart';
|
|
import 'package:provider/provider.dart';
|
|
import '../models/subscription.dart';
|
|
import '../models/subscription_model.dart';
|
|
import '../services/subscription_url_matcher.dart';
|
|
import '../services/currency_util.dart';
|
|
import 'package:intl/intl.dart'; // NumberFormat을 사용하기 위한 import 추가
|
|
import '../widgets/glassmorphism_card.dart';
|
|
import '../widgets/themed_text.dart';
|
|
import '../theme/app_colors.dart';
|
|
import '../widgets/common/snackbar/app_snackbar.dart';
|
|
import '../widgets/common/buttons/primary_button.dart';
|
|
import '../widgets/common/buttons/secondary_button.dart';
|
|
import '../widgets/common/form_fields/base_text_field.dart';
|
|
import '../providers/category_provider.dart';
|
|
import '../models/category_model.dart';
|
|
import '../widgets/common/form_fields/category_selector.dart';
|
|
import '../widgets/native_ad_widget.dart';
|
|
import '../l10n/app_localizations.dart';
|
|
|
|
class SmsScanScreen extends StatefulWidget {
|
|
const SmsScanScreen({super.key});
|
|
|
|
@override
|
|
State<SmsScanScreen> createState() => _SmsScanScreenState();
|
|
}
|
|
|
|
class _SmsScanScreenState extends State<SmsScanScreen> {
|
|
bool _isLoading = false;
|
|
String? _errorMessage;
|
|
final SmsScanner _smsScanner = SmsScanner();
|
|
|
|
// 스캔한 구독 목록
|
|
List<Subscription> _scannedSubscriptions = [];
|
|
|
|
// 현재 표시 중인 구독 인덱스
|
|
int _currentIndex = 0;
|
|
|
|
// 웹사이트 URL 컨트롤러
|
|
final TextEditingController _websiteUrlController = TextEditingController();
|
|
|
|
// 선택된 카테고리 ID 저장
|
|
String? _selectedCategoryId;
|
|
|
|
@override
|
|
void dispose() {
|
|
_websiteUrlController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
// SMS 스캔 실행
|
|
Future<void> _scanSms() async {
|
|
setState(() {
|
|
_isLoading = true;
|
|
_errorMessage = null;
|
|
_scannedSubscriptions = [];
|
|
_currentIndex = 0;
|
|
});
|
|
|
|
try {
|
|
// SMS 스캔 실행
|
|
print('SMS 스캔 시작');
|
|
final scannedSubscriptionModels =
|
|
await _smsScanner.scanForSubscriptions();
|
|
print('스캔된 구독: ${scannedSubscriptionModels.length}개');
|
|
|
|
if (scannedSubscriptionModels.isNotEmpty) {
|
|
print(
|
|
'첫 번째 구독: ${scannedSubscriptionModels[0].serviceName}, 반복 횟수: ${scannedSubscriptionModels[0].repeatCount}');
|
|
}
|
|
|
|
if (!mounted) return;
|
|
|
|
if (scannedSubscriptionModels.isEmpty) {
|
|
print('스캔된 구독이 없음');
|
|
setState(() {
|
|
_errorMessage = AppLocalizations.of(context).subscriptionNotFound;
|
|
_isLoading = false;
|
|
});
|
|
return;
|
|
}
|
|
|
|
// SubscriptionModel을 Subscription으로 변환
|
|
final scannedSubscriptions =
|
|
_convertModelsToSubscriptions(scannedSubscriptionModels);
|
|
|
|
// 2회 이상 반복 결제된 구독만 필터링
|
|
final repeatSubscriptions =
|
|
scannedSubscriptions.where((sub) => sub.repeatCount >= 2).toList();
|
|
print('반복 결제된 구독: ${repeatSubscriptions.length}개');
|
|
|
|
if (repeatSubscriptions.isNotEmpty) {
|
|
print(
|
|
'첫 번째 반복 구독: ${repeatSubscriptions[0].serviceName}, 반복 횟수: ${repeatSubscriptions[0].repeatCount}');
|
|
}
|
|
|
|
if (repeatSubscriptions.isEmpty) {
|
|
print('반복 결제된 구독이 없음');
|
|
setState(() {
|
|
_errorMessage = AppLocalizations.of(context).repeatSubscriptionNotFound;
|
|
_isLoading = false;
|
|
});
|
|
return;
|
|
}
|
|
|
|
// 구독 목록 가져오기
|
|
final provider =
|
|
Provider.of<SubscriptionProvider>(context, listen: false);
|
|
final existingSubscriptions = provider.subscriptions;
|
|
print('기존 구독: ${existingSubscriptions.length}개');
|
|
|
|
// 중복 구독 필터링
|
|
final filteredSubscriptions =
|
|
_filterDuplicates(repeatSubscriptions, existingSubscriptions);
|
|
print('중복 제거 후 구독: ${filteredSubscriptions.length}개');
|
|
|
|
if (filteredSubscriptions.isNotEmpty) {
|
|
print(
|
|
'첫 번째 필터링된 구독: ${filteredSubscriptions[0].serviceName}, 반복 횟수: ${filteredSubscriptions[0].repeatCount}');
|
|
}
|
|
|
|
// 중복 제거 후 신규 구독이 없는 경우
|
|
if (filteredSubscriptions.isEmpty) {
|
|
print('중복 제거 후 신규 구독이 없음');
|
|
setState(() {
|
|
_isLoading = false;
|
|
});
|
|
|
|
// 스낵바로 안내 메시지 표시
|
|
if (mounted) {
|
|
AppSnackBar.showInfo(
|
|
context: context,
|
|
message: AppLocalizations.of(context).newSubscriptionNotFound,
|
|
icon: Icons.search_off_rounded,
|
|
);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
setState(() {
|
|
_scannedSubscriptions = filteredSubscriptions;
|
|
_isLoading = false;
|
|
_websiteUrlController.text = ''; // URL 입력 필드 초기화
|
|
});
|
|
} catch (e) {
|
|
print('SMS 스캔 중 오류 발생: $e');
|
|
if (mounted) {
|
|
setState(() {
|
|
_errorMessage = AppLocalizations.of(context).smsScanErrorWithMessage(e.toString());
|
|
_isLoading = false;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// SubscriptionModel 리스트를 Subscription 리스트로 변환
|
|
List<Subscription> _convertModelsToSubscriptions(
|
|
List<SubscriptionModel> models) {
|
|
final result = <Subscription>[];
|
|
|
|
for (var model in models) {
|
|
try {
|
|
// 모델의 필드가 null인 경우 기본값 사용
|
|
result.add(Subscription(
|
|
id: model.id,
|
|
serviceName: model.serviceName,
|
|
monthlyCost: model.monthlyCost,
|
|
billingCycle: model.billingCycle,
|
|
nextBillingDate: model.nextBillingDate,
|
|
category: model.categoryId, // categoryId를 category로 올바르게 매핑
|
|
repeatCount: model.repeatCount > 0
|
|
? model.repeatCount
|
|
: 1, // 반복 횟수가 0 이하인 경우 기본값 1 사용
|
|
lastPaymentDate: model.lastPaymentDate,
|
|
websiteUrl: model.websiteUrl,
|
|
currency: model.currency, // 통화 단위 정보 추가
|
|
));
|
|
|
|
print(
|
|
'모델 변환 성공: ${model.serviceName}, 카테고리ID: ${model.categoryId}, URL: ${model.websiteUrl}, 통화: ${model.currency}');
|
|
} catch (e) {
|
|
print('모델 변환 중 오류 발생: $e');
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
// 중복 구독 필터링 (서비스명과 금액이 같으면 중복으로 간주)
|
|
List<Subscription> _filterDuplicates(
|
|
List<Subscription> scanned, List<SubscriptionModel> existing) {
|
|
print(
|
|
'_filterDuplicates: 스캔된 구독 ${scanned.length}개, 기존 구독 ${existing.length}개');
|
|
|
|
// 중복되지 않은 구독만 필터링
|
|
final nonDuplicates = scanned.where((scannedSub) {
|
|
|
|
// 서비스명과 금액이 동일한 기존 구독 찾기
|
|
final hasDuplicate = existing.any((existingSub) =>
|
|
existingSub.serviceName.toLowerCase() ==
|
|
scannedSub.serviceName.toLowerCase() &&
|
|
existingSub.monthlyCost == scannedSub.monthlyCost);
|
|
|
|
if (hasDuplicate) {
|
|
print('_filterDuplicates: 중복 발견 - ${scannedSub.serviceName}');
|
|
}
|
|
|
|
// 중복이 없으면 true 반환
|
|
return !hasDuplicate;
|
|
}).toList();
|
|
|
|
print('_filterDuplicates: 중복 제거 후 ${nonDuplicates.length}개');
|
|
|
|
// 각 구독에 웹사이트 URL 자동 매칭 시도
|
|
final result = <Subscription>[];
|
|
|
|
for (int i = 0; i < nonDuplicates.length; i++) {
|
|
final subscription = nonDuplicates[i];
|
|
|
|
String? websiteUrl = subscription.websiteUrl;
|
|
|
|
if (websiteUrl == null || websiteUrl.isEmpty) {
|
|
websiteUrl =
|
|
SubscriptionUrlMatcher.suggestUrl(subscription.serviceName);
|
|
print(
|
|
'_filterDuplicates: URL 자동 매칭 시도 - ${subscription.serviceName}, 결과: ${websiteUrl ?? "매칭 실패"}');
|
|
}
|
|
|
|
try {
|
|
// 유효성 검사
|
|
if (subscription.serviceName.isEmpty) {
|
|
print('_filterDuplicates: 서비스명이 비어 있습니다. 건너뜁니다.');
|
|
continue;
|
|
}
|
|
|
|
if (subscription.monthlyCost <= 0) {
|
|
print('_filterDuplicates: 월 비용이 0 이하입니다. 건너뜁니다.');
|
|
continue;
|
|
}
|
|
|
|
// Subscription 객체에 URL 설정 (새 객체 생성)
|
|
result.add(Subscription(
|
|
id: subscription.id,
|
|
serviceName: subscription.serviceName,
|
|
monthlyCost: subscription.monthlyCost,
|
|
billingCycle: subscription.billingCycle,
|
|
nextBillingDate: subscription.nextBillingDate,
|
|
category: subscription.category,
|
|
notes: subscription.notes,
|
|
repeatCount:
|
|
subscription.repeatCount > 0 ? subscription.repeatCount : 1,
|
|
lastPaymentDate: subscription.lastPaymentDate,
|
|
websiteUrl: websiteUrl,
|
|
currency: subscription.currency, // 통화 단위 정보 추가
|
|
));
|
|
|
|
print(
|
|
'_filterDuplicates: URL 설정 - ${subscription.serviceName}, URL: ${websiteUrl ?? "없음"}, 카테고리: ${subscription.category ?? "없음"}, 통화: ${subscription.currency}');
|
|
} catch (e) {
|
|
print('_filterDuplicates: 구독 객체 생성 중 오류 발생: $e');
|
|
}
|
|
}
|
|
|
|
print('_filterDuplicates: URL 설정 완료, 최종 ${result.length}개 구독');
|
|
return result;
|
|
}
|
|
|
|
// 현재 구독 추가
|
|
Future<void> _addCurrentSubscription() async {
|
|
if (_scannedSubscriptions.isEmpty ||
|
|
_currentIndex >= _scannedSubscriptions.length) {
|
|
print(
|
|
'오류: 인덱스가 범위를 벗어났습니다. (index: $_currentIndex, size: ${_scannedSubscriptions.length})');
|
|
return;
|
|
}
|
|
|
|
final subscription = _scannedSubscriptions[_currentIndex];
|
|
|
|
final provider = Provider.of<SubscriptionProvider>(context, listen: false);
|
|
|
|
// 날짜가 과거면 다음 결제일을 조정
|
|
final now = DateTime.now();
|
|
DateTime nextBillingDate = subscription.nextBillingDate;
|
|
|
|
if (nextBillingDate.isBefore(now)) {
|
|
// 주기에 따라 다음 결제일 조정
|
|
if (subscription.billingCycle == '월간') {
|
|
// 현재 달의 결제일
|
|
int day = nextBillingDate.day;
|
|
// 현재 월의 마지막 날을 초과하는 경우 조정
|
|
final lastDay = DateTime(now.year, now.month + 1, 0).day;
|
|
if (day > lastDay) {
|
|
day = lastDay;
|
|
}
|
|
|
|
DateTime adjustedDate = DateTime(now.year, now.month, day);
|
|
|
|
// 현재 날짜보다 이전이라면 다음 달로 설정
|
|
if (adjustedDate.isBefore(now)) {
|
|
// 다음 달의 마지막 날을 초과하는 경우 조정
|
|
final nextMonthLastDay = DateTime(now.year, now.month + 2, 0).day;
|
|
if (day > nextMonthLastDay) {
|
|
day = nextMonthLastDay;
|
|
}
|
|
adjustedDate = DateTime(now.year, now.month + 1, day);
|
|
}
|
|
|
|
nextBillingDate = adjustedDate;
|
|
} else if (subscription.billingCycle == '연간') {
|
|
// 현재 년도의 결제일
|
|
int day = nextBillingDate.day;
|
|
// 해당 월의 마지막 날을 초과하는 경우 조정
|
|
final lastDay = DateTime(now.year, nextBillingDate.month + 1, 0).day;
|
|
if (day > lastDay) {
|
|
day = lastDay;
|
|
}
|
|
|
|
DateTime adjustedDate = DateTime(now.year, nextBillingDate.month, day);
|
|
|
|
// 현재 날짜보다 이전이라면 다음 해로 설정
|
|
if (adjustedDate.isBefore(now)) {
|
|
// 다음 해 해당 월의 마지막 날을 초과하는 경우 조정
|
|
final nextYearLastDay =
|
|
DateTime(now.year + 1, nextBillingDate.month + 1, 0).day;
|
|
if (day > nextYearLastDay) {
|
|
day = nextYearLastDay;
|
|
}
|
|
adjustedDate = DateTime(now.year + 1, nextBillingDate.month, day);
|
|
}
|
|
|
|
nextBillingDate = adjustedDate;
|
|
} else if (subscription.billingCycle == '주간') {
|
|
// 현재 날짜에서 가장 가까운 다음 주 같은 요일
|
|
final daysUntilNext = 7 - (now.weekday - nextBillingDate.weekday) % 7;
|
|
nextBillingDate =
|
|
now.add(Duration(days: daysUntilNext == 0 ? 7 : daysUntilNext));
|
|
}
|
|
}
|
|
|
|
// 웹사이트 URL이 비어있으면 자동 매칭 시도
|
|
String? websiteUrl = _websiteUrlController.text.trim();
|
|
if (websiteUrl.isEmpty && subscription.websiteUrl != null) {
|
|
websiteUrl = subscription.websiteUrl;
|
|
print('구독 추가: 기존 URL 사용 - ${websiteUrl ?? "없음"}');
|
|
} else if (websiteUrl.isEmpty) {
|
|
try {
|
|
websiteUrl =
|
|
SubscriptionUrlMatcher.suggestUrl(subscription.serviceName);
|
|
print(
|
|
'구독 추가: URL 자동 매칭 - ${subscription.serviceName} -> ${websiteUrl ?? "매칭 실패"}');
|
|
} catch (e) {
|
|
print('구독 추가: URL 자동 매칭 실패 - $e');
|
|
websiteUrl = null;
|
|
}
|
|
} else {
|
|
print('구독 추가: 사용자 입력 URL 사용 - $websiteUrl');
|
|
}
|
|
|
|
try {
|
|
print(
|
|
'구독 추가 시도 - 서비스명: ${subscription.serviceName}, 비용: ${subscription.monthlyCost}, 반복 횟수: ${subscription.repeatCount}');
|
|
|
|
// 반복 횟수가 0 이하인 경우 기본값 1 사용
|
|
final int safeRepeatCount =
|
|
subscription.repeatCount > 0 ? subscription.repeatCount : 1;
|
|
|
|
// 카테고리 설정 로직
|
|
final categoryId = _selectedCategoryId ?? subscription.category ?? _getDefaultCategoryId();
|
|
print('카테고리 설정 - 선택된: $_selectedCategoryId, 자동매칭: ${subscription.category}, 최종: $categoryId');
|
|
|
|
await provider.addSubscription(
|
|
serviceName: subscription.serviceName,
|
|
monthlyCost: subscription.monthlyCost,
|
|
billingCycle: subscription.billingCycle,
|
|
nextBillingDate: nextBillingDate,
|
|
websiteUrl: websiteUrl,
|
|
isAutoDetected: true,
|
|
repeatCount: safeRepeatCount,
|
|
lastPaymentDate: subscription.lastPaymentDate,
|
|
categoryId: categoryId,
|
|
currency: subscription.currency, // 통화 단위 정보 추가
|
|
);
|
|
|
|
print('구독 추가 성공');
|
|
|
|
// 성공 메시지 표시
|
|
if (mounted) {
|
|
AppSnackBar.showSuccess(
|
|
context: context,
|
|
message: AppLocalizations.of(context).subscriptionAddedWithName(subscription.serviceName),
|
|
);
|
|
}
|
|
|
|
// 다음 구독으로 이동
|
|
_moveToNextSubscription();
|
|
} catch (e) {
|
|
print('구독 추가 중 오류 발생: $e');
|
|
if (mounted) {
|
|
AppSnackBar.showError(
|
|
context: context,
|
|
message: AppLocalizations.of(context).subscriptionAddErrorWithMessage(e.toString()),
|
|
);
|
|
|
|
// 오류가 있어도 다음 구독으로 이동
|
|
_moveToNextSubscription();
|
|
}
|
|
}
|
|
}
|
|
|
|
// 현재 구독 건너뛰기
|
|
void _skipCurrentSubscription() {
|
|
final subscription = _scannedSubscriptions[_currentIndex];
|
|
|
|
if (mounted) {
|
|
AppSnackBar.showInfo(
|
|
context: context,
|
|
message: AppLocalizations.of(context).subscriptionSkipped(subscription.serviceName),
|
|
icon: Icons.skip_next_rounded,
|
|
);
|
|
}
|
|
|
|
_moveToNextSubscription();
|
|
}
|
|
|
|
// 다음 구독으로 이동
|
|
void _moveToNextSubscription() {
|
|
setState(() {
|
|
_currentIndex++;
|
|
_websiteUrlController.text = ''; // URL 입력 필드 초기화
|
|
_selectedCategoryId = null; // 카테고리 선택 초기화
|
|
|
|
// 모든 구독을 처리했으면 홈 화면으로 이동
|
|
if (_currentIndex >= _scannedSubscriptions.length) {
|
|
_navigateToHome();
|
|
}
|
|
});
|
|
}
|
|
|
|
// 홈 화면으로 이동
|
|
void _navigateToHome() {
|
|
// NavigationProvider를 사용하여 홈 화면으로 이동
|
|
final navigationProvider = Provider.of<NavigationProvider>(context, listen: false);
|
|
navigationProvider.updateCurrentIndex(0);
|
|
|
|
// 완료 메시지 표시
|
|
AppSnackBar.showSuccess(
|
|
context: context,
|
|
message: AppLocalizations.of(context).allSubscriptionsProcessed,
|
|
);
|
|
}
|
|
|
|
// 날짜 상태 텍스트 가져오기
|
|
String _getNextBillingText(DateTime date) {
|
|
final now = DateTime.now();
|
|
|
|
if (date.isBefore(now)) {
|
|
// 주기에 따라 다음 결제일 예측
|
|
if (_currentIndex >= _scannedSubscriptions.length) {
|
|
return '다음 결제일 확인 필요';
|
|
}
|
|
|
|
final subscription = _scannedSubscriptions[_currentIndex];
|
|
if (subscription.billingCycle == '월간') {
|
|
// 이번 달 또는 다음 달 같은 날짜
|
|
int day = date.day;
|
|
// 현재 월의 마지막 날을 초과하는 경우 조정
|
|
final lastDay = DateTime(now.year, now.month + 1, 0).day;
|
|
if (day > lastDay) {
|
|
day = lastDay;
|
|
}
|
|
|
|
DateTime adjusted = DateTime(now.year, now.month, day);
|
|
if (adjusted.isBefore(now)) {
|
|
// 다음 달의 마지막 날을 초과하는 경우 조정
|
|
final nextMonthLastDay = DateTime(now.year, now.month + 2, 0).day;
|
|
if (day > nextMonthLastDay) {
|
|
day = nextMonthLastDay;
|
|
}
|
|
adjusted = DateTime(now.year, now.month + 1, day);
|
|
}
|
|
|
|
final daysUntil = adjusted.difference(now).inDays;
|
|
return AppLocalizations.of(context).nextBillingDateEstimated(AppLocalizations.of(context).formatDate(adjusted), daysUntil);
|
|
} else if (subscription.billingCycle == '연간') {
|
|
// 올해 또는 내년 같은 날짜
|
|
int day = date.day;
|
|
// 해당 월의 마지막 날을 초과하는 경우 조정
|
|
final lastDay = DateTime(now.year, date.month + 1, 0).day;
|
|
if (day > lastDay) {
|
|
day = lastDay;
|
|
}
|
|
|
|
DateTime adjusted = DateTime(now.year, date.month, day);
|
|
if (adjusted.isBefore(now)) {
|
|
// 다음 해 해당 월의 마지막 날을 초과하는 경우 조정
|
|
final nextYearLastDay = DateTime(now.year + 1, date.month + 1, 0).day;
|
|
if (day > nextYearLastDay) {
|
|
day = nextYearLastDay;
|
|
}
|
|
adjusted = DateTime(now.year + 1, date.month, day);
|
|
}
|
|
|
|
final daysUntil = adjusted.difference(now).inDays;
|
|
return AppLocalizations.of(context).nextBillingDateEstimated(AppLocalizations.of(context).formatDate(adjusted), daysUntil);
|
|
} else {
|
|
return '다음 결제일 확인 필요 (과거 날짜)';
|
|
}
|
|
} else {
|
|
// 미래 날짜인 경우
|
|
final daysUntil = date.difference(now).inDays;
|
|
return AppLocalizations.of(context).nextBillingDateInfo(AppLocalizations.of(context).formatDate(date), daysUntil);
|
|
}
|
|
}
|
|
|
|
// 날짜 포맷 함수
|
|
String _formatDate(DateTime date) {
|
|
return '${date.year}년 ${date.month}월 ${date.day}일';
|
|
}
|
|
|
|
// 결제 반복 횟수 텍스트
|
|
String _getRepeatCountText(int count) {
|
|
return AppLocalizations.of(context).repeatCountDetected(count);
|
|
}
|
|
|
|
// 카테고리 칩 빌드
|
|
Widget _buildCategoryChip(String? categoryId, CategoryProvider categoryProvider) {
|
|
final category = categoryId != null
|
|
? categoryProvider.getCategoryById(categoryId)
|
|
: null;
|
|
|
|
// 카테고리가 없으면 기타 카테고리 찾기
|
|
final defaultCategory = category ?? categoryProvider.categories.firstWhere(
|
|
(cat) => cat.name == 'other',
|
|
orElse: () => categoryProvider.categories.first,
|
|
);
|
|
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
|
decoration: BoxDecoration(
|
|
color: AppColors.navyGray.withValues(alpha: 0.1),
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
// 카테고리 아이콘 표시
|
|
Icon(
|
|
_getCategoryIcon(defaultCategory),
|
|
size: 16,
|
|
color: AppColors.darkNavy,
|
|
),
|
|
const SizedBox(width: 6),
|
|
ThemedText(
|
|
categoryProvider.getLocalizedCategoryName(context, defaultCategory.name),
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w500,
|
|
forceDark: true,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
|
|
// 카테고리 아이콘 반환
|
|
IconData _getCategoryIcon(CategoryModel category) {
|
|
switch (category.name) {
|
|
case 'music':
|
|
return Icons.music_note_rounded;
|
|
case 'ottVideo':
|
|
return Icons.movie_filter_rounded;
|
|
case 'storageCloud':
|
|
return Icons.cloud_outlined;
|
|
case 'telecomInternetTv':
|
|
return Icons.wifi_rounded;
|
|
case 'lifestyle':
|
|
return Icons.home_outlined;
|
|
case 'shoppingEcommerce':
|
|
return Icons.shopping_cart_outlined;
|
|
case 'programming':
|
|
return Icons.code_rounded;
|
|
case 'collaborationOffice':
|
|
return Icons.business_center_outlined;
|
|
case 'aiService':
|
|
return Icons.smart_toy_outlined;
|
|
case 'other':
|
|
default:
|
|
return Icons.category_outlined;
|
|
}
|
|
}
|
|
|
|
// 기본 카테고리 ID (기타) 반환
|
|
String _getDefaultCategoryId() {
|
|
final categoryProvider = Provider.of<CategoryProvider>(context, listen: false);
|
|
final otherCategory = categoryProvider.categories.firstWhere(
|
|
(cat) => cat.name == 'other',
|
|
orElse: () => categoryProvider.categories.first, // 만약 "기타"가 없으면 첫 번째 카테고리
|
|
);
|
|
print('기본 카테고리 설정: ${otherCategory.name} (ID: ${otherCategory.id})');
|
|
return otherCategory.id;
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return SingleChildScrollView(
|
|
padding: EdgeInsets.zero,
|
|
child: Column(
|
|
children: [
|
|
// toolbar 높이 추가
|
|
SizedBox(
|
|
height: kToolbarHeight + MediaQuery.of(context).padding.top,
|
|
),
|
|
_isLoading
|
|
? _buildLoadingState()
|
|
: (_scannedSubscriptions.isEmpty
|
|
? _buildInitialState()
|
|
: _buildSubscriptionState()),
|
|
// FloatingNavigationBar를 위한 충분한 하단 여백
|
|
SizedBox(
|
|
height: 120 + MediaQuery.of(context).padding.bottom,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
// 로딩 상태 UI
|
|
Widget _buildLoadingState() {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
|
child: Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
CircularProgressIndicator(
|
|
valueColor: AlwaysStoppedAnimation<Color>(AppColors.primaryColor),
|
|
),
|
|
const SizedBox(height: 16),
|
|
ThemedText(AppLocalizations.of(context).scanningMessages, forceDark: true),
|
|
const SizedBox(height: 8),
|
|
ThemedText(AppLocalizations.of(context).findingSubscriptions, opacity: 0.7, forceDark: true),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
// 초기 상태 UI
|
|
Widget _buildInitialState() {
|
|
return Column(
|
|
children: [
|
|
// 광고 위젯 추가
|
|
const NativeAdWidget(key: ValueKey('sms_scan_start_ad')),
|
|
const SizedBox(height: 48),
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
if (_errorMessage != null)
|
|
Padding(
|
|
padding: const EdgeInsets.only(bottom: 24.0),
|
|
child: ThemedText(
|
|
_errorMessage!,
|
|
color: Colors.red,
|
|
textAlign: TextAlign.center,
|
|
),
|
|
),
|
|
ThemedText(
|
|
AppLocalizations.of(context).findRepeatSubscriptions,
|
|
fontSize: 20,
|
|
fontWeight: FontWeight.bold,
|
|
forceDark: true,
|
|
),
|
|
const SizedBox(height: 16),
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
|
child: ThemedText(
|
|
AppLocalizations.of(context).scanTextMessages,
|
|
textAlign: TextAlign.center,
|
|
opacity: 0.7,
|
|
forceDark: true,
|
|
),
|
|
),
|
|
const SizedBox(height: 32),
|
|
PrimaryButton(
|
|
text: AppLocalizations.of(context).startScanning,
|
|
icon: Icons.search_rounded,
|
|
onPressed: _scanSms,
|
|
width: 200,
|
|
height: 56,
|
|
backgroundColor: AppColors.primaryColor,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
// 구독 표시 상태 UI
|
|
Widget _buildSubscriptionState() {
|
|
if (_currentIndex >= _scannedSubscriptions.length) {
|
|
// 처리 완료 후 초기 상태로 복귀
|
|
_scannedSubscriptions = [];
|
|
_currentIndex = 0;
|
|
return _buildInitialState(); // 스캔 버튼이 있는 초기 화면으로 돌아감
|
|
}
|
|
|
|
final subscription = _scannedSubscriptions[_currentIndex];
|
|
final categoryProvider = Provider.of<CategoryProvider>(context, listen: false);
|
|
|
|
// 구독 리스트 카드를 표시할 때 URL 필드 자동 설정
|
|
if (_websiteUrlController.text.isEmpty && subscription.websiteUrl != null) {
|
|
_websiteUrlController.text = subscription.websiteUrl!;
|
|
}
|
|
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
// 광고 위젯 추가
|
|
const NativeAdWidget(key: ValueKey('sms_scan_result_ad')),
|
|
const SizedBox(height: 16),
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
// 진행 상태 표시
|
|
LinearProgressIndicator(
|
|
value: (_currentIndex + 1) / _scannedSubscriptions.length,
|
|
backgroundColor: AppColors.navyGray.withValues(alpha: 0.2),
|
|
valueColor: AlwaysStoppedAnimation<Color>(
|
|
Theme.of(context).colorScheme.primary),
|
|
),
|
|
const SizedBox(height: 8),
|
|
ThemedText(
|
|
'${_currentIndex + 1}/${_scannedSubscriptions.length}',
|
|
fontWeight: FontWeight.w500,
|
|
opacity: 0.7,
|
|
forceDark: true,
|
|
),
|
|
const SizedBox(height: 24),
|
|
|
|
// 구독 정보 카드
|
|
GlassmorphismCard(
|
|
width: double.infinity,
|
|
padding: const EdgeInsets.all(16.0),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
ThemedText(
|
|
AppLocalizations.of(context).foundSubscription,
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.bold,
|
|
forceDark: true,
|
|
),
|
|
const SizedBox(height: 24),
|
|
// 서비스명
|
|
ThemedText(
|
|
AppLocalizations.of(context).serviceName,
|
|
fontWeight: FontWeight.w500,
|
|
opacity: 0.7,
|
|
forceDark: true,
|
|
),
|
|
const SizedBox(height: 4),
|
|
ThemedText(
|
|
subscription.serviceName,
|
|
fontSize: 22,
|
|
fontWeight: FontWeight.bold,
|
|
forceDark: true,
|
|
),
|
|
const SizedBox(height: 16),
|
|
|
|
// 금액 및 결제 주기
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
ThemedText(
|
|
AppLocalizations.of(context).monthlyCost,
|
|
fontWeight: FontWeight.w500,
|
|
opacity: 0.7,
|
|
forceDark: true,
|
|
),
|
|
const SizedBox(height: 4),
|
|
// 언어별 통화 표시
|
|
FutureBuilder<String>(
|
|
future: CurrencyUtil.formatAmountWithLocale(
|
|
subscription.monthlyCost,
|
|
subscription.currency,
|
|
context.read<LocaleProvider>().locale.languageCode,
|
|
),
|
|
builder: (context, snapshot) {
|
|
return ThemedText(
|
|
snapshot.data ?? '-',
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.bold,
|
|
forceDark: true,
|
|
);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
ThemedText(
|
|
AppLocalizations.of(context).billingCycle,
|
|
fontWeight: FontWeight.w500,
|
|
opacity: 0.7,
|
|
forceDark: true,
|
|
),
|
|
const SizedBox(height: 4),
|
|
ThemedText(
|
|
subscription.billingCycle,
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.w500,
|
|
forceDark: true,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 16),
|
|
|
|
// 다음 결제일
|
|
ThemedText(
|
|
AppLocalizations.of(context).nextBillingDateLabel,
|
|
fontWeight: FontWeight.w500,
|
|
opacity: 0.7,
|
|
forceDark: true,
|
|
),
|
|
const SizedBox(height: 4),
|
|
ThemedText(
|
|
_getNextBillingText(subscription.nextBillingDate),
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.w500,
|
|
forceDark: true,
|
|
),
|
|
const SizedBox(height: 16),
|
|
|
|
// 카테고리 선택
|
|
ThemedText(
|
|
AppLocalizations.of(context).category,
|
|
fontWeight: FontWeight.w500,
|
|
opacity: 0.7,
|
|
forceDark: true,
|
|
),
|
|
const SizedBox(height: 8),
|
|
CategorySelector(
|
|
categories: categoryProvider.categories,
|
|
selectedCategoryId: _selectedCategoryId ?? subscription.category,
|
|
onChanged: (categoryId) {
|
|
setState(() {
|
|
_selectedCategoryId = categoryId;
|
|
});
|
|
},
|
|
baseColor: (() {
|
|
final categoryId = _selectedCategoryId ?? subscription.category;
|
|
if (categoryId == null) return null;
|
|
final category = categoryProvider.getCategoryById(categoryId);
|
|
if (category == null) return null;
|
|
return Color(int.parse(category.color.replaceFirst('#', '0xFF')));
|
|
})(),
|
|
isGlassmorphism: true,
|
|
),
|
|
const SizedBox(height: 24),
|
|
|
|
// 웹사이트 URL 입력 필드 추가/수정
|
|
BaseTextField(
|
|
controller: _websiteUrlController,
|
|
label: AppLocalizations.of(context).websiteUrlAuto,
|
|
hintText: AppLocalizations.of(context).websiteUrlHint,
|
|
prefixIcon: Icon(
|
|
Icons.language,
|
|
color: AppColors.navyGray,
|
|
),
|
|
style: TextStyle(
|
|
color: AppColors.darkNavy,
|
|
),
|
|
fillColor: AppColors.pureWhite.withValues(alpha: 0.8),
|
|
),
|
|
const SizedBox(height: 32),
|
|
|
|
// 작업 버튼
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: SecondaryButton(
|
|
text: AppLocalizations.of(context).skip,
|
|
onPressed: _skipCurrentSubscription,
|
|
height: 48,
|
|
),
|
|
),
|
|
const SizedBox(width: 16),
|
|
Expanded(
|
|
child: PrimaryButton(
|
|
text: AppLocalizations.of(context).add,
|
|
onPressed: _addCurrentSubscription,
|
|
height: 48,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
@override
|
|
void didChangeDependencies() {
|
|
super.didChangeDependencies();
|
|
if (_scannedSubscriptions.isNotEmpty &&
|
|
_currentIndex < _scannedSubscriptions.length) {
|
|
final currentSub = _scannedSubscriptions[_currentIndex];
|
|
if (_websiteUrlController.text.isEmpty && currentSub.websiteUrl != null) {
|
|
_websiteUrlController.text = currentSub.websiteUrl!;
|
|
}
|
|
}
|
|
}
|
|
}
|