558 lines
18 KiB
Dart
558 lines
18 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter/foundation.dart' show kIsWeb, kDebugMode;
|
|
import 'package:provider/provider.dart';
|
|
import 'package:intl/intl.dart';
|
|
import '../providers/subscription_provider.dart';
|
|
import '../providers/category_provider.dart';
|
|
import '../providers/payment_card_provider.dart';
|
|
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 'package:permission_handler/permission_handler.dart' as permission;
|
|
|
|
/// AddSubscriptionScreen의 비즈니스 로직을 관리하는 Controller
|
|
class AddSubscriptionController {
|
|
final BuildContext context;
|
|
|
|
// Form Key
|
|
final formKey = GlobalKey<FormState>();
|
|
|
|
// Text Controllers
|
|
final serviceNameController = TextEditingController();
|
|
final monthlyCostController = TextEditingController();
|
|
final nextBillingDateController = TextEditingController();
|
|
final websiteUrlController = TextEditingController();
|
|
final eventPriceController = TextEditingController();
|
|
|
|
// Form State
|
|
String billingCycle = 'monthly';
|
|
String currency = 'KRW';
|
|
DateTime? nextBillingDate;
|
|
bool isLoading = false;
|
|
String? selectedCategoryId;
|
|
String? selectedPaymentCardId;
|
|
|
|
// Event State
|
|
bool isEventActive = false;
|
|
DateTime? eventStartDate = DateTime.now();
|
|
DateTime? eventEndDate = DateTime.now().add(const Duration(days: 30));
|
|
|
|
// Focus Nodes
|
|
final serviceNameFocus = FocusNode();
|
|
final monthlyCostFocus = FocusNode();
|
|
final billingCycleFocus = FocusNode();
|
|
final nextBillingDateFocus = FocusNode();
|
|
final websiteUrlFocus = FocusNode();
|
|
final categoryFocus = FocusNode();
|
|
final currencyFocus = FocusNode();
|
|
|
|
// Animation Controller
|
|
AnimationController? animationController;
|
|
Animation<double>? fadeAnimation;
|
|
Animation<Offset>? slideAnimation;
|
|
|
|
// Scroll Controller
|
|
final ScrollController scrollController = ScrollController();
|
|
double scrollOffset = 0;
|
|
|
|
// UI State
|
|
int currentEditingField = -1;
|
|
bool isSaveHovered = false;
|
|
|
|
// Gradient Colors
|
|
final List<Color> gradientColors = [
|
|
const Color(0xFF3B82F6),
|
|
const Color(0xFF0EA5E9),
|
|
const Color(0xFF06B6D4),
|
|
];
|
|
|
|
AddSubscriptionController({required this.context});
|
|
|
|
/// 초기화
|
|
void initialize({required TickerProvider vsync}) {
|
|
// 결제일 기본값을 오늘 날짜로 설정
|
|
nextBillingDate = DateTime.now();
|
|
|
|
// 서비스명 컨트롤러에 리스너 추가
|
|
serviceNameController.addListener(onServiceNameChanged);
|
|
|
|
// 웹사이트 URL 컨트롤러에 리스너 추가
|
|
websiteUrlController.addListener(onWebsiteUrlChanged);
|
|
|
|
// 애니메이션 컨트롤러 초기화
|
|
animationController = AnimationController(
|
|
vsync: vsync,
|
|
duration: const Duration(milliseconds: 800),
|
|
);
|
|
|
|
fadeAnimation = Tween<double>(
|
|
begin: 0.0,
|
|
end: 1.0,
|
|
).animate(CurvedAnimation(
|
|
parent: animationController!,
|
|
curve: Curves.easeIn,
|
|
));
|
|
|
|
slideAnimation = Tween<Offset>(
|
|
begin: const Offset(0.0, 0.2),
|
|
end: Offset.zero,
|
|
).animate(CurvedAnimation(
|
|
parent: animationController!,
|
|
curve: Curves.easeOut,
|
|
));
|
|
|
|
// 스크롤 리스너
|
|
scrollController.addListener(() {
|
|
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가 아직 준비되지 않은 경우 기본값 유지
|
|
}
|
|
|
|
// 기본 결제수단 설정
|
|
try {
|
|
final paymentCardProvider =
|
|
Provider.of<PaymentCardProvider>(context, listen: false);
|
|
selectedPaymentCardId = paymentCardProvider.defaultCard?.id;
|
|
} catch (_) {}
|
|
|
|
// 애니메이션 시작
|
|
animationController!.forward();
|
|
}
|
|
|
|
/// 리소스 정리
|
|
void dispose() {
|
|
// Controllers
|
|
serviceNameController.dispose();
|
|
monthlyCostController.dispose();
|
|
nextBillingDateController.dispose();
|
|
websiteUrlController.dispose();
|
|
eventPriceController.dispose();
|
|
|
|
// Focus Nodes
|
|
serviceNameFocus.dispose();
|
|
monthlyCostFocus.dispose();
|
|
billingCycleFocus.dispose();
|
|
nextBillingDateFocus.dispose();
|
|
websiteUrlFocus.dispose();
|
|
categoryFocus.dispose();
|
|
currencyFocus.dispose();
|
|
|
|
// Animation
|
|
animationController?.dispose();
|
|
|
|
// Scroll
|
|
scrollController.dispose();
|
|
}
|
|
|
|
/// 서비스명 변경시 호출
|
|
void onServiceNameChanged() {
|
|
autoSelectCategory();
|
|
}
|
|
|
|
/// 웹사이트 URL 변경시 호출
|
|
void onWebsiteUrlChanged() async {
|
|
final url = websiteUrlController.text.trim();
|
|
|
|
// URL이 비어있거나 너무 짧으면 무시
|
|
if (url.isEmpty || url.length < 5) return;
|
|
|
|
// 이미 서비스명이 입력되어 있으면 자동 매칭하지 않음
|
|
if (serviceNameController.text.isNotEmpty) return;
|
|
|
|
try {
|
|
// URL로 서비스 정보 찾기
|
|
final serviceInfo = await SubscriptionUrlMatcher.findServiceByUrl(url);
|
|
|
|
if (serviceInfo != null && context.mounted) {
|
|
// 서비스명 자동 입력
|
|
serviceNameController.text = serviceInfo.serviceName;
|
|
|
|
// 카테고리 자동 선택
|
|
final categoryProvider =
|
|
Provider.of<CategoryProvider>(context, listen: false);
|
|
final categories = categoryProvider.categories;
|
|
|
|
// 카테고리 ID로 매칭
|
|
final matchedCategory = categories.firstWhere(
|
|
(cat) =>
|
|
cat.name == serviceInfo.categoryNameKr ||
|
|
cat.name == serviceInfo.categoryNameEn,
|
|
orElse: () => categories.first,
|
|
);
|
|
|
|
selectedCategoryId = matchedCategory.id;
|
|
|
|
// 스낵바로 알림
|
|
if (context.mounted) {
|
|
AppSnackBar.showSuccess(
|
|
context: context,
|
|
message: AppLocalizations.of(context)
|
|
.serviceRecognized(serviceInfo.serviceName),
|
|
);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
if (kDebugMode) {
|
|
// ignore: avoid_print
|
|
print('AddSubscriptionController: URL 자동 매칭 중 오류 - $e');
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 카테고리 자동 선택
|
|
void autoSelectCategory() {
|
|
final categoryProvider =
|
|
Provider.of<CategoryProvider>(context, listen: false);
|
|
final categories = categoryProvider.categories;
|
|
|
|
final serviceName = serviceNameController.text.toLowerCase();
|
|
|
|
// 서비스명에 기반한 카테고리 매칭 로직
|
|
dynamic matchedCategory;
|
|
|
|
// 엔터테인먼트 관련 키워드
|
|
if (serviceName.contains('netflix') ||
|
|
serviceName.contains('youtube') ||
|
|
serviceName.contains('disney') ||
|
|
serviceName.contains('왓챠') ||
|
|
serviceName.contains('티빙') ||
|
|
serviceName.contains('웨이브') ||
|
|
serviceName.contains('coupang play') ||
|
|
serviceName.contains('쿠팡플레이')) {
|
|
matchedCategory = categories.firstWhere(
|
|
(cat) => cat.name == '엔터테인먼트',
|
|
orElse: () => categories.first,
|
|
);
|
|
}
|
|
// 음악 관련 키워드
|
|
else if (serviceName.contains('spotify') ||
|
|
serviceName.contains('apple music') ||
|
|
serviceName.contains('멜론') ||
|
|
serviceName.contains('지니') ||
|
|
serviceName.contains('플로') ||
|
|
serviceName.contains('벅스')) {
|
|
matchedCategory = categories.firstWhere(
|
|
(cat) => cat.name == 'music',
|
|
orElse: () => categories.first,
|
|
);
|
|
}
|
|
// 생산성 관련 키워드
|
|
else if (serviceName.contains('notion') ||
|
|
serviceName.contains('microsoft') ||
|
|
serviceName.contains('office') ||
|
|
serviceName.contains('google') ||
|
|
serviceName.contains('dropbox') ||
|
|
serviceName.contains('icloud') ||
|
|
serviceName.contains('adobe')) {
|
|
matchedCategory = categories.firstWhere(
|
|
(cat) => cat.name == '생산성',
|
|
orElse: () => categories.first,
|
|
);
|
|
}
|
|
// 게임 관련 키워드
|
|
else if (serviceName.contains('xbox') ||
|
|
serviceName.contains('playstation') ||
|
|
serviceName.contains('nintendo') ||
|
|
serviceName.contains('steam') ||
|
|
serviceName.contains('게임')) {
|
|
matchedCategory = categories.firstWhere(
|
|
(cat) => cat.name == '게임',
|
|
orElse: () => categories.first,
|
|
);
|
|
}
|
|
// 교육 관련 키워드
|
|
else if (serviceName.contains('coursera') ||
|
|
serviceName.contains('udemy') ||
|
|
serviceName.contains('인프런') ||
|
|
serviceName.contains('패스트캠퍼스') ||
|
|
serviceName.contains('클래스101')) {
|
|
matchedCategory = categories.firstWhere(
|
|
(cat) => cat.name == '교육',
|
|
orElse: () => categories.first,
|
|
);
|
|
}
|
|
// 쇼핑 관련 키워드
|
|
else if (serviceName.contains('쿠팡') ||
|
|
serviceName.contains('coupang') ||
|
|
serviceName.contains('amazon') ||
|
|
serviceName.contains('네이버') ||
|
|
serviceName.contains('11번가')) {
|
|
matchedCategory = categories.firstWhere(
|
|
(cat) => cat.name == '쇼핑',
|
|
orElse: () => categories.first,
|
|
);
|
|
}
|
|
|
|
if (matchedCategory != null) {
|
|
selectedCategoryId = matchedCategory.id;
|
|
}
|
|
}
|
|
|
|
/// SMS 스캔
|
|
Future<void> scanSMS({required Function setState}) async {
|
|
if (kIsWeb) return;
|
|
|
|
setState(() => isLoading = true);
|
|
|
|
try {
|
|
final ctx = context;
|
|
if (!await SMSService.hasSMSPermission()) {
|
|
final granted = await SMSService.requestSMSPermission();
|
|
if (!ctx.mounted) return;
|
|
if (!granted) {
|
|
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 (ctx.mounted) {
|
|
AppSnackBar.showWarning(
|
|
context: ctx,
|
|
message: AppLocalizations.of(ctx).noSubscriptionSmsFound,
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
final subscription = subscriptions.first;
|
|
|
|
// SMS에서 서비스 정보 추출 시도
|
|
ServiceInfo? serviceInfo;
|
|
final smsContent = subscription['smsContent'] ?? '';
|
|
|
|
if (smsContent.isNotEmpty) {
|
|
try {
|
|
serviceInfo =
|
|
await SubscriptionUrlMatcher.extractServiceFromSms(smsContent);
|
|
} catch (e) {
|
|
if (kDebugMode) {
|
|
// ignore: avoid_print
|
|
print('AddSubscriptionController: SMS 서비스 추출 실패 - $e');
|
|
}
|
|
}
|
|
}
|
|
|
|
setState(() {
|
|
// 서비스 정보가 있으면 우선 사용, 없으면 SMS에서 추출한 정보 사용
|
|
if (serviceInfo != null) {
|
|
serviceNameController.text = serviceInfo.serviceName;
|
|
websiteUrlController.text = serviceInfo.serviceUrl ?? '';
|
|
|
|
// 카테고리 자동 선택
|
|
final categoryProvider =
|
|
Provider.of<CategoryProvider>(context, listen: false);
|
|
final categories = categoryProvider.categories;
|
|
|
|
final matchedCategory = categories.firstWhere(
|
|
(cat) =>
|
|
cat.name == serviceInfo!.categoryNameKr ||
|
|
cat.name == serviceInfo.categoryNameEn,
|
|
orElse: () => categories.first,
|
|
);
|
|
|
|
selectedCategoryId = matchedCategory.id;
|
|
} else {
|
|
// 기존 로직 사용
|
|
serviceNameController.text = subscription['serviceName'] ?? '';
|
|
}
|
|
|
|
// 비용 처리 및 통화 단위 자동 감지
|
|
final costValue = subscription['monthlyCost']?.toString() ?? '';
|
|
|
|
if (costValue.isNotEmpty) {
|
|
// 달러 표시가 있거나 소수점이 있으면 달러로 판단
|
|
if (costValue.contains('\$') || costValue.contains('.')) {
|
|
currency = 'USD';
|
|
String numericValue = costValue.replaceAll('\$', '').trim();
|
|
if (!numericValue.contains('.')) {
|
|
numericValue = '$numericValue.00';
|
|
}
|
|
final double parsedValue =
|
|
double.tryParse(numericValue.replaceAll(',', '')) ?? 0.0;
|
|
monthlyCostController.text =
|
|
NumberFormat('#,##0.00').format(parsedValue);
|
|
} else {
|
|
currency = 'KRW';
|
|
String numericValue =
|
|
costValue.replaceAll('₩', '').replaceAll(',', '').trim();
|
|
final int parsedValue = int.tryParse(numericValue) ?? 0;
|
|
monthlyCostController.text =
|
|
NumberFormat.decimalPattern().format(parsedValue);
|
|
}
|
|
} else {
|
|
monthlyCostController.text = '';
|
|
}
|
|
|
|
billingCycle = subscription['billingCycle'] ?? '월간';
|
|
nextBillingDate = subscription['nextBillingDate'] != null
|
|
? DateTime.parse(subscription['nextBillingDate'])
|
|
: DateTime.now();
|
|
|
|
// 서비스 정보가 없고 서비스명이 있으면 URL 자동 매칭 시도
|
|
if (serviceInfo == null &&
|
|
subscription['serviceName'] != null &&
|
|
subscription['serviceName'].isNotEmpty) {
|
|
final suggestedUrl =
|
|
SubscriptionUrlMatcher.suggestUrl(subscription['serviceName']);
|
|
if (suggestedUrl != null && websiteUrlController.text.isEmpty) {
|
|
websiteUrlController.text = suggestedUrl;
|
|
}
|
|
|
|
// 서비스명 기반으로 카테고리 자동 선택
|
|
autoSelectCategory();
|
|
}
|
|
|
|
// 애니메이션 재생
|
|
animationController!.reset();
|
|
animationController!.forward();
|
|
});
|
|
} catch (e) {
|
|
if (context.mounted) {
|
|
AppSnackBar.showError(
|
|
context: context,
|
|
message: AppLocalizations.of(context)
|
|
.smsScanErrorWithMessage(e.toString()),
|
|
);
|
|
}
|
|
} finally {
|
|
if (context.mounted) {
|
|
setState(() => isLoading = false);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 구독 저장
|
|
Future<void> saveSubscription({required Function setState}) async {
|
|
if (formKey.currentState!.validate() && nextBillingDate != null) {
|
|
setState(() {
|
|
isLoading = true;
|
|
});
|
|
|
|
try {
|
|
// 콤마 제거하고 숫자만 추출
|
|
final monthlyCost =
|
|
double.parse(monthlyCostController.text.replaceAll(',', ''));
|
|
|
|
// 이벤트 가격 파싱
|
|
double? eventPrice;
|
|
if (isEventActive && eventPriceController.text.isNotEmpty) {
|
|
eventPrice =
|
|
double.tryParse(eventPriceController.text.replaceAll(',', ''));
|
|
}
|
|
|
|
// 선택일이 오늘(또는 과거)이면 결제 주기에 맞춰 다음 회차로 보정하여 저장 + 영업일 이월
|
|
final originalDateOnly = DateTime(
|
|
nextBillingDate!.year,
|
|
nextBillingDate!.month,
|
|
nextBillingDate!.day,
|
|
);
|
|
var adjustedNext =
|
|
BillingDateUtil.ensureFutureDate(originalDateOnly, billingCycle);
|
|
|
|
await Provider.of<SubscriptionProvider>(context, listen: false)
|
|
.addSubscription(
|
|
serviceName: serviceNameController.text.trim(),
|
|
monthlyCost: monthlyCost,
|
|
billingCycle: billingCycle,
|
|
nextBillingDate: adjustedNext,
|
|
websiteUrl: websiteUrlController.text.trim(),
|
|
categoryId: selectedCategoryId,
|
|
paymentCardId: selectedPaymentCardId,
|
|
currency: currency,
|
|
isEventActive: isEventActive,
|
|
eventStartDate: eventStartDate,
|
|
eventEndDate: eventEndDate,
|
|
eventPrice: eventPrice,
|
|
);
|
|
|
|
// 자동 보정이 발생했으면 안내
|
|
if (adjustedNext.isAfter(originalDateOnly)) {
|
|
if (context.mounted) {
|
|
AppSnackBar.showInfo(
|
|
context: context,
|
|
message: AppLocalizations.of(context).nextBillingDateAdjusted,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (context.mounted) {
|
|
Navigator.pop(context, true); // 성공 여부 반환
|
|
}
|
|
} catch (e) {
|
|
setState(() {
|
|
isLoading = false;
|
|
});
|
|
|
|
if (context.mounted) {
|
|
AppSnackBar.showError(
|
|
context: context,
|
|
message:
|
|
AppLocalizations.of(context).saveErrorWithMessage(e.toString()),
|
|
);
|
|
}
|
|
}
|
|
} else {
|
|
scrollController.animateTo(
|
|
0.0,
|
|
duration: const Duration(milliseconds: 300),
|
|
curve: Curves.easeOut,
|
|
);
|
|
}
|
|
}
|
|
}
|