/// 일정 화면 (MVCS의 View 레이어) /// /// UI 렌더링만 담당하고, 비즈니스 로직은 Controller에 위임합니다. library; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.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 { final ScrollController _dateScrollController = ScrollController(); 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(); _contentScrollController.dispose(); super.dispose(); } /// 선택된 날짜로 스크롤 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 controller = ref.read(scheduleProvider.notifier); // 날짜가 변경되면 스크롤 if (_lastSelectedDate != scheduleState.selectedDate) { _lastSelectedDate = scheduleState.selectedDate; WidgetsBinding.instance.addPostFrameCallback((_) { _scrollToSelectedDate(scheduleState.selectedDate); }); } return Stack( children: [ // 메인 콘텐츠 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( bottom: false, child: Container( height: 56, padding: const EdgeInsets.symmetric(horizontal: 4), child: Row( children: [ // 달력 아이콘 IconButton( onPressed: () { setState(() { if (_showCalendar) { _showCalendar = false; _showYearMonthPicker = false; } else { _calendarViewDate = state.selectedDate; _showCalendar = true; } }); }, 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, ), // 년월 표시 Expanded( 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: () { 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, ), // 검색 아이콘 (3단계에서 구현) IconButton( onPressed: () { // TODO: 검색 모드 }, icon: const Icon(Icons.search, size: 20), color: AppColors.textSecondary, ), ], ), ), ), ); } /// 달력 팝업 빌드 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( 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( 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, ), ), ); } }