From 221aaa2bb4fc123606810d62ac6f872c33e1caaf Mon Sep 17 00:00:00 2001 From: caadiq Date: Tue, 13 Jan 2026 18:06:50 +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=201=EB=8B=A8=EA=B3=84=20=EA=B5=AC=ED=98=84?= =?UTF-8?q?=20(=EB=82=A0=EC=A7=9C=20=EC=84=A0=ED=83=9D=EA=B8=B0=20+=20?= =?UTF-8?q?=EC=9D=BC=EC=A0=95=20=EB=AA=A9=EB=A1=9D)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 자체 툴바 (년월 표시, 이전/다음 월 버튼) - 가로 스크롤 날짜 선택기 (일정 점 표시, 자동 중앙 스크롤) - 일정 카드 (시간, 카테고리, 제목, 출처, 멤버) - 순차적 페이드 인 애니메이션 Co-Authored-By: Claude Opus 4.5 --- app/lib/views/main_shell.dart | 61 ++- app/lib/views/schedule/schedule_view.dart | 636 +++++++++++++++++++++- 2 files changed, 644 insertions(+), 53 deletions(-) diff --git a/app/lib/views/main_shell.dart b/app/lib/views/main_shell.dart index c5729c3..cc3a67d 100644 --- a/app/lib/views/main_shell.dart +++ b/app/lib/views/main_shell.dart @@ -16,43 +16,46 @@ class MainShell extends StatelessWidget { Widget build(BuildContext context) { final location = GoRouterState.of(context).uri.path; final isMembersPage = location == '/members'; + final isSchedulePage = location.startsWith('/schedule'); return Scaffold( backgroundColor: AppColors.background, - // 앱바 (툴바) - 멤버 페이지에서는 그림자 제거 (인디케이터와 계단 효과 방지) - appBar: PreferredSize( - preferredSize: const Size.fromHeight(56), - child: Container( - decoration: BoxDecoration( - color: Colors.white, - boxShadow: isMembersPage - ? null - : [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.05), - blurRadius: 4, - offset: const Offset(0, 1), + // 앱바 (툴바) - 일정 페이지는 자체 툴바 사용, 멤버 페이지는 그림자 제거 + appBar: isSchedulePage + ? null + : PreferredSize( + preferredSize: const Size.fromHeight(56), + child: Container( + decoration: BoxDecoration( + color: Colors.white, + boxShadow: isMembersPage + ? null + : [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 4, + offset: const Offset(0, 1), + ), + ], + ), + child: SafeArea( + child: SizedBox( + height: 56, + child: Center( + child: Text( + _getTitle(location), + style: const TextStyle( + fontFamily: 'Pretendard', + color: AppColors.primary, + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), ), - ], - ), - child: SafeArea( - child: SizedBox( - height: 56, - child: Center( - child: Text( - _getTitle(location), - style: const TextStyle( - fontFamily: 'Pretendard', - color: AppColors.primary, - fontSize: 20, - fontWeight: FontWeight.bold, ), ), ), ), - ), - ), - ), // 콘텐츠 body: child, // 바텀 네비게이션 diff --git a/app/lib/views/schedule/schedule_view.dart b/app/lib/views/schedule/schedule_view.dart index 5ab5413..2e57e1f 100644 --- a/app/lib/views/schedule/schedule_view.dart +++ b/app/lib/views/schedule/schedule_view.dart @@ -2,40 +2,628 @@ library; import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; import '../../core/constants.dart'; +import '../../models/schedule.dart'; +import '../../services/schedules_service.dart'; -class ScheduleView extends StatelessWidget { +/// 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 const Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.calendar_today_outlined, - size: 64, + 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, ), - SizedBox(height: 16), - Text( - '일정', - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - color: AppColors.textSecondary, - ), + ), + ); + } + + 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), ), - SizedBox(height: 8), - Text( - '일정 화면 준비 중', - style: TextStyle( - fontSize: 14, - color: AppColors.textTertiary, - ), + ); + }, + ); + } +} + +/// 애니메이션이 적용된 일정 카드 래퍼 +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, + ), ), ); }