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:
JiWoong Sul
2025-07-11 00:21:18 +09:00
parent 4731288622
commit 83c5e3d64e
56 changed files with 9092 additions and 4579 deletions

View File

@@ -0,0 +1,95 @@
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'dart:math' as math;
import '../../controllers/add_subscription_controller.dart';
/// 구독 추가 화면의 App Bar
class AddSubscriptionAppBar extends StatelessWidget implements PreferredSizeWidget {
final AddSubscriptionController controller;
final double scrollOffset;
final VoidCallback onScanSMS;
const AddSubscriptionAppBar({
super.key,
required this.controller,
required this.scrollOffset,
required this.onScanSMS,
});
@override
Size get preferredSize => const Size.fromHeight(60);
@override
Widget build(BuildContext context) {
final double appBarOpacity = math.max(0, math.min(1, scrollOffset / 100));
return Container(
decoration: BoxDecoration(
color: Colors.white.withOpacity(appBarOpacity),
boxShadow: appBarOpacity > 0.6
? [
BoxShadow(
color: Colors.black.withOpacity(0.1 * appBarOpacity),
spreadRadius: 1,
blurRadius: 8,
offset: const Offset(0, 4),
)
]
: null,
),
child: SafeArea(
child: AppBar(
title: Text(
'구독 추가',
style: TextStyle(
fontFamily: 'Montserrat',
fontSize: 24,
fontWeight: FontWeight.w800,
letterSpacing: -0.5,
color: const Color(0xFF1E293B),
shadows: appBarOpacity > 0.6
? [
Shadow(
color: Colors.black.withOpacity(0.2),
offset: const Offset(0, 1),
blurRadius: 2,
)
]
: null,
),
),
elevation: 0,
backgroundColor: Colors.transparent,
actions: [
if (!kIsWeb)
controller.isLoading
? const Padding(
padding: EdgeInsets.only(right: 16.0),
child: Center(
child: SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
Color(0xFF3B82F6)),
),
),
),
)
: IconButton(
icon: const FaIcon(
FontAwesomeIcons.message,
size: 20,
color: Color(0xFF3B82F6),
),
onPressed: onScanSMS,
tooltip: 'SMS에서 구독 정보 스캔',
),
],
),
),
);
}
}

View File

