Refactor screens to MVC architecture with modular widgets
- Extract business logic from screens into dedicated controllers - Split large screen files into smaller, reusable widget components - Add controllers for AddSubscriptionScreen and DetailScreen - Create modular widgets for subscription and detail features - Improve code organization and maintainability - Remove duplicated code and improve reusability 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
422
lib/controllers/detail_screen_controller.dart
Normal file
422
lib/controllers/detail_screen_controller.dart
Normal file
@@ -0,0 +1,422 @@
|
||||
import 'package:flutter/material.dart';
|
||||
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 '../services/subscription_url_matcher.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
/// DetailScreen의 비즈니스 로직을 관리하는 Controller
|
||||
class DetailScreenController {
|
||||
final BuildContext context;
|
||||
final SubscriptionModel subscription;
|
||||
|
||||
// Text Controllers
|
||||
late TextEditingController serviceNameController;
|
||||
late TextEditingController monthlyCostController;
|
||||
late TextEditingController websiteUrlController;
|
||||
late TextEditingController eventPriceController;
|
||||
|
||||
// Form State
|
||||
late String billingCycle;
|
||||
late DateTime nextBillingDate;
|
||||
String? selectedCategoryId;
|
||||
late String currency;
|
||||
bool isLoading = false;
|
||||
|
||||
// Event State
|
||||
late bool isEventActive;
|
||||
DateTime? eventStartDate;
|
||||
DateTime? eventEndDate;
|
||||
|
||||
// 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();
|
||||
|
||||
// 서비스명 변경 감지 리스너
|
||||
serviceNameController.addListener(onServiceNameChanged);
|
||||
|
||||
// 스크롤 리스너
|
||||
scrollController.addListener(() {
|
||||
scrollOffset = scrollController.offset;
|
||||
});
|
||||
}
|
||||
|
||||
/// 리소스 정리
|
||||
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();
|
||||
}
|
||||
|
||||
/// 통화 단위에 따른 금액 표시 형식 업데이트
|
||||
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 == '엔터테인먼트',
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// 구독 정보 업데이트
|
||||
Future<void> updateSubscription() async {
|
||||
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;
|
||||
}
|
||||
|
||||
subscription.serviceName = serviceNameController.text;
|
||||
subscription.monthlyCost = monthlyCost;
|
||||
subscription.websiteUrl = websiteUrl;
|
||||
subscription.billingCycle = billingCycle;
|
||||
subscription.nextBillingDate = nextBillingDate;
|
||||
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;
|
||||
}
|
||||
|
||||
// 구독 업데이트
|
||||
await provider.updateSubscription(subscription);
|
||||
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: const Row(
|
||||
children: [
|
||||
Icon(Icons.check_circle_rounded, color: Colors.white),
|
||||
SizedBox(width: 12),
|
||||
Text('구독 정보가 업데이트되었습니다.'),
|
||||
],
|
||||
),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
backgroundColor: const Color(0xFF10B981),
|
||||
duration: const Duration(seconds: 2),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
||||
),
|
||||
);
|
||||
|
||||
// 변경 사항이 반영될 시간을 주기 위해 짧게 지연 후 결과 반환
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).pop(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 구독 삭제
|
||||
Future<void> deleteSubscription() async {
|
||||
if (context.mounted) {
|
||||
final provider = Provider.of<SubscriptionProvider>(context, listen: false);
|
||||
await provider.deleteSubscription(subscription.id);
|
||||
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: const Row(
|
||||
children: [
|
||||
Icon(Icons.delete_forever_rounded, color: Colors.white),
|
||||
SizedBox(width: 12),
|
||||
Text('구독이 삭제되었습니다.'),
|
||||
],
|
||||
),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
backgroundColor: const Color(0xFFDC2626),
|
||||
duration: const Duration(seconds: 2),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
||||
),
|
||||
);
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 해지 페이지 열기
|
||||
Future<void> openCancellationPage() async {
|
||||
if (subscription.websiteUrl != null &&
|
||||
subscription.websiteUrl!.isNotEmpty) {
|
||||
final Uri url = Uri.parse(subscription.websiteUrl!);
|
||||
if (!await launchUrl(url, mode: LaunchMode.externalApplication)) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('웹사이트를 열 수 없습니다.'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('웹사이트 정보가 없습니다. 해지는 웹사이트에서 진행해주세요.'),
|
||||
backgroundColor: Colors.orange,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 카드 색상 가져오기
|
||||
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];
|
||||
}
|
||||
|
||||
/// 그라데이션 가져오기
|
||||
LinearGradient getGradient(Color baseColor) {
|
||||
return LinearGradient(
|
||||
colors: [
|
||||
baseColor,
|
||||
baseColor.withValues(alpha: 0.8),
|
||||
],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user