- ScheduleDetail 모델: broadcaster, replayUrl, varietyThumbnailUrl 필드 추가 - 예능 섹션: 썸네일(블러 배경 + contain) + 정보 카드 - 방송사 뱃지(카테고리 색상), 날짜/시간, 제목, 멤버, 다시보기 버튼 - 썸네일 없을 때 카테고리 색상 배경 + Tv 아이콘 표시 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
967 lines
33 KiB
Dart
967 lines
33 KiB
Dart
/// 일정 상세 화면
|
|
library;
|
|
|
|
import 'dart:ui';
|
|
import 'package:cached_network_image/cached_network_image.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:lucide_icons/lucide_icons.dart';
|
|
import 'package:omni_video_player/omni_video_player.dart';
|
|
import 'package:url_launcher/url_launcher.dart';
|
|
import '../../core/constants.dart';
|
|
import '../../models/schedule.dart';
|
|
import '../../services/schedules_service.dart';
|
|
import 'widgets/schedule_card.dart';
|
|
import 'widgets/member_chip.dart';
|
|
|
|
/// 카테고리 ID 상수
|
|
class CategoryId {
|
|
static const int youtube = 2;
|
|
static const int x = 3;
|
|
static const int album = 4;
|
|
static const int fansign = 5;
|
|
static const int concert = 6;
|
|
static const int ticket = 7;
|
|
static const int variety = 10;
|
|
}
|
|
|
|
/// 일정 상세 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;
|
|
}
|
|
|
|
/// URL 열기 (외부 앱)
|
|
Future<void> _launchUrl(String url) async {
|
|
final uri = Uri.parse(url);
|
|
if (await canLaunchUrl(uri)) {
|
|
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/// 날짜 포맷팅 (2026. 1. 15. (수))
|
|
String _formatFullDate(String dateStr) {
|
|
final date = DateTime.parse(dateStr);
|
|
final dayNames = ['일', '월', '화', '수', '목', '금', '토'];
|
|
return '${date.year}. ${date.month}. ${date.day}. (${dayNames[date.weekday % 7]})';
|
|
}
|
|
|
|
/// X용 날짜/시간 포맷팅 (오후 2:30 · 2026년 1월 15일)
|
|
String _formatXDateTime(String dateStr, String? timeStr) {
|
|
final date = DateTime.parse(dateStr);
|
|
var result = '${date.year}년 ${date.month}월 ${date.day}일';
|
|
|
|
if (timeStr != null && timeStr.length >= 5) {
|
|
final parts = timeStr.split(':');
|
|
final hours = int.parse(parts[0]);
|
|
final minutes = parts[1];
|
|
final period = hours < 12 ? '오전' : '오후';
|
|
final hour12 = hours == 0 ? 12 : (hours > 12 ? hours - 12 : hours);
|
|
result = '$period $hour12:$minutes · $result';
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final scheduleAsync = ref.watch(scheduleDetailProvider(_currentScheduleId));
|
|
|
|
return Scaffold(
|
|
backgroundColor: AppColors.background,
|
|
appBar: AppBar(
|
|
backgroundColor: Colors.white,
|
|
elevation: 0,
|
|
scrolledUnderElevation: 0.5,
|
|
leading: IconButton(
|
|
icon: const Icon(LucideIcons.chevronLeft, size: 24),
|
|
onPressed: () => Navigator.of(context).pop(),
|
|
),
|
|
title: scheduleAsync.whenOrNull(
|
|
data: (schedule) => Text(
|
|
schedule.categoryName ?? '',
|
|
style: TextStyle(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.w600,
|
|
color: parseColor(schedule.categoryColor),
|
|
),
|
|
),
|
|
),
|
|
centerTitle: true,
|
|
),
|
|
body: scheduleAsync.when(
|
|
loading: () => const Center(
|
|
child: CircularProgressIndicator(color: AppColors.primary),
|
|
),
|
|
error: (error, stack) => _buildErrorView(),
|
|
data: (schedule) => _buildContent(schedule),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// 에러 화면
|
|
Widget _buildErrorView() {
|
|
return Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(32),
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Container(
|
|
width: 80,
|
|
height: 80,
|
|
decoration: BoxDecoration(
|
|
color: AppColors.primary.withValues(alpha: 0.1),
|
|
borderRadius: BorderRadius.circular(20),
|
|
),
|
|
child: Icon(
|
|
LucideIcons.calendar,
|
|
size: 40,
|
|
color: AppColors.primary.withValues(alpha: 0.4),
|
|
),
|
|
),
|
|
const SizedBox(height: 24),
|
|
const Text(
|
|
'일정을 찾을 수 없습니다',
|
|
style: TextStyle(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.bold,
|
|
color: AppColors.textPrimary,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
const Text(
|
|
'요청하신 일정이 존재하지 않거나\n삭제되었을 수 있습니다.',
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
color: AppColors.textSecondary,
|
|
),
|
|
),
|
|
const SizedBox(height: 32),
|
|
OutlinedButton.icon(
|
|
onPressed: () => Navigator.of(context).pop(),
|
|
icon: const Icon(LucideIcons.arrowLeft, size: 18),
|
|
label: const Text('돌아가기'),
|
|
style: OutlinedButton.styleFrom(
|
|
foregroundColor: AppColors.primary,
|
|
side: const BorderSide(color: AppColors.primary),
|
|
padding:
|
|
const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// 메인 컨텐츠
|
|
Widget _buildContent(ScheduleDetail schedule) {
|
|
final bottomPadding = MediaQuery.of(context).padding.bottom;
|
|
return SingleChildScrollView(
|
|
padding: EdgeInsets.fromLTRB(16, 16, 16, 16 + bottomPadding),
|
|
child: _buildCategorySection(schedule),
|
|
);
|
|
}
|
|
|
|
/// 카테고리별 섹션
|
|
Widget _buildCategorySection(ScheduleDetail schedule) {
|
|
switch (schedule.categoryId) {
|
|
case CategoryId.youtube:
|
|
return _buildYoutubeSection(schedule);
|
|
case CategoryId.x:
|
|
return _buildXSection(schedule);
|
|
case CategoryId.variety:
|
|
return _buildVarietySection(schedule);
|
|
default:
|
|
return _buildDefaultSection(schedule);
|
|
}
|
|
}
|
|
|
|
/// 유튜브 섹션
|
|
Widget _buildYoutubeSection(ScheduleDetail schedule) {
|
|
final videoId = schedule.videoId;
|
|
final isScheduled = videoId == null;
|
|
final members = schedule.members;
|
|
final isFullGroup = members.length >= 5;
|
|
|
|
return Column(
|
|
children: [
|
|
// 영상 썸네일 또는 예정 플레이스홀더
|
|
if (isScheduled)
|
|
_buildScheduledPlaceholder(schedule.bannerUrl)
|
|
else
|
|
ClipRRect(
|
|
borderRadius: BorderRadius.circular(12),
|
|
child: AspectRatio(
|
|
aspectRatio: 16 / 9,
|
|
child: OmniVideoPlayer(
|
|
configuration: VideoPlayerConfiguration(
|
|
videoSourceConfiguration: VideoSourceConfiguration.youtube(
|
|
videoUrl: Uri.parse('https://www.youtube.com/watch?v=$videoId'),
|
|
preferredQualities: [OmniVideoQuality.high720],
|
|
),
|
|
),
|
|
callbacks: const VideoPlayerCallbacks(),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
// 영상 정보 카드
|
|
Container(
|
|
decoration: BoxDecoration(
|
|
gradient: LinearGradient(
|
|
begin: Alignment.topLeft,
|
|
end: Alignment.bottomRight,
|
|
colors: [Colors.grey[100]!, Colors.grey[200]!.withValues(alpha: 0.8)],
|
|
),
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// 제목 + 예정 뱃지
|
|
Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
decodeHtmlEntities(schedule.title),
|
|
style: const TextStyle(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.bold,
|
|
color: AppColors.textPrimary,
|
|
height: 1.4,
|
|
),
|
|
),
|
|
),
|
|
if (isScheduled) ...[
|
|
const SizedBox(width: 8),
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
|
decoration: BoxDecoration(
|
|
color: const Color(0xFFFEF3C7), // amber-100
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: const Text(
|
|
'예정',
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.w600,
|
|
color: Color(0xFFB45309), // amber-700
|
|
),
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
// 메타 정보
|
|
Wrap(
|
|
spacing: 12,
|
|
runSpacing: 6,
|
|
children: [
|
|
_buildMetaItem(LucideIcons.calendar, _formatXDateTime(schedule.date, schedule.time)),
|
|
if (schedule.channelName != null)
|
|
_buildMetaItem(LucideIcons.link2, schedule.channelName!),
|
|
],
|
|
),
|
|
// 멤버
|
|
if (members.isNotEmpty) ...[
|
|
const SizedBox(height: 12),
|
|
Wrap(
|
|
spacing: 6,
|
|
runSpacing: 6,
|
|
children: isFullGroup
|
|
? [const MemberChip(name: '프로미스나인')]
|
|
: members.map((m) => MemberChip(name: m.name)).toList(),
|
|
),
|
|
],
|
|
// YouTube에서 보기 버튼
|
|
if (!isScheduled) ...[
|
|
const SizedBox(height: 16),
|
|
Container(
|
|
padding: const EdgeInsets.only(top: 16),
|
|
decoration: BoxDecoration(
|
|
border: Border(
|
|
top: BorderSide(color: Colors.grey[300]!.withValues(alpha: 0.5)),
|
|
),
|
|
),
|
|
child: SizedBox(
|
|
width: double.infinity,
|
|
child: ElevatedButton(
|
|
onPressed: () => _launchUrl(schedule.videoUrl!),
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: Colors.red[500],
|
|
foregroundColor: Colors.white,
|
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
elevation: 0,
|
|
),
|
|
child: const Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(LucideIcons.youtube, size: 20),
|
|
SizedBox(width: 8),
|
|
Text(
|
|
'YouTube에서 보기',
|
|
style: TextStyle(fontWeight: FontWeight.w500),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
/// 예정 일정 플레이스홀더
|
|
Widget _buildScheduledPlaceholder(String? bannerUrl) {
|
|
return ClipRRect(
|
|
borderRadius: BorderRadius.circular(12),
|
|
child: AspectRatio(
|
|
aspectRatio: 16 / 9,
|
|
child: Stack(
|
|
fit: StackFit.expand,
|
|
children: [
|
|
// 배경
|
|
if (bannerUrl != null)
|
|
Stack(
|
|
fit: StackFit.expand,
|
|
children: [
|
|
CachedNetworkImage(
|
|
imageUrl: bannerUrl,
|
|
fit: BoxFit.cover,
|
|
placeholder: (_, _) => Container(color: Colors.grey[900]),
|
|
errorWidget: (_, _, _) => Container(
|
|
decoration: BoxDecoration(
|
|
gradient: LinearGradient(
|
|
begin: Alignment.topLeft,
|
|
end: Alignment.bottomRight,
|
|
colors: [Colors.grey[800]!, Colors.grey[900]!],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
// 그라데이션 오버레이
|
|
Container(
|
|
decoration: BoxDecoration(
|
|
gradient: LinearGradient(
|
|
begin: Alignment.topCenter,
|
|
end: Alignment.bottomCenter,
|
|
colors: [
|
|
Colors.transparent,
|
|
Colors.black.withValues(alpha: 0.7),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
)
|
|
else
|
|
Container(
|
|
decoration: BoxDecoration(
|
|
gradient: LinearGradient(
|
|
begin: Alignment.topLeft,
|
|
end: Alignment.bottomRight,
|
|
colors: [Colors.grey[800]!, Colors.grey[900]!],
|
|
),
|
|
),
|
|
),
|
|
// 하단 텍스트
|
|
Positioned(
|
|
left: 16,
|
|
bottom: 16,
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
LucideIcons.clock,
|
|
size: 16,
|
|
color: Colors.amber[400],
|
|
),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
'업로드 예정',
|
|
style: TextStyle(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.w500,
|
|
color: Colors.white.withValues(alpha: 0.9),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// X 섹션
|
|
Widget _buildXSection(ScheduleDetail schedule) {
|
|
final username = schedule.username ?? 'Unknown';
|
|
final displayName = schedule.profileDisplayName ?? username;
|
|
final avatarUrl = schedule.profileAvatarUrl;
|
|
|
|
return Container(
|
|
clipBehavior: Clip.antiAlias,
|
|
decoration: BoxDecoration(
|
|
color: Colors.white,
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(color: AppColors.border),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// 헤더
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
|
|
child: Row(
|
|
children: [
|
|
// 프로필 이미지
|
|
avatarUrl != null
|
|
? ClipOval(
|
|
child: CachedNetworkImage(
|
|
imageUrl: avatarUrl,
|
|
width: 40,
|
|
height: 40,
|
|
fit: BoxFit.cover,
|
|
placeholder: (_, _) => Container(
|
|
width: 40, height: 40,
|
|
decoration: BoxDecoration(
|
|
color: Colors.grey[300],
|
|
shape: BoxShape.circle,
|
|
),
|
|
),
|
|
errorWidget: (_, _, _) => _buildAvatarFallback(displayName),
|
|
),
|
|
)
|
|
: _buildAvatarFallback(displayName),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Flexible(
|
|
child: Text(
|
|
displayName,
|
|
style: const TextStyle(
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: 14,
|
|
),
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
const SizedBox(width: 4),
|
|
_buildVerifiedBadge(),
|
|
],
|
|
),
|
|
Text(
|
|
'@$username',
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
color: Colors.grey[500],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
// 본문
|
|
Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Text(
|
|
decodeHtmlEntities(schedule.content ?? schedule.title),
|
|
style: const TextStyle(
|
|
fontSize: 15,
|
|
height: 1.5,
|
|
color: AppColors.textPrimary,
|
|
),
|
|
),
|
|
),
|
|
// 이미지
|
|
if (schedule.imageUrls.isNotEmpty) ...[
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
|
child: ClipRRect(
|
|
borderRadius: BorderRadius.circular(12),
|
|
child: schedule.imageUrls.length == 1
|
|
? CachedNetworkImage(
|
|
imageUrl: schedule.imageUrls[0],
|
|
fit: BoxFit.cover,
|
|
placeholder: (_, _) => Container(
|
|
height: 200,
|
|
color: Colors.grey[200],
|
|
),
|
|
errorWidget: (_, _, _) => const SizedBox.shrink(),
|
|
)
|
|
: GridView.builder(
|
|
shrinkWrap: true,
|
|
physics: const NeverScrollableScrollPhysics(),
|
|
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
|
crossAxisCount: 2,
|
|
crossAxisSpacing: 4,
|
|
mainAxisSpacing: 4,
|
|
),
|
|
itemCount: schedule.imageUrls.length,
|
|
itemBuilder: (context, index) {
|
|
return CachedNetworkImage(
|
|
imageUrl: schedule.imageUrls[index],
|
|
fit: BoxFit.cover,
|
|
placeholder: (_, _) => Container(color: Colors.grey[200]),
|
|
errorWidget: (_, _, _) => const SizedBox.shrink(),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
],
|
|
// 날짜/시간
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
|
decoration: BoxDecoration(
|
|
border: Border(top: BorderSide(color: Colors.grey[100]!)),
|
|
),
|
|
child: Text(
|
|
_formatXDateTime(schedule.date, schedule.time),
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
color: Colors.grey[500],
|
|
),
|
|
),
|
|
),
|
|
// X에서 보기 버튼
|
|
if (schedule.postUrl != null)
|
|
Container(
|
|
padding: const EdgeInsets.all(16),
|
|
decoration: BoxDecoration(
|
|
color: Colors.grey[50],
|
|
borderRadius: const BorderRadius.vertical(bottom: Radius.circular(12)),
|
|
border: Border(top: BorderSide(color: Colors.grey[100]!)),
|
|
),
|
|
child: SizedBox(
|
|
width: double.infinity,
|
|
child: ElevatedButton(
|
|
onPressed: () => _launchUrl(schedule.postUrl!),
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: Colors.grey[900],
|
|
foregroundColor: Colors.white,
|
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(24),
|
|
),
|
|
),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
_buildXLogo(14),
|
|
const SizedBox(width: 8),
|
|
const Text(
|
|
'X에서 보기',
|
|
style: TextStyle(fontWeight: FontWeight.w500),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
/// 예능 섹션
|
|
Widget _buildVarietySection(ScheduleDetail schedule) {
|
|
final members = schedule.members;
|
|
final isFullGroup = members.length >= 5;
|
|
final hasThumbnail = schedule.varietyThumbnailUrl != null;
|
|
final categoryColor = parseColor(schedule.categoryColor);
|
|
|
|
return Column(
|
|
children: [
|
|
// 썸네일
|
|
ClipRRect(
|
|
borderRadius: BorderRadius.circular(12),
|
|
child: SizedBox(
|
|
width: double.infinity,
|
|
height: 200,
|
|
child: hasThumbnail
|
|
? Stack(
|
|
fit: StackFit.expand,
|
|
children: [
|
|
// 블러 배경
|
|
CachedNetworkImage(
|
|
imageUrl: schedule.varietyThumbnailUrl!,
|
|
fit: BoxFit.cover,
|
|
color: Colors.black.withValues(alpha: 0.3),
|
|
colorBlendMode: BlendMode.darken,
|
|
),
|
|
ClipRect(
|
|
child: BackdropFilter(
|
|
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
|
|
child: Container(color: Colors.transparent),
|
|
),
|
|
),
|
|
// 메인 이미지
|
|
CachedNetworkImage(
|
|
imageUrl: schedule.varietyThumbnailUrl!,
|
|
fit: BoxFit.contain,
|
|
placeholder: (_, _) => Container(color: Colors.grey[200]),
|
|
),
|
|
],
|
|
)
|
|
: Container(
|
|
color: categoryColor.withValues(alpha: 0.1),
|
|
child: Center(
|
|
child: Icon(LucideIcons.tv, size: 40, color: categoryColor),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
// 정보 카드
|
|
Container(
|
|
width: double.infinity,
|
|
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: [
|
|
// 방송사 + 날짜
|
|
Row(
|
|
children: [
|
|
if (schedule.broadcaster != null)
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
|
margin: const EdgeInsets.only(right: 8),
|
|
decoration: BoxDecoration(
|
|
color: categoryColor.withValues(alpha: 0.1),
|
|
borderRadius: BorderRadius.circular(6),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(LucideIcons.tv, size: 11, color: categoryColor),
|
|
const SizedBox(width: 4),
|
|
Text(
|
|
schedule.broadcaster!,
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w600,
|
|
color: categoryColor,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Text(
|
|
_formatFullDate(schedule.date),
|
|
style: TextStyle(fontSize: 12, color: Colors.grey[400]),
|
|
),
|
|
if (schedule.formattedTime != null) ...[
|
|
Text(
|
|
' · ${schedule.formattedTime}',
|
|
style: TextStyle(fontSize: 12, color: Colors.grey[400]),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
const SizedBox(height: 10),
|
|
// 제목
|
|
Text(
|
|
decodeHtmlEntities(schedule.title),
|
|
style: const TextStyle(
|
|
fontSize: 17,
|
|
fontWeight: FontWeight.bold,
|
|
color: AppColors.textPrimary,
|
|
height: 1.4,
|
|
),
|
|
),
|
|
// 멤버
|
|
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(),
|
|
),
|
|
],
|
|
// 다시보기 버튼
|
|
if (schedule.replayUrl != null) ...[
|
|
const SizedBox(height: 16),
|
|
Container(
|
|
padding: const EdgeInsets.only(top: 16),
|
|
decoration: const BoxDecoration(
|
|
border: Border(top: BorderSide(color: AppColors.divider)),
|
|
),
|
|
child: SizedBox(
|
|
width: double.infinity,
|
|
child: ElevatedButton.icon(
|
|
onPressed: () => _launchUrl(schedule.replayUrl!),
|
|
icon: const Icon(LucideIcons.externalLink, size: 16),
|
|
label: const Text('다시보기', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500)),
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: Colors.grey[900],
|
|
foregroundColor: Colors.white,
|
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
|
|
elevation: 0,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
/// 기본 섹션
|
|
Widget _buildDefaultSection(ScheduleDetail schedule) {
|
|
return _buildInfoCard(schedule);
|
|
}
|
|
|
|
/// 정보 카드
|
|
Widget _buildInfoCard(ScheduleDetail schedule) {
|
|
final isFullGroup = schedule.members.length >= 5;
|
|
|
|
// 채널명 또는 소스 정보
|
|
final sourceName = schedule.channelName ?? schedule.username;
|
|
|
|
return Container(
|
|
decoration: BoxDecoration(
|
|
color: Colors.white,
|
|
borderRadius: BorderRadius.circular(12),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withValues(alpha: 0.04),
|
|
blurRadius: 12,
|
|
offset: const Offset(0, 2),
|
|
),
|
|
],
|
|
),
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// 제목
|
|
Text(
|
|
decodeHtmlEntities(schedule.title),
|
|
style: const TextStyle(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.bold,
|
|
color: AppColors.textPrimary,
|
|
height: 1.4,
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
// 메타 정보
|
|
Wrap(
|
|
spacing: 12,
|
|
runSpacing: 8,
|
|
children: [
|
|
_buildMetaItem(LucideIcons.calendar, _formatFullDate(schedule.date)),
|
|
if (schedule.formattedTime != null)
|
|
_buildMetaItem(LucideIcons.clock, schedule.formattedTime!),
|
|
if (sourceName != null)
|
|
_buildMetaItem(LucideIcons.link2, sourceName),
|
|
],
|
|
),
|
|
// 멤버
|
|
if (schedule.members.isNotEmpty) ...[
|
|
const SizedBox(height: 16),
|
|
Container(
|
|
width: double.infinity,
|
|
height: 1,
|
|
color: AppColors.divider,
|
|
),
|
|
const SizedBox(height: 16),
|
|
Wrap(
|
|
spacing: 6,
|
|
runSpacing: 6,
|
|
children: isFullGroup
|
|
? [const MemberChip(name: '프로미스나인')]
|
|
: schedule.members
|
|
.map((m) => MemberChip(name: m.name))
|
|
.toList(),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
/// 메타 아이템
|
|
Widget _buildMetaItem(IconData icon, String text) {
|
|
return Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(icon, size: 14, color: AppColors.textSecondary),
|
|
const SizedBox(width: 4),
|
|
Text(
|
|
text,
|
|
style: const TextStyle(
|
|
fontSize: 12,
|
|
color: AppColors.textSecondary,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
/// 아바타 폴백 (이니셜)
|
|
Widget _buildAvatarFallback(String name) {
|
|
return Container(
|
|
width: 40,
|
|
height: 40,
|
|
decoration: BoxDecoration(
|
|
gradient: LinearGradient(
|
|
begin: Alignment.topLeft,
|
|
end: Alignment.bottomRight,
|
|
colors: [Colors.grey[700]!, Colors.grey[900]!],
|
|
),
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: Center(
|
|
child: Text(
|
|
name[0].toUpperCase(),
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: 16,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// 인증 배지
|
|
Widget _buildVerifiedBadge() {
|
|
return SizedBox(
|
|
width: 16,
|
|
height: 16,
|
|
child: CustomPaint(
|
|
painter: _VerifiedBadgePainter(),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// X 로고
|
|
Widget _buildXLogo(double size) {
|
|
return SizedBox(
|
|
width: size,
|
|
height: size,
|
|
child: CustomPaint(
|
|
painter: _XLogoPainter(),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 인증 배지 페인터
|
|
class _VerifiedBadgePainter extends CustomPainter {
|
|
@override
|
|
void paint(Canvas canvas, Size size) {
|
|
final paint = Paint()
|
|
..color = const Color(0xFF3B82F6)
|
|
..style = PaintingStyle.fill;
|
|
|
|
final center = Offset(size.width / 2, size.height / 2);
|
|
final radius = size.width / 2;
|
|
canvas.drawCircle(center, radius, paint);
|
|
|
|
final checkPaint = Paint()
|
|
..color = Colors.white
|
|
..style = PaintingStyle.stroke
|
|
..strokeWidth = 1.5
|
|
..strokeCap = StrokeCap.round
|
|
..strokeJoin = StrokeJoin.round;
|
|
|
|
final checkPath = Path()
|
|
..moveTo(size.width * 0.28, size.height * 0.52)
|
|
..lineTo(size.width * 0.45, size.height * 0.68)
|
|
..lineTo(size.width * 0.72, size.height * 0.35);
|
|
|
|
canvas.drawPath(checkPath, checkPaint);
|
|
}
|
|
|
|
@override
|
|
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
|
}
|
|
|
|
/// X 로고 페인터
|
|
class _XLogoPainter extends CustomPainter {
|
|
@override
|
|
void paint(Canvas canvas, Size size) {
|
|
final paint = Paint()
|
|
..color = Colors.white
|
|
..style = PaintingStyle.fill;
|
|
|
|
final path = Path();
|
|
path.moveTo(size.width * 0.76, size.height * 0.09);
|
|
path.lineTo(size.width * 0.9, size.height * 0.09);
|
|
path.lineTo(size.width * 0.6, size.height * 0.44);
|
|
path.lineTo(size.width * 0.95, size.height * 0.91);
|
|
path.lineTo(size.width * 0.67, size.height * 0.91);
|
|
path.lineTo(size.width * 0.46, size.height * 0.63);
|
|
path.lineTo(size.width * 0.21, size.height * 0.91);
|
|
path.lineTo(size.width * 0.07, size.height * 0.91);
|
|
path.lineTo(size.width * 0.4, size.height * 0.53);
|
|
path.lineTo(size.width * 0.05, size.height * 0.09);
|
|
path.lineTo(size.width * 0.34, size.height * 0.09);
|
|
path.lineTo(size.width * 0.52, size.height * 0.35);
|
|
path.close();
|
|
|
|
canvas.drawPath(path, paint);
|
|
}
|
|
|
|
@override
|
|
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
|
}
|