@@ -0,0 +1,142 @@
import 'package:flutter/material.dart';
import '../../controllers/add_subscription_controller.dart';
import '../common/form_fields/currency_input_field.dart';
import '../common/form_fields/date_picker_field.dart';
/// 구독 추가 화면의 이벤트/할인 섹션
class AddSubscriptionEventSection extends StatelessWidget {
final AddSubscriptionController controller;
final Animation<double> fadeAnimation;
final Animation<Offset> slideAnimation;
final Function setState;
const AddSubscriptionEventSection({
super.key,
required this.controller,
required this.fadeAnimation,
required this.slideAnimation,
required this.setState,
});
@override
Widget build(BuildContext context) {
return FadeTransition(
opacity: Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(
parent: controller.animationController!,
curve: const Interval(0.4, 1.0, curve: Curves.easeIn),
),
),
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0.0, 0.5),
end: Offset.zero,
).animate(CurvedAnimation(
parent: controller.animationController!,
curve: const Interval(0.4, 1.0, curve: Curves.easeOutCubic),
)),
child: Container(
margin: const EdgeInsets.only(bottom: 20),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: controller.isEventActive
? const Color(0xFF3B82F6)
: Colors.grey.withOpacity(0.2),
width: controller.isEventActive ? 2 : 1,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Checkbox(
value: controller.isEventActive,
onChanged: (value) {
setState(() {
controller.isEventActive = value ?? false;
if (!controller.isEventActive) {
// 이벤트 비활성화 시 관련 데이터 초기화
controller.eventStartDate = DateTime.now();
controller.eventEndDate = DateTime.now().add(const Duration(days: 30));
controller.eventPriceController.clear();
} else {
// 이벤트 활성화 시 날짜가 null이면 기본값 설정
controller.eventStartDate ??= DateTime.now();
controller.eventEndDate ??= DateTime.now().add(const Duration(days: 30));
}
});
},
activeColor: const Color(0xFF3B82F6),
),
const Text(
'이벤트/할인 설정',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Color(0xFF1E293B),
),
),
const SizedBox(width: 8),
Icon(
Icons.local_offer,
size: 20,
color: controller.isEventActive
? const Color(0xFF3B82F6)
: Colors.grey,
),
],
),
// 이벤트 활성화 시 추가 필드 표시
AnimatedContainer(
duration: const Duration(milliseconds: 300),
height: controller.isEventActive ? null : 0,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 300),
opacity: controller.isEventActive ? 1.0 : 0.0,
child: Column(
children: [
const SizedBox(height: 20),
// 이벤트 기간
DateRangePickerField(
startDate: controller.eventStartDate,
endDate: controller.eventEndDate,
onStartDateSelected: (date) {
setState(() {
controller.eventStartDate = date;
});
},
onEndDateSelected: (date) {
setState(() {
controller.eventEndDate = date;
});
},
startLabel: '시작일',
endLabel: '종료일',
primaryColor: const Color(0xFF3B82F6),
),
const SizedBox(height: 20),
// 이벤트 가격
CurrencyInputField(
controller: controller.eventPriceController,
currency: controller.currency,
label: '이벤트 가격',
hintText: '할인된 가격을 입력하세요',
),
],
),
),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,431 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import '../../controllers/add_subscription_controller.dart';
import '../../providers/category_provider.dart';
import '../common/form_fields/base_text_field.dart';
import '../common/form_fields/currency_input_field.dart';
import '../common/form_fields/date_picker_field.dart';
/// 구독 추가 화면의 폼 섹션
class AddSubscriptionForm extends StatelessWidget {
final AddSubscriptionController controller;
final Animation<double> fadeAnimation;
final Animation<Offset> slideAnimation;
final Function setState;
const AddSubscriptionForm({
super.key,
required this.controller,
required this.fadeAnimation,
required this.slideAnimation,
required this.setState,
});
@override
Widget build(BuildContext context) {
return FadeTransition(
opacity: Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(
parent: controller.animationController!,
curve: const Interval(0.2, 1.0, curve: Curves.easeIn),
),
),
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0.0, 0.4),
end: Offset.zero,
).animate(CurvedAnimation(
parent: controller.animationController!,
curve: const Interval(0.2, 1.0, curve: Curves.easeOutCubic),
)),
child: Card(
elevation: 1,
shadowColor: Colors.black12,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 헤더
Row(
children: [
ShaderMask(
shaderCallback: (bounds) => LinearGradient(
colors: controller.gradientColors,
begin: Alignment.topLeft,
end: Alignment.bottomRight,
).createShader(bounds),
child: const Icon(
FontAwesomeIcons.fileLines,
size: 20,
color: Colors.white,
),
),
const SizedBox(width: 12),
const Text(
'서비스 정보',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
letterSpacing: -0.5,
color: Color(0xFF1E293B),
),
),
],
),
const SizedBox(height: 24),
// 서비스명 필드
BaseTextField(
controller: controller.serviceNameController,
focusNode: controller.serviceNameFocus,
label: '서비스명',
hintText: '예: Netflix, Spotify',
textInputAction: TextInputAction.next,
onEditingComplete: () {
controller.monthlyCostFocus.requestFocus();
},
validator: (value) {
if (value == null || value.isEmpty) {
return '서비스명을 입력해주세요';
}
return null;
},
),
const SizedBox(height: 20),
// 월 지출 및 통화 선택
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
flex: 2,
child: CurrencyInputField(
controller: controller.monthlyCostController,
currency: controller.currency,
label: '월 지출',
focusNode: controller.monthlyCostFocus,
textInputAction: TextInputAction.next,
onEditingComplete: () {
controller.billingCycleFocus.requestFocus();
},
validator: (value) {
if (value == null || value.isEmpty) {
return '금액을 입력해주세요';
}
return null;
},
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'통화',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
_CurrencySelector(
currency: controller.currency,
onChanged: (value) {
setState(() {
controller.currency = value;
});
},
),
],
),
),
],
),
const SizedBox(height: 20),
// 결제 주기
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'결제 주기',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
_BillingCycleSelector(
billingCycle: controller.billingCycle,
gradientColors: controller.gradientColors,
onChanged: (value) {
setState(() {
controller.billingCycle = value;
});
},
),
],
),
const SizedBox(height: 20),
// 다음 결제일
DatePickerField(
selectedDate: controller.nextBillingDate ?? DateTime.now(),
onDateSelected: (date) {
setState(() {
controller.nextBillingDate = date;
});
},
label: '다음 결제일',
firstDate: DateTime.now(),
lastDate: DateTime.now().add(const Duration(days: 365 * 2)),
primaryColor: controller.gradientColors[0],
),
const SizedBox(height: 20),
// 웹사이트 URL
BaseTextField(
controller: controller.websiteUrlController,
focusNode: controller.websiteUrlFocus,
label: '웹사이트 URL (선택)',
hintText: 'https://example.com',
keyboardType: TextInputType.url,
prefixIcon: Icon(
Icons.link_rounded,
color: Colors.grey[600],
),
),
const SizedBox(height: 20),
// 카테고리 선택
Consumer<CategoryProvider>(
builder: (context, categoryProvider, child) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'카테고리',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
_CategorySelector(
categories: categoryProvider.categories,
selectedCategoryId: controller.selectedCategoryId,
gradientColors: controller.gradientColors,
onChanged: (categoryId) {
setState(() {
controller.selectedCategoryId = categoryId;
});
},
),
],
);
},
),
],
),
),
),
),
);
}
}
/// 통화 선택기
class _CurrencySelector extends StatelessWidget {
final String currency;
final ValueChanged<String> onChanged;
const _CurrencySelector({
required this.currency,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
return Row(
children: [
_CurrencyOption(
label: '',
value: 'KRW',
isSelected: currency == 'KRW',
onTap: () => onChanged('KRW'),
),
const SizedBox(width: 8),
_CurrencyOption(
label: '\$',
value: 'USD',
isSelected: currency == 'USD',
onTap: () => onChanged('USD'),
),
],
);
}
}
/// 통화 옵션
class _CurrencyOption extends StatelessWidget {
final String label;
final String value;
final bool isSelected;
final VoidCallback onTap;
const _CurrencyOption({
required this.label,
required this.value,
required this.isSelected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Expanded(
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration(
color: isSelected
? const Color(0xFF3B82F6)
: Colors.grey.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Center(
child: Text(
label,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: isSelected ? Colors.white : Colors.grey[600],
),
),
),
),
),
);
}
}
/// 결제 주기 선택기
class _BillingCycleSelector extends StatelessWidget {
final String billingCycle;
final List<Color> gradientColors;
final ValueChanged<String> onChanged;
const _BillingCycleSelector({
required this.billingCycle,
required this.gradientColors,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
final cycles = ['월간', '분기별', '반기별', '연간'];
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: cycles.map((cycle) {
final isSelected = billingCycle == cycle;
return Padding(
padding: const EdgeInsets.only(right: 8),
child: InkWell(
onTap: () => onChanged(cycle),
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 12,
),
decoration: BoxDecoration(
color: isSelected
? gradientColors[0]
: Colors.grey.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Text(
cycle,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: isSelected ? Colors.white : Colors.grey[700],
),
),
),
),
);
}).toList(),
),
);
}
}
/// 카테고리 선택기
class _CategorySelector extends StatelessWidget {
final List<dynamic> categories;
final String? selectedCategoryId;
final List<Color> gradientColors;
final ValueChanged<String?> onChanged;
const _CategorySelector({
required this.categories,
required this.selectedCategoryId,
required this.gradientColors,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
return Wrap(
spacing: 8,
runSpacing: 8,
children: categories.map((category) {
final isSelected = selectedCategoryId == category.id;
return InkWell(
onTap: () => onChanged(category.id),
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 10,
),
decoration: BoxDecoration(
color: isSelected
? gradientColors[0]
: Colors.grey.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
category.emoji,
style: const TextStyle(fontSize: 16),
),
const SizedBox(width: 6),
Text(
category.name,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: isSelected ? Colors.white : Colors.grey[700],
),
),
],
),
),
);
}).toList(),
);
}
}

