From 6284d216bd99d104f809b3c5f53ea6c16bd38966 Mon Sep 17 00:00:00 2001 From: caadiq Date: Fri, 27 Mar 2026 19:18:57 +0900 Subject: [PATCH] =?UTF-8?q?feat(app):=20=EC=9D=BC=EC=A0=95=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EC=83=88=20API=20=EB=8C=80=EC=9D=91=20=EB=B0=8F=20?= =?UTF-8?q?=EC=83=9D=EC=9D=BC/=EB=8D=B0=EB=B7=94=20=EC=B9=B4=EB=93=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Schedule 모델: dynamic id로 변경 (생일/기념일 문자열 ID 지원) - 생일/데뷔/기념일 특별 필드 추가 (isBirthday, isDebut 등) - BirthdayCard: 핑크-보라 그라데이션, 멤버 사진, 케이크 이모지 - DebutCard: 블루 그라데이션, DEBUT/N YEARS 아이콘, 별 장식 - 일정 목록에서 특별 일정 카드 자동 분기 렌더링 - 특별 일정 클릭 시 상세 라우팅 방지 - 달력 그리드 그림자 클리핑 수정 Co-Authored-By: Claude Opus 4.6 (1M context) --- app/lib/models/schedule.dart | 27 +- app/lib/views/schedule/schedule_view.dart | 31 ++- .../views/schedule/widgets/schedule_card.dart | 255 ++++++++++++++++++ 3 files changed, 301 insertions(+), 12 deletions(-) diff --git a/app/lib/models/schedule.dart b/app/lib/models/schedule.dart index 3f61262..e622282 100644 --- a/app/lib/models/schedule.dart +++ b/app/lib/models/schedule.dart @@ -112,7 +112,8 @@ class ScheduleDetail { } class Schedule { - final int id; + /// ID (일반 일정: int, 생일/기념일: String) + final dynamic id; final String title; final String date; final String? time; @@ -122,6 +123,12 @@ class Schedule { final List members; final String? sourceName; final String? sourceUrl; + // 특별 일정 필드 + final bool isBirthday; + final bool isDebut; + final bool isAnniversary; + final String? memberImage; + final int? anniversaryYear; Schedule({ required this.id, @@ -134,6 +141,11 @@ class Schedule { this.members = const [], this.sourceName, this.sourceUrl, + this.isBirthday = false, + this.isDebut = false, + this.isAnniversary = false, + this.memberImage, + this.anniversaryYear, }); factory Schedule.fromJson(Map json) { @@ -149,7 +161,7 @@ class Schedule { final source = json['source'] as Map?; return Schedule( - id: json['id'] is String ? int.parse(json['id']) : json['id'] as int, + id: json['id'], // int 또는 String (생일/기념일) title: json['title'] as String, date: json['date'] as String, time: json['time'] as String?, @@ -159,9 +171,20 @@ class Schedule { members: membersList, sourceName: source?['name'] as String?, sourceUrl: source?['url'] as String?, + isBirthday: json['is_birthday'] == true, + isDebut: json['is_debut'] == true, + isAnniversary: json['is_anniversary'] == true, + memberImage: json['member_image'] as String?, + anniversaryYear: (json['anniversary_year'] as num?)?.toInt(), ); } + /// 특별 일정 여부 (생일, 데뷔, 기념일) + bool get isSpecial => isBirthday || isDebut || isAnniversary; + + /// 일반 일정의 int ID (특별 일정은 null) + int? get numericId => id is int ? id : null; + /// 멤버 리스트 반환 List get memberList => members; diff --git a/app/lib/views/schedule/schedule_view.dart b/app/lib/views/schedule/schedule_view.dart index 1bbed1c..6b656fd 100644 --- a/app/lib/views/schedule/schedule_view.dart +++ b/app/lib/views/schedule/schedule_view.dart @@ -779,7 +779,7 @@ class _ScheduleViewState extends ConsumerState bottom: index < searchState.results.length - 1 ? 12 : 0, ), child: GestureDetector( - onTap: () => context.push('/schedule/${schedule.id}'), + onTap: schedule.isSpecial ? null : () => context.push('/schedule/${schedule.id}'), child: SearchScheduleCard( schedule: schedule, categoryColor: parseColor(schedule.categoryColor), @@ -1370,7 +1370,8 @@ class _ScheduleViewState extends ConsumerState }) { return GridView.builder( shrinkWrap: true, - padding: EdgeInsets.zero, + clipBehavior: Clip.none, + padding: const EdgeInsets.only(top: 4), physics: const NeverScrollableScrollPhysics(), gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 7, @@ -1409,9 +1410,8 @@ class _ScheduleViewState extends ConsumerState boxShadow: isSelected ? [ BoxShadow( - color: AppColors.primary.withValues(alpha: 0.4), - blurRadius: 8, - spreadRadius: 1, + color: AppColors.primary.withValues(alpha: 0.3), + blurRadius: 4, ), ] : null, @@ -1581,11 +1581,15 @@ class _ScheduleViewState extends ConsumerState 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: GestureDetector( + + // 특별 일정 카드 (생일/데뷔/기념일) + Widget card; + if (schedule.isBirthday) { + card = BirthdayCard(schedule: schedule); + } else if (schedule.isDebut || schedule.isAnniversary) { + card = DebutCard(schedule: schedule); + } else { + card = GestureDetector( onTap: () => context.push('/schedule/${schedule.id}'), child: AnimatedScheduleCard( key: ValueKey('${schedule.id}_${state.selectedDate.toString()}'), @@ -1593,7 +1597,14 @@ class _ScheduleViewState extends ConsumerState schedule: schedule, categoryColor: parseColor(schedule.categoryColor), ), + ); + } + + return Padding( + padding: EdgeInsets.only( + bottom: index < state.selectedDateSchedules.length - 1 ? 12 : 0, ), + child: card, ); }, ); diff --git a/app/lib/views/schedule/widgets/schedule_card.dart b/app/lib/views/schedule/widgets/schedule_card.dart index de12409..46a70dd 100644 --- a/app/lib/views/schedule/widgets/schedule_card.dart +++ b/app/lib/views/schedule/widgets/schedule_card.dart @@ -1,6 +1,7 @@ /// 일정 카드 위젯 library; +import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import '../../../core/constants.dart'; import '../../../models/schedule.dart'; @@ -250,3 +251,257 @@ class ScheduleCard extends StatelessWidget { ); } } + +/// 생일 카드 위젯 +class BirthdayCard extends StatelessWidget { + final Schedule schedule; + + const BirthdayCard({super.key, required this.schedule}); + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + gradient: const LinearGradient( + colors: [Color(0xFFF472B6), Color(0xFFA855F7), Color(0xFF6366F1)], + ), + boxShadow: [ + BoxShadow( + color: const Color(0xFFA855F7).withValues(alpha: 0.3), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + clipBehavior: Clip.antiAlias, + child: Stack( + children: [ + // 배경 장식 + Positioned( + top: -12, right: -12, + child: Container( + width: 64, height: 64, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.white.withValues(alpha: 0.1), + ), + ), + ), + Positioned( + bottom: -16, left: -16, + child: Container( + width: 80, height: 80, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.white.withValues(alpha: 0.1), + ), + ), + ), + const Positioned( + bottom: 12, left: 32, + child: Text('🎉', style: TextStyle(fontSize: 14)), + ), + // 내용 + Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + // 멤버 사진 + if (schedule.memberImage != null) + Container( + width: 56, height: 56, + margin: const EdgeInsets.only(right: 12), + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: Colors.white.withValues(alpha: 0.5), + width: 2, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.2), + blurRadius: 8, + ), + ], + ), + clipBehavior: Clip.antiAlias, + child: CachedNetworkImage( + imageUrl: schedule.memberImage!, + fit: BoxFit.cover, + placeholder: (context, url) => Container(color: Colors.white), + ), + ), + // 제목 + Expanded( + child: Row( + children: [ + const Text('🎂', style: TextStyle(fontSize: 24)), + const SizedBox(width: 8), + Expanded( + child: Text( + decodeHtmlEntities(schedule.title), + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.white, + letterSpacing: 0.5, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +/// 데뷔/기념일 카드 위젯 +class DebutCard extends StatelessWidget { + final Schedule schedule; + + const DebutCard({super.key, required this.schedule}); + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFF7a99c8), Color(0xFF98b0d8), Color(0xFFb8c8e8)], + ), + boxShadow: [ + BoxShadow( + color: const Color(0xFF7a99c8).withValues(alpha: 0.3), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + clipBehavior: Clip.antiAlias, + child: Stack( + children: [ + // 배경 장식 + Positioned( + top: -24, left: -24, + child: Container( + width: 80, height: 80, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.white.withValues(alpha: 0.1), + ), + ), + ), + Positioned( + bottom: -32, right: -32, + child: Container( + width: 96, height: 96, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.white.withValues(alpha: 0.1), + ), + ), + ), + // 반짝이는 별 + Positioned( + top: 8, right: 16, + child: Text('✦', style: TextStyle(fontSize: 12, color: Colors.white.withValues(alpha: 0.6))), + ), + Positioned( + top: 16, right: 48, + child: Text('✦', style: TextStyle(fontSize: 10, color: Colors.white.withValues(alpha: 0.4))), + ), + Positioned( + bottom: 12, right: 24, + child: Text('✦', style: TextStyle(fontSize: 14, color: Colors.white.withValues(alpha: 0.5))), + ), + // 내용 + Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + // 아이콘 영역 + Container( + width: 56, height: 56, + margin: const EdgeInsets.only(right: 12), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.white.withValues(alpha: 0.3), + ), + child: Center( + child: schedule.isDebut + ? const Text( + 'DEBUT', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w900, + color: Colors.white, + letterSpacing: 1, + ), + ) + : Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '${schedule.anniversaryYear}', + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.w900, + color: Colors.white, + height: 1, + ), + ), + Text( + 'YEARS', + style: TextStyle( + fontSize: 8, + fontWeight: FontWeight.bold, + color: Colors.white.withValues(alpha: 0.8), + ), + ), + ], + ), + ), + ), + // 제목 + Expanded( + child: Row( + children: [ + Text( + schedule.isDebut ? '🍀' : '☘️', + style: const TextStyle(fontSize: 18), + ), + const SizedBox(width: 6), + Expanded( + child: Text( + schedule.title, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.white, + letterSpacing: 0.5, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ], + ), + ), + ], + ), + ); + } +}