597 lines
18 KiB
Dart
597 lines
18 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter/foundation.dart' show kDebugMode;
|
|
import 'package:provider/provider.dart';
|
|
import '../models/subscription_model.dart';
|
|
import '../models/category_model.dart';
|
|
import '../providers/subscription_provider.dart';
|
|
import '../providers/category_provider.dart';
|
|
import '../providers/locale_provider.dart';
|
|
import '../services/subscription_url_matcher.dart';
|
|
import 'package:url_launcher/url_launcher.dart';
|
|
import 'package:intl/intl.dart';
|
|
import '../widgets/dialogs/delete_confirmation_dialog.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';
|
|
|
|
/// DetailScreen의 비즈니스 로직을 관리하는 Controller
|
|
class DetailScreenController extends ChangeNotifier {
|
|
final BuildContext context;
|
|
final SubscriptionModel subscription;
|
|
|
|
// Text Controllers
|
|
late TextEditingController serviceNameController;
|
|
late TextEditingController monthlyCostController;
|
|
late TextEditingController websiteUrlController;
|
|
late TextEditingController eventPriceController;
|
|
|
|
// Display Names
|
|
String? _displayName;
|
|
String? get displayName => _displayName;
|
|
|
|
// Form State
|
|
final GlobalKey<FormState> formKey = GlobalKey<FormState>();
|
|
late String _billingCycle;
|
|
late DateTime _nextBillingDate;
|
|
String? _selectedCategoryId;
|
|
late String _currency;
|
|
bool _isLoading = false;
|
|
|
|
// Event State
|
|
late bool _isEventActive;
|
|
DateTime? _eventStartDate;
|
|
DateTime? _eventEndDate;
|
|
|
|
// Getters
|
|
String get billingCycle => _billingCycle;
|
|
DateTime get nextBillingDate => _nextBillingDate;
|
|
String? get selectedCategoryId => _selectedCategoryId;
|
|
String get currency => _currency;
|
|
bool get isLoading => _isLoading;
|
|
bool get isEventActive => _isEventActive;
|
|
DateTime? get eventStartDate => _eventStartDate;
|
|
DateTime? get eventEndDate => _eventEndDate;
|
|
|
|
// Setters
|
|
set billingCycle(String value) {
|
|
if (_billingCycle != value) {
|
|
_billingCycle = value;
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
set nextBillingDate(DateTime value) {
|
|
if (_nextBillingDate != value) {
|
|
_nextBillingDate = value;
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
set selectedCategoryId(String? value) {
|
|
if (_selectedCategoryId != value) {
|
|
_selectedCategoryId = value;
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
set currency(String value) {
|
|
if (_currency != value) {
|
|
_currency = value;
|
|
_updateMonthlyCostFormat();
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
set isLoading(bool value) {
|
|
if (_isLoading != value) {
|
|
_isLoading = value;
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
set isEventActive(bool value) {
|
|
if (_isEventActive != value) {
|
|
_isEventActive = value;
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
set eventStartDate(DateTime? value) {
|
|
if (_eventStartDate != value) {
|
|
_eventStartDate = value;
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
set eventEndDate(DateTime? value) {
|
|
if (_eventEndDate != value) {
|
|
_eventEndDate = value;
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
// Focus Nodes
|
|
final serviceNameFocus = FocusNode();
|
|
final monthlyCostFocus = FocusNode();
|
|
final billingCycleFocus = FocusNode();
|
|
final nextBillingDateFocus = FocusNode();
|
|
final websiteUrlFocus = FocusNode();
|
|
final categoryFocus = FocusNode();
|
|
final currencyFocus = FocusNode();
|
|
|
|
// UI State
|
|
final ScrollController scrollController = ScrollController();
|
|
double scrollOffset = 0;
|
|
int currentEditingField = -1;
|
|
bool isDeleteHovered = false;
|
|
bool isSaveHovered = false;
|
|
bool isCancelHovered = false;
|
|
|
|
// Animation Controller
|
|
AnimationController? animationController;
|
|
Animation<double>? fadeAnimation;
|
|
Animation<Offset>? slideAnimation;
|
|
Animation<double>? rotateAnimation;
|
|
|
|
DetailScreenController({
|
|
required this.context,
|
|
required this.subscription,
|
|
});
|
|
|
|
/// 초기화
|
|
void initialize({required TickerProvider vsync}) {
|
|
// Text Controllers 초기화
|
|
serviceNameController =
|
|
TextEditingController(text: subscription.serviceName);
|
|
monthlyCostController =
|
|
TextEditingController(text: subscription.monthlyCost.toString());
|
|
websiteUrlController =
|
|
TextEditingController(text: subscription.websiteUrl ?? '');
|
|
eventPriceController = TextEditingController();
|
|
|
|
// Form State 초기화
|
|
_billingCycle = subscription.billingCycle;
|
|
_nextBillingDate = subscription.nextBillingDate;
|
|
_selectedCategoryId = subscription.categoryId;
|
|
_currency = subscription.currency;
|
|
|
|
// Event State 초기화
|
|
_isEventActive = subscription.isEventActive;
|
|
_eventStartDate = subscription.eventStartDate;
|
|
_eventEndDate = subscription.eventEndDate;
|
|
|
|
// 이벤트 가격 초기화
|
|
if (subscription.eventPrice != null) {
|
|
if (currency == 'KRW') {
|
|
eventPriceController.text = NumberFormat.decimalPattern()
|
|
.format(subscription.eventPrice!.toInt());
|
|
} else {
|
|
eventPriceController.text =
|
|
NumberFormat('#,##0.00').format(subscription.eventPrice!);
|
|
}
|
|
}
|
|
|
|
// 통화 단위에 따른 금액 표시 형식 조정
|
|
_updateMonthlyCostFormat();
|
|
|
|
// 애니메이션 초기화
|
|
animationController = AnimationController(
|
|
duration: const Duration(milliseconds: 800),
|
|
vsync: vsync,
|
|
);
|
|
|
|
fadeAnimation = Tween<double>(
|
|
begin: 0.0,
|
|
end: 1.0,
|
|
).animate(CurvedAnimation(
|
|
parent: animationController!,
|
|
curve: Curves.easeInOut,
|
|
));
|
|
|
|
slideAnimation = Tween<Offset>(
|
|
begin: const Offset(0.0, 0.3),
|
|
end: Offset.zero,
|
|
).animate(CurvedAnimation(
|
|
parent: animationController!,
|
|
curve: Curves.easeOutCubic,
|
|
));
|
|
|
|
rotateAnimation = Tween<double>(
|
|
begin: 0.0,
|
|
end: 1.0,
|
|
).animate(CurvedAnimation(
|
|
parent: animationController!,
|
|
curve: Curves.easeOutCubic,
|
|
));
|
|
|
|
// 애니메이션 시작
|
|
animationController!.forward();
|
|
|
|
// 로케일에 맞는 서비스명 로드
|
|
_loadDisplayName();
|
|
|
|
// 서비스명 변경 감지 리스너
|
|
serviceNameController.addListener(onServiceNameChanged);
|
|
|
|
// 스크롤 리스너
|
|
scrollController.addListener(() {
|
|
scrollOffset = scrollController.offset;
|
|
});
|
|
}
|
|
|
|
/// 로케일에 맞는 서비스명 로드
|
|
Future<void> _loadDisplayName() async {
|
|
final localeProvider = context.read<LocaleProvider>();
|
|
final locale = localeProvider.locale.languageCode;
|
|
|
|
final displayName = await SubscriptionUrlMatcher.getServiceDisplayName(
|
|
serviceName: subscription.serviceName,
|
|
locale: locale,
|
|
);
|
|
|
|
_displayName = displayName;
|
|
notifyListeners();
|
|
}
|
|
|
|
/// 리소스 정리
|
|
@override
|
|
void dispose() {
|
|
// Controllers
|
|
serviceNameController.dispose();
|
|
monthlyCostController.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();
|
|
|
|
super.dispose();
|
|
}
|
|
|
|
/// 통화 단위에 따른 금액 표시 형식 업데이트
|
|
void _updateMonthlyCostFormat() {
|
|
if (_currency == 'KRW') {
|
|
// 원화는 소수점 없이 표시
|
|
final intValue = subscription.monthlyCost.toInt();
|
|
monthlyCostController.text =
|
|
NumberFormat.decimalPattern().format(intValue);
|
|
} else {
|
|
// 달러는 소수점 2자리까지 표시
|
|
monthlyCostController.text =
|
|
NumberFormat('#,##0.00').format(subscription.monthlyCost);
|
|
}
|
|
}
|
|
|
|
/// 서비스명 변경시 카테고리 자동 선택
|
|
void onServiceNameChanged() {
|
|
autoSelectCategory();
|
|
}
|
|
|
|
/// 카테고리 자동 선택
|
|
void autoSelectCategory() {
|
|
final categoryProvider =
|
|
Provider.of<CategoryProvider>(context, listen: false);
|
|
final categories = categoryProvider.categories;
|
|
|
|
final serviceName = serviceNameController.text.toLowerCase();
|
|
|
|
// 서비스명에 기반한 카테고리 매칭 로직
|
|
CategoryModel? 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 == 'OTT 서비스',
|
|
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 == 'collaborationOffice',
|
|
orElse: () => categories.first,
|
|
);
|
|
}
|
|
// AI 관련 키워드
|
|
else if (serviceName.contains('chatgpt') ||
|
|
serviceName.contains('claude') ||
|
|
serviceName.contains('gemini') ||
|
|
serviceName.contains('copilot') ||
|
|
serviceName.contains('midjourney')) {
|
|
matchedCategory = categories.firstWhere(
|
|
(cat) => cat.name == 'aiService',
|
|
orElse: () => categories.first,
|
|
);
|
|
}
|
|
// 교육 관련 키워드
|
|
else if (serviceName.contains('coursera') ||
|
|
serviceName.contains('udemy') ||
|
|
serviceName.contains('인프런') ||
|
|
serviceName.contains('패스트캠퍼스') ||
|
|
serviceName.contains('클래스101')) {
|
|
matchedCategory = categories.firstWhere(
|
|
(cat) => cat.name == 'programming',
|
|
orElse: () => categories.first,
|
|
);
|
|
}
|
|
// 쇼핑 관련 키워드
|
|
else if (serviceName.contains('쿠팡') ||
|
|
serviceName.contains('coupang') ||
|
|
serviceName.contains('amazon') ||
|
|
serviceName.contains('네이버') ||
|
|
serviceName.contains('11번가')) {
|
|
matchedCategory = categories.firstWhere(
|
|
(cat) => cat.name == 'other',
|
|
orElse: () => categories.first,
|
|
);
|
|
}
|
|
|
|
if (matchedCategory != null) {
|
|
selectedCategoryId = matchedCategory.id;
|
|
}
|
|
}
|
|
|
|
/// 구독 정보 업데이트
|
|
Future<void> updateSubscription() async {
|
|
// Form 검증
|
|
if (formKey.currentState != null && !formKey.currentState!.validate()) {
|
|
AppSnackBar.showError(
|
|
context: context,
|
|
message: AppLocalizations.of(context).requiredFieldsError,
|
|
);
|
|
return;
|
|
}
|
|
|
|
final provider = Provider.of<SubscriptionProvider>(context, listen: false);
|
|
|
|
// 웹사이트 URL이 비어있는 경우 자동 매칭 다시 시도
|
|
String? websiteUrl = websiteUrlController.text;
|
|
if (websiteUrl.isEmpty) {
|
|
websiteUrl =
|
|
SubscriptionUrlMatcher.suggestUrl(serviceNameController.text);
|
|
}
|
|
|
|
// 구독 정보 업데이트
|
|
|
|
// 콤마 제거하고 숫자만 추출
|
|
double monthlyCost = 0.0;
|
|
try {
|
|
monthlyCost =
|
|
double.parse(monthlyCostController.text.replaceAll(',', ''));
|
|
} catch (e) {
|
|
// 파싱 오류 발생 시 기본값 사용
|
|
monthlyCost = subscription.monthlyCost;
|
|
}
|
|
|
|
debugPrint('[DetailScreenController] 구독 업데이트 시작: '
|
|
'${subscription.serviceName} → ${serviceNameController.text}, '
|
|
'금액: $subscription.monthlyCost → $monthlyCost $_currency');
|
|
|
|
subscription.serviceName = serviceNameController.text;
|
|
subscription.monthlyCost = monthlyCost;
|
|
subscription.websiteUrl = websiteUrl;
|
|
subscription.billingCycle = _billingCycle;
|
|
// 선택일이 오늘(또는 과거)이면 결제 주기에 맞춰 다음 회차로 보정하여 저장
|
|
final originalDateOnly = DateTime(
|
|
_nextBillingDate.year, _nextBillingDate.month, _nextBillingDate.day);
|
|
var adjustedNext =
|
|
BillingDateUtil.ensureFutureDate(originalDateOnly, _billingCycle);
|
|
// 주말/고정 공휴일 보정 → 다음 영업일로 이월
|
|
adjustedNext = BusinessDayUtil.nextBusinessDay(adjustedNext);
|
|
subscription.nextBillingDate = adjustedNext;
|
|
subscription.categoryId = _selectedCategoryId;
|
|
subscription.currency = _currency;
|
|
|
|
// 이벤트 정보 업데이트
|
|
subscription.isEventActive = _isEventActive;
|
|
subscription.eventStartDate = _eventStartDate;
|
|
subscription.eventEndDate = _eventEndDate;
|
|
|
|
// 이벤트 가격 파싱
|
|
if (_isEventActive && eventPriceController.text.isNotEmpty) {
|
|
try {
|
|
subscription.eventPrice =
|
|
double.parse(eventPriceController.text.replaceAll(',', ''));
|
|
} catch (e) {
|
|
subscription.eventPrice = null;
|
|
}
|
|
} else {
|
|
subscription.eventPrice = null;
|
|
}
|
|
|
|
debugPrint('[DetailScreenController] 업데이트 정보: '
|
|
'현재가격=${subscription.currentPrice}, '
|
|
'이벤트활성=${subscription.isEventActive}');
|
|
|
|
// 구독 업데이트
|
|
// 자동 보정이 발생했으면 안내
|
|
if (adjustedNext.isAfter(originalDateOnly)) {
|
|
AppSnackBar.showInfo(
|
|
context: context,
|
|
message: '다음 결제 예정일로 저장됨',
|
|
);
|
|
}
|
|
|
|
await provider.updateSubscription(subscription);
|
|
|
|
if (context.mounted) {
|
|
AppSnackBar.showSuccess(
|
|
context: context,
|
|
message: AppLocalizations.of(context).subscriptionUpdated,
|
|
);
|
|
|
|
// 변경 사항이 반영될 시간을 주기 위해 짧게 지연 후 결과 반환
|
|
await Future.delayed(const Duration(milliseconds: 100));
|
|
if (context.mounted) {
|
|
Navigator.of(context).pop(true);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 구독 삭제
|
|
Future<void> deleteSubscription() async {
|
|
if (context.mounted) {
|
|
// 로케일에 맞는 서비스명 가져오기
|
|
final localeProvider =
|
|
Provider.of<LocaleProvider>(context, listen: false);
|
|
final locale = localeProvider.locale.languageCode;
|
|
final displayName = await SubscriptionUrlMatcher.getServiceDisplayName(
|
|
serviceName: subscription.serviceName,
|
|
locale: locale,
|
|
);
|
|
if (!context.mounted) return;
|
|
|
|
// 삭제 확인 다이얼로그 표시
|
|
final shouldDelete = await DeleteConfirmationDialog.show(
|
|
context: context,
|
|
serviceName: displayName,
|
|
);
|
|
if (!context.mounted) return;
|
|
|
|
if (!shouldDelete) return;
|
|
|
|
// 사용자가 확인한 경우에만 삭제 진행
|
|
if (context.mounted) {
|
|
final provider =
|
|
Provider.of<SubscriptionProvider>(context, listen: false);
|
|
await provider.deleteSubscription(subscription.id);
|
|
|
|
if (context.mounted) {
|
|
AppSnackBar.showError(
|
|
context: context,
|
|
message:
|
|
AppLocalizations.of(context).subscriptionDeleted(displayName),
|
|
icon: Icons.delete_forever_rounded,
|
|
);
|
|
Navigator.of(context).pop();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 해지 페이지 열기
|
|
Future<void> openCancellationPage() async {
|
|
try {
|
|
// 1. 현재 언어 설정 가져오기
|
|
final locale = Localizations.localeOf(context).languageCode;
|
|
|
|
// 2. 해지 안내 URL 찾기
|
|
String? cancellationUrl =
|
|
await SubscriptionUrlMatcher.findCancellationUrl(
|
|
serviceName: subscription.serviceName,
|
|
websiteUrl: subscription.websiteUrl,
|
|
locale: locale == 'ko' ? 'kr' : 'en',
|
|
);
|
|
|
|
// 3. 해지 안내 URL이 없으면 구글 검색
|
|
if (cancellationUrl == null) {
|
|
final searchQuery =
|
|
'${subscription.serviceName} ${locale == 'ko' ? '해지 방법' : 'cancel subscription'}';
|
|
cancellationUrl =
|
|
'https://www.google.com/search?q=${Uri.encodeComponent(searchQuery)}';
|
|
|
|
if (context.mounted) {
|
|
AppSnackBar.showInfo(
|
|
context: context,
|
|
message: AppLocalizations.of(context).officialCancelPageNotFound,
|
|
);
|
|
}
|
|
}
|
|
|
|
// 4. URL 열기
|
|
final Uri url = Uri.parse(cancellationUrl);
|
|
if (!await launchUrl(url, mode: LaunchMode.externalApplication)) {
|
|
if (context.mounted) {
|
|
AppSnackBar.showError(
|
|
context: context,
|
|
message: AppLocalizations.of(context).cannotOpenWebsite,
|
|
);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
if (kDebugMode) {
|
|
// ignore: avoid_print
|
|
print('DetailScreenController: 해지 페이지 열기 실패 - $e');
|
|
}
|
|
|
|
// 오류 발생시 일반 웹사이트로 폴백
|
|
if (subscription.websiteUrl != null &&
|
|
subscription.websiteUrl!.isNotEmpty) {
|
|
final Uri url = Uri.parse(subscription.websiteUrl!);
|
|
if (!await launchUrl(url, mode: LaunchMode.externalApplication)) {
|
|
if (context.mounted) {
|
|
AppSnackBar.showError(
|
|
context: context,
|
|
message: AppLocalizations.of(context).cannotOpenWebsite,
|
|
);
|
|
}
|
|
}
|
|
} else {
|
|
if (context.mounted) {
|
|
AppSnackBar.showWarning(
|
|
context: context,
|
|
message: AppLocalizations.of(context).noWebsiteInfo,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 카드 색상 가져오기
|
|
Color getCardColor() {
|
|
// 서비스 이름에 따라 일관된 색상 생성
|
|
final int hash = subscription.serviceName.hashCode.abs();
|
|
final List<Color> colors = [
|
|
const Color(0xFF3B82F6), // 파랑
|
|
const Color(0xFF10B981), // 초록
|
|
const Color(0xFF8B5CF6), // 보라
|
|
const Color(0xFFF59E0B), // 노랑
|
|
const Color(0xFFEF4444), // 빨강
|
|
const Color(0xFF0EA5E9), // 하늘
|
|
const Color(0xFFEC4899), // 분홍
|
|
];
|
|
|
|
return colors[hash % colors.length];
|
|
}
|
|
|
|
// getGradient 제거됨 (그라데이션 미사용)
|
|
}
|