From c37d7e14af347e2121a918ae14cdd3965fbf04fb Mon Sep 17 00:00:00 2001 From: caadiq Date: Fri, 27 Mar 2026 20:20:51 +0900 Subject: [PATCH] =?UTF-8?q?feat(app):=20=EC=9D=BC=EC=A0=95=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=ED=99=94=EB=A9=B4=20=EC=83=88=20API=20=EB=8C=80?= =?UTF-8?q?=EC=9D=91=20=EB=B0=8F=20YouTube=20=EC=95=B1=20=EB=82=B4=20?= =?UTF-8?q?=EC=9E=AC=EC=83=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ScheduleDetail 모델: 새 API 형식 (category 중첩 객체, YouTube/X 전용 필드) - YouTube 섹션: omni_video_player로 앱 내 재생, 예정 플레이스홀더 추가 - X 섹션: username, content, imageUrls, postUrl 직접 사용 - 숏츠 영상 16:9 통일, 날짜 형식 웹과 동일하게 변경 - 콘서트 섹션을 기본 섹션으로 통합 (API 변경 반영) Co-Authored-By: Claude Opus 4.6 (1M context) --- app/lib/models/schedule.dart | 78 +- .../views/schedule/schedule_detail_view.dart | 893 ++++++------------ app/pubspec.lock | 144 +++ app/pubspec.yaml | 1 + 4 files changed, 488 insertions(+), 628 deletions(-) diff --git a/app/lib/models/schedule.dart b/app/lib/models/schedule.dart index e622282..489d8bd 100644 --- a/app/lib/models/schedule.dart +++ b/app/lib/models/schedule.dart @@ -39,68 +39,70 @@ class ScheduleDetail { final String title; final String date; final String? time; - final String? description; - final int categoryId; + 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; + // YouTube 관련 + final String? channelName; + final String? videoId; + final String? videoType; + final String? videoUrl; + final String? bannerUrl; + // X 관련 + final String? postId; + final String? username; + final String? content; + final List imageUrls; + final String? postUrl; ScheduleDetail({ required this.id, required this.title, required this.date, this.time, - this.description, - required this.categoryId, + 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 [], + this.channelName, + this.videoId, + this.videoType, + this.videoUrl, + this.bannerUrl, + this.postId, + this.username, + this.content, + this.imageUrls = const [], + this.postUrl, }); factory ScheduleDetail.fromJson(Map json) { + // category 중첩 객체 파싱 + final category = json['category'] as Map?; + 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?, + categoryId: category?['id'] as int?, + categoryName: category?['name'] as String?, + categoryColor: category?['color'] 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() ?? - [], + channelName: json['channelName'] as String?, + videoId: json['videoId'] as String?, + videoType: json['videoType'] as String?, + videoUrl: json['videoUrl'] as String?, + bannerUrl: json['bannerUrl'] as String?, + postId: json['postId'] as String?, + username: json['username'] as String?, + content: json['content'] as String?, + imageUrls: (json['imageUrls'] as List?)?.cast() ?? [], + postUrl: json['postUrl'] as String?, ); } diff --git a/app/lib/views/schedule/schedule_detail_view.dart b/app/lib/views/schedule/schedule_detail_view.dart index dcd2835..0e43323 100644 --- a/app/lib/views/schedule/schedule_detail_view.dart +++ b/app/lib/views/schedule/schedule_detail_view.dart @@ -5,6 +5,7 @@ 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'; @@ -46,14 +47,7 @@ class _ScheduleDetailViewState extends ConsumerState { _currentScheduleId = widget.scheduleId; } - /// 회차 변경 - void _changeSchedule(int newId) { - setState(() { - _currentScheduleId = newId; - }); - } - - /// URL 열기 + /// URL 열기 (외부 앱) Future _launchUrl(String url) async { final uri = Uri.parse(url); if (await canLaunchUrl(uri)) { @@ -61,26 +55,7 @@ class _ScheduleDetailViewState extends ConsumerState { } } - /// 유튜브 비디오 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; - } - /// X URL에서 username 추출 - String? _extractXUsername(String? url) { - if (url == null) return null; - final match = RegExp(r'(?:twitter\.com|x\.com)/([^/]+)').firstMatch(url); - return match?.group(1); - } /// 날짜 포맷팅 (2026. 1. 15. (수)) String _formatFullDate(String dateStr) { @@ -89,25 +64,10 @@ class _ScheduleDetailViewState extends ConsumerState { 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; - } - /// X용 날짜/시간 포맷팅 (오후 2:30 · 2026년 1월 15일) String _formatXDateTime(String dateStr, String? timeStr) { final date = DateTime.parse(dateStr); - final year = date.year; - final month = date.month; - final day = date.day; - - var result = '$year년 $month월 $day일'; + var result = '${date.year}년 ${date.month}월 ${date.day}일'; if (timeStr != null && timeStr.length >= 5) { final parts = timeStr.split(':'); @@ -229,8 +189,6 @@ class _ScheduleDetailViewState extends ConsumerState { return _buildYoutubeSection(schedule); case CategoryId.x: return _buildXSection(schedule); - case CategoryId.concert: - return _buildConcertSection(schedule); default: return _buildDefaultSection(schedule); } @@ -238,112 +196,234 @@ class _ScheduleDetailViewState extends ConsumerState { /// 유튜브 섹션 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'; + final videoId = schedule.videoId; + final isScheduled = videoId == null; + final members = schedule.members; + final isFullGroup = members.length >= 5; return Column( children: [ - // 썸네일 + 재생 버튼 (클릭시 YouTube로 이동) - GestureDetector( - onTap: () => _launchUrl(schedule.sourceUrl!), - child: isShorts - ? Center( - child: SizedBox( - width: 200, - child: ClipRRect( - borderRadius: BorderRadius.circular(12), - 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[900], - ), - errorWidget: (_, _, _) => Container( - color: Colors.grey[900], - child: const Icon(LucideIcons.play, size: 48, color: Colors.white54), - ), - ), - _buildYoutubePlayButton(), - ], - ), + // 영상 썸네일 또는 예정 플레이스홀더 + 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, ), ), ), - ) - : ClipRRect( - borderRadius: BorderRadius.circular(12), - child: 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[900], - ), - errorWidget: (_, _, _) => Container( - color: Colors.grey[900], - child: const Icon(LucideIcons.play, size: 48, color: Colors.white54), - ), + 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 ), - _buildYoutubePlayButton(), - ], + ), + ), + ], + ], + ), + 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), + ), + ], + ), ), ), ), + ], + ], + ), ), - const SizedBox(height: 16), - // 정보 카드 (버튼 없음) - _buildInfoCard(schedule, bottomButton: null), ], ); } - /// 유튜브 재생 버튼 (실제 유튜브 아이콘) - Widget _buildYoutubePlayButton() { - return Container( - width: 68, - height: 48, - decoration: BoxDecoration( - color: Colors.red, - borderRadius: BorderRadius.circular(12), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.4), - blurRadius: 12, - offset: const Offset(0, 4), - ), - ], - ), - child: Center( - child: CustomPaint( - size: const Size(24, 24), - painter: _YoutubePlayIconPainter(), + /// 예정 일정 플레이스홀더 + 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 섹션 (웹과 동일) + /// X 섹션 Widget _buildXSection(ScheduleDetail schedule) { - final username = _extractXUsername(schedule.sourceUrl); - final displayName = schedule.sourceName ?? username ?? 'Unknown'; + final displayName = schedule.username ?? 'Unknown'; return Container( decoration: BoxDecoration( @@ -400,18 +480,16 @@ class _ScheduleDetailViewState extends ConsumerState { ), ), const SizedBox(width: 4), - // 인증 배지 (웹과 동일한 SVG 형태) _buildVerifiedBadge(), ], ), - if (username != null) - Text( - '@$username', - style: TextStyle( - fontSize: 13, - color: Colors.grey[500], - ), + Text( + '@$displayName', + style: TextStyle( + fontSize: 13, + color: Colors.grey[500], ), + ), ], ), ), @@ -422,7 +500,7 @@ class _ScheduleDetailViewState extends ConsumerState { Padding( padding: const EdgeInsets.all(16), child: Text( - decodeHtmlEntities(schedule.description ?? schedule.title), + decodeHtmlEntities(schedule.content ?? schedule.title), style: const TextStyle( fontSize: 15, height: 1.5, @@ -431,20 +509,39 @@ class _ScheduleDetailViewState extends ConsumerState { ), ), // 이미지 - if (schedule.imageUrl != null) ...[ + if (schedule.imageUrls.isNotEmpty) ...[ Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), child: ClipRRect( borderRadius: BorderRadius.circular(12), - child: CachedNetworkImage( - imageUrl: schedule.imageUrl!, - fit: BoxFit.cover, - placeholder: (_, _) => Container( - height: 200, - color: Colors.grey[200], - ), - errorWidget: (_, _, _) => const SizedBox.shrink(), - ), + 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(), + ); + }, + ), ), ), ], @@ -463,362 +560,39 @@ class _ScheduleDetailViewState extends ConsumerState { ), ), // X에서 보기 버튼 - 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.sourceUrl ?? ''), - 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: [ - // X 로고 - _buildXLogo(14), - const SizedBox(width: 8), - const Text( - 'X에서 보기', - style: TextStyle(fontWeight: FontWeight.w500), + 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), + ), + ], + ), ), ), ), - ), - ], - ), - ); - } - - /// 인증 배지 (웹과 동일한 SVG) - 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(), - ), - ); - } - - /// 콘서트 섹션 - 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( - 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: const BorderRadius.only(topLeft: Radius.circular(12)), - child: CachedNetworkImage( - imageUrl: schedule.images[0], - width: 56, - height: 72, - fit: BoxFit.cover, - placeholder: (_, _) => Container( - width: 56, - height: 72, - color: Colors.white24, - ), - errorWidget: (_, _, _) => const SizedBox.shrink(), - ), - ), - Expanded( - child: Padding( - padding: const EdgeInsets.all(16), - child: Text( - decodeHtmlEntities(schedule.title), - style: const TextStyle( - color: Colors.white, - fontWeight: FontWeight.bold, - fontSize: 15, - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ), - ), - ], - ), - ), - // 정보 목록 - Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // 공연 일정 - _buildSectionLabel(LucideIcons.calendar, '공연 일정'), - 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, - // 볼드체 제거 - 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(LucideIcons.mapPin, '장소'), - 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 (hasLocation) ...[ - _buildSectionLabel(LucideIcons.navigation, '위치'), - const SizedBox(height: 8), - // 지도 플레이스홀더 (탭시 카카오맵으로 이동) - GestureDetector( - onTap: () => _launchUrl( - 'https://map.kakao.com/link/map/${Uri.encodeComponent(schedule.locationName!)},${schedule.locationLat},${schedule.locationLng}', - ), - child: Container( - height: 160, - decoration: BoxDecoration( - color: Colors.grey[100], - borderRadius: BorderRadius.circular(12), - border: Border.all(color: AppColors.border), - ), - child: Stack( - children: [ - // 지도 이미지 (카카오 Static Map API) - ClipRRect( - borderRadius: BorderRadius.circular(11), - child: CachedNetworkImage( - imageUrl: 'https://map.kakao.com/link/map/${Uri.encodeComponent(schedule.locationName!)},${schedule.locationLat},${schedule.locationLng}', - fit: BoxFit.cover, - width: double.infinity, - height: double.infinity, - placeholder: (_, _) => Container( - color: Colors.grey[200], - child: const Center( - child: Icon(LucideIcons.map, size: 32, color: Colors.grey), - ), - ), - errorWidget: (_, _, _) => Container( - color: Colors.grey[200], - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(LucideIcons.navigation, size: 28, color: Colors.grey[400]), - const SizedBox(height: 8), - Text( - schedule.locationName!, - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w500, - color: Colors.grey[600], - ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 4), - Text( - '탭하여 지도에서 보기', - style: TextStyle( - fontSize: 11, - color: Colors.grey[400], - ), - ), - ], - ), - ), - ), - ), - ), - ], - ), - ), - ), - 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( - onPressed: () => _launchUrl( - 'https://map.kakao.com/link/to/${Uri.encodeComponent(schedule.locationName!)},${schedule.locationLat},${schedule.locationLng}'), - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF3B82F6), // blue-500 - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(vertical: 14), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: const [ - Icon(LucideIcons.navigation, size: 18), - SizedBox(width: 8), - Text('길찾기'), - ], - ), - ), - ), - if (hasLocation && schedule.sourceUrl != null) - const SizedBox(height: 8), - // 상세 정보 버튼 - if (schedule.sourceUrl != null) - SizedBox( - width: double.infinity, - child: ElevatedButton( - onPressed: () => _launchUrl(schedule.sourceUrl!), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.grey[900], - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(vertical: 14), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: const [ - Icon(LucideIcons.externalLink, size: 18), - SizedBox(width: 8), - Text('상세 정보 보기'), - ], - ), - ), - ), - ], - ), - ), ], ), ); @@ -826,39 +600,16 @@ class _ScheduleDetailViewState extends ConsumerState { /// 기본 섹션 Widget _buildDefaultSection(ScheduleDetail schedule) { - return _buildInfoCard( - schedule, - bottomButton: schedule.sourceUrl != null - ? SizedBox( - width: double.infinity, - child: ElevatedButton( - onPressed: () => _launchUrl(schedule.sourceUrl!), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.grey[900], - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(vertical: 14), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: const [ - Icon(LucideIcons.externalLink, size: 18), - SizedBox(width: 8), - Text('원본 보기'), - ], - ), - ), - ) - : null, - ); + return _buildInfoCard(schedule); } /// 정보 카드 - Widget _buildInfoCard(ScheduleDetail schedule, {Widget? bottomButton}) { + Widget _buildInfoCard(ScheduleDetail schedule) { final isFullGroup = schedule.members.length >= 5; + // 채널명 또는 소스 정보 + final sourceName = schedule.channelName ?? schedule.username; + return Container( decoration: BoxDecoration( color: Colors.white, @@ -894,8 +645,8 @@ class _ScheduleDetailViewState extends ConsumerState { _buildMetaItem(LucideIcons.calendar, _formatFullDate(schedule.date)), if (schedule.formattedTime != null) _buildMetaItem(LucideIcons.clock, schedule.formattedTime!), - if (schedule.sourceName != null) - _buildMetaItem(LucideIcons.link2, schedule.sourceName!), + if (sourceName != null) + _buildMetaItem(LucideIcons.link2, sourceName), ], ), // 멤버 @@ -917,24 +668,6 @@ class _ScheduleDetailViewState extends ConsumerState { .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, - ], ], ), ); @@ -958,60 +691,41 @@ class _ScheduleDetailViewState extends ConsumerState { ); } - /// 섹션 라벨 - 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 _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 _YoutubePlayIconPainter extends CustomPainter { - @override - void paint(Canvas canvas, Size size) { - final paint = Paint() - ..color = Colors.white - ..style = PaintingStyle.fill; - - final path = Path() - ..moveTo(size.width * 0.35, size.height * 0.25) - ..lineTo(size.width * 0.35, size.height * 0.75) - ..lineTo(size.width * 0.75, size.height * 0.5) - ..close(); - - canvas.drawPath(path, paint); - } - - @override - bool shouldRepaint(covariant CustomPainter oldDelegate) => false; -} - -/// 인증 배지 페인터 (웹과 동일한 디자인) +/// 인증 배지 페인터 class _VerifiedBadgePainter extends CustomPainter { @override void paint(Canvas canvas, Size size) { final paint = Paint() - ..color = const Color(0xFF3B82F6) // blue-500 + ..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 @@ -1040,7 +754,6 @@ class _XLogoPainter extends CustomPainter { ..style = PaintingStyle.fill; final path = Path(); - // X 로고 경로 (간소화) 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); diff --git a/app/pubspec.lock b/app/pubspec.lock index de39110..89d8823 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -17,6 +17,14 @@ packages: url: "https://pub.dev" source: hosted version: "8.4.1" + archive: + dependency: transitive + description: + name: archive + sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff + url: "https://pub.dev" + source: hosted + version: "4.0.9" args: dependency: transitive description: @@ -230,6 +238,70 @@ packages: url: "https://pub.dev" source: hosted version: "1.12.0" + flutter_inappwebview: + dependency: transitive + description: + name: flutter_inappwebview + sha256: "80092d13d3e29b6227e25b67973c67c7210bd5e35c4b747ca908e31eb71a46d5" + url: "https://pub.dev" + source: hosted + version: "6.1.5" + flutter_inappwebview_android: + dependency: transitive + description: + name: flutter_inappwebview_android + sha256: "62557c15a5c2db5d195cb3892aab74fcaec266d7b86d59a6f0027abd672cddba" + url: "https://pub.dev" + source: hosted + version: "1.1.3" + flutter_inappwebview_internal_annotations: + dependency: transitive + description: + name: flutter_inappwebview_internal_annotations + sha256: e30fba942e3debea7b7e6cdd4f0f59ce89dd403a9865193e3221293b6d1544c6 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + flutter_inappwebview_ios: + dependency: transitive + description: + name: flutter_inappwebview_ios + sha256: "5818cf9b26cf0cbb0f62ff50772217d41ea8d3d9cc00279c45f8aabaa1b4025d" + url: "https://pub.dev" + source: hosted + version: "1.1.2" + flutter_inappwebview_macos: + dependency: transitive + description: + name: flutter_inappwebview_macos + sha256: c1fbb86af1a3738e3541364d7d1866315ffb0468a1a77e34198c9be571287da1 + url: "https://pub.dev" + source: hosted + version: "1.1.2" + flutter_inappwebview_platform_interface: + dependency: transitive + description: + name: flutter_inappwebview_platform_interface + sha256: cf5323e194096b6ede7a1ca808c3e0a078e4b33cc3f6338977d75b4024ba2500 + url: "https://pub.dev" + source: hosted + version: "1.3.0+1" + flutter_inappwebview_web: + dependency: transitive + description: + name: flutter_inappwebview_web + sha256: "55f89c83b0a0d3b7893306b3bb545ba4770a4df018204917148ebb42dc14a598" + url: "https://pub.dev" + source: hosted + version: "1.1.2" + flutter_inappwebview_windows: + dependency: transitive + description: + name: flutter_inappwebview_windows + sha256: "8b4d3a46078a2cdc636c4a3d10d10f2a16882f6be607962dbfff8874d1642055" + url: "https://pub.dev" + source: hosted + version: "0.6.0" flutter_lints: dependency: "direct dev" description: @@ -264,6 +336,14 @@ packages: description: flutter source: sdk version: "0.0.0" + freezed_annotation: + dependency: transitive + description: + name: freezed_annotation + sha256: "7294967ff0a6d98638e7acb774aac3af2550777accd8149c90af5b014e6d44d8" + url: "https://pub.dev" + source: hosted + version: "3.1.0" frontend_server_client: dependency: transitive description: @@ -344,6 +424,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.2" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: cb09e7dac6210041fad964ed7fbee004f14258b4eca4040f72d1234062ace4c8 + url: "https://pub.dev" + source: hosted + version: "4.11.0" leak_tracker: dependency: transitive description: @@ -456,6 +544,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.0" + omni_video_player: + dependency: "direct main" + description: + name: omni_video_player + sha256: e01ce74413c2eb1cfe042c81507ef2573af66e7ee2984b9ee45808d35a3ea9da + url: "https://pub.dev" + source: hosted + version: "3.7.2" package_config: dependency: transitive description: @@ -632,6 +728,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.2" + posix: + dependency: transitive + description: + name: posix + sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07" + url: "https://pub.dev" + source: hosted + version: "6.5.0" provider: dependency: transitive description: @@ -752,6 +856,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.0" + simple_sparse_list: + dependency: transitive + description: + name: simple_sparse_list + sha256: aa648fd240fa39b49dcd11c19c266990006006de6699a412de485695910fbc1f + url: "https://pub.dev" + source: hosted + version: "0.1.4" sky_engine: dependency: transitive description: flutter @@ -901,6 +1013,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + unicode: + dependency: transitive + description: + name: unicode + sha256: a6f7bcfc8ea1d5ce1f6c0b1c39117a9919f4953edd9fd7a64090a9796c499b57 + url: "https://pub.dev" + source: hosted + version: "1.1.9" url_launcher: dependency: "direct main" description: @@ -1045,6 +1165,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.4.0" + visibility_detector: + dependency: transitive + description: + name: visibility_detector + sha256: dd5cc11e13494f432d15939c3aa8ae76844c42b723398643ce9addb88a5ed420 + url: "https://pub.dev" + source: hosted + version: "0.4.0+2" vm_service: dependency: transitive description: @@ -1053,6 +1181,14 @@ packages: url: "https://pub.dev" source: hosted version: "15.0.2" + volume_controller: + dependency: transitive + description: + name: volume_controller + sha256: "109c31a8d4f8cb0e18a1a231b1db97cdbc7084cb4f43928051e9fad59916dd09" + url: "https://pub.dev" + source: hosted + version: "3.4.3" wakelock_plus: dependency: transitive description: @@ -1141,6 +1277,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.3" + youtube_explode_dart: + dependency: transitive + description: + name: youtube_explode_dart + sha256: "3d731d71df9901b1915bae806781df519cff32517e36db279f844ae619669e45" + url: "https://pub.dev" + source: hosted + version: "3.0.5" sdks: dart: ">=3.10.7 <4.0.0" flutter: ">=3.35.0" diff --git a/app/pubspec.yaml b/app/pubspec.yaml index cd20f5f..2aeb964 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -50,6 +50,7 @@ dependencies: video_player: ^2.9.2 chewie: ^1.8.5 expandable_page_view: ^1.0.17 + omni_video_player: ^3.1.6 shared_preferences: ^2.3.5 dev_dependencies: