From c4cbdc7d334add960808f336c548f48024b99052 Mon Sep 17 00:00:00 2001 From: caadiq Date: Tue, 13 Jan 2026 20:37:00 +0900 Subject: [PATCH] =?UTF-8?q?feat(schedule):=20=EB=8B=AC=EB=A0=A5=20UI=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=EA=B2=80=EC=83=89=20=EC=A4=80?= =?UTF-8?q?=EB=B9=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ExpandablePageView로 달력 높이 동적 조절 (월별 주 수에 따라) - 데이트픽커 년도 변경 시 스와이프 애니메이션 추가 - 달력 월 변경 시 일정 점 비동기 업데이트 (캐시 기반) - 모든 달력/데이트픽커 텍스트에 Pretendard 폰트 적용 - 데이트픽커 화살표 터치 영역 확대 및 ripple effect 추가 - 데이트픽커 펼침 시 툴바 좌우 화살표 숨김 (페이드 애니메이션) - expandable_page_view 패키지 추가 Co-Authored-By: Claude --- app/lib/controllers/schedule_controller.dart | 51 +- app/lib/views/schedule/schedule_view.dart | 795 ++++++++++++------- app/pubspec.lock | 8 + app/pubspec.yaml | 1 + 4 files changed, 557 insertions(+), 298 deletions(-) diff --git a/app/lib/controllers/schedule_controller.dart b/app/lib/controllers/schedule_controller.dart index 6c11baf..293d5cf 100644 --- a/app/lib/controllers/schedule_controller.dart +++ b/app/lib/controllers/schedule_controller.dart @@ -15,12 +15,15 @@ class ScheduleState { final List schedules; final bool isLoading; final String? error; + // 달력용 월별 일정 캐시 (key: "yyyy-MM") + final Map> calendarCache; const ScheduleState({ required this.selectedDate, this.schedules = const [], this.isLoading = false, this.error, + this.calendarCache = const {}, }); /// 상태 복사 (불변성 유지) @@ -29,12 +32,14 @@ class ScheduleState { List? schedules, bool? isLoading, String? error, + Map>? calendarCache, }) { return ScheduleState( selectedDate: selectedDate ?? this.selectedDate, schedules: schedules ?? this.schedules, isLoading: isLoading ?? this.isLoading, error: error, + calendarCache: calendarCache ?? this.calendarCache, ); } @@ -45,11 +50,29 @@ class ScheduleState { } /// 특정 날짜의 일정 (점 표시용, 최대 3개) + /// 캐시에서 먼저 찾고, 없으면 현재 schedules에서 찾음 List getDaySchedules(DateTime date) { final dateStr = DateFormat('yyyy-MM-dd').format(date); + final cacheKey = DateFormat('yyyy-MM').format(date); + + // 캐시에 있으면 캐시에서 가져옴 + if (calendarCache.containsKey(cacheKey)) { + return calendarCache[cacheKey]! + .where((s) => s.date.split('T')[0] == dateStr) + .take(3) + .toList(); + } + + // 캐시에 없으면 현재 schedules에서 찾음 return schedules.where((s) => s.date.split('T')[0] == dateStr).take(3).toList(); } + /// 특정 월의 일정이 캐시에 있는지 확인 + bool hasMonthCache(int year, int month) { + final cacheKey = '$year-${month.toString().padLeft(2, '0')}'; + return calendarCache.containsKey(cacheKey); + } + /// 해당 달의 모든 날짜 배열 List get daysInMonth { final year = selectedDate.year; @@ -79,12 +102,38 @@ class ScheduleController extends Notifier { state.selectedDate.year, state.selectedDate.month, ); - state = state.copyWith(schedules: schedules, isLoading: false); + // 현재 월 일정을 캐시에도 저장 + final cacheKey = '${state.selectedDate.year}-${state.selectedDate.month.toString().padLeft(2, '0')}'; + final newCache = Map>.from(state.calendarCache); + newCache[cacheKey] = schedules; + state = state.copyWith( + schedules: schedules, + isLoading: false, + calendarCache: newCache, + ); } catch (e) { state = state.copyWith(isLoading: false, error: e.toString()); } } + /// 달력용 특정 월의 일정 비동기 로드 (UI 블로킹 없음) + Future loadCalendarMonth(int year, int month) async { + final cacheKey = '$year-${month.toString().padLeft(2, '0')}'; + + // 이미 캐시에 있으면 스킵 + if (state.calendarCache.containsKey(cacheKey)) return; + + try { + final schedules = await getSchedules(year, month); + // 비동기 완료 후 캐시 업데이트 + final newCache = Map>.from(state.calendarCache); + newCache[cacheKey] = schedules; + state = state.copyWith(calendarCache: newCache); + } catch (e) { + // 에러는 무시 (달력 점 표시가 안될 뿐) + } + } + /// 날짜 선택 void selectDate(DateTime date) { state = state.copyWith(selectedDate: date); diff --git a/app/lib/views/schedule/schedule_view.dart b/app/lib/views/schedule/schedule_view.dart index c3576bf..c813abd 100644 --- a/app/lib/views/schedule/schedule_view.dart +++ b/app/lib/views/schedule/schedule_view.dart @@ -5,6 +5,7 @@ library; 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'; @@ -27,7 +28,8 @@ class ScheduleView extends ConsumerStatefulWidget { ConsumerState createState() => _ScheduleViewState(); } -class _ScheduleViewState extends ConsumerState { +class _ScheduleViewState extends ConsumerState + with SingleTickerProviderStateMixin { final ScrollController _dateScrollController = ScrollController(); final ScrollController _contentScrollController = ScrollController(); DateTime? _lastSelectedDate; @@ -38,13 +40,81 @@ class _ScheduleViewState extends ConsumerState { 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, + ); + } + @override void dispose() { _dateScrollController.dispose(); _contentScrollController.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; @@ -103,6 +173,10 @@ class _ScheduleViewState extends ConsumerState { }); } + // 툴바 + 날짜 선택기 높이 + final safeTop = MediaQuery.of(context).padding.top; + final overlayTop = safeTop + 56 + 80; // toolbar(56) + date selector(80) + return Stack( children: [ // 메인 콘텐츠 @@ -118,29 +192,29 @@ class _ScheduleViewState extends ConsumerState { ), ], ), - // 달력 팝업 오버레이 (툴바 아래부터) + // 달력 팝업 오버레이 if (_showCalendar) ...[ - // 배경 오버레이 (툴바 제외) + // 배경 오버레이 (날짜 선택기 아래부터) Positioned( - top: MediaQuery.of(context).padding.top + 56, + top: overlayTop, left: 0, right: 0, bottom: 0, - child: GestureDetector( - onTap: () { - setState(() { - _showCalendar = false; - _showYearMonthPicker = false; - }); + child: AnimatedBuilder( + animation: _calendarAnimation, + builder: (context, child) { + return GestureDetector( + onTap: _closeCalendar, + child: Container( + color: Colors.black.withValues(alpha: 0.4 * _calendarAnimation.value), + ), + ); }, - child: Container( - color: Colors.black.withValues(alpha: 0.4), - ), ), ), // 달력 팝업 (애니메이션) Positioned( - top: MediaQuery.of(context).padding.top + 56, + top: safeTop + 56, left: 0, right: 0, child: _buildCalendarPopup(scheduleState, controller), @@ -154,6 +228,7 @@ class _ScheduleViewState extends ConsumerState { Widget _buildToolbar(ScheduleState state, ScheduleController controller) { // 달력이 열려있을 때는 달력 뷰 날짜 기준, 아니면 선택된 날짜 기준 final displayDate = _showCalendar ? _calendarViewDate : state.selectedDate; + final yearMonthText = '${displayDate.year}년 ${displayDate.month}월'; return Container( color: Colors.white, @@ -167,106 +242,118 @@ class _ScheduleViewState extends ConsumerState { // 달력 아이콘 IconButton( onPressed: () { - setState(() { - if (_showCalendar) { - _showCalendar = false; - _showYearMonthPicker = false; - } else { - _calendarViewDate = state.selectedDate; - _showCalendar = true; - } - }); + if (_showCalendar) { + _closeCalendar(); + } else { + _openCalendar(state.selectedDate); + } }, icon: const Icon(Icons.calendar_today_outlined, size: 20), color: _showCalendar ? AppColors.primary : AppColors.textSecondary, ), - // 이전 월 - IconButton( - onPressed: () { - if (_showCalendar) { - setState(() { - _calendarViewDate = DateTime( - _calendarViewDate.year, - _calendarViewDate.month - 1, - 1, - ); - }); - } else { - controller.changeMonth(-1); - } - }, - icon: const Icon(Icons.chevron_left, size: 24), - color: AppColors.textPrimary, + // 이전 월 (데이트픽커가 펼쳐지면 숨김, 페이드 애니메이션) + 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: Stack( - alignment: Alignment.center, - children: [ - // 년월 텍스트 (가운데 고정) - GestureDetector( - onTap: _showCalendar - ? () { - setState(() { - _showYearMonthPicker = !_showYearMonthPicker; - _yearRangeStart = (_calendarViewDate.year ~/ 12) * 12; - }); - } - : null, - child: Text( - '${displayDate.year}년 ${displayDate.month}월', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: _showYearMonthPicker - ? AppColors.primary - : AppColors.textPrimary, + 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), ), - ), - ), - // 화살표 (년월 옆에 위치) - if (_showCalendar) - Positioned( - // 년월 텍스트 너비의 절반 + 여백 - left: MediaQuery.of(context).size.width / 2 - 48 + 52, - child: GestureDetector( - onTap: () { - setState(() { - _showYearMonthPicker = !_showYearMonthPicker; - _yearRangeStart = (_calendarViewDate.year ~/ 12) * 12; - }); - }, - child: Icon( - _showYearMonthPicker - ? Icons.keyboard_arrow_up - : Icons.keyboard_arrow_down, - size: 18, + // 년월 텍스트 (항상 가운데 고정) + 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), + ), + ], + ), + ), ), ), - // 다음 월 - IconButton( - onPressed: () { - if (_showCalendar) { - setState(() { - _calendarViewDate = DateTime( - _calendarViewDate.year, - _calendarViewDate.month + 1, - 1, - ); - }); - } else { - controller.changeMonth(1); - } - }, - icon: const Icon(Icons.chevron_right, size: 24), - color: AppColors.textPrimary, + // 다음 월 (데이트픽커가 펼쳐지면 숨김, 페이드 애니메이션) + 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), ), // 검색 아이콘 (3단계에서 구현) IconButton( @@ -285,17 +372,15 @@ class _ScheduleViewState extends ConsumerState { /// 달력 팝업 빌드 Widget _buildCalendarPopup(ScheduleState state, ScheduleController controller) { - return TweenAnimationBuilder( - duration: const Duration(milliseconds: 200), - curve: Curves.easeOut, - tween: Tween(begin: 0.0, end: 1.0), - builder: (context, value, child) { + return AnimatedBuilder( + animation: _calendarAnimation, + builder: (context, child) { return ClipRect( child: Align( alignment: Alignment.topCenter, - heightFactor: value, + heightFactor: _calendarAnimation.value, child: Opacity( - opacity: value, + opacity: _calendarAnimation.value, child: child, ), ), @@ -303,14 +388,52 @@ class _ScheduleViewState extends ConsumerState { }, child: Material( color: Colors.white, - elevation: 8, - child: AnimatedCrossFade( - duration: const Duration(milliseconds: 150), - crossFadeState: _showYearMonthPicker - ? CrossFadeState.showSecond - : CrossFadeState.showFirst, - firstChild: _buildCalendarGrid(state, controller), - secondChild: _buildYearMonthPicker(), + 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), + ), + ), ), ), ); @@ -318,11 +441,12 @@ class _ScheduleViewState extends ConsumerState { /// 년월 선택기 Widget _buildYearMonthPicker() { - final yearRange = List.generate(12, (i) => _yearRangeStart + i); + final today = DateTime.now(); return Padding( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.fromLTRB(16, 4, 16, 16), child: Column( + mainAxisSize: MainAxisSize.min, children: [ // 년도 범위 헤더 Row( @@ -330,26 +454,37 @@ class _ScheduleViewState extends ConsumerState { children: [ IconButton( onPressed: () { - setState(() { - _yearRangeStart -= 12; - }); + _yearPageController.previousPage( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); }, - icon: const Icon(Icons.chevron_left, size: 18), + 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: () { - setState(() { - _yearRangeStart += 12; - }); + _yearPageController.nextPage( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); }, - icon: const Icon(Icons.chevron_right, size: 18), + icon: const Icon(Icons.chevron_right, size: 20), + color: AppColors.textPrimary, + splashRadius: 20, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 40, minHeight: 40), ), ], ), @@ -357,68 +492,51 @@ class _ScheduleViewState extends ConsumerState { // 년도 라벨 const Text( '년도', - style: TextStyle(fontSize: 12, color: AppColors.textTertiary), + style: TextStyle(fontFamily: 'Pretendard', fontSize: 12, color: AppColors.textTertiary), ), - const SizedBox(height: 8), - // 년도 그리드 - GridView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 4, - mainAxisSpacing: 8, - crossAxisSpacing: 8, - childAspectRatio: 2, - ), - itemCount: 12, - itemBuilder: (context, index) { - final year = yearRange[index]; - final isSelected = year == _calendarViewDate.year; - return GestureDetector( - onTap: () { - setState(() { - _calendarViewDate = DateTime(year, _calendarViewDate.month, 1); - }); - }, - child: Container( - decoration: BoxDecoration( - color: isSelected ? AppColors.primary : Colors.transparent, - borderRadius: BorderRadius.circular(8), - ), - alignment: Alignment.center, - child: Text( - '$year', - style: TextStyle( - fontSize: 14, - fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400, - color: isSelected ? Colors.white : AppColors.textPrimary, - ), - ), - ), - ); + 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(fontSize: 12, color: AppColors.textTertiary), + style: TextStyle(fontFamily: 'Pretendard', fontSize: 12, color: AppColors.textTertiary), ), - const SizedBox(height: 8), + const SizedBox(height: 4), // 월 그리드 GridView.builder( shrinkWrap: true, + padding: EdgeInsets.zero, physics: const NeverScrollableScrollPhysics(), gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 4, - mainAxisSpacing: 8, + mainAxisSpacing: 6, crossAxisSpacing: 8, - childAspectRatio: 2, + 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(() { @@ -426,19 +544,27 @@ class _ScheduleViewState extends ConsumerState { _showYearMonthPicker = false; }); }, - child: Container( + 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: Text( - '$month월', + child: AnimatedDefaultTextStyle( + duration: const Duration(milliseconds: 150), style: TextStyle( + fontFamily: 'Pretendard', fontSize: 14, - fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400, - color: isSelected ? Colors.white : AppColors.textPrimary, + fontWeight: isSelected || isCurrentMonth ? FontWeight.w600 : FontWeight.w400, + color: isSelected + ? Colors.white + : isCurrentMonth + ? AppColors.primary + : AppColors.textPrimary, ), + child: Text('$month월'), ), ), ); @@ -449,31 +575,24 @@ class _ScheduleViewState extends ConsumerState { ); } - /// 달력 그리드 - Widget _buildCalendarGrid(ScheduleState state, ScheduleController controller) { - final year = _calendarViewDate.year; - final month = _calendarViewDate.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; // 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( @@ -481,11 +600,69 @@ class _ScheduleViewState extends ConsumerState { (i) => DateTime(year, month + 1, i + 1), ); - final allDays = [...prevMonthDays, ...currentMonthDays, ...nextMonthDays]; + 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( @@ -497,6 +674,7 @@ class _ScheduleViewState extends ConsumerState { child: Text( day, style: TextStyle( + fontFamily: 'Pretendard', fontSize: 12, fontWeight: FontWeight.w500, color: index == 0 @@ -510,103 +688,41 @@ class _ScheduleViewState extends ConsumerState { ); }).toList(), ), - const SizedBox(height: 8), - // 날짜 그리드 - GridView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 7, - mainAxisSpacing: 4, - crossAxisSpacing: 4, - childAspectRatio: 1, - ), - 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); - } - // 달력 닫기 - setState(() { - _showCalendar = false; - _showYearMonthPicker = false; - }); - }, - 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( - 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(), - ), - ], - ), + 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, ); }, ), @@ -619,10 +735,8 @@ class _ScheduleViewState extends ConsumerState { if (_contentScrollController.hasClients) { _contentScrollController.jumpTo(0); } - setState(() { - _showCalendar = false; - _showYearMonthPicker = false; - }); + _calendarPageController.jumpToPage(_initialPage); + _closeCalendar(); }, child: Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), @@ -645,23 +759,111 @@ class _ScheduleViewState extends ConsumerState { ); } + /// 날짜 그리드 + 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: Container( - height: 80, - decoration: BoxDecoration( - color: Colors.white, - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.05), - blurRadius: 4, - offset: const Offset(0, 1), - ), - ], - ), - child: ListView.builder( + child: ListView.builder( controller: _dateScrollController, scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), @@ -742,7 +944,6 @@ class _ScheduleViewState extends ConsumerState { ); }, ), - ), ); } diff --git a/app/pubspec.lock b/app/pubspec.lock index dc85c1a..8806d09 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -169,6 +169,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.1" + expandable_page_view: + dependency: "direct main" + description: + name: expandable_page_view + sha256: "2d2c9e6fbbaa153f761054200c199eb69dc45948c8018b98a871212c67b60608" + url: "https://pub.dev" + source: hosted + version: "1.2.0" fake_async: dependency: transitive description: diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 59a912f..d7f7135 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -49,6 +49,7 @@ dependencies: modal_bottom_sheet: ^3.0.0 video_player: ^2.9.2 chewie: ^1.8.5 + expandable_page_view: ^1.0.17 dev_dependencies: flutter_test: