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:
parent
122460b2ad
commit
b023e08750
3 changed files with 377 additions and 153 deletions
|
|
@ -4,6 +4,7 @@ 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:url_launcher/url_launcher.dart';
|
||||
import '../../core/constants.dart';
|
||||
import '../../models/schedule.dart';
|
||||
|
|
@ -74,6 +75,13 @@ class _ScheduleDetailViewState extends ConsumerState<ScheduleDetailView> {
|
|||
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) {
|
||||
final date = DateTime.parse(dateStr);
|
||||
|
|
@ -92,6 +100,27 @@ class _ScheduleDetailViewState extends ConsumerState<ScheduleDetailView> {
|
|||
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
|
||||
Widget build(BuildContext context) {
|
||||
final scheduleAsync = ref.watch(scheduleDetailProvider(_currentScheduleId));
|
||||
|
|
@ -103,7 +132,7 @@ class _ScheduleDetailViewState extends ConsumerState<ScheduleDetailView> {
|
|||
elevation: 0,
|
||||
scrolledUnderElevation: 0.5,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back_ios_new, size: 20),
|
||||
icon: const Icon(LucideIcons.chevronLeft, size: 24),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
title: scheduleAsync.whenOrNull(
|
||||
|
|
@ -144,7 +173,7 @@ class _ScheduleDetailViewState extends ConsumerState<ScheduleDetailView> {
|
|||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.calendar_today,
|
||||
LucideIcons.calendar,
|
||||
size: 40,
|
||||
color: AppColors.primary.withValues(alpha: 0.4),
|
||||
),
|
||||
|
|
@ -170,7 +199,7 @@ class _ScheduleDetailViewState extends ConsumerState<ScheduleDetailView> {
|
|||
const SizedBox(height: 32),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
icon: const Icon(LucideIcons.arrowLeft, size: 18),
|
||||
label: const Text('돌아가기'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: AppColors.primary,
|
||||
|
|
@ -219,15 +248,15 @@ class _ScheduleDetailViewState extends ConsumerState<ScheduleDetailView> {
|
|||
|
||||
return Column(
|
||||
children: [
|
||||
// 썸네일 + 재생 버튼
|
||||
// 썸네일 + 재생 버튼 (클릭시 YouTube로 이동)
|
||||
GestureDetector(
|
||||
onTap: () => _launchUrl(schedule.sourceUrl!),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: isShorts
|
||||
? Center(
|
||||
child: SizedBox(
|
||||
width: 200,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 9 / 16,
|
||||
child: Stack(
|
||||
|
|
@ -239,20 +268,23 @@ class _ScheduleDetailViewState extends ConsumerState<ScheduleDetailView> {
|
|||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
placeholder: (_, _) => Container(
|
||||
color: Colors.grey[200],
|
||||
color: Colors.grey[900],
|
||||
),
|
||||
errorWidget: (_, _, _) => Container(
|
||||
color: Colors.grey[200],
|
||||
child: const Icon(Icons.play_circle_outline, size: 48),
|
||||
color: Colors.grey[900],
|
||||
child: const Icon(LucideIcons.play, size: 48, color: Colors.white54),
|
||||
),
|
||||
),
|
||||
_buildPlayButton(),
|
||||
_buildYoutubePlayButton(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: AspectRatio(
|
||||
: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 16 / 9,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
|
|
@ -263,55 +295,56 @@ class _ScheduleDetailViewState extends ConsumerState<ScheduleDetailView> {
|
|||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
placeholder: (_, _) => Container(
|
||||
color: Colors.grey[200],
|
||||
color: Colors.grey[900],
|
||||
),
|
||||
errorWidget: (_, _, _) => Container(
|
||||
color: Colors.grey[200],
|
||||
child: const Icon(Icons.play_circle_outline, size: 48),
|
||||
color: Colors.grey[900],
|
||||
child: const Icon(LucideIcons.play, size: 48, color: Colors.white54),
|
||||
),
|
||||
),
|
||||
_buildPlayButton(),
|
||||
_buildYoutubePlayButton(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// 정보 카드
|
||||
_buildInfoCard(
|
||||
schedule,
|
||||
bottomButton: _buildYoutubeButton(schedule.sourceUrl),
|
||||
),
|
||||
// 정보 카드 (버튼 없음)
|
||||
_buildInfoCard(schedule, bottomButton: null),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 재생 버튼
|
||||
Widget _buildPlayButton() {
|
||||
/// 유튜브 재생 버튼 (실제 유튜브 아이콘)
|
||||
Widget _buildYoutubePlayButton() {
|
||||
return Container(
|
||||
width: 64,
|
||||
height: 64,
|
||||
width: 68,
|
||||
height: 48,
|
||||
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),
|
||||
color: Colors.black.withValues(alpha: 0.4),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.play_arrow,
|
||||
color: Colors.white,
|
||||
size: 40,
|
||||
child: Center(
|
||||
child: CustomPaint(
|
||||
size: const Size(24, 24),
|
||||
painter: _YoutubePlayIconPainter(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// X 섹션
|
||||
/// X 섹션 (웹과 동일)
|
||||
Widget _buildXSection(ScheduleDetail schedule) {
|
||||
final username = _extractXUsername(schedule.sourceUrl);
|
||||
final displayName = schedule.sourceName ?? username ?? 'Unknown';
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
|
|
@ -323,22 +356,28 @@ class _ScheduleDetailViewState extends ConsumerState<ScheduleDetailView> {
|
|||
children: [
|
||||
// 헤더
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
|
||||
child: Row(
|
||||
children: [
|
||||
// 프로필 이미지
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[800],
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [Colors.grey[700]!, Colors.grey[900]!],
|
||||
),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
(schedule.sourceName ?? 'X')[0].toUpperCase(),
|
||||
displayName[0].toUpperCase(),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -352,7 +391,7 @@ class _ScheduleDetailViewState extends ConsumerState<ScheduleDetailView> {
|
|||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
schedule.sourceName ?? '',
|
||||
displayName,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 14,
|
||||
|
|
@ -361,10 +400,18 @@ class _ScheduleDetailViewState extends ConsumerState<ScheduleDetailView> {
|
|||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
const Icon(Icons.verified,
|
||||
color: Colors.blue, size: 16),
|
||||
// 인증 배지 (웹과 동일한 SVG 형태)
|
||||
_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: const EdgeInsets.symmetric(horizontal: 16),
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
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) ...[
|
||||
const SizedBox(height: 12),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Image.network(
|
||||
schedule.imageUrl!,
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: schedule.imageUrl!,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, _, _) => const SizedBox.shrink(),
|
||||
placeholder: (_, _) => Container(
|
||||
height: 200,
|
||||
color: Colors.grey[200],
|
||||
),
|
||||
errorWidget: (_, _, _) => const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
// 날짜
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
// 날짜/시간
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(top: BorderSide(color: Colors.grey[100]!)),
|
||||
),
|
||||
child: Text(
|
||||
_formatFullDate(schedule.date),
|
||||
style: const TextStyle(
|
||||
_formatXDateTime(schedule.date, schedule.time),
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.textSecondary,
|
||||
color: Colors.grey[500],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -410,22 +467,32 @@ class _ScheduleDetailViewState extends ConsumerState<ScheduleDetailView> {
|
|||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[50],
|
||||
border: Border(top: BorderSide(color: AppColors.border)),
|
||||
border: Border(top: BorderSide(color: Colors.grey[100]!)),
|
||||
),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton.icon(
|
||||
child: ElevatedButton(
|
||||
onPressed: () => _launchUrl(schedule.sourceUrl ?? ''),
|
||||
icon: const Icon(Icons.open_in_new, size: 18),
|
||||
label: const Text('X에서 보기'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.black,
|
||||
backgroundColor: Colors.grey[900],
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -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) {
|
||||
final hasLocation =
|
||||
|
|
@ -456,9 +545,8 @@ class _ScheduleDetailViewState extends ConsumerState<ScheduleDetailView> {
|
|||
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)],
|
||||
|
|
@ -470,21 +558,24 @@ class _ScheduleDetailViewState extends ConsumerState<ScheduleDetailView> {
|
|||
children: [
|
||||
if (hasPoster)
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Image.network(
|
||||
schedule.images[0],
|
||||
borderRadius: const BorderRadius.only(topLeft: Radius.circular(12)),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: schedule.images[0],
|
||||
width: 56,
|
||||
height: 72,
|
||||
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(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
decodeHtmlEntities(schedule.title),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
|
|
@ -494,18 +585,6 @@ class _ScheduleDetailViewState extends ConsumerState<ScheduleDetailView> {
|
|||
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,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
@ -518,7 +597,7 @@ class _ScheduleDetailViewState extends ConsumerState<ScheduleDetailView> {
|
|||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 공연 일정
|
||||
_buildSectionLabel(Icons.calendar_today, '공연 일정'),
|
||||
_buildSectionLabel(LucideIcons.calendar, '공연 일정'),
|
||||
const SizedBox(height: 8),
|
||||
if (hasMultipleDates)
|
||||
...schedule.relatedDates.asMap().entries.map((entry) {
|
||||
|
|
@ -546,8 +625,7 @@ class _ScheduleDetailViewState extends ConsumerState<ScheduleDetailView> {
|
|||
'${index + 1}회차 ${_formatSingleDate(item.date, item.time)}',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight:
|
||||
isCurrent ? FontWeight.bold : FontWeight.normal,
|
||||
// 볼드체 제거
|
||||
color: isCurrent
|
||||
? Colors.white
|
||||
: AppColors.textPrimary,
|
||||
|
|
@ -568,7 +646,7 @@ class _ScheduleDetailViewState extends ConsumerState<ScheduleDetailView> {
|
|||
const SizedBox(height: 16),
|
||||
// 장소
|
||||
if (schedule.locationName != null) ...[
|
||||
_buildSectionLabel(Icons.place, '장소'),
|
||||
_buildSectionLabel(LucideIcons.mapPin, '장소'),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
schedule.locationName!,
|
||||
|
|
@ -589,6 +667,75 @@ class _ScheduleDetailViewState extends ConsumerState<ScheduleDetailView> {
|
|||
],
|
||||
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) ...[
|
||||
|
|
@ -618,33 +765,39 @@ class _ScheduleDetailViewState extends ConsumerState<ScheduleDetailView> {
|
|||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
child: Column(
|
||||
children: [
|
||||
// 길찾기 버튼 (파란색)
|
||||
if (hasLocation)
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton.icon(
|
||||
child: ElevatedButton(
|
||||
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,
|
||||
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.icon(
|
||||
child: ElevatedButton(
|
||||
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,
|
||||
|
|
@ -653,6 +806,14 @@ class _ScheduleDetailViewState extends ConsumerState<ScheduleDetailView> {
|
|||
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
|
||||
? SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton.icon(
|
||||
child: ElevatedButton(
|
||||
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,
|
||||
|
|
@ -682,6 +841,14 @@ class _ScheduleDetailViewState extends ConsumerState<ScheduleDetailView> {
|
|||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: const [
|
||||
Icon(LucideIcons.externalLink, size: 18),
|
||||
SizedBox(width: 8),
|
||||
Text('원본 보기'),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
|
|
@ -724,11 +891,11 @@ class _ScheduleDetailViewState extends ConsumerState<ScheduleDetailView> {
|
|||
spacing: 12,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
_buildMetaItem(Icons.calendar_today, _formatFullDate(schedule.date)),
|
||||
_buildMetaItem(LucideIcons.calendar, _formatFullDate(schedule.date)),
|
||||
if (schedule.formattedTime != null)
|
||||
_buildMetaItem(Icons.access_time, schedule.formattedTime!),
|
||||
_buildMetaItem(LucideIcons.clock, schedule.formattedTime!),
|
||||
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();
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 유튜브 재생 아이콘 페인터
|
||||
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
|
||||
..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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -427,25 +427,18 @@ function ConcertSection({ schedule, onDateChange }) {
|
|||
className="bg-white rounded-xl overflow-hidden shadow-sm"
|
||||
>
|
||||
{/* 헤더: 포스터 썸네일 + 제목 */}
|
||||
<div className="bg-gradient-to-r from-primary to-primary/80 p-4">
|
||||
<div className="flex gap-3">
|
||||
<div className="bg-gradient-to-r from-primary to-primary/80 flex">
|
||||
{hasPoster && (
|
||||
<img
|
||||
src={schedule.images[0]}
|
||||
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">
|
||||
{decodeHtmlEntities(schedule.title)}
|
||||
</h1>
|
||||
{schedule.location_name && (
|
||||
<p className="text-white/80 text-xs mt-1 truncate">
|
||||
{schedule.location_name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -467,7 +460,7 @@ function ConcertSection({ schedule, onDateChange }) {
|
|||
onClick={() => onDateChange(item.id)}
|
||||
className={`block w-full px-3 py-2 rounded-lg text-sm text-left transition-all ${
|
||||
isCurrentDate
|
||||
? 'bg-primary text-white font-bold'
|
||||
? 'bg-primary text-white'
|
||||
: 'bg-gray-50 active:bg-gray-100 text-gray-700'
|
||||
}`}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -495,7 +495,7 @@ function ConcertSection({ schedule }) {
|
|||
to={`/schedule/${item.id}`}
|
||||
className={`block px-4 py-2.5 rounded-xl transition-all ${
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue