/// 일정 화면 (MVCS의 View 레이어) /// /// UI 렌더링만 담당하고, 비즈니스 로직은 Controller에 위임합니다. library; import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:expandable_page_view/expandable_page_view.dart'; import '../../core/constants.dart'; import '../../models/schedule.dart'; import '../../controllers/schedule_controller.dart'; import 'widgets/schedule_card.dart'; import 'widgets/search_card.dart'; class ScheduleView extends ConsumerStatefulWidget { const ScheduleView({super.key}); @override ConsumerState createState() => _ScheduleViewState(); } class _ScheduleViewState extends ConsumerState with SingleTickerProviderStateMixin { final ScrollController _dateScrollController = ScrollController(); final ScrollController _contentScrollController = ScrollController(); final ScrollController _searchScrollController = ScrollController(); final TextEditingController _searchInputController = TextEditingController(); final FocusNode _searchFocusNode = FocusNode(); DateTime? _lastSelectedDate; // 검색 모드 상태 bool _isSearchMode = false; // 추천 검색어 화면 표시 여부 (입력창 포커스 시) bool _showSuggestions = false; // 디바운스 타이머 Timer? _debounceTimer; // 마지막 검색어 (뒤로가기 시 복원용) String _lastSearchTerm = ''; // 달력 팝업 상태 bool _showCalendar = false; DateTime _calendarViewDate = DateTime.now(); bool _showYearMonthPicker = false; int _yearRangeStart = (DateTime.now().year ~/ 12) * 12; // 달력 PageView 컨트롤러 late PageController _calendarPageController; late PageController _yearPageController; static const int _initialPage = 1000; // 달력 애니메이션 late AnimationController _calendarAnimController; late Animation _calendarAnimation; @override void initState() { super.initState(); _calendarPageController = PageController(initialPage: _initialPage); _yearPageController = PageController(initialPage: _initialPage); _calendarAnimController = AnimationController( duration: const Duration(milliseconds: 200), vsync: this, ); _calendarAnimation = CurvedAnimation( parent: _calendarAnimController, curve: Curves.easeOut, reverseCurve: Curves.easeIn, ); // 검색 무한 스크롤 리스너 _searchScrollController.addListener(_onSearchScroll); } /// 검색 스크롤 리스너 (무한 스크롤) void _onSearchScroll() { // 스크롤이 끝에서 500px 전에 다음 페이지 미리 로드 if (_searchScrollController.position.pixels >= _searchScrollController.position.maxScrollExtent - 500) { ref.read(searchProvider.notifier).loadMore(); } } /// 검색 모드 진입 void _enterSearchMode() { setState(() { _isSearchMode = true; _showSuggestions = true; }); // 검색 입력창 포커스 WidgetsBinding.instance.addPostFrameCallback((_) { _searchFocusNode.requestFocus(); }); } /// 검색 모드 종료 void _exitSearchMode() { _debounceTimer?.cancel(); setState(() { _isSearchMode = false; _showSuggestions = false; _searchInputController.clear(); }); ref.read(searchProvider.notifier).clear(); ref.read(suggestionProvider.notifier).clear(); _searchFocusNode.unfocus(); // 검색 종료 시 선택된 날짜로 스크롤 final selectedDate = ref.read(scheduleProvider).selectedDate; WidgetsBinding.instance.addPostFrameCallback((_) { _scrollToSelectedDate(selectedDate); }); } /// 추천 검색어 화면 표시 (유튜브 스타일) void _showSuggestionsScreen() { setState(() { _showSuggestions = true; }); _searchFocusNode.requestFocus(); } /// 추천 검색어 화면에서 뒤로가기 (검색 결과가 있으면 결과 화면으로) void _hideSuggestionsScreen() { final searchState = ref.read(searchProvider); if (searchState.results.isNotEmpty) { // 검색 결과가 있으면 결과 화면으로 (검색어 복원) setState(() { _showSuggestions = false; _searchInputController.text = _lastSearchTerm; }); _searchFocusNode.unfocus(); } else { // 검색 결과가 없으면 검색 모드 종료 _exitSearchMode(); } } /// 검색 실행 void _onSearch(String query) { if (query.trim().isNotEmpty) { _lastSearchTerm = query; // 검색어 저장 ref.read(searchProvider.notifier).search(query); ref.read(recentSearchProvider.notifier).addSearch(query); // 최근 검색기록 저장 setState(() { _showSuggestions = false; }); _searchFocusNode.unfocus(); } } /// 검색어 입력 변경 (디바운스로 추천 검색어 로드) void _onSearchInputChanged(String value) { setState(() {}); // X 버튼 표시 갱신 _debounceTimer?.cancel(); _debounceTimer = Timer(const Duration(milliseconds: 200), () { if (value.trim().isNotEmpty) { ref.read(suggestionProvider.notifier).loadSuggestions(value); } else { ref.read(suggestionProvider.notifier).clear(); } }); } @override void dispose() { _debounceTimer?.cancel(); _dateScrollController.dispose(); _contentScrollController.dispose(); _searchScrollController.removeListener(_onSearchScroll); _searchScrollController.dispose(); _searchInputController.dispose(); _searchFocusNode.dispose(); _calendarPageController.dispose(); _yearPageController.dispose(); _calendarAnimController.dispose(); super.dispose(); } /// 달력 열기 void _openCalendar(DateTime initialDate) { final today = DateTime.now(); final monthDelta = (initialDate.year - today.year) * 12 + (initialDate.month - today.month); _yearRangeStart = (initialDate.year ~/ 12) * 12; // 년도 PageView 페이지 계산 final baseYearRange = (today.year ~/ 12) * 12; final yearPageDelta = (_yearRangeStart - baseYearRange) ~/ 12; setState(() { _calendarViewDate = initialDate; _showCalendar = true; }); // 해당 월로 PageView 이동 WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted && _calendarPageController.hasClients) { _calendarPageController.jumpToPage(_initialPage + monthDelta); } if (mounted && _yearPageController.hasClients) { _yearPageController.jumpToPage(_initialPage + yearPageDelta); } }); _calendarAnimController.forward(); } /// 달력 닫기 void _closeCalendar() { _calendarAnimController.reverse().then((_) { if (mounted) { setState(() { _showCalendar = false; _showYearMonthPicker = false; }); } }); } /// 선택된 날짜로 스크롤 void _scrollToSelectedDate(DateTime selectedDate) { if (!_dateScrollController.hasClients) return; final dayIndex = selectedDate.day - 1; const itemWidth = 52.0; // 44 + 8 (gap) final targetOffset = (dayIndex * itemWidth) - (MediaQuery.of(context).size.width / 2) + (itemWidth / 2); _dateScrollController.animateTo( targetOffset.clamp(0, _dateScrollController.position.maxScrollExtent), duration: const Duration(milliseconds: 150), curve: Curves.easeOut, ); } /// 날짜 선택 핸들러 void _onDateSelected(DateTime date) { // 일정 목록 맨 위로 즉시 이동 if (_contentScrollController.hasClients) { _contentScrollController.jumpTo(0); } // Controller에 날짜 선택 요청 ref.read(scheduleProvider.notifier).selectDate(date); // 선택된 날짜로 스크롤 _scrollToSelectedDate(date); } /// 요일 이름 String _getDayName(DateTime date) { const days = ['일', '월', '화', '수', '목', '금', '토']; return days[date.weekday % 7]; } @override Widget build(BuildContext context) { final scheduleState = ref.watch(scheduleProvider); final searchState = ref.watch(searchProvider); final suggestionState = ref.watch(suggestionProvider); final controller = ref.read(scheduleProvider.notifier); // 날짜가 변경되면 스크롤 if (!_isSearchMode && _lastSelectedDate != scheduleState.selectedDate) { _lastSelectedDate = scheduleState.selectedDate; WidgetsBinding.instance.addPostFrameCallback((_) { _scrollToSelectedDate(scheduleState.selectedDate); }); } // 툴바 + 날짜 선택기 높이 final safeTop = MediaQuery.of(context).padding.top; final overlayTop = safeTop + 56 + 80; // toolbar(56) + date selector(80) // 뒤로가기 키 처리 (유튜브 스타일) // 추천 검색어 화면 → 검색 결과 화면 (결과 있으면) → 일정 화면 return PopScope( canPop: !_isSearchMode, onPopInvokedWithResult: (didPop, result) { if (!didPop && _isSearchMode) { if (_showSuggestions && searchState.results.isNotEmpty) { // 추천 검색어 화면에서 검색 결과가 있으면 결과 화면으로 _hideSuggestionsScreen(); } else { // 그 외에는 검색 모드 종료 _exitSearchMode(); } } }, child: Stack( children: [ // 메인 콘텐츠 Column( children: [ // 툴바 (검색 모드 전환 애니메이션) AnimatedSwitcher( duration: const Duration(milliseconds: 250), switchInCurve: Curves.easeOut, switchOutCurve: Curves.easeIn, transitionBuilder: (child, animation) { return FadeTransition(opacity: animation, child: child); }, child: _isSearchMode ? KeyedSubtree( key: const ValueKey('search_toolbar'), child: _buildSearchToolbar(), ) : KeyedSubtree( key: const ValueKey('normal_toolbar'), child: _buildToolbar(scheduleState, controller), ), ), // 날짜 선택기 (검색 모드 전환 애니메이션) AnimatedSize( duration: const Duration(milliseconds: 250), curve: Curves.easeOut, child: _isSearchMode ? const SizedBox.shrink() : _buildDateSelector(scheduleState, controller), ), // 일정 목록, 추천 검색어, 또는 검색 결과 Expanded( child: AnimatedSwitcher( duration: const Duration(milliseconds: 200), child: _isSearchMode ? (_showSuggestions ? KeyedSubtree( key: const ValueKey('suggestions'), child: _buildSuggestions(suggestionState), ) : KeyedSubtree( key: const ValueKey('search_results'), child: _buildSearchResults(searchState), )) : KeyedSubtree( key: const ValueKey('schedule_list'), child: _buildScheduleList(scheduleState), ), ), ), ], ), // 달력 팝업 오버레이 (검색 모드가 아닐 때만) if (_showCalendar && !_isSearchMode) ...[ // 배경 오버레이 (날짜 선택기 아래부터) Positioned( top: overlayTop, left: 0, right: 0, bottom: 0, child: AnimatedBuilder( animation: _calendarAnimation, builder: (context, child) { return GestureDetector( onTap: _closeCalendar, child: Container( color: Colors.black.withValues( alpha: 0.4 * _calendarAnimation.value, ), ), ); }, ), ), // 달력 팝업 (애니메이션) Positioned( top: safeTop + 56, left: 0, right: 0, child: _buildCalendarPopup(scheduleState, controller), ), ], ], ), ); } /// 검색 툴바 빌드 Widget _buildSearchToolbar() { return Container( color: Colors.white, child: SafeArea( bottom: false, child: Container( height: 56, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Row( children: [ // 검색 입력창 Expanded( child: Container( height: 40, decoration: BoxDecoration( color: AppColors.background, borderRadius: BorderRadius.circular(20), ), child: Row( children: [ const SizedBox(width: 16), const Icon( Icons.search, size: 18, color: AppColors.textTertiary, ), const SizedBox(width: 8), Expanded( child: TextField( controller: _searchInputController, focusNode: _searchFocusNode, style: const TextStyle( fontFamily: 'Pretendard', fontSize: 14, ), decoration: const InputDecoration( hintText: '일정 검색...', hintStyle: TextStyle( fontFamily: 'Pretendard', fontSize: 14, color: AppColors.textTertiary, ), border: InputBorder.none, contentPadding: EdgeInsets.zero, isDense: true, ), textInputAction: TextInputAction.search, onTap: () { // 입력창 클릭 시 추천 검색어 화면 표시 if (!_showSuggestions) { _showSuggestionsScreen(); } }, onChanged: _onSearchInputChanged, onSubmitted: _onSearch, ), ), // 입력 내용 삭제 버튼 (클릭 시 추천 검색어 화면으로) if (_searchInputController.text.isNotEmpty) GestureDetector( onTap: () { setState(() { _searchInputController.clear(); _showSuggestions = true; }); ref.read(suggestionProvider.notifier).clear(); _searchFocusNode.requestFocus(); }, child: const Padding( padding: EdgeInsets.all(8), child: Icon( Icons.close, size: 18, color: AppColors.textTertiary, ), ), ) else const SizedBox(width: 8), ], ), ), ), const SizedBox(width: 12), // 취소 버튼 GestureDetector( onTap: _exitSearchMode, child: const Text( '취소', style: TextStyle( fontFamily: 'Pretendard', fontSize: 14, color: AppColors.textSecondary, ), ), ), ], ), ), ), ); } /// 추천 검색어 빌드 (유튜브 스타일) Widget _buildSuggestions(SuggestionState suggestionState) { final recentSearchState = ref.watch(recentSearchProvider); // 입력값이 없을 때 - 최근 검색기록 표시 if (_searchInputController.text.isEmpty) { if (recentSearchState.searches.isEmpty) { return Padding( padding: const EdgeInsets.only(top: 100), child: Align( alignment: Alignment.topCenter, child: Text( '검색어를 입력하세요', style: const TextStyle( fontFamily: 'Pretendard', fontSize: 14, color: AppColors.textTertiary, ), ), ), ); } // 최근 검색기록 목록 return _buildRecentSearches(recentSearchState.searches); } // 추천 검색어 없음 if (suggestionState.suggestions.isEmpty) { return Padding( padding: const EdgeInsets.only(top: 100), child: Align( alignment: Alignment.topCenter, child: Text( '추천 검색어가 없습니다', style: const TextStyle( fontFamily: 'Pretendard', fontSize: 14, color: AppColors.textTertiary, ), ), ), ); } // 추천 검색어 목록 return ListView.builder( padding: const EdgeInsets.symmetric(horizontal: 16), itemCount: suggestionState.suggestions.length, itemBuilder: (context, index) { final suggestion = suggestionState.suggestions[index]; return GestureDetector( onTap: () { _searchInputController.text = suggestion; _onSearch(suggestion); }, behavior: HitTestBehavior.opaque, child: Container( padding: const EdgeInsets.symmetric(vertical: 14), decoration: BoxDecoration( border: Border( bottom: BorderSide( color: AppColors.divider.withValues(alpha: 0.5), width: 1, ), ), ), child: Row( children: [ const Icon( Icons.search, size: 18, color: AppColors.textTertiary, ), const SizedBox(width: 16), Expanded( child: Text( suggestion, style: const TextStyle( fontFamily: 'Pretendard', fontSize: 15, color: AppColors.textPrimary, ), ), ), // 화살표 아이콘 (검색어를 입력창에 채우기) GestureDetector( onTap: () { _searchInputController.text = suggestion; _searchInputController.selection = TextSelection.fromPosition( TextPosition(offset: suggestion.length), ); _onSearchInputChanged(suggestion); }, child: const Padding( padding: EdgeInsets.all(4), child: Icon( Icons.north_west, size: 16, color: AppColors.textTertiary, ), ), ), ], ), ), ); }, ); } /// 최근 검색기록 빌드 Widget _buildRecentSearches(List searches) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // 헤더 Padding( padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ const Text( '최근 검색', style: TextStyle( fontFamily: 'Pretendard', fontSize: 14, fontWeight: FontWeight.w600, color: AppColors.textSecondary, ), ), GestureDetector( onTap: () { ref.read(recentSearchProvider.notifier).clearAll(); }, child: const Text( '전체 삭제', style: TextStyle( fontFamily: 'Pretendard', fontSize: 12, color: AppColors.textTertiary, ), ), ), ], ), ), // 검색기록 목록 Expanded( child: ListView.builder( padding: const EdgeInsets.symmetric(horizontal: 16), itemCount: searches.length, itemBuilder: (context, index) { final search = searches[index]; return GestureDetector( onTap: () { _searchInputController.text = search; _onSearch(search); }, behavior: HitTestBehavior.opaque, child: Container( padding: const EdgeInsets.symmetric(vertical: 14), decoration: BoxDecoration( border: Border( bottom: BorderSide( color: AppColors.divider.withValues(alpha: 0.5), width: 1, ), ), ), child: Row( children: [ const Icon( Icons.history, size: 18, color: AppColors.textTertiary, ), const SizedBox(width: 16), Expanded( child: Text( search, style: const TextStyle( fontFamily: 'Pretendard', fontSize: 15, color: AppColors.textPrimary, ), ), ), // 삭제 버튼 GestureDetector( onTap: () { ref.read(recentSearchProvider.notifier).removeSearch(search); }, child: const Padding( padding: EdgeInsets.all(4), child: Icon( Icons.close, size: 16, color: AppColors.textTertiary, ), ), ), ], ), ), ); }, ), ), ], ); } /// 검색 결과 빌드 Widget _buildSearchResults(SearchState searchState) { // 검색어가 없을 때 if (searchState.searchTerm.isEmpty) { return Padding( padding: const EdgeInsets.only(top: 100), child: Align( alignment: Alignment.topCenter, child: Text( '검색어를 입력하세요', style: const TextStyle( fontFamily: 'Pretendard', fontSize: 14, color: AppColors.textTertiary, ), ), ), ); } // 로딩 중 if (searchState.isLoading) { return Padding( padding: const EdgeInsets.only(top: 100), child: Align( alignment: Alignment.topCenter, child: const CircularProgressIndicator(color: AppColors.primary), ), ); } // 검색 결과 없음 if (searchState.results.isEmpty) { return Padding( padding: const EdgeInsets.only(top: 100), child: Align( alignment: Alignment.topCenter, child: Text( '검색 결과가 없습니다', style: const TextStyle( fontFamily: 'Pretendard', fontSize: 14, color: AppColors.textTertiary, ), ), ), ); } // 검색 결과 목록 (화면 전체 페이드 애니메이션) return AnimatedSwitcher( duration: const Duration(milliseconds: 200), child: ListView.builder( key: ValueKey('search_list_${searchState.searchTerm}'), controller: _searchScrollController, padding: const EdgeInsets.all(16), itemCount: searchState.results.length + (searchState.isFetchingMore ? 1 : 0), itemBuilder: (context, index) { // 로딩 인디케이터 if (index >= searchState.results.length) { return const Padding( padding: EdgeInsets.symmetric(vertical: 16), child: Center( child: SizedBox( width: 24, height: 24, child: CircularProgressIndicator( color: AppColors.primary, strokeWidth: 2, ), ), ), ); } final schedule = searchState.results[index]; return Padding( padding: EdgeInsets.only( bottom: index < searchState.results.length - 1 ? 12 : 0, ), child: SearchScheduleCard( schedule: schedule, categoryColor: parseColor(schedule.categoryColor), ), ); }, ), ); } /// 툴바 빌드 Widget _buildToolbar(ScheduleState state, ScheduleController controller) { // 달력이 열려있을 때는 달력 뷰 날짜 기준, 아니면 선택된 날짜 기준 final displayDate = _showCalendar ? _calendarViewDate : state.selectedDate; final yearMonthText = '${displayDate.year}년 ${displayDate.month}월'; return Container( color: Colors.white, child: SafeArea( bottom: false, child: Container( height: 56, padding: const EdgeInsets.symmetric(horizontal: 4), child: Row( children: [ // 달력 아이콘 IconButton( onPressed: () { if (_showCalendar) { _closeCalendar(); } else { _openCalendar(state.selectedDate); } }, icon: const Icon(Icons.calendar_today_outlined, size: 20), color: _showCalendar ? AppColors.primary : AppColors.textSecondary, ), // 이전 월 (데이트픽커가 펼쳐지면 숨김, 페이드 애니메이션) AnimatedSwitcher( duration: const Duration(milliseconds: 200), child: !_showYearMonthPicker ? IconButton( key: const ValueKey('prev_button'), onPressed: () { if (_showCalendar) { _calendarPageController.previousPage( duration: const Duration(milliseconds: 300), curve: Curves.easeInOut, ); } else { controller.changeMonth(-1); } }, icon: const Icon(Icons.chevron_left, size: 24), color: AppColors.textPrimary, ) : const SizedBox(key: ValueKey('prev_empty'), width: 48), ), // 년월 표시 Expanded( child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: _showCalendar ? () { setState(() { _showYearMonthPicker = !_showYearMonthPicker; _yearRangeStart = (_calendarViewDate.year ~/ 12) * 12; }); } : null, child: Center( child: Row( mainAxisSize: MainAxisSize.min, children: [ // 화살표 공간 (왼쪽에 보이지 않는 공간으로 균형, 페이드 애니메이션) AnimatedSwitcher( duration: const Duration(milliseconds: 200), child: _showCalendar ? const SizedBox( key: ValueKey('arrow_space'), width: 20, ) : const SizedBox( key: ValueKey('no_space'), width: 0, ), ), // 년월 텍스트 (항상 가운데 고정) Text( yearMonthText, style: TextStyle( fontFamily: 'Pretendard', fontSize: 16, fontWeight: FontWeight.bold, color: _showYearMonthPicker ? AppColors.primary : AppColors.textPrimary, ), ), // 드롭다운 화살표 (페이드 애니메이션) AnimatedSwitcher( duration: const Duration(milliseconds: 200), child: _showCalendar ? Row( key: ValueKey( 'dropdown_$_showYearMonthPicker', ), mainAxisSize: MainAxisSize.min, children: [ const SizedBox(width: 2), Icon( _showYearMonthPicker ? Icons.keyboard_arrow_up : Icons.keyboard_arrow_down, size: 18, color: _showYearMonthPicker ? AppColors.primary : AppColors.textPrimary, ), ], ) : const SizedBox( key: ValueKey('no_dropdown'), width: 0, ), ), ], ), ), ), ), // 다음 월 (데이트픽커가 펼쳐지면 숨김, 페이드 애니메이션) AnimatedSwitcher( duration: const Duration(milliseconds: 200), child: !_showYearMonthPicker ? IconButton( key: const ValueKey('next_button'), onPressed: () { if (_showCalendar) { _calendarPageController.nextPage( duration: const Duration(milliseconds: 300), curve: Curves.easeInOut, ); } else { controller.changeMonth(1); } }, icon: const Icon(Icons.chevron_right, size: 24), color: AppColors.textPrimary, ) : const SizedBox(key: ValueKey('next_empty'), width: 48), ), // 검색 아이콘 IconButton( onPressed: _enterSearchMode, icon: const Icon(Icons.search, size: 20), color: AppColors.textSecondary, ), ], ), ), ), ); } /// 달력 팝업 빌드 Widget _buildCalendarPopup( ScheduleState state, ScheduleController controller, ) { return AnimatedBuilder( animation: _calendarAnimation, builder: (context, child) { return ClipRect( child: Align( alignment: Alignment.topCenter, heightFactor: _calendarAnimation.value, child: Opacity(opacity: _calendarAnimation.value, child: child), ), ); }, child: Material( color: Colors.white, elevation: 0, child: AnimatedSize( duration: const Duration(milliseconds: 200), curve: Curves.easeOut, alignment: Alignment.topCenter, child: AnimatedSwitcher( duration: const Duration(milliseconds: 150), switchInCurve: Curves.easeOut, switchOutCurve: Curves.easeIn, layoutBuilder: (currentChild, previousChildren) { // 콘텐츠를 항상 상단에 고정 return Stack( alignment: Alignment.topCenter, children: [ ...previousChildren, if (currentChild != null) currentChild, ], ); }, transitionBuilder: (child, animation) { // 년월 선택기에만 슬라이드 효과 추가 final isYearMonthPicker = child.key == const ValueKey('yearMonth'); if (isYearMonthPicker) { return FadeTransition( opacity: animation, child: SlideTransition( position: Tween( begin: const Offset(0, -0.05), end: Offset.zero, ).animate(animation), child: child, ), ); } return FadeTransition(opacity: animation, child: child); }, child: _showYearMonthPicker ? KeyedSubtree( key: const ValueKey('yearMonth'), child: _buildYearMonthPicker(), ) : KeyedSubtree( key: const ValueKey('calendar'), child: _buildCalendarGrid(state, controller), ), ), ), ), ); } /// 년월 선택기 Widget _buildYearMonthPicker() { final today = DateTime.now(); return Padding( padding: const EdgeInsets.fromLTRB(16, 4, 16, 16), child: Column( mainAxisSize: MainAxisSize.min, children: [ // 년도 범위 헤더 Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ IconButton( onPressed: () { _yearPageController.previousPage( duration: const Duration(milliseconds: 300), curve: Curves.easeInOut, ); }, icon: const Icon(Icons.chevron_left, size: 20), color: AppColors.textPrimary, splashRadius: 20, padding: EdgeInsets.zero, constraints: const BoxConstraints(minWidth: 40, minHeight: 40), ), Text( '$_yearRangeStart - ${_yearRangeStart + 11}', style: const TextStyle( fontFamily: 'Pretendard', fontSize: 14, fontWeight: FontWeight.w600, ), ), IconButton( onPressed: () { _yearPageController.nextPage( duration: const Duration(milliseconds: 300), curve: Curves.easeInOut, ); }, icon: const Icon(Icons.chevron_right, size: 20), color: AppColors.textPrimary, splashRadius: 20, padding: EdgeInsets.zero, constraints: const BoxConstraints(minWidth: 40, minHeight: 40), ), ], ), const SizedBox(height: 8), // 년도 라벨 const Text( '년도', style: TextStyle( fontFamily: 'Pretendard', fontSize: 12, color: AppColors.textTertiary, ), ), const SizedBox(height: 4), // 년도 그리드 (ExpandablePageView로 스와이프 애니메이션) ExpandablePageView.builder( controller: _yearPageController, itemCount: _initialPage * 2, onPageChanged: (page) { final delta = page - _initialPage; final baseYearRange = (today.year ~/ 12) * 12; setState(() { _yearRangeStart = baseYearRange + (delta * 12); }); }, itemBuilder: (context, page) { final delta = page - _initialPage; final baseYearRange = (today.year ~/ 12) * 12; final rangeStart = baseYearRange + (delta * 12); final yearRange = List.generate(12, (i) => rangeStart + i); return _buildYearGrid(yearRange: yearRange, today: today); }, ), const SizedBox(height: 16), // 월 라벨 const Text( '월', style: TextStyle( fontFamily: 'Pretendard', fontSize: 12, color: AppColors.textTertiary, ), ), const SizedBox(height: 4), // 월 그리드 GridView.builder( shrinkWrap: true, padding: EdgeInsets.zero, physics: const NeverScrollableScrollPhysics(), gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 4, mainAxisSpacing: 6, crossAxisSpacing: 8, childAspectRatio: 2.0, ), itemCount: 12, itemBuilder: (context, index) { final month = index + 1; final isSelected = month == _calendarViewDate.month; final isCurrentMonth = month == today.month && _calendarViewDate.year == today.year; return GestureDetector( onTap: () { setState(() { _calendarViewDate = DateTime( _calendarViewDate.year, month, 1, ); _showYearMonthPicker = false; }); }, child: AnimatedContainer( duration: const Duration(milliseconds: 150), curve: Curves.easeOut, decoration: BoxDecoration( color: isSelected ? AppColors.primary : Colors.transparent, borderRadius: BorderRadius.circular(8), ), alignment: Alignment.center, child: AnimatedDefaultTextStyle( duration: const Duration(milliseconds: 150), style: TextStyle( fontFamily: 'Pretendard', fontSize: 14, fontWeight: isSelected || isCurrentMonth ? FontWeight.w600 : FontWeight.w400, color: isSelected ? Colors.white : isCurrentMonth ? AppColors.primary : AppColors.textPrimary, ), child: Text('$month월'), ), ), ); }, ), ], ), ); } /// 특정 월의 달력 데이터 생성 List _getMonthDays(int year, int month) { final firstDay = DateTime(year, month, 1); final lastDay = DateTime(year, month + 1, 0); final startWeekday = firstDay.weekday % 7; final daysInMonth = lastDay.day; final prevMonth = DateTime(year, month, 0); final prevMonthDays = List.generate( startWeekday, (i) => DateTime(year, month - 1, prevMonth.day - startWeekday + 1 + i), ); final currentMonthDays = List.generate( daysInMonth, (i) => DateTime(year, month, i + 1), ); final totalDays = prevMonthDays.length + currentMonthDays.length; final remaining = (7 - (totalDays % 7)) % 7; final nextMonthDays = List.generate( remaining, (i) => DateTime(year, month + 1, i + 1), ); return [...prevMonthDays, ...currentMonthDays, ...nextMonthDays]; } /// 년도 그리드 위젯 Widget _buildYearGrid({ required List yearRange, required DateTime today, }) { return GridView.builder( shrinkWrap: true, padding: EdgeInsets.zero, physics: const NeverScrollableScrollPhysics(), gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 4, mainAxisSpacing: 6, crossAxisSpacing: 8, childAspectRatio: 2.0, ), itemCount: 12, itemBuilder: (context, index) { final year = yearRange[index]; final isSelected = year == _calendarViewDate.year; final isCurrentYear = year == today.year; return GestureDetector( onTap: () { setState(() { _calendarViewDate = DateTime(year, _calendarViewDate.month, 1); }); }, child: AnimatedContainer( duration: const Duration(milliseconds: 150), curve: Curves.easeOut, decoration: BoxDecoration( color: isSelected ? AppColors.primary : Colors.transparent, borderRadius: BorderRadius.circular(8), ), alignment: Alignment.center, child: AnimatedDefaultTextStyle( duration: const Duration(milliseconds: 150), style: TextStyle( fontFamily: 'Pretendard', fontSize: 14, fontWeight: isSelected || isCurrentYear ? FontWeight.w600 : FontWeight.w400, color: isSelected ? Colors.white : isCurrentYear ? AppColors.primary : AppColors.textPrimary, ), child: Text('$year'), ), ), ); }, ); } /// 달력 그리드 Widget _buildCalendarGrid( ScheduleState state, ScheduleController controller, ) { return Padding( padding: const EdgeInsets.all(16), child: Column( mainAxisSize: MainAxisSize.min, children: [ // 요일 헤더 Row( children: ['일', '월', '화', '수', '목', '금', '토'].asMap().entries.map(( entry, ) { final index = entry.key; final day = entry.value; return Expanded( child: Center( child: Text( day, style: TextStyle( fontFamily: 'Pretendard', fontSize: 12, fontWeight: FontWeight.w500, color: index == 0 ? Colors.red.shade400 : index == 6 ? Colors.blue.shade400 : AppColors.textSecondary, ), ), ), ); }).toList(), ), const SizedBox(height: 12), // 날짜 그리드 (ExpandablePageView로 높이 자동 조절) ExpandablePageView.builder( controller: _calendarPageController, itemCount: _initialPage * 2, // 충분히 큰 범위 onPageChanged: (page) { final delta = page - _initialPage; final newDate = DateTime( DateTime.now().year, DateTime.now().month + delta, 1, ); setState(() { _calendarViewDate = newDate; }); // 해당 월의 일정을 비동기로 로드 (점 표시용) controller.loadCalendarMonth(newDate.year, newDate.month); }, itemBuilder: (context, page) { final delta = page - _initialPage; final targetDate = DateTime( DateTime.now().year, DateTime.now().month + delta, 1, ); // 해당 월의 일정이 캐시에 없으면 비동기 로드 if (!state.hasMonthCache(targetDate.year, targetDate.month)) { controller.loadCalendarMonth(targetDate.year, targetDate.month); } final allDays = _getMonthDays(targetDate.year, targetDate.month); return _buildCalendarDaysGrid( allDays: allDays, month: targetDate.month, state: state, controller: controller, ); }, ), const SizedBox(height: 16), // 오늘 버튼 GestureDetector( onTap: () { final today = DateTime.now(); controller.goToDate(today); if (_contentScrollController.hasClients) { _contentScrollController.jumpTo(0); } _calendarPageController.jumpToPage(_initialPage); _closeCalendar(); }, child: Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), decoration: BoxDecoration( color: AppColors.primary.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(20), ), child: const Text( '오늘', style: TextStyle( fontSize: 12, fontWeight: FontWeight.w600, color: AppColors.primary, ), ), ), ), ], ), ); } /// 날짜 그리드 Widget _buildCalendarDaysGrid({ required List allDays, required int month, required ScheduleState state, required ScheduleController controller, }) { return GridView.builder( shrinkWrap: true, padding: EdgeInsets.zero, physics: const NeverScrollableScrollPhysics(), gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 7, mainAxisSpacing: 4, crossAxisSpacing: 4, mainAxisExtent: 46, // Container(36) + SizedBox(6) + 여백(4) ), itemCount: allDays.length, itemBuilder: (context, index) { final date = allDays[index]; final isCurrentMonth = date.month == month; final isSelected = controller.isSelected(date); final isToday = controller.isToday(date); final dayOfWeek = index % 7; final daySchedules = isCurrentMonth ? state.getDaySchedules(date) : []; return GestureDetector( onTap: () { controller.goToDate(date); if (_contentScrollController.hasClients) { _contentScrollController.jumpTo(0); } _closeCalendar(); }, child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Container( width: 36, height: 36, decoration: BoxDecoration( color: isSelected ? AppColors.primary : Colors.transparent, shape: BoxShape.circle, boxShadow: isSelected ? [ BoxShadow( color: AppColors.primary.withValues(alpha: 0.4), blurRadius: 8, spreadRadius: 1, ), ] : null, ), alignment: Alignment.center, child: Text( '${date.day}', style: TextStyle( fontFamily: 'Pretendard', fontSize: 14, fontWeight: isSelected || isToday ? FontWeight.bold : FontWeight.w400, color: !isCurrentMonth ? AppColors.textTertiary.withValues(alpha: 0.5) : isSelected ? Colors.white : isToday ? AppColors.primary : dayOfWeek == 0 ? Colors.red.shade500 : dayOfWeek == 6 ? Colors.blue.shade500 : AppColors.textPrimary, ), ), ), SizedBox( height: 6, child: !isSelected && daySchedules.isNotEmpty ? Row( mainAxisAlignment: MainAxisAlignment.center, children: daySchedules.map((schedule) { return Container( width: 4, height: 4, margin: const EdgeInsets.symmetric(horizontal: 1), decoration: BoxDecoration( color: parseColor(schedule.categoryColor), shape: BoxShape.circle, ), ); }).toList(), ) : const SizedBox.shrink(), ), ], ), ); }, ); } /// 날짜 선택기 빌드 Widget _buildDateSelector( ScheduleState state, ScheduleController controller, ) { return Container( height: 80, color: Colors.white, child: ListView.builder( controller: _dateScrollController, scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), itemCount: state.daysInMonth.length, itemBuilder: (context, index) { final date = state.daysInMonth[index]; final isSelected = controller.isSelected(date); final isToday = controller.isToday(date); final dayOfWeek = date.weekday; final daySchedules = state.getDaySchedules(date); return GestureDetector( onTap: () => _onDateSelected(date), child: Container( width: 44, margin: const EdgeInsets.symmetric(horizontal: 4), decoration: BoxDecoration( color: isSelected ? AppColors.primary : Colors.transparent, borderRadius: BorderRadius.circular(12), ), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ // 요일 Text( _getDayName(date), style: TextStyle( fontSize: 10, fontWeight: FontWeight.w500, color: isSelected ? Colors.white.withValues(alpha: 0.8) : dayOfWeek == 7 ? Colors.red.shade400 : dayOfWeek == 6 ? Colors.blue.shade400 : AppColors.textTertiary, ), ), const SizedBox(height: 4), // 날짜 Text( '${date.day}', style: TextStyle( fontSize: 14, fontWeight: FontWeight.w600, color: isSelected ? Colors.white : isToday ? AppColors.primary : AppColors.textPrimary, ), ), const SizedBox(height: 4), // 일정 점 (최대 3개) SizedBox( height: 6, child: !isSelected && daySchedules.isNotEmpty ? Row( mainAxisAlignment: MainAxisAlignment.center, children: daySchedules.map((schedule) { return Container( width: 4, height: 4, margin: const EdgeInsets.symmetric( horizontal: 1, ), decoration: BoxDecoration( color: parseColor(schedule.categoryColor), shape: BoxShape.circle, ), ); }).toList(), ) : const SizedBox.shrink(), ), ], ), ), ); }, ), ); } /// 일정 목록 빌드 Widget _buildScheduleList(ScheduleState state) { if (state.isLoading) { return const Center( child: CircularProgressIndicator(color: AppColors.primary), ); } if (state.selectedDateSchedules.isEmpty) { return Center( child: Text( '${state.selectedDate.month}월 ${state.selectedDate.day}일 일정이 없습니다', style: const TextStyle(fontSize: 14, color: AppColors.textTertiary), ), ); } return ListView.builder( controller: _contentScrollController, padding: const EdgeInsets.all(16), itemCount: state.selectedDateSchedules.length, itemBuilder: (context, index) { final schedule = state.selectedDateSchedules[index]; return Padding( padding: EdgeInsets.only( bottom: index < state.selectedDateSchedules.length - 1 ? 12 : 0, ), child: AnimatedScheduleCard( key: ValueKey('${schedule.id}_${state.selectedDate.toString()}'), index: index, schedule: schedule, categoryColor: parseColor(schedule.categoryColor), ), ); }, ); } }