Files
submanager/lib/screens/add_subscription_screen.dart
JiWoong Sul 8619e96739 Initial commit: SubManager Flutter App
주요 구현 완료 기능:
- 구독 관리 (추가/편집/삭제/카테고리 분류)
- 이벤트 할인 시스템 (기본값 자동 설정)
- SMS 자동 스캔 및 구독 정보 추출
- 알림 시스템 (타임존 처리 안정화)
- 환율 변환 지원 (KRW/USD)
- 반응형 UI 및 애니메이션
- 다국어 지원 (한국어/영어)

버그 수정:
- NotificationService tz.local 초기화 오류 해결
- MainScreenSummaryCard 레이아웃 오버플로우 수정
- 구독 추가 시 LateInitializationError 완전 해결

🤖 Generated with Claude Code

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

2023 lines
103 KiB
Dart

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:intl/intl.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/services.dart';
import 'dart:math' as math;
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import '../providers/subscription_provider.dart';
import '../providers/category_provider.dart';
import '../models/category_model.dart';
import '../services/sms_service.dart';
import '../services/subscription_url_matcher.dart';
import '../services/exchange_rate_service.dart';
class AddSubscriptionScreen extends StatefulWidget {
const AddSubscriptionScreen({Key? key}) : super(key: key);
@override
State<AddSubscriptionScreen> createState() => _AddSubscriptionScreenState();
}
class _AddSubscriptionScreenState extends State<AddSubscriptionScreen>
with SingleTickerProviderStateMixin {
final _formKey = GlobalKey<FormState>();
final _serviceNameController = TextEditingController();
final _monthlyCostController = TextEditingController();
final _nextBillingDateController = TextEditingController();
final _websiteUrlController = TextEditingController();
String _billingCycle = '월간';
String _currency = 'KRW';
DateTime? _nextBillingDate;
bool _isLoading = false;
String? _selectedCategoryId;
// 이벤트 관련 상태 변수
bool _isEventActive = false;
DateTime? _eventStartDate = DateTime.now(); // 오늘로 초기화
DateTime? _eventEndDate = DateTime.now().add(const Duration(days: 30)); // 30일 후로 초기화
final _eventPriceController = TextEditingController();
// 포커스 노드 추가
final _serviceNameFocus = FocusNode();
final _monthlyCostFocus = FocusNode();
final _billingCycleFocus = FocusNode();
final _nextBillingDateFocus = FocusNode();
final _websiteUrlFocus = FocusNode();
final _categoryFocus = FocusNode();
final _currencyFocus = FocusNode();
// 애니메이션 컨트롤러
late AnimationController _animationController;
late Animation<double> _fadeAnimation;
late Animation<Offset> _slideAnimation;
// 스크롤 컨트롤러
final ScrollController _scrollController = ScrollController();
double _scrollOffset = 0;
// 현재 편집 중인 필드
int _currentEditingField = -1;
// 호버 상태
bool _isSaveHovered = false;
final List<Color> _gradientColors = [
const Color(0xFF3B82F6),
const Color(0xFF0EA5E9),
const Color(0xFF06B6D4),
];
@override
void initState() {
super.initState();
// 결제일 기본값을 오늘 날짜로 설정
_nextBillingDate = DateTime.now();
// 디버깅 정보 출력
print('환경 정보: kIsWeb = $kIsWeb');
print('초기 통화 단위: $_currency');
// 서비스명 컨트롤러에 리스너 추가
_serviceNameController.addListener(_onServiceNameChanged);
// 애니메이션 컨트롤러 초기화
_animationController = AnimationController(
vsync: this,
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.easeOutCubic,
));
_scrollController.addListener(() {
setState(() {
_scrollOffset = _scrollController.offset;
});
});
_animationController.forward();
}
@override
void dispose() {
_serviceNameController.removeListener(_onServiceNameChanged);
_serviceNameController.dispose();
_monthlyCostController.dispose();
_nextBillingDateController.dispose();
_websiteUrlController.dispose();
_eventPriceController.dispose();
_animationController.dispose();
_scrollController.dispose();
// 포커스 노드 해제
_serviceNameFocus.dispose();
_monthlyCostFocus.dispose();
_billingCycleFocus.dispose();
_nextBillingDateFocus.dispose();
_websiteUrlFocus.dispose();
_categoryFocus.dispose();
_currencyFocus.dispose();
super.dispose();
}
// 서비스명이 변경될 때 호출되는 콜백 함수
void _onServiceNameChanged() {
if (_serviceNameController.text.isNotEmpty &&
_websiteUrlController.text.isEmpty) {
// 자동 URL 매칭 시도
final suggestedUrl =
SubscriptionUrlMatcher.suggestUrl(_serviceNameController.text);
// 매칭된 URL이 있으면 텍스트 컨트롤러에 설정
if (suggestedUrl != null && suggestedUrl.isNotEmpty) {
setState(() {
_websiteUrlController.text = suggestedUrl;
});
}
}
// 서비스명이 변경될 때 카테고리 자동 선택 시도
if (_serviceNameController.text.isNotEmpty && _selectedCategoryId == null) {
_autoSelectCategory();
}
}
// 서비스명을 기반으로 카테고리 자동 선택 함수
void _autoSelectCategory() {
if (_serviceNameController.text.isEmpty) return;
final serviceName = _serviceNameController.text.toLowerCase();
final categoryProvider =
Provider.of<CategoryProvider>(context, listen: false);
// 카테고리가 없으면 리턴
if (categoryProvider.categories.isEmpty) return;
// OTT 서비스 확인
if (SubscriptionUrlMatcher.ottServices.keys.any((key) =>
serviceName.contains(key.toLowerCase()) ||
key.toLowerCase().contains(serviceName))) {
// OTT 관련 카테고리 찾기
try {
final ottCategory = categoryProvider.categories.firstWhere(
(cat) =>
cat.name.contains('OTT') ||
cat.name.contains('미디어') ||
cat.name.contains('영상'),
);
setState(() {
_selectedCategoryId = ottCategory.id;
});
return;
} catch (_) {
// OTT 카테고리가 없으면 첫 번째 카테고리 사용
if (categoryProvider.categories.isNotEmpty) {
setState(() {
_selectedCategoryId = categoryProvider.categories.first.id;
});
}
}
}
// 음악 서비스 확인
if (SubscriptionUrlMatcher.musicServices.keys.any((key) =>
serviceName.contains(key.toLowerCase()) ||
key.toLowerCase().contains(serviceName))) {
// 음악 관련 카테고리 찾기
try {
final musicCategory = categoryProvider.categories.firstWhere(
(cat) => cat.name.contains('음악') || cat.name.contains('스트리밍'),
);
setState(() {
_selectedCategoryId = musicCategory.id;
});
return;
} catch (_) {
// 음악 카테고리가 없으면 첫 번째 카테고리 사용
if (categoryProvider.categories.isNotEmpty) {
setState(() {
_selectedCategoryId = categoryProvider.categories.first.id;
});
}
}
}
// AI 서비스 확인
if (SubscriptionUrlMatcher.aiServices.keys.any((key) =>
serviceName.contains(key.toLowerCase()) ||
key.toLowerCase().contains(serviceName))) {
// AI 관련 카테고리 찾기
try {
final aiCategory = categoryProvider.categories.firstWhere(
(cat) => cat.name.contains('AI') || cat.name.contains('인공지능'),
);
setState(() {
_selectedCategoryId = aiCategory.id;
});
return;
} catch (_) {
// AI 카테고리가 없으면 첫 번째 카테고리 사용
if (categoryProvider.categories.isNotEmpty) {
setState(() {
_selectedCategoryId = categoryProvider.categories.first.id;
});
}
}
}
// 프로그래밍/개발 서비스 확인
if (SubscriptionUrlMatcher.programmingServices.keys.any((key) =>
serviceName.contains(key.toLowerCase()) ||
key.toLowerCase().contains(serviceName))) {
// 개발 관련 카테고리 찾기
try {
final devCategory = categoryProvider.categories.firstWhere(
(cat) => cat.name.contains('개발') || cat.name.contains('프로그래밍'),
);
setState(() {
_selectedCategoryId = devCategory.id;
});
return;
} catch (_) {
// 개발 카테고리가 없으면 첫 번째 카테고리 사용
if (categoryProvider.categories.isNotEmpty) {
setState(() {
_selectedCategoryId = categoryProvider.categories.first.id;
});
}
}
}
// 오피스/협업 툴 확인
if (SubscriptionUrlMatcher.officeTools.keys.any((key) =>
serviceName.contains(key.toLowerCase()) ||
key.toLowerCase().contains(serviceName))) {
// 오피스 관련 카테고리 찾기
try {
final officeCategory = categoryProvider.categories.firstWhere(
(cat) =>
cat.name.contains('오피스') ||
cat.name.contains('협업') ||
cat.name.contains('업무'),
);
setState(() {
_selectedCategoryId = officeCategory.id;
});
return;
} catch (_) {
// 오피스 카테고리가 없으면 첫 번째 카테고리 사용
if (categoryProvider.categories.isNotEmpty) {
setState(() {
_selectedCategoryId = categoryProvider.categories.first.id;
});
}
}
}
// 기타 서비스 확인
if (SubscriptionUrlMatcher.otherServices.keys.any((key) =>
serviceName.contains(key.toLowerCase()) ||
key.toLowerCase().contains(serviceName))) {
// 기타 관련 카테고리 찾기
try {
final otherCategory = categoryProvider.categories.firstWhere(
(cat) => cat.name.contains('기타') || cat.name.contains('게임'),
);
setState(() {
_selectedCategoryId = otherCategory.id;
});
} catch (_) {
// 기타 카테고리가 없으면 첫 번째 카테고리 사용
if (categoryProvider.categories.isNotEmpty) {
setState(() {
_selectedCategoryId = categoryProvider.categories.first.id;
});
}
}
}
}
Future<void> _scanSMS() async {
if (kIsWeb) return;
setState(() => _isLoading = true);
try {
if (!await SMSService.hasSMSPermission()) {
final granted = await SMSService.requestSMSPermission();
if (!granted) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const Icon(Icons.error_outline, color: Colors.white),
const SizedBox(width: 12),
const Expanded(child: Text('SMS 권한이 필요합니다.')),
],
),
behavior: SnackBarBehavior.floating,
backgroundColor: Colors.red,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10)),
),
);
}
return;
}
}
final subscriptions = await SMSService.scanSubscriptions();
if (subscriptions.isEmpty) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const Icon(Icons.info_outline, color: Colors.white),
const SizedBox(width: 12),
const Expanded(child: Text('구독 관련 SMS를 찾을 수 없습니다.')),
],
),
behavior: SnackBarBehavior.floating,
backgroundColor: Colors.orange,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10)),
),
);
}
return;
}
final subscription = subscriptions.first;
setState(() {
_serviceNameController.text = subscription['serviceName'] ?? '';
// 비용 처리 및 통화 단위 자동 감지
final costValue = subscription['monthlyCost']?.toString() ?? '';
// costValue가 비어있지 않을 경우에만 처리
if (costValue.isNotEmpty) {
// 달러 표시가 있거나 소수점이 있으면 달러로 판단
if (costValue.contains('\$') || costValue.contains('.')) {
// 달러로 설정
_currency = 'USD';
// 달러 기호 제거 및 숫자만 추출
String numericValue = costValue.replaceAll('\$', '').trim();
// 소수점이 없는 경우 소수점 추가
if (!numericValue.contains('.')) {
numericValue = '$numericValue.00';
}
// 3자리마다 콤마 추가하여 포맷팅
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 (subscription['serviceName'] != null &&
subscription['serviceName'].isNotEmpty) {
final suggestedUrl =
SubscriptionUrlMatcher.suggestUrl(subscription['serviceName']);
if (suggestedUrl != null) {
_websiteUrlController.text = suggestedUrl;
}
// 서비스명 기반으로 카테고리 자동 선택
_autoSelectCategory();
}
// 애니메이션 재생
_animationController.reset();
_animationController.forward();
});
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const Icon(Icons.error_outline, color: Colors.white),
const SizedBox(width: 12),
Expanded(child: Text('SMS 스캔 중 오류 발생: $e')),
],
),
behavior: SnackBarBehavior.floating,
backgroundColor: Colors.red,
shape:
RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
);
}
} finally {
if (mounted) {
setState(() => _isLoading = false);
}
}
}
Future<void> _saveSubscription() 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 (mounted) {
Navigator.pop(context);
}
} catch (e) {
setState(() {
_isLoading = false;
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('저장 중 오류가 발생했습니다: $e'),
backgroundColor: Colors.red,
),
);
}
}
} else {
_scrollController.animateTo(
0.0,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
}
}
@override
Widget build(BuildContext context) {
final double appBarOpacity = math.max(0, math.min(1, _scrollOffset / 100));
// 통화 기호 텍스트 (디버깅용)
print('현재 통화 단위: $_currency');
final currencySymbol = _currency == 'KRW' ? '' : '\$';
print('통화 기호: $currencySymbol');
return Scaffold(
backgroundColor: const Color(0xFFF8FAFC),
extendBodyBehindAppBar: true,
appBar: PreferredSize(
preferredSize: const Size.fromHeight(60),
child: 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)
_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: _scanSMS,
tooltip: 'SMS에서 구독 정보 스캔',
),
],
),
),
),
),
body: SingleChildScrollView(
controller: _scrollController,
physics: const BouncingScrollPhysics(),
padding: const EdgeInsets.all(16),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(height: MediaQuery.of(context).padding.top + 60),
// 헤더 섹션
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: _gradientColors,
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
boxShadow: [
BoxShadow(
color: _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,
),
),
],
),
),
],
),
),
),
),
// 서비스 정보 카드
FadeTransition(
opacity: Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(
parent: _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: _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: _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),
// 서비스명 필드
AnimatedContainer(
duration: const Duration(milliseconds: 200),
margin: const EdgeInsets.only(bottom: 20),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
color: _currentEditingField == 0
? const Color(0xFF3B82F6).withOpacity(0.1)
: Colors.transparent,
),
padding: const EdgeInsets.all(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'서비스명',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: Color(0xFF3B82F6),
),
),
const SizedBox(height: 8),
TextFormField(
controller: _serviceNameController,
focusNode: _serviceNameFocus,
textInputAction: TextInputAction.next,
onTap: () =>
setState(() => _currentEditingField = 0),
onEditingComplete: () {
_monthlyCostFocus.requestFocus();
setState(() => _currentEditingField = -1);
},
validator: (value) {
if (value == null || value.isEmpty) {
return '서비스명을 입력해주세요';
}
return null;
},
style: const TextStyle(
color: Color(0xFF1E293B),
fontSize: 16,
fontWeight: FontWeight.w500,
),
decoration: InputDecoration(
filled: true,
fillColor: Colors.white,
contentPadding: const EdgeInsets.symmetric(
vertical: 16, horizontal: 20),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: Colors.grey.withOpacity(0.2),
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(
color: Color(0xFF3B82F6),
width: 2,
),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(
color: Colors.red,
width: 2,
),
),
hintText: '넷플릭스',
hintStyle: TextStyle(
color: Colors.grey.shade500,
fontSize: 16,
),
),
),
],
),
),
// 월 비용 필드
AnimatedContainer(
duration: const Duration(milliseconds: 200),
margin: const EdgeInsets.only(bottom: 20),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
color: _currentEditingField == 1
? const Color(0xFF3B82F6).withOpacity(0.1)
: Colors.transparent,
),
padding: const EdgeInsets.all(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 환율 정보와 비용 입력 제목 표시 (상단)
Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Text(
'비용 입력',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: Color(0xFF3B82F6),
),
),
if (_currency == 'USD')
FutureBuilder<String>(
future: ExchangeRateService()
.getFormattedExchangeRateInfo(),
builder: (context, snapshot) {
if (snapshot.hasData) {
return Text(
snapshot.data!,
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
fontWeight: FontWeight.w500,
),
);
}
return const SizedBox.shrink();
},
),
],
),
const SizedBox(height: 8),
Row(
children: [
// 통화 단위 선택 (좌측)
Expanded(
flex: 3, // 25% 너비 차지
child: DropdownButtonFormField<String>(
value: _currency,
focusNode: _currencyFocus,
isDense: true,
onTap: () => setState(
() => _currentEditingField = 1),
onChanged: (value) {
if (value != null) {
setState(() {
_currency = value;
// 통화 단위 변경 시 입력 값 변환
final currentText =
_monthlyCostController.text;
if (currentText.isNotEmpty) {
// 콤마 제거하고 숫자만 추출
final numericValue =
double.tryParse(currentText
.replaceAll(',', ''));
if (numericValue != null) {
if (value == 'KRW') {
// 달러 → 원화: 소수점 제거
_monthlyCostController
.text = NumberFormat
.decimalPattern()
.format(numericValue
.toInt());
} else {
// 원화 → 달러: 소수점 2자리 추가
_monthlyCostController
.text = NumberFormat(
'#,##0.00')
.format(numericValue);
}
}
}
// 화면 갱신하여 통화 기호도 업데이트
_monthlyCostFocus.requestFocus();
});
}
},
decoration: InputDecoration(
filled: true,
fillColor: Colors.white,
contentPadding:
const EdgeInsets.symmetric(
vertical: 16, horizontal: 12),
border: OutlineInputBorder(
borderRadius:
BorderRadius.circular(12),
borderSide: BorderSide(
color:
Colors.grey.withOpacity(0.2),
),
),
focusedBorder: OutlineInputBorder(
borderRadius:
BorderRadius.circular(12),
borderSide: const BorderSide(
color: Color(0xFF3B82F6),
width: 2,
),
),
),
icon: const Icon(
Icons.arrow_drop_down,
color: Color(0xFF3B82F6),
),
items: ['KRW', 'USD']
.map((currency) => DropdownMenuItem(
value: currency,
child: Text(
currency == 'KRW'
? 'KRW'
: 'USD',
style: const TextStyle(
fontSize: 14,
fontWeight:
FontWeight.w500,
color: Colors.black87,
),
),
))
.toList(),
),
),
const SizedBox(width: 8),
// 월 비용 입력 필드 (우측) - 테두리 제거 및 배경색 통일
Expanded(
flex: 7, // 75% 너비 차지
child: Container(
height: 50, // 높이를 56에서 50으로 줄임
// 우측에서 40픽셀 줄이기
margin: const EdgeInsets.only(right: 0),
// 내부 패딩을 고정값으로 설정하여 포커스 상태와 관계없이 일관되게 유지
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
// 포커스 상태에 따른 배경색 변경 (배경색만 변경)
color: _currentEditingField == 1
? const Color(
0xFFF3F4F6) // 포커스 상태일 때 연한 회색
: Colors
.transparent, // 포커스 없을 때 투명
borderRadius:
BorderRadius.circular(12),
// 테두리 설정 (포커스 상태에 따라 색상만 변경)
border: Border.all(
color: _currentEditingField == 1
? const Color(0xFF3B82F6)
: Colors.grey.withOpacity(
0.4), // 포커스 없을 때 더 진한 회색
width: _currentEditingField == 1
? 2
: 1,
),
),
child: Row(
children: [
// 통화 기호 - 항상 표시되도록 수정
Container(
width: 40,
alignment: Alignment.center,
decoration: BoxDecoration(
// 테두리 추가 (좌측 통화선택란과 동일한 스타일)
border: Border(
right: BorderSide(
color: Colors.grey
.withOpacity(0.2),
width: 1,
),
),
),
child: Text(
_currency == 'KRW' ? '' : '\$',
style: const TextStyle(
color: Color(0xFF3B82F6),
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
),
// 실제 입력 필드 - 기호 관련 코드 제거하여 중복 방지
Expanded(
child: Stack(
alignment:
Alignment.centerRight,
children: [
TextField(
controller:
_monthlyCostController,
focusNode:
_monthlyCostFocus,
textInputAction:
TextInputAction.next,
keyboardType:
const TextInputType
.numberWithOptions(
decimal: true),
inputFormatters: [
// 통화 단위에 따라 다른 입력 형식 적용
FilteringTextInputFormatter
.allow(
_currency == 'KRW'
? RegExp(
r'[0-9,]') // 원화: 정수만 허용
: RegExp(
r'[0-9,.]'), // 달러: 소수점 허용
),
// 커스텀 포맷터 - 3자리마다 콤마 추가
TextInputFormatter
.withFunction(
(oldValue,
newValue) {
// 입력값에서 콤마 제거
final text = newValue
.text
.replaceAll(
',', '');
if (text.isEmpty) {
return newValue
.copyWith(
text: '');
}
// 숫자 형식 검증
if (_currency ==
'KRW') {
// 원화: 정수 형식
if (double.tryParse(
text) ==
null) {
return oldValue;
}
// 3자리마다 콤마 추가
final formattedValue =
NumberFormat
.decimalPattern()
.format(
int.parse(
text));
return newValue
.copyWith(
text:
formattedValue,
selection: TextSelection
.collapsed(
offset: formattedValue
.length),
);
} else {
// 달러: 소수점 형식
if (double.tryParse(
text) ==
null &&
text != '.') {
return oldValue;
}
// 소수점 이하 처리를 위해 부분 분리
final parts =
text.split('.');
final integerPart =
parts[0];
final decimalPart = parts
.length >
1
? '.${parts[1].length > 2 ? parts[1].substring(0, 2) : parts[1]}'
: '';
// 3자리마다 콤마 추가 (정수 부분만)
String formattedValue;
if (integerPart
.isEmpty) {
formattedValue =
'0$decimalPart';
} else {
final formatted = NumberFormat
.decimalPattern()
.format(int.parse(
integerPart));
formattedValue =
'$formatted$decimalPart';
}
return newValue
.copyWith(
text:
formattedValue,
selection: TextSelection
.collapsed(
offset: formattedValue
.length),
);
}
}),
],
onTap: () => setState(() =>
_currentEditingField =
1),
onSubmitted: (_) {
_billingCycleFocus
.requestFocus();
setState(() =>
_currentEditingField =
-1);
},
style: const TextStyle(
color: Color(0xFF1E293B),
fontSize: 16,
fontWeight:
FontWeight.w500,
),
decoration: InputDecoration(
border: InputBorder.none,
// 포커스 상태와 관계없이 일관된 패딩 유지
contentPadding:
const EdgeInsets
.symmetric(
vertical: 14,
horizontal: 8),
hintText:
_currency == 'KRW'
? '9,000'
: '9.99',
hintStyle: TextStyle(
color: Colors
.grey.shade500,
fontSize: 16,
),
// 모든 테두리 제거
enabledBorder:
InputBorder.none,
focusedBorder:
InputBorder.none,
errorBorder:
InputBorder.none,
disabledBorder:
InputBorder.none,
focusedErrorBorder:
InputBorder.none,
),
),
// 달러일 때 원화 환산 금액 표시
if (_currency == 'USD')
ValueListenableBuilder<
TextEditingValue>(
valueListenable:
_monthlyCostController,
builder: (context, value,
child) {
// 입력값이 바뀔 때마다 환산 금액 갱신
return FutureBuilder<
String>(
future: ExchangeRateService()
.getFormattedKrwAmount(
double.tryParse(value
.text
.replaceAll(
',',
'')) ??
0.0),
builder: (context,
snapshot) {
if (snapshot
.hasData &&
snapshot.data!
.isNotEmpty) {
return Padding(
padding:
const EdgeInsets
.only(
right:
12.0),
child: Text(
snapshot
.data!,
style:
const TextStyle(
fontSize:
14,
color: Colors
.blue,
fontWeight:
FontWeight
.w500,
),
),
);
}
return const SizedBox
.shrink();
},
);
},
),
],
),
),
],
),
),
),
],
),
],
),
),
// 결제 주기 필드
AnimatedContainer(
duration: const Duration(milliseconds: 200),
margin: const EdgeInsets.only(bottom: 20),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
color: _currentEditingField == 2
? const Color(0xFF3B82F6).withOpacity(0.1)
: Colors.transparent,
),
padding: const EdgeInsets.all(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'결제 주기',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: Color(0xFF3B82F6),
),
),
const SizedBox(height: 8),
DropdownButtonFormField<String>(
value: _billingCycle,
focusNode: _billingCycleFocus,
onTap: () =>
setState(() => _currentEditingField = 2),
onChanged: (value) {
if (value != null) {
setState(() {
_billingCycle = value;
_currentEditingField = -1;
_nextBillingDateFocus.requestFocus();
});
}
},
decoration: InputDecoration(
filled: true,
fillColor: Colors.white,
contentPadding: const EdgeInsets.all(16),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: Colors.grey.withOpacity(0.2),
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(
color: Color(0xFF3B82F6),
width: 2,
),
),
prefixIcon: const Icon(
Icons.calendar_today_rounded,
color: Color(0xFF3B82F6),
),
),
icon: const Icon(
Icons.arrow_drop_down_circle_outlined,
color: Color(0xFF3B82F6),
),
elevation: 2,
dropdownColor: Colors.white,
style: const TextStyle(
color: Colors.black87,
fontSize: 16,
fontWeight: FontWeight.w500,
),
items: ['월간', '연간', '주간']
.map((cycle) => DropdownMenuItem(
value: cycle,
child: Text(
cycle,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: Colors.black87,
),
),
))
.toList(),
),
],
),
),
// 다음 결제일 필드
AnimatedContainer(
duration: const Duration(milliseconds: 200),
margin: const EdgeInsets.only(bottom: 20),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
color: _currentEditingField == 3
? const Color(0xFF3B82F6).withOpacity(0.1)
: Colors.transparent,
),
padding: const EdgeInsets.all(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'다음 결제일',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: Color(0xFF3B82F6),
),
),
const SizedBox(height: 8),
InkWell(
focusNode: _nextBillingDateFocus,
onTap: () async {
setState(() => _currentEditingField = 3);
final DateTime? picked =
await showDatePicker(
context: context,
initialDate:
_nextBillingDate ?? DateTime.now(),
firstDate: DateTime.now(),
lastDate: DateTime.now().add(
const Duration(days: 365 * 2),
),
builder: (BuildContext context,
Widget? child) {
return Theme(
data: ThemeData.light().copyWith(
colorScheme: ColorScheme.light(
primary: _gradientColors[0],
onPrimary: Colors.white,
surface: Colors.white,
onSurface: Colors.black,
),
),
child: child!,
);
},
);
if (picked != null) {
setState(() {
_nextBillingDate = picked;
_currentEditingField = -1;
_websiteUrlFocus.requestFocus();
});
} else {
setState(() => _currentEditingField = -1);
}
},
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border.all(
color: _nextBillingDate == null
? Colors.red
: Colors.grey.withOpacity(0.2),
),
borderRadius: BorderRadius.circular(12),
color: Colors.white,
),
child: Row(
children: [
const Icon(
Icons.event_rounded,
color: Color(0xFF3B82F6),
),
const SizedBox(width: 12),
Text(
_nextBillingDate == null
? '결제일을 선택해주세요'
: DateFormat('yyyy년 MM월 dd일')
.format(_nextBillingDate!),
style: TextStyle(
fontSize: 16,
color: _nextBillingDate == null
? Colors.grey.shade500
: const Color(0xFF1E293B),
fontWeight: FontWeight.w500,
),
),
],
),
),
),
],
),
),
// 웹사이트 URL 필드
AnimatedContainer(
duration: const Duration(milliseconds: 200),
margin: const EdgeInsets.only(bottom: 20),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
color: _currentEditingField == 4
? const Color(0xFF3B82F6).withOpacity(0.1)
: Colors.transparent,
),
padding: const EdgeInsets.all(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'웹사이트 URL (선택)',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: Color(0xFF3B82F6),
),
),
const SizedBox(height: 8),
TextFormField(
controller: _websiteUrlController,
focusNode: _websiteUrlFocus,
textInputAction: TextInputAction.done,
onTap: () =>
setState(() => _currentEditingField = 4),
onEditingComplete: () {
setState(() => _currentEditingField = 5);
_categoryFocus.requestFocus();
},
style: const TextStyle(
color: Color(0xFF1E293B),
fontSize: 16,
fontWeight: FontWeight.w500,
),
decoration: InputDecoration(
filled: true,
fillColor: Colors.white,
contentPadding: const EdgeInsets.symmetric(
vertical: 16, horizontal: 20),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: Colors.grey.withOpacity(0.2),
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(
color: Color(0xFF3B82F6),
width: 2,
),
),
hintText: 'https://netflix.com',
hintStyle: TextStyle(
color: Colors.grey.shade500,
fontSize: 16,
),
),
),
],
),
),
// 카테고리 선택 필드
AnimatedContainer(
duration: const Duration(milliseconds: 200),
margin: const EdgeInsets.only(bottom: 20),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
color: _currentEditingField == 5
? const Color(0xFF3B82F6).withOpacity(0.1)
: Colors.transparent,
),
padding: const EdgeInsets.all(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'카테고리 (선택)',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: Color(0xFF3B82F6),
),
),
const SizedBox(height: 8),
Consumer<CategoryProvider>(
builder: (context, categoryProvider, child) {
// 카테고리가 없을 때 메시지 표시
if (categoryProvider.categories.isEmpty) {
return InkWell(
onTap: () {
// 서비스명 분석 후 카테고리 자동 선택
_autoSelectCategory();
},
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(
vertical: 16,
horizontal: 16,
),
decoration: BoxDecoration(
border: Border.all(
color:
Colors.grey.withOpacity(0.2),
),
borderRadius:
BorderRadius.circular(12),
color: Colors.white,
),
child: Row(
children: [
const Icon(
Icons.category_rounded,
color: Color(0xFF3B82F6),
),
const SizedBox(width: 12),
const Expanded(
child: Text(
'카테고리 자동 설정',
style: TextStyle(
color: Color(0xFF1E293B),
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
),
const Icon(
Icons.sync,
color: Color(0xFF3B82F6),
),
],
),
),
);
}
// 카테고리 드롭다운 표시
return DropdownButtonFormField<String>(
value: _selectedCategoryId,
focusNode: _categoryFocus,
onTap: () => setState(
() => _currentEditingField = 5),
onChanged: (value) {
setState(() {
_selectedCategoryId = value;
_currentEditingField = -1;
_categoryFocus.unfocus();
});
},
icon: const Icon(
Icons.arrow_drop_down_circle_outlined,
color: Color(0xFF3B82F6),
),
decoration: InputDecoration(
filled: true,
fillColor: Colors.white,
contentPadding:
const EdgeInsets.symmetric(
vertical: 16, horizontal: 20),
border: OutlineInputBorder(
borderRadius:
BorderRadius.circular(12),
borderSide: BorderSide(
color: Colors.grey.withOpacity(0.2),
),
),
focusedBorder: OutlineInputBorder(
borderRadius:
BorderRadius.circular(12),
borderSide: const BorderSide(
color: Color(0xFF3B82F6),
width: 2,
),
),
),
style: const TextStyle(
color: Colors.black87,
fontSize: 16,
fontWeight: FontWeight.w500,
),
hint: const Text(
'카테고리 선택',
style: TextStyle(
color: Colors.grey,
fontSize: 16,
),
),
isExpanded: true,
items: [
DropdownMenuItem<String>(
value: null,
child: const Text(
'카테고리 없음',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: Colors.grey,
),
),
),
...categoryProvider.categories
.map((category) {
return DropdownMenuItem<String>(
value: category.id,
child: Text(
category.name,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: Colors.black87,
),
),
);
}).toList(),
],
);
},
),
],
),
),
// 이벤트 설정 섹션
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: _isEventActive
? const Color(0xFF3B82F6)
: Colors.grey.withOpacity(0.2),
width: _isEventActive ? 2 : 1,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Checkbox(
value: _isEventActive,
onChanged: (value) {
setState(() {
_isEventActive = value ?? false;
if (!_isEventActive) {
// 이벤트 비활성화 시 관련 데이터 초기화
_eventStartDate = DateTime.now(); // 오늘로 재설정
_eventEndDate = DateTime.now().add(const Duration(days: 30)); // 30일 후로 재설정
_eventPriceController.clear();
} else {
// 이벤트 활성화 시 날짜가 null이면 기본값 설정
_eventStartDate ??= DateTime.now();
_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: _isEventActive
? const Color(0xFF3B82F6)
: Colors.grey,
),
],
),
// 이벤트 활성화 시 추가 필드 표시
AnimatedContainer(
duration: const Duration(milliseconds: 300),
height: _isEventActive ? null : 0,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 300),
opacity: _isEventActive ? 1.0 : 0.0,
child: Column(
children: [
const SizedBox(height: 16),
// 이벤트 기간 설정
Row(
children: [
// 시작일
Expanded(
child: InkWell(
onTap: () async {
final DateTime? picked = await showDatePicker(
context: context,
initialDate: _eventStartDate ?? DateTime.now(),
firstDate: DateTime.now().subtract(const Duration(days: 365)),
lastDate: DateTime.now().add(const Duration(days: 365 * 2)),
builder: (BuildContext context, Widget? child) {
return Theme(
data: ThemeData.light().copyWith(
colorScheme: ColorScheme.light(
primary: const Color(0xFF3B82F6),
onPrimary: Colors.white,
surface: Colors.white,
onSurface: Colors.black,
),
),
child: child!,
);
},
);
if (picked != null) {
setState(() {
_eventStartDate = picked;
});
}
},
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.withOpacity(0.3)),
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'시작일',
style: TextStyle(
fontSize: 12,
color: Colors.grey,
),
),
const SizedBox(height: 4),
Text(
_eventStartDate == null
? '선택'
: DateFormat('MM/dd').format(_eventStartDate!),
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
],
),
),
),
),
const SizedBox(width: 8),
const Icon(Icons.arrow_forward, color: Colors.grey),
const SizedBox(width: 8),
// 종료일
Expanded(
child: InkWell(
onTap: () async {
final DateTime? picked = await showDatePicker(
context: context,
initialDate: _eventEndDate ?? DateTime.now().add(const Duration(days: 30)),
firstDate: _eventStartDate ?? DateTime.now(),
lastDate: DateTime.now().add(const Duration(days: 365 * 2)),
builder: (BuildContext context, Widget? child) {
return Theme(
data: ThemeData.light().copyWith(
colorScheme: ColorScheme.light(
primary: const Color(0xFF3B82F6),
onPrimary: Colors.white,
surface: Colors.white,
onSurface: Colors.black,
),
),
child: child!,
);
},
);
if (picked != null) {
setState(() {
_eventEndDate = picked;
});
}
},
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.withOpacity(0.3)),
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'종료일',
style: TextStyle(
fontSize: 12,
color: Colors.grey,
),
),
const SizedBox(height: 4),
Text(
_eventEndDate == null
? '선택'
: DateFormat('MM/dd').format(_eventEndDate!),
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
],
),
),
),
),
],
),
const SizedBox(height: 16),
// 이벤트 가격 입력
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'이벤트 가격',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: Color(0xFF3B82F6),
),
),
const SizedBox(height: 8),
TextFormField(
controller: _eventPriceController,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp(r'[\d,.]')),
],
decoration: InputDecoration(
hintText: '할인된 가격을 입력하세요',
prefixText: _currency == 'KRW' ? '' : '\$ ',
filled: true,
fillColor: Colors.white,
contentPadding: const EdgeInsets.symmetric(
vertical: 16,
horizontal: 16,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: Colors.grey.withOpacity(0.2),
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(
color: Color(0xFF3B82F6),
width: 2,
),
),
),
onChanged: (value) {
// 콤마 자동 추가
if (value.isNotEmpty && !value.contains('.')) {
final number = int.tryParse(value.replaceAll(',', ''));
if (number != null) {
final formatted = NumberFormat('#,###').format(number);
if (formatted != value) {
_eventPriceController.value = TextEditingValue(
text: formatted,
selection: TextSelection.collapsed(
offset: formatted.length,
),
);
}
}
}
},
),
],
),
],
),
),
),
],
),
),
],
),
),
),
),
),
const SizedBox(height: 32),
// 저장 버튼
FadeTransition(
opacity: Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(
parent: _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: _animationController,
curve: const Interval(0.6, 1.0, curve: Curves.easeOutCubic),
)),
child: MouseRegion(
onEnter: (_) => setState(() => _isSaveHovered = true),
onExit: (_) => setState(() => _isSaveHovered = false),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: double.infinity,
height: 60,
transform: _isSaveHovered
? (Matrix4.identity()..scale(1.02))
: Matrix4.identity(),
child: ElevatedButton(
onPressed: _isLoading ? null : _saveSubscription,
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF3B82F6),
foregroundColor: Colors.white,
disabledBackgroundColor: Colors.grey.withOpacity(0.3),
disabledForegroundColor:
Colors.white.withOpacity(0.5),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
padding: const EdgeInsets.symmetric(vertical: 16),
elevation: _isSaveHovered ? 8 : 4,
shadowColor: const Color(0xFF3B82F6).withOpacity(0.5),
),
child: _isLoading
? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
Colors.white),
),
)
: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.add_circle_outline,
color: Colors.white,
size: _isSaveHovered ? 24 : 20,
),
const SizedBox(width: 8),
const Text(
'구독 추가하기',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
],
),
),
),
),
),
),
const SizedBox(height: 80),
],
),
),
),
);
}
}