Flutter 앱: 컨셉포토 갤러리, UI 개선, 앱 이름 변경

- 컨셉포토 전체보기 화면 추가 (2열 Masonry 레이아웃)
- ConceptPhoto 모델에 width, height, members, concept 필드 추가
- 앨범 상세/갤러리 화면 스크롤 시 툴바 색상 고정
- 멤버 인디케이터 중앙 정렬 수정
- 티저 포토 → 티저 이미지로 명칭 변경
- 뒤로가기 두 번 종료 기능 제거
- 앱 이름 fromis9 → fromis_9로 변경

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-01-13 13:02:40 +09:00
parent 255839a598
commit 300fe18a8d
11 changed files with 539 additions and 81 deletions

View file

@ -6,7 +6,7 @@
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/> <uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<application <application
android:usesCleartextTraffic="true" android:usesCleartextTraffic="true"
android:label="fromis9" android:label="fromis_9"
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"> android:icon="@mipmap/ic_launcher">
<activity <activity

View file

@ -5,7 +5,7 @@
<key>CFBundleDevelopmentRegion</key> <key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string> <string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key> <key>CFBundleDisplayName</key>
<string>Fromis9</string> <string>fromis_9</string>
<key>CFBundleExecutable</key> <key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string> <string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key> <key>CFBundleIdentifier</key>
@ -13,7 +13,7 @@
<key>CFBundleInfoDictionaryVersion</key> <key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string> <string>6.0</string>
<key>CFBundleName</key> <key>CFBundleName</key>
<string>fromis9</string> <string>fromis_9</string>
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>

View file

@ -8,6 +8,7 @@ import '../views/home/home_view.dart';
import '../views/members/members_view.dart'; import '../views/members/members_view.dart';
import '../views/album/album_view.dart'; import '../views/album/album_view.dart';
import '../views/album/album_detail_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/album/track_detail_view.dart';
import '../views/schedule/schedule_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);
},
),
], ],
); );

View file

