diff --git a/app/lib/views/album/album_detail_view.dart b/app/lib/views/album/album_detail_view.dart index d2481a5..c9a8051 100644 --- a/app/lib/views/album/album_detail_view.dart +++ b/app/lib/views/album/album_detail_view.dart @@ -945,17 +945,12 @@ class _TeaserViewer extends StatefulWidget { class _TeaserViewerState extends State<_TeaserViewer> { late PageController _pageController; late int _currentIndex; - final Set _preloadedIndices = {}; @override void initState() { super.initState(); _currentIndex = widget.initialIndex; _pageController = PageController(initialPage: widget.initialIndex); - // 초기 로드 시 주변 이미지 프리로드 - WidgetsBinding.instance.addPostFrameCallback((_) { - _preloadAdjacentImages(_currentIndex); - }); } @override @@ -964,25 +959,6 @@ class _TeaserViewerState extends State<_TeaserViewer> { super.dispose(); } - /// 주변 이미지 프리로드 (좌우 2장씩, 이미지만) - void _preloadAdjacentImages(int index) { - for (int i = index - 2; i <= index + 2; i++) { - 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, - ); - } - } - } - } - } - /// 이미지 다운로드 (시스템 다운로드 매니저 사용) Future _downloadImage() async { final teaser = widget.teasers[_currentIndex]; @@ -1018,9 +994,9 @@ class _TeaserViewerState extends State<_TeaserViewer> { PhotoViewGallery.builder( pageController: _pageController, itemCount: widget.teasers.length, + allowImplicitScrolling: true, onPageChanged: (index) { setState(() => _currentIndex = index); - _preloadAdjacentImages(index); }, backgroundDecoration: const BoxDecoration(color: Colors.black), builder: (context, index) { diff --git a/app/lib/views/album/album_gallery_view.dart b/app/lib/views/album/album_gallery_view.dart index 533978b..90d0d08 100644 --- a/app/lib/views/album/album_gallery_view.dart +++ b/app/lib/views/album/album_gallery_view.dart @@ -2,12 +2,16 @@ 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; @@ -283,6 +287,7 @@ class _MasonryGrid extends StatelessWidget { child: _PhotoItem( photo: item.photo, index: item.originalIndex, + allPhotos: photos, skipAnimation: skipAnimation, ), ); @@ -299,6 +304,7 @@ class _MasonryGrid extends StatelessWidget { child: _PhotoItem( photo: item.photo, index: item.originalIndex, + allPhotos: photos, skipAnimation: skipAnimation, ), ); @@ -348,10 +354,12 @@ 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, }); @@ -421,7 +429,22 @@ class _PhotoItemState extends State<_PhotoItem> position: _slideAnimation, child: GestureDetector( onTap: () { - // TODO: 라이트박스 열기 + 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( @@ -458,3 +481,427 @@ class _PhotoItemState extends State<_PhotoItem> ); } } + +/// 컨셉 포토 뷰어 (라이트박스) +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), + ), + ), + ), + ); + }), + ), + ), + ], + ), + ), + ), + ); + } +}