Flutter 앱: 컨셉포토 라이트박스 추가 및 이미지 프리로드 개선
- 컨셉포토 갤러리에 라이트박스 기능 추가 - PhotoViewGallery로 이미지 스와이프 및 줌 지원 - 멤버/컨셉 정보 바텀시트 (info 버튼) - 이미지 다운로드 기능 - 슬라이딩 인디케이터 - PhotoViewGallery에 allowImplicitScrolling 옵션 추가 - 인접 페이지 미리 빌드로 스와이프 시 즉시 표시 - 불필요한 수동 프리로드 코드 제거 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
300fe18a8d
commit
6b512f943e
2 changed files with 449 additions and 26 deletions
|
|
@ -945,17 +945,12 @@ class _TeaserViewer extends StatefulWidget {
|
|||
class _TeaserViewerState extends State<_TeaserViewer> {
|
||||
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
|
||||
|
|
@ -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<void> _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) {
|
||||
|
|
|
|||
|
|
@ -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<ConceptPhoto> 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<ConceptPhoto> 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<void> _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<SystemUiOverlayStyle>(
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue