diff --git a/app/lib/views/schedule/schedule_view.dart b/app/lib/views/schedule/schedule_view.dart index bee6d97..5ae1573 100644 --- a/app/lib/views/schedule/schedule_view.dart +++ b/app/lib/views/schedule/schedule_view.dart @@ -11,17 +11,8 @@ import 'package:expandable_page_view/expandable_page_view.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(' ', ' '); -} +import 'widgets/schedule_card.dart'; +import 'widgets/search_card.dart'; class ScheduleView extends ConsumerStatefulWidget { const ScheduleView({super.key}); @@ -255,17 +246,6 @@ class _ScheduleViewState extends ConsumerState 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); @@ -687,9 +667,9 @@ class _ScheduleViewState extends ConsumerState padding: EdgeInsets.only( bottom: index < searchState.results.length - 1 ? 12 : 0, ), - child: _SearchScheduleCard( + child: SearchScheduleCard( schedule: schedule, - categoryColor: _parseColor(schedule.categoryColor), + categoryColor: parseColor(schedule.categoryColor), ), ); }, @@ -1314,7 +1294,7 @@ class _ScheduleViewState extends ConsumerState height: 4, margin: const EdgeInsets.symmetric(horizontal: 1), decoration: BoxDecoration( - color: _parseColor(schedule.categoryColor), + color: parseColor(schedule.categoryColor), shape: BoxShape.circle, ), ); @@ -1401,7 +1381,7 @@ class _ScheduleViewState extends ConsumerState margin: const EdgeInsets.symmetric(horizontal: 1), decoration: BoxDecoration( - color: _parseColor(schedule.categoryColor), + color: parseColor(schedule.categoryColor), shape: BoxShape.circle, ), ); @@ -1447,555 +1427,14 @@ class _ScheduleViewState extends ConsumerState return Padding( padding: EdgeInsets.only( bottom: index < state.selectedDateSchedules.length - 1 ? 12 : 0), - child: _AnimatedScheduleCard( + child: AnimatedScheduleCard( key: ValueKey('${schedule.id}_${state.selectedDate.toString()}'), index: index, schedule: schedule, - categoryColor: _parseColor(schedule.categoryColor), + 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, - ), - ), - ); - } -} - -/// 검색 결과 카드 (웹과 동일한 디자인 - 왼쪽에 날짜, 오른쪽에 내용) -class _SearchScheduleCard extends StatelessWidget { - final Schedule schedule; - final Color categoryColor; - - const _SearchScheduleCard({ - required this.schedule, - required this.categoryColor, - }); - - /// 날짜 파싱 - Map? _parseDate(String? dateStr) { - if (dateStr == null) return null; - try { - final date = DateTime.parse(dateStr); - const weekdays = ['일', '월', '화', '수', '목', '금', '토']; - return { - 'year': date.year, - 'month': date.month, - 'day': date.day, - 'weekday': weekdays[date.weekday % 7], - 'isSunday': date.weekday == 7, - 'isSaturday': date.weekday == 6, - }; - } catch (_) { - return null; - } - } - - @override - Widget build(BuildContext context) { - final memberList = schedule.memberList; - final dateInfo = _parseDate(schedule.date); - - 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: IntrinsicHeight( - child: Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // 왼쪽 날짜 영역 (카드 높이에 맞춤) - if (dateInfo != null) - Container( - width: 72, - padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 6), - decoration: const BoxDecoration( - color: AppColors.background, - borderRadius: BorderRadius.only( - topLeft: Radius.circular(7), - bottomLeft: Radius.circular(7), - ), - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - // 년도 - Text( - '${dateInfo['year']}', - style: const TextStyle( - fontFamily: 'Pretendard', - fontSize: 10, - color: AppColors.textTertiary, - ), - ), - // 월.일 (줄바꿈 방지) - FittedBox( - fit: BoxFit.scaleDown, - child: Text( - '${dateInfo['month']}.${dateInfo['day']}', - style: const TextStyle( - fontFamily: 'Pretendard', - fontSize: 18, - fontWeight: FontWeight.bold, - color: AppColors.textPrimary, - ), - ), - ), - // 요일 - Text( - '${dateInfo['weekday']}요일', - style: TextStyle( - fontFamily: 'Pretendard', - fontSize: 11, - fontWeight: FontWeight.w500, - color: dateInfo['isSunday'] == true - ? Colors.red.shade500 - : dateInfo['isSaturday'] == true - ? Colors.blue.shade500 - : AppColors.textSecondary, - ), - ), - ], - ), - ), - // 오른쪽 콘텐츠 영역 - Expanded( - child: Padding( - padding: const EdgeInsets.all(12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // 시간 및 카테고리 뱃지 - Row( - children: [ - // 시간 뱃지 - if (schedule.formattedTime != null) - Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 2, - ), - 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( - fontFamily: 'Pretendard', - fontSize: 10, - 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: 2, - ), - decoration: BoxDecoration( - color: categoryColor.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(12), - ), - child: Text( - schedule.categoryName!, - style: TextStyle( - fontFamily: 'Pretendard', - fontSize: 10, - fontWeight: FontWeight.w500, - color: categoryColor, - ), - ), - ), - ], - ), - const SizedBox(height: 8), - // 제목 - Text( - decodeHtmlEntities(schedule.title), - style: const TextStyle( - fontFamily: 'Pretendard', - fontSize: 14, - fontWeight: FontWeight.bold, - color: AppColors.textPrimary, - height: 1.4, - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - // 출처 (빈 문자열이 아닌 경우에만 표시) - if (schedule.sourceName != null && schedule.sourceName!.isNotEmpty) ...[ - const SizedBox(height: 6), - Row( - children: [ - const Icon( - Icons.link, - size: 12, - color: AppColors.textTertiary, - ), - const SizedBox(width: 4), - Expanded( - child: Text( - schedule.sourceName!, - style: const TextStyle( - fontFamily: 'Pretendard', - fontSize: 12, - color: AppColors.textTertiary, - ), - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - ], - // 멤버 - if (memberList.isNotEmpty) ...[ - const SizedBox(height: 10), - // divider (전체 너비) - Container( - width: double.infinity, - height: 1, - color: AppColors.divider, - ), - const SizedBox(height: 10), - Wrap( - spacing: 4, - runSpacing: 4, - children: memberList.length >= 5 - ? [ - _SearchMemberChip(name: '프로미스나인'), - ] - : memberList - .map((name) => _SearchMemberChip(name: name)) - .toList(), - ), - ], - ], - ), - ), - ), - ], - ), - ), - ); - } -} - -/// 검색 결과용 멤버 칩 (작은 사이즈) -class _SearchMemberChip extends StatelessWidget { - final String name; - - const _SearchMemberChip({required this.name}); - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - gradient: const LinearGradient( - colors: [AppColors.primary, AppColors.primaryDark], - ), - borderRadius: BorderRadius.circular(6), - boxShadow: [ - BoxShadow( - color: AppColors.primary.withValues(alpha: 0.3), - blurRadius: 3, - offset: const Offset(0, 1), - ), - ], - ), - child: Text( - name, - style: const TextStyle( - fontFamily: 'Pretendard', - fontSize: 10, - fontWeight: FontWeight.w600, - color: Colors.white, - ), - ), - ); - } -} diff --git a/app/lib/views/schedule/widgets/member_chip.dart b/app/lib/views/schedule/widgets/member_chip.dart new file mode 100644 index 0000000..c6d2c47 --- /dev/null +++ b/app/lib/views/schedule/widgets/member_chip.dart @@ -0,0 +1,76 @@ +/// 멤버 칩 위젯 +library; + +import 'package:flutter/material.dart'; +import '../../../core/constants.dart'; + +/// 멤버 칩 위젯 (일정 카드용) +class MemberChip extends StatelessWidget { + final String name; + + const MemberChip({super.key, 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, + ), + ), + ); + } +} + +/// 검색 결과용 멤버 칩 (작은 사이즈) +class SearchMemberChip extends StatelessWidget { + final String name; + + const SearchMemberChip({super.key, required this.name}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [AppColors.primary, AppColors.primaryDark], + ), + borderRadius: BorderRadius.circular(6), + boxShadow: [ + BoxShadow( + color: AppColors.primary.withValues(alpha: 0.3), + blurRadius: 3, + offset: const Offset(0, 1), + ), + ], + ), + child: Text( + name, + style: const TextStyle( + fontFamily: 'Pretendard', + fontSize: 10, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + ); + } +} diff --git a/app/lib/views/schedule/widgets/schedule_card.dart b/app/lib/views/schedule/widgets/schedule_card.dart new file mode 100644 index 0000000..8868906 --- /dev/null +++ b/app/lib/views/schedule/widgets/schedule_card.dart @@ -0,0 +1,253 @@ +/// 일정 카드 위젯 +library; + +import 'package:flutter/material.dart'; +import '../../../core/constants.dart'; +import '../../../models/schedule.dart'; +import 'member_chip.dart'; + +/// HTML 엔티티 디코딩 +String decodeHtmlEntities(String text) { + return text + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll(''', "'") + .replaceAll(' ', ' '); +} + +/// 카테고리 색상 파싱 +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; + } +} + +/// 애니메이션이 적용된 일정 카드 래퍼 +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 createState() => _AnimatedScheduleCardState(); +} + +class _AnimatedScheduleCardState extends State + 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), + ); + + _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({ + super.key, + 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 && + schedule.sourceName!.isNotEmpty) ...[ + 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 + ? [ + const MemberChip(name: '프로미스나인'), + ] + : memberList + .map((name) => MemberChip(name: name)) + .toList(), + ), + ), + ], + ], + ), + ), + ); + } +} diff --git a/app/lib/views/schedule/widgets/search_card.dart b/app/lib/views/schedule/widgets/search_card.dart new file mode 100644 index 0000000..cb6d6d1 --- /dev/null +++ b/app/lib/views/schedule/widgets/search_card.dart @@ -0,0 +1,257 @@ +/// 검색 결과 카드 위젯 +library; + +import 'package:flutter/material.dart'; +import '../../../core/constants.dart'; +import '../../../models/schedule.dart'; +import 'member_chip.dart'; +import 'schedule_card.dart' show decodeHtmlEntities; + +/// 검색 결과 카드 (웹과 동일한 디자인 - 왼쪽에 날짜, 오른쪽에 내용) +class SearchScheduleCard extends StatelessWidget { + final Schedule schedule; + final Color categoryColor; + + const SearchScheduleCard({ + super.key, + required this.schedule, + required this.categoryColor, + }); + + /// 날짜 파싱 + Map? _parseDate(String? dateStr) { + if (dateStr == null) return null; + try { + final date = DateTime.parse(dateStr); + const weekdays = ['일', '월', '화', '수', '목', '금', '토']; + return { + 'year': date.year, + 'month': date.month, + 'day': date.day, + 'weekday': weekdays[date.weekday % 7], + 'isSunday': date.weekday == 7, + 'isSaturday': date.weekday == 6, + }; + } catch (_) { + return null; + } + } + + @override + Widget build(BuildContext context) { + final memberList = schedule.memberList; + final dateInfo = _parseDate(schedule.date); + + 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: IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // 왼쪽 날짜 영역 (카드 높이에 맞춤) + if (dateInfo != null) + Container( + width: 72, + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 6), + decoration: const BoxDecoration( + color: AppColors.background, + borderRadius: BorderRadius.only( + topLeft: Radius.circular(7), + bottomLeft: Radius.circular(7), + ), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // 년도 + Text( + '${dateInfo['year']}', + style: const TextStyle( + fontFamily: 'Pretendard', + fontSize: 10, + color: AppColors.textTertiary, + ), + ), + // 월.일 (줄바꿈 방지) + FittedBox( + fit: BoxFit.scaleDown, + child: Text( + '${dateInfo['month']}.${dateInfo['day']}', + style: const TextStyle( + fontFamily: 'Pretendard', + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + ), + ), + ), + // 요일 + Text( + '${dateInfo['weekday']}요일', + style: TextStyle( + fontFamily: 'Pretendard', + fontSize: 11, + fontWeight: FontWeight.w500, + color: dateInfo['isSunday'] == true + ? Colors.red.shade500 + : dateInfo['isSaturday'] == true + ? Colors.blue.shade500 + : AppColors.textSecondary, + ), + ), + ], + ), + ), + // 오른쪽 콘텐츠 영역 + Expanded( + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 시간 및 카테고리 뱃지 + Row( + children: [ + // 시간 뱃지 + if (schedule.formattedTime != null) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), + 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( + fontFamily: 'Pretendard', + fontSize: 10, + 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: 2, + ), + decoration: BoxDecoration( + color: categoryColor.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + schedule.categoryName!, + style: TextStyle( + fontFamily: 'Pretendard', + fontSize: 10, + fontWeight: FontWeight.w500, + color: categoryColor, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + // 제목 + Text( + decodeHtmlEntities(schedule.title), + style: const TextStyle( + fontFamily: 'Pretendard', + fontSize: 14, + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + height: 1.4, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + // 출처 (빈 문자열이 아닌 경우에만 표시) + if (schedule.sourceName != null && + schedule.sourceName!.isNotEmpty) ...[ + const SizedBox(height: 6), + Row( + children: [ + const Icon( + Icons.link, + size: 12, + color: AppColors.textTertiary, + ), + const SizedBox(width: 4), + Expanded( + child: Text( + schedule.sourceName!, + style: const TextStyle( + fontFamily: 'Pretendard', + fontSize: 12, + color: AppColors.textTertiary, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ], + // 멤버 + if (memberList.isNotEmpty) ...[ + const SizedBox(height: 10), + // divider (전체 너비) + Container( + width: double.infinity, + height: 1, + color: AppColors.divider, + ), + const SizedBox(height: 10), + Wrap( + spacing: 4, + runSpacing: 4, + children: memberList.length >= 5 + ? [ + const SearchMemberChip(name: '프로미스나인'), + ] + : memberList + .map((name) => SearchMemberChip(name: name)) + .toList(), + ), + ], + ], + ), + ), + ), + ], + ), + ), + ); + } +}