Files
submanager/lib/controllers/add_subscription_controller.dart
JiWoong Sul 917a68aa14 feat: 구독 URL 매칭 서비스 개선 및 컨트롤러 최적화
- URL 매칭 로직 개선
- 구독 추가/상세 화면 컨트롤러 리팩토링
- assets 폴더 구조 추가

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-14 19:13:13 +09:00

465 lines
15 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 '../services/sms_service.dart';
import '../services/subscription_url_matcher.dart';
import '../widgets/common/snackbar/app_snackbar.dart';
/// 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 = '월간';
String currency = 'KRW';
DateTime? nextBillingDate;
bool isLoading = false;
String? selectedCategoryId;
// 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;
});
// 애니메이션 시작
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: '${serviceInfo.serviceName} 서비스가 자동으로 인식되었습니다.',
);
}
}
} catch (e) {
if (kDebugMode) {
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 == '음악',
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 {
if (!await SMSService.hasSMSPermission()) {
final granted = await SMSService.requestSMSPermission();
if (!granted) {
if (context.mounted) {
AppSnackBar.showError(
context: context,
message: 'SMS 권한이 필요합니다.',
);
}
return;
}
}
final subscriptions = await SMSService.scanSubscriptions();
if (subscriptions.isEmpty) {
if (context.mounted) {
AppSnackBar.showWarning(
context: context,
message: '구독 관련 SMS를 찾을 수 없습니다.',
);
}
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) {
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: 'SMS 스캔 중 오류 발생: $e',
);
}
} 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(',', '')
);
}
await Provider.of<SubscriptionProvider>(context, listen: false)
.addSubscription(
serviceName: serviceNameController.text.trim(),
monthlyCost: monthlyCost,
billingCycle: billingCycle,
nextBillingDate: nextBillingDate!,
websiteUrl: websiteUrlController.text.trim(),
categoryId: selectedCategoryId,
currency: currency,
isEventActive: isEventActive,
eventStartDate: eventStartDate,
eventEndDate: eventEndDate,
eventPrice: eventPrice,
);
if (context.mounted) {
Navigator.pop(context, true); // 성공 여부 반환
}
} catch (e) {
setState(() {
isLoading = false;
});
if (context.mounted) {
AppSnackBar.showError(
context: context,
message: '저장 중 오류가 발생했습니다: $e',
);
}
}
} else {
scrollController.animateTo(
0.0,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
}
}
}