2026-01-13 13:02:40 +09:00
|
|
|
/// 앨범 컨셉포토 갤러리 화면
|
|
|
|
|
library;
|
|
|
|
|
|
|
|
|
|
import 'package:flutter/material.dart';
|
2026-01-13 13:54:59 +09:00
|
|
|
import 'package:flutter/services.dart';
|
2026-01-13 13:02:40 +09:00
|
|
|
import 'package:go_router/go_router.dart';
|
|
|
|
|
import 'package:cached_network_image/cached_network_image.dart';
|
|
|
|
|
import 'package:lucide_icons/lucide_icons.dart';
|
2026-01-13 13:54:59 +09:00
|
|
|
import 'package:photo_view/photo_view.dart';
|
|
|
|
|
import 'package:photo_view/photo_view_gallery.dart';
|
2026-01-13 13:02:40 +09:00
|
|
|
import '../../core/constants.dart';
|
|
|
|
|
import '../../models/album.dart';
|
|
|
|
|
import '../../services/albums_service.dart';
|
2026-01-13 13:54:59 +09:00
|
|
|
import '../../services/download_service.dart';
|
2026-01-13 13:02:40 +09:00
|
|
|
|
|
|
|
|
class AlbumGalleryView extends StatefulWidget {
|
|
|
|
|
final String albumName;
|
|
|
|
|
|
|
|
|
|
const AlbumGalleryView({super.key, required this.albumName});
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
State<AlbumGalleryView> createState() => _AlbumGalleryViewState();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class _AlbumGalleryViewState extends State<AlbumGalleryView> {
|
|
|
|
|
late Future<Album> _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<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),
|
|
|
|
|
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<ConceptPhoto> _flattenPhotosWithConcept(Album album) {
|
|
|
|
|
if (album.conceptPhotos == null) return [];
|
|
|
|
|
|
|
|
|
|
final List<ConceptPhoto> 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<ConceptPhoto> 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,
|
2026-01-13 13:54:59 +09:00
|
|
|
allPhotos: photos,
|
2026-01-13 13:02:40 +09:00
|
|
|
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,
|
2026-01-13 13:54:59 +09:00
|
|
|
allPhotos: photos,
|
2026-01-13 13:02:40 +09:00
|
|
|
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;
|
2026-01-13 13:54:59 +09:00
|
|
|
final List<ConceptPhoto> allPhotos;
|
2026-01-13 13:02:40 +09:00
|
|
|
|
|
|
|
|
const _PhotoItem({
|
|
|
|
|
required this.photo,
|
|
|
|
|
required this.index,
|
2026-01-13 13:54:59 +09:00
|
|
|
required this.allPhotos,
|
2026-01-13 13:02:40 +09:00
|
|
|
this.skipAnimation = false,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
State<_PhotoItem> createState() => _PhotoItemState();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class _PhotoItemState extends State<_PhotoItem>
|
|
|
|
|
with SingleTickerProviderStateMixin, AutomaticKeepAliveClientMixin {
|
|
|
|
|
late AnimationController _controller;
|
|
|
|
|
late Animation<double> _opacityAnimation;
|
|
|
|
|
late Animation<Offset> _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<double>(begin: 0.0, end: 1.0).animate(
|
|
|
|
|
CurvedAnimation(parent: _controller, curve: Curves.easeOut),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
_slideAnimation = Tween<Offset>(
|
|
|
|
|
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: () {
|
2026-01-13 13:54:59 +09:00
|
|
|
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),
|
|
|
|
|
),
|
|
|
|
|
);
|
2026-01-13 13:02:40 +09:00
|
|
|
},
|
|
|
|
|
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),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-01-13 13:54:59 +09:00
|
|
|
|
|
|
|
|
/// 컨셉 포토 뷰어 (라이트박스)
|
|
|
|
|
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),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|