주요 구현 완료 기능: - 구독 관리 (추가/편집/삭제/카테고리 분류) - 이벤트 할인 시스템 (기본값 자동 설정) - SMS 자동 스캔 및 구독 정보 추출 - 알림 시스템 (타임존 처리 안정화) - 환율 변환 지원 (KRW/USD) - 반응형 UI 및 애니메이션 - 다국어 지원 (한국어/영어) 버그 수정: - NotificationService tz.local 초기화 오류 해결 - MainScreenSummaryCard 레이아웃 오버플로우 수정 - 구독 추가 시 LateInitializationError 완전 해결 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
2233 lines
112 KiB
Dart
2233 lines
112 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:provider/provider.dart';
|
|
import 'dart:math' as math;
|
|
import '../models/subscription_model.dart';
|
|
import '../models/category_model.dart';
|
|
import '../providers/subscription_provider.dart';
|
|
import '../providers/category_provider.dart';
|
|
import 'package:intl/intl.dart';
|
|
import '../widgets/website_icon.dart';
|
|
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
|
import '../services/subscription_url_matcher.dart';
|
|
import 'package:url_launcher/url_launcher.dart';
|
|
import 'package:flutter/services.dart'; // TextInputFormatter 사용을 위한 import 추가
|
|
import '../services/exchange_rate_service.dart'; // 환율 서비스만 사용
|
|
|
|
class DetailScreen extends StatefulWidget {
|
|
final SubscriptionModel subscription;
|
|
|
|
const DetailScreen({
|
|
super.key,
|
|
required this.subscription,
|
|
});
|
|
|
|
@override
|
|
State<DetailScreen> createState() => _DetailScreenState();
|
|
}
|
|
|
|
class _DetailScreenState extends State<DetailScreen>
|
|
with SingleTickerProviderStateMixin {
|
|
late TextEditingController _serviceNameController;
|
|
late TextEditingController _monthlyCostController;
|
|
late TextEditingController _websiteUrlController;
|
|
late String _billingCycle;
|
|
late DateTime _nextBillingDate;
|
|
late AnimationController _animationController;
|
|
late Animation<double> _fadeAnimation;
|
|
late Animation<Offset> _slideAnimation;
|
|
late Animation<double> _rotateAnimation;
|
|
String? _selectedCategoryId; // 선택된 카테고리 ID
|
|
late String _currency; // 통화 단위: '원화' 또는 '달러'
|
|
bool _isLoading = false; // 로딩 상태
|
|
|
|
// 이벤트 관련 상태 변수
|
|
late bool _isEventActive;
|
|
DateTime? _eventStartDate;
|
|
DateTime? _eventEndDate;
|
|
late TextEditingController _eventPriceController;
|
|
|
|
// 포커스 노드 추가
|
|
final _serviceNameFocus = FocusNode();
|
|
final _monthlyCostFocus = FocusNode();
|
|
final _billingCycleFocus = FocusNode();
|
|
final _nextBillingDateFocus = FocusNode();
|
|
final _websiteUrlFocus = FocusNode();
|
|
final _categoryFocus = FocusNode(); // 카테고리 포커스 노드
|
|
final _currencyFocus = FocusNode(); // 통화 단위 포커스 노드
|
|
|
|
final ScrollController _scrollController = ScrollController();
|
|
double _scrollOffset = 0;
|
|
|
|
// 현재 편집 중인 필드
|
|
int _currentEditingField = -1;
|
|
|
|
// 호버 상태
|
|
bool _isDeleteHovered = false;
|
|
bool _isSaveHovered = false;
|
|
bool _isCancelHovered = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_serviceNameController =
|
|
TextEditingController(text: widget.subscription.serviceName);
|
|
_monthlyCostController =
|
|
TextEditingController(text: widget.subscription.monthlyCost.toString());
|
|
_websiteUrlController =
|
|
TextEditingController(text: widget.subscription.websiteUrl ?? '');
|
|
_billingCycle = widget.subscription.billingCycle;
|
|
_nextBillingDate = widget.subscription.nextBillingDate;
|
|
_selectedCategoryId = widget.subscription.categoryId; // 카테고리 ID 설정
|
|
_currency = widget.subscription.currency; // 통화 단위 설정
|
|
|
|
// 이벤트 관련 초기화
|
|
_isEventActive = widget.subscription.isEventActive;
|
|
_eventStartDate = widget.subscription.eventStartDate;
|
|
_eventEndDate = widget.subscription.eventEndDate;
|
|
_eventPriceController = TextEditingController();
|
|
|
|
// 이벤트 가격 초기화
|
|
if (widget.subscription.eventPrice != null) {
|
|
if (_currency == 'KRW') {
|
|
_eventPriceController.text = NumberFormat.decimalPattern()
|
|
.format(widget.subscription.eventPrice!.toInt());
|
|
} else {
|
|
_eventPriceController.text =
|
|
NumberFormat('#,##0.00').format(widget.subscription.eventPrice!);
|
|
}
|
|
}
|
|
|
|
// 통화 단위에 따른 금액 표시 형식 조정
|
|
if (_currency == 'KRW') {
|
|
// 원화: 정수 형식으로 표시 (콤마 포함)
|
|
_monthlyCostController.text = NumberFormat.decimalPattern()
|
|
.format(widget.subscription.monthlyCost.toInt());
|
|
} else {
|
|
// 달러: 소수점 2자리까지 표시 (콤마 포함)
|
|
_monthlyCostController.text =
|
|
NumberFormat('#,##0.00').format(widget.subscription.monthlyCost);
|
|
}
|
|
|
|
// 카테고리 ID가 없으면 서비스명 기반으로 자동 선택 시도
|
|
if (_selectedCategoryId == null) {
|
|
_autoSelectCategory();
|
|
}
|
|
|
|
// 서비스명 컨트롤러에 리스너 추가
|
|
_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: const Interval(0.0, 0.6, curve: Curves.easeIn),
|
|
));
|
|
|
|
_slideAnimation = Tween<Offset>(
|
|
begin: const Offset(0.0, 0.2),
|
|
end: Offset.zero,
|
|
).animate(CurvedAnimation(
|
|
parent: _animationController,
|
|
curve: Curves.easeOutCubic,
|
|
));
|
|
|
|
_rotateAnimation = Tween<double>(
|
|
begin: 0.0,
|
|
end: 2 * math.pi,
|
|
).animate(CurvedAnimation(
|
|
parent: _animationController,
|
|
curve: const Interval(0.0, 0.5, curve: Curves.easeOutBack),
|
|
));
|
|
|
|
_scrollController.addListener(() {
|
|
setState(() {
|
|
_scrollOffset = _scrollController.offset;
|
|
});
|
|
});
|
|
|
|
_animationController.forward();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
// 서비스명 컨트롤러 리스너 제거
|
|
_serviceNameController.removeListener(_onServiceNameChanged);
|
|
|
|
// 카테고리가 변경되었으면 구독 정보 업데이트
|
|
if (_selectedCategoryId != widget.subscription.categoryId) {
|
|
widget.subscription.categoryId = _selectedCategoryId;
|
|
final provider =
|
|
Provider.of<SubscriptionProvider>(context, listen: false);
|
|
provider.updateSubscription(widget.subscription);
|
|
}
|
|
|
|
_serviceNameController.dispose();
|
|
_monthlyCostController.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() {
|
|
// 웹사이트 URL이 비어있거나 기존 URL이 서비스와 매칭되지 않는 경우에만 자동 매칭
|
|
if (_serviceNameController.text.isNotEmpty &&
|
|
(_websiteUrlController.text.isEmpty ||
|
|
SubscriptionUrlMatcher.findMatchingUrl(
|
|
_serviceNameController.text) !=
|
|
_websiteUrlController.text)) {
|
|
// 자동 URL 매칭 시도
|
|
final suggestedUrl =
|
|
SubscriptionUrlMatcher.suggestUrl(_serviceNameController.text);
|
|
|
|
// 매칭된 URL이 있으면 텍스트 컨트롤러에 설정
|
|
if (suggestedUrl != null && suggestedUrl.isNotEmpty) {
|
|
setState(() {
|
|
_websiteUrlController.text = suggestedUrl;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
// 구독 정보 업데이트
|
|
final oldCategoryId = widget.subscription.categoryId;
|
|
final newCategoryId = _selectedCategoryId;
|
|
|
|
// 콤마 제거하고 숫자만 추출
|
|
double monthlyCost = 0.0;
|
|
try {
|
|
monthlyCost =
|
|
double.parse(_monthlyCostController.text.replaceAll(',', ''));
|
|
} catch (e) {
|
|
// 파싱 오류 발생 시 기본값 사용
|
|
monthlyCost = widget.subscription.monthlyCost;
|
|
}
|
|
|
|
widget.subscription.serviceName = _serviceNameController.text;
|
|
widget.subscription.monthlyCost = monthlyCost;
|
|
widget.subscription.websiteUrl = websiteUrl;
|
|
widget.subscription.billingCycle = _billingCycle;
|
|
widget.subscription.nextBillingDate = _nextBillingDate;
|
|
widget.subscription.categoryId = _selectedCategoryId; // 카테고리 업데이트
|
|
widget.subscription.currency = _currency; // 통화 단위 업데이트
|
|
|
|
// 이벤트 정보 업데이트
|
|
widget.subscription.isEventActive = _isEventActive;
|
|
widget.subscription.eventStartDate = _eventStartDate;
|
|
widget.subscription.eventEndDate = _eventEndDate;
|
|
|
|
// 이벤트 가격 파싱
|
|
if (_isEventActive && _eventPriceController.text.isNotEmpty) {
|
|
try {
|
|
widget.subscription.eventPrice =
|
|
double.parse(_eventPriceController.text.replaceAll(',', ''));
|
|
} catch (e) {
|
|
widget.subscription.eventPrice = null;
|
|
}
|
|
} else {
|
|
widget.subscription.eventPrice = null;
|
|
}
|
|
|
|
// 구독 업데이트
|
|
await provider.updateSubscription(widget.subscription);
|
|
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Row(
|
|
children: [
|
|
const Icon(Icons.check_circle_rounded, color: Colors.white),
|
|
const SizedBox(width: 12),
|
|
const Text('구독 정보가 업데이트되었습니다.'),
|
|
],
|
|
),
|
|
behavior: SnackBarBehavior.floating,
|
|
backgroundColor: const Color(0xFF10B981),
|
|
duration: const Duration(seconds: 2),
|
|
shape:
|
|
RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
|
),
|
|
);
|
|
|
|
// 변경 사항이 반영될 시간을 주기 위해 짧게 지연 후 결과 반환
|
|
// 카테고리가 변경된 경우에만 true를 반환
|
|
final categoryChanged = oldCategoryId != newCategoryId;
|
|
await Future.delayed(const Duration(milliseconds: 100));
|
|
Navigator.of(context).pop(true);
|
|
}
|
|
}
|
|
|
|
Future<void> _deleteSubscription() async {
|
|
final confirmed = await showDialog<bool>(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
|
title: const Text(
|
|
'구독 삭제',
|
|
style: TextStyle(
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: 20,
|
|
),
|
|
),
|
|
content: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Container(
|
|
padding: const EdgeInsets.all(16),
|
|
decoration: BoxDecoration(
|
|
color: const Color(0xFFFEF2F2),
|
|
borderRadius: BorderRadius.circular(16),
|
|
),
|
|
child: const Icon(
|
|
Icons.warning_amber_rounded,
|
|
color: Color(0xFFDC2626),
|
|
size: 48,
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
const Text('이 구독을 삭제하시겠습니까?\n이 작업은 되돌릴 수 없습니다.'),
|
|
],
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(context).pop(false),
|
|
child: const Text('취소'),
|
|
),
|
|
ElevatedButton(
|
|
onPressed: () => Navigator.of(context).pop(true),
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: const Color(0xFFDC2626),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
),
|
|
child: const Text(
|
|
'삭제',
|
|
style: TextStyle(color: Colors.white),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
if (confirmed == true && mounted) {
|
|
final provider =
|
|
Provider.of<SubscriptionProvider>(context, listen: false);
|
|
await provider.deleteSubscription(widget.subscription.id);
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Row(
|
|
children: [
|
|
const Icon(Icons.delete_forever_rounded, color: Colors.white),
|
|
const SizedBox(width: 12),
|
|
const Text('구독이 삭제되었습니다.'),
|
|
],
|
|
),
|
|
behavior: SnackBarBehavior.floating,
|
|
backgroundColor: const Color(0xFFDC2626),
|
|
duration: const Duration(seconds: 2),
|
|
shape:
|
|
RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
|
),
|
|
);
|
|
Navigator.of(context).pop();
|
|
}
|
|
}
|
|
}
|
|
|
|
// 배경 그라데이션과 색상 가져오기
|
|
Color _getCardColor() {
|
|
// 서비스 이름에 따라 일관된 색상 생성
|
|
final int hash = widget.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.withOpacity(0.7),
|
|
],
|
|
begin: Alignment.topLeft,
|
|
end: Alignment.bottomRight,
|
|
);
|
|
}
|
|
|
|
// 서비스명을 기반으로 카테고리 자동 선택 함수
|
|
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;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// URL을 외부 앱에서 여는 함수
|
|
Future<void> _openCancellationPage() async {
|
|
final serviceName = widget.subscription.serviceName;
|
|
final websiteUrl = widget.subscription.websiteUrl;
|
|
|
|
// 해지 안내 페이지 URL 찾기
|
|
final cancellationUrl =
|
|
SubscriptionUrlMatcher.findCancellationUrl(serviceName);
|
|
|
|
if (cancellationUrl == null) {
|
|
// 해지 안내 페이지가 없는 경우 사용자에게 안내
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: const Text('공식 해지 안내 페이지가 제공되지 않는 서비스입니다.'),
|
|
behavior: SnackBarBehavior.floating,
|
|
backgroundColor: Colors.grey.shade700,
|
|
duration: const Duration(seconds: 2),
|
|
shape:
|
|
RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
|
),
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
try {
|
|
final Uri url = Uri.parse(cancellationUrl);
|
|
if (await canLaunchUrl(url)) {
|
|
await launchUrl(url, mode: LaunchMode.externalApplication);
|
|
} else {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: const Text('해지 안내 페이지를 열 수 없습니다.'),
|
|
behavior: SnackBarBehavior.floating,
|
|
backgroundColor: Colors.red.shade700,
|
|
duration: const Duration(seconds: 2),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(10)),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text('오류가 발생했습니다: $e'),
|
|
behavior: SnackBarBehavior.floating,
|
|
backgroundColor: Colors.red.shade700,
|
|
duration: const Duration(seconds: 2),
|
|
shape:
|
|
RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final daysUntilBilling =
|
|
widget.subscription.nextBillingDate.difference(DateTime.now()).inDays;
|
|
final isNearBilling = daysUntilBilling <= 7;
|
|
final baseColor = _getCardColor();
|
|
final double appBarOpacity = math.max(0, math.min(1, _scrollOffset / 150));
|
|
|
|
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: 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 (SubscriptionUrlMatcher.hasCancellationPage(
|
|
widget.subscription.serviceName))
|
|
MouseRegion(
|
|
onEnter: (_) => setState(() => _isCancelHovered = true),
|
|
onExit: (_) => setState(() => _isCancelHovered = false),
|
|
child: AnimatedContainer(
|
|
duration: const Duration(milliseconds: 200),
|
|
margin: const EdgeInsets.symmetric(
|
|
horizontal: 8, vertical: 6),
|
|
decoration: BoxDecoration(
|
|
color: _isCancelHovered
|
|
? const Color(0xFFF1F5F9)
|
|
: Colors.transparent,
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: TextButton.icon(
|
|
icon: const Icon(
|
|
Icons.open_in_browser,
|
|
size: 18,
|
|
color: Color(0xFF6B7280),
|
|
),
|
|
label: const Text(
|
|
'해지 안내',
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w500,
|
|
color: Color(0xFF6B7280),
|
|
),
|
|
),
|
|
onPressed: _openCancellationPage,
|
|
style: TextButton.styleFrom(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 10, vertical: 6),
|
|
minimumSize: Size.zero,
|
|
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
MouseRegion(
|
|
onEnter: (_) => setState(() => _isDeleteHovered = true),
|
|
onExit: (_) => setState(() => _isDeleteHovered = false),
|
|
child: AnimatedContainer(
|
|
duration: const Duration(milliseconds: 200),
|
|
margin: const EdgeInsets.only(right: 8),
|
|
decoration: BoxDecoration(
|
|
color: _isDeleteHovered
|
|
? const Color(0xFFFEF2F2)
|
|
: Colors.transparent,
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: IconButton(
|
|
icon: const FaIcon(FontAwesomeIcons.trashCan,
|
|
size: 20, color: Color(0xFFDC2626)),
|
|
tooltip: '삭제',
|
|
onPressed: _deleteSubscription,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
body: SingleChildScrollView(
|
|
controller: _scrollController,
|
|
physics: const BouncingScrollPhysics(),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16.0),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
SizedBox(height: MediaQuery.of(context).padding.top + 60),
|
|
FadeTransition(
|
|
opacity: _fadeAnimation,
|
|
child: SlideTransition(
|
|
position: _slideAnimation,
|
|
child: Hero(
|
|
tag: 'subscription_${widget.subscription.id}',
|
|
child: Card(
|
|
elevation: 8,
|
|
shadowColor: baseColor.withOpacity(0.4),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(24),
|
|
),
|
|
child: Container(
|
|
constraints: BoxConstraints(
|
|
maxHeight:
|
|
MediaQuery.of(context).size.height * 0.3),
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(24),
|
|
gradient: LinearGradient(
|
|
begin: Alignment.topLeft,
|
|
end: Alignment.bottomRight,
|
|
colors: [
|
|
baseColor.withOpacity(0.8),
|
|
baseColor,
|
|
],
|
|
),
|
|
),
|
|
padding: const EdgeInsets.all(24),
|
|
child: SingleChildScrollView(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
AnimatedBuilder(
|
|
animation: _animationController,
|
|
builder: (context, child) {
|
|
return Transform.rotate(
|
|
angle: _rotateAnimation.value,
|
|
child: Container(
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white,
|
|
borderRadius:
|
|
BorderRadius.circular(16),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black
|
|
.withOpacity(0.1),
|
|
blurRadius: 10,
|
|
spreadRadius: 0,
|
|
),
|
|
],
|
|
),
|
|
child: WebsiteIcon(
|
|
key: ValueKey(
|
|
'detail_icon_${widget.subscription.id}'),
|
|
url: widget.subscription.websiteUrl,
|
|
serviceName:
|
|
widget.subscription.serviceName,
|
|
size: 48,
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
const SizedBox(width: 16),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment:
|
|
CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
widget.subscription.serviceName,
|
|
style: const TextStyle(
|
|
fontSize: 28,
|
|
fontWeight: FontWeight.w800,
|
|
color: Colors.white,
|
|
letterSpacing: -0.5,
|
|
shadows: [
|
|
Shadow(
|
|
color: Colors.black26,
|
|
offset: Offset(0, 2),
|
|
blurRadius: 4,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
'${widget.subscription.billingCycle} 결제',
|
|
style: TextStyle(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.w500,
|
|
color:
|
|
Colors.white.withOpacity(0.8),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 20),
|
|
Container(
|
|
padding: const EdgeInsets.all(16),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white.withOpacity(0.15),
|
|
borderRadius: BorderRadius.circular(16),
|
|
),
|
|
child: Row(
|
|
mainAxisAlignment:
|
|
MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Column(
|
|
crossAxisAlignment:
|
|
CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'다음 결제일',
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w500,
|
|
color:
|
|
Colors.white.withOpacity(0.8),
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
DateFormat('yyyy년 MM월 dd일').format(
|
|
widget.subscription
|
|
.nextBillingDate),
|
|
style: const TextStyle(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.w700,
|
|
color: Colors.white,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
Column(
|
|
crossAxisAlignment:
|
|
CrossAxisAlignment.end,
|
|
children: [
|
|
Text(
|
|
'월 지출',
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w500,
|
|
color:
|
|
Colors.white.withOpacity(0.8),
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
NumberFormat.currency(
|
|
locale: _currency == 'KRW'
|
|
? 'ko_KR'
|
|
: 'en_US',
|
|
symbol:
|
|
_currency == 'KRW' ? '₩' : '\$',
|
|
decimalDigits:
|
|
_currency == 'KRW' ? 0 : 2,
|
|
).format(
|
|
widget.subscription.monthlyCost),
|
|
style: const TextStyle(
|
|
fontSize: 28,
|
|
fontWeight: FontWeight.w800,
|
|
color: Colors.white,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
if (isNearBilling) ...[
|
|
const SizedBox(height: 16),
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 12,
|
|
vertical: 10,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: const Color(0xFFDC2626)
|
|
.withOpacity(0.2),
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(
|
|
color: Colors.white.withOpacity(0.3),
|
|
width: 1,
|
|
),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
const Icon(
|
|
Icons.access_time_rounded,
|
|
size: 20,
|
|
color: Colors.white,
|
|
),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(
|
|
daysUntilBilling == 0
|
|
? '오늘 결제 예정'
|
|
: '$daysUntilBilling일 후 결제 예정',
|
|
style: const TextStyle(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.w600,
|
|
color: Colors.white,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 32),
|
|
FadeTransition(
|
|
opacity: _fadeAnimation,
|
|
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: Text(
|
|
'구독 정보 수정',
|
|
style: TextStyle(
|
|
fontSize: 20,
|
|
fontWeight: FontWeight.w700,
|
|
color: baseColor,
|
|
letterSpacing: -0.5,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
FadeTransition(
|
|
opacity: _fadeAnimation,
|
|
child: SlideTransition(
|
|
position: Tween<Offset>(
|
|
begin: const Offset(0.0, 0.6),
|
|
end: Offset.zero,
|
|
).animate(CurvedAnimation(
|
|
parent: _animationController,
|
|
curve: const Interval(0.3, 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,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
// 서비스명 필드
|
|
AnimatedContainer(
|
|
duration: const Duration(milliseconds: 200),
|
|
margin: const EdgeInsets.only(bottom: 20),
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(16),
|
|
color: _currentEditingField == 0
|
|
? baseColor.withOpacity(0.1)
|
|
: Colors.transparent,
|
|
),
|
|
padding: const EdgeInsets.all(8),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'서비스명',
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w500,
|
|
color: baseColor,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
TextFormField(
|
|
controller: _serviceNameController,
|
|
focusNode: _serviceNameFocus,
|
|
textInputAction: TextInputAction.next,
|
|
onTap: () =>
|
|
setState(() => _currentEditingField = 0),
|
|
onEditingComplete: () {
|
|
_monthlyCostFocus.requestFocus();
|
|
setState(() => _currentEditingField = -1);
|
|
},
|
|
style: const TextStyle(
|
|
color: Color(0xFF1E293B),
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
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: BorderSide(
|
|
color: baseColor,
|
|
width: 2,
|
|
),
|
|
),
|
|
prefixIcon: Icon(
|
|
Icons.business_rounded,
|
|
color: baseColor,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
// 월 비용 필드
|
|
AnimatedContainer(
|
|
duration: const Duration(milliseconds: 200),
|
|
margin: const EdgeInsets.only(bottom: 20),
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(16),
|
|
color: _currentEditingField == 1
|
|
? baseColor.withOpacity(0.1)
|
|
: Colors.transparent,
|
|
),
|
|
padding: const EdgeInsets.all(8),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// 환율 정보와 비용 입력 제목 표시 (상단)
|
|
Row(
|
|
mainAxisAlignment:
|
|
MainAxisAlignment.spaceBetween,
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
children: [
|
|
Text(
|
|
'비용 입력',
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w500,
|
|
color: baseColor,
|
|
),
|
|
),
|
|
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: BorderSide(
|
|
color: baseColor,
|
|
width: 2,
|
|
),
|
|
),
|
|
),
|
|
icon: Icon(
|
|
Icons.arrow_drop_down,
|
|
color: baseColor,
|
|
),
|
|
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
|
|
? baseColor
|
|
: 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: TextStyle(
|
|
color: baseColor,
|
|
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
|
|
? baseColor.withOpacity(0.1)
|
|
: Colors.transparent,
|
|
),
|
|
padding: const EdgeInsets.all(8),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'결제 주기',
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w500,
|
|
color: baseColor,
|
|
),
|
|
),
|
|
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: BorderSide(
|
|
color: baseColor,
|
|
width: 2,
|
|
),
|
|
),
|
|
prefixIcon: Icon(
|
|
Icons.calendar_today_rounded,
|
|
color: baseColor,
|
|
),
|
|
),
|
|
dropdownColor: Colors.white,
|
|
style: const TextStyle(
|
|
color: Colors.black87,
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
items: ['월간', '연간', '주간', '일간']
|
|
.map((cycle) => DropdownMenuItem(
|
|
value: cycle,
|
|
child: Text(cycle),
|
|
))
|
|
.toList(),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
// 다음 결제일 필드
|
|
AnimatedContainer(
|
|
duration: const Duration(milliseconds: 200),
|
|
margin: const EdgeInsets.only(bottom: 20),
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(16),
|
|
color: _currentEditingField == 3
|
|
? baseColor.withOpacity(0.1)
|
|
: Colors.transparent,
|
|
),
|
|
padding: const EdgeInsets.all(8),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'다음 결제일',
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w500,
|
|
color: baseColor,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
InkWell(
|
|
focusNode: _nextBillingDateFocus,
|
|
onTap: () async {
|
|
setState(() => _currentEditingField = 3);
|
|
final DateTime? picked =
|
|
await showDatePicker(
|
|
context: context,
|
|
initialDate: _nextBillingDate,
|
|
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: baseColor,
|
|
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: Colors.grey.withOpacity(0.2),
|
|
),
|
|
borderRadius: BorderRadius.circular(12),
|
|
color: Colors.white,
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
Icons.event_rounded,
|
|
color: baseColor,
|
|
),
|
|
const SizedBox(width: 12),
|
|
Text(
|
|
DateFormat('yyyy년 MM월 dd일')
|
|
.format(_nextBillingDate),
|
|
style: const TextStyle(
|
|
fontSize: 16,
|
|
color: 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
|
|
? baseColor.withOpacity(0.1)
|
|
: Colors.transparent,
|
|
),
|
|
padding: const EdgeInsets.all(8),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'웹사이트 URL (선택)',
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w500,
|
|
color: baseColor,
|
|
),
|
|
),
|
|
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.all(16),
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
borderSide: BorderSide(
|
|
color: Colors.grey.withOpacity(0.2),
|
|
),
|
|
),
|
|
focusedBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
borderSide: BorderSide(
|
|
color: baseColor,
|
|
width: 2,
|
|
),
|
|
),
|
|
hintText: 'https://example.com',
|
|
hintStyle: TextStyle(
|
|
color: Colors.grey.shade500,
|
|
fontSize: 16,
|
|
),
|
|
prefixIcon: Icon(
|
|
Icons.language_rounded,
|
|
color: baseColor,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
// 카테고리 선택 필드 추가
|
|
AnimatedContainer(
|
|
duration: const Duration(milliseconds: 200),
|
|
margin: const EdgeInsets.only(bottom: 20),
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(16),
|
|
color: _currentEditingField == 5
|
|
? baseColor.withOpacity(0.1)
|
|
: Colors.transparent,
|
|
),
|
|
padding: const EdgeInsets.all(8),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'카테고리 (선택)',
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w500,
|
|
color: baseColor,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Consumer<CategoryProvider>(
|
|
builder: (context, categoryProvider, child) {
|
|
// 카테고리가 없을 때 메시지 표시
|
|
if (categoryProvider.categories.isEmpty) {
|
|
return 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: [
|
|
Icon(
|
|
Icons.category_rounded,
|
|
color: baseColor,
|
|
),
|
|
const SizedBox(width: 12),
|
|
const Text(
|
|
'카테고리가 없습니다',
|
|
style: TextStyle(
|
|
color: Colors.grey,
|
|
fontSize: 16,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
// 카테고리 드롭다운 표시
|
|
return DropdownButtonFormField<String>(
|
|
value: _selectedCategoryId,
|
|
focusNode: _categoryFocus,
|
|
onTap: () => setState(
|
|
() => _currentEditingField = 5),
|
|
onChanged: (value) {
|
|
setState(() {
|
|
_selectedCategoryId = value;
|
|
_currentEditingField = -1;
|
|
_categoryFocus.unfocus();
|
|
});
|
|
},
|
|
icon: Icon(
|
|
Icons.arrow_drop_down_circle_outlined,
|
|
color: baseColor,
|
|
),
|
|
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: BorderSide(
|
|
color: baseColor,
|
|
width: 2,
|
|
),
|
|
),
|
|
prefixIcon: Icon(
|
|
Icons.category_rounded,
|
|
color: baseColor,
|
|
),
|
|
),
|
|
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
|
|
? baseColor
|
|
: 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 = null;
|
|
_eventEndDate = null;
|
|
_eventPriceController.clear();
|
|
}
|
|
});
|
|
},
|
|
activeColor: baseColor,
|
|
),
|
|
const Text(
|
|
'이벤트/할인 설정',
|
|
style: TextStyle(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.w600,
|
|
color: Color(0xFF1E293B),
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Icon(
|
|
Icons.local_offer,
|
|
size: 20,
|
|
color: _isEventActive
|
|
? baseColor
|
|
: 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: baseColor,
|
|
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: baseColor,
|
|
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: [
|
|
Text(
|
|
'이벤트 가격',
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w500,
|
|
color: baseColor,
|
|
),
|
|
),
|
|
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: BorderSide(
|
|
color: baseColor,
|
|
width: 2,
|
|
),
|
|
),
|
|
prefixIcon: Icon(
|
|
Icons.sell,
|
|
color: baseColor,
|
|
),
|
|
),
|
|
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: _fadeAnimation,
|
|
child: SlideTransition(
|
|
position: Tween<Offset>(
|
|
begin: const Offset(0.0, 0.8),
|
|
end: Offset.zero,
|
|
).animate(CurvedAnimation(
|
|
parent: _animationController,
|
|
curve: const Interval(0.4, 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: _updateSubscription,
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: baseColor,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(16),
|
|
),
|
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
|
elevation: _isSaveHovered ? 8 : 4,
|
|
shadowColor: baseColor.withOpacity(0.5),
|
|
),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(
|
|
Icons.save_rounded,
|
|
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),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|