From 122460b2adff56b09edcae8072d904813f7ca5ff Mon Sep 17 00:00:00 2001 From: caadiq Date: Thu, 15 Jan 2026 21:27:24 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20Flutter=20=EC=95=B1=20=EC=9D=BC?= =?UTF-8?q?=EC=A0=95=20=EC=83=81=EC=84=B8=20=ED=99=94=EB=A9=B4=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ScheduleDetail, ScheduleMember, RelatedDate 모델 추가 - getSchedule API 서비스 함수 추가 - schedule_detail_view.dart 구현 (유튜브, X, 콘서트, 기본 섹션) - 라우터에 /schedule/:id 경로 추가 - 일정 목록 및 검색 결과에서 상세 화면 이동 기능 추가 Co-Authored-By: Claude Opus 4.5 --- app/lib/core/router.dart | 10 + app/lib/models/schedule.dart | 110 +++ app/lib/services/schedules_service.dart | 6 + .../views/schedule/schedule_detail_view.dart | 832 ++++++++++++++++++ app/lib/views/schedule/schedule_view.dart | 23 +- 5 files changed, 973 insertions(+), 8 deletions(-) create mode 100644 app/lib/views/schedule/schedule_detail_view.dart diff --git a/app/lib/core/router.dart b/app/lib/core/router.dart index fe310c9..4b39d4d 100644 --- a/app/lib/core/router.dart +++ b/app/lib/core/router.dart @@ -11,6 +11,7 @@ import '../views/album/album_detail_view.dart'; import '../views/album/album_gallery_view.dart'; import '../views/album/track_detail_view.dart'; import '../views/schedule/schedule_view.dart'; +import '../views/schedule/schedule_detail_view.dart'; /// 네비게이션 키 final GlobalKey rootNavigatorKey = GlobalKey(); @@ -81,5 +82,14 @@ final GoRouter appRouter = GoRouter( return AlbumGalleryView(albumName: albumName); }, ), + // 일정 상세 (셸 외부) + GoRoute( + path: '/schedule/:id', + parentNavigatorKey: rootNavigatorKey, + builder: (context, state) { + final scheduleId = int.parse(state.pathParameters['id']!); + return ScheduleDetailView(scheduleId: scheduleId); + }, + ), ], ); diff --git a/app/lib/models/schedule.dart b/app/lib/models/schedule.dart index 6d837c0..0611c17 100644 --- a/app/lib/models/schedule.dart +++ b/app/lib/models/schedule.dart @@ -1,6 +1,116 @@ /// 일정 모델 library; +/// 멤버 정보 +class ScheduleMember { + final int id; + final String name; + + ScheduleMember({required this.id, required this.name}); + + factory ScheduleMember.fromJson(Map json) { + return ScheduleMember( + id: json['id'] as int, + name: json['name'] as String, + ); + } +} + +/// 관련 일정 (콘서트 회차 등) +class RelatedDate { + final int id; + final String date; + final String? time; + + RelatedDate({required this.id, required this.date, this.time}); + + factory RelatedDate.fromJson(Map json) { + return RelatedDate( + id: json['id'] as int, + date: json['date'] as String, + time: json['time'] as String?, + ); + } +} + +/// 일정 상세 모델 +class ScheduleDetail { + final int id; + final String title; + final String date; + final String? time; + final String? description; + final int categoryId; + final String? categoryName; + final String? categoryColor; + final String? sourceUrl; + final String? sourceName; + final String? imageUrl; + final List images; + final String? locationName; + final String? locationAddress; + final String? locationLat; + final String? locationLng; + final List members; + final List relatedDates; + + ScheduleDetail({ + required this.id, + required this.title, + required this.date, + this.time, + this.description, + required this.categoryId, + this.categoryName, + this.categoryColor, + this.sourceUrl, + this.sourceName, + this.imageUrl, + this.images = const [], + this.locationName, + this.locationAddress, + this.locationLat, + this.locationLng, + this.members = const [], + this.relatedDates = const [], + }); + + factory ScheduleDetail.fromJson(Map json) { + return ScheduleDetail( + id: json['id'] as int, + title: json['title'] as String, + date: json['date'] as String, + time: json['time'] as String?, + description: json['description'] as String?, + categoryId: json['category_id'] as int, + categoryName: json['category_name'] as String?, + categoryColor: json['category_color'] as String?, + sourceUrl: json['source_url'] as String?, + sourceName: json['source_name'] as String?, + imageUrl: json['image_url'] as String?, + images: (json['images'] as List?)?.cast() ?? [], + locationName: json['location_name'] as String?, + locationAddress: json['location_address'] as String?, + locationLat: json['location_lat'] as String?, + locationLng: json['location_lng'] as String?, + members: (json['members'] as List?) + ?.map((m) => ScheduleMember.fromJson(m)) + .toList() ?? + [], + relatedDates: (json['related_dates'] as List?) + ?.map((r) => RelatedDate.fromJson(r)) + .toList() ?? + [], + ); + } + + /// 시간 포맷 (HH:mm) + String? get formattedTime { + if (time == null) return null; + return time!.length >= 5 ? time!.substring(0, 5) : time; + } +} + class Schedule { final int id; final String title; diff --git a/app/lib/services/schedules_service.dart b/app/lib/services/schedules_service.dart index 6ba5503..8639061 100644 --- a/app/lib/services/schedules_service.dart +++ b/app/lib/services/schedules_service.dart @@ -63,6 +63,12 @@ Future searchSchedules(String query, {int offset = 0, int limit = ); } +/// 단일 일정 상세 조회 +Future getSchedule(int id) async { + final response = await dio.get('/schedules/$id'); + return ScheduleDetail.fromJson(response.data); +} + /// 추천 검색어 조회 Future> getSuggestions(String query, {int limit = 10}) async { if (query.trim().isEmpty) return []; diff --git a/app/lib/views/schedule/schedule_detail_view.dart b/app/lib/views/schedule/schedule_detail_view.dart new file mode 100644 index 0000000..d97b765 --- /dev/null +++ b/app/lib/views/schedule/schedule_detail_view.dart @@ -0,0 +1,832 @@ +/// 일정 상세 화면 +library; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:url_launcher/url_launcher.dart'; +import '../../core/constants.dart'; +import '../../models/schedule.dart'; +import '../../services/schedules_service.dart'; +import 'widgets/schedule_card.dart'; +import 'widgets/member_chip.dart'; + +/// 카테고리 ID 상수 +class CategoryId { + static const int youtube = 2; + static const int x = 3; + static const int album = 4; + static const int fansign = 5; + static const int concert = 6; + static const int ticket = 7; +} + +/// 일정 상세 Provider +final scheduleDetailProvider = + FutureProvider.family((ref, id) async { + return await getSchedule(id); +}); + +class ScheduleDetailView extends ConsumerStatefulWidget { + final int scheduleId; + + const ScheduleDetailView({super.key, required this.scheduleId}); + + @override + ConsumerState createState() => _ScheduleDetailViewState(); +} + +class _ScheduleDetailViewState extends ConsumerState { + late int _currentScheduleId; + + @override + void initState() { + super.initState(); + _currentScheduleId = widget.scheduleId; + } + + /// 회차 변경 + void _changeSchedule(int newId) { + setState(() { + _currentScheduleId = newId; + }); + } + + /// URL 열기 + Future _launchUrl(String url) async { + final uri = Uri.parse(url); + if (await canLaunchUrl(uri)) { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } + } + + /// 유튜브 비디오 ID 추출 + String? _extractYoutubeVideoId(String? url) { + if (url == null) return null; + final shortMatch = RegExp(r'youtu\.be/([a-zA-Z0-9_-]{11})').firstMatch(url); + if (shortMatch != null) return shortMatch.group(1); + final watchMatch = + RegExp(r'youtube\.com/watch\?v=([a-zA-Z0-9_-]{11})').firstMatch(url); + if (watchMatch != null) return watchMatch.group(1); + final shortsMatch = + RegExp(r'youtube\.com/shorts/([a-zA-Z0-9_-]{11})').firstMatch(url); + if (shortsMatch != null) return shortsMatch.group(1); + return null; + } + + /// 날짜 포맷팅 (2026. 1. 15. (수)) + String _formatFullDate(String dateStr) { + final date = DateTime.parse(dateStr); + final dayNames = ['일', '월', '화', '수', '목', '금', '토']; + return '${date.year}. ${date.month}. ${date.day}. (${dayNames[date.weekday % 7]})'; + } + + /// 단일 날짜 포맷팅 (1월 15일 (수) 19:00) + String _formatSingleDate(String dateStr, String? timeStr) { + final date = DateTime.parse(dateStr); + final dayNames = ['일', '월', '화', '수', '목', '금', '토']; + var result = '${date.month}월 ${date.day}일 (${dayNames[date.weekday % 7]})'; + if (timeStr != null && timeStr.length >= 5) { + result += ' ${timeStr.substring(0, 5)}'; + } + return result; + } + + @override + Widget build(BuildContext context) { + final scheduleAsync = ref.watch(scheduleDetailProvider(_currentScheduleId)); + + return Scaffold( + backgroundColor: AppColors.background, + appBar: AppBar( + backgroundColor: Colors.white, + elevation: 0, + scrolledUnderElevation: 0.5, + leading: IconButton( + icon: const Icon(Icons.arrow_back_ios_new, size: 20), + onPressed: () => Navigator.of(context).pop(), + ), + title: scheduleAsync.whenOrNull( + data: (schedule) => Text( + schedule.categoryName ?? '', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: parseColor(schedule.categoryColor), + ), + ), + ), + centerTitle: true, + ), + body: scheduleAsync.when( + loading: () => const Center( + child: CircularProgressIndicator(color: AppColors.primary), + ), + error: (error, stack) => _buildErrorView(), + data: (schedule) => _buildContent(schedule), + ), + ); + } + + /// 에러 화면 + Widget _buildErrorView() { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 80, + height: 80, + decoration: BoxDecoration( + color: AppColors.primary.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(20), + ), + child: Icon( + Icons.calendar_today, + size: 40, + color: AppColors.primary.withValues(alpha: 0.4), + ), + ), + const SizedBox(height: 24), + const Text( + '일정을 찾을 수 없습니다', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 8), + const Text( + '요청하신 일정이 존재하지 않거나\n삭제되었을 수 있습니다.', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 14, + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 32), + OutlinedButton.icon( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.arrow_back), + label: const Text('돌아가기'), + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.primary, + side: const BorderSide(color: AppColors.primary), + padding: + const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + ), + ), + ], + ), + ), + ); + } + + /// 메인 컨텐츠 + Widget _buildContent(ScheduleDetail schedule) { + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: _buildCategorySection(schedule), + ); + } + + /// 카테고리별 섹션 + Widget _buildCategorySection(ScheduleDetail schedule) { + switch (schedule.categoryId) { + case CategoryId.youtube: + return _buildYoutubeSection(schedule); + case CategoryId.x: + return _buildXSection(schedule); + case CategoryId.concert: + return _buildConcertSection(schedule); + default: + return _buildDefaultSection(schedule); + } + } + + /// 유튜브 섹션 + Widget _buildYoutubeSection(ScheduleDetail schedule) { + final videoId = _extractYoutubeVideoId(schedule.sourceUrl); + final isShorts = schedule.sourceUrl?.contains('/shorts/') ?? false; + + if (videoId == null) return _buildDefaultSection(schedule); + + // 썸네일 URL + final thumbnailUrl = 'https://img.youtube.com/vi/$videoId/maxresdefault.jpg'; + + return Column( + children: [ + // 썸네일 + 재생 버튼 + GestureDetector( + onTap: () => _launchUrl(schedule.sourceUrl!), + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: isShorts + ? Center( + child: SizedBox( + width: 200, + child: AspectRatio( + aspectRatio: 9 / 16, + child: Stack( + alignment: Alignment.center, + children: [ + CachedNetworkImage( + imageUrl: thumbnailUrl, + fit: BoxFit.cover, + width: double.infinity, + height: double.infinity, + placeholder: (_, _) => Container( + color: Colors.grey[200], + ), + errorWidget: (_, _, _) => Container( + color: Colors.grey[200], + child: const Icon(Icons.play_circle_outline, size: 48), + ), + ), + _buildPlayButton(), + ], + ), + ), + ), + ) + : AspectRatio( + aspectRatio: 16 / 9, + child: Stack( + alignment: Alignment.center, + children: [ + CachedNetworkImage( + imageUrl: thumbnailUrl, + fit: BoxFit.cover, + width: double.infinity, + height: double.infinity, + placeholder: (_, _) => Container( + color: Colors.grey[200], + ), + errorWidget: (_, _, _) => Container( + color: Colors.grey[200], + child: const Icon(Icons.play_circle_outline, size: 48), + ), + ), + _buildPlayButton(), + ], + ), + ), + ), + ), + const SizedBox(height: 16), + // 정보 카드 + _buildInfoCard( + schedule, + bottomButton: _buildYoutubeButton(schedule.sourceUrl), + ), + ], + ); + } + + /// 재생 버튼 + Widget _buildPlayButton() { + return Container( + width: 64, + height: 64, + decoration: BoxDecoration( + color: Colors.red, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.3), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: const Icon( + Icons.play_arrow, + color: Colors.white, + size: 40, + ), + ); + } + + /// X 섹션 + Widget _buildXSection(ScheduleDetail schedule) { + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 헤더 + Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: Colors.grey[800], + shape: BoxShape.circle, + ), + child: Center( + child: Text( + (schedule.sourceName ?? 'X')[0].toUpperCase(), + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Flexible( + child: Text( + schedule.sourceName ?? '', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 14, + ), + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 4), + const Icon(Icons.verified, + color: Colors.blue, size: 16), + ], + ), + ], + ), + ), + ], + ), + ), + // 본문 + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text( + decodeHtmlEntities(schedule.description ?? schedule.title), + style: const TextStyle(fontSize: 15, height: 1.5), + ), + ), + // 이미지 + if (schedule.imageUrl != null) ...[ + const SizedBox(height: 12), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Image.network( + schedule.imageUrl!, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => const SizedBox.shrink(), + ), + ), + ), + ], + // 날짜 + Padding( + padding: const EdgeInsets.all(16), + child: Text( + _formatFullDate(schedule.date), + style: const TextStyle( + fontSize: 14, + color: AppColors.textSecondary, + ), + ), + ), + // X에서 보기 버튼 + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.grey[50], + border: Border(top: BorderSide(color: AppColors.border)), + ), + child: SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: () => _launchUrl(schedule.sourceUrl ?? ''), + icon: const Icon(Icons.open_in_new, size: 18), + label: const Text('X에서 보기'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.black, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24), + ), + ), + ), + ), + ), + ], + ), + ); + } + + /// 콘서트 섹션 + Widget _buildConcertSection(ScheduleDetail schedule) { + final hasLocation = + schedule.locationLat != null && schedule.locationLng != null; + final hasPoster = schedule.images.isNotEmpty; + final hasMultipleDates = schedule.relatedDates.length > 1; + + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.04), + blurRadius: 12, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 헤더 + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [AppColors.primary, AppColors.primary.withValues(alpha: 0.8)], + ), + borderRadius: + const BorderRadius.vertical(top: Radius.circular(12)), + ), + child: Row( + children: [ + if (hasPoster) + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.network( + schedule.images[0], + width: 56, + height: 72, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => const SizedBox.shrink(), + ), + ), + if (hasPoster) const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + decodeHtmlEntities(schedule.title), + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 15, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + if (schedule.locationName != null) ...[ + const SizedBox(height: 4), + Text( + schedule.locationName!, + style: TextStyle( + color: Colors.white.withValues(alpha: 0.8), + fontSize: 12, + ), + overflow: TextOverflow.ellipsis, + ), + ], + ], + ), + ), + ], + ), + ), + // 정보 목록 + Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 공연 일정 + _buildSectionLabel(Icons.calendar_today, '공연 일정'), + const SizedBox(height: 8), + if (hasMultipleDates) + ...schedule.relatedDates.asMap().entries.map((entry) { + final index = entry.key; + final item = entry.value; + final isCurrent = item.id == schedule.id; + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: InkWell( + onTap: isCurrent ? null : () => _changeSchedule(item.id), + borderRadius: BorderRadius.circular(8), + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + decoration: BoxDecoration( + color: isCurrent + ? AppColors.primary + : Colors.grey[100], + borderRadius: BorderRadius.circular(8), + ), + child: Text( + '${index + 1}회차 ${_formatSingleDate(item.date, item.time)}', + style: TextStyle( + fontSize: 13, + fontWeight: + isCurrent ? FontWeight.bold : FontWeight.normal, + color: isCurrent + ? Colors.white + : AppColors.textPrimary, + ), + ), + ), + ), + ); + }) + else + Text( + _formatSingleDate(schedule.date, schedule.time), + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 14, + ), + ), + const SizedBox(height: 16), + // 장소 + if (schedule.locationName != null) ...[ + _buildSectionLabel(Icons.place, '장소'), + const SizedBox(height: 8), + Text( + schedule.locationName!, + style: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 14, + ), + ), + if (schedule.locationAddress != null) ...[ + const SizedBox(height: 2), + Text( + schedule.locationAddress!, + style: const TextStyle( + fontSize: 12, + color: AppColors.textSecondary, + ), + ), + ], + const SizedBox(height: 16), + ], + // 설명 + if (schedule.description != null && + schedule.description!.isNotEmpty) ...[ + Container( + width: double.infinity, + padding: const EdgeInsets.only(top: 16), + decoration: BoxDecoration( + border: Border( + top: BorderSide(color: AppColors.divider), + ), + ), + child: Text( + decodeHtmlEntities(schedule.description!), + style: const TextStyle( + fontSize: 14, + color: AppColors.textSecondary, + height: 1.5, + ), + ), + ), + ], + ], + ), + ), + // 버튼 영역 + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + child: Column( + children: [ + if (hasLocation) + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: () => _launchUrl( + 'https://map.kakao.com/link/to/${Uri.encodeComponent(schedule.locationName!)},${schedule.locationLat},${schedule.locationLng}'), + icon: const Icon(Icons.navigation, size: 18), + label: const Text('길찾기'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.blue, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ), + if (hasLocation && schedule.sourceUrl != null) + const SizedBox(height: 8), + if (schedule.sourceUrl != null) + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: () => _launchUrl(schedule.sourceUrl!), + icon: const Icon(Icons.open_in_new, size: 18), + label: const Text('상세 정보 보기'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.grey[900], + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ), + ], + ), + ), + ], + ), + ); + } + + /// 기본 섹션 + Widget _buildDefaultSection(ScheduleDetail schedule) { + return _buildInfoCard( + schedule, + bottomButton: schedule.sourceUrl != null + ? SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: () => _launchUrl(schedule.sourceUrl!), + icon: const Icon(Icons.open_in_new, size: 18), + label: const Text('원본 보기'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.grey[900], + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ) + : null, + ); + } + + /// 정보 카드 + Widget _buildInfoCard(ScheduleDetail schedule, {Widget? bottomButton}) { + final isFullGroup = schedule.members.length >= 5; + + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.04), + blurRadius: 12, + offset: const Offset(0, 2), + ), + ], + ), + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 제목 + Text( + decodeHtmlEntities(schedule.title), + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + height: 1.4, + ), + ), + const SizedBox(height: 12), + // 메타 정보 + Wrap( + spacing: 12, + runSpacing: 8, + children: [ + _buildMetaItem(Icons.calendar_today, _formatFullDate(schedule.date)), + if (schedule.formattedTime != null) + _buildMetaItem(Icons.access_time, schedule.formattedTime!), + if (schedule.sourceName != null) + _buildMetaItem(Icons.link, schedule.sourceName!), + ], + ), + // 멤버 + if (schedule.members.isNotEmpty) ...[ + const SizedBox(height: 16), + Container( + width: double.infinity, + height: 1, + color: AppColors.divider, + ), + const SizedBox(height: 16), + Wrap( + spacing: 6, + runSpacing: 6, + children: isFullGroup + ? [const MemberChip(name: '프로미스나인')] + : schedule.members + .map((m) => MemberChip(name: m.name)) + .toList(), + ), + ], + // 설명 + if (schedule.description != null && + schedule.description!.isNotEmpty) ...[ + const SizedBox(height: 16), + Text( + decodeHtmlEntities(schedule.description!), + style: const TextStyle( + fontSize: 14, + color: AppColors.textSecondary, + height: 1.5, + ), + ), + ], + // 하단 버튼 + if (bottomButton != null) ...[ + const SizedBox(height: 16), + bottomButton, + ], + ], + ), + ); + } + + /// 메타 아이템 + Widget _buildMetaItem(IconData icon, String text) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 14, color: AppColors.textSecondary), + const SizedBox(width: 4), + Text( + text, + style: const TextStyle( + fontSize: 12, + color: AppColors.textSecondary, + ), + ), + ], + ); + } + + /// 섹션 라벨 + Widget _buildSectionLabel(IconData icon, String text) { + return Row( + children: [ + Icon(icon, size: 14, color: AppColors.textSecondary), + const SizedBox(width: 6), + Text( + text, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: AppColors.textSecondary, + ), + ), + ], + ); + } + + /// 유튜브 버튼 + Widget _buildYoutubeButton(String? url) { + if (url == null) return const SizedBox.shrink(); + return SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: () => _launchUrl(url), + icon: const Icon(Icons.play_circle_filled, size: 20), + label: const Text('YouTube에서 보기'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ); + } +} diff --git a/app/lib/views/schedule/schedule_view.dart b/app/lib/views/schedule/schedule_view.dart index 1fb504b..1bbed1c 100644 --- a/app/lib/views/schedule/schedule_view.dart +++ b/app/lib/views/schedule/schedule_view.dart @@ -8,6 +8,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:expandable_page_view/expandable_page_view.dart'; +import 'package:go_router/go_router.dart'; import '../../core/constants.dart'; import '../../models/schedule.dart'; import '../../controllers/schedule_controller.dart'; @@ -777,9 +778,12 @@ class _ScheduleViewState extends ConsumerState padding: EdgeInsets.only( bottom: index < searchState.results.length - 1 ? 12 : 0, ), - child: SearchScheduleCard( - schedule: schedule, - categoryColor: parseColor(schedule.categoryColor), + child: GestureDetector( + onTap: () => context.push('/schedule/${schedule.id}'), + child: SearchScheduleCard( + schedule: schedule, + categoryColor: parseColor(schedule.categoryColor), + ), ), ); }, @@ -1581,11 +1585,14 @@ class _ScheduleViewState extends ConsumerState 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), + child: GestureDetector( + onTap: () => context.push('/schedule/${schedule.id}'), + child: AnimatedScheduleCard( + key: ValueKey('${schedule.id}_${state.selectedDate.toString()}'), + index: index, + schedule: schedule, + categoryColor: parseColor(schedule.categoryColor), + ), ), ); },