@ -66,7 +66,7 @@ class Fromis9App extends StatelessWidget {
backgroundColor: Colors.white, backgroundColor: Colors.white,
foregroundColor: AppColors.textPrimary, foregroundColor: AppColors.textPrimary,
elevation: 0, elevation: 0,
scrolledUnderElevation: 1, scrolledUnderElevation: 0,
centerTitle: true, centerTitle: true,
titleTextStyle: TextStyle( titleTextStyle: TextStyle(
fontFamily: 'Pretendard', fontFamily: 'Pretendard',

View file

@ -164,12 +164,20 @@ class ConceptPhoto {
final String? originalUrl; final String? originalUrl;
final String? mediumUrl; final String? mediumUrl;
final String? thumbUrl; final String? thumbUrl;
final int? width;
final int? height;
final String? members;
final String? concept;
ConceptPhoto({ ConceptPhoto({
required this.id, required this.id,
this.originalUrl, this.originalUrl,
this.mediumUrl, this.mediumUrl,
this.thumbUrl, this.thumbUrl,
this.width,
this.height,
this.members,
this.concept,
}); });
factory ConceptPhoto.fromJson(Map<String, dynamic> json) { factory ConceptPhoto.fromJson(Map<String, dynamic> json) {
@ -178,8 +186,20 @@ class ConceptPhoto {
originalUrl: json['original_url'] as String?, originalUrl: json['original_url'] as String?,
mediumUrl: json['medium_url'] as String?, mediumUrl: json['medium_url'] as String?,
thumbUrl: json['thumb_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;
}
} }
/// ( ) /// ( )

View file

@ -132,6 +132,7 @@ class _AlbumDetailViewState extends State<AlbumDetailView> {
backgroundColor: Colors.white, backgroundColor: Colors.white,
foregroundColor: AppColors.textPrimary, foregroundColor: AppColors.textPrimary,
elevation: 0, elevation: 0,
scrolledUnderElevation: 0,
leading: IconButton( leading: IconButton(
icon: const Icon(LucideIcons.arrowLeft), icon: const Icon(LucideIcons.arrowLeft),
onPressed: () => context.pop(), onPressed: () => context.pop(),
@ -150,7 +151,7 @@ class _AlbumDetailViewState extends State<AlbumDetailView> {
child: _HeroSection(album: album, formatDate: _formatDate), child: _HeroSection(album: album, formatDate: _formatDate),
), ),
// //
if (album.teasers != null && album.teasers!.isNotEmpty) if (album.teasers != null && album.teasers!.isNotEmpty)
SliverToBoxAdapter( SliverToBoxAdapter(
child: _TeaserSection(teasers: album.teasers!), child: _TeaserSection(teasers: album.teasers!),
@ -409,7 +410,7 @@ class _MetaItem extends StatelessWidget {
} }
} }
/// ///
class _TeaserSection extends StatelessWidget { class _TeaserSection extends StatelessWidget {
final List<Teaser> teasers; final List<Teaser> teasers;
@ -430,7 +431,7 @@ class _TeaserSection extends StatelessWidget {
const Padding( const Padding(
padding: EdgeInsets.fromLTRB(16, 16, 16, 12), padding: EdgeInsets.fromLTRB(16, 16, 16, 12),
child: Text( child: Text(
'티저 포토', '티저 이미지',
style: TextStyle( style: TextStyle(
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
@ -880,7 +881,7 @@ class _ConceptPhotosSection extends StatelessWidget {
width: double.infinity, width: double.infinity,
child: ElevatedButton( child: ElevatedButton(
onPressed: () { onPressed: () {
// TODO: context.push('/album/$albumName/gallery');
}, },
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primary.withValues(alpha: 0.05), backgroundColor: AppColors.primary.withValues(alpha: 0.05),

View file

@ -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<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,
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<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: () {
// 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),
),
),
),
),
),
);
}
}

View file

@ -2,55 +2,22 @@
library; library;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter_svg/flutter_svg.dart';
import '../core/constants.dart'; import '../core/constants.dart';
/// ( + + ) /// ( + + )
class MainShell extends StatefulWidget { class MainShell extends StatelessWidget {
final Widget child; final Widget child;
const MainShell({super.key, required this.child}); const MainShell({super.key, required this.child});
@override
State<MainShell> createState() => _MainShellState();
}
class _MainShellState extends State<MainShell> {
DateTime? _lastBackPressed;
/// -
Future<bool> _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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final location = GoRouterState.of(context).uri.path; final location = GoRouterState.of(context).uri.path;
final isMembersPage = location == '/members'; final isMembersPage = location == '/members';
return PopScope( return Scaffold(
canPop: false,
onPopInvokedWithResult: (didPop, result) async {
if (didPop) return;
await _onWillPop();
},
child: Scaffold(
backgroundColor: AppColors.background, backgroundColor: AppColors.background,
// () - ( ) // () - ( )
appBar: PreferredSize( appBar: PreferredSize(
@ -73,7 +40,7 @@ class _MainShellState extends State<MainShell> {
height: 56, height: 56,
child: Center( child: Center(
child: Text( child: Text(
_getTitle(context), _getTitle(location),
style: const TextStyle( style: const TextStyle(
fontFamily: 'Pretendard', fontFamily: 'Pretendard',
color: AppColors.primary, color: AppColors.primary,
@ -87,16 +54,14 @@ class _MainShellState extends State<MainShell> {
), ),
), ),
// //
body: widget.child, body: child,
// //
bottomNavigationBar: const _BottomNavBar(), bottomNavigationBar: const _BottomNavBar(),
),
); );
} }
/// ///
String _getTitle(BuildContext context) { String _getTitle(String location) {
final location = GoRouterState.of(context).uri.path;
switch (location) { switch (location) {
case '/': case '/':
return 'fromis_9'; return 'fromis_9';

View file

@ -111,7 +111,9 @@ class _MembersViewState extends State<MembersView> with TickerProviderStateMixin
if (!_indicatorScrollController.hasClients) return; if (!_indicatorScrollController.hasClients) return;
final screenWidth = MediaQuery.of(context).size.width; 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; final maxOffset = _indicatorScrollController.position.maxScrollExtent;
_indicatorScrollController.animateTo( _indicatorScrollController.animateTo(

View file

@ -204,14 +204,14 @@ function MobileAlbumDetail() {
</div> </div>
</div> </div>
{/* 티저 포토 */} {/* 티저 이미지 */}
{album.teasers && album.teasers.length > 0 && ( {album.teasers && album.teasers.length > 0 && (
<motion.div <motion.div
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
className="px-4 py-4 border-b border-gray-100" className="px-4 py-4 border-b border-gray-100"
> >
<p className="text-sm font-semibold mb-3">티저 포토</p> <p className="text-sm font-semibold mb-3">티저 이미지</p>
<div className="flex gap-3 overflow-x-auto pb-1 -mx-4 px-4 scrollbar-hide"> <div className="flex gap-3 overflow-x-auto pb-1 -mx-4 px-4 scrollbar-hide">
{album.teasers.map((teaser, index) => ( {album.teasers.map((teaser, index) => (
<div <div

View file

@ -288,7 +288,7 @@ function AlbumDetail() {
{/* 앨범 티저 이미지/영상 */} {/* 앨범 티저 이미지/영상 */}
{album.teasers && album.teasers.length > 0 && ( {album.teasers && album.teasers.length > 0 && (
<div className="mt-auto"> <div className="mt-auto">
<p className="text-xs text-gray-400 mb-2">티저 포토</p> <p className="text-xs text-gray-400 mb-2">티저 이미지</p>
<div className="flex gap-2"> <div className="flex gap-2">
{album.teasers.map((teaser, index) => ( {album.teasers.map((teaser, index) => (
<div <div