/// 일정 화면 (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'; /// HTML 엔티티 디코딩 String decodeHtmlEntities(String text) { return text .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll(''', "'") .replaceAll(' ', ' '); } 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(); } /// 추천 검색어 화면 표시 (유튜브 스타일) 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); 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]; } /// 카테고리 색상 파싱 Color _parseColor(String? colorStr) { if (colorStr == null || colorStr.isEmpty) return AppColors.textTertiary; try { final hex = colorStr.replaceFirst('#', ''); return Color(int.parse('FF$hex', radix: 16)); } catch (_) { return AppColors.textTertiary; } } @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) { // 입력값이 없을 때 if (_searchInputController.text.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 (suggestionState.isLoading && suggestionState.suggestions.isEmpty) { return Padding( padding: const EdgeInsets.only(top: 100), child: Align( alignment: Alignment.topCenter, child: const CircularProgressIndicator(color: AppColors.primary), ), ); } // 추천 검색어 없음 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 InkWell( onTap: () { _searchInputController.text = suggestion; _onSearch(suggestion); }, 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 _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: 4), // 날짜 그리드 (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: 12), // 오늘 버튼 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, 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), ), ); }, ); } } /// 애니메이션이 적용된 일정 카드 래퍼 class _AnimatedScheduleCard extends StatefulWidget { final int index; final Schedule schedule; final Color categoryColor; const _AnimatedScheduleCard({ super.key, required this.index, required this.schedule, required this.categoryColor, }); @override State<_AnimatedScheduleCard> createState() => _AnimatedScheduleCardState(); } class _AnimatedScheduleCardState extends State<_AnimatedScheduleCard> with SingleTickerProviderStateMixin { late AnimationController _controller; late Animation _fadeAnimation; late Animation _slideAnimation; @override void initState() { super.initState(); _controller = AnimationController( duration: const Duration(milliseconds: 300), vsync: this, ); _fadeAnimation = Tween(begin: 0.0, end: 1.0).animate( CurvedAnimation(parent: _controller, curve: Curves.easeOut), ); // 웹과 동일: x: -10px 에서 0으로 (spring 효과) _slideAnimation = Tween(begin: -10.0, end: 0.0).animate( CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic), ); // 순차적 애니메이션 (index * 30ms 딜레이) - 더 빠르게 Future.delayed(Duration(milliseconds: widget.index * 30), () { if (mounted) _controller.forward(); }); } @override void dispose() { _controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return AnimatedBuilder( animation: _controller, builder: (context, child) { return Opacity( opacity: _fadeAnimation.value, child: Transform.translate( offset: Offset(_slideAnimation.value, 0), child: child, ), ); }, child: _ScheduleCard( schedule: widget.schedule, categoryColor: widget.categoryColor, ), ); } } /// 일정 카드 위젯 class _ScheduleCard extends StatelessWidget { final Schedule schedule; final Color categoryColor; const _ScheduleCard({ required this.schedule, required this.categoryColor, }); @override Widget build(BuildContext context) { final memberList = schedule.memberList; return Container( decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(8), boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.04), blurRadius: 12, offset: const Offset(0, 2), ), ], border: Border.all( color: AppColors.border.withValues(alpha: 0.5), width: 1, ), ), child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // 시간 및 카테고리 뱃지 Row( children: [ // 시간 뱃지 if (schedule.formattedTime != null) Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration( color: categoryColor, borderRadius: BorderRadius.circular(12), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ const Icon( Icons.access_time, size: 10, color: Colors.white, ), const SizedBox(width: 4), Text( schedule.formattedTime!, style: const TextStyle( fontSize: 11, fontWeight: FontWeight.w500, color: Colors.white, ), ), ], ), ), if (schedule.formattedTime != null) const SizedBox(width: 6), // 카테고리 뱃지 if (schedule.categoryName != null) Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration( color: categoryColor.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(12), ), child: Text( schedule.categoryName!, style: TextStyle( fontSize: 11, fontWeight: FontWeight.w500, color: categoryColor, ), ), ), ], ), const SizedBox(height: 10), // 제목 Text( decodeHtmlEntities(schedule.title), style: const TextStyle( fontSize: 15, fontWeight: FontWeight.bold, color: AppColors.textPrimary, height: 1.4, ), ), // 출처 if (schedule.sourceName != null) ...[ const SizedBox(height: 6), Row( children: [ Icon( Icons.link, size: 11, color: AppColors.textTertiary, ), const SizedBox(width: 4), Text( schedule.sourceName!, style: const TextStyle( fontSize: 11, color: AppColors.textTertiary, ), ), ], ), ], // 멤버 if (memberList.isNotEmpty) ...[ const SizedBox(height: 12), Container( padding: const EdgeInsets.only(top: 12), decoration: const BoxDecoration( border: Border( top: BorderSide(color: AppColors.divider, width: 1), ), ), child: Wrap( spacing: 6, runSpacing: 6, children: memberList.length >= 5 ? [ _MemberChip(name: '프로미스나인'), ] : memberList .map((name) => _MemberChip(name: name)) .toList(), ), ), ], ], ), ), ); } } /// 멤버 칩 위젯 class _MemberChip extends StatelessWidget { final String name; const _MemberChip({required this.name}); @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), decoration: BoxDecoration( gradient: const LinearGradient( colors: [AppColors.primary, AppColors.primaryDark], ), borderRadius: BorderRadius.circular(8), boxShadow: [ BoxShadow( color: AppColors.primary.withValues(alpha: 0.3), blurRadius: 4, offset: const Offset(0, 2), ), ], ), child: Text( name, style: const TextStyle( fontSize: 11, fontWeight: FontWeight.w600, color: Colors.white, ), ), ); } } /// 검색 결과 카드 (웹과 동일한 디자인 - 왼쪽에 날짜, 오른쪽에 내용) class _SearchScheduleCard extends StatelessWidget { final Schedule schedule; final Color categoryColor; const _SearchScheduleCard({ required this.schedule, required this.categoryColor, }); /// 날짜 파싱 Map? _parseDate(String? dateStr) { if (dateStr == null) return null; try { final date = DateTime.parse(dateStr); const weekdays = ['일', '월', '화', '수', '목', '금', '토']; return { 'year': date.year, 'month': date.month, 'day': date.day, 'weekday': weekdays[date.weekday % 7], 'isSunday': date.weekday == 7, 'isSaturday': date.weekday == 6, }; } catch (_) { return null; } } @override Widget build(BuildContext context) { final memberList = schedule.memberList; final dateInfo = _parseDate(schedule.date); return Container( decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(8), boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.04), blurRadius: 12, offset: const Offset(0, 2), ), ], border: Border.all( color: AppColors.border.withValues(alpha: 0.5), width: 1, ), ), child: IntrinsicHeight( child: Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // 왼쪽 날짜 영역 (카드 높이에 맞춤) if (dateInfo != null) Container( width: 72, padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 6), decoration: const BoxDecoration( color: AppColors.background, borderRadius: BorderRadius.only( topLeft: Radius.circular(7), bottomLeft: Radius.circular(7), ), ), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ // 년도 Text( '${dateInfo['year']}', style: const TextStyle( fontFamily: 'Pretendard', fontSize: 10, color: AppColors.textTertiary, ), ), // 월.일 (줄바꿈 방지) FittedBox( fit: BoxFit.scaleDown, child: Text( '${dateInfo['month']}.${dateInfo['day']}', style: const TextStyle( fontFamily: 'Pretendard', fontSize: 18, fontWeight: FontWeight.bold, color: AppColors.textPrimary, ), ), ), // 요일 Text( '${dateInfo['weekday']}요일', style: TextStyle( fontFamily: 'Pretendard', fontSize: 11, fontWeight: FontWeight.w500, color: dateInfo['isSunday'] == true ? Colors.red.shade500 : dateInfo['isSaturday'] == true ? Colors.blue.shade500 : AppColors.textSecondary, ), ), ], ), ), // 오른쪽 콘텐츠 영역 Expanded( child: Padding( padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // 시간 및 카테고리 뱃지 Row( children: [ // 시간 뱃지 if (schedule.formattedTime != null) Container( padding: const EdgeInsets.symmetric( horizontal: 8, vertical: 2, ), decoration: BoxDecoration( color: categoryColor, borderRadius: BorderRadius.circular(12), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ const Icon( Icons.access_time, size: 10, color: Colors.white, ), const SizedBox(width: 4), Text( schedule.formattedTime!, style: const TextStyle( fontFamily: 'Pretendard', fontSize: 10, fontWeight: FontWeight.w500, color: Colors.white, ), ), ], ), ), if (schedule.formattedTime != null) const SizedBox(width: 6), // 카테고리 뱃지 if (schedule.categoryName != null) Container( padding: const EdgeInsets.symmetric( horizontal: 8, vertical: 2, ), decoration: BoxDecoration( color: categoryColor.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(12), ), child: Text( schedule.categoryName!, style: TextStyle( fontFamily: 'Pretendard', fontSize: 10, fontWeight: FontWeight.w500, color: categoryColor, ), ), ), ], ), const SizedBox(height: 8), // 제목 Text( decodeHtmlEntities(schedule.title), style: const TextStyle( fontFamily: 'Pretendard', fontSize: 14, fontWeight: FontWeight.bold, color: AppColors.textPrimary, height: 1.4, ), maxLines: 2, overflow: TextOverflow.ellipsis, ), // 출처 (빈 문자열이 아닌 경우에만 표시) if (schedule.sourceName != null && schedule.sourceName!.isNotEmpty) ...[ const SizedBox(height: 6), Row( children: [ const Icon( Icons.link, size: 12, color: AppColors.textTertiary, ), const SizedBox(width: 4), Expanded( child: Text( schedule.sourceName!, style: const TextStyle( fontFamily: 'Pretendard', fontSize: 12, color: AppColors.textTertiary, ), overflow: TextOverflow.ellipsis, ), ), ], ), ], // 멤버 if (memberList.isNotEmpty) ...[ const SizedBox(height: 10), // divider (전체 너비) Container( width: double.infinity, height: 1, color: AppColors.divider, ), const SizedBox(height: 10), Wrap( spacing: 4, runSpacing: 4, children: memberList.length >= 5 ? [ _SearchMemberChip(name: '프로미스나인'), ] : memberList .map((name) => _SearchMemberChip(name: name)) .toList(), ), ], ], ), ), ), ], ), ), ); } } /// 검색 결과용 멤버 칩 (작은 사이즈) class _SearchMemberChip extends StatelessWidget { final String name; const _SearchMemberChip({required this.name}); @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( gradient: const LinearGradient( colors: [AppColors.primary, AppColors.primaryDark], ), borderRadius: BorderRadius.circular(6), boxShadow: [ BoxShadow( color: AppColors.primary.withValues(alpha: 0.3), blurRadius: 3, offset: const Offset(0, 1), ), ], ), child: Text( name, style: const TextStyle( fontFamily: 'Pretendard', fontSize: 10, fontWeight: FontWeight.w600, color: Colors.white, ), ), ); } }