From 300fe18a8dc1c36e15edb90b82f1ca2fd2aaa076 Mon Sep 17 00:00:00 2001 From: caadiq Date: Tue, 13 Jan 2026 13:02:40 +0900 Subject: [PATCH] =?UTF-8?q?Flutter=20=EC=95=B1:=20=EC=BB=A8=EC=85=89?= =?UTF-8?q?=ED=8F=AC=ED=86=A0=20=EA=B0=A4=EB=9F=AC=EB=A6=AC,=20UI=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0,=20=EC=95=B1=20=EC=9D=B4=EB=A6=84=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 컨셉포토 전체보기 화면 추가 (2열 Masonry 레이아웃) - ConceptPhoto 모델에 width, height, members, concept 필드 추가 - 앨범 상세/갤러리 화면 스크롤 시 툴바 색상 고정 - 멤버 인디케이터 중앙 정렬 수정 - 티저 포토 → 티저 이미지로 명칭 변경 - 뒤로가기 두 번 종료 기능 제거 - 앱 이름 fromis9 → fromis_9로 변경 Co-Authored-By: Claude Opus 4.5 --- app/android/app/src/main/AndroidManifest.xml | 2 +- app/ios/Runner/Info.plist | 4 +- app/lib/core/router.dart | 10 + app/lib/main.dart | 2 +- app/lib/models/album.dart | 20 + app/lib/views/album/album_detail_view.dart | 9 +- app/lib/views/album/album_gallery_view.dart | 460 ++++++++++++++++++ app/lib/views/main_shell.dart | 103 ++-- app/lib/views/members/members_view.dart | 4 +- .../src/pages/mobile/public/AlbumDetail.jsx | 4 +- frontend/src/pages/pc/public/AlbumDetail.jsx | 2 +- 11 files changed, 539 insertions(+), 81 deletions(-) create mode 100644 app/lib/views/album/album_gallery_view.dart diff --git a/app/android/app/src/main/AndroidManifest.xml b/app/android/app/src/main/AndroidManifest.xml index 81eb1a8..916113f 100644 --- a/app/android/app/src/main/AndroidManifest.xml +++ b/app/android/app/src/main/AndroidManifest.xml @@ -6,7 +6,7 @@ CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName - Fromis9 + fromis_9 CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier @@ -13,7 +13,7 @@ CFBundleInfoDictionaryVersion 6.0 CFBundleName - fromis9 + fromis_9 CFBundlePackageType APPL CFBundleShortVersionString diff --git a/app/lib/core/router.dart b/app/lib/core/router.dart index 4b8ac4c..fe310c9 100644 --- a/app/lib/core/router.dart +++ b/app/lib/core/router.dart @@ -8,6 +8,7 @@ import '../views/home/home_view.dart'; import '../views/members/members_view.dart'; import '../views/album/album_view.dart'; import '../views/album/album_detail_view.dart'; +import '../views/album/album_gallery_view.dart'; import '../views/album/track_detail_view.dart'; import '../views/schedule/schedule_view.dart'; @@ -71,5 +72,14 @@ final GoRouter appRouter = GoRouter( ); }, ), + // 앨범 갤러리 (컨셉포토 전체보기) + GoRoute( + path: '/album/:name/gallery', + parentNavigatorKey: rootNavigatorKey, + builder: (context, state) { + final albumName = state.pathParameters['name']!; + return AlbumGalleryView(albumName: albumName); + }, + ), ], ); diff --git a/app/lib/main.dart b/app/lib/main.dart index 732da1d..ecc6610 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -66,7 +66,7 @@ class Fromis9App extends StatelessWidget { backgroundColor: Colors.white, foregroundColor: AppColors.textPrimary, elevation: 0, - scrolledUnderElevation: 1, + scrolledUnderElevation: 0, centerTitle: true, titleTextStyle: TextStyle( fontFamily: 'Pretendard', diff --git a/app/lib/models/album.dart b/app/lib/models/album.dart index 215e16b..67b3c27 100644 --- a/app/lib/models/album.dart +++ b/app/lib/models/album.dart @@ -164,12 +164,20 @@ class ConceptPhoto { final String? originalUrl; final String? mediumUrl; final String? thumbUrl; + final int? width; + final int? height; + final String? members; + final String? concept; ConceptPhoto({ required this.id, this.originalUrl, this.mediumUrl, this.thumbUrl, + this.width, + this.height, + this.members, + this.concept, }); factory ConceptPhoto.fromJson(Map json) { @@ -178,8 +186,20 @@ class ConceptPhoto { originalUrl: json['original_url'] as String?, mediumUrl: json['medium_url'] as String?, thumbUrl: json['thumb_url'] as String?, + width: (json['width'] as num?)?.toInt(), + height: (json['height'] as num?)?.toInt(), + members: json['members'] as String?, + concept: json['concept'] as String?, ); } + + /// 이미지 종횡비 + double get aspectRatio { + if (width != null && height != null && width! > 0) { + return height! / width!; + } + return 1.0; + } } /// 트랙 상세 모델 (앨범 정보 포함) diff --git a/app/lib/views/album/album_detail_view.dart b/app/lib/views/album/album_detail_view.dart index a56aa1a..d2481a5 100644 --- a/app/lib/views/album/album_detail_view.dart +++ b/app/lib/views/album/album_detail_view.dart @@ -132,6 +132,7 @@ class _AlbumDetailViewState extends State { backgroundColor: Colors.white, foregroundColor: AppColors.textPrimary, elevation: 0, + scrolledUnderElevation: 0, leading: IconButton( icon: const Icon(LucideIcons.arrowLeft), onPressed: () => context.pop(), @@ -150,7 +151,7 @@ class _AlbumDetailViewState extends State { child: _HeroSection(album: album, formatDate: _formatDate), ), - // 티저 포토 + // 티저 이미지 if (album.teasers != null && album.teasers!.isNotEmpty) SliverToBoxAdapter( child: _TeaserSection(teasers: album.teasers!), @@ -409,7 +410,7 @@ class _MetaItem extends StatelessWidget { } } -/// 티저 포토 섹션 +/// 티저 이미지 섹션 class _TeaserSection extends StatelessWidget { final List teasers; @@ -430,7 +431,7 @@ class _TeaserSection extends StatelessWidget { const Padding( padding: EdgeInsets.fromLTRB(16, 16, 16, 12), child: Text( - '티저 포토', + '티저 이미지', style: TextStyle( fontSize: 14, fontWeight: FontWeight.w600, @@ -880,7 +881,7 @@ class _ConceptPhotosSection extends StatelessWidget { width: double.infinity, child: ElevatedButton( onPressed: () { - // TODO: 갤러리 페이지로 이동 + context.push('/album/$albumName/gallery'); }, style: ElevatedButton.styleFrom( backgroundColor: AppColors.primary.withValues(alpha: 0.05), diff --git a/app/lib/views/album/album_gallery_view.dart b/app/lib/views/album/album_gallery_view.dart new file mode 100644 index 0000000..533978b --- /dev/null +++ b/app/lib/views/album/album_gallery_view.dart @@ -0,0 +1,460 @@ +/// 앨범 컨셉포토 갤러리 화면 +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), + ), + ), + ), + ), + ), + ); + } +} diff --git a/app/lib/views/main_shell.dart b/app/lib/views/main_shell.dart index 2480c26..c5729c3 100644 --- a/app/lib/views/main_shell.dart +++ b/app/lib/views/main_shell.dart @@ -2,101 +2,66 @@ library; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:go_router/go_router.dart'; import 'package:flutter_svg/flutter_svg.dart'; import '../core/constants.dart'; /// 메인 앱 셸 (툴바 + 바텀 네비게이션 + 콘텐츠) -class MainShell extends StatefulWidget { +class MainShell extends StatelessWidget { final Widget child; const MainShell({super.key, required this.child}); - @override - State createState() => _MainShellState(); -} - -class _MainShellState extends State { - DateTime? _lastBackPressed; - - /// 뒤로가기 처리 - 두 번 눌러서 종료 - Future _onWillPop() async { - final now = DateTime.now(); - if (_lastBackPressed == null || now.difference(_lastBackPressed!) > const Duration(seconds: 2)) { - _lastBackPressed = now; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('한 번 더 누르면 종료됩니다'), - duration: Duration(seconds: 2), - behavior: SnackBarBehavior.floating, - ), - ); - return false; - } - // 앱 종료 - SystemNavigator.pop(); - return true; - } - @override Widget build(BuildContext context) { final location = GoRouterState.of(context).uri.path; final isMembersPage = location == '/members'; - return PopScope( - canPop: false, - onPopInvokedWithResult: (didPop, result) async { - if (didPop) return; - await _onWillPop(); - }, - child: Scaffold( - backgroundColor: AppColors.background, - // 앱바 (툴바) - 멤버 페이지에서는 그림자 제거 (인디케이터와 계단 효과 방지) - appBar: PreferredSize( - preferredSize: const Size.fromHeight(56), - child: Container( - decoration: BoxDecoration( - color: Colors.white, - boxShadow: isMembersPage - ? null - : [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.05), - blurRadius: 4, - offset: const Offset(0, 1), - ), - ], - ), - child: SafeArea( - child: SizedBox( - height: 56, - child: Center( - child: Text( - _getTitle(context), - style: const TextStyle( - fontFamily: 'Pretendard', - color: AppColors.primary, - fontSize: 20, - fontWeight: FontWeight.bold, + return Scaffold( + backgroundColor: AppColors.background, + // 앱바 (툴바) - 멤버 페이지에서는 그림자 제거 (인디케이터와 계단 효과 방지) + appBar: PreferredSize( + preferredSize: const Size.fromHeight(56), + child: Container( + decoration: BoxDecoration( + color: Colors.white, + boxShadow: isMembersPage + ? null + : [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 4, + offset: const Offset(0, 1), ), + ], + ), + child: SafeArea( + child: SizedBox( + height: 56, + child: Center( + child: Text( + _getTitle(location), + style: const TextStyle( + fontFamily: 'Pretendard', + color: AppColors.primary, + fontSize: 20, + fontWeight: FontWeight.bold, ), ), ), ), ), ), - // 콘텐츠 - body: widget.child, - // 바텀 네비게이션 - bottomNavigationBar: const _BottomNavBar(), ), + // 콘텐츠 + body: child, + // 바텀 네비게이션 + bottomNavigationBar: const _BottomNavBar(), ); } /// 현재 경로에 따른 타이틀 반환 - String _getTitle(BuildContext context) { - final location = GoRouterState.of(context).uri.path; + String _getTitle(String location) { switch (location) { case '/': return 'fromis_9'; diff --git a/app/lib/views/members/members_view.dart b/app/lib/views/members/members_view.dart index 2df96e6..c212c9e 100644 --- a/app/lib/views/members/members_view.dart +++ b/app/lib/views/members/members_view.dart @@ -111,7 +111,9 @@ class _MembersViewState extends State with TickerProviderStateMixin if (!_indicatorScrollController.hasClients) return; final screenWidth = MediaQuery.of(context).size.width; - final targetOffset = (index * _indicatorItemWidth) - (screenWidth / 2) + (_indicatorItemWidth / 2) + 16; + // 아이템 중심 위치: 왼쪽패딩(16) + index * 아이템너비(64) + 아이템반지름(26) + const itemRadius = 26.0; // 52 / 2 + final targetOffset = (index * _indicatorItemWidth) + 16 + itemRadius - (screenWidth / 2); final maxOffset = _indicatorScrollController.position.maxScrollExtent; _indicatorScrollController.animateTo( diff --git a/frontend/src/pages/mobile/public/AlbumDetail.jsx b/frontend/src/pages/mobile/public/AlbumDetail.jsx index 70cf718..aeb716e 100644 --- a/frontend/src/pages/mobile/public/AlbumDetail.jsx +++ b/frontend/src/pages/mobile/public/AlbumDetail.jsx @@ -204,14 +204,14 @@ function MobileAlbumDetail() { - {/* 티저 포토 */} + {/* 티저 이미지 */} {album.teasers && album.teasers.length > 0 && ( -

티저 포토

+

티저 이미지

{album.teasers.map((teaser, index) => (
0 && (
-

티저 포토

+

티저 이미지

{album.teasers.map((teaser, index) => (