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:
parent
255839a598
commit
300fe18a8d
11 changed files with 539 additions and 81 deletions
|
|
@ -6,7 +6,7 @@
|
|||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
<application
|
||||
android:usesCleartextTraffic="true"
|
||||
android:label="fromis9"
|
||||
android:label="fromis_9"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
<activity
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Fromis9</string>
|
||||
<string>fromis_9</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
|
|
@ -13,7 +13,7 @@
|
|||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>fromis9</string>
|
||||
<string>fromis_9</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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<String, dynamic> 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;
|
||||
}
|
||||
}
|
||||
|
||||
/// 트랙 상세 모델 (앨범 정보 포함)
|
||||
|
|
|
|||
|
|
@ -132,6 +132,7 @@ class _AlbumDetailViewState extends State<AlbumDetailView> {
|
|||
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<AlbumDetailView> {
|
|||
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<Teaser> 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),
|
||||
|
|
|
|||
460
app/lib/views/album/album_gallery_view.dart
Normal file
460
app/lib/views/album/album_gallery_view.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<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
|
||||
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';
|
||||
|
|
|
|||
|
|
@ -111,7 +111,9 @@ class _MembersViewState extends State<MembersView> 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(
|
||||
|
|
|
|||
|
|
@ -204,14 +204,14 @@ function MobileAlbumDetail() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* 티저 포토 */}
|
||||
{/* 티저 이미지 */}
|
||||
{album.teasers && album.teasers.length > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
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">
|
||||
{album.teasers.map((teaser, index) => (
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -288,7 +288,7 @@ function AlbumDetail() {
|
|||
{/* 앨범 티저 이미지/영상 */}
|
||||
{album.teasers && album.teasers.length > 0 && (
|
||||
<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">
|
||||
{album.teasers.map((teaser, index) => (
|
||||
<div
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue