fix: 일정 상세 화면 UI 개선

PC/모바일 웹:
- 콘서트 선택된 일정 텍스트 볼드체 제거
- 모바일 콘서트 헤더 장소 표시 제거
- 모바일 콘서트 포스터 여백 제거

Flutter 앱:
- lucide_icons 사용하여 웹과 동일한 아이콘 적용
- 콘서트 헤더 장소 제거 및 포스터 여백 제거
- 콘서트 선택된 일정 볼드체 제거
- 지도 플레이스홀더 추가 (탭시 카카오맵 이동)
- 길찾기 버튼 색상 blue-500으로 변경
- 유튜브 섹션: 숏츠 둥근 테두리, 버튼 제거, 유튜브 아이콘으로 교체
- X 섹션: 웹과 동일한 디자인 (프로필, @username, 인증배지, X로고 버튼)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-01-15 21:39:29 +09:00
parent 122460b2ad
commit b023e08750
3 changed files with 377 additions and 153 deletions

View file

@ -4,6 +4,7 @@ library;
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import '../../core/constants.dart'; import '../../core/constants.dart';
import '../../models/schedule.dart'; import '../../models/schedule.dart';
@ -74,6 +75,13 @@ class _ScheduleDetailViewState extends ConsumerState<ScheduleDetailView> {
return null; 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. ()) /// (2026. 1. 15. ())
String _formatFullDate(String dateStr) { String _formatFullDate(String dateStr) {
final date = DateTime.parse(dateStr); final date = DateTime.parse(dateStr);
@ -92,6 +100,27 @@ class _ScheduleDetailViewState extends ConsumerState<ScheduleDetailView> {
return result; 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일';
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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final scheduleAsync = ref.watch(scheduleDetailProvider(_currentScheduleId)); final scheduleAsync = ref.watch(scheduleDetailProvider(_currentScheduleId));
@ -103,7 +132,7 @@ class _ScheduleDetailViewState extends ConsumerState<ScheduleDetailView> {
elevation: 0, elevation: 0,
scrolledUnderElevation: 0.5, scrolledUnderElevation: 0.5,
leading: IconButton( leading: IconButton(
icon: const Icon(Icons.arrow_back_ios_new, size: 20), icon: const Icon(LucideIcons.chevronLeft, size: 24),
onPressed: () => Navigator.of(context).pop(), onPressed: () => Navigator.of(context).pop(),
), ),
title: scheduleAsync.whenOrNull( title: scheduleAsync.whenOrNull(
@ -144,7 +173,7 @@ class _ScheduleDetailViewState extends ConsumerState<ScheduleDetailView> {
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
), ),
child: Icon( child: Icon(
Icons.calendar_today, LucideIcons.calendar,
size: 40, size: 40,
color: AppColors.primary.withValues(alpha: 0.4), color: AppColors.primary.withValues(alpha: 0.4),
), ),
@ -170,7 +199,7 @@ class _ScheduleDetailViewState extends ConsumerState<ScheduleDetailView> {
const SizedBox(height: 32), const SizedBox(height: 32),
OutlinedButton.icon( OutlinedButton.icon(
onPressed: () => Navigator.of(context).pop(), onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.arrow_back), icon: const Icon(LucideIcons.arrowLeft, size: 18),
label: const Text('돌아가기'), label: const Text('돌아가기'),
style: OutlinedButton.styleFrom( style: OutlinedButton.styleFrom(
foregroundColor: AppColors.primary, foregroundColor: AppColors.primary,
@ -219,15 +248,15 @@ class _ScheduleDetailViewState extends ConsumerState<ScheduleDetailView> {
return Column( return Column(
children: [ children: [
// + // + ( YouTube로 )
GestureDetector( GestureDetector(
onTap: () => _launchUrl(schedule.sourceUrl!), onTap: () => _launchUrl(schedule.sourceUrl!),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: isShorts child: isShorts
? Center( ? Center(
child: SizedBox( child: SizedBox(
width: 200, width: 200,
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: AspectRatio( child: AspectRatio(
aspectRatio: 9 / 16, aspectRatio: 9 / 16,
child: Stack( child: Stack(
@ -239,20 +268,23 @@ class _ScheduleDetailViewState extends ConsumerState<ScheduleDetailView> {
width: double.infinity, width: double.infinity,
height: double.infinity, height: double.infinity,
placeholder: (_, _) => Container( placeholder: (_, _) => Container(
color: Colors.grey[200], color: Colors.grey[900],
), ),
errorWidget: (_, _, _) => Container( errorWidget: (_, _, _) => Container(
color: Colors.grey[200], color: Colors.grey[900],
child: const Icon(Icons.play_circle_outline, size: 48), child: const Icon(LucideIcons.play, size: 48, color: Colors.white54),
), ),
), ),
_buildPlayButton(), _buildYoutubePlayButton(),
], ],
), ),
), ),
), ),
),
) )
: AspectRatio( : ClipRRect(
borderRadius: BorderRadius.circular(12),
child: AspectRatio(
aspectRatio: 16 / 9, aspectRatio: 16 / 9,
child: Stack( child: Stack(
alignment: Alignment.center, alignment: Alignment.center,
@ -263,55 +295,56 @@ class _ScheduleDetailViewState extends ConsumerState<ScheduleDetailView> {
width: double.infinity, width: double.infinity,
height: double.infinity, height: double.infinity,
placeholder: (_, _) => Container( placeholder: (_, _) => Container(
color: Colors.grey[200], color: Colors.grey[900],
), ),
errorWidget: (_, _, _) => Container( errorWidget: (_, _, _) => Container(
color: Colors.grey[200], color: Colors.grey[900],
child: const Icon(Icons.play_circle_outline, size: 48), child: const Icon(LucideIcons.play, size: 48, color: Colors.white54),
), ),
), ),
_buildPlayButton(), _buildYoutubePlayButton(),
], ],
), ),
), ),
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
// // ( )
_buildInfoCard( _buildInfoCard(schedule, bottomButton: null),
schedule,
bottomButton: _buildYoutubeButton(schedule.sourceUrl),
),
], ],
); );
} }
/// /// ( )
Widget _buildPlayButton() { Widget _buildYoutubePlayButton() {
return Container( return Container(
width: 64, width: 68,
height: 64, height: 48,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.red, color: Colors.red,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: Colors.black.withValues(alpha: 0.3), color: Colors.black.withValues(alpha: 0.4),
blurRadius: 8, blurRadius: 12,
offset: const Offset(0, 2), offset: const Offset(0, 4),
), ),
], ],
), ),
child: const Icon( child: Center(
Icons.play_arrow, child: CustomPaint(
color: Colors.white, size: const Size(24, 24),
size: 40, painter: _YoutubePlayIconPainter(),
),
), ),
); );
} }
/// X /// X ( )
Widget _buildXSection(ScheduleDetail schedule) { Widget _buildXSection(ScheduleDetail schedule) {
final username = _extractXUsername(schedule.sourceUrl);
final displayName = schedule.sourceName ?? username ?? 'Unknown';
return Container( return Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: Colors.white,
@ -323,22 +356,28 @@ class _ScheduleDetailViewState extends ConsumerState<ScheduleDetailView> {
children: [ children: [
// //
Padding( Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
child: Row( child: Row(
children: [ children: [
//
Container( Container(
width: 40, width: 40,
height: 40, height: 40,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey[800], gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Colors.grey[700]!, Colors.grey[900]!],
),
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
child: Center( child: Center(
child: Text( child: Text(
(schedule.sourceName ?? 'X')[0].toUpperCase(), displayName[0].toUpperCase(),
style: const TextStyle( style: const TextStyle(
color: Colors.white, color: Colors.white,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontSize: 16,
), ),
), ),
), ),
@ -352,7 +391,7 @@ class _ScheduleDetailViewState extends ConsumerState<ScheduleDetailView> {
children: [ children: [
Flexible( Flexible(
child: Text( child: Text(
schedule.sourceName ?? '', displayName,
style: const TextStyle( style: const TextStyle(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontSize: 14, fontSize: 14,
@ -361,10 +400,18 @@ class _ScheduleDetailViewState extends ConsumerState<ScheduleDetailView> {
), ),
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
const Icon(Icons.verified, // ( SVG )
color: Colors.blue, size: 16), _buildVerifiedBadge(),
], ],
), ),
if (username != null)
Text(
'@$username',
style: TextStyle(
fontSize: 13,
color: Colors.grey[500],
),
),
], ],
), ),
), ),
@ -373,35 +420,45 @@ class _ScheduleDetailViewState extends ConsumerState<ScheduleDetailView> {
), ),
// //
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.all(16),
child: Text( child: Text(
decodeHtmlEntities(schedule.description ?? schedule.title), decodeHtmlEntities(schedule.description ?? schedule.title),
style: const TextStyle(fontSize: 15, height: 1.5), style: const TextStyle(
fontSize: 15,
height: 1.5,
color: AppColors.textPrimary,
),
), ),
), ),
// //
if (schedule.imageUrl != null) ...[ if (schedule.imageUrl != null) ...[
const SizedBox(height: 12),
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
child: Image.network( child: CachedNetworkImage(
schedule.imageUrl!, imageUrl: schedule.imageUrl!,
fit: BoxFit.cover, fit: BoxFit.cover,
errorBuilder: (_, _, _) => const SizedBox.shrink(), placeholder: (_, _) => Container(
height: 200,
color: Colors.grey[200],
),
errorWidget: (_, _, _) => const SizedBox.shrink(),
), ),
), ),
), ),
], ],
// // /
Padding( Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
border: Border(top: BorderSide(color: Colors.grey[100]!)),
),
child: Text( child: Text(
_formatFullDate(schedule.date), _formatXDateTime(schedule.date, schedule.time),
style: const TextStyle( style: TextStyle(
fontSize: 14, fontSize: 14,
color: AppColors.textSecondary, color: Colors.grey[500],
), ),
), ),
), ),
@ -410,22 +467,32 @@ class _ScheduleDetailViewState extends ConsumerState<ScheduleDetailView> {
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey[50], color: Colors.grey[50],
border: Border(top: BorderSide(color: AppColors.border)), border: Border(top: BorderSide(color: Colors.grey[100]!)),
), ),
child: SizedBox( child: SizedBox(
width: double.infinity, width: double.infinity,
child: ElevatedButton.icon( child: ElevatedButton(
onPressed: () => _launchUrl(schedule.sourceUrl ?? ''), onPressed: () => _launchUrl(schedule.sourceUrl ?? ''),
icon: const Icon(Icons.open_in_new, size: 18),
label: const Text('X에서 보기'),
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: Colors.black, backgroundColor: Colors.grey[900],
foregroundColor: Colors.white, foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 14), padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24), 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),
),
],
),
), ),
), ),
), ),
@ -434,6 +501,28 @@ class _ScheduleDetailViewState extends ConsumerState<ScheduleDetailView> {
); );
} }
/// ( 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) { Widget _buildConcertSection(ScheduleDetail schedule) {
final hasLocation = final hasLocation =
@ -456,9 +545,8 @@ class _ScheduleDetailViewState extends ConsumerState<ScheduleDetailView> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// // ( )
Container( Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: LinearGradient( gradient: LinearGradient(
colors: [AppColors.primary, AppColors.primary.withValues(alpha: 0.8)], colors: [AppColors.primary, AppColors.primary.withValues(alpha: 0.8)],
@ -470,21 +558,24 @@ class _ScheduleDetailViewState extends ConsumerState<ScheduleDetailView> {
children: [ children: [
if (hasPoster) if (hasPoster)
ClipRRect( ClipRRect(
borderRadius: BorderRadius.circular(8), borderRadius: const BorderRadius.only(topLeft: Radius.circular(12)),
child: Image.network( child: CachedNetworkImage(
schedule.images[0], imageUrl: schedule.images[0],
width: 56, width: 56,
height: 72, height: 72,
fit: BoxFit.cover, fit: BoxFit.cover,
errorBuilder: (_, _, _) => const SizedBox.shrink(), placeholder: (_, _) => Container(
width: 56,
height: 72,
color: Colors.white24,
),
errorWidget: (_, _, _) => const SizedBox.shrink(),
), ),
), ),
if (hasPoster) const SizedBox(width: 12),
Expanded( Expanded(
child: Column( child: Padding(
crossAxisAlignment: CrossAxisAlignment.start, padding: const EdgeInsets.all(16),
children: [ child: Text(
Text(
decodeHtmlEntities(schedule.title), decodeHtmlEntities(schedule.title),
style: const TextStyle( style: const TextStyle(
color: Colors.white, color: Colors.white,
@ -494,18 +585,6 @@ class _ScheduleDetailViewState extends ConsumerState<ScheduleDetailView> {
maxLines: 2, maxLines: 2,
overflow: TextOverflow.ellipsis, 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,
),
],
],
), ),
), ),
], ],
@ -518,7 +597,7 @@ class _ScheduleDetailViewState extends ConsumerState<ScheduleDetailView> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// //
_buildSectionLabel(Icons.calendar_today, '공연 일정'), _buildSectionLabel(LucideIcons.calendar, '공연 일정'),
const SizedBox(height: 8), const SizedBox(height: 8),
if (hasMultipleDates) if (hasMultipleDates)
...schedule.relatedDates.asMap().entries.map((entry) { ...schedule.relatedDates.asMap().entries.map((entry) {
@ -546,8 +625,7 @@ class _ScheduleDetailViewState extends ConsumerState<ScheduleDetailView> {
'${index + 1}회차 ${_formatSingleDate(item.date, item.time)}', '${index + 1}회차 ${_formatSingleDate(item.date, item.time)}',
style: TextStyle( style: TextStyle(
fontSize: 13, fontSize: 13,
fontWeight: //
isCurrent ? FontWeight.bold : FontWeight.normal,
color: isCurrent color: isCurrent
? Colors.white ? Colors.white
: AppColors.textPrimary, : AppColors.textPrimary,
@ -568,7 +646,7 @@ class _ScheduleDetailViewState extends ConsumerState<ScheduleDetailView> {
const SizedBox(height: 16), const SizedBox(height: 16),
// //
if (schedule.locationName != null) ...[ if (schedule.locationName != null) ...[
_buildSectionLabel(Icons.place, '장소'), _buildSectionLabel(LucideIcons.mapPin, '장소'),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
schedule.locationName!, schedule.locationName!,
@ -589,6 +667,75 @@ class _ScheduleDetailViewState extends ConsumerState<ScheduleDetailView> {
], ],
const SizedBox(height: 16), 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 && if (schedule.description != null &&
schedule.description!.isNotEmpty) ...[ schedule.description!.isNotEmpty) ...[
@ -618,33 +765,39 @@ class _ScheduleDetailViewState extends ConsumerState<ScheduleDetailView> {
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: Column( child: Column(
children: [ children: [
// ()
if (hasLocation) if (hasLocation)
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
child: ElevatedButton.icon( child: ElevatedButton(
onPressed: () => _launchUrl( onPressed: () => _launchUrl(
'https://map.kakao.com/link/to/${Uri.encodeComponent(schedule.locationName!)},${schedule.locationLat},${schedule.locationLng}'), '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( style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue, backgroundColor: const Color(0xFF3B82F6), // blue-500
foregroundColor: Colors.white, foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 14), padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), 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) if (hasLocation && schedule.sourceUrl != null)
const SizedBox(height: 8), const SizedBox(height: 8),
//
if (schedule.sourceUrl != null) if (schedule.sourceUrl != null)
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
child: ElevatedButton.icon( child: ElevatedButton(
onPressed: () => _launchUrl(schedule.sourceUrl!), onPressed: () => _launchUrl(schedule.sourceUrl!),
icon: const Icon(Icons.open_in_new, size: 18),
label: const Text('상세 정보 보기'),
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: Colors.grey[900], backgroundColor: Colors.grey[900],
foregroundColor: Colors.white, foregroundColor: Colors.white,
@ -653,6 +806,14 @@ class _ScheduleDetailViewState extends ConsumerState<ScheduleDetailView> {
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
), ),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: const [
Icon(LucideIcons.externalLink, size: 18),
SizedBox(width: 8),
Text('상세 정보 보기'),
],
),
), ),
), ),
], ],
@ -670,10 +831,8 @@ class _ScheduleDetailViewState extends ConsumerState<ScheduleDetailView> {
bottomButton: schedule.sourceUrl != null bottomButton: schedule.sourceUrl != null
? SizedBox( ? SizedBox(
width: double.infinity, width: double.infinity,
child: ElevatedButton.icon( child: ElevatedButton(
onPressed: () => _launchUrl(schedule.sourceUrl!), onPressed: () => _launchUrl(schedule.sourceUrl!),
icon: const Icon(Icons.open_in_new, size: 18),
label: const Text('원본 보기'),
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: Colors.grey[900], backgroundColor: Colors.grey[900],
foregroundColor: Colors.white, foregroundColor: Colors.white,
@ -682,6 +841,14 @@ class _ScheduleDetailViewState extends ConsumerState<ScheduleDetailView> {
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
), ),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: const [
Icon(LucideIcons.externalLink, size: 18),
SizedBox(width: 8),
Text('원본 보기'),
],
),
), ),
) )
: null, : null,
@ -724,11 +891,11 @@ class _ScheduleDetailViewState extends ConsumerState<ScheduleDetailView> {
spacing: 12, spacing: 12,
runSpacing: 8, runSpacing: 8,
children: [ children: [
_buildMetaItem(Icons.calendar_today, _formatFullDate(schedule.date)), _buildMetaItem(LucideIcons.calendar, _formatFullDate(schedule.date)),
if (schedule.formattedTime != null) if (schedule.formattedTime != null)
_buildMetaItem(Icons.access_time, schedule.formattedTime!), _buildMetaItem(LucideIcons.clock, schedule.formattedTime!),
if (schedule.sourceName != null) if (schedule.sourceName != null)
_buildMetaItem(Icons.link, schedule.sourceName!), _buildMetaItem(LucideIcons.link2, schedule.sourceName!),
], ],
), ),
// //
@ -808,25 +975,89 @@ class _ScheduleDetailViewState extends ConsumerState<ScheduleDetailView> {
], ],
); );
} }
}
///
Widget _buildYoutubeButton(String? url) { ///
if (url == null) return const SizedBox.shrink(); class _YoutubePlayIconPainter extends CustomPainter {
return SizedBox( @override
width: double.infinity, void paint(Canvas canvas, Size size) {
child: ElevatedButton.icon( final paint = Paint()
onPressed: () => _launchUrl(url), ..color = Colors.white
icon: const Icon(Icons.play_circle_filled, size: 20), ..style = PaintingStyle.fill;
label: const Text('YouTube에서 보기'),
style: ElevatedButton.styleFrom( final path = Path()
backgroundColor: Colors.red, ..moveTo(size.width * 0.35, size.height * 0.25)
foregroundColor: Colors.white, ..lineTo(size.width * 0.35, size.height * 0.75)
padding: const EdgeInsets.symmetric(vertical: 14), ..lineTo(size.width * 0.75, size.height * 0.5)
shape: RoundedRectangleBorder( ..close();
borderRadius: BorderRadius.circular(12),
), 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
..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();
// 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);
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;
} }

View file

@ -427,25 +427,18 @@ function ConcertSection({ schedule, onDateChange }) {
className="bg-white rounded-xl overflow-hidden shadow-sm" className="bg-white rounded-xl overflow-hidden shadow-sm"
> >
{/* 헤더: 포스터 썸네일 + 제목 */} {/* 헤더: 포스터 썸네일 + 제목 */}
<div className="bg-gradient-to-r from-primary to-primary/80 p-4"> <div className="bg-gradient-to-r from-primary to-primary/80 flex">
<div className="flex gap-3">
{hasPoster && ( {hasPoster && (
<img <img
src={schedule.images[0]} src={schedule.images[0]}
alt={schedule.title} alt={schedule.title}
className="w-16 h-20 rounded-lg object-cover flex-shrink-0" className="w-16 h-20 object-cover flex-shrink-0"
/> />
)} )}
<div className="flex-1 min-w-0 text-white"> <div className="flex-1 min-w-0 text-white p-4 flex items-center">
<h1 className="font-bold text-base leading-snug line-clamp-2"> <h1 className="font-bold text-base leading-snug line-clamp-2">
{decodeHtmlEntities(schedule.title)} {decodeHtmlEntities(schedule.title)}
</h1> </h1>
{schedule.location_name && (
<p className="text-white/80 text-xs mt-1 truncate">
{schedule.location_name}
</p>
)}
</div>
</div> </div>
</div> </div>
@ -467,7 +460,7 @@ function ConcertSection({ schedule, onDateChange }) {
onClick={() => onDateChange(item.id)} onClick={() => onDateChange(item.id)}
className={`block w-full px-3 py-2 rounded-lg text-sm text-left transition-all ${ className={`block w-full px-3 py-2 rounded-lg text-sm text-left transition-all ${
isCurrentDate isCurrentDate
? 'bg-primary text-white font-bold' ? 'bg-primary text-white'
: 'bg-gray-50 active:bg-gray-100 text-gray-700' : 'bg-gray-50 active:bg-gray-100 text-gray-700'
}`} }`}
> >

View file

@ -495,7 +495,7 @@ function ConcertSection({ schedule }) {
to={`/schedule/${item.id}`} to={`/schedule/${item.id}`}
className={`block px-4 py-2.5 rounded-xl transition-all ${ className={`block px-4 py-2.5 rounded-xl transition-all ${
isCurrentDate isCurrentDate
? 'bg-primary text-white font-bold shadow-lg shadow-primary/20' ? 'bg-primary text-white shadow-lg shadow-primary/20'
: 'bg-gray-50 hover:bg-gray-100 text-gray-700' : 'bg-gray-50 hover:bg-gray-100 text-gray-700'
}`} }`}
> >