2026-01-15 21:27:24 +09:00
|
|
|
/// 일정 상세 화면
|
|
|
|
|
library;
|
|
|
|
|
|
|
|
|
|
import 'package:cached_network_image/cached_network_image.dart';
|
|
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
2026-01-15 21:39:29 +09:00
|
|
|
import 'package:lucide_icons/lucide_icons.dart';
|
2026-01-15 21:27:24 +09:00
|
|
|
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<ScheduleDetail, int>((ref, id) async {
|
|
|
|
|
return await getSchedule(id);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
class ScheduleDetailView extends ConsumerStatefulWidget {
|
|
|
|
|
final int scheduleId;
|
|
|
|
|
|
|
|
|
|
const ScheduleDetailView({super.key, required this.scheduleId});
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
ConsumerState<ScheduleDetailView> createState() => _ScheduleDetailViewState();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class _ScheduleDetailViewState extends ConsumerState<ScheduleDetailView> {
|
|
|
|
|
late int _currentScheduleId;
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
void initState() {
|
|
|
|
|
super.initState();
|
|
|
|
|
_currentScheduleId = widget.scheduleId;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 회차 변경
|
|
|
|
|
void _changeSchedule(int newId) {
|
|
|
|
|
setState(() {
|
|
|
|
|
_currentScheduleId = newId;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// URL 열기
|
|
|
|
|
Future<void> _launchUrl(String url) async {
|
|
|
|
|
final uri = Uri.parse(url);
|
|
|
|
|
if (await canLaunchUrl(uri)) {
|
|
|
|
|
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 유튜브 비디오 ID 추출
|
|
|
|
|
String? _extractYoutubeVideoId(String? url) {
|
|
|
|
|
if (url == null) return null;
|
|
|
|
|
final shortMatch = RegExp(r'youtu\.be/([a-zA-Z0-9_-]{11})').firstMatch(url);
|
|
|
|
|
if (shortMatch != null) return shortMatch.group(1);
|
|
|
|
|
final watchMatch =
|
|
|
|
|
RegExp(r'youtube\.com/watch\?v=([a-zA-Z0-9_-]{11})').firstMatch(url);
|
|
|
|
|
if (watchMatch != null) return watchMatch.group(1);
|
|
|
|
|
final shortsMatch =
|
|
|
|
|
RegExp(r'youtube\.com/shorts/([a-zA-Z0-9_-]{11})').firstMatch(url);
|
|
|
|
|
if (shortsMatch != null) return shortsMatch.group(1);
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-15 21:39:29 +09:00
|
|
|
/// 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-01-15 21:27:24 +09:00
|
|
|
/// 날짜 포맷팅 (2026. 1. 15. (수))
|
|
|
|
|
String _formatFullDate(String dateStr) {
|
|
|
|
|
final date = DateTime.parse(dateStr);
|
|
|
|
|
final dayNames = ['일', '월', '화', '수', '목', '금', '토'];
|
|
|
|
|
return '${date.year}. ${date.month}. ${date.day}. (${dayNames[date.weekday % 7]})';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 단일 날짜 포맷팅 (1월 15일 (수) 19:00)
|
|
|
|
|
String _formatSingleDate(String dateStr, String? timeStr) {
|
|
|
|
|
final date = DateTime.parse(dateStr);
|
|
|
|
|
final dayNames = ['일', '월', '화', '수', '목', '금', '토'];
|
|
|
|
|
var result = '${date.month}월 ${date.day}일 (${dayNames[date.weekday % 7]})';
|
|
|
|
|
if (timeStr != null && timeStr.length >= 5) {
|
|
|
|
|
result += ' ${timeStr.substring(0, 5)}';
|
|
|
|
|
}
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-15 21:39:29 +09:00
|
|
|
/// 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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-15 21:27:24 +09:00
|
|
|
@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(
|
2026-01-15 21:39:29 +09:00
|
|
|
icon: const Icon(LucideIcons.chevronLeft, size: 24),
|
2026-01-15 21:27:24 +09:00
|
|
|
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(
|
2026-01-15 21:39:29 +09:00
|
|
|
LucideIcons.calendar,
|
2026-01-15 21:27:24 +09:00
|
|
|
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(),
|
2026-01-15 21:39:29 +09:00
|
|
|
icon: const Icon(LucideIcons.arrowLeft, size: 18),
|
2026-01-15 21:27:24 +09:00
|
|
|
label: const Text('돌아가기'),
|
|
|
|
|
style: OutlinedButton.styleFrom(
|
|
|
|
|
foregroundColor: AppColors.primary,
|
|
|
|
|
side: const BorderSide(color: AppColors.primary),
|
|
|
|
|
padding:
|
|
|
|
|
const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 메인 컨텐츠
|
|
|
|
|
Widget _buildContent(ScheduleDetail schedule) {
|
|
|
|
|
return SingleChildScrollView(
|
|
|
|
|
padding: const EdgeInsets.all(16),
|
|
|
|
|
child: _buildCategorySection(schedule),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 카테고리별 섹션
|
|
|
|
|
Widget _buildCategorySection(ScheduleDetail schedule) {
|
|
|
|
|
switch (schedule.categoryId) {
|
|
|
|
|
case CategoryId.youtube:
|
|
|
|
|
return _buildYoutubeSection(schedule);
|
|
|
|
|
case CategoryId.x:
|
|
|
|
|
return _buildXSection(schedule);
|
|
|
|
|
case CategoryId.concert:
|
|
|
|
|
return _buildConcertSection(schedule);
|
|
|
|
|
default:
|
|
|
|
|
return _buildDefaultSection(schedule);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 유튜브 섹션
|
|
|
|
|
Widget _buildYoutubeSection(ScheduleDetail schedule) {
|
|
|
|
|
final videoId = _extractYoutubeVideoId(schedule.sourceUrl);
|
|
|
|
|
final isShorts = schedule.sourceUrl?.contains('/shorts/') ?? false;
|
|
|
|
|
|
|
|
|
|
if (videoId == null) return _buildDefaultSection(schedule);
|
|
|
|
|
|
|
|
|
|
// 썸네일 URL
|
|
|
|
|
final thumbnailUrl = 'https://img.youtube.com/vi/$videoId/maxresdefault.jpg';
|
|
|
|
|
|
|
|
|
|
return Column(
|
|
|
|
|
children: [
|
2026-01-15 21:39:29 +09:00
|
|
|
// 썸네일 + 재생 버튼 (클릭시 YouTube로 이동)
|
2026-01-15 21:27:24 +09:00
|
|
|
GestureDetector(
|
|
|
|
|
onTap: () => _launchUrl(schedule.sourceUrl!),
|
2026-01-15 21:39:29 +09:00
|
|
|
child: isShorts
|
|
|
|
|
? Center(
|
|
|
|
|
child: SizedBox(
|
|
|
|
|
width: 200,
|
|
|
|
|
child: ClipRRect(
|
|
|
|
|
borderRadius: BorderRadius.circular(12),
|
2026-01-15 21:27:24 +09:00
|
|
|
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(
|
2026-01-15 21:39:29 +09:00
|
|
|
color: Colors.grey[900],
|
2026-01-15 21:27:24 +09:00
|
|
|
),
|
|
|
|
|
errorWidget: (_, _, _) => Container(
|
2026-01-15 21:39:29 +09:00
|
|
|
color: Colors.grey[900],
|
|
|
|
|
child: const Icon(LucideIcons.play, size: 48, color: Colors.white54),
|
2026-01-15 21:27:24 +09:00
|
|
|
),
|
|
|
|
|
),
|
2026-01-15 21:39:29 +09:00
|
|
|
_buildYoutubePlayButton(),
|
2026-01-15 21:27:24 +09:00
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
2026-01-15 21:39:29 +09:00
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
: ClipRRect(
|
|
|
|
|
borderRadius: BorderRadius.circular(12),
|
|
|
|
|
child: AspectRatio(
|
2026-01-15 21:27:24 +09:00
|
|
|
aspectRatio: 16 / 9,
|
|
|
|
|
child: Stack(
|
|
|
|
|
alignment: Alignment.center,
|
|
|
|
|
children: [
|
|
|
|
|
CachedNetworkImage(
|
|
|
|
|
imageUrl: thumbnailUrl,
|
|
|
|
|
fit: BoxFit.cover,
|
|
|
|
|
width: double.infinity,
|
|
|
|
|
height: double.infinity,
|
|
|
|
|
placeholder: (_, _) => Container(
|
2026-01-15 21:39:29 +09:00
|
|
|
color: Colors.grey[900],
|
2026-01-15 21:27:24 +09:00
|
|
|
),
|
|
|
|
|
errorWidget: (_, _, _) => Container(
|
2026-01-15 21:39:29 +09:00
|
|
|
color: Colors.grey[900],
|
|
|
|
|
child: const Icon(LucideIcons.play, size: 48, color: Colors.white54),
|
2026-01-15 21:27:24 +09:00
|
|
|
),
|
|
|
|
|
),
|
2026-01-15 21:39:29 +09:00
|
|
|
_buildYoutubePlayButton(),
|
2026-01-15 21:27:24 +09:00
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
2026-01-15 21:39:29 +09:00
|
|
|
),
|
2026-01-15 21:27:24 +09:00
|
|
|
),
|
|
|
|
|
const SizedBox(height: 16),
|
2026-01-15 21:39:29 +09:00
|
|
|
// 정보 카드 (버튼 없음)
|
|
|
|
|
_buildInfoCard(schedule, bottomButton: null),
|
2026-01-15 21:27:24 +09:00
|
|
|
],
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-15 21:39:29 +09:00
|
|
|
/// 유튜브 재생 버튼 (실제 유튜브 아이콘)
|
|
|
|
|
Widget _buildYoutubePlayButton() {
|
2026-01-15 21:27:24 +09:00
|
|
|
return Container(
|
2026-01-15 21:39:29 +09:00
|
|
|
width: 68,
|
|
|
|
|
height: 48,
|
2026-01-15 21:27:24 +09:00
|
|
|
decoration: BoxDecoration(
|
|
|
|
|
color: Colors.red,
|
|
|
|
|
borderRadius: BorderRadius.circular(12),
|
|
|
|
|
boxShadow: [
|
|
|
|
|
BoxShadow(
|
2026-01-15 21:39:29 +09:00
|
|
|
color: Colors.black.withValues(alpha: 0.4),
|
|
|
|
|
blurRadius: 12,
|
|
|
|
|
offset: const Offset(0, 4),
|
2026-01-15 21:27:24 +09:00
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
2026-01-15 21:39:29 +09:00
|
|
|
child: Center(
|
|
|
|
|
child: CustomPaint(
|
|
|
|
|
size: const Size(24, 24),
|
|
|
|
|
painter: _YoutubePlayIconPainter(),
|
|
|
|
|
),
|
2026-01-15 21:27:24 +09:00
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-15 21:39:29 +09:00
|
|
|
/// X 섹션 (웹과 동일)
|
2026-01-15 21:27:24 +09:00
|
|
|
Widget _buildXSection(ScheduleDetail schedule) {
|
2026-01-15 21:39:29 +09:00
|
|
|
final username = _extractXUsername(schedule.sourceUrl);
|
|
|
|
|
final displayName = schedule.sourceName ?? username ?? 'Unknown';
|
|
|
|
|
|
2026-01-15 21:27:24 +09:00
|
|
|
return Container(
|
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
|
color: Colors.white,
|
|
|
|
|
borderRadius: BorderRadius.circular(12),
|
|
|
|
|
border: Border.all(color: AppColors.border),
|
|
|
|
|
),
|
|
|
|
|
child: Column(
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
children: [
|
|
|
|
|
// 헤더
|
|
|
|
|
Padding(
|
2026-01-15 21:39:29 +09:00
|
|
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
|
2026-01-15 21:27:24 +09:00
|
|
|
child: Row(
|
|
|
|
|
children: [
|
2026-01-15 21:39:29 +09:00
|
|
|
// 프로필 이미지
|
2026-01-15 21:27:24 +09:00
|
|
|
Container(
|
|
|
|
|
width: 40,
|
|
|
|
|
height: 40,
|
|
|
|
|
decoration: BoxDecoration(
|
2026-01-15 21:39:29 +09:00
|
|
|
gradient: LinearGradient(
|
|
|
|
|
begin: Alignment.topLeft,
|
|
|
|
|
end: Alignment.bottomRight,
|
|
|
|
|
colors: [Colors.grey[700]!, Colors.grey[900]!],
|
|
|
|
|
),
|
2026-01-15 21:27:24 +09:00
|
|
|
shape: BoxShape.circle,
|
|
|
|
|
),
|
|
|
|
|
child: Center(
|
|
|
|
|
child: Text(
|
2026-01-15 21:39:29 +09:00
|
|
|
displayName[0].toUpperCase(),
|
2026-01-15 21:27:24 +09:00
|
|
|
style: const TextStyle(
|
|
|
|
|
color: Colors.white,
|
|
|
|
|
fontWeight: FontWeight.bold,
|
2026-01-15 21:39:29 +09:00
|
|
|
fontSize: 16,
|
2026-01-15 21:27:24 +09:00
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(width: 12),
|
|
|
|
|
Expanded(
|
|
|
|
|
child: Column(
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
children: [
|
|
|
|
|
Row(
|
|
|
|
|
children: [
|
|
|
|
|
Flexible(
|
|
|
|
|
child: Text(
|
2026-01-15 21:39:29 +09:00
|
|
|
displayName,
|
2026-01-15 21:27:24 +09:00
|
|
|
style: const TextStyle(
|
|
|
|
|
fontWeight: FontWeight.bold,
|
|
|
|
|
fontSize: 14,
|
|
|
|
|
),
|
|
|
|
|
overflow: TextOverflow.ellipsis,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(width: 4),
|
2026-01-15 21:39:29 +09:00
|
|
|
// 인증 배지 (웹과 동일한 SVG 형태)
|
|
|
|
|
_buildVerifiedBadge(),
|
2026-01-15 21:27:24 +09:00
|
|
|
],
|
|
|
|
|
),
|
2026-01-15 21:39:29 +09:00
|
|
|
if (username != null)
|
|
|
|
|
Text(
|
|
|
|
|
'@$username',
|
|
|
|
|
style: TextStyle(
|
|
|
|
|
fontSize: 13,
|
|
|
|
|
color: Colors.grey[500],
|
|
|
|
|
),
|
|
|
|
|
),
|
2026-01-15 21:27:24 +09:00
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
// 본문
|
|
|
|
|
Padding(
|
2026-01-15 21:39:29 +09:00
|
|
|
padding: const EdgeInsets.all(16),
|
2026-01-15 21:27:24 +09:00
|
|
|
child: Text(
|
|
|
|
|
decodeHtmlEntities(schedule.description ?? schedule.title),
|
2026-01-15 21:39:29 +09:00
|
|
|
style: const TextStyle(
|
|
|
|
|
fontSize: 15,
|
|
|
|
|
height: 1.5,
|
|
|
|
|
color: AppColors.textPrimary,
|
|
|
|
|
),
|
2026-01-15 21:27:24 +09:00
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
// 이미지
|
|
|
|
|
if (schedule.imageUrl != null) ...[
|
|
|
|
|
Padding(
|
2026-01-15 21:39:29 +09:00
|
|
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
2026-01-15 21:27:24 +09:00
|
|
|
child: ClipRRect(
|
|
|
|
|
borderRadius: BorderRadius.circular(12),
|
2026-01-15 21:39:29 +09:00
|
|
|
child: CachedNetworkImage(
|
|
|
|
|
imageUrl: schedule.imageUrl!,
|
2026-01-15 21:27:24 +09:00
|
|
|
fit: BoxFit.cover,
|
2026-01-15 21:39:29 +09:00
|
|
|
placeholder: (_, _) => Container(
|
|
|
|
|
height: 200,
|
|
|
|
|
color: Colors.grey[200],
|
|
|
|
|
),
|
|
|
|
|
errorWidget: (_, _, _) => const SizedBox.shrink(),
|
2026-01-15 21:27:24 +09:00
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
2026-01-15 21:39:29 +09:00
|
|
|
// 날짜/시간
|
|
|
|
|
Container(
|
|
|
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
|
border: Border(top: BorderSide(color: Colors.grey[100]!)),
|
|
|
|
|
),
|
2026-01-15 21:27:24 +09:00
|
|
|
child: Text(
|
2026-01-15 21:39:29 +09:00
|
|
|
_formatXDateTime(schedule.date, schedule.time),
|
|
|
|
|
style: TextStyle(
|
2026-01-15 21:27:24 +09:00
|
|
|
fontSize: 14,
|
2026-01-15 21:39:29 +09:00
|
|
|
color: Colors.grey[500],
|
2026-01-15 21:27:24 +09:00
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
// X에서 보기 버튼
|
|
|
|
|
Container(
|
|
|
|
|
padding: const EdgeInsets.all(16),
|
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
|
color: Colors.grey[50],
|
2026-01-15 21:39:29 +09:00
|
|
|
border: Border(top: BorderSide(color: Colors.grey[100]!)),
|
2026-01-15 21:27:24 +09:00
|
|
|
),
|
|
|
|
|
child: SizedBox(
|
|
|
|
|
width: double.infinity,
|
2026-01-15 21:39:29 +09:00
|
|
|
child: ElevatedButton(
|
2026-01-15 21:27:24 +09:00
|
|
|
onPressed: () => _launchUrl(schedule.sourceUrl ?? ''),
|
|
|
|
|
style: ElevatedButton.styleFrom(
|
2026-01-15 21:39:29 +09:00
|
|
|
backgroundColor: Colors.grey[900],
|
2026-01-15 21:27:24 +09:00
|
|
|
foregroundColor: Colors.white,
|
2026-01-15 21:39:29 +09:00
|
|
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
2026-01-15 21:27:24 +09:00
|
|
|
shape: RoundedRectangleBorder(
|
|
|
|
|
borderRadius: BorderRadius.circular(24),
|
|
|
|
|
),
|
|
|
|
|
),
|
2026-01-15 21:39:29 +09:00
|
|
|
child: Row(
|
|
|
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
|
|
|
children: [
|
|
|
|
|
// X 로고
|
|
|
|
|
_buildXLogo(14),
|
|
|
|
|
const SizedBox(width: 8),
|
|
|
|
|
const Text(
|
|
|
|
|
'X에서 보기',
|
|
|
|
|
style: TextStyle(fontWeight: FontWeight.w500),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
2026-01-15 21:27:24 +09:00
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-15 21:39:29 +09:00
|
|
|
/// 인증 배지 (웹과 동일한 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(),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-15 21:27:24 +09:00
|
|
|
/// 콘서트 섹션
|
|
|
|
|
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: [
|
2026-01-15 21:39:29 +09:00
|
|
|
// 헤더 (포스터 여백 제거)
|
2026-01-15 21:27:24 +09:00
|
|
|
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(
|
2026-01-15 21:39:29 +09:00
|
|
|
borderRadius: const BorderRadius.only(topLeft: Radius.circular(12)),
|
|
|
|
|
child: CachedNetworkImage(
|
|
|
|
|
imageUrl: schedule.images[0],
|
2026-01-15 21:27:24 +09:00
|
|
|
width: 56,
|
|
|
|
|
height: 72,
|
|
|
|
|
fit: BoxFit.cover,
|
2026-01-15 21:39:29 +09:00
|
|
|
placeholder: (_, _) => Container(
|
|
|
|
|
width: 56,
|
|
|
|
|
height: 72,
|
|
|
|
|
color: Colors.white24,
|
|
|
|
|
),
|
|
|
|
|
errorWidget: (_, _, _) => const SizedBox.shrink(),
|
2026-01-15 21:27:24 +09:00
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
Expanded(
|
2026-01-15 21:39:29 +09:00
|
|
|
child: Padding(
|
|
|
|
|
padding: const EdgeInsets.all(16),
|
|
|
|
|
child: Text(
|
|
|
|
|
decodeHtmlEntities(schedule.title),
|
|
|
|
|
style: const TextStyle(
|
|
|
|
|
color: Colors.white,
|
|
|
|
|
fontWeight: FontWeight.bold,
|
|
|
|
|
fontSize: 15,
|
2026-01-15 21:27:24 +09:00
|
|
|
),
|
2026-01-15 21:39:29 +09:00
|
|
|
maxLines: 2,
|
|
|
|
|
overflow: TextOverflow.ellipsis,
|
|
|
|
|
),
|
2026-01-15 21:27:24 +09:00
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
// 정보 목록
|
|
|
|
|
Padding(
|
|
|
|
|
padding: const EdgeInsets.all(16),
|
|
|
|
|
child: Column(
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
children: [
|
|
|
|
|
// 공연 일정
|
2026-01-15 21:39:29 +09:00
|
|
|
_buildSectionLabel(LucideIcons.calendar, '공연 일정'),
|
2026-01-15 21:27:24 +09:00
|
|
|
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,
|
2026-01-15 21:39:29 +09:00
|
|
|
// 볼드체 제거
|
2026-01-15 21:27:24 +09:00
|
|
|
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) ...[
|
2026-01-15 21:39:29 +09:00
|
|
|
_buildSectionLabel(LucideIcons.mapPin, '장소'),
|
2026-01-15 21:27:24 +09:00
|
|
|
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),
|
|
|
|
|
],
|
2026-01-15 21:39:29 +09:00
|
|
|
// 위치 (지도)
|
|
|
|
|
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),
|
|
|
|
|
],
|
2026-01-15 21:27:24 +09:00
|
|
|
// 설명
|
|
|
|
|
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: [
|
2026-01-15 21:39:29 +09:00
|
|
|
// 길찾기 버튼 (파란색)
|
2026-01-15 21:27:24 +09:00
|
|
|
if (hasLocation)
|
|
|
|
|
SizedBox(
|
|
|
|
|
width: double.infinity,
|
2026-01-15 21:39:29 +09:00
|
|
|
child: ElevatedButton(
|
2026-01-15 21:27:24 +09:00
|
|
|
onPressed: () => _launchUrl(
|
|
|
|
|
'https://map.kakao.com/link/to/${Uri.encodeComponent(schedule.locationName!)},${schedule.locationLat},${schedule.locationLng}'),
|
|
|
|
|
style: ElevatedButton.styleFrom(
|
2026-01-15 21:39:29 +09:00
|
|
|
backgroundColor: const Color(0xFF3B82F6), // blue-500
|
2026-01-15 21:27:24 +09:00
|
|
|
foregroundColor: Colors.white,
|
|
|
|
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
|
|
|
|
shape: RoundedRectangleBorder(
|
|
|
|
|
borderRadius: BorderRadius.circular(12),
|
|
|
|
|
),
|
|
|
|
|
),
|
2026-01-15 21:39:29 +09:00
|
|
|
child: Row(
|
|
|
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
|
|
|
children: const [
|
|
|
|
|
Icon(LucideIcons.navigation, size: 18),
|
|
|
|
|
SizedBox(width: 8),
|
|
|
|
|
Text('길찾기'),
|
|
|
|
|
],
|
|
|
|
|
),
|
2026-01-15 21:27:24 +09:00
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
if (hasLocation && schedule.sourceUrl != null)
|
|
|
|
|
const SizedBox(height: 8),
|
2026-01-15 21:39:29 +09:00
|
|
|
// 상세 정보 버튼
|
2026-01-15 21:27:24 +09:00
|
|
|
if (schedule.sourceUrl != null)
|
|
|
|
|
SizedBox(
|
|
|
|
|
width: double.infinity,
|
2026-01-15 21:39:29 +09:00
|
|
|
child: ElevatedButton(
|
2026-01-15 21:27:24 +09:00
|
|
|
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),
|
|
|
|
|
),
|
|
|
|
|
),
|
2026-01-15 21:39:29 +09:00
|
|
|
child: Row(
|
|
|
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
|
|
|
children: const [
|
|
|
|
|
Icon(LucideIcons.externalLink, size: 18),
|
|
|
|
|
SizedBox(width: 8),
|
|
|
|
|
Text('상세 정보 보기'),
|
|
|
|
|
],
|
|
|
|
|
),
|
2026-01-15 21:27:24 +09:00
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 기본 섹션
|
|
|
|
|
Widget _buildDefaultSection(ScheduleDetail schedule) {
|
|
|
|
|
return _buildInfoCard(
|
|
|
|
|
schedule,
|
|
|
|
|
bottomButton: schedule.sourceUrl != null
|
|
|
|
|
? SizedBox(
|
|
|
|
|
width: double.infinity,
|
2026-01-15 21:39:29 +09:00
|
|
|
child: ElevatedButton(
|
2026-01-15 21:27:24 +09:00
|
|
|
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),
|
|
|
|
|
),
|
|
|
|
|
),
|
2026-01-15 21:39:29 +09:00
|
|
|
child: Row(
|
|
|
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
|
|
|
children: const [
|
|
|
|
|
Icon(LucideIcons.externalLink, size: 18),
|
|
|
|
|
SizedBox(width: 8),
|
|
|
|
|
Text('원본 보기'),
|
|
|
|
|
],
|
|
|
|
|
),
|
2026-01-15 21:27:24 +09:00
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
: null,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 정보 카드
|
|
|
|
|
Widget _buildInfoCard(ScheduleDetail schedule, {Widget? bottomButton}) {
|
|
|
|
|
final isFullGroup = schedule.members.length >= 5;
|
|
|
|
|
|
|
|
|
|
return Container(
|
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
|
color: Colors.white,
|
|
|
|
|
borderRadius: BorderRadius.circular(12),
|
|
|
|
|
boxShadow: [
|
|
|
|
|
BoxShadow(
|
|
|
|
|
color: Colors.black.withValues(alpha: 0.04),
|
|
|
|
|
blurRadius: 12,
|
|
|
|
|
offset: const Offset(0, 2),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
padding: const EdgeInsets.all(16),
|
|
|
|
|
child: Column(
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
children: [
|
|
|
|
|
// 제목
|
|
|
|
|
Text(
|
|
|
|
|
decodeHtmlEntities(schedule.title),
|
|
|
|
|
style: const TextStyle(
|
|
|
|
|
fontSize: 16,
|
|
|
|
|
fontWeight: FontWeight.bold,
|
|
|
|
|
color: AppColors.textPrimary,
|
|
|
|
|
height: 1.4,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 12),
|
|
|
|
|
// 메타 정보
|
|
|
|
|
Wrap(
|
|
|
|
|
spacing: 12,
|
|
|
|
|
runSpacing: 8,
|
|
|
|
|
children: [
|
2026-01-15 21:39:29 +09:00
|
|
|
_buildMetaItem(LucideIcons.calendar, _formatFullDate(schedule.date)),
|
2026-01-15 21:27:24 +09:00
|
|
|
if (schedule.formattedTime != null)
|
2026-01-15 21:39:29 +09:00
|
|
|
_buildMetaItem(LucideIcons.clock, schedule.formattedTime!),
|
2026-01-15 21:27:24 +09:00
|
|
|
if (schedule.sourceName != null)
|
2026-01-15 21:39:29 +09:00
|
|
|
_buildMetaItem(LucideIcons.link2, schedule.sourceName!),
|
2026-01-15 21:27:24 +09:00
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
// 멤버
|
|
|
|
|
if (schedule.members.isNotEmpty) ...[
|
|
|
|
|
const SizedBox(height: 16),
|
|
|
|
|
Container(
|
|
|
|
|
width: double.infinity,
|
|
|
|
|
height: 1,
|
|
|
|
|
color: AppColors.divider,
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 16),
|
|
|
|
|
Wrap(
|
|
|
|
|
spacing: 6,
|
|
|
|
|
runSpacing: 6,
|
|
|
|
|
children: isFullGroup
|
|
|
|
|
? [const MemberChip(name: '프로미스나인')]
|
|
|
|
|
: schedule.members
|
|
|
|
|
.map((m) => MemberChip(name: m.name))
|
|
|
|
|
.toList(),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
// 설명
|
|
|
|
|
if (schedule.description != null &&
|
|
|
|
|
schedule.description!.isNotEmpty) ...[
|
|
|
|
|
const SizedBox(height: 16),
|
|
|
|
|
Text(
|
|
|
|
|
decodeHtmlEntities(schedule.description!),
|
|
|
|
|
style: const TextStyle(
|
|
|
|
|
fontSize: 14,
|
|
|
|
|
color: AppColors.textSecondary,
|
|
|
|
|
height: 1.5,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
// 하단 버튼
|
|
|
|
|
if (bottomButton != null) ...[
|
|
|
|
|
const SizedBox(height: 16),
|
|
|
|
|
bottomButton,
|
|
|
|
|
],
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 메타 아이템
|
|
|
|
|
Widget _buildMetaItem(IconData icon, String text) {
|
|
|
|
|
return Row(
|
|
|
|
|
mainAxisSize: MainAxisSize.min,
|
|
|
|
|
children: [
|
|
|
|
|
Icon(icon, size: 14, color: AppColors.textSecondary),
|
|
|
|
|
const SizedBox(width: 4),
|
|
|
|
|
Text(
|
|
|
|
|
text,
|
|
|
|
|
style: const TextStyle(
|
|
|
|
|
fontSize: 12,
|
|
|
|
|
color: AppColors.textSecondary,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 섹션 라벨
|
|
|
|
|
Widget _buildSectionLabel(IconData icon, String text) {
|
|
|
|
|
return Row(
|
|
|
|
|
children: [
|
|
|
|
|
Icon(icon, size: 14, color: AppColors.textSecondary),
|
|
|
|
|
const SizedBox(width: 6),
|
|
|
|
|
Text(
|
|
|
|
|
text,
|
|
|
|
|
style: const TextStyle(
|
|
|
|
|
fontSize: 12,
|
|
|
|
|
fontWeight: FontWeight.w500,
|
|
|
|
|
color: AppColors.textSecondary,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-01-15 21:39:29 +09:00
|
|
|
}
|
2026-01-15 21:27:24 +09:00
|
|
|
|
2026-01-15 21:39:29 +09:00
|
|
|
/// 유튜브 재생 아이콘 페인터
|
|
|
|
|
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);
|
2026-01-15 21:27:24 +09:00
|
|
|
}
|
2026-01-15 21:39:29 +09:00
|
|
|
|
|
|
|
|
@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;
|
2026-01-15 21:27:24 +09:00
|
|
|
}
|