diff --git a/lib/screens/add_subscription_screen_old.dart b/lib/screens/add_subscription_screen_old.dart deleted file mode 100644 index 0bc3f87..0000000 --- a/lib/screens/add_subscription_screen_old.dart +++ /dev/null @@ -1,2015 +0,0 @@ -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 '../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 createState() => _AddSubscriptionScreenState(); -} - -class _AddSubscriptionScreenState extends State - with SingleTickerProviderStateMixin { - final _formKey = GlobalKey(); - 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 _fadeAnimation; - late Animation _slideAnimation; - - // 스크롤 컨트롤러 - final ScrollController _scrollController = ScrollController(); - double _scrollOffset = 0; - - // 현재 편집 중인 필드 - int _currentEditingField = -1; - - // 호버 상태 - bool _isSaveHovered = false; - - final List _gradientColors = [ - const Color(0xFF3B82F6), - const Color(0xFF0EA5E9), - const Color(0xFF06B6D4), - ]; - - @override - void initState() { - super.initState(); - - // 결제일 기본값을 오늘 날짜로 설정 - _nextBillingDate = DateTime.now(); - - // 디버깅 정보 출력 - - // 서비스명 컨트롤러에 리스너 추가 - _serviceNameController.addListener(_onServiceNameChanged); - - // 애니메이션 컨트롤러 초기화 - _animationController = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 800), - ); - - _fadeAnimation = Tween( - begin: 0.0, - end: 1.0, - ).animate(CurvedAnimation( - parent: _animationController, - curve: Curves.easeIn, - )); - - _slideAnimation = Tween( - 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(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 _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 _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(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, true); // 성공 여부 반환 - } - } 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)); - - - return Scaffold( - backgroundColor: const Color(0xFFF8FAFC), - extendBodyBehindAppBar: true, - appBar: PreferredSize( - preferredSize: const Size.fromHeight(60), - child: Container( - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: appBarOpacity), - boxShadow: appBarOpacity > 0.6 - ? [ - BoxShadow( - color: Colors.black.withValues(alpha: 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.withValues(alpha: 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(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].withValues(alpha: 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.withValues(alpha: 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(begin: 0.0, end: 1.0).animate( - CurvedAnimation( - parent: _animationController, - curve: const Interval(0.2, 1.0, curve: Curves.easeIn), - ), - ), - child: SlideTransition( - position: Tween( - 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).withValues(alpha: 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.withValues(alpha: 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).withValues(alpha: 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( - 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( - 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.withValues(alpha: 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.withValues(alpha: - 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 - .withValues(alpha: 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).withValues(alpha: 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( - 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.withValues(alpha: 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).withValues(alpha: 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.withValues(alpha: 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).withValues(alpha: 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.withValues(alpha: 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).withValues(alpha: 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( - 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.withValues(alpha: 0.2), - ), - borderRadius: - BorderRadius.circular(12), - color: Colors.white, - ), - child: const 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( - 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.withValues(alpha: 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( - value: null, - child: const Text( - '카테고리 없음', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, - color: Colors.grey, - ), - ), - ), - ...categoryProvider.categories - .map((category) { - return DropdownMenuItem( - 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.withValues(alpha: 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: const ColorScheme.light( - primary: 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.withValues(alpha: 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: const ColorScheme.light( - primary: 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.withValues(alpha: 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.withValues(alpha: 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(begin: 0.0, end: 1.0).animate( - CurvedAnimation( - parent: _animationController, - curve: const Interval(0.6, 1.0, curve: Curves.easeIn), - ), - ), - child: SlideTransition( - position: Tween( - 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.withValues(alpha: 0.3), - disabledForegroundColor: - Colors.white.withValues(alpha: 0.5), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - padding: const EdgeInsets.symmetric(vertical: 16), - elevation: _isSaveHovered ? 8 : 4, - shadowColor: const Color(0xFF3B82F6).withValues(alpha: 0.5), - ), - child: _isLoading - ? const SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: AlwaysStoppedAnimation( - 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), - ], - ), - ), - ), - ); - } -} diff --git a/lib/screens/detail_screen_old.dart b/lib/screens/detail_screen_old.dart deleted file mode 100644 index e7c212f..0000000 --- a/lib/screens/detail_screen_old.dart +++ /dev/null @@ -1,2215 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; -import 'dart:math' as math; -import '../models/subscription_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 createState() => _DetailScreenState(); -} - -class _DetailScreenState extends State - with SingleTickerProviderStateMixin { - late TextEditingController _serviceNameController; - late TextEditingController _monthlyCostController; - late TextEditingController _websiteUrlController; - late String _billingCycle; - late DateTime _nextBillingDate; - late AnimationController _animationController; - late Animation _fadeAnimation; - late Animation _slideAnimation; - late Animation _rotateAnimation; - String? _selectedCategoryId; // 선택된 카테고리 ID - late String _currency; // 통화 단위: '원화' 또는 '달러' - - // 이벤트 관련 상태 변수 - 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( - begin: 0.0, - end: 1.0, - ).animate(CurvedAnimation( - parent: _animationController, - curve: const Interval(0.0, 0.6, curve: Curves.easeIn), - )); - - _slideAnimation = Tween( - begin: const Offset(0.0, 0.2), - end: Offset.zero, - ).animate(CurvedAnimation( - parent: _animationController, - curve: Curves.easeOutCubic, - )); - - _rotateAnimation = Tween( - 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(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 _updateSubscription() async { - final provider = Provider.of(context, listen: false); - - // 웹사이트 URL이 비어있는 경우 자동 매칭 다시 시도 - String? websiteUrl = _websiteUrlController.text; - if (websiteUrl.isEmpty) { - websiteUrl = - SubscriptionUrlMatcher.suggestUrl(_serviceNameController.text); - } - - // 구독 정보 업데이트 - // 콤마 제거하고 숫자만 추출 - double monthlyCost = 0.0; - try { - monthlyCost = - double.parse(_monthlyCostController.text.replaceAll(',', '')); - } catch (e) { - // 파싱 오류 발생 시 기본값 사용 - monthlyCost = 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)), - ), - ); - - // 변경 사항이 반영될 시간을 주기 위해 짧게 지연 후 결과 반환 - await Future.delayed(const Duration(milliseconds: 100)); - if (!context.mounted) return; - Navigator.of(context).pop(true); - } - } - - Future _deleteSubscription() async { - final confirmed = await showDialog( - 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(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 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]; - } - - - // 서비스명을 기반으로 카테고리 자동 선택 함수 - void _autoSelectCategory() { - if (_serviceNameController.text.isEmpty) return; - - final serviceName = _serviceNameController.text.toLowerCase(); - final categoryProvider = - Provider.of(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 _openCancellationPage() async { - final serviceName = widget.subscription.serviceName; - - // 해지 안내 페이지 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.withValues(alpha: appBarOpacity), - boxShadow: appBarOpacity > 0.6 - ? [ - BoxShadow( - color: Colors.black.withValues(alpha: 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.withValues(alpha: 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.withValues(alpha: 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.withValues(alpha: 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 - .withValues(alpha: 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.withValues(alpha: 0.8), - ), - ), - ], - ), - ), - ], - ), - const SizedBox(height: 20), - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 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.withValues(alpha: 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.withValues(alpha: 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) - .withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: Colors.white.withValues(alpha: 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( - 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( - 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.withValues(alpha: 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.withValues(alpha: 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.withValues(alpha: 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( - 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( - 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.withValues(alpha: 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.withValues(alpha: - 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 - .withValues(alpha: 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.withValues(alpha: 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( - 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.withValues(alpha: 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.withValues(alpha: 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.withValues(alpha: 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.withValues(alpha: 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.withValues(alpha: 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.withValues(alpha: 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( - 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.withValues(alpha: 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( - 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.withValues(alpha: 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( - value: null, - child: const Text( - '카테고리 없음', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, - color: Colors.grey, - ), - ), - ), - ...categoryProvider.categories - .map((category) { - return DropdownMenuItem( - 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.withValues(alpha: 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.withValues(alpha: 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.withValues(alpha: 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.withValues(alpha: 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( - 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.withValues(alpha: 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), - ], - ), - ), - ), - ); - } -} diff --git a/lib/widgets/cached_network_image_widget.dart b/lib/widgets/cached_network_image_widget.dart deleted file mode 100644 index f778517..0000000 --- a/lib/widgets/cached_network_image_widget.dart +++ /dev/null @@ -1,315 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:cached_network_image/cached_network_image.dart'; -import '../theme/app_colors.dart'; -import 'skeleton_loading.dart'; - -/// 최적화된 캐시 네트워크 이미지 위젯 -class OptimizedCachedNetworkImage extends StatelessWidget { - final String imageUrl; - final double? width; - final double? height; - final BoxFit fit; - final BorderRadius? borderRadius; - final Duration fadeInDuration; - final Duration fadeOutDuration; - final Widget? placeholder; - final Widget? errorWidget; - final Map? httpHeaders; - final bool enableMemoryCache; - final bool enableDiskCache; - final int? maxWidth; - final int? maxHeight; - - const OptimizedCachedNetworkImage({ - super.key, - required this.imageUrl, - this.width, - this.height, - this.fit = BoxFit.cover, - this.borderRadius, - this.fadeInDuration = const Duration(milliseconds: 300), - this.fadeOutDuration = const Duration(milliseconds: 300), - this.placeholder, - this.errorWidget, - this.httpHeaders, - this.enableMemoryCache = true, - this.enableDiskCache = true, - this.maxWidth, - this.maxHeight, - }); - - @override - Widget build(BuildContext context) { - // 성능 최적화를 위한 이미지 크기 계산 - final devicePixelRatio = MediaQuery.of(context).devicePixelRatio; - final optimalWidth = maxWidth ?? - (width != null ? (width! * devicePixelRatio).round() : null); - final optimalHeight = maxHeight ?? - (height != null ? (height! * devicePixelRatio).round() : null); - - Widget image = CachedNetworkImage( - imageUrl: imageUrl, - width: width, - height: height, - fit: fit, - fadeInDuration: fadeInDuration, - fadeOutDuration: fadeOutDuration, - httpHeaders: httpHeaders, - memCacheWidth: optimalWidth, - memCacheHeight: optimalHeight, - maxWidthDiskCache: optimalWidth, - maxHeightDiskCache: optimalHeight, - placeholder: (context, url) => placeholder ?? _buildDefaultPlaceholder(), - errorWidget: (context, url, error) => - errorWidget ?? _buildDefaultErrorWidget(), - imageBuilder: (context, imageProvider) { - return Container( - width: width, - height: height, - decoration: BoxDecoration( - borderRadius: borderRadius, - image: DecorationImage( - image: imageProvider, - fit: fit, - ), - ), - ); - }, - ); - - if (borderRadius != null) { - return ClipRRect( - borderRadius: borderRadius!, - child: image, - ); - } - - return image; - } - - Widget _buildDefaultPlaceholder() { - return SkeletonLoading( - width: width, - height: height, - borderRadius: borderRadius?.topLeft.x ?? 0, - ); - } - - Widget _buildDefaultErrorWidget() { - return Container( - width: width, - height: height, - decoration: BoxDecoration( - color: AppColors.surfaceColorAlt, - borderRadius: borderRadius, - ), - child: const Icon( - Icons.broken_image_outlined, - color: AppColors.textMuted, - size: 24, - ), - ); - } -} - -/// 프로그레시브 이미지 로더 (저화질 → 고화질) -class ProgressiveNetworkImage extends StatelessWidget { - final String thumbnailUrl; - final String imageUrl; - final double? width; - final double? height; - final BoxFit fit; - final BorderRadius? borderRadius; - - const ProgressiveNetworkImage({ - super.key, - required this.thumbnailUrl, - required this.imageUrl, - this.width, - this.height, - this.fit = BoxFit.cover, - this.borderRadius, - }); - - @override - Widget build(BuildContext context) { - return Stack( - fit: StackFit.passthrough, - children: [ - // 썸네일 (저화질) - OptimizedCachedNetworkImage( - imageUrl: thumbnailUrl, - width: width, - height: height, - fit: fit, - borderRadius: borderRadius, - fadeInDuration: Duration.zero, - ), - // 원본 이미지 (고화질) - OptimizedCachedNetworkImage( - imageUrl: imageUrl, - width: width, - height: height, - fit: fit, - borderRadius: borderRadius, - ), - ], - ); - } -} - -/// 이미지 갤러리 위젯 (메모리 효율적) -class OptimizedImageGallery extends StatefulWidget { - final List imageUrls; - final double itemHeight; - final double spacing; - final int crossAxisCount; - final void Function(int)? onImageTap; - - const OptimizedImageGallery({ - super.key, - required this.imageUrls, - this.itemHeight = 120, - this.spacing = 8, - this.crossAxisCount = 3, - this.onImageTap, - }); - - @override - State createState() => _OptimizedImageGalleryState(); -} - -class _OptimizedImageGalleryState extends State { - final ScrollController _scrollController = ScrollController(); - final Set _visibleIndices = {}; - - @override - void initState() { - super.initState(); - _scrollController.addListener(_onScroll); - // 초기 보이는 아이템 계산 - WidgetsBinding.instance.addPostFrameCallback((_) { - _calculateVisibleIndices(); - }); - } - - @override - void dispose() { - _scrollController.dispose(); - super.dispose(); - } - - void _onScroll() { - _calculateVisibleIndices(); - } - - void _calculateVisibleIndices() { - if (!mounted) return; - - final viewportHeight = context.size?.height ?? 0; - final scrollOffset = _scrollController.offset; - final itemHeight = widget.itemHeight + widget.spacing; - final itemsPerRow = widget.crossAxisCount; - - final firstVisibleRow = (scrollOffset / itemHeight).floor(); - final lastVisibleRow = ((scrollOffset + viewportHeight) / itemHeight).ceil(); - - final newVisibleIndices = {}; - for (int row = firstVisibleRow; row <= lastVisibleRow; row++) { - for (int col = 0; col < itemsPerRow; col++) { - final index = row * itemsPerRow + col; - if (index < widget.imageUrls.length) { - newVisibleIndices.add(index); - } - } - } - - if (!setEquals(_visibleIndices, newVisibleIndices)) { - setState(() { - _visibleIndices.clear(); - _visibleIndices.addAll(newVisibleIndices); - }); - } - } - - @override - Widget build(BuildContext context) { - return GridView.builder( - controller: _scrollController, - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: widget.crossAxisCount, - childAspectRatio: 1.0, - crossAxisSpacing: widget.spacing, - mainAxisSpacing: widget.spacing, - ), - itemCount: widget.imageUrls.length, - itemBuilder: (context, index) { - // 보이는 영역의 이미지만 로드 - if (_visibleIndices.contains(index) || - (index >= _visibleIndices.first - widget.crossAxisCount && - index <= _visibleIndices.last + widget.crossAxisCount)) { - return GestureDetector( - onTap: () => widget.onImageTap?.call(index), - child: OptimizedCachedNetworkImage( - imageUrl: widget.imageUrls[index], - fit: BoxFit.cover, - borderRadius: BorderRadius.circular(8), - ), - ); - } - - // 보이지 않는 영역은 플레이스홀더 - return Container( - decoration: BoxDecoration( - color: AppColors.surfaceColorAlt, - borderRadius: BorderRadius.circular(8), - ), - ); - }, - ); - } - - bool setEquals(Set a, Set b) { - if (a.length != b.length) return false; - for (final item in a) { - if (!b.contains(item)) return false; - } - return true; - } -} - -/// 히어로 애니메이션이 적용된 이미지 -class HeroNetworkImage extends StatelessWidget { - final String imageUrl; - final String heroTag; - final double? width; - final double? height; - final BoxFit fit; - final VoidCallback? onTap; - - const HeroNetworkImage({ - super.key, - required this.imageUrl, - required this.heroTag, - this.width, - this.height, - this.fit = BoxFit.cover, - this.onTap, - }); - - @override - Widget build(BuildContext context) { - return GestureDetector( - onTap: onTap, - child: Hero( - tag: heroTag, - child: OptimizedCachedNetworkImage( - imageUrl: imageUrl, - width: width, - height: height, - fit: fit, - ), - ), - ); - } -} \ No newline at end of file diff --git a/lib/widgets/lazy_loading_list.dart b/lib/widgets/lazy_loading_list.dart deleted file mode 100644 index 2c1da22..0000000 --- a/lib/widgets/lazy_loading_list.dart +++ /dev/null @@ -1,416 +0,0 @@ -import 'package:flutter/material.dart'; -import 'dart:async'; -import '../utils/performance_optimizer.dart'; -import '../widgets/skeleton_loading.dart'; - -/// 레이지 로딩이 적용된 리스트 위젯 -class LazyLoadingList extends StatefulWidget { - final Future> Function(int page, int pageSize) loadMore; - final Widget Function(BuildContext, T, int) itemBuilder; - final int pageSize; - final double scrollThreshold; - final Widget? loadingWidget; - final Widget? emptyWidget; - final Widget? errorWidget; - final bool enableRefresh; - final ScrollPhysics? physics; - final EdgeInsetsGeometry? padding; - - const LazyLoadingList({ - super.key, - required this.loadMore, - required this.itemBuilder, - this.pageSize = 20, - this.scrollThreshold = 0.8, - this.loadingWidget, - this.emptyWidget, - this.errorWidget, - this.enableRefresh = true, - this.physics, - this.padding, - }); - - @override - State> createState() => _LazyLoadingListState(); -} - -class _LazyLoadingListState extends State> { - final List _items = []; - final ScrollController _scrollController = ScrollController(); - - int _currentPage = 0; - bool _isLoading = false; - bool _hasMore = true; - String? _error; - - @override - void initState() { - super.initState(); - _loadInitialData(); - _scrollController.addListener(_onScroll); - } - - @override - void dispose() { - _scrollController.dispose(); - super.dispose(); - } - - void _onScroll() { - if (_isLoading || !_hasMore) return; - - final position = _scrollController.position; - final maxScroll = position.maxScrollExtent; - final currentScroll = position.pixels; - - if (currentScroll >= maxScroll * widget.scrollThreshold) { - _loadMoreData(); - } - } - - Future _loadInitialData() async { - setState(() { - _isLoading = true; - _error = null; - }); - - try { - final newItems = await PerformanceMeasure.measure( - name: 'Initial data load', - operation: () => widget.loadMore(0, widget.pageSize), - ); - - setState(() { - _items.clear(); - _items.addAll(newItems); - _currentPage = 0; - _hasMore = newItems.length >= widget.pageSize; - _isLoading = false; - }); - } catch (e) { - setState(() { - _error = e.toString(); - _isLoading = false; - }); - } - } - - Future _loadMoreData() async { - if (_isLoading || !_hasMore) return; - - setState(() { - _isLoading = true; - }); - - try { - final nextPage = _currentPage + 1; - final newItems = await PerformanceMeasure.measure( - name: 'Load more data (page $nextPage)', - operation: () => widget.loadMore(nextPage, widget.pageSize), - ); - - setState(() { - _items.addAll(newItems); - _currentPage = nextPage; - _hasMore = newItems.length >= widget.pageSize; - _isLoading = false; - }); - } catch (e) { - setState(() { - _error = e.toString(); - _isLoading = false; - }); - } - } - - Future _refresh() async { - await _loadInitialData(); - } - - @override - Widget build(BuildContext context) { - if (_error != null && _items.isEmpty) { - return Center( - child: widget.errorWidget ?? _buildDefaultErrorWidget(), - ); - } - - if (!_isLoading && _items.isEmpty) { - return Center( - child: widget.emptyWidget ?? _buildDefaultEmptyWidget(), - ); - } - - Widget listView = ListView.builder( - controller: _scrollController, - physics: widget.physics ?? PerformanceOptimizer.getOptimizedScrollPhysics(), - padding: widget.padding, - itemCount: _items.length + (_isLoading || _hasMore ? 1 : 0), - itemBuilder: (context, index) { - if (index < _items.length) { - return widget.itemBuilder(context, _items[index], index); - } - - // 로딩 인디케이터 - return Padding( - padding: const EdgeInsets.all(16.0), - child: Center( - child: widget.loadingWidget ?? _buildDefaultLoadingWidget(), - ), - ); - }, - ); - - if (widget.enableRefresh) { - return RefreshIndicator( - onRefresh: _refresh, - child: listView, - ); - } - - return listView; - } - - Widget _buildDefaultLoadingWidget() { - return const CircularProgressIndicator(); - } - - Widget _buildDefaultEmptyWidget() { - return Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.inbox_outlined, - size: 64, - color: Colors.grey[400], - ), - const SizedBox(height: 16), - Text( - '데이터가 없습니다', - style: TextStyle( - fontSize: 16, - color: Colors.grey[600], - ), - ), - ], - ); - } - - Widget _buildDefaultErrorWidget() { - return Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.error_outline, - size: 64, - color: Colors.red[400], - ), - const SizedBox(height: 16), - Text( - '오류가 발생했습니다', - style: TextStyle( - fontSize: 16, - color: Colors.grey[600], - ), - ), - const SizedBox(height: 8), - Text( - _error ?? '', - style: TextStyle( - fontSize: 14, - color: Colors.grey[500], - ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 16), - ElevatedButton( - onPressed: _loadInitialData, - child: const Text('다시 시도'), - ), - ], - ); - } -} - -/// 캐시가 적용된 레이지 로딩 리스트 -class CachedLazyLoadingList extends StatefulWidget { - final String cacheKey; - final Future> Function(int page, int pageSize) loadMore; - final Widget Function(BuildContext, T, int) itemBuilder; - final int pageSize; - final Duration cacheDuration; - final Widget? loadingWidget; - final Widget? emptyWidget; - - const CachedLazyLoadingList({ - super.key, - required this.cacheKey, - required this.loadMore, - required this.itemBuilder, - this.pageSize = 20, - this.cacheDuration = const Duration(minutes: 5), - this.loadingWidget, - this.emptyWidget, - }); - - @override - State> createState() => _CachedLazyLoadingListState(); -} - -class _CachedLazyLoadingListState extends State> { - final Map> _pageCache = {}; - - Future> _loadWithCache(int page, int pageSize) async { - // 캐시 확인 - if (_pageCache.containsKey(page)) { - return _pageCache[page]!; - } - - // 데이터 로드 - final items = await widget.loadMore(page, pageSize); - - // 캐시 저장 - _pageCache[page] = items; - - // 일정 시간 후 캐시 제거 - Timer(widget.cacheDuration, () { - if (mounted) { - setState(() { - _pageCache.remove(page); - }); - } - }); - - return items; - } - - @override - Widget build(BuildContext context) { - return LazyLoadingList( - loadMore: _loadWithCache, - itemBuilder: widget.itemBuilder, - pageSize: widget.pageSize, - loadingWidget: widget.loadingWidget, - emptyWidget: widget.emptyWidget, - ); - } -} - -/// 무한 스크롤 그리드 뷰 -class LazyLoadingGrid extends StatefulWidget { - final Future> Function(int page, int pageSize) loadMore; - final Widget Function(BuildContext, T, int) itemBuilder; - final int crossAxisCount; - final int pageSize; - final double scrollThreshold; - final double childAspectRatio; - final double crossAxisSpacing; - final double mainAxisSpacing; - final EdgeInsetsGeometry? padding; - - const LazyLoadingGrid({ - super.key, - required this.loadMore, - required this.itemBuilder, - required this.crossAxisCount, - this.pageSize = 20, - this.scrollThreshold = 0.8, - this.childAspectRatio = 1.0, - this.crossAxisSpacing = 8.0, - this.mainAxisSpacing = 8.0, - this.padding, - }); - - @override - State> createState() => _LazyLoadingGridState(); -} - -class _LazyLoadingGridState extends State> { - final List _items = []; - final ScrollController _scrollController = ScrollController(); - - int _currentPage = 0; - bool _isLoading = false; - bool _hasMore = true; - - @override - void initState() { - super.initState(); - _loadInitialData(); - _scrollController.addListener(_onScroll); - } - - @override - void dispose() { - _scrollController.dispose(); - super.dispose(); - } - - void _onScroll() { - if (_isLoading || !_hasMore) return; - - final position = _scrollController.position; - final maxScroll = position.maxScrollExtent; - final currentScroll = position.pixels; - - if (currentScroll >= maxScroll * widget.scrollThreshold) { - _loadMoreData(); - } - } - - Future _loadInitialData() async { - setState(() => _isLoading = true); - - final newItems = await widget.loadMore(0, widget.pageSize); - - setState(() { - _items.clear(); - _items.addAll(newItems); - _currentPage = 0; - _hasMore = newItems.length >= widget.pageSize; - _isLoading = false; - }); - } - - Future _loadMoreData() async { - if (_isLoading || !_hasMore) return; - - setState(() => _isLoading = true); - - final nextPage = _currentPage + 1; - final newItems = await widget.loadMore(nextPage, widget.pageSize); - - setState(() { - _items.addAll(newItems); - _currentPage = nextPage; - _hasMore = newItems.length >= widget.pageSize; - _isLoading = false; - }); - } - - @override - Widget build(BuildContext context) { - return GridView.builder( - controller: _scrollController, - padding: widget.padding, - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: widget.crossAxisCount, - childAspectRatio: widget.childAspectRatio, - crossAxisSpacing: widget.crossAxisSpacing, - mainAxisSpacing: widget.mainAxisSpacing, - ), - itemCount: _items.length + (_isLoading ? widget.crossAxisCount : 0), - itemBuilder: (context, index) { - if (index < _items.length) { - return widget.itemBuilder(context, _items[index], index); - } - - // 로딩 스켈레톤 - return const SkeletonLoading( - height: 100, - borderRadius: 12, - ); - }, - ); - } -} \ No newline at end of file