feat(app): 일정 상세 화면 새 API 대응 및 YouTube 앱 내 재생
- ScheduleDetail 모델: 새 API 형식 (category 중첩 객체, YouTube/X 전용 필드) - YouTube 섹션: omni_video_player로 앱 내 재생, 예정 플레이스홀더 추가 - X 섹션: username, content, imageUrls, postUrl 직접 사용 - 숏츠 영상 16:9 통일, 날짜 형식 웹과 동일하게 변경 - 콘서트 섹션을 기본 섹션으로 통합 (API 변경 반영) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
6284d216bd
commit
c37d7e14af
4 changed files with 488 additions and 628 deletions
|
|
@ -39,68 +39,70 @@ class ScheduleDetail {
|
|||
final String title;
|
||||
final String date;
|
||||
final String? time;
|
||||
final String? description;
|
||||
final int categoryId;
|
||||
final int? categoryId;
|
||||
final String? categoryName;
|
||||
final String? categoryColor;
|
||||
final String? sourceUrl;
|
||||
final String? sourceName;
|
||||
final String? imageUrl;
|
||||
final List<String> images;
|
||||
final String? locationName;
|
||||
final String? locationAddress;
|
||||
final String? locationLat;
|
||||
final String? locationLng;
|
||||
final List<ScheduleMember> members;
|
||||
final List<RelatedDate> relatedDates;
|
||||
// YouTube 관련
|
||||
final String? channelName;
|
||||
final String? videoId;
|
||||
final String? videoType;
|
||||
final String? videoUrl;
|
||||
final String? bannerUrl;
|
||||
// X 관련
|
||||
final String? postId;
|
||||
final String? username;
|
||||
final String? content;
|
||||
final List<String> imageUrls;
|
||||
final String? postUrl;
|
||||
|
||||
ScheduleDetail({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.date,
|
||||
this.time,
|
||||
this.description,
|
||||
required this.categoryId,
|
||||
this.categoryId,
|
||||
this.categoryName,
|
||||
this.categoryColor,
|
||||
this.sourceUrl,
|
||||
this.sourceName,
|
||||
this.imageUrl,
|
||||
this.images = const [],
|
||||
this.locationName,
|
||||
this.locationAddress,
|
||||
this.locationLat,
|
||||
this.locationLng,
|
||||
this.members = const [],
|
||||
this.relatedDates = const [],
|
||||
this.channelName,
|
||||
this.videoId,
|
||||
this.videoType,
|
||||
this.videoUrl,
|
||||
this.bannerUrl,
|
||||
this.postId,
|
||||
this.username,
|
||||
this.content,
|
||||
this.imageUrls = const [],
|
||||
this.postUrl,
|
||||
});
|
||||
|
||||
factory ScheduleDetail.fromJson(Map<String, dynamic> json) {
|
||||
// category 중첩 객체 파싱
|
||||
final category = json['category'] as Map<String, dynamic>?;
|
||||
|
||||
return ScheduleDetail(
|
||||
id: json['id'] as int,
|
||||
title: json['title'] as String,
|
||||
date: json['date'] as String,
|
||||
time: json['time'] as String?,
|
||||
description: json['description'] as String?,
|
||||
categoryId: json['category_id'] as int,
|
||||
categoryName: json['category_name'] as String?,
|
||||
categoryColor: json['category_color'] as String?,
|
||||
sourceUrl: json['source_url'] as String?,
|
||||
sourceName: json['source_name'] as String?,
|
||||
imageUrl: json['image_url'] as String?,
|
||||
images: (json['images'] as List<dynamic>?)?.cast<String>() ?? [],
|
||||
locationName: json['location_name'] as String?,
|
||||
locationAddress: json['location_address'] as String?,
|
||||
locationLat: json['location_lat'] as String?,
|
||||
locationLng: json['location_lng'] as String?,
|
||||
categoryId: category?['id'] as int?,
|
||||
categoryName: category?['name'] as String?,
|
||||
categoryColor: category?['color'] as String?,
|
||||
members: (json['members'] as List<dynamic>?)
|
||||
?.map((m) => ScheduleMember.fromJson(m))
|
||||
.toList() ??
|
||||
[],
|
||||
relatedDates: (json['related_dates'] as List<dynamic>?)
|
||||
?.map((r) => RelatedDate.fromJson(r))
|
||||
.toList() ??
|
||||
[],
|
||||
channelName: json['channelName'] as String?,
|
||||
videoId: json['videoId'] as String?,
|
||||
videoType: json['videoType'] as String?,
|
||||
videoUrl: json['videoUrl'] as String?,
|
||||
bannerUrl: json['bannerUrl'] as String?,
|
||||
postId: json['postId'] as String?,
|
||||
username: json['username'] as String?,
|
||||
content: json['content'] as String?,
|
||||
imageUrls: (json['imageUrls'] as List<dynamic>?)?.cast<String>() ?? [],
|
||||
postUrl: json['postUrl'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ 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';
|
||||
|
|
@ -46,14 +47,7 @@ class _ScheduleDetailViewState extends ConsumerState<ScheduleDetailView> {
|
|||
_currentScheduleId = widget.scheduleId;
|
||||
}
|
||||
|
||||
/// 회차 변경
|
||||
void _changeSchedule(int newId) {
|
||||
setState(() {
|
||||
_currentScheduleId = newId;
|
||||
});
|
||||
}
|
||||
|
||||
/// URL 열기
|
||||
/// URL 열기 (외부 앱)
|
||||
Future<void> _launchUrl(String url) async {
|
||||
final uri = Uri.parse(url);
|
||||
if (await canLaunchUrl(uri)) {
|
||||
|
|
@ -61,26 +55,7 @@ class _ScheduleDetailViewState extends ConsumerState<ScheduleDetailView> {
|
|||
}
|
||||
}
|
||||
|
||||
/// 유튜브 비디오 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;
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
|
|
@ -89,25 +64,10 @@ class _ScheduleDetailViewState extends ConsumerState<ScheduleDetailView> {
|
|||
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;
|
||||
}
|
||||
|
||||
/// 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일';
|
||||
var result = '${date.year}년 ${date.month}월 ${date.day}일';
|
||||
|
||||
if (timeStr != null && timeStr.length >= 5) {
|
||||
final parts = timeStr.split(':');
|
||||
|
|
@ -229,8 +189,6 @@ class _ScheduleDetailViewState extends ConsumerState<ScheduleDetailView> {
|
|||
return _buildYoutubeSection(schedule);
|
||||
case CategoryId.x:
|
||||
return _buildXSection(schedule);
|
||||
case CategoryId.concert:
|
||||
return _buildConcertSection(schedule);
|
||||
default:
|
||||
return _buildDefaultSection(schedule);
|
||||
}
|
||||
|
|
@ -238,112 +196,234 @@ class _ScheduleDetailViewState extends ConsumerState<ScheduleDetailView> {
|
|||
|
||||
/// 유튜브 섹션
|
||||
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';
|
||||
final videoId = schedule.videoId;
|
||||
final isScheduled = videoId == null;
|
||||
final members = schedule.members;
|
||||
final isFullGroup = members.length >= 5;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// 썸네일 + 재생 버튼 (클릭시 YouTube로 이동)
|
||||
GestureDetector(
|
||||
onTap: () => _launchUrl(schedule.sourceUrl!),
|
||||
child: isShorts
|
||||
? Center(
|
||||
child: SizedBox(
|
||||
width: 200,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
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(
|
||||
color: Colors.grey[900],
|
||||
),
|
||||
errorWidget: (_, _, _) => Container(
|
||||
color: Colors.grey[900],
|
||||
child: const Icon(LucideIcons.play, size: 48, color: Colors.white54),
|
||||
),
|
||||
),
|
||||
_buildYoutubePlayButton(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: ClipRRect(
|
||||
// 영상 썸네일 또는 예정 플레이스홀더
|
||||
if (isScheduled)
|
||||
_buildScheduledPlaceholder(schedule.bannerUrl)
|
||||
else
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 16 / 9,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
CachedNetworkImage(
|
||||
imageUrl: thumbnailUrl,
|
||||
fit: BoxFit.cover,
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
placeholder: (_, _) => Container(
|
||||
color: Colors.grey[900],
|
||||
),
|
||||
errorWidget: (_, _, _) => Container(
|
||||
color: Colors.grey[900],
|
||||
child: const Icon(LucideIcons.play, size: 48, color: Colors.white54),
|
||||
child: OmniVideoPlayer(
|
||||
configuration: VideoPlayerConfiguration(
|
||||
videoSourceConfiguration: VideoSourceConfiguration.youtube(
|
||||
videoUrl: Uri.parse('https://www.youtube.com/watch?v=$videoId'),
|
||||
preferredQualities: [OmniVideoQuality.high720],
|
||||
),
|
||||
),
|
||||
_buildYoutubePlayButton(),
|
||||
],
|
||||
),
|
||||
callbacks: const VideoPlayerCallbacks(),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// 정보 카드 (버튼 없음)
|
||||
_buildInfoCard(schedule, bottomButton: null),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 유튜브 재생 버튼 (실제 유튜브 아이콘)
|
||||
Widget _buildYoutubePlayButton() {
|
||||
return Container(
|
||||
width: 68,
|
||||
height: 48,
|
||||
// 영상 정보 카드
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red,
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [Colors.grey[100]!, Colors.grey[200]!.withValues(alpha: 0.8)],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.4),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Center(
|
||||
child: CustomPaint(
|
||||
size: const Size(24, 24),
|
||||
painter: _YoutubePlayIconPainter(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 예정 일정 플레이스홀더
|
||||
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 섹션 (웹과 동일)
|
||||
/// X 섹션
|
||||
Widget _buildXSection(ScheduleDetail schedule) {
|
||||
final username = _extractXUsername(schedule.sourceUrl);
|
||||
final displayName = schedule.sourceName ?? username ?? 'Unknown';
|
||||
final displayName = schedule.username ?? 'Unknown';
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
|
|
@ -400,13 +480,11 @@ class _ScheduleDetailViewState extends ConsumerState<ScheduleDetailView> {
|
|||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
// 인증 배지 (웹과 동일한 SVG 형태)
|
||||
_buildVerifiedBadge(),
|
||||
],
|
||||
),
|
||||
if (username != null)
|
||||
Text(
|
||||
'@$username',
|
||||
'@$displayName',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Colors.grey[500],
|
||||
|
|
@ -422,7 +500,7 @@ class _ScheduleDetailViewState extends ConsumerState<ScheduleDetailView> {
|
|||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
decodeHtmlEntities(schedule.description ?? schedule.title),
|
||||
decodeHtmlEntities(schedule.content ?? schedule.title),
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
height: 1.5,
|
||||
|
|
@ -431,19 +509,38 @@ class _ScheduleDetailViewState extends ConsumerState<ScheduleDetailView> {
|
|||
),
|
||||
),
|
||||
// 이미지
|
||||
if (schedule.imageUrl != null) ...[
|
||||
if (schedule.imageUrls.isNotEmpty) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: schedule.imageUrl!,
|
||||
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(),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -463,6 +560,7 @@ class _ScheduleDetailViewState extends ConsumerState<ScheduleDetailView> {
|
|||
),
|
||||
),
|
||||
// X에서 보기 버튼
|
||||
if (schedule.postUrl != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
|
|
@ -472,7 +570,7 @@ class _ScheduleDetailViewState extends ConsumerState<ScheduleDetailView> {
|
|||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: () => _launchUrl(schedule.sourceUrl ?? ''),
|
||||
onPressed: () => _launchUrl(schedule.postUrl!),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.grey[900],
|
||||
foregroundColor: Colors.white,
|
||||
|
|
@ -484,7 +582,6 @@ class _ScheduleDetailViewState extends ConsumerState<ScheduleDetailView> {
|
|||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// X 로고
|
||||
_buildXLogo(14),
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
|
|
@ -501,364 +598,18 @@ 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 =
|
||||
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: [
|
||||
// 헤더 (포스터 여백 제거)
|
||||
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(
|
||||
borderRadius: const BorderRadius.only(topLeft: Radius.circular(12)),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: schedule.images[0],
|
||||
width: 56,
|
||||
height: 72,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (_, _) => Container(
|
||||
width: 56,
|
||||
height: 72,
|
||||
color: Colors.white24,
|
||||
),
|
||||
errorWidget: (_, _, _) => const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
decodeHtmlEntities(schedule.title),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 15,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// 정보 목록
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 공연 일정
|
||||
_buildSectionLabel(LucideIcons.calendar, '공연 일정'),
|
||||
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,
|
||||
// 볼드체 제거
|
||||
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) ...[
|
||||
_buildSectionLabel(LucideIcons.mapPin, '장소'),
|
||||
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),
|
||||
],
|
||||
// 위치 (지도)
|
||||
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) ...[
|
||||
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: [
|
||||
// 길찾기 버튼 (파란색)
|
||||
if (hasLocation)
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: () => _launchUrl(
|
||||
'https://map.kakao.com/link/to/${Uri.encodeComponent(schedule.locationName!)},${schedule.locationLat},${schedule.locationLng}'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
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(
|
||||
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),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: const [
|
||||
Icon(LucideIcons.externalLink, size: 18),
|
||||
SizedBox(width: 8),
|
||||
Text('상세 정보 보기'),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 기본 섹션
|
||||
Widget _buildDefaultSection(ScheduleDetail schedule) {
|
||||
return _buildInfoCard(
|
||||
schedule,
|
||||
bottomButton: schedule.sourceUrl != null
|
||||
? SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
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),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: const [
|
||||
Icon(LucideIcons.externalLink, size: 18),
|
||||
SizedBox(width: 8),
|
||||
Text('원본 보기'),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
);
|
||||
return _buildInfoCard(schedule);
|
||||
}
|
||||
|
||||
/// 정보 카드
|
||||
Widget _buildInfoCard(ScheduleDetail schedule, {Widget? bottomButton}) {
|
||||
Widget _buildInfoCard(ScheduleDetail schedule) {
|
||||
final isFullGroup = schedule.members.length >= 5;
|
||||
|
||||
// 채널명 또는 소스 정보
|
||||
final sourceName = schedule.channelName ?? schedule.username;
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
|
|
@ -894,8 +645,8 @@ class _ScheduleDetailViewState extends ConsumerState<ScheduleDetailView> {
|
|||
_buildMetaItem(LucideIcons.calendar, _formatFullDate(schedule.date)),
|
||||
if (schedule.formattedTime != null)
|
||||
_buildMetaItem(LucideIcons.clock, schedule.formattedTime!),
|
||||
if (schedule.sourceName != null)
|
||||
_buildMetaItem(LucideIcons.link2, schedule.sourceName!),
|
||||
if (sourceName != null)
|
||||
_buildMetaItem(LucideIcons.link2, sourceName),
|
||||
],
|
||||
),
|
||||
// 멤버
|
||||
|
|
@ -917,24 +668,6 @@ class _ScheduleDetailViewState extends ConsumerState<ScheduleDetailView> {
|
|||
.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,
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
@ -958,60 +691,41 @@ class _ScheduleDetailViewState extends ConsumerState<ScheduleDetailView> {
|
|||
);
|
||||
}
|
||||
|
||||
/// 섹션 라벨
|
||||
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,
|
||||
/// 인증 배지
|
||||
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 _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
|
||||
..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
|
||||
|
|
@ -1040,7 +754,6 @@ class _XLogoPainter extends CustomPainter {
|
|||
..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);
|
||||
|
|
|
|||
144
app/pubspec.lock
144
app/pubspec.lock
|
|
@ -17,6 +17,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.4.1"
|
||||
archive:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: archive
|
||||
sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.9"
|
||||
args:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -230,6 +238,70 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.12.0"
|
||||
flutter_inappwebview:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_inappwebview
|
||||
sha256: "80092d13d3e29b6227e25b67973c67c7210bd5e35c4b747ca908e31eb71a46d5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.1.5"
|
||||
flutter_inappwebview_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_inappwebview_android
|
||||
sha256: "62557c15a5c2db5d195cb3892aab74fcaec266d7b86d59a6f0027abd672cddba"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.3"
|
||||
flutter_inappwebview_internal_annotations:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_inappwebview_internal_annotations
|
||||
sha256: e30fba942e3debea7b7e6cdd4f0f59ce89dd403a9865193e3221293b6d1544c6
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.0"
|
||||
flutter_inappwebview_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_inappwebview_ios
|
||||
sha256: "5818cf9b26cf0cbb0f62ff50772217d41ea8d3d9cc00279c45f8aabaa1b4025d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
flutter_inappwebview_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_inappwebview_macos
|
||||
sha256: c1fbb86af1a3738e3541364d7d1866315ffb0468a1a77e34198c9be571287da1
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
flutter_inappwebview_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_inappwebview_platform_interface
|
||||
sha256: cf5323e194096b6ede7a1ca808c3e0a078e4b33cc3f6338977d75b4024ba2500
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.0+1"
|
||||
flutter_inappwebview_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_inappwebview_web
|
||||
sha256: "55f89c83b0a0d3b7893306b3bb545ba4770a4df018204917148ebb42dc14a598"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
flutter_inappwebview_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_inappwebview_windows
|
||||
sha256: "8b4d3a46078a2cdc636c4a3d10d10f2a16882f6be607962dbfff8874d1642055"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.6.0"
|
||||
flutter_lints:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
|
|
@ -264,6 +336,14 @@ packages:
|
|||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
freezed_annotation:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: freezed_annotation
|
||||
sha256: "7294967ff0a6d98638e7acb774aac3af2550777accd8149c90af5b014e6d44d8"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.0"
|
||||
frontend_server_client:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -344,6 +424,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.2"
|
||||
json_annotation:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: json_annotation
|
||||
sha256: cb09e7dac6210041fad964ed7fbee004f14258b4eca4040f72d1234062ace4c8
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.11.0"
|
||||
leak_tracker:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -456,6 +544,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.0"
|
||||
omni_video_player:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: omni_video_player
|
||||
sha256: e01ce74413c2eb1cfe042c81507ef2573af66e7ee2984b9ee45808d35a3ea9da
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.7.2"
|
||||
package_config:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -632,6 +728,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.5.2"
|
||||
posix:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: posix
|
||||
sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.5.0"
|
||||
provider:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -752,6 +856,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.0"
|
||||
simple_sparse_list:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: simple_sparse_list
|
||||
sha256: aa648fd240fa39b49dcd11c19c266990006006de6699a412de485695910fbc1f
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.1.4"
|
||||
sky_engine:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
|
|
@ -901,6 +1013,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
unicode:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: unicode
|
||||
sha256: a6f7bcfc8ea1d5ce1f6c0b1c39117a9919f4953edd9fd7a64090a9796c499b57
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.9"
|
||||
url_launcher:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
|
@ -1045,6 +1165,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.0"
|
||||
visibility_detector:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: visibility_detector
|
||||
sha256: dd5cc11e13494f432d15939c3aa8ae76844c42b723398643ce9addb88a5ed420
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.4.0+2"
|
||||
vm_service:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -1053,6 +1181,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "15.0.2"
|
||||
volume_controller:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: volume_controller
|
||||
sha256: "109c31a8d4f8cb0e18a1a231b1db97cdbc7084cb4f43928051e9fad59916dd09"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.4.3"
|
||||
wakelock_plus:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -1141,6 +1277,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.3"
|
||||
youtube_explode_dart:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: youtube_explode_dart
|
||||
sha256: "3d731d71df9901b1915bae806781df519cff32517e36db279f844ae619669e45"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.5"
|
||||
sdks:
|
||||
dart: ">=3.10.7 <4.0.0"
|
||||
flutter: ">=3.35.0"
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ dependencies:
|
|||
video_player: ^2.9.2
|
||||
chewie: ^1.8.5
|
||||
expandable_page_view: ^1.0.17
|
||||
omni_video_player: ^3.1.6
|
||||
shared_preferences: ^2.3.5
|
||||
|
||||
dev_dependencies:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue