feat(app): 예능 일정 상세 화면 추가

- ScheduleDetail 모델: broadcaster, replayUrl, varietyThumbnailUrl 필드 추가
- 예능 섹션: 썸네일(블러 배경 + contain) + 정보 카드
- 방송사 뱃지(카테고리 색상), 날짜/시간, 제목, 멤버, 다시보기 버튼
- 썸네일 없을 때 카테고리 색상 배경 + Tv 아이콘 표시

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-04-05 17:06:06 +09:00
parent aae606725f
commit f73314d5dc
2 changed files with 174 additions and 0 deletions

View file

@ -58,6 +58,10 @@ class ScheduleDetail {
// X
final String? profileDisplayName;
final String? profileAvatarUrl;
//
final String? broadcaster;
final String? replayUrl;
final String? varietyThumbnailUrl;
ScheduleDetail({
required this.id,
@ -80,6 +84,9 @@ class ScheduleDetail {
this.postUrl,
this.profileDisplayName,
this.profileAvatarUrl,
this.broadcaster,
this.replayUrl,
this.varietyThumbnailUrl,
});
factory ScheduleDetail.fromJson(Map<String, dynamic> json) {
@ -110,6 +117,9 @@ class ScheduleDetail {
postUrl: json['postUrl'] as String?,
profileDisplayName: (json['profile'] as Map<String, dynamic>?)?['displayName'] as String?,
profileAvatarUrl: (json['profile'] as Map<String, dynamic>?)?['avatarUrl'] as String?,
broadcaster: json['broadcaster'] as String?,
replayUrl: json['replayUrl'] as String?,
varietyThumbnailUrl: json['thumbnailUrl'] as String?,
);
}

View file

@ -1,6 +1,7 @@
///
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';
@ -21,6 +22,7 @@ class CategoryId {
static const int fansign = 5;
static const int concert = 6;
static const int ticket = 7;
static const int variety = 10;
}
/// Provider
@ -190,6 +192,8 @@ class _ScheduleDetailViewState extends ConsumerState<ScheduleDetailView> {
return _buildYoutubeSection(schedule);
case CategoryId.x:
return _buildXSection(schedule);
case CategoryId.variety:
return _buildVarietySection(schedule);
default:
return _buildDefaultSection(schedule);
}
@ -599,6 +603,166 @@ class _ScheduleDetailViewState extends ConsumerState<ScheduleDetailView> {
);
}
///
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);