2026-01-13 10:42:52 +09:00
|
|
|
/// 앨범 상세 화면
|
|
|
|
|
library;
|
|
|
|
|
|
2026-01-13 11:59:12 +09:00
|
|
|
import 'dart:io';
|
2026-01-13 10:42:52 +09:00
|
|
|
import 'dart:ui';
|
|
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
|
import 'package:flutter/services.dart';
|
|
|
|
|
import 'package:go_router/go_router.dart';
|
|
|
|
|
import 'package:cached_network_image/cached_network_image.dart';
|
|
|
|
|
import 'package:lucide_icons/lucide_icons.dart';
|
|
|
|
|
import 'package:photo_view/photo_view.dart';
|
|
|
|
|
import 'package:photo_view/photo_view_gallery.dart';
|
|
|
|
|
import 'package:modal_bottom_sheet/modal_bottom_sheet.dart';
|
2026-01-13 11:59:12 +09:00
|
|
|
import 'package:video_player/video_player.dart';
|
|
|
|
|
import 'package:chewie/chewie.dart';
|
|
|
|
|
import 'package:video_thumbnail/video_thumbnail.dart';
|
|
|
|
|
import 'package:path_provider/path_provider.dart';
|
2026-01-13 10:42:52 +09:00
|
|
|
import '../../core/constants.dart';
|
|
|
|
|
import '../../models/album.dart';
|
|
|
|
|
import '../../services/albums_service.dart';
|
|
|
|
|
import '../../services/download_service.dart';
|
|
|
|
|
|
|
|
|
|
class AlbumDetailView extends StatefulWidget {
|
|
|
|
|
final String albumName;
|
|
|
|
|
|
|
|
|
|
const AlbumDetailView({super.key, required this.albumName});
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
State<AlbumDetailView> createState() => _AlbumDetailViewState();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class _AlbumDetailViewState extends State<AlbumDetailView> {
|
|
|
|
|
late Future<Album> _albumFuture;
|
|
|
|
|
bool _showAllTracks = false;
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
void initState() {
|
|
|
|
|
super.initState();
|
|
|
|
|
_albumFuture = getAlbumByName(widget.albumName);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
String _formatDate(String? date) {
|
|
|
|
|
if (date == null || date.length < 10) return '';
|
|
|
|
|
return '${date.substring(0, 4)}.${date.substring(5, 7)}.${date.substring(8, 10)}';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
|
return Scaffold(
|
|
|
|
|
backgroundColor: AppColors.background,
|
|
|
|
|
body: FutureBuilder<Album>(
|
|
|
|
|
future: _albumFuture,
|
|
|
|
|
builder: (context, snapshot) {
|
|
|
|
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
|
|
|
|
return const Center(
|
|
|
|
|
child: CircularProgressIndicator(color: AppColors.primary),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (snapshot.hasError) {
|
|
|
|
|
return Center(
|
|
|
|
|
child: Column(
|
|
|
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
|
|
|
children: [
|
|
|
|
|
const Icon(LucideIcons.alertCircle, size: 48, color: AppColors.textTertiary),
|
|
|
|
|
const SizedBox(height: 16),
|
|
|
|
|
Text(
|
|
|
|
|
'앨범을 불러오는데 실패했습니다',
|
|
|
|
|
style: const TextStyle(color: AppColors.textSecondary),
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 8),
|
|
|
|
|
Padding(
|
|
|
|
|
padding: const EdgeInsets.symmetric(horizontal: 32),
|
|
|
|
|
child: Text(
|
|
|
|
|
snapshot.error.toString(),
|
|
|
|
|
style: const TextStyle(fontSize: 12, color: AppColors.textTertiary),
|
|
|
|
|
textAlign: TextAlign.center,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 16),
|
|
|
|
|
Row(
|
|
|
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
|
|
|
children: [
|
|
|
|
|
TextButton(
|
|
|
|
|
onPressed: () {
|
|
|
|
|
setState(() {
|
|
|
|
|
_albumFuture = getAlbumByName(widget.albumName);
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
child: const Text('다시 시도'),
|
|
|
|
|
),
|
|
|
|
|
TextButton(
|
|
|
|
|
onPressed: () => context.pop(),
|
|
|
|
|
child: const Text('뒤로 가기'),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!snapshot.hasData) {
|
|
|
|
|
return Center(
|
|
|
|
|
child: Column(
|
|
|
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
|
|
|
children: [
|
|
|
|
|
const Icon(LucideIcons.disc3, size: 48, color: AppColors.textTertiary),
|
|
|
|
|
const SizedBox(height: 16),
|
|
|
|
|
const Text('앨범을 찾을 수 없습니다'),
|
|
|
|
|
const SizedBox(height: 16),
|
|
|
|
|
TextButton(
|
|
|
|
|
onPressed: () => context.pop(),
|
|
|
|
|
child: const Text('뒤로 가기'),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
final album = snapshot.data!;
|
|
|
|
|
final allPhotos = album.allConceptPhotos;
|
|
|
|
|
final displayTracks = _showAllTracks
|
|
|
|
|
? album.tracks
|
|
|
|
|
: album.tracks?.take(5).toList();
|
|
|
|
|
|
|
|
|
|
return CustomScrollView(
|
|
|
|
|
slivers: [
|
|
|
|
|
// 앱바
|
|
|
|
|
SliverAppBar(
|
|
|
|
|
pinned: true,
|
|
|
|
|
backgroundColor: Colors.white,
|
|
|
|
|
foregroundColor: AppColors.textPrimary,
|
|
|
|
|
elevation: 0,
|
2026-01-13 13:02:40 +09:00
|
|
|
scrolledUnderElevation: 0,
|
2026-01-13 10:42:52 +09:00
|
|
|
leading: IconButton(
|
|
|
|
|
icon: const Icon(LucideIcons.arrowLeft),
|
|
|
|
|
onPressed: () => context.pop(),
|
|
|
|
|
),
|
|
|
|
|
title: const Text(
|
|
|
|
|
'앨범',
|
|
|
|
|
style: TextStyle(
|
|
|
|
|
fontSize: 18,
|
|
|
|
|
fontWeight: FontWeight.w600,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
|
|
|
|
|
// 히어로 섹션
|
|
|
|
|
SliverToBoxAdapter(
|
|
|
|
|
child: _HeroSection(album: album, formatDate: _formatDate),
|
|
|
|
|
),
|
|
|
|
|
|
2026-01-13 13:02:40 +09:00
|
|
|
// 티저 이미지
|
2026-01-13 10:42:52 +09:00
|
|
|
if (album.teasers != null && album.teasers!.isNotEmpty)
|
|
|
|
|
SliverToBoxAdapter(
|
|
|
|
|
child: _TeaserSection(teasers: album.teasers!),
|
|
|
|
|
),
|
|
|
|
|
|
|
|
|
|
// 수록곡
|
|
|
|
|
if (album.tracks != null && album.tracks!.isNotEmpty)
|
|
|
|
|
SliverToBoxAdapter(
|
|
|
|
|
child: _TracksSection(
|
|
|
|
|
album: album,
|
2026-01-13 11:59:12 +09:00
|
|
|
albumName: widget.albumName,
|
2026-01-13 10:42:52 +09:00
|
|
|
displayTracks: displayTracks,
|
|
|
|
|
showAllTracks: _showAllTracks,
|
|
|
|
|
onToggle: () => setState(() => _showAllTracks = !_showAllTracks),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
|
|
|
|
|
// 컨셉 포토
|
|
|
|
|
if (allPhotos.isNotEmpty)
|
|
|
|
|
SliverToBoxAdapter(
|
|
|
|
|
child: _ConceptPhotosSection(
|
|
|
|
|
photos: allPhotos,
|
|
|
|
|
albumName: widget.albumName,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
|
|
|
|
|
// 하단 여백 (컨셉 포토가 없을 때만)
|
|
|
|
|
if (allPhotos.isEmpty)
|
|
|
|
|
SliverToBoxAdapter(
|
|
|
|
|
child: SizedBox(height: 16 + MediaQuery.of(context).padding.bottom),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 히어로 섹션
|
|
|
|
|
class _HeroSection extends StatelessWidget {
|
|
|
|
|
final Album album;
|
|
|
|
|
final String Function(String?) formatDate;
|
|
|
|
|
|
|
|
|
|
const _HeroSection({required this.album, required this.formatDate});
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
|
return ClipRect(
|
|
|
|
|
child: Stack(
|
|
|
|
|
clipBehavior: Clip.hardEdge,
|
|
|
|
|
children: [
|
|
|
|
|
// 배경 블러 이미지
|
|
|
|
|
if (album.coverMediumUrl != null)
|
|
|
|
|
Positioned.fill(
|
|
|
|
|
child: ImageFiltered(
|
|
|
|
|
imageFilter: ImageFilter.blur(sigmaX: 30, sigmaY: 30),
|
|
|
|
|
child: Opacity(
|
|
|
|
|
opacity: 0.3,
|
|
|
|
|
child: CachedNetworkImage(
|
|
|
|
|
imageUrl: album.coverMediumUrl!,
|
|
|
|
|
fit: BoxFit.cover,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
// 그라데이션 오버레이
|
|
|
|
|
Positioned.fill(
|
|
|
|
|
child: Container(
|
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
|
gradient: LinearGradient(
|
|
|
|
|
begin: Alignment.topCenter,
|
|
|
|
|
end: Alignment.bottomCenter,
|
|
|
|
|
colors: [
|
|
|
|
|
Colors.white.withValues(alpha: 0.6),
|
|
|
|
|
Colors.white.withValues(alpha: 0.8),
|
|
|
|
|
AppColors.background,
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
// 콘텐츠
|
|
|
|
|
Padding(
|
|
|
|
|
padding: const EdgeInsets.fromLTRB(20, 16, 20, 20),
|
|
|
|
|
child: Column(
|
|
|
|
|
children: [
|
|
|
|
|
// 앨범 커버
|
|
|
|
|
Container(
|
|
|
|
|
width: 176,
|
|
|
|
|
height: 176,
|
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
|
borderRadius: BorderRadius.circular(16),
|
|
|
|
|
boxShadow: [
|
|
|
|
|
BoxShadow(
|
|
|
|
|
color: Colors.black.withValues(alpha: 0.2),
|
|
|
|
|
blurRadius: 20,
|
|
|
|
|
offset: const Offset(0, 8),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
child: ClipRRect(
|
|
|
|
|
borderRadius: BorderRadius.circular(16),
|
|
|
|
|
child: album.coverMediumUrl != null
|
|
|
|
|
? CachedNetworkImage(
|
|
|
|
|
imageUrl: album.coverMediumUrl!,
|
|
|
|
|
fit: BoxFit.cover,
|
|
|
|
|
placeholder: (context, url) => Container(
|
|
|
|
|
color: AppColors.divider,
|
|
|
|
|
),
|
|
|
|
|
errorWidget: (context, url, error) => Container(
|
|
|
|
|
color: AppColors.divider,
|
|
|
|
|
child: const Icon(
|
|
|
|
|
LucideIcons.disc3,
|
|
|
|
|
size: 64,
|
|
|
|
|
color: AppColors.textTertiary,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
: Container(
|
|
|
|
|
color: AppColors.divider,
|
|
|
|
|
child: const Icon(
|
|
|
|
|
LucideIcons.disc3,
|
|
|
|
|
size: 64,
|
|
|
|
|
color: AppColors.textTertiary,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 16),
|
|
|
|
|
// 앨범 타입 뱃지
|
|
|
|
|
Container(
|
|
|
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
|
color: AppColors.primary.withValues(alpha: 0.1),
|
|
|
|
|
borderRadius: BorderRadius.circular(20),
|
|
|
|
|
),
|
|
|
|
|
child: Text(
|
|
|
|
|
album.albumType ?? '',
|
|
|
|
|
style: const TextStyle(
|
|
|
|
|
fontSize: 12,
|
|
|
|
|
fontWeight: FontWeight.w500,
|
|
|
|
|
color: AppColors.primary,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 8),
|
|
|
|
|
// 앨범 제목
|
|
|
|
|
Text(
|
|
|
|
|
album.title,
|
|
|
|
|
style: const TextStyle(
|
|
|
|
|
fontSize: 24,
|
|
|
|
|
fontWeight: FontWeight.bold,
|
|
|
|
|
color: AppColors.textPrimary,
|
|
|
|
|
),
|
|
|
|
|
textAlign: TextAlign.center,
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 12),
|
|
|
|
|
// 메타 정보
|
|
|
|
|
Row(
|
|
|
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
|
|
|
children: [
|
|
|
|
|
_MetaItem(
|
|
|
|
|
icon: LucideIcons.calendar,
|
|
|
|
|
text: formatDate(album.releaseDate),
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(width: 16),
|
|
|
|
|
_MetaItem(
|
|
|
|
|
icon: LucideIcons.music2,
|
|
|
|
|
text: '${album.tracks?.length ?? 0}곡',
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(width: 16),
|
|
|
|
|
_MetaItem(
|
|
|
|
|
icon: LucideIcons.clock,
|
|
|
|
|
text: album.totalDuration,
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
// 앨범 소개 버튼
|
|
|
|
|
if (album.description != null && album.description!.isNotEmpty) ...[
|
|
|
|
|
const SizedBox(height: 12),
|
|
|
|
|
GestureDetector(
|
|
|
|
|
onTap: () => _showDescriptionModal(context, album.description!),
|
|
|
|
|
child: Container(
|
|
|
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
|
color: Colors.white.withValues(alpha: 0.8),
|
|
|
|
|
borderRadius: BorderRadius.circular(20),
|
|
|
|
|
boxShadow: [
|
|
|
|
|
BoxShadow(
|
|
|
|
|
color: Colors.black.withValues(alpha: 0.05),
|
|
|
|
|
blurRadius: 4,
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
child: const Row(
|
|
|
|
|
mainAxisSize: MainAxisSize.min,
|
|
|
|
|
children: [
|
|
|
|
|
Icon(LucideIcons.fileText, size: 14, color: AppColors.textSecondary),
|
|
|
|
|
SizedBox(width: 4),
|
|
|
|
|
Text(
|
|
|
|
|
'앨범 소개',
|
|
|
|
|
style: TextStyle(
|
|
|
|
|
fontSize: 12,
|
|
|
|
|
color: AppColors.textSecondary,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void _showDescriptionModal(BuildContext context, String description) {
|
|
|
|
|
showBarModalBottomSheet(
|
|
|
|
|
context: context,
|
|
|
|
|
backgroundColor: Colors.white,
|
|
|
|
|
shape: const RoundedRectangleBorder(
|
|
|
|
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
|
|
|
|
),
|
|
|
|
|
builder: (context) => _DescriptionContent(description: description),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 메타 정보 아이템
|
|
|
|
|
class _MetaItem extends StatelessWidget {
|
|
|
|
|
final IconData icon;
|
|
|
|
|
final String text;
|
|
|
|
|
|
|
|
|
|
const _MetaItem({required this.icon, required this.text});
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
|
return Row(
|
|
|
|
|
mainAxisSize: MainAxisSize.min,
|
|
|
|
|
children: [
|
|
|
|
|
Icon(icon, size: 14, color: AppColors.textSecondary),
|
|
|
|
|
const SizedBox(width: 4),
|
|
|
|
|
Text(
|
|
|
|
|
text,
|
|
|
|
|
style: const TextStyle(
|
|
|
|
|
fontSize: 13,
|
|
|
|
|
color: AppColors.textSecondary,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-13 13:02:40 +09:00
|
|
|
/// 티저 이미지 섹션
|
2026-01-13 10:42:52 +09:00
|
|
|
class _TeaserSection extends StatelessWidget {
|
|
|
|
|
final List<Teaser> teasers;
|
|
|
|
|
|
|
|
|
|
const _TeaserSection({required this.teasers});
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
|
return Container(
|
|
|
|
|
decoration: const BoxDecoration(
|
|
|
|
|
color: AppColors.background,
|
|
|
|
|
border: Border(
|
|
|
|
|
bottom: BorderSide(color: AppColors.divider),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
child: Column(
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
children: [
|
|
|
|
|
const Padding(
|
|
|
|
|
padding: EdgeInsets.fromLTRB(16, 16, 16, 12),
|
|
|
|
|
child: Text(
|
2026-01-13 13:02:40 +09:00
|
|
|
'티저 이미지',
|
2026-01-13 10:42:52 +09:00
|
|
|
style: TextStyle(
|
|
|
|
|
fontSize: 14,
|
|
|
|
|
fontWeight: FontWeight.w600,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
SizedBox(
|
|
|
|
|
height: 96,
|
|
|
|
|
child: ListView.builder(
|
|
|
|
|
scrollDirection: Axis.horizontal,
|
|
|
|
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
|
|
|
clipBehavior: Clip.none,
|
|
|
|
|
itemCount: teasers.length,
|
|
|
|
|
itemBuilder: (context, index) {
|
|
|
|
|
final teaser = teasers[index];
|
|
|
|
|
return Padding(
|
|
|
|
|
padding: EdgeInsets.only(right: index < teasers.length - 1 ? 12 : 0),
|
|
|
|
|
child: GestureDetector(
|
|
|
|
|
onTap: () => _showImageViewer(context, teasers, index),
|
2026-01-13 11:59:12 +09:00
|
|
|
child: _TeaserThumbnail(teaser: teaser),
|
2026-01-13 10:42:52 +09:00
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 16),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void _showImageViewer(BuildContext context, List<Teaser> teasers, int initialIndex) {
|
|
|
|
|
Navigator.of(context).push(
|
|
|
|
|
PageRouteBuilder(
|
|
|
|
|
opaque: false,
|
|
|
|
|
pageBuilder: (context, animation, secondaryAnimation) {
|
2026-01-13 11:59:12 +09:00
|
|
|
return _TeaserViewer(
|
|
|
|
|
teasers: teasers,
|
2026-01-13 10:42:52 +09:00
|
|
|
initialIndex: initialIndex,
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
|
|
|
|
return FadeTransition(opacity: animation, child: child);
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-13 11:59:12 +09:00
|
|
|
/// 티저 썸네일 (동영상의 경우 video_thumbnail으로 1초 프레임 추출)
|
|
|
|
|
class _TeaserThumbnail extends StatefulWidget {
|
|
|
|
|
final Teaser teaser;
|
|
|
|
|
|
|
|
|
|
const _TeaserThumbnail({required this.teaser});
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
State<_TeaserThumbnail> createState() => _TeaserThumbnailState();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class _TeaserThumbnailState extends State<_TeaserThumbnail> {
|
|
|
|
|
String? _thumbnailPath;
|
|
|
|
|
bool _isLoading = true;
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
void initState() {
|
|
|
|
|
super.initState();
|
|
|
|
|
if (widget.teaser.mediaType == 'video' && widget.teaser.thumbUrl == null) {
|
|
|
|
|
_extractThumbnail();
|
|
|
|
|
} else {
|
|
|
|
|
_isLoading = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> _extractThumbnail() async {
|
|
|
|
|
if (widget.teaser.originalUrl == null) {
|
|
|
|
|
if (mounted) {
|
|
|
|
|
setState(() => _isLoading = false);
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
// 임시 디렉토리 경로 획득
|
|
|
|
|
final tempDir = await getTemporaryDirectory();
|
|
|
|
|
|
|
|
|
|
// 썸네일 파일 생성
|
|
|
|
|
final thumbnailPath = await VideoThumbnail.thumbnailFile(
|
|
|
|
|
video: widget.teaser.originalUrl!,
|
|
|
|
|
thumbnailPath: tempDir.path,
|
|
|
|
|
imageFormat: ImageFormat.JPEG,
|
|
|
|
|
maxHeight: 200,
|
|
|
|
|
quality: 75,
|
|
|
|
|
timeMs: 1000, // 1초 위치
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (mounted) {
|
|
|
|
|
setState(() {
|
|
|
|
|
_thumbnailPath = thumbnailPath;
|
|
|
|
|
_isLoading = false;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
} catch (e) {
|
|
|
|
|
// 썸네일 추출 실패 - 플레이스홀더 표시
|
|
|
|
|
if (mounted) {
|
|
|
|
|
setState(() => _isLoading = false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
|
final teaser = widget.teaser;
|
|
|
|
|
final isVideo = teaser.mediaType == 'video';
|
|
|
|
|
|
|
|
|
|
return Container(
|
|
|
|
|
width: 96,
|
|
|
|
|
height: 96,
|
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
|
color: AppColors.divider,
|
|
|
|
|
borderRadius: BorderRadius.circular(16),
|
|
|
|
|
),
|
|
|
|
|
child: ClipRRect(
|
|
|
|
|
borderRadius: BorderRadius.circular(16),
|
|
|
|
|
child: Stack(
|
|
|
|
|
fit: StackFit.expand,
|
|
|
|
|
children: [
|
|
|
|
|
// 이미지 또는 동영상 썸네일
|
|
|
|
|
_buildThumbnail(),
|
|
|
|
|
// 동영상 재생 버튼 오버레이
|
|
|
|
|
if (isVideo)
|
|
|
|
|
Container(
|
|
|
|
|
color: Colors.black.withValues(alpha: 0.3),
|
|
|
|
|
child: const Center(
|
|
|
|
|
child: CircleAvatar(
|
|
|
|
|
radius: 16,
|
|
|
|
|
backgroundColor: Colors.white,
|
|
|
|
|
child: Icon(
|
|
|
|
|
LucideIcons.play,
|
|
|
|
|
size: 18,
|
|
|
|
|
color: AppColors.textPrimary,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Widget _buildThumbnail() {
|
|
|
|
|
final teaser = widget.teaser;
|
|
|
|
|
final isVideo = teaser.mediaType == 'video';
|
|
|
|
|
|
|
|
|
|
// 이미지이거나 thumbUrl이 있는 경우
|
|
|
|
|
if (!isVideo || teaser.thumbUrl != null) {
|
|
|
|
|
final imageUrl = teaser.thumbUrl ?? teaser.originalUrl;
|
|
|
|
|
if (imageUrl != null) {
|
|
|
|
|
return CachedNetworkImage(
|
|
|
|
|
imageUrl: imageUrl,
|
|
|
|
|
fit: BoxFit.cover,
|
|
|
|
|
placeholder: (context, url) => Container(color: AppColors.divider),
|
|
|
|
|
errorWidget: (context, url, error) => _buildPlaceholder(),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
return _buildPlaceholder();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 동영상 썸네일 로딩 중
|
|
|
|
|
if (_isLoading) {
|
|
|
|
|
return Container(
|
|
|
|
|
color: AppColors.divider,
|
|
|
|
|
child: const Center(
|
|
|
|
|
child: SizedBox(
|
|
|
|
|
width: 20,
|
|
|
|
|
height: 20,
|
|
|
|
|
child: CircularProgressIndicator(
|
|
|
|
|
strokeWidth: 2,
|
|
|
|
|
color: AppColors.textTertiary,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 동영상 썸네일 추출 성공
|
|
|
|
|
if (_thumbnailPath != null) {
|
|
|
|
|
return Image.file(
|
|
|
|
|
File(_thumbnailPath!),
|
|
|
|
|
fit: BoxFit.cover,
|
|
|
|
|
errorBuilder: (context, error, stackTrace) => _buildPlaceholder(),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 에러 발생 시 플레이스홀더
|
|
|
|
|
return _buildPlaceholder();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Widget _buildPlaceholder() {
|
|
|
|
|
return Container(
|
|
|
|
|
color: AppColors.divider,
|
|
|
|
|
child: const Center(
|
|
|
|
|
child: Icon(
|
|
|
|
|
LucideIcons.video,
|
|
|
|
|
size: 32,
|
|
|
|
|
color: AppColors.textTertiary,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-13 10:42:52 +09:00
|
|
|
/// 수록곡 섹션
|
|
|
|
|
class _TracksSection extends StatelessWidget {
|
|
|
|
|
final Album album;
|
2026-01-13 11:59:12 +09:00
|
|
|
final String albumName;
|
2026-01-13 10:42:52 +09:00
|
|
|
final List<Track>? displayTracks;
|
|
|
|
|
final bool showAllTracks;
|
|
|
|
|
final VoidCallback onToggle;
|
|
|
|
|
|
|
|
|
|
const _TracksSection({
|
|
|
|
|
required this.album,
|
2026-01-13 11:59:12 +09:00
|
|
|
required this.albumName,
|
2026-01-13 10:42:52 +09:00
|
|
|
required this.displayTracks,
|
|
|
|
|
required this.showAllTracks,
|
|
|
|
|
required this.onToggle,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
|
return Container(
|
|
|
|
|
padding: const EdgeInsets.all(16),
|
|
|
|
|
decoration: const BoxDecoration(
|
|
|
|
|
color: AppColors.background,
|
|
|
|
|
border: Border(
|
|
|
|
|
bottom: BorderSide(color: AppColors.divider),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
child: Column(
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
children: [
|
|
|
|
|
const Text(
|
|
|
|
|
'수록곡',
|
|
|
|
|
style: TextStyle(
|
|
|
|
|
fontSize: 14,
|
|
|
|
|
fontWeight: FontWeight.w600,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 12),
|
2026-01-13 11:59:12 +09:00
|
|
|
...?displayTracks?.map((track) => _TrackItem(
|
|
|
|
|
track: track,
|
|
|
|
|
albumName: albumName,
|
|
|
|
|
)),
|
2026-01-13 10:42:52 +09:00
|
|
|
if (album.tracks != null && album.tracks!.length > 5)
|
|
|
|
|
GestureDetector(
|
|
|
|
|
onTap: onToggle,
|
|
|
|
|
child: Padding(
|
|
|
|
|
padding: const EdgeInsets.only(top: 8),
|
|
|
|
|
child: Row(
|
|
|
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
|
|
|
children: [
|
|
|
|
|
Text(
|
|
|
|
|
showAllTracks
|
|
|
|
|
? '접기'
|
|
|
|
|
: '${album.tracks!.length - 5}곡 더보기',
|
|
|
|
|
style: const TextStyle(
|
|
|
|
|
fontSize: 14,
|
|
|
|
|
color: AppColors.textSecondary,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
Icon(
|
|
|
|
|
showAllTracks ? LucideIcons.chevronUp : LucideIcons.chevronDown,
|
|
|
|
|
size: 18,
|
|
|
|
|
color: AppColors.textSecondary,
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 트랙 아이템
|
|
|
|
|
class _TrackItem extends StatelessWidget {
|
|
|
|
|
final Track track;
|
2026-01-13 11:59:12 +09:00
|
|
|
final String albumName;
|
2026-01-13 10:42:52 +09:00
|
|
|
|
2026-01-13 11:59:12 +09:00
|
|
|
const _TrackItem({required this.track, required this.albumName});
|
2026-01-13 10:42:52 +09:00
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Widget build(BuildContext context) {
|
2026-01-13 11:59:12 +09:00
|
|
|
return GestureDetector(
|
|
|
|
|
onTap: () {
|
|
|
|
|
final encodedTrackTitle = Uri.encodeComponent(track.title);
|
|
|
|
|
context.push('/album/$albumName/track/$encodedTrackTitle');
|
|
|
|
|
},
|
|
|
|
|
behavior: HitTestBehavior.opaque,
|
|
|
|
|
child: Padding(
|
|
|
|
|
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 12),
|
|
|
|
|
child: Row(
|
|
|
|
|
children: [
|
|
|
|
|
// 트랙 번호
|
|
|
|
|
SizedBox(
|
|
|
|
|
width: 24,
|
|
|
|
|
child: Text(
|
|
|
|
|
track.trackNumber.toString().padLeft(2, '0'),
|
|
|
|
|
style: const TextStyle(
|
|
|
|
|
fontSize: 14,
|
|
|
|
|
color: AppColors.textTertiary,
|
|
|
|
|
fontFeatures: [FontFeature.tabularFigures()],
|
|
|
|
|
),
|
2026-01-13 10:42:52 +09:00
|
|
|
),
|
|
|
|
|
),
|
2026-01-13 11:59:12 +09:00
|
|
|
const SizedBox(width: 12),
|
|
|
|
|
// 트랙 제목
|
|
|
|
|
Expanded(
|
|
|
|
|
child: Row(
|
|
|
|
|
children: [
|
|
|
|
|
Flexible(
|
|
|
|
|
child: Text(
|
|
|
|
|
track.title,
|
2026-01-13 10:42:52 +09:00
|
|
|
style: TextStyle(
|
2026-01-13 11:59:12 +09:00
|
|
|
fontSize: 14,
|
|
|
|
|
fontWeight: FontWeight.w500,
|
|
|
|
|
color: track.isTitleTrack == 1
|
|
|
|
|
? AppColors.primary
|
|
|
|
|
: AppColors.textPrimary,
|
2026-01-13 10:42:52 +09:00
|
|
|
),
|
2026-01-13 11:59:12 +09:00
|
|
|
overflow: TextOverflow.ellipsis,
|
2026-01-13 10:42:52 +09:00
|
|
|
),
|
|
|
|
|
),
|
2026-01-13 11:59:12 +09:00
|
|
|
if (track.isTitleTrack == 1) ...[
|
|
|
|
|
const SizedBox(width: 8),
|
|
|
|
|
Container(
|
|
|
|
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
|
color: AppColors.primary,
|
|
|
|
|
borderRadius: BorderRadius.circular(4),
|
|
|
|
|
),
|
|
|
|
|
child: const Text(
|
|
|
|
|
'TITLE',
|
|
|
|
|
style: TextStyle(
|
|
|
|
|
fontSize: 10,
|
|
|
|
|
fontWeight: FontWeight.bold,
|
|
|
|
|
color: Colors.white,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
2026-01-13 10:42:52 +09:00
|
|
|
],
|
2026-01-13 11:59:12 +09:00
|
|
|
),
|
2026-01-13 10:42:52 +09:00
|
|
|
),
|
2026-01-13 11:59:12 +09:00
|
|
|
// 재생 시간
|
|
|
|
|
Text(
|
|
|
|
|
track.duration ?? '-',
|
|
|
|
|
style: const TextStyle(
|
|
|
|
|
fontSize: 12,
|
|
|
|
|
color: AppColors.textTertiary,
|
|
|
|
|
fontFeatures: [FontFeature.tabularFigures()],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
// 화살표 아이콘
|
|
|
|
|
const SizedBox(width: 8),
|
|
|
|
|
const Icon(
|
|
|
|
|
LucideIcons.chevronRight,
|
|
|
|
|
size: 16,
|
2026-01-13 10:42:52 +09:00
|
|
|
color: AppColors.textTertiary,
|
|
|
|
|
),
|
2026-01-13 11:59:12 +09:00
|
|
|
],
|
|
|
|
|
),
|
2026-01-13 10:42:52 +09:00
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 컨셉 포토 섹션
|
|
|
|
|
class _ConceptPhotosSection extends StatelessWidget {
|
|
|
|
|
final List<ConceptPhoto> photos;
|
|
|
|
|
final String albumName;
|
|
|
|
|
|
|
|
|
|
const _ConceptPhotosSection({required this.photos, required this.albumName});
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
|
final bottomPadding = MediaQuery.of(context).padding.bottom;
|
|
|
|
|
|
|
|
|
|
return Container(
|
|
|
|
|
color: AppColors.background,
|
|
|
|
|
child: Column(
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
children: [
|
|
|
|
|
const Padding(
|
|
|
|
|
padding: EdgeInsets.fromLTRB(16, 16, 16, 12),
|
|
|
|
|
child: Text(
|
|
|
|
|
'컨셉 포토',
|
|
|
|
|
style: TextStyle(
|
|
|
|
|
fontSize: 14,
|
|
|
|
|
fontWeight: FontWeight.w600,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
GridView.builder(
|
|
|
|
|
shrinkWrap: true,
|
|
|
|
|
physics: const NeverScrollableScrollPhysics(),
|
|
|
|
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
|
|
|
clipBehavior: Clip.none,
|
|
|
|
|
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
|
|
|
|
crossAxisCount: 3,
|
|
|
|
|
crossAxisSpacing: 8,
|
|
|
|
|
mainAxisSpacing: 8,
|
|
|
|
|
),
|
|
|
|
|
itemCount: photos.length > 6 ? 6 : photos.length,
|
|
|
|
|
itemBuilder: (context, index) {
|
|
|
|
|
final photo = photos[index];
|
|
|
|
|
return GestureDetector(
|
|
|
|
|
onTap: () => _showImageViewer(context, photo),
|
|
|
|
|
child: Container(
|
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
|
color: AppColors.divider,
|
|
|
|
|
borderRadius: BorderRadius.circular(12),
|
|
|
|
|
),
|
|
|
|
|
child: ClipRRect(
|
|
|
|
|
borderRadius: BorderRadius.circular(12),
|
|
|
|
|
child: photo.thumbUrl != null || photo.mediumUrl != null
|
|
|
|
|
? CachedNetworkImage(
|
|
|
|
|
imageUrl: photo.thumbUrl ?? photo.mediumUrl!,
|
|
|
|
|
fit: BoxFit.cover,
|
|
|
|
|
placeholder: (context, url) => Container(
|
|
|
|
|
color: AppColors.divider,
|
|
|
|
|
),
|
|
|
|
|
errorWidget: (context, url, error) => const SizedBox(),
|
|
|
|
|
)
|
|
|
|
|
: const SizedBox(),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
// 전체보기 버튼
|
|
|
|
|
if (photos.length > 6)
|
|
|
|
|
Padding(
|
|
|
|
|
padding: EdgeInsets.fromLTRB(16, 12, 16, 16 + bottomPadding),
|
|
|
|
|
child: SizedBox(
|
|
|
|
|
width: double.infinity,
|
|
|
|
|
child: ElevatedButton(
|
|
|
|
|
onPressed: () {
|
2026-01-13 13:02:40 +09:00
|
|
|
context.push('/album/$albumName/gallery');
|
2026-01-13 10:42:52 +09:00
|
|
|
},
|
|
|
|
|
style: ElevatedButton.styleFrom(
|
|
|
|
|
backgroundColor: AppColors.primary.withValues(alpha: 0.05),
|
|
|
|
|
foregroundColor: AppColors.primary,
|
|
|
|
|
elevation: 0,
|
|
|
|
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
|
|
|
|
shape: RoundedRectangleBorder(
|
|
|
|
|
borderRadius: BorderRadius.circular(12),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
child: Row(
|
|
|
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
|
|
|
children: [
|
|
|
|
|
Text('전체 ${photos.length}장 보기'),
|
|
|
|
|
const SizedBox(width: 4),
|
|
|
|
|
const Icon(LucideIcons.chevronRight, size: 18),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
// 전체보기 버튼이 없을 때의 하단 여백
|
|
|
|
|
if (photos.length <= 6)
|
|
|
|
|
SizedBox(height: 16 + bottomPadding),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void _showImageViewer(BuildContext context, ConceptPhoto photo) {
|
|
|
|
|
Navigator.of(context).push(
|
|
|
|
|
PageRouteBuilder(
|
|
|
|
|
opaque: false,
|
|
|
|
|
pageBuilder: (context, animation, secondaryAnimation) {
|
|
|
|
|
return _SingleImageViewer(
|
|
|
|
|
imageUrl: photo.originalUrl ?? '',
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
|
|
|
|
return FadeTransition(opacity: animation, child: child);
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-13 11:59:12 +09:00
|
|
|
/// 티저 뷰어 (이미지 + 동영상 지원)
|
|
|
|
|
class _TeaserViewer extends StatefulWidget {
|
|
|
|
|
final List<Teaser> teasers;
|
2026-01-13 10:42:52 +09:00
|
|
|
final int initialIndex;
|
|
|
|
|
|
2026-01-13 11:59:12 +09:00
|
|
|
const _TeaserViewer({
|
|
|
|
|
required this.teasers,
|
2026-01-13 10:42:52 +09:00
|
|
|
required this.initialIndex,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
@override
|
2026-01-13 11:59:12 +09:00
|
|
|
State<_TeaserViewer> createState() => _TeaserViewerState();
|
2026-01-13 10:42:52 +09:00
|
|
|
}
|
|
|
|
|
|
2026-01-13 11:59:12 +09:00
|
|
|
class _TeaserViewerState extends State<_TeaserViewer> {
|
2026-01-13 10:42:52 +09:00
|
|
|
late PageController _pageController;
|
|
|
|
|
late int _currentIndex;
|
|
|
|
|
final Set<int> _preloadedIndices = {};
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
void initState() {
|
|
|
|
|
super.initState();
|
|
|
|
|
_currentIndex = widget.initialIndex;
|
|
|
|
|
_pageController = PageController(initialPage: widget.initialIndex);
|
|
|
|
|
// 초기 로드 시 주변 이미지 프리로드
|
|
|
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
|
|
|
_preloadAdjacentImages(_currentIndex);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
void dispose() {
|
|
|
|
|
_pageController.dispose();
|
|
|
|
|
super.dispose();
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-13 11:59:12 +09:00
|
|
|
/// 주변 이미지 프리로드 (좌우 2장씩, 이미지만)
|
2026-01-13 10:42:52 +09:00
|
|
|
void _preloadAdjacentImages(int index) {
|
|
|
|
|
for (int i = index - 2; i <= index + 2; i++) {
|
2026-01-13 11:59:12 +09:00
|
|
|
if (i >= 0 && i < widget.teasers.length && !_preloadedIndices.contains(i)) {
|
|
|
|
|
final teaser = widget.teasers[i];
|
|
|
|
|
if (teaser.mediaType != 'video') {
|
|
|
|
|
final url = teaser.originalUrl;
|
|
|
|
|
if (url != null && url.isNotEmpty) {
|
|
|
|
|
_preloadedIndices.add(i);
|
|
|
|
|
precacheImage(
|
|
|
|
|
CachedNetworkImageProvider(url),
|
|
|
|
|
context,
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-01-13 10:42:52 +09:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 이미지 다운로드 (시스템 다운로드 매니저 사용)
|
|
|
|
|
Future<void> _downloadImage() async {
|
2026-01-13 11:59:12 +09:00
|
|
|
final teaser = widget.teasers[_currentIndex];
|
|
|
|
|
if (teaser.mediaType == 'video') return; // 동영상은 다운로드 안함
|
|
|
|
|
final imageUrl = teaser.originalUrl;
|
|
|
|
|
if (imageUrl == null || imageUrl.isEmpty) return;
|
2026-01-13 10:42:52 +09:00
|
|
|
|
|
|
|
|
final taskId = await downloadImage(imageUrl);
|
|
|
|
|
if (taskId != null && mounted) {
|
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
|
|
|
const SnackBar(
|
|
|
|
|
content: Text('다운로드를 시작합니다'),
|
|
|
|
|
duration: Duration(seconds: 2),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
|
final bottomPadding = MediaQuery.of(context).padding.bottom;
|
|
|
|
|
final topPadding = MediaQuery.of(context).padding.top;
|
2026-01-13 11:59:12 +09:00
|
|
|
final currentTeaser = widget.teasers[_currentIndex];
|
|
|
|
|
final isVideo = currentTeaser.mediaType == 'video';
|
2026-01-13 10:42:52 +09:00
|
|
|
|
|
|
|
|
return AnnotatedRegion<SystemUiOverlayStyle>(
|
|
|
|
|
value: SystemUiOverlayStyle.light,
|
|
|
|
|
child: Scaffold(
|
|
|
|
|
backgroundColor: Colors.black,
|
|
|
|
|
body: Stack(
|
|
|
|
|
children: [
|
2026-01-13 11:59:12 +09:00
|
|
|
// 갤러리
|
2026-01-13 10:42:52 +09:00
|
|
|
PhotoViewGallery.builder(
|
|
|
|
|
pageController: _pageController,
|
2026-01-13 11:59:12 +09:00
|
|
|
itemCount: widget.teasers.length,
|
2026-01-13 10:42:52 +09:00
|
|
|
onPageChanged: (index) {
|
|
|
|
|
setState(() => _currentIndex = index);
|
|
|
|
|
_preloadAdjacentImages(index);
|
|
|
|
|
},
|
|
|
|
|
backgroundDecoration: const BoxDecoration(color: Colors.black),
|
|
|
|
|
builder: (context, index) {
|
2026-01-13 11:59:12 +09:00
|
|
|
final teaser = widget.teasers[index];
|
|
|
|
|
final isVideoItem = teaser.mediaType == 'video';
|
|
|
|
|
|
|
|
|
|
// 동영상인 경우 Chewie 플레이어로 재생
|
|
|
|
|
if (isVideoItem) {
|
|
|
|
|
return PhotoViewGalleryPageOptions.customChild(
|
|
|
|
|
child: _VideoTeaserPage(teaser: teaser),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 이미지인 경우
|
|
|
|
|
final imageUrl = teaser.thumbUrl ?? teaser.originalUrl;
|
|
|
|
|
if (imageUrl == null || imageUrl.isEmpty) {
|
2026-01-13 10:42:52 +09:00
|
|
|
return PhotoViewGalleryPageOptions.customChild(
|
|
|
|
|
child: const Center(
|
|
|
|
|
child: Icon(
|
|
|
|
|
LucideIcons.imageOff,
|
|
|
|
|
color: Colors.white54,
|
|
|
|
|
size: 64,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-01-13 11:59:12 +09:00
|
|
|
|
|
|
|
|
// 이미지인 경우 PhotoView 사용
|
2026-01-13 10:42:52 +09:00
|
|
|
return PhotoViewGalleryPageOptions(
|
2026-01-13 11:59:12 +09:00
|
|
|
imageProvider: CachedNetworkImageProvider(teaser.originalUrl ?? imageUrl),
|
2026-01-13 10:42:52 +09:00
|
|
|
minScale: PhotoViewComputedScale.contained,
|
|
|
|
|
maxScale: PhotoViewComputedScale.covered * 3,
|
|
|
|
|
initialScale: PhotoViewComputedScale.contained,
|
|
|
|
|
heroAttributes: PhotoViewHeroAttributes(tag: 'teaser_$index'),
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
loadingBuilder: (context, event) => const Center(
|
|
|
|
|
child: CircularProgressIndicator(
|
|
|
|
|
color: Colors.white54,
|
|
|
|
|
strokeWidth: 2,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
2026-01-13 11:59:12 +09:00
|
|
|
// 상단 헤더
|
|
|
|
|
Positioned(
|
|
|
|
|
top: topPadding + 8,
|
|
|
|
|
left: 0,
|
|
|
|
|
right: 0,
|
|
|
|
|
child: Padding(
|
|
|
|
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
|
|
|
child: Row(
|
|
|
|
|
children: [
|
|
|
|
|
// 왼쪽: 닫기 버튼
|
|
|
|
|
Expanded(
|
|
|
|
|
child: Align(
|
|
|
|
|
alignment: Alignment.centerLeft,
|
|
|
|
|
child: GestureDetector(
|
|
|
|
|
onTap: () => Navigator.pop(context),
|
|
|
|
|
child: const Padding(
|
|
|
|
|
padding: EdgeInsets.all(4),
|
|
|
|
|
child: Icon(LucideIcons.x, color: Colors.white70, size: 24),
|
|
|
|
|
),
|
2026-01-13 10:42:52 +09:00
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
2026-01-13 11:59:12 +09:00
|
|
|
// 가운데: 페이지 번호
|
|
|
|
|
if (widget.teasers.length > 1)
|
|
|
|
|
Text(
|
|
|
|
|
'${_currentIndex + 1} / ${widget.teasers.length}',
|
|
|
|
|
style: const TextStyle(
|
|
|
|
|
color: Colors.white70,
|
|
|
|
|
fontSize: 14,
|
|
|
|
|
fontFeatures: [FontFeature.tabularFigures()],
|
2026-01-13 10:42:52 +09:00
|
|
|
),
|
|
|
|
|
),
|
2026-01-13 11:59:12 +09:00
|
|
|
// 오른쪽: 다운로드 버튼 (이미지만)
|
|
|
|
|
Expanded(
|
|
|
|
|
child: Align(
|
|
|
|
|
alignment: Alignment.centerRight,
|
|
|
|
|
child: isVideo
|
|
|
|
|
? const SizedBox(width: 30)
|
|
|
|
|
: GestureDetector(
|
|
|
|
|
onTap: _downloadImage,
|
|
|
|
|
child: const Padding(
|
|
|
|
|
padding: EdgeInsets.all(4),
|
|
|
|
|
child: Icon(
|
|
|
|
|
LucideIcons.download,
|
|
|
|
|
color: Colors.white70,
|
|
|
|
|
size: 22,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
2026-01-13 10:42:52 +09:00
|
|
|
),
|
2026-01-13 11:59:12 +09:00
|
|
|
],
|
|
|
|
|
),
|
2026-01-13 10:42:52 +09:00
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
// 하단 인디케이터
|
2026-01-13 11:59:12 +09:00
|
|
|
if (widget.teasers.length > 1)
|
2026-01-13 10:42:52 +09:00
|
|
|
Positioned(
|
|
|
|
|
bottom: bottomPadding + 16,
|
|
|
|
|
left: 0,
|
|
|
|
|
right: 0,
|
|
|
|
|
child: _SlidingIndicator(
|
2026-01-13 11:59:12 +09:00
|
|
|
count: widget.teasers.length,
|
2026-01-13 10:42:52 +09:00
|
|
|
currentIndex: _currentIndex,
|
|
|
|
|
onTap: (index) {
|
|
|
|
|
_pageController.animateToPage(
|
|
|
|
|
index,
|
|
|
|
|
duration: const Duration(milliseconds: 300),
|
|
|
|
|
curve: Curves.easeOut,
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-01-13 11:59:12 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 동영상 티저 페이지 (Chewie로 내부 재생)
|
|
|
|
|
class _VideoTeaserPage extends StatefulWidget {
|
|
|
|
|
final Teaser teaser;
|
|
|
|
|
|
|
|
|
|
const _VideoTeaserPage({required this.teaser});
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
State<_VideoTeaserPage> createState() => _VideoTeaserPageState();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class _VideoTeaserPageState extends State<_VideoTeaserPage> {
|
|
|
|
|
VideoPlayerController? _videoController;
|
|
|
|
|
ChewieController? _chewieController;
|
|
|
|
|
bool _isInitialized = false;
|
|
|
|
|
bool _hasError = false;
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
void initState() {
|
|
|
|
|
super.initState();
|
|
|
|
|
_initializePlayer();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> _initializePlayer() async {
|
|
|
|
|
final videoUrl = widget.teaser.videoUrl ?? widget.teaser.originalUrl;
|
|
|
|
|
if (videoUrl == null) {
|
|
|
|
|
setState(() => _hasError = true);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
final videoController = VideoPlayerController.networkUrl(
|
|
|
|
|
Uri.parse(videoUrl),
|
|
|
|
|
);
|
|
|
|
|
_videoController = videoController;
|
|
|
|
|
|
|
|
|
|
await videoController.initialize();
|
|
|
|
|
|
|
|
|
|
final chewieController = ChewieController(
|
|
|
|
|
videoPlayerController: videoController,
|
|
|
|
|
autoPlay: false,
|
|
|
|
|
looping: false,
|
|
|
|
|
showControls: true,
|
|
|
|
|
allowFullScreen: false,
|
|
|
|
|
allowMuting: true,
|
|
|
|
|
showOptions: false,
|
|
|
|
|
placeholder: Container(color: Colors.black),
|
|
|
|
|
errorBuilder: (context, errorMessage) => Center(
|
|
|
|
|
child: Column(
|
|
|
|
|
mainAxisSize: MainAxisSize.min,
|
|
|
|
|
children: [
|
|
|
|
|
const Icon(LucideIcons.alertCircle, color: Colors.white54, size: 48),
|
|
|
|
|
const SizedBox(height: 8),
|
|
|
|
|
Text(
|
|
|
|
|
'동영상을 재생할 수 없습니다',
|
|
|
|
|
style: const TextStyle(color: Colors.white54),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
_chewieController = chewieController;
|
|
|
|
|
|
|
|
|
|
if (mounted) {
|
|
|
|
|
setState(() => _isInitialized = true);
|
|
|
|
|
}
|
|
|
|
|
} catch (e) {
|
|
|
|
|
if (mounted) {
|
|
|
|
|
setState(() => _hasError = true);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
void dispose() {
|
|
|
|
|
_chewieController?.dispose();
|
|
|
|
|
_videoController?.dispose();
|
|
|
|
|
super.dispose();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
|
if (_hasError) {
|
|
|
|
|
return const Center(
|
|
|
|
|
child: Column(
|
|
|
|
|
mainAxisSize: MainAxisSize.min,
|
|
|
|
|
children: [
|
|
|
|
|
Icon(LucideIcons.alertCircle, color: Colors.white54, size: 48),
|
|
|
|
|
SizedBox(height: 8),
|
|
|
|
|
Text(
|
|
|
|
|
'동영상을 불러올 수 없습니다',
|
|
|
|
|
style: TextStyle(color: Colors.white54),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!_isInitialized || _chewieController == null) {
|
|
|
|
|
return const Center(
|
|
|
|
|
child: CircularProgressIndicator(
|
|
|
|
|
color: Colors.white54,
|
|
|
|
|
strokeWidth: 2,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Center(
|
|
|
|
|
child: AspectRatio(
|
|
|
|
|
aspectRatio: _videoController!.value.aspectRatio,
|
|
|
|
|
child: Chewie(controller: _chewieController!),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-01-13 10:42:52 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 컨셉 포토용 이미지 뷰어 (단일 이미지, 스와이프 없음)
|
|
|
|
|
class _SingleImageViewer extends StatelessWidget {
|
|
|
|
|
final String imageUrl;
|
|
|
|
|
|
|
|
|
|
const _SingleImageViewer({required this.imageUrl});
|
|
|
|
|
|
|
|
|
|
/// 이미지 다운로드 (시스템 다운로드 매니저 사용)
|
|
|
|
|
Future<void> _downloadImage(BuildContext context) async {
|
|
|
|
|
if (imageUrl.isEmpty) return;
|
|
|
|
|
|
|
|
|
|
final taskId = await downloadImage(imageUrl);
|
|
|
|
|
if (taskId != null && context.mounted) {
|
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
|
|
|
const SnackBar(
|
|
|
|
|
content: Text('다운로드를 시작합니다'),
|
|
|
|
|
duration: Duration(seconds: 2),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
|
final topPadding = MediaQuery.of(context).padding.top;
|
|
|
|
|
|
|
|
|
|
return AnnotatedRegion<SystemUiOverlayStyle>(
|
|
|
|
|
value: SystemUiOverlayStyle.light,
|
|
|
|
|
child: Scaffold(
|
|
|
|
|
backgroundColor: Colors.black,
|
|
|
|
|
body: Stack(
|
|
|
|
|
children: [
|
|
|
|
|
// 이미지 (핀치줌 - 전체 화면으로 확대 가능)
|
|
|
|
|
imageUrl.isNotEmpty
|
|
|
|
|
? PhotoView(
|
|
|
|
|
imageProvider: CachedNetworkImageProvider(imageUrl),
|
|
|
|
|
minScale: PhotoViewComputedScale.contained,
|
|
|
|
|
maxScale: PhotoViewComputedScale.covered * 3,
|
|
|
|
|
initialScale: PhotoViewComputedScale.contained,
|
|
|
|
|
backgroundDecoration: const BoxDecoration(color: Colors.black),
|
|
|
|
|
loadingBuilder: (context, event) => const Center(
|
|
|
|
|
child: CircularProgressIndicator(
|
|
|
|
|
color: Colors.white54,
|
|
|
|
|
strokeWidth: 2,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
errorBuilder: (context, error, stackTrace) => const Center(
|
|
|
|
|
child: Icon(
|
|
|
|
|
LucideIcons.imageOff,
|
|
|
|
|
color: Colors.white54,
|
|
|
|
|
size: 64,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
: const Center(
|
|
|
|
|
child: Icon(
|
|
|
|
|
LucideIcons.imageOff,
|
|
|
|
|
color: Colors.white54,
|
|
|
|
|
size: 64,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
// 상단 헤더
|
|
|
|
|
Positioned(
|
|
|
|
|
top: topPadding + 8,
|
|
|
|
|
left: 0,
|
|
|
|
|
right: 0,
|
|
|
|
|
child: Padding(
|
|
|
|
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
|
|
|
child: Row(
|
|
|
|
|
children: [
|
|
|
|
|
// 왼쪽: 닫기 버튼
|
|
|
|
|
Expanded(
|
|
|
|
|
child: Align(
|
|
|
|
|
alignment: Alignment.centerLeft,
|
|
|
|
|
child: GestureDetector(
|
|
|
|
|
onTap: () => Navigator.pop(context),
|
|
|
|
|
child: const Padding(
|
|
|
|
|
padding: EdgeInsets.all(4),
|
|
|
|
|
child: Icon(LucideIcons.x, color: Colors.white70, size: 24),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
// 오른쪽: 다운로드 버튼
|
|
|
|
|
Expanded(
|
|
|
|
|
child: Align(
|
|
|
|
|
alignment: Alignment.centerRight,
|
|
|
|
|
child: GestureDetector(
|
|
|
|
|
onTap: () => _downloadImage(context),
|
|
|
|
|
child: const Padding(
|
|
|
|
|
padding: EdgeInsets.all(4),
|
|
|
|
|
child: Icon(LucideIcons.download, color: Colors.white70, size: 22),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 슬라이딩 인디케이터
|
|
|
|
|
class _SlidingIndicator extends StatelessWidget {
|
|
|
|
|
final int count;
|
|
|
|
|
final int currentIndex;
|
|
|
|
|
final Function(int) onTap;
|
|
|
|
|
|
|
|
|
|
const _SlidingIndicator({
|
|
|
|
|
required this.count,
|
|
|
|
|
required this.currentIndex,
|
|
|
|
|
required this.onTap,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
|
const double width = 120;
|
|
|
|
|
const double dotSpacing = 18;
|
|
|
|
|
const double activeDotSize = 12;
|
|
|
|
|
const double inactiveDotSize = 10;
|
|
|
|
|
|
|
|
|
|
final double halfWidth = width / 2;
|
|
|
|
|
final double translateX = -(currentIndex * dotSpacing) + halfWidth - (activeDotSize / 2);
|
|
|
|
|
|
|
|
|
|
return Center(
|
|
|
|
|
child: SizedBox(
|
|
|
|
|
width: width,
|
|
|
|
|
height: 20,
|
|
|
|
|
child: ShaderMask(
|
|
|
|
|
shaderCallback: (Rect bounds) {
|
|
|
|
|
return const LinearGradient(
|
|
|
|
|
colors: [
|
|
|
|
|
Colors.transparent,
|
|
|
|
|
Colors.white,
|
|
|
|
|
Colors.white,
|
|
|
|
|
Colors.transparent,
|
|
|
|
|
],
|
|
|
|
|
stops: [0.0, 0.15, 0.85, 1.0],
|
|
|
|
|
).createShader(bounds);
|
|
|
|
|
},
|
|
|
|
|
blendMode: BlendMode.dstIn,
|
|
|
|
|
child: Stack(
|
|
|
|
|
children: [
|
|
|
|
|
// 슬라이딩 점들
|
|
|
|
|
AnimatedPositioned(
|
|
|
|
|
duration: const Duration(milliseconds: 300),
|
|
|
|
|
curve: Curves.easeOutCubic,
|
|
|
|
|
left: translateX,
|
|
|
|
|
top: 0,
|
|
|
|
|
bottom: 0,
|
|
|
|
|
child: Row(
|
|
|
|
|
mainAxisSize: MainAxisSize.min,
|
|
|
|
|
children: List.generate(count, (index) {
|
|
|
|
|
final isActive = index == currentIndex;
|
|
|
|
|
return GestureDetector(
|
|
|
|
|
onTap: () => onTap(index),
|
|
|
|
|
child: Container(
|
|
|
|
|
width: dotSpacing,
|
|
|
|
|
alignment: Alignment.center,
|
|
|
|
|
child: AnimatedContainer(
|
|
|
|
|
duration: const Duration(milliseconds: 300),
|
|
|
|
|
width: isActive ? activeDotSize : inactiveDotSize,
|
|
|
|
|
height: isActive ? activeDotSize : inactiveDotSize,
|
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
|
shape: BoxShape.circle,
|
|
|
|
|
color: isActive
|
|
|
|
|
? Colors.white
|
|
|
|
|
: Colors.white.withValues(alpha: 0.4),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 앨범 소개 내용
|
|
|
|
|
class _DescriptionContent extends StatelessWidget {
|
|
|
|
|
final String description;
|
|
|
|
|
|
|
|
|
|
const _DescriptionContent({required this.description});
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
|
final bottomPadding = MediaQuery.of(context).padding.bottom;
|
|
|
|
|
|
|
|
|
|
return Column(
|
|
|
|
|
mainAxisSize: MainAxisSize.min,
|
|
|
|
|
children: [
|
|
|
|
|
// 헤더 (고정)
|
|
|
|
|
Padding(
|
|
|
|
|
padding: const EdgeInsets.fromLTRB(20, 16, 12, 12),
|
|
|
|
|
child: Row(
|
|
|
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
|
|
|
children: [
|
|
|
|
|
const Text(
|
|
|
|
|
'앨범 소개',
|
|
|
|
|
style: TextStyle(
|
|
|
|
|
fontSize: 18,
|
|
|
|
|
fontWeight: FontWeight.bold,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
GestureDetector(
|
|
|
|
|
onTap: () => Navigator.pop(context),
|
|
|
|
|
child: const Padding(
|
|
|
|
|
padding: EdgeInsets.all(8),
|
|
|
|
|
child: Icon(LucideIcons.x, size: 22),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
// 구분선
|
|
|
|
|
Container(
|
|
|
|
|
height: 0.5,
|
|
|
|
|
color: AppColors.divider,
|
|
|
|
|
),
|
|
|
|
|
// 내용 (스크롤)
|
|
|
|
|
Flexible(
|
|
|
|
|
child: SingleChildScrollView(
|
|
|
|
|
controller: ModalScrollController.of(context),
|
|
|
|
|
padding: EdgeInsets.fromLTRB(20, 20, 20, 20 + bottomPadding),
|
|
|
|
|
child: Text(
|
|
|
|
|
description,
|
|
|
|
|
style: const TextStyle(
|
|
|
|
|
fontSize: 14,
|
|
|
|
|
height: 1.8,
|
|
|
|
|
color: AppColors.textSecondary,
|
|
|
|
|
),
|
|
|
|
|
textAlign: TextAlign.justify,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|