/// 일정 화면 (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; @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 Column( children: [ // 자체 툴바 _buildToolbar(scheduleState, controller), // 날짜 선택기 _buildDateSelector(scheduleState, controller), // 일정 목록 Expanded( child: _buildScheduleList(scheduleState), ), ], ); } /// 툴바 빌드 Widget _buildToolbar(ScheduleState state, ScheduleController controller) { return Container( color: Colors.white, child: SafeArea( bottom: false, child: Container( height: 56, padding: const EdgeInsets.symmetric(horizontal: 4), child: Row( children: [ // 달력 아이콘 (2단계에서 구현) IconButton( onPressed: () { // TODO: 달력 팝업 }, icon: const Icon(Icons.calendar_today_outlined, size: 20), color: AppColors.textSecondary, ), // 이전 월 IconButton( onPressed: () => 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, ), ), ), ), // 다음 월 IconButton( onPressed: () => 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 _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, ), ), ); } }