/// 앨범 컨셉포토 갤러리 화면 library; import 'package:flutter/material.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 '../../core/constants.dart'; import '../../models/album.dart'; import '../../services/albums_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, 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, 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; const _PhotoItem({ required this.photo, required this.index, 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: () { // TODO: 라이트박스 열기 }, 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), ), ), ), ), ), ); } }