/// 일정 화면 library; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import '../../core/constants.dart'; import '../../models/schedule.dart'; import '../../services/schedules_service.dart'; /// HTML 엔티티 디코딩 String decodeHtmlEntities(String text) { return text .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll(''', "'") .replaceAll(' ', ' '); } class ScheduleView extends StatefulWidget { const ScheduleView({super.key}); @override State createState() => _ScheduleViewState(); } class _ScheduleViewState extends State { DateTime _selectedDate = DateTime.now(); List _schedules = []; bool _isLoading = true; final ScrollController _dateScrollController = ScrollController(); final ScrollController _contentScrollController = ScrollController(); @override void initState() { super.initState(); _loadSchedules(); } @override void dispose() { _dateScrollController.dispose(); _contentScrollController.dispose(); super.dispose(); } /// 월별 일정 로드 Future _loadSchedules() async { setState(() => _isLoading = true); try { final schedules = await getSchedules( _selectedDate.year, _selectedDate.month, ); setState(() { _schedules = schedules; _isLoading = false; }); // 선택된 날짜로 스크롤 WidgetsBinding.instance.addPostFrameCallback((_) { _scrollToSelectedDate(); }); } catch (e) { setState(() => _isLoading = false); } } /// 선택된 날짜로 스크롤 void _scrollToSelectedDate() { 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 _changeMonth(int delta) { final newDate = DateTime(_selectedDate.year, _selectedDate.month + delta, 1); final today = DateTime.now(); // 이번 달이면 오늘 날짜, 다른 달이면 1일 선택 final selectedDay = (newDate.year == today.year && newDate.month == today.month) ? today.day : 1; setState(() { _selectedDate = DateTime(newDate.year, newDate.month, selectedDay); }); _loadSchedules(); } /// 날짜 선택 void _selectDate(DateTime date) { // 일정 목록 맨 위로 즉시 이동 (애니메이션과 겹치지 않도록) if (_contentScrollController.hasClients) { _contentScrollController.jumpTo(0); } setState(() => _selectedDate = date); // 선택된 날짜 중앙으로 스크롤 _scrollToSelectedDate(); } /// 해당 달의 모든 날짜 배열 List get _daysInMonth { final year = _selectedDate.year; final month = _selectedDate.month; final lastDay = DateTime(year, month + 1, 0).day; return List.generate(lastDay, (i) => DateTime(year, month, i + 1)); } /// 선택된 날짜의 일정 List get _selectedDateSchedules { final dateStr = DateFormat('yyyy-MM-dd').format(_selectedDate); return _schedules.where((s) => s.date.split('T')[0] == dateStr).toList(); } /// 특정 날짜의 일정 (점 표시용) List _getDaySchedules(DateTime date) { final dateStr = DateFormat('yyyy-MM-dd').format(date); return _schedules.where((s) => s.date.split('T')[0] == dateStr).take(3).toList(); } /// 요일 이름 String _getDayName(DateTime date) { const days = ['일', '월', '화', '수', '목', '금', '토']; return days[date.weekday % 7]; } /// 오늘 여부 bool _isToday(DateTime date) { final today = DateTime.now(); return date.year == today.year && date.month == today.month && date.day == today.day; } /// 선택된 날짜 여부 bool _isSelected(DateTime date) { return date.year == _selectedDate.year && date.month == _selectedDate.month && date.day == _selectedDate.day; } /// 카테고리 색상 파싱 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) { return Column( children: [ // 자체 툴바 _buildToolbar(), // 날짜 선택기 _buildDateSelector(), // 일정 목록 Expanded( child: _buildScheduleList(), ), ], ); } /// 툴바 빌드 Widget _buildToolbar() { 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: () => _changeMonth(-1), icon: const Icon(Icons.chevron_left, size: 24), color: AppColors.textPrimary, ), // 년월 표시 Expanded( child: Center( child: Text( '${_selectedDate.year}년 ${_selectedDate.month}월', style: const TextStyle( fontSize: 16, fontWeight: FontWeight.bold, color: AppColors.textPrimary, ), ), ), ), // 다음 월 IconButton( onPressed: () => _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() { 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: _daysInMonth.length, itemBuilder: (context, index) { final date = _daysInMonth[index]; final isSelected = _isSelected(date); final isToday = _isToday(date); final dayOfWeek = date.weekday; final daySchedules = _getDaySchedules(date); return GestureDetector( onTap: () => _selectDate(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() { if (_isLoading) { return const Center( child: CircularProgressIndicator(color: AppColors.primary), ); } if (_selectedDateSchedules.isEmpty) { return Center( child: Text( '${_selectedDate.month}월 ${_selectedDate.day}일 일정이 없습니다', style: const TextStyle( fontSize: 14, color: AppColors.textTertiary, ), ), ); } return ListView.builder( controller: _contentScrollController, padding: const EdgeInsets.all(16), itemCount: _selectedDateSchedules.length, itemBuilder: (context, index) { final schedule = _selectedDateSchedules[index]; return Padding( padding: EdgeInsets.only(bottom: index < _selectedDateSchedules.length - 1 ? 12 : 0), child: _AnimatedScheduleCard( key: ValueKey('${schedule.id}_${_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, ), ), ); } }