/// 앨범 컨셉포토 갤러리 화면 library; 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 '../../core/constants.dart'; import '../../models/album.dart'; import '../../services/albums_service.dart'; import '../../services/download_service.dart'; class AlbumGalleryView extends StatefulWidget { final String albumName; const AlbumGalleryView({super.key, required this.albumName}); @override State createState() => _AlbumGalleryViewState(); } class _AlbumGalleryViewState extends State { late Future _albumFuture; bool _initialAnimationDone = false; @override void initState() { super.initState(); _albumFuture = getAlbumByName(widget.albumName); } @override Widget build(BuildContext context) { return Scaffold( backgroundColor: AppColors.background, body: FutureBuilder( 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), const Text( '사진을 불러오는데 실패했습니다', style: TextStyle(color: AppColors.textSecondary), ), const SizedBox(height: 16), TextButton( onPressed: () { setState(() { _albumFuture = getAlbumByName(widget.albumName); }); }, child: const Text('다시 시도'), ), ], ), ); } if (!snapshot.hasData) { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const Icon(LucideIcons.image, 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 photos = _flattenPhotosWithConcept(album); // 초기 애니메이션이 끝났는지 표시 if (!_initialAnimationDone) { WidgetsBinding.instance.addPostFrameCallback((_) { Future.delayed(const Duration(milliseconds: 600), () { if (mounted) { setState(() => _initialAnimationDone = true); } }); }); } return CustomScrollView( slivers: [ // 앱바 SliverAppBar( pinned: true, backgroundColor: Colors.white, foregroundColor: AppColors.textPrimary, elevation: 0, scrolledUnderElevation: 0, leading: IconButton( icon: const Icon(LucideIcons.arrowLeft), onPressed: () => context.pop(), ), title: const Text( '앨범', style: TextStyle( fontSize: 18, fontWeight: FontWeight.w600, ), ), ), // 앨범 헤더 카드 SliverToBoxAdapter( child: _AlbumHeaderCard(album: album, photoCount: photos.length), ), // 2열 Masonry 그리드 SliverToBoxAdapter( child: _MasonryGrid( photos: photos, skipAnimation: _initialAnimationDone, ), ), // 하단 여백 SliverToBoxAdapter( child: SizedBox(height: 12 + MediaQuery.of(context).padding.bottom), ), ], ); }, ), ); } /// 컨셉 포토를 플랫하게 펼치면서 concept 정보 유지 List _flattenPhotosWithConcept(Album album) { if (album.conceptPhotos == null) return []; final List allPhotos = []; album.conceptPhotos!.forEach((concept, photos) { for (final photo in photos) { // concept이 'Default'가 아닌 경우에만 concept 값 설정 allPhotos.add(ConceptPhoto( id: photo.id, originalUrl: photo.originalUrl, mediumUrl: photo.mediumUrl, thumbUrl: photo.thumbUrl, width: photo.width, height: photo.height, members: photo.members, concept: concept != 'Default' ? concept : null, )); } }); return allPhotos; } } /// 앨범 헤더 카드 class _AlbumHeaderCard extends StatelessWidget { final Album album; final int photoCount; const _AlbumHeaderCard({required this.album, required this.photoCount}); @override Widget build(BuildContext context) { return Container( margin: const EdgeInsets.all(16), padding: const EdgeInsets.all(16), decoration: BoxDecoration( gradient: LinearGradient( colors: [ AppColors.primary.withValues(alpha: 0.05), AppColors.primary.withValues(alpha: 0.1), ], ), borderRadius: BorderRadius.circular(16), ), child: Row( children: [ // 앨범 커버 if (album.coverThumbUrl != null) Container( width: 56, height: 56, decoration: BoxDecoration( borderRadius: BorderRadius.circular(12), boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.1), blurRadius: 4, ), ], ), child: ClipRRect( borderRadius: BorderRadius.circular(12), child: CachedNetworkImage( imageUrl: album.coverThumbUrl!, fit: BoxFit.cover, placeholder: (context, url) => Container(color: AppColors.divider), errorWidget: (context, url, error) => Container( color: AppColors.divider, child: const Icon(LucideIcons.disc3, color: AppColors.textTertiary), ), ), ), ), const SizedBox(width: 16), // 앨범 정보 Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '컨셉 포토', style: TextStyle( fontSize: 12, fontWeight: FontWeight.w500, color: AppColors.primary, ), ), const SizedBox(height: 2), Text( album.title, style: const TextStyle( fontSize: 16, fontWeight: FontWeight.bold, ), overflow: TextOverflow.ellipsis, ), const SizedBox(height: 2), Text( '$photoCount장의 사진', style: const TextStyle( fontSize: 12, color: AppColors.textTertiary, ), ), ], ), ), ], ), ); } } /// 2열 Masonry 그리드 class _MasonryGrid extends StatelessWidget { final List photos; final bool skipAnimation; const _MasonryGrid({required this.photos, this.skipAnimation = false}); @override Widget build(BuildContext context) { final columns = _distributePhotos(); return Padding( padding: const EdgeInsets.symmetric(horizontal: 12), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ // 왼쪽 열 Expanded( child: Column( children: columns.leftColumn.map((item) { return Padding( padding: const EdgeInsets.only(bottom: 8), child: _PhotoItem( photo: item.photo, index: item.originalIndex, allPhotos: photos, skipAnimation: skipAnimation, ), ); }).toList(), ), ), const SizedBox(width: 8), // 오른쪽 열 Expanded( child: Column( children: columns.rightColumn.map((item) { return Padding( padding: const EdgeInsets.only(bottom: 8), child: _PhotoItem( photo: item.photo, index: item.originalIndex, allPhotos: photos, skipAnimation: skipAnimation, ), ); }).toList(), ), ), ], ), ); } /// 사진을 2열로 균등 분배 (높이 기반) ({List<_PhotoWithIndex> leftColumn, List<_PhotoWithIndex> rightColumn}) _distributePhotos() { final List<_PhotoWithIndex> leftColumn = []; final List<_PhotoWithIndex> rightColumn = []; double leftHeight = 0; double rightHeight = 0; for (int i = 0; i < photos.length; i++) { final photo = photos[i]; final aspectRatio = photo.aspectRatio; // 더 짧은 열에 사진 추가 if (leftHeight <= rightHeight) { leftColumn.add(_PhotoWithIndex(photo: photo, originalIndex: i)); leftHeight += aspectRatio; } else { rightColumn.add(_PhotoWithIndex(photo: photo, originalIndex: i)); rightHeight += aspectRatio; } } return (leftColumn: leftColumn, rightColumn: rightColumn); } } /// 인덱스 정보를 포함한 사진 class _PhotoWithIndex { final ConceptPhoto photo; final int originalIndex; _PhotoWithIndex({required this.photo, required this.originalIndex}); } /// 개별 사진 아이템 (애니메이션 포함) class _PhotoItem extends StatefulWidget { final ConceptPhoto photo; final int index; final bool skipAnimation; final List allPhotos; const _PhotoItem({ required this.photo, required this.index, required this.allPhotos, this.skipAnimation = false, }); @override State<_PhotoItem> createState() => _PhotoItemState(); } class _PhotoItemState extends State<_PhotoItem> with SingleTickerProviderStateMixin, AutomaticKeepAliveClientMixin { late AnimationController _controller; late Animation _opacityAnimation; late Animation _slideAnimation; bool _hasAnimated = false; @override bool get wantKeepAlive => true; @override void initState() { super.initState(); _controller = AnimationController( duration: const Duration(milliseconds: 400), vsync: this, ); _opacityAnimation = Tween(begin: 0.0, end: 1.0).animate( CurvedAnimation(parent: _controller, curve: Curves.easeOut), ); _slideAnimation = Tween( begin: const Offset(0, 0.1), end: Offset.zero, ).animate( CurvedAnimation(parent: _controller, curve: Curves.easeOut), ); // skipAnimation이면 애니메이션 건너뛰고 바로 표시 if (widget.skipAnimation) { _hasAnimated = true; _controller.value = 1.0; } else { // 순차적으로 애니메이션 시작 (최대 10개까지만 순차, 이후는 동시에) final delay = widget.index < 10 ? widget.index * 40 : 400; Future.delayed(Duration(milliseconds: delay), () { if (mounted && !_hasAnimated) { _hasAnimated = true; _controller.forward(); } }); } } @override void dispose() { _controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { super.build(context); final imageUrl = widget.photo.thumbUrl ?? widget.photo.mediumUrl; return FadeTransition( opacity: _opacityAnimation, child: SlideTransition( position: _slideAnimation, child: GestureDetector( onTap: () { Navigator.push( context, PageRouteBuilder( opaque: false, pageBuilder: (context, animation, secondaryAnimation) { return _ConceptPhotoViewer( photos: widget.allPhotos, initialIndex: widget.index, ); }, transitionsBuilder: (context, animation, secondaryAnimation, child) { return FadeTransition(opacity: animation, child: child); }, transitionDuration: const Duration(milliseconds: 200), ), ); }, child: Container( decoration: BoxDecoration( color: AppColors.divider, borderRadius: BorderRadius.circular(12), ), child: ClipRRect( borderRadius: BorderRadius.circular(12), child: imageUrl != null ? AspectRatio( aspectRatio: widget.photo.width != null && widget.photo.height != null && widget.photo.height! > 0 ? widget.photo.width! / widget.photo.height! : 1.0, child: CachedNetworkImage( imageUrl: imageUrl, fit: BoxFit.cover, placeholder: (context, url) => Container(color: AppColors.divider), errorWidget: (context, url, error) => Container( color: AppColors.divider, child: const Icon(LucideIcons.imageOff, color: AppColors.textTertiary), ), ), ) : const AspectRatio( aspectRatio: 1.0, child: Icon(LucideIcons.imageOff, color: AppColors.textTertiary), ), ), ), ), ), ); } } /// 컨셉 포토 뷰어 (라이트박스) class _ConceptPhotoViewer extends StatefulWidget { final List photos; final int initialIndex; const _ConceptPhotoViewer({ required this.photos, required this.initialIndex, }); @override State<_ConceptPhotoViewer> createState() => _ConceptPhotoViewerState(); } class _ConceptPhotoViewerState extends State<_ConceptPhotoViewer> { late PageController _pageController; late int _currentIndex; @override void initState() { super.initState(); _currentIndex = widget.initialIndex; _pageController = PageController(initialPage: widget.initialIndex); } @override void dispose() { _pageController.dispose(); super.dispose(); } /// 이미지 다운로드 Future _downloadImage() async { final photo = widget.photos[_currentIndex]; final imageUrl = photo.originalUrl; if (imageUrl == null || imageUrl.isEmpty) return; final taskId = await downloadImage(imageUrl); if (taskId != null && mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('다운로드를 시작합니다'), duration: Duration(seconds: 2), ), ); } } /// 사진 정보가 있는지 확인 bool get _hasInfo { final photo = widget.photos[_currentIndex]; return (photo.members != null && photo.members!.isNotEmpty) || (photo.concept != null && photo.concept!.isNotEmpty); } /// Info 바텀시트 표시 void _showInfoSheet() { final photo = widget.photos[_currentIndex]; showModalBottomSheet( context: context, backgroundColor: Colors.transparent, builder: (context) => _InfoBottomSheet(photo: photo), ); } @override Widget build(BuildContext context) { final bottomPadding = MediaQuery.of(context).padding.bottom; final topPadding = MediaQuery.of(context).padding.top; return AnnotatedRegion( value: SystemUiOverlayStyle.light, child: Scaffold( backgroundColor: Colors.black, body: Stack( children: [ // 갤러리 PhotoViewGallery.builder( pageController: _pageController, itemCount: widget.photos.length, allowImplicitScrolling: true, // 인접 페이지 미리 빌드 onPageChanged: (index) { setState(() => _currentIndex = index); }, backgroundDecoration: const BoxDecoration(color: Colors.black), builder: (context, index) { final photo = widget.photos[index]; final imageUrl = photo.mediumUrl ?? photo.originalUrl; if (imageUrl == null || imageUrl.isEmpty) { return PhotoViewGalleryPageOptions.customChild( child: const Center( child: Icon( LucideIcons.imageOff, color: Colors.white54, size: 64, ), ), ); } return PhotoViewGalleryPageOptions( imageProvider: CachedNetworkImageProvider(photo.originalUrl ?? imageUrl), minScale: PhotoViewComputedScale.contained, maxScale: PhotoViewComputedScale.covered * 3, initialScale: PhotoViewComputedScale.contained, heroAttributes: PhotoViewHeroAttributes(tag: 'concept_photo_$index'), ); }, loadingBuilder: (context, event) => const Center( child: CircularProgressIndicator( color: Colors.white54, strokeWidth: 2, ), ), ), // 상단 헤더 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), ), ), ), ), // 가운데: 페이지 번호 if (widget.photos.length > 1) Text( '${_currentIndex + 1} / ${widget.photos.length}', style: const TextStyle( color: Colors.white70, fontSize: 14, fontFeatures: [FontFeature.tabularFigures()], ), ), // 오른쪽: 정보 버튼 + 다운로드 버튼 Expanded( child: Align( alignment: Alignment.centerRight, child: Row( mainAxisSize: MainAxisSize.min, children: [ if (_hasInfo) GestureDetector( onTap: _showInfoSheet, child: const Padding( padding: EdgeInsets.all(4), child: Icon( LucideIcons.info, color: Colors.white70, size: 22, ), ), ), const SizedBox(width: 8), GestureDetector( onTap: _downloadImage, child: const Padding( padding: EdgeInsets.all(4), child: Icon( LucideIcons.download, color: Colors.white70, size: 22, ), ), ), ], ), ), ), ], ), ), ), // 하단 인디케이터 if (widget.photos.length > 1) Positioned( bottom: bottomPadding + 16, left: 0, right: 0, child: _SlidingIndicator( count: widget.photos.length, currentIndex: _currentIndex, onTap: (index) { _pageController.animateToPage( index, duration: const Duration(milliseconds: 300), curve: Curves.easeOut, ); }, ), ), ], ), ), ); } } /// 정보 바텀시트 class _InfoBottomSheet extends StatelessWidget { final ConceptPhoto photo; const _InfoBottomSheet({required this.photo}); @override Widget build(BuildContext context) { final bottomPadding = MediaQuery.of(context).padding.bottom; return Container( decoration: const BoxDecoration( color: Color(0xFF18181B), // zinc-900 borderRadius: BorderRadius.vertical(top: Radius.circular(24)), ), child: Column( mainAxisSize: MainAxisSize.min, children: [ // 드래그 핸들 Container( margin: const EdgeInsets.only(top: 12, bottom: 8), width: 40, height: 4, decoration: BoxDecoration( color: const Color(0xFF52525B), // zinc-600 borderRadius: BorderRadius.circular(2), ), ), // 내용 Padding( padding: EdgeInsets.fromLTRB(20, 8, 20, 32 + bottomPadding), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( '사진 정보', style: TextStyle( color: Colors.white, fontSize: 18, fontWeight: FontWeight.w600, ), ), const SizedBox(height: 16), // 멤버 if (photo.members != null && photo.members!.isNotEmpty) _InfoRow( icon: LucideIcons.users, iconBackgroundColor: AppColors.primary.withValues(alpha: 0.2), iconColor: AppColors.primary, label: '멤버', value: photo.members!, ), // 컨셉 if (photo.concept != null && photo.concept!.isNotEmpty) ...[ if (photo.members != null && photo.members!.isNotEmpty) const SizedBox(height: 16), _InfoRow( icon: LucideIcons.tag, iconBackgroundColor: Colors.white.withValues(alpha: 0.1), iconColor: const Color(0xFFA1A1AA), // zinc-400 label: '컨셉', value: photo.concept!, ), ], ], ), ), ], ), ); } } /// 정보 행 위젯 class _InfoRow extends StatelessWidget { final IconData icon; final Color iconBackgroundColor; final Color iconColor; final String label; final String value; const _InfoRow({ required this.icon, required this.iconBackgroundColor, required this.iconColor, required this.label, required this.value, }); @override Widget build(BuildContext context) { return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( width: 32, height: 32, decoration: BoxDecoration( color: iconBackgroundColor, shape: BoxShape.circle, ), child: Icon(icon, size: 16, color: iconColor), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( label, style: const TextStyle( color: Color(0xFFA1A1AA), // zinc-400 fontSize: 12, ), ), const SizedBox(height: 2), Text( value, style: const TextStyle( color: Colors.white, fontSize: 15, ), ), ], ), ), ], ); } } /// 슬라이딩 인디케이터 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; 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; const inactiveDotSize = 10.0; 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), ), ), ), ); }), ), ), ], ), ), ), ); } }