View File

@@ -0,0 +1,88 @@
import 'package:flutter/material.dart';
import '../../controllers/add_subscription_controller.dart';
/// 구독 추가 화면의 헤더 섹션
class AddSubscriptionHeader extends StatelessWidget {
final AddSubscriptionController controller;
final Animation<double> fadeAnimation;
final Animation<Offset> slideAnimation;
const AddSubscriptionHeader({
super.key,
required this.controller,
required this.fadeAnimation,
required this.slideAnimation,
});
@override
Widget build(BuildContext context) {
return FadeTransition(
opacity: fadeAnimation,
child: SlideTransition(
position: slideAnimation,
child: Container(
margin: const EdgeInsets.only(bottom: 24),
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(24),
gradient: LinearGradient(
colors: controller.gradientColors,
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
boxShadow: [
BoxShadow(
color: controller.gradientColors[0].withOpacity(0.3),
blurRadius: 20,
spreadRadius: 0,
offset: const Offset(0, 8),
),
],
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
borderRadius: BorderRadius.circular(16),
),
child: const Icon(
Icons.add_rounded,
size: 32,
color: Colors.white,
),
),
const SizedBox(width: 16),
const Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'새 구독 추가',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.w800,
color: Colors.white,
letterSpacing: -0.5,
),
),
SizedBox(height: 4),
Text(
'서비스 정보를 입력해주세요',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: Colors.white70,
),
),
],
),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,52 @@
import 'package:flutter/material.dart';
import '../../controllers/add_subscription_controller.dart';
import '../common/buttons/primary_button.dart';
/// 구독 추가 화면의 저장 버튼
class AddSubscriptionSaveButton extends StatelessWidget {
final AddSubscriptionController controller;
final Animation<double> fadeAnimation;
final Animation<Offset> slideAnimation;
final Function setState;
const AddSubscriptionSaveButton({
super.key,
required this.controller,
required this.fadeAnimation,
required this.slideAnimation,
required this.setState,
});
@override
Widget build(BuildContext context) {
return FadeTransition(
opacity: Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(
parent: controller.animationController!,
curve: const Interval(0.6, 1.0, curve: Curves.easeIn),
),
),
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0.0, 0.6),
end: Offset.zero,
).animate(CurvedAnimation(
parent: controller.animationController!,
curve: const Interval(0.6, 1.0, curve: Curves.easeOutCubic),
)),
child: Padding(
padding: const EdgeInsets.only(bottom: 80),
child: PrimaryButton(
text: '구독 추가하기',
icon: Icons.add_circle_outline,
onPressed: controller.isLoading
? null
: () => controller.saveSubscription(setState: setState),
isLoading: controller.isLoading,
backgroundColor: const Color(0xFF3B82F6),
),
),
),
);
}
}