From 21bd887f5ea7d898cf873ee69f89a236ff6feae3 Mon Sep 17 00:00:00 2001 From: caadiq Date: Tue, 13 Jan 2026 18:25:14 +0900 Subject: [PATCH] =?UTF-8?q?Flutter=20=EC=95=B1:=20=EC=9D=BC=EC=A0=95=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=202=EB=8B=A8=EA=B3=84=20-=20=EB=8B=AC?= =?UTF-8?q?=EB=A0=A5=20=ED=8C=9D=EC=97=85=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 달력 아이콘 클릭시 달력 팝업 표시: - 월 그리드 (요일 헤더 + 날짜 + 일정 점 표시) - 년월 선택 모드 (년도 범위 + 월 선택) - 오늘 버튼 - 배경 터치로 닫기 Co-Authored-By: Claude Opus 4.5 --- app/lib/controllers/schedule_controller.dart | 13 + app/lib/views/schedule/schedule_view.dart | 484 ++++++++++++++++++- 2 files changed, 477 insertions(+), 20 deletions(-) diff --git a/app/lib/controllers/schedule_controller.dart b/app/lib/controllers/schedule_controller.dart index 60393a0..6c11baf 100644 --- a/app/lib/controllers/schedule_controller.dart +++ b/app/lib/controllers/schedule_controller.dart @@ -110,6 +110,19 @@ class ScheduleController extends Notifier { loadSchedules(); } + /// 특정 날짜로 이동 (달력에서 선택 시) + void goToDate(DateTime date) { + final currentMonth = state.selectedDate.month; + final currentYear = state.selectedDate.year; + + state = state.copyWith(selectedDate: date); + + // 월이 변경되면 일정 다시 로드 + if (date.month != currentMonth || date.year != currentYear) { + loadSchedules(); + } + } + /// 오늘 여부 bool isToday(DateTime date) { final today = DateTime.now(); diff --git a/app/lib/views/schedule/schedule_view.dart b/app/lib/views/schedule/schedule_view.dart index 7b21b6f..5fa3c64 100644 --- a/app/lib/views/schedule/schedule_view.dart +++ b/app/lib/views/schedule/schedule_view.dart @@ -32,6 +32,12 @@ class _ScheduleViewState extends ConsumerState { final ScrollController _contentScrollController = ScrollController(); DateTime? _lastSelectedDate; + // 달력 팝업 상태 + bool _showCalendar = false; + DateTime _calendarViewDate = DateTime.now(); + bool _showYearMonthPicker = false; + int _yearRangeStart = (DateTime.now().year ~/ 12) * 12; + @override void dispose() { _dateScrollController.dispose(); @@ -97,22 +103,54 @@ class _ScheduleViewState extends ConsumerState { }); } - return Column( + return Stack( children: [ - // 자체 툴바 - _buildToolbar(scheduleState, controller), - // 날짜 선택기 - _buildDateSelector(scheduleState, controller), - // 일정 목록 - Expanded( - child: _buildScheduleList(scheduleState), + // 메인 콘텐츠 + Column( + children: [ + // 자체 툴바 + _buildToolbar(scheduleState, controller), + // 날짜 선택기 + _buildDateSelector(scheduleState, controller), + // 일정 목록 + Expanded( + child: _buildScheduleList(scheduleState), + ), + ], ), + // 달력 팝업 오버레이 + if (_showCalendar) ...[ + // 배경 오버레이 + Positioned.fill( + child: GestureDetector( + onTap: () { + setState(() { + _showCalendar = false; + _showYearMonthPicker = false; + }); + }, + child: Container( + color: Colors.black.withValues(alpha: 0.4), + ), + ), + ), + // 달력 팝업 + Positioned( + top: MediaQuery.of(context).padding.top + 56, + left: 0, + right: 0, + child: _buildCalendarPopup(scheduleState, controller), + ), + ], ], ); } /// 툴바 빌드 Widget _buildToolbar(ScheduleState state, ScheduleController controller) { + // 달력이 열려있을 때는 달력 뷰 날짜 기준, 아니면 선택된 날짜 기준 + final displayDate = _showCalendar ? _calendarViewDate : state.selectedDate; + return Container( color: Colors.white, child: SafeArea( @@ -122,36 +160,97 @@ class _ScheduleViewState extends ConsumerState { padding: const EdgeInsets.symmetric(horizontal: 4), child: Row( children: [ - // 달력 아이콘 (2단계에서 구현) + // 달력 아이콘 IconButton( onPressed: () { - // TODO: 달력 팝업 + setState(() { + if (_showCalendar) { + _showCalendar = false; + _showYearMonthPicker = false; + } else { + _calendarViewDate = state.selectedDate; + _showCalendar = true; + } + }); }, icon: const Icon(Icons.calendar_today_outlined, size: 20), - color: AppColors.textSecondary, + color: _showCalendar ? AppColors.primary : AppColors.textSecondary, ), // 이전 월 IconButton( - onPressed: () => controller.changeMonth(-1), + 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, ), // 년월 표시 Expanded( - child: Center( - child: Text( - '${state.selectedDate.year}년 ${state.selectedDate.month}월', - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: AppColors.textPrimary, + child: GestureDetector( + onTap: _showCalendar + ? () { + setState(() { + _showYearMonthPicker = !_showYearMonthPicker; + _yearRangeStart = (_calendarViewDate.year ~/ 12) * 12; + }); + } + : null, + child: Center( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '${displayDate.year}년 ${displayDate.month}월', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: _showYearMonthPicker + ? AppColors.primary + : AppColors.textPrimary, + ), + ), + if (_showCalendar) ...[ + const SizedBox(width: 4), + Icon( + _showYearMonthPicker + ? Icons.keyboard_arrow_up + : Icons.keyboard_arrow_down, + size: 18, + color: _showYearMonthPicker + ? AppColors.primary + : AppColors.textPrimary, + ), + ], + ], ), ), ), ), // 다음 월 IconButton( - onPressed: () => controller.changeMonth(1), + 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, ), @@ -170,6 +269,351 @@ class _ScheduleViewState extends ConsumerState { ); } + /// 달력 팝업 빌드 + Widget _buildCalendarPopup(ScheduleState state, ScheduleController controller) { + return 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(), + ), + ); + } + + /// 년월 선택기 + Widget _buildYearMonthPicker() { + final yearRange = List.generate(12, (i) => _yearRangeStart + i); + + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + // 년도 범위 헤더 + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + IconButton( + onPressed: () { + setState(() { + _yearRangeStart -= 12; + }); + }, + icon: const Icon(Icons.chevron_left, size: 18), + ), + Text( + '$_yearRangeStart - ${_yearRangeStart + 11}', + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + IconButton( + onPressed: () { + setState(() { + _yearRangeStart += 12; + }); + }, + icon: const Icon(Icons.chevron_right, size: 18), + ), + ], + ), + const SizedBox(height: 8), + // 년도 라벨 + const Text( + '년도', + style: TextStyle(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: 16), + // 월 라벨 + const Text( + '월', + style: TextStyle(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 month = index + 1; + final isSelected = month == _calendarViewDate.month; + return GestureDetector( + onTap: () { + setState(() { + _calendarViewDate = DateTime(_calendarViewDate.year, month, 1); + _showYearMonthPicker = false; + }); + }, + child: Container( + decoration: BoxDecoration( + color: isSelected ? AppColors.primary : Colors.transparent, + borderRadius: BorderRadius.circular(8), + ), + alignment: Alignment.center, + child: Text( + '$month월', + style: TextStyle( + fontSize: 14, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400, + color: isSelected ? Colors.white : AppColors.textPrimary, + ), + ), + ), + ); + }, + ), + ], + ), + ); + } + + /// 달력 그리드 + Widget _buildCalendarGrid(ScheduleState state, ScheduleController controller) { + final year = _calendarViewDate.year; + final month = _calendarViewDate.month; + + // 달력 데이터 생성 + final firstDay = DateTime(year, month, 1); + final lastDay = DateTime(year, month + 1, 0); + final startWeekday = firstDay.weekday % 7; // 0 = 일요일 + 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), + ); + + final allDays = [...prevMonthDays, ...currentMonthDays, ...nextMonthDays]; + + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + // 요일 헤더 + Row( + children: ['일', '월', '화', '수', '목', '금', '토'].asMap().entries.map((entry) { + final index = entry.key; + final day = entry.value; + return Expanded( + child: Center( + child: Text( + day, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: index == 0 + ? Colors.red.shade400 + : index == 6 + ? Colors.blue.shade400 + : AppColors.textSecondary, + ), + ), + ), + ); + }).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: 12), + // 오늘 버튼 + GestureDetector( + onTap: () { + final today = DateTime.now(); + controller.goToDate(today); + if (_contentScrollController.hasClients) { + _contentScrollController.jumpTo(0); + } + setState(() { + _showCalendar = false; + _showYearMonthPicker = false; + }); + }, + 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 _buildDateSelector(ScheduleState state, ScheduleController controller) { return Container(