/// 일정 상세 화면 library; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:lucide_icons/lucide_icons.dart'; import 'package:omni_video_player/omni_video_player.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; } /// URL 열기 (외부 앱) Future _launchUrl(String url) async { final uri = Uri.parse(url); if (await canLaunchUrl(uri)) { await launchUrl(uri, mode: LaunchMode.externalApplication); } } /// 날짜 포맷팅 (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]})'; } /// X용 날짜/시간 포맷팅 (오후 2:30 · 2026년 1월 15일) String _formatXDateTime(String dateStr, String? timeStr) { final date = DateTime.parse(dateStr); var result = '${date.year}년 ${date.month}월 ${date.day}일'; if (timeStr != null && timeStr.length >= 5) { final parts = timeStr.split(':'); final hours = int.parse(parts[0]); final minutes = parts[1]; final period = hours < 12 ? '오전' : '오후'; final hour12 = hours == 0 ? 12 : (hours > 12 ? hours - 12 : hours); result = '$period $hour12:$minutes · $result'; } 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(LucideIcons.chevronLeft, size: 24), 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( LucideIcons.calendar, 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(LucideIcons.arrowLeft, size: 18), 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); default: return _buildDefaultSection(schedule); } } /// 유튜브 섹션 Widget _buildYoutubeSection(ScheduleDetail schedule) { final videoId = schedule.videoId; final isScheduled = videoId == null; final members = schedule.members; final isFullGroup = members.length >= 5; return Column( children: [ // 영상 썸네일 또는 예정 플레이스홀더 if (isScheduled) _buildScheduledPlaceholder(schedule.bannerUrl) else ClipRRect( borderRadius: BorderRadius.circular(12), child: AspectRatio( aspectRatio: 16 / 9, child: OmniVideoPlayer( configuration: VideoPlayerConfiguration( videoSourceConfiguration: VideoSourceConfiguration.youtube( videoUrl: Uri.parse('https://www.youtube.com/watch?v=$videoId'), preferredQualities: [OmniVideoQuality.high720], ), ), callbacks: const VideoPlayerCallbacks(), ), ), ), const SizedBox(height: 16), // 영상 정보 카드 Container( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [Colors.grey[100]!, Colors.grey[200]!.withValues(alpha: 0.8)], ), borderRadius: BorderRadius.circular(12), ), padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // 제목 + 예정 뱃지 Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: Text( decodeHtmlEntities(schedule.title), style: const TextStyle( fontSize: 16, fontWeight: FontWeight.bold, color: AppColors.textPrimary, height: 1.4, ), ), ), if (isScheduled) ...[ const SizedBox(width: 8), Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration( color: const Color(0xFFFEF3C7), // amber-100 borderRadius: BorderRadius.circular(12), ), child: const Text( '예정', style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, color: Color(0xFFB45309), // amber-700 ), ), ), ], ], ), const SizedBox(height: 12), // 메타 정보 Wrap( spacing: 12, runSpacing: 6, children: [ _buildMetaItem(LucideIcons.calendar, _formatXDateTime(schedule.date, schedule.time)), if (schedule.channelName != null) _buildMetaItem(LucideIcons.link2, schedule.channelName!), ], ), // 멤버 if (members.isNotEmpty) ...[ const SizedBox(height: 12), Wrap( spacing: 6, runSpacing: 6, children: isFullGroup ? [const MemberChip(name: '프로미스나인')] : members.map((m) => MemberChip(name: m.name)).toList(), ), ], // YouTube에서 보기 버튼 if (!isScheduled) ...[ const SizedBox(height: 16), Container( padding: const EdgeInsets.only(top: 16), decoration: BoxDecoration( border: Border( top: BorderSide(color: Colors.grey[300]!.withValues(alpha: 0.5)), ), ), child: SizedBox( width: double.infinity, child: ElevatedButton( onPressed: () => _launchUrl(schedule.videoUrl!), style: ElevatedButton.styleFrom( backgroundColor: Colors.red[500], foregroundColor: Colors.white, padding: const EdgeInsets.symmetric(vertical: 12), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), elevation: 0, ), child: const Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(LucideIcons.youtube, size: 20), SizedBox(width: 8), Text( 'YouTube에서 보기', style: TextStyle(fontWeight: FontWeight.w500), ), ], ), ), ), ), ], ], ), ), ], ); } /// 예정 일정 플레이스홀더 Widget _buildScheduledPlaceholder(String? bannerUrl) { return ClipRRect( borderRadius: BorderRadius.circular(12), child: AspectRatio( aspectRatio: 16 / 9, child: Stack( fit: StackFit.expand, children: [ // 배경 if (bannerUrl != null) Stack( fit: StackFit.expand, children: [ CachedNetworkImage( imageUrl: bannerUrl, fit: BoxFit.cover, placeholder: (_, _) => Container(color: Colors.grey[900]), errorWidget: (_, _, _) => Container( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [Colors.grey[800]!, Colors.grey[900]!], ), ), ), ), // 그라데이션 오버레이 Container( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ Colors.transparent, Colors.black.withValues(alpha: 0.7), ], ), ), ), ], ) else Container( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [Colors.grey[800]!, Colors.grey[900]!], ), ), ), // 하단 텍스트 Positioned( left: 16, bottom: 16, child: Row( children: [ Icon( LucideIcons.clock, size: 16, color: Colors.amber[400], ), const SizedBox(width: 8), Text( '업로드 예정', style: TextStyle( fontSize: 16, fontWeight: FontWeight.w500, color: Colors.white.withValues(alpha: 0.9), ), ), ], ), ), ], ), ), ); } /// X 섹션 Widget _buildXSection(ScheduleDetail schedule) { final displayName = schedule.username ?? 'Unknown'; 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.fromLTRB(16, 16, 16, 0), child: Row( children: [ // 프로필 이미지 Container( width: 40, height: 40, decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [Colors.grey[700]!, Colors.grey[900]!], ), shape: BoxShape.circle, ), child: Center( child: Text( displayName[0].toUpperCase(), style: const TextStyle( color: Colors.white, fontWeight: FontWeight.bold, fontSize: 16, ), ), ), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Flexible( child: Text( displayName, style: const TextStyle( fontWeight: FontWeight.bold, fontSize: 14, ), overflow: TextOverflow.ellipsis, ), ), const SizedBox(width: 4), _buildVerifiedBadge(), ], ), Text( '@$displayName', style: TextStyle( fontSize: 13, color: Colors.grey[500], ), ), ], ), ), ], ), ), // 본문 Padding( padding: const EdgeInsets.all(16), child: _buildLinkedText( decodeHtmlEntities(schedule.content ?? schedule.title), ), ), // 이미지 if (schedule.imageUrls.isNotEmpty) ...[ Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), child: ClipRRect( borderRadius: BorderRadius.circular(12), child: schedule.imageUrls.length == 1 ? CachedNetworkImage( imageUrl: schedule.imageUrls[0], fit: BoxFit.cover, placeholder: (_, _) => Container( height: 200, color: Colors.grey[200], ), errorWidget: (_, _, _) => const SizedBox.shrink(), ) : GridView.builder( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 2, crossAxisSpacing: 4, mainAxisSpacing: 4, ), itemCount: schedule.imageUrls.length, itemBuilder: (context, index) { return CachedNetworkImage( imageUrl: schedule.imageUrls[index], fit: BoxFit.cover, placeholder: (_, _) => Container(color: Colors.grey[200]), errorWidget: (_, _, _) => const SizedBox.shrink(), ); }, ), ), ), ], // 날짜/시간 Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), decoration: BoxDecoration( border: Border(top: BorderSide(color: Colors.grey[100]!)), ), child: Text( _formatXDateTime(schedule.date, schedule.time), style: TextStyle( fontSize: 14, color: Colors.grey[500], ), ), ), // X에서 보기 버튼 if (schedule.postUrl != null) Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: Colors.grey[50], border: Border(top: BorderSide(color: Colors.grey[100]!)), ), child: SizedBox( width: double.infinity, child: ElevatedButton( onPressed: () => _launchUrl(schedule.postUrl!), style: ElevatedButton.styleFrom( backgroundColor: Colors.grey[900], foregroundColor: Colors.white, padding: const EdgeInsets.symmetric(vertical: 12), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(24), ), ), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ _buildXLogo(14), const SizedBox(width: 8), const Text( 'X에서 보기', style: TextStyle(fontWeight: FontWeight.w500), ), ], ), ), ), ), ], ), ); } /// 기본 섹션 Widget _buildDefaultSection(ScheduleDetail schedule) { return _buildInfoCard(schedule); } /// 정보 카드 Widget _buildInfoCard(ScheduleDetail schedule) { final isFullGroup = schedule.members.length >= 5; // 채널명 또는 소스 정보 final sourceName = schedule.channelName ?? schedule.username; 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(LucideIcons.calendar, _formatFullDate(schedule.date)), if (schedule.formattedTime != null) _buildMetaItem(LucideIcons.clock, schedule.formattedTime!), if (sourceName != null) _buildMetaItem(LucideIcons.link2, 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(), ), ], ], ), ); } /// 메타 아이템 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, ), ), ], ); } /// 해시태그, URL을 감지해서 링크로 만드는 RichText Widget _buildLinkedText(String text) { // 해시태그 (#xxx) 또는 URL (https://...) 매칭 final pattern = RegExp(r'(#[^\s#]+)|(https?://[^\s]+)'); final spans = []; int lastEnd = 0; for (final match in pattern.allMatches(text)) { // 매치 앞의 일반 텍스트 if (match.start > lastEnd) { spans.add(TextSpan( text: text.substring(lastEnd, match.start), style: const TextStyle( fontSize: 15, height: 1.5, color: AppColors.textPrimary, ), )); } final matched = match.group(0)!; final isHashtag = matched.startsWith('#'); final url = isHashtag ? 'https://x.com/hashtag/${Uri.encodeComponent(matched.substring(1))}?src=hashtag_click' : matched; spans.add(WidgetSpan( alignment: PlaceholderAlignment.baseline, baseline: TextBaseline.alphabetic, child: GestureDetector( onTap: () => _launchUrl(url), child: Text( matched, style: TextStyle( fontSize: 15, height: 1.5, color: AppColors.primary, decoration: isHashtag ? TextDecoration.none : TextDecoration.underline, decorationColor: AppColors.primary, ), ), ), )); lastEnd = match.end; } // 남은 텍스트 if (lastEnd < text.length) { spans.add(TextSpan( text: text.substring(lastEnd), style: const TextStyle( fontSize: 15, height: 1.5, color: AppColors.textPrimary, ), )); } return RichText(text: TextSpan(children: spans)); } /// 인증 배지 Widget _buildVerifiedBadge() { return SizedBox( width: 16, height: 16, child: CustomPaint( painter: _VerifiedBadgePainter(), ), ); } /// X 로고 Widget _buildXLogo(double size) { return SizedBox( width: size, height: size, child: CustomPaint( painter: _XLogoPainter(), ), ); } } /// 인증 배지 페인터 class _VerifiedBadgePainter extends CustomPainter { @override void paint(Canvas canvas, Size size) { final paint = Paint() ..color = const Color(0xFF3B82F6) ..style = PaintingStyle.fill; final center = Offset(size.width / 2, size.height / 2); final radius = size.width / 2; canvas.drawCircle(center, radius, paint); final checkPaint = Paint() ..color = Colors.white ..style = PaintingStyle.stroke ..strokeWidth = 1.5 ..strokeCap = StrokeCap.round ..strokeJoin = StrokeJoin.round; final checkPath = Path() ..moveTo(size.width * 0.28, size.height * 0.52) ..lineTo(size.width * 0.45, size.height * 0.68) ..lineTo(size.width * 0.72, size.height * 0.35); canvas.drawPath(checkPath, checkPaint); } @override bool shouldRepaint(covariant CustomPainter oldDelegate) => false; } /// X 로고 페인터 class _XLogoPainter extends CustomPainter { @override void paint(Canvas canvas, Size size) { final paint = Paint() ..color = Colors.white ..style = PaintingStyle.fill; final path = Path(); path.moveTo(size.width * 0.76, size.height * 0.09); path.lineTo(size.width * 0.9, size.height * 0.09); path.lineTo(size.width * 0.6, size.height * 0.44); path.lineTo(size.width * 0.95, size.height * 0.91); path.lineTo(size.width * 0.67, size.height * 0.91); path.lineTo(size.width * 0.46, size.height * 0.63); path.lineTo(size.width * 0.21, size.height * 0.91); path.lineTo(size.width * 0.07, size.height * 0.91); path.lineTo(size.width * 0.4, size.height * 0.53); path.lineTo(size.width * 0.05, size.height * 0.09); path.lineTo(size.width * 0.34, size.height * 0.09); path.lineTo(size.width * 0.52, size.height * 0.35); path.close(); canvas.drawPath(path, paint); } @override bool shouldRepaint(covariant CustomPainter oldDelegate) => false; }