Compare commits

..

No commits in common. "02fe9314e42b604bf58849916652e81ffefd5781" and "255839a598c402adafd519bef30fa3a195da9868" have entirely different histories.

29 changed files with 685 additions and 4120 deletions

View file

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

View file

@ -5,7 +5,7 @@
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>fromis_9</string>
<string>Fromis9</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>fromis_9</string>
<string>fromis9</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>

View file

@ -1,73 +0,0 @@
/// (MVCS의 Controller )
///
/// .
/// View는 Controller를 .
library;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/album.dart';
import '../services/albums_service.dart';
///
class AlbumState {
final List<Album> albums;
final bool isLoading;
final String? error;
const AlbumState({
this.albums = const [],
this.isLoading = true,
this.error,
});
/// ( )
AlbumState copyWith({
List<Album>? albums,
bool? isLoading,
String? error,
}) {
return AlbumState(
albums: albums ?? this.albums,
isLoading: isLoading ?? this.isLoading,
error: error,
);
}
}
///
class AlbumController extends Notifier<AlbumState> {
@override
AlbumState build() {
//
Future.microtask(() => loadAlbums());
return const AlbumState();
}
///
Future<void> loadAlbums() async {
state = state.copyWith(isLoading: true, error: null);
try {
final albums = await getAlbums();
state = state.copyWith(
albums: albums,
isLoading: false,
);
} catch (e) {
state = state.copyWith(
isLoading: false,
error: e.toString(),
);
}
}
///
Future<void> refresh() async {
await loadAlbums();
}
}
/// Provider
final albumProvider = NotifierProvider<AlbumController, AlbumState>(
AlbumController.new,
);

View file

@ -1,93 +0,0 @@
/// (MVCS의 Controller )
///
/// .
/// View는 Controller를 .
library;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/member.dart';
import '../models/album.dart';
import '../models/schedule.dart';
import '../services/members_service.dart';
import '../services/albums_service.dart';
import '../services/schedules_service.dart';
///
class HomeState {
final List<Member> members;
final List<Album> albums;
final List<Schedule> schedules;
final bool isLoading;
final bool dataLoaded;
final String? error;
const HomeState({
this.members = const [],
this.albums = const [],
this.schedules = const [],
this.isLoading = true,
this.dataLoaded = false,
this.error,
});
/// ( )
HomeState copyWith({
List<Member>? members,
List<Album>? albums,
List<Schedule>? schedules,
bool? isLoading,
bool? dataLoaded,
String? error,
}) {
return HomeState(
members: members ?? this.members,
albums: albums ?? this.albums,
schedules: schedules ?? this.schedules,
isLoading: isLoading ?? this.isLoading,
dataLoaded: dataLoaded ?? this.dataLoaded,
error: error,
);
}
}
///
class HomeController extends Notifier<HomeState> {
@override
HomeState build() {
//
Future.microtask(() => loadData());
return const HomeState();
}
/// (, , )
Future<void> loadData() async {
state = state.copyWith(isLoading: true, error: null);
try {
final results = await Future.wait([
getActiveMembers(),
getRecentAlbums(2),
getUpcomingSchedules(3),
]);
state = state.copyWith(
members: results[0] as List<Member>,
albums: results[1] as List<Album>,
schedules: results[2] as List<Schedule>,
isLoading: false,
dataLoaded: true,
);
} catch (e) {
state = state.copyWith(
isLoading: false,
dataLoaded: true,
error: e.toString(),
);
}
}
}
/// Provider
final homeProvider = NotifierProvider<HomeController, HomeState>(
HomeController.new,
);

View file

@ -1,111 +0,0 @@
/// (MVCS의 Controller )
///
/// .
/// View는 Controller를 .
library;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/member.dart';
import '../services/members_service.dart';
///
class MembersState {
final List<Member> members;
final int currentIndex;
final bool isLoading;
final String? error;
const MembersState({
this.members = const [],
this.currentIndex = 0,
this.isLoading = true,
this.error,
});
/// ( )
MembersState copyWith({
List<Member>? members,
int? currentIndex,
bool? isLoading,
String? error,
}) {
return MembersState(
members: members ?? this.members,
currentIndex: currentIndex ?? this.currentIndex,
isLoading: isLoading ?? this.isLoading,
error: error,
);
}
///
Member? get currentMember =>
members.isNotEmpty ? members[currentIndex] : null;
}
///
class MembersController extends Notifier<MembersState> {
@override
MembersState build() {
//
Future.microtask(() => loadMembers());
return const MembersState();
}
///
Future<void> loadMembers() async {
state = state.copyWith(isLoading: true, error: null);
try {
final members = await getMembers();
// ,
members.sort((a, b) {
if (a.isFormer != b.isFormer) {
return a.isFormer ? 1 : -1;
}
return 0;
});
state = state.copyWith(
members: members,
isLoading: false,
);
} catch (e) {
state = state.copyWith(
isLoading: false,
error: e.toString(),
);
}
}
///
void setCurrentIndex(int index) {
if (index >= 0 && index < state.members.length) {
state = state.copyWith(currentIndex: index);
}
}
///
int? calculateAge(String? birthDate) {
if (birthDate == null) return null;
final birth = DateTime.tryParse(birthDate);
if (birth == null) return null;
final today = DateTime.now();
int age = today.year - birth.year;
if (today.month < birth.month ||
(today.month == birth.month && today.day < birth.day)) {
age--;
}
return age;
}
///
String formatBirthDate(String? birthDate) {
if (birthDate == null) return '';
return birthDate.substring(0, 10).replaceAll('-', '.');
}
}
/// Provider
final membersProvider = NotifierProvider<MembersController, MembersState>(
MembersController.new,
);

View file

@ -1,365 +0,0 @@
/// (MVCS의 Controller )
///
/// .
/// View는 Controller를 .
library;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
import '../models/schedule.dart';
import '../services/schedules_service.dart';
///
class ScheduleState {
final DateTime selectedDate;
final List<Schedule> schedules;
final bool isLoading;
final String? error;
// (key: "yyyy-MM")
final Map<String, List<Schedule>> calendarCache;
const ScheduleState({
required this.selectedDate,
this.schedules = const [],
this.isLoading = false,
this.error,
this.calendarCache = const {},
});
/// ( )
ScheduleState copyWith({
DateTime? selectedDate,
List<Schedule>? schedules,
bool? isLoading,
String? error,
Map<String, List<Schedule>>? calendarCache,
}) {
return ScheduleState(
selectedDate: selectedDate ?? this.selectedDate,
schedules: schedules ?? this.schedules,
isLoading: isLoading ?? this.isLoading,
error: error,
calendarCache: calendarCache ?? this.calendarCache,
);
}
///
List<Schedule> get selectedDateSchedules {
final dateStr = DateFormat('yyyy-MM-dd').format(selectedDate);
return schedules.where((s) => s.date.split('T')[0] == dateStr).toList();
}
/// ( , 3)
/// , schedules에서
List<Schedule> getDaySchedules(DateTime date) {
final dateStr = DateFormat('yyyy-MM-dd').format(date);
final cacheKey = DateFormat('yyyy-MM').format(date);
//
if (calendarCache.containsKey(cacheKey)) {
return calendarCache[cacheKey]!
.where((s) => s.date.split('T')[0] == dateStr)
.take(3)
.toList();
}
// schedules에서
return schedules.where((s) => s.date.split('T')[0] == dateStr).take(3).toList();
}
///
bool hasMonthCache(int year, int month) {
final cacheKey = '$year-${month.toString().padLeft(2, '0')}';
return calendarCache.containsKey(cacheKey);
}
///
List<DateTime> get daysInMonth {
final year = selectedDate.year;
final month = selectedDate.month;
final lastDay = DateTime(year, month + 1, 0).day;
return List.generate(lastDay, (i) => DateTime(year, month, i + 1));
}
}
///
class ScheduleController extends Notifier<ScheduleState> {
@override
ScheduleState build() {
//
final initialState = ScheduleState(selectedDate: DateTime.now());
//
Future.microtask(() => loadSchedules());
return initialState;
}
///
Future<void> loadSchedules() async {
state = state.copyWith(isLoading: true, error: null);
try {
final schedules = await getSchedules(
state.selectedDate.year,
state.selectedDate.month,
);
//
final cacheKey = '${state.selectedDate.year}-${state.selectedDate.month.toString().padLeft(2, '0')}';
final newCache = Map<String, List<Schedule>>.from(state.calendarCache);
newCache[cacheKey] = schedules;
state = state.copyWith(
schedules: schedules,
isLoading: false,
calendarCache: newCache,
);
} catch (e) {
state = state.copyWith(isLoading: false, error: e.toString());
}
}
/// (UI )
Future<void> loadCalendarMonth(int year, int month) async {
final cacheKey = '$year-${month.toString().padLeft(2, '0')}';
//
if (state.calendarCache.containsKey(cacheKey)) return;
try {
final schedules = await getSchedules(year, month);
//
final newCache = Map<String, List<Schedule>>.from(state.calendarCache);
newCache[cacheKey] = schedules;
state = state.copyWith(calendarCache: newCache);
} catch (e) {
// ( )
}
}
///
void selectDate(DateTime date) {
state = state.copyWith(selectedDate: date);
}
///
void changeMonth(int delta) {
final newDate = DateTime(
state.selectedDate.year,
state.selectedDate.month + delta,
1,
);
final today = DateTime.now();
// , 1
final selectedDay = (newDate.year == today.year && newDate.month == today.month)
? today.day
: 1;
state = state.copyWith(
selectedDate: DateTime(newDate.year, newDate.month, selectedDay),
);
loadSchedules();
}
/// ( )
void goToDate(DateTime date) {
final currentMonth = state.selectedDate.month;
final currentYear = state.selectedDate.year;
state = state.copyWith(selectedDate: date);
//
if (date.month != currentMonth || date.year != currentYear) {
loadSchedules();
}
}
///
bool isToday(DateTime date) {
final today = DateTime.now();
return date.year == today.year &&
date.month == today.month &&
date.day == today.day;
}
///
bool isSelected(DateTime date) {
return date.year == state.selectedDate.year &&
date.month == state.selectedDate.month &&
date.day == state.selectedDate.day;
}
}
/// Provider
final scheduleProvider = NotifierProvider<ScheduleController, ScheduleState>(
ScheduleController.new,
);
///
class SearchState {
final String searchTerm;
final List<Schedule> results;
final bool isLoading;
final bool isFetchingMore;
final bool hasMore;
final int offset;
final String? error;
const SearchState({
this.searchTerm = '',
this.results = const [],
this.isLoading = false,
this.isFetchingMore = false,
this.hasMore = true,
this.offset = 0,
this.error,
});
SearchState copyWith({
String? searchTerm,
List<Schedule>? results,
bool? isLoading,
bool? isFetchingMore,
bool? hasMore,
int? offset,
String? error,
}) {
return SearchState(
searchTerm: searchTerm ?? this.searchTerm,
results: results ?? this.results,
isLoading: isLoading ?? this.isLoading,
isFetchingMore: isFetchingMore ?? this.isFetchingMore,
hasMore: hasMore ?? this.hasMore,
offset: offset ?? this.offset,
error: error,
);
}
}
///
class ScheduleSearchController extends Notifier<SearchState> {
static const int _pageSize = 20;
@override
SearchState build() {
return const SearchState();
}
///
Future<void> search(String query) async {
if (query.trim().isEmpty) {
state = const SearchState();
return;
}
state = SearchState(searchTerm: query, isLoading: true);
try {
final result = await searchSchedules(query, offset: 0, limit: _pageSize);
state = state.copyWith(
results: result.schedules,
isLoading: false,
hasMore: result.hasMore,
offset: result.schedules.length,
);
} catch (e) {
state = state.copyWith(isLoading: false, error: e.toString());
}
}
///
Future<void> loadMore() async {
if (state.isFetchingMore || !state.hasMore || state.searchTerm.isEmpty) {
return;
}
state = state.copyWith(isFetchingMore: true);
try {
final result = await searchSchedules(
state.searchTerm,
offset: state.offset,
limit: _pageSize,
);
state = state.copyWith(
results: [...state.results, ...result.schedules],
isFetchingMore: false,
hasMore: result.hasMore,
offset: state.offset + result.schedules.length,
);
} catch (e) {
state = state.copyWith(isFetchingMore: false, error: e.toString());
}
}
///
void clear() {
state = const SearchState();
}
}
/// Provider
final searchProvider = NotifierProvider<ScheduleSearchController, SearchState>(
ScheduleSearchController.new,
);
///
class SuggestionState {
final String query;
final List<String> suggestions;
final bool isLoading;
const SuggestionState({
this.query = '',
this.suggestions = const [],
this.isLoading = false,
});
SuggestionState copyWith({
String? query,
List<String>? suggestions,
bool? isLoading,
}) {
return SuggestionState(
query: query ?? this.query,
suggestions: suggestions ?? this.suggestions,
isLoading: isLoading ?? this.isLoading,
);
}
}
///
class SuggestionController extends Notifier<SuggestionState> {
@override
SuggestionState build() {
return const SuggestionState();
}
///
Future<void> loadSuggestions(String query) async {
if (query.trim().isEmpty) {
state = const SuggestionState();
return;
}
//
if (state.query == query && state.suggestions.isNotEmpty) return;
state = state.copyWith(query: query, isLoading: true);
try {
final suggestions = await getSuggestions(query, limit: 10);
state = state.copyWith(suggestions: suggestions, isLoading: false);
} catch (e) {
state = state.copyWith(suggestions: [], isLoading: false);
}
}
///
void clear() {
state = const SuggestionState();
}
}
/// Provider
final suggestionProvider = NotifierProvider<SuggestionController, SuggestionState>(
SuggestionController.new,
);

View file

@ -8,7 +8,6 @@ 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';
@ -72,14 +71,5 @@ 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,
foregroundColor: AppColors.textPrimary,
elevation: 0,
scrolledUnderElevation: 0,
scrolledUnderElevation: 1,
centerTitle: true,
titleTextStyle: TextStyle(
fontFamily: 'Pretendard',

View file

@ -164,20 +164,12 @@ 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) {
@ -186,20 +178,8 @@ 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;
}
}
/// ( )

View file

@ -14,32 +14,7 @@ Future<void> initDownloadService() async {
);
}
/// URL에서
String _getExtensionFromUrl(String url) {
try {
final uri = Uri.parse(url);
final path = uri.path.toLowerCase();
//
if (path.endsWith('.mp4')) return '.mp4';
if (path.endsWith('.mov')) return '.mov';
if (path.endsWith('.avi')) return '.avi';
if (path.endsWith('.webm')) return '.webm';
//
if (path.endsWith('.jpg') || path.endsWith('.jpeg')) return '.jpg';
if (path.endsWith('.png')) return '.png';
if (path.endsWith('.gif')) return '.gif';
if (path.endsWith('.webp')) return '.webp';
//
return '.jpg';
} catch (_) {
return '.jpg';
}
}
/// (/)
///
Future<String?> downloadImage(String url, {String? fileName}) async {
//
if (Platform.isAndroid) {
@ -49,20 +24,21 @@ Future<String?> downloadImage(String url, {String? fileName}) async {
}
}
// (Pictures )
final Directory directory;
//
Directory? directory;
if (Platform.isAndroid) {
directory = Directory('/storage/emulated/0/Pictures/fromis_9');
directory = Directory('/storage/emulated/0/Download');
if (!await directory.exists()) {
await directory.create(recursive: true);
directory = await getExternalStorageDirectory();
}
} else {
directory = await getApplicationDocumentsDirectory();
}
// (URL에서 )
final extension = _getExtensionFromUrl(url);
final name = fileName ?? 'fromis9_${DateTime.now().millisecondsSinceEpoch}$extension';
if (directory == null) return null;
//
final name = fileName ?? 'fromis9_${DateTime.now().millisecondsSinceEpoch}.jpg';
//
final taskId = await FlutterDownloader.enqueue(
@ -71,7 +47,6 @@ Future<String?> downloadImage(String url, {String? fileName}) async {
fileName: name,
showNotification: true,
openFileFromNotification: true,
saveInPublicStorage: true, //
);
return taskId;

View file

@ -31,51 +31,3 @@ Future<List<Schedule>> getUpcomingSchedules(int limit) async {
final List<dynamic> data = response.data;
return data.map((json) => Schedule.fromJson(json)).toList();
}
///
class SearchResult {
final List<Schedule> schedules;
final int offset;
final bool hasMore;
const SearchResult({
required this.schedules,
required this.offset,
required this.hasMore,
});
}
/// (Meilisearch)
Future<SearchResult> searchSchedules(String query, {int offset = 0, int limit = 20}) async {
final response = await dio.get('/schedules', queryParameters: {
'search': query,
'offset': offset.toString(),
'limit': limit.toString(),
});
// : { schedules: [...], hasMore: bool, offset: int }
final Map<String, dynamic> data = response.data;
final List<dynamic> schedulesJson = data['schedules'] ?? [];
final schedules = schedulesJson.map((json) => Schedule.fromJson(json)).toList();
return SearchResult(
schedules: schedules,
offset: offset,
hasMore: data['hasMore'] ?? schedules.length >= limit,
);
}
///
Future<List<String>> getSuggestions(String query, {int limit = 10}) async {
if (query.trim().isEmpty) return [];
try {
final response = await dio.get('/schedules/suggestions', queryParameters: {
'q': query,
'limit': limit.toString(),
});
final Map<String, dynamic> data = response.data;
final List<dynamic> suggestions = data['suggestions'] ?? [];
return suggestions.cast<String>();
} catch (e) {
return [];
}
}

View file

@ -1,6 +1,7 @@
///
library;
import 'dart:io';
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@ -12,6 +13,8 @@ import 'package:photo_view/photo_view_gallery.dart';
import 'package:modal_bottom_sheet/modal_bottom_sheet.dart';
import 'package:video_player/video_player.dart';
import 'package:chewie/chewie.dart';
import 'package:video_thumbnail/video_thumbnail.dart';
import 'package:path_provider/path_provider.dart';
import '../../core/constants.dart';
import '../../models/album.dart';
import '../../services/albums_service.dart';
@ -129,7 +132,6 @@ 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(),
@ -148,7 +150,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!),
@ -407,7 +409,7 @@ class _MetaItem extends StatelessWidget {
}
}
///
///
class _TeaserSection extends StatelessWidget {
final List<Teaser> teasers;
@ -428,7 +430,7 @@ class _TeaserSection extends StatelessWidget {
const Padding(
padding: EdgeInsets.fromLTRB(16, 16, 16, 12),
child: Text(
'티저 이미지',
'티저 포토',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
@ -478,16 +480,70 @@ class _TeaserSection extends StatelessWidget {
}
}
///
class _TeaserThumbnail extends StatelessWidget {
/// ( video_thumbnail으로 1 )
class _TeaserThumbnail extends StatefulWidget {
final Teaser teaser;
const _TeaserThumbnail({required this.teaser});
@override
State<_TeaserThumbnail> createState() => _TeaserThumbnailState();
}
class _TeaserThumbnailState extends State<_TeaserThumbnail> {
String? _thumbnailPath;
bool _isLoading = true;
@override
void initState() {
super.initState();
if (widget.teaser.mediaType == 'video' && widget.teaser.thumbUrl == null) {
_extractThumbnail();
} else {
_isLoading = false;
}
}
Future<void> _extractThumbnail() async {
if (widget.teaser.originalUrl == null) {
if (mounted) {
setState(() => _isLoading = false);
}
return;
}
try {
//
final tempDir = await getTemporaryDirectory();
//
final thumbnailPath = await VideoThumbnail.thumbnailFile(
video: widget.teaser.originalUrl!,
thumbnailPath: tempDir.path,
imageFormat: ImageFormat.JPEG,
maxHeight: 200,
quality: 75,
timeMs: 1000, // 1
);
if (mounted) {
setState(() {
_thumbnailPath = thumbnailPath;
_isLoading = false;
});
}
} catch (e) {
// -
if (mounted) {
setState(() => _isLoading = false);
}
}
}
@override
Widget build(BuildContext context) {
final teaser = widget.teaser;
final isVideo = teaser.mediaType == 'video';
final imageUrl = teaser.thumbUrl ?? teaser.originalUrl;
return Container(
width: 96,
@ -501,16 +557,8 @@ class _TeaserThumbnail extends StatelessWidget {
child: Stack(
fit: StackFit.expand,
children: [
//
if (imageUrl != null)
CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
placeholder: (context, url) => Container(color: AppColors.divider),
errorWidget: (context, url, error) => _buildPlaceholder(),
)
else
_buildPlaceholder(),
//
_buildThumbnail(),
//
if (isVideo)
Container(
@ -533,6 +581,54 @@ class _TeaserThumbnail extends StatelessWidget {
);
}
Widget _buildThumbnail() {
final teaser = widget.teaser;
final isVideo = teaser.mediaType == 'video';
// thumbUrl이
if (!isVideo || teaser.thumbUrl != null) {
final imageUrl = teaser.thumbUrl ?? teaser.originalUrl;
if (imageUrl != null) {
return CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
placeholder: (context, url) => Container(color: AppColors.divider),
errorWidget: (context, url, error) => _buildPlaceholder(),
);
}
return _buildPlaceholder();
}
//
if (_isLoading) {
return Container(
color: AppColors.divider,
child: const Center(
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: AppColors.textTertiary,
),
),
),
);
}
//
if (_thumbnailPath != null) {
return Image.file(
File(_thumbnailPath!),
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) => _buildPlaceholder(),
);
}
//
return _buildPlaceholder();
}
Widget _buildPlaceholder() {
return Container(
color: AppColors.divider,
@ -784,7 +880,7 @@ class _ConceptPhotosSection extends StatelessWidget {
width: double.infinity,
child: ElevatedButton(
onPressed: () {
context.push('/album/$albumName/gallery');
// TODO:
},
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primary.withValues(alpha: 0.05),
@ -848,12 +944,17 @@ class _TeaserViewer extends StatefulWidget {
class _TeaserViewerState extends State<_TeaserViewer> {
late PageController _pageController;
late int _currentIndex;
final Set<int> _preloadedIndices = {};
@override
void initState() {
super.initState();
_currentIndex = widget.initialIndex;
_pageController = PageController(initialPage: widget.initialIndex);
//
WidgetsBinding.instance.addPostFrameCallback((_) {
_preloadAdjacentImages(_currentIndex);
});
}
@override
@ -862,14 +963,33 @@ class _TeaserViewerState extends State<_TeaserViewer> {
super.dispose();
}
/// ( )
Future<void> _download() async {
final teaser = widget.teasers[_currentIndex];
final isVideo = teaser.mediaType == 'video';
final url = isVideo ? (teaser.videoUrl ?? teaser.originalUrl) : teaser.originalUrl;
if (url == null || url.isEmpty) return;
/// ( 2, )
void _preloadAdjacentImages(int index) {
for (int i = index - 2; i <= index + 2; i++) {
if (i >= 0 && i < widget.teasers.length && !_preloadedIndices.contains(i)) {
final teaser = widget.teasers[i];
if (teaser.mediaType != 'video') {
final url = teaser.originalUrl;
if (url != null && url.isNotEmpty) {
_preloadedIndices.add(i);
precacheImage(
CachedNetworkImageProvider(url),
context,
);
}
}
}
}
}
final taskId = await downloadImage(url);
/// ( )
Future<void> _downloadImage() async {
final teaser = widget.teasers[_currentIndex];
if (teaser.mediaType == 'video') return; //
final imageUrl = teaser.originalUrl;
if (imageUrl == null || imageUrl.isEmpty) return;
final taskId = await downloadImage(imageUrl);
if (taskId != null && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
@ -884,6 +1004,8 @@ class _TeaserViewerState extends State<_TeaserViewer> {
Widget build(BuildContext context) {
final bottomPadding = MediaQuery.of(context).padding.bottom;
final topPadding = MediaQuery.of(context).padding.top;
final currentTeaser = widget.teasers[_currentIndex];
final isVideo = currentTeaser.mediaType == 'video';
return AnnotatedRegion<SystemUiOverlayStyle>(
value: SystemUiOverlayStyle.light,
@ -895,9 +1017,9 @@ class _TeaserViewerState extends State<_TeaserViewer> {
PhotoViewGallery.builder(
pageController: _pageController,
itemCount: widget.teasers.length,
allowImplicitScrolling: true,
onPageChanged: (index) {
setState(() => _currentIndex = index);
_preloadAdjacentImages(index);
},
backgroundDecoration: const BoxDecoration(color: Colors.black),
builder: (context, index) {
@ -973,21 +1095,23 @@ class _TeaserViewerState extends State<_TeaserViewer> {
fontFeatures: [FontFeature.tabularFigures()],
),
),
// :
// : ()
Expanded(
child: Align(
alignment: Alignment.centerRight,
child: GestureDetector(
onTap: _download,
child: const Padding(
padding: EdgeInsets.all(4),
child: Icon(
LucideIcons.download,
color: Colors.white70,
size: 22,
),
),
),
child: isVideo
? const SizedBox(width: 30)
: GestureDetector(
onTap: _downloadImage,
child: const Padding(
padding: EdgeInsets.all(4),
child: Icon(
LucideIcons.download,
color: Colors.white70,
size: 22,
),
),
),
),
),
],

View file

@ -1,907 +0,0 @@
///
library;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:go_router/go_router.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:photo_view/photo_view.dart';
import 'package:photo_view/photo_view_gallery.dart';
import '../../core/constants.dart';
import '../../models/album.dart';
import '../../services/albums_service.dart';
import '../../services/download_service.dart';
class AlbumGalleryView extends StatefulWidget {
final String albumName;
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,
allPhotos: photos,
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,
allPhotos: photos,
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;
final List<ConceptPhoto> allPhotos;
const _PhotoItem({
required this.photo,
required this.index,
required this.allPhotos,
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: () {
Navigator.push(
context,
PageRouteBuilder(
opaque: false,
pageBuilder: (context, animation, secondaryAnimation) {
return _ConceptPhotoViewer(
photos: widget.allPhotos,
initialIndex: widget.index,
);
},
transitionsBuilder: (context, animation, secondaryAnimation, child) {
return FadeTransition(opacity: animation, child: child);
},
transitionDuration: const Duration(milliseconds: 200),
),
);
},
child: Container(
decoration: BoxDecoration(
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),
),
),
),
),
),
);
}
}
/// ()
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),
),
),
),
);
}),
),
),
],
),
),
),
);
}
}

View file

@ -1,30 +1,29 @@
/// (MVCS의 View )
///
/// UI , Controller에 .
///
library;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.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 '../../controllers/album_controller.dart';
import '../../services/albums_service.dart';
class AlbumView extends ConsumerStatefulWidget {
class AlbumView extends StatefulWidget {
const AlbumView({super.key});
@override
ConsumerState<AlbumView> createState() => _AlbumViewState();
State<AlbumView> createState() => _AlbumViewState();
}
class _AlbumViewState extends ConsumerState<AlbumView> {
class _AlbumViewState extends State<AlbumView> {
late Future<List<Album>> _albumsFuture;
bool _initialLoadComplete = false;
@override
void initState() {
super.initState();
_albumsFuture = getAlbums();
//
Future.delayed(const Duration(milliseconds: 600), () {
if (mounted) {
@ -35,62 +34,69 @@ class _AlbumViewState extends ConsumerState<AlbumView> {
@override
Widget build(BuildContext context) {
final albumState = ref.watch(albumProvider);
if (albumState.isLoading) {
return const Center(
child: CircularProgressIndicator(
color: AppColors.primary,
),
);
}
if (albumState.error != null) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
LucideIcons.alertCircle,
size: 48,
color: AppColors.textTertiary,
return FutureBuilder<List<Album>>(
future: _albumsFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(
child: CircularProgressIndicator(
color: AppColors.primary,
),
const SizedBox(height: 16),
Text(
'앨범을 불러오는데 실패했습니다',
style: TextStyle(
fontSize: 14,
color: AppColors.textSecondary,
),
),
const SizedBox(height: 16),
TextButton(
onPressed: () {
ref.read(albumProvider.notifier).refresh();
},
child: const Text('다시 시도'),
),
],
),
);
}
);
}
return GridView.builder(
padding: const EdgeInsets.all(16),
clipBehavior: Clip.none,
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
childAspectRatio: 0.75,
),
itemCount: albumState.albums.length,
itemBuilder: (context, index) {
final album = albumState.albums[index];
return _AlbumCard(
album: album,
index: index,
skipAnimation: _initialLoadComplete,
if (snapshot.hasError) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
LucideIcons.alertCircle,
size: 48,
color: AppColors.textTertiary,
),
const SizedBox(height: 16),
Text(
'앨범을 불러오는데 실패했습니다',
style: TextStyle(
fontSize: 14,
color: AppColors.textSecondary,
),
),
const SizedBox(height: 16),
TextButton(
onPressed: () {
setState(() {
_albumsFuture = getAlbums();
});
},
child: const Text('다시 시도'),
),
],
),
);
}
final albums = snapshot.data ?? [];
return GridView.builder(
padding: const EdgeInsets.all(16),
clipBehavior: Clip.none,
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
childAspectRatio: 0.75,
),
itemCount: albums.length,
itemBuilder: (context, index) {
final album = albums[index];
return _AlbumCard(
album: album,
index: index,
skipAnimation: _initialLoadComplete,
);
},
);
},
);

View file

@ -1,25 +1,32 @@
/// (MVCS의 View )
///
/// UI , Controller에 .
/// - +
library;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter_svg/flutter_svg.dart';
import '../../core/constants.dart';
import '../../models/member.dart';
import '../../models/album.dart';
import '../../models/schedule.dart';
import '../../controllers/home_controller.dart';
import '../../services/members_service.dart';
import '../../services/albums_service.dart';
import '../../services/schedules_service.dart';
class HomeView extends ConsumerStatefulWidget {
class HomeView extends StatefulWidget {
const HomeView({super.key});
@override
ConsumerState<HomeView> createState() => _HomeViewState();
State<HomeView> createState() => _HomeViewState();
}
class _HomeViewState extends ConsumerState<HomeView> with TickerProviderStateMixin {
class _HomeViewState extends State<HomeView> with TickerProviderStateMixin {
List<Member> _members = [];
List<Album> _albums = [];
List<Schedule> _schedules = [];
bool _isLoading = true;
bool _dataLoaded = false;
//
late AnimationController _animController;
@ -36,12 +43,12 @@ class _HomeViewState extends ConsumerState<HomeView> with TickerProviderStateMix
// ( )
String? _previousPath;
bool _animationStarted = false;
@override
void initState() {
super.initState();
_setupAnimations();
_loadData();
}
void _setupAnimations() {
@ -139,10 +146,9 @@ class _HomeViewState extends ConsumerState<HomeView> with TickerProviderStateMix
super.didChangeDependencies();
// go_router에서
final currentPath = GoRouterState.of(context).uri.path;
final homeState = ref.read(homeProvider);
// ('/')
if (_previousPath != null && _previousPath != '/' && currentPath == '/' && homeState.dataLoaded) {
if (_previousPath != null && _previousPath != '/' && currentPath == '/' && _dataLoaded) {
_startAnimations();
}
_previousPath = currentPath;
@ -154,19 +160,36 @@ class _HomeViewState extends ConsumerState<HomeView> with TickerProviderStateMix
super.dispose();
}
Future<void> _loadData() async {
try {
final results = await Future.wait([
getActiveMembers(),
getRecentAlbums(2),
getUpcomingSchedules(3),
]);
setState(() {
_members = results[0] as List<Member>;
_albums = results[1] as List<Album>;
_schedules = results[2] as List<Schedule>;
_isLoading = false;
_dataLoaded = true;
});
//
_startAnimations();
} catch (e) {
setState(() {
_isLoading = false;
_dataLoaded = true;
});
_startAnimations();
}
}
@override
Widget build(BuildContext context) {
final homeState = ref.watch(homeProvider);
//
if (homeState.dataLoaded && !_animationStarted) {
_animationStarted = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
_startAnimations();
});
}
if (homeState.isLoading) {
if (_isLoading) {
return const Center(
child: CircularProgressIndicator(color: AppColors.primary),
);
@ -179,9 +202,10 @@ class _HomeViewState extends ConsumerState<HomeView> with TickerProviderStateMix
child: Column(
children: [
_buildHeroSection(),
_buildMembersSection(homeState),
_buildAlbumsSection(homeState),
_buildSchedulesSection(homeState),
_buildMembersSection(),
_buildAlbumsSection(),
_buildSchedulesSection(),
const SizedBox(height: 16),
],
),
);
@ -282,7 +306,7 @@ class _HomeViewState extends ConsumerState<HomeView> with TickerProviderStateMix
}
/// - px-4(16px) py-6(24px)
Widget _buildMembersSection(HomeState homeState) {
Widget _buildMembersSection() {
return Opacity(
opacity: _membersSectionOpacity.value,
child: Transform.translate(
@ -296,7 +320,7 @@ class _HomeViewState extends ConsumerState<HomeView> with TickerProviderStateMix
const SizedBox(height: 16),
// grid-cols-5 gap-2(8px)
Row(
children: homeState.members.asMap().entries.map((entry) {
children: _members.asMap().entries.map((entry) {
final index = entry.key;
final member = entry.value;
// : delay 0.4+index*0.05s, duration 0.3s
@ -338,7 +362,7 @@ class _HomeViewState extends ConsumerState<HomeView> with TickerProviderStateMix
? CachedNetworkImage(
imageUrl: member.imageUrl!,
fit: BoxFit.cover,
placeholder: (context, url) => Container(color: Colors.grey[200]),
placeholder: (_, __) => Container(color: Colors.grey[200]),
)
: null,
),
@ -366,7 +390,7 @@ class _HomeViewState extends ConsumerState<HomeView> with TickerProviderStateMix
}
/// - px-4(16px) py-6(24px)
Widget _buildAlbumsSection(HomeState homeState) {
Widget _buildAlbumsSection() {
return Opacity(
opacity: _albumsSectionOpacity.value,
child: Transform.translate(
@ -380,7 +404,7 @@ class _HomeViewState extends ConsumerState<HomeView> with TickerProviderStateMix
const SizedBox(height: 16),
// grid-cols-2 gap-3(12px)
Row(
children: homeState.albums.asMap().entries.map((entry) {
children: _albums.asMap().entries.map((entry) {
final index = entry.key;
final album = entry.value;
// : delay 0.6+index*0.1s, duration 0.3s
@ -434,7 +458,7 @@ class _HomeViewState extends ConsumerState<HomeView> with TickerProviderStateMix
? CachedNetworkImage(
imageUrl: album.coverThumbUrl!,
fit: BoxFit.cover,
placeholder: (context, url) => Container(color: Colors.grey[200]),
placeholder: (_, __) => Container(color: Colors.grey[200]),
)
: Container(color: Colors.grey[200]),
),
@ -477,7 +501,7 @@ class _HomeViewState extends ConsumerState<HomeView> with TickerProviderStateMix
}
/// - px-4(16px) py-4(16px)
Widget _buildSchedulesSection(HomeState homeState) {
Widget _buildSchedulesSection() {
return Opacity(
opacity: _schedulesSectionOpacity.value,
child: Transform.translate(
@ -489,7 +513,7 @@ class _HomeViewState extends ConsumerState<HomeView> with TickerProviderStateMix
// mb-4(16px)
_buildSectionHeader('다가오는 일정', () => context.go('/schedule')),
const SizedBox(height: 16),
if (homeState.schedules.isEmpty)
if (_schedules.isEmpty)
Container(
padding: const EdgeInsets.symmetric(vertical: 32),
child: Text(
@ -500,7 +524,7 @@ class _HomeViewState extends ConsumerState<HomeView> with TickerProviderStateMix
else
// space-y-3(12px)
Column(
children: homeState.schedules.asMap().entries.map((entry) {
children: _schedules.asMap().entries.map((entry) {
final index = entry.key;
final schedule = entry.value;
// : delay 0.8+index*0.1s, duration 0.3s, x -200
@ -521,7 +545,7 @@ class _HomeViewState extends ConsumerState<HomeView> with TickerProviderStateMix
);
return Padding(
padding: EdgeInsets.only(bottom: index < homeState.schedules.length - 1 ? 12 : 0),
padding: EdgeInsets.only(bottom: index < _schedules.length - 1 ? 12 : 0),
child: Opacity(
opacity: itemOpacity.value,
child: Transform.translate(

View file

@ -2,69 +2,101 @@
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 StatelessWidget {
class MainShell extends StatefulWidget {
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';
final isSchedulePage = location.startsWith('/schedule');
return Scaffold(
backgroundColor: AppColors.background,
// () - ,
appBar: isSchedulePage
? null
: 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,
),
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,
),
),
),
),
),
//
body: child,
//
bottomNavigationBar: const _BottomNavBar(),
),
),
//
body: widget.child,
//
bottomNavigationBar: const _BottomNavBar(),
),
);
}
///
String _getTitle(String location) {
String _getTitle(BuildContext context) {
final location = GoRouterState.of(context).uri.path;
switch (location) {
case '/':
return 'fromis_9';

View file

@ -1,27 +1,27 @@
/// (MVCS의 View )
///
/// UI , Controller에 .
/// -
library;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:go_router/go_router.dart';
import '../../core/constants.dart';
import '../../models/member.dart';
import '../../controllers/members_controller.dart';
import '../../services/members_service.dart';
class MembersView extends ConsumerStatefulWidget {
class MembersView extends StatefulWidget {
const MembersView({super.key});
@override
ConsumerState<MembersView> createState() => _MembersViewState();
State<MembersView> createState() => _MembersViewState();
}
class _MembersViewState extends ConsumerState<MembersView> with TickerProviderStateMixin {
class _MembersViewState extends State<MembersView> with TickerProviderStateMixin {
List<Member> _members = [];
bool _isLoading = true;
int _currentIndex = 0;
late PageController _pageController;
late ScrollController _indicatorScrollController;
late AnimationController _animController;
@ -37,7 +37,6 @@ class _MembersViewState extends ConsumerState<MembersView> with TickerProviderSt
// ( )
String? _previousPath;
bool _animationStarted = false;
@override
void initState() {
@ -49,6 +48,7 @@ class _MembersViewState extends ConsumerState<MembersView> with TickerProviderSt
duration: const Duration(milliseconds: 800),
);
_setupAnimations();
_loadData();
}
///
@ -111,9 +111,7 @@ class _MembersViewState extends ConsumerState<MembersView> with TickerProviderSt
if (!_indicatorScrollController.hasClients) return;
final screenWidth = MediaQuery.of(context).size.width;
// : (16) + index * (64) + (26)
const itemRadius = 26.0; // 52 / 2
final targetOffset = (index * _indicatorItemWidth) + 16 + itemRadius - (screenWidth / 2);
final targetOffset = (index * _indicatorItemWidth) - (screenWidth / 2) + (_indicatorItemWidth / 2) + 16;
final maxOffset = _indicatorScrollController.position.maxScrollExtent;
_indicatorScrollController.animateTo(
@ -123,6 +121,46 @@ class _MembersViewState extends ConsumerState<MembersView> with TickerProviderSt
);
}
Future<void> _loadData() async {
try {
final members = await getMembers();
// ,
members.sort((a, b) {
if (a.isFormer != b.isFormer) {
return a.isFormer ? 1 : -1;
}
return 0;
});
setState(() {
_members = members;
_isLoading = false;
});
_animController.forward();
} catch (e) {
setState(() => _isLoading = false);
}
}
///
int? _calculateAge(String? birthDate) {
if (birthDate == null) return null;
final birth = DateTime.tryParse(birthDate);
if (birth == null) return null;
final today = DateTime.now();
int age = today.year - birth.year;
if (today.month < birth.month ||
(today.month == birth.month && today.day < birth.day)) {
age--;
}
return age;
}
///
String _formatBirthDate(String? birthDate) {
if (birthDate == null) return '';
return birthDate.substring(0, 10).replaceAll('-', '.');
}
/// ( , )
Future<void> _openInstagram(String? url) async {
@ -153,24 +191,13 @@ class _MembersViewState extends ConsumerState<MembersView> with TickerProviderSt
@override
Widget build(BuildContext context) {
final membersState = ref.watch(membersProvider);
final controller = ref.read(membersProvider.notifier);
//
if (!membersState.isLoading && !_animationStarted && membersState.members.isNotEmpty) {
_animationStarted = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
_animController.forward();
});
}
if (membersState.isLoading) {
if (_isLoading) {
return const Center(
child: CircularProgressIndicator(color: AppColors.primary),
);
}
if (membersState.members.isEmpty) {
if (_members.isEmpty) {
return const Center(
child: Text('멤버 정보가 없습니다', style: TextStyle(color: AppColors.textSecondary)),
);
@ -186,7 +213,7 @@ class _MembersViewState extends ConsumerState<MembersView> with TickerProviderSt
offset: Offset(0, _indicatorSlide.value),
child: Opacity(
opacity: _indicatorOpacity.value,
child: _buildThumbnailIndicator(membersState),
child: _buildThumbnailIndicator(),
),
),
@ -198,10 +225,10 @@ class _MembersViewState extends ConsumerState<MembersView> with TickerProviderSt
opacity: _cardOpacity.value,
child: PageView.builder(
controller: _pageController,
itemCount: membersState.members.length,
itemCount: _members.length,
padEnds: true,
onPageChanged: (index) {
controller.setCurrentIndex(index);
setState(() => _currentIndex = index);
HapticFeedback.selectionClick();
_scrollIndicatorToIndex(index);
},
@ -216,7 +243,7 @@ class _MembersViewState extends ConsumerState<MembersView> with TickerProviderSt
}
return Transform.scale(
scale: Curves.easeOut.transform(value),
child: _buildMemberCard(membersState.members[index], controller),
child: _buildMemberCard(_members[index], index),
);
},
);
@ -232,9 +259,9 @@ class _MembersViewState extends ConsumerState<MembersView> with TickerProviderSt
}
///
Widget _buildMemberCard(Member member, MembersController controller) {
Widget _buildMemberCard(Member member, int index) {
final isFormer = member.isFormer;
final age = controller.calculateAge(member.birthDate);
final age = _calculateAge(member.birthDate);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
@ -263,7 +290,7 @@ class _MembersViewState extends ConsumerState<MembersView> with TickerProviderSt
child: CachedNetworkImage(
imageUrl: member.imageUrl!,
fit: BoxFit.cover,
placeholder: (context, url) => Container(
placeholder: (_, _) => Container(
color: Colors.grey[200],
child: const Center(
child: CircularProgressIndicator(color: AppColors.primary),
@ -372,7 +399,7 @@ class _MembersViewState extends ConsumerState<MembersView> with TickerProviderSt
_buildIcon('calendar', 16, Colors.white70),
const SizedBox(width: 6),
Text(
controller.formatBirthDate(member.birthDate),
_formatBirthDate(member.birthDate),
style: TextStyle(
fontSize: 14,
color: Colors.white.withValues(alpha: 0.8),
@ -448,7 +475,7 @@ class _MembersViewState extends ConsumerState<MembersView> with TickerProviderSt
}
///
Widget _buildThumbnailIndicator(MembersState membersState) {
Widget _buildThumbnailIndicator() {
return Container(
height: 88,
decoration: BoxDecoration(
@ -465,10 +492,10 @@ class _MembersViewState extends ConsumerState<MembersView> with TickerProviderSt
controller: _indicatorScrollController,
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
itemCount: membersState.members.length,
itemCount: _members.length,
itemBuilder: (context, index) {
final member = membersState.members[index];
final isSelected = index == membersState.currentIndex;
final member = _members[index];
final isSelected = index == _currentIndex;
final isFormer = member.isFormer;
return GestureDetector(
@ -518,7 +545,7 @@ class _MembersViewState extends ConsumerState<MembersView> with TickerProviderSt
fit: BoxFit.cover,
width: 48,
height: 48,
placeholder: (context, url) => Container(color: Colors.grey[300]),
placeholder: (_, _) => Container(color: Colors.grey[300]),
)
: Container(
width: 48,

File diff suppressed because it is too large Load diff

View file

@ -1,76 +0,0 @@
///
library;
import 'package:flutter/material.dart';
import '../../../core/constants.dart';
/// ( )
class MemberChip extends StatelessWidget {
final String name;
const MemberChip({super.key, required this.name});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [AppColors.primary, AppColors.primaryDark],
),
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: AppColors.primary.withValues(alpha: 0.3),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Text(
name,
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
);
}
}
/// ( )
class SearchMemberChip extends StatelessWidget {
final String name;
const SearchMemberChip({super.key, required this.name});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [AppColors.primary, AppColors.primaryDark],
),
borderRadius: BorderRadius.circular(6),
boxShadow: [
BoxShadow(
color: AppColors.primary.withValues(alpha: 0.3),
blurRadius: 3,
offset: const Offset(0, 1),
),
],
),
child: Text(
name,
style: const TextStyle(
fontFamily: 'Pretendard',
fontSize: 10,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
);
}
}

View file

@ -1,253 +0,0 @@
///
library;
import 'package:flutter/material.dart';
import '../../../core/constants.dart';
import '../../../models/schedule.dart';
import 'member_chip.dart';
/// HTML
String decodeHtmlEntities(String text) {
return text
.replaceAll('&amp;', '&')
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&quot;', '"')
.replaceAll('&#39;', "'")
.replaceAll('&nbsp;', ' ');
}
///
Color parseColor(String? colorStr) {
if (colorStr == null || colorStr.isEmpty) return AppColors.textTertiary;
try {
final hex = colorStr.replaceFirst('#', '');
return Color(int.parse('FF$hex', radix: 16));
} catch (_) {
return AppColors.textTertiary;
}
}
///
class AnimatedScheduleCard extends StatefulWidget {
final int index;
final Schedule schedule;
final Color categoryColor;
const AnimatedScheduleCard({
super.key,
required this.index,
required this.schedule,
required this.categoryColor,
});
@override
State<AnimatedScheduleCard> createState() => _AnimatedScheduleCardState();
}
class _AnimatedScheduleCardState extends State<AnimatedScheduleCard>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _fadeAnimation;
late Animation<double> _slideAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 300),
vsync: this,
);
_fadeAnimation = Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeOut),
);
_slideAnimation = Tween<double>(begin: -10.0, end: 0.0).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic),
);
// (index * 30ms )
Future.delayed(Duration(milliseconds: widget.index * 30), () {
if (mounted) _controller.forward();
});
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return Opacity(
opacity: _fadeAnimation.value,
child: Transform.translate(
offset: Offset(_slideAnimation.value, 0),
child: child,
),
);
},
child: ScheduleCard(
schedule: widget.schedule,
categoryColor: widget.categoryColor,
),
);
}
}
///
class ScheduleCard extends StatelessWidget {
final Schedule schedule;
final Color categoryColor;
const ScheduleCard({
super.key,
required this.schedule,
required this.categoryColor,
});
@override
Widget build(BuildContext context) {
final memberList = schedule.memberList;
return Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.04),
blurRadius: 12,
offset: const Offset(0, 2),
),
],
border: Border.all(
color: AppColors.border.withValues(alpha: 0.5),
width: 1,
),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
//
Row(
children: [
//
if (schedule.formattedTime != null)
Container(
padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: categoryColor,
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.access_time,
size: 10,
color: Colors.white,
),
const SizedBox(width: 4),
Text(
schedule.formattedTime!,
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: Colors.white,
),
),
],
),
),
if (schedule.formattedTime != null) const SizedBox(width: 6),
//
if (schedule.categoryName != null)
Container(
padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: categoryColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Text(
schedule.categoryName!,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: categoryColor,
),
),
),
],
),
const SizedBox(height: 10),
//
Text(
decodeHtmlEntities(schedule.title),
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
color: AppColors.textPrimary,
height: 1.4,
),
),
//
if (schedule.sourceName != null &&
schedule.sourceName!.isNotEmpty) ...[
const SizedBox(height: 6),
Row(
children: [
Icon(
Icons.link,
size: 11,
color: AppColors.textTertiary,
),
const SizedBox(width: 4),
Text(
schedule.sourceName!,
style: const TextStyle(
fontSize: 11,
color: AppColors.textTertiary,
),
),
],
),
],
//
if (memberList.isNotEmpty) ...[
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.only(top: 12),
decoration: const BoxDecoration(
border: Border(
top: BorderSide(color: AppColors.divider, width: 1),
),
),
child: Wrap(
spacing: 6,
runSpacing: 6,
children: memberList.length >= 5
? [
const MemberChip(name: '프로미스나인'),
]
: memberList
.map((name) => MemberChip(name: name))
.toList(),
),
),
],
],
),
),
);
}
}

View file

@ -1,257 +0,0 @@
///
library;
import 'package:flutter/material.dart';
import '../../../core/constants.dart';
import '../../../models/schedule.dart';
import 'member_chip.dart';
import 'schedule_card.dart' show decodeHtmlEntities;
/// ( - , )
class SearchScheduleCard extends StatelessWidget {
final Schedule schedule;
final Color categoryColor;
const SearchScheduleCard({
super.key,
required this.schedule,
required this.categoryColor,
});
///
Map<String, dynamic>? _parseDate(String? dateStr) {
if (dateStr == null) return null;
try {
final date = DateTime.parse(dateStr);
const weekdays = ['', '', '', '', '', '', ''];
return {
'year': date.year,
'month': date.month,
'day': date.day,
'weekday': weekdays[date.weekday % 7],
'isSunday': date.weekday == 7,
'isSaturday': date.weekday == 6,
};
} catch (_) {
return null;
}
}
@override
Widget build(BuildContext context) {
final memberList = schedule.memberList;
final dateInfo = _parseDate(schedule.date);
return Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.04),
blurRadius: 12,
offset: const Offset(0, 2),
),
],
border: Border.all(
color: AppColors.border.withValues(alpha: 0.5),
width: 1,
),
),
child: IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// ( )
if (dateInfo != null)
Container(
width: 72,
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 6),
decoration: const BoxDecoration(
color: AppColors.background,
borderRadius: BorderRadius.only(
topLeft: Radius.circular(7),
bottomLeft: Radius.circular(7),
),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
//
Text(
'${dateInfo['year']}',
style: const TextStyle(
fontFamily: 'Pretendard',
fontSize: 10,
color: AppColors.textTertiary,
),
),
// . ( )
FittedBox(
fit: BoxFit.scaleDown,
child: Text(
'${dateInfo['month']}.${dateInfo['day']}',
style: const TextStyle(
fontFamily: 'Pretendard',
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppColors.textPrimary,
),
),
),
//
Text(
'${dateInfo['weekday']}요일',
style: TextStyle(
fontFamily: 'Pretendard',
fontSize: 11,
fontWeight: FontWeight.w500,
color: dateInfo['isSunday'] == true
? Colors.red.shade500
: dateInfo['isSaturday'] == true
? Colors.blue.shade500
: AppColors.textSecondary,
),
),
],
),
),
//
Expanded(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
//
Row(
children: [
//
if (schedule.formattedTime != null)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
decoration: BoxDecoration(
color: categoryColor,
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.access_time,
size: 10,
color: Colors.white,
),
const SizedBox(width: 4),
Text(
schedule.formattedTime!,
style: const TextStyle(
fontFamily: 'Pretendard',
fontSize: 10,
fontWeight: FontWeight.w500,
color: Colors.white,
),
),
],
),
),
if (schedule.formattedTime != null)
const SizedBox(width: 6),
//
if (schedule.categoryName != null)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
decoration: BoxDecoration(
color: categoryColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Text(
schedule.categoryName!,
style: TextStyle(
fontFamily: 'Pretendard',
fontSize: 10,
fontWeight: FontWeight.w500,
color: categoryColor,
),
),
),
],
),
const SizedBox(height: 8),
//
Text(
decodeHtmlEntities(schedule.title),
style: const TextStyle(
fontFamily: 'Pretendard',
fontSize: 14,
fontWeight: FontWeight.bold,
color: AppColors.textPrimary,
height: 1.4,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
// ( )
if (schedule.sourceName != null &&
schedule.sourceName!.isNotEmpty) ...[
const SizedBox(height: 6),
Row(
children: [
const Icon(
Icons.link,
size: 12,
color: AppColors.textTertiary,
),
const SizedBox(width: 4),
Expanded(
child: Text(
schedule.sourceName!,
style: const TextStyle(
fontFamily: 'Pretendard',
fontSize: 12,
color: AppColors.textTertiary,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
],
//
if (memberList.isNotEmpty) ...[
const SizedBox(height: 10),
// divider ( )
Container(
width: double.infinity,
height: 1,
color: AppColors.divider,
),
const SizedBox(height: 10),
Wrap(
spacing: 4,
runSpacing: 4,
children: memberList.length >= 5
? [
const SearchMemberChip(name: '프로미스나인'),
]
: memberList
.map((name) => SearchMemberChip(name: name))
.toList(),
),
],
],
),
),
),
],
),
),
);
}
}

View file

@ -169,14 +169,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.1"
expandable_page_view:
dependency: "direct main"
description:
name: expandable_page_view
sha256: "2d2c9e6fbbaa153f761054200c199eb69dc45948c8018b98a871212c67b60608"
url: "https://pub.dev"
source: hosted
version: "1.2.0"
fake_async:
dependency: transitive
description:
@ -989,6 +981,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.4.0"
video_thumbnail:
dependency: "direct main"
description:
name: video_thumbnail
sha256: "181a0c205b353918954a881f53a3441476b9e301641688a581e0c13f00dc588b"
url: "https://pub.dev"
source: hosted
version: "0.5.6"
vm_service:
dependency: transitive
description:

View file

@ -47,9 +47,9 @@ dependencies:
flutter_downloader: ^1.11.8
permission_handler: ^11.3.1
modal_bottom_sheet: ^3.0.0
video_thumbnail: ^0.5.3
video_player: ^2.9.2
chewie: ^1.8.5
expandable_page_view: ^1.0.17
dev_dependencies:
flutter_test:

View file

@ -9,12 +9,6 @@ const inko = new Inko();
const SUGGESTION_PREFIX = "suggestions:";
const CACHE_TTL = 86400; // 24시간
// 추천 검색어로 노출되기 위한 최소 비율 (최대 검색 횟수 대비)
// 예: 0.01 = 최대 검색 횟수의 1% 이상만 노출
const MIN_COUNT_RATIO = 0.01;
// 최소 임계값 (데이터가 적을 때 오타 방지)
const MIN_COUNT_FLOOR = 10;
/**
* 영문만 포함된 검색어인지 확인
*/
@ -172,21 +166,38 @@ export async function getSuggestions(query, limit = 10) {
/**
* 다음 단어 예측 (Bi-gram 기반)
* 동적 임계값을 사용하므로 Redis 캐시를 사용하지 않음
*/
async function getNextWordSuggestions(lastWord, prefix, limit) {
try {
const [rows] = await pool.query(
`SELECT word2, count FROM word_pairs
WHERE word1 = ?
AND count >= GREATEST((SELECT MAX(count) * ? FROM word_pairs), ?)
ORDER BY count DESC
LIMIT ?`,
[lastWord, MIN_COUNT_RATIO, MIN_COUNT_FLOOR, limit]
);
// 1. Redis 캐시 확인
const cacheKey = `${SUGGESTION_PREFIX}${lastWord}`;
let nextWords = await redis.zrevrange(cacheKey, 0, limit - 1);
// prefix + 다음 단어 조합으로 반환
return rows.map((r) => `${prefix} ${r.word2}`);
// 2. 캐시 미스 시 DB 조회 후 Redis 채우기
if (nextWords.length === 0) {
const [rows] = await pool.query(
`SELECT word2, count FROM word_pairs
WHERE word1 = ?
ORDER BY count DESC
LIMIT ?`,
[lastWord, limit * 2] // 여유있게 가져오기
);
if (rows.length > 0) {
// Redis에 캐싱
const multi = redis.multi();
for (const row of rows) {
multi.zadd(cacheKey, row.count, row.word2);
}
multi.expire(cacheKey, CACHE_TTL);
await multi.exec();
nextWords = rows.map((r) => r.word2);
}
}
// 3. prefix + 다음 단어 조합으로 반환
return nextWords.slice(0, limit).map((word) => `${prefix} ${word}`);
} catch (error) {
console.error("[SearchSuggestion] Bi-gram 조회 오류:", error.message);
return [];
@ -207,21 +218,19 @@ async function getPrefixSuggestions(prefix, koreanPrefix, limit) {
// 영어 원본과 한글 변환 둘 다 검색
[rows] = await pool.query(
`SELECT query FROM search_queries
WHERE (query LIKE ? OR query LIKE ?)
AND count >= GREATEST((SELECT MAX(count) * ? FROM search_queries), ?)
WHERE query LIKE ? OR query LIKE ?
ORDER BY count DESC, last_searched_at DESC
LIMIT ?`,
[`${prefix}%`, `${koreanPrefix}%`, MIN_COUNT_RATIO, MIN_COUNT_FLOOR, limit]
[`${prefix}%`, `${koreanPrefix}%`, limit]
);
} else {
// 단일 검색
[rows] = await pool.query(
`SELECT query FROM search_queries
WHERE query LIKE ?
AND count >= GREATEST((SELECT MAX(count) * ? FROM search_queries), ?)
ORDER BY count DESC, last_searched_at DESC
LIMIT ?`,
[`${prefix}%`, MIN_COUNT_RATIO, MIN_COUNT_FLOOR, limit]
[`${prefix}%`, limit]
);
}

View file

@ -91,7 +91,7 @@ function App() {
<MobileView>
<Routes>
<Route path="/" element={<MobileLayout><MobileHome /></MobileLayout>} />
<Route path="/members" element={<MobileLayout pageTitle="멤버" noShadow><MobileMembers /></MobileLayout>} />
<Route path="/members" element={<MobileLayout pageTitle="멤버"><MobileMembers /></MobileLayout>} />
<Route path="/album" element={<MobileLayout pageTitle="앨범"><MobileAlbum /></MobileLayout>} />
<Route path="/album/:name" element={<MobileLayout pageTitle="앨범"><MobileAlbumDetail /></MobileLayout>} />
<Route path="/album/:name/gallery" element={<MobileLayout pageTitle="앨범"><MobileAlbumGallery /></MobileLayout>} />

View file

@ -4,9 +4,9 @@ import { useEffect } from 'react';
import '../../mobile.css';
//
function MobileHeader({ title, noShadow = false }) {
function MobileHeader({ title }) {
return (
<header className={`bg-white sticky top-0 z-50 ${noShadow ? '' : 'shadow-sm'}`}>
<header className="bg-white shadow-sm sticky top-0 z-50">
<div className="flex items-center justify-center h-14 px-4">
{title ? (
<span className="text-xl font-bold text-primary">{title}</span>
@ -62,7 +62,7 @@ function MobileBottomNav() {
// pageTitle: ( fromis_9)
// hideHeader: true ( )
// useCustomLayout: true (mobile-layout-container )
function MobileLayout({ children, pageTitle, hideHeader = false, useCustomLayout = false, noShadow = false }) {
function MobileLayout({ children, pageTitle, hideHeader = false, useCustomLayout = false }) {
// (body )
useEffect(() => {
document.documentElement.classList.add('mobile-layout');
@ -83,7 +83,7 @@ function MobileLayout({ children, pageTitle, hideHeader = false, useCustomLayout
return (
<div className="mobile-layout-container bg-white">
{!hideHeader && <MobileHeader title={pageTitle} noShadow={noShadow} />}
{!hideHeader && <MobileHeader title={pageTitle} />}
<main className="mobile-content">{children}</main>
<MobileBottomNav />
</div>

View file

@ -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

View file

@ -1,16 +1,12 @@
import { motion } from 'framer-motion';
import { useState, useMemo, useRef, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { useState, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Instagram, Calendar } from 'lucide-react';
import { Swiper, SwiperSlide } from 'swiper/react';
import 'swiper/css';
import { Instagram, Calendar, X } from 'lucide-react';
import { getMembers } from '../../../api/public/members';
// -
//
function MobileMembers() {
const [currentIndex, setCurrentIndex] = useState(0);
const swiperRef = useRef(null);
const indicatorRef = useRef(null);
const [selectedMember, setSelectedMember] = useState(null);
// useQuery
const { data: allMembers = [] } = useQuery({
@ -18,15 +14,10 @@ function MobileMembers() {
queryFn: getMembers,
});
// useMemo / ( , )
const members = useMemo(() => {
return [...allMembers].sort((a, b) => {
if (a.is_former !== b.is_former) {
return a.is_former ? 1 : -1;
}
return 0;
});
}, [allMembers]);
// useMemo /
const members = useMemo(() => allMembers.filter(m => !m.is_former), [allMembers]);
const formerMembers = useMemo(() => allMembers.filter(m => m.is_former), [allMembers]);
//
const calculateAge = (birthDate) => {
@ -41,188 +32,176 @@ function MobileMembers() {
return age;
};
//
useEffect(() => {
if (indicatorRef.current && members.length > 0) {
const container = indicatorRef.current;
const itemWidth = 64; // 52px + 12px
const containerWidth = container.offsetWidth;
const paddingLeft = 16; // px-4
const targetScroll = paddingLeft + (currentIndex * itemWidth) + 26 - (containerWidth / 2);
//
const closeModal = () => setSelectedMember(null);
container.scrollTo({
left: Math.max(0, targetScroll),
behavior: 'smooth'
});
}
}, [currentIndex, members.length]);
//
const renderMemberCard = (member, index, isFormer = false) => (
<motion.div
key={member.id}
onClick={() => setSelectedMember(member)}
className="cursor-pointer group"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.05, duration: 0.3 }}
whileTap={{ scale: 0.95 }}
>
{/* 카드 컨테이너 */}
<div className={`relative overflow-hidden rounded-2xl bg-white shadow-sm
transition-shadow duration-300 group-hover:shadow-lg
${isFormer ? 'grayscale' : ''}`}
>
{/* 이미지 영역 - 3:4 비율 */}
<div className="aspect-[3/4] bg-gradient-to-br from-gray-100 to-gray-200 overflow-hidden">
{member.image_url && (
<img
src={member.image_url}
alt={member.name}
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
/>
)}
</div>
//
const handleIndicatorClick = (index) => {
if (swiperRef.current) {
swiperRef.current.slideTo(index);
}
};
{/* 정보 영역 - 하단 그라데이션 오버레이 */}
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/70 via-black/30 to-transparent p-3 pt-10">
<p className="font-bold text-white text-sm drop-shadow-md">
{member.name}
</p>
</div>
if (members.length === 0) {
return (
<div className="flex items-center justify-center h-[calc(100vh-200px)]">
<p className="text-gray-400">멤버 정보가 없습니다</p>
{/* 호버시 반짝이 효과 */}
{!isFormer && (
<div className="absolute inset-0 bg-gradient-to-tr from-primary/0 via-white/0 to-white/20
opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none" />
)}
</div>
);
}
</motion.div>
);
return (
<div className="flex flex-col h-[calc(100dvh-120px)] overflow-hidden overscroll-none touch-none">
{/* 상단 썸네일 인디케이터 */}
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, ease: 'easeOut' }}
className="bg-white shadow-sm"
>
<div
ref={indicatorRef}
className="flex gap-3 px-4 py-4 overflow-x-auto scrollbar-hide"
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
>
{members.map((member, index) => {
const isSelected = index === currentIndex;
const isFormer = member.is_former;
return (
<button
key={member.id}
onClick={() => handleIndicatorClick(index)}
className={`flex-shrink-0 w-[52px] h-[52px] rounded-full p-[2px] transition-all duration-200
${isSelected
? 'ring-[2.5px] ring-primary shadow-[0_0_8px_rgba(var(--primary-rgb),0.35)]'
: 'ring-[1.5px] ring-gray-300'
}`}
>
<div className={`w-full h-full rounded-full overflow-hidden bg-gray-200
${isFormer ? 'grayscale' : ''}`}
>
{member.image_url ? (
<img
src={member.image_url}
alt={member.name}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center bg-gray-300 text-white font-bold">
{member.name[0]}
</div>
)}
</div>
</button>
);
})}
<div className="pb-4">
{/* 현재 멤버 그리드 */}
<div className="px-4 pt-4">
<div className="grid grid-cols-3 gap-3">
{members.map((member, index) => renderMemberCard(member, index))}
</div>
</motion.div>
</div>
{/* 메인 카드 영역 */}
<motion.div
initial={{ opacity: 0, y: 40 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.2, ease: 'easeOut' }}
className="flex-1 overflow-visible"
>
<Swiper
onSwiper={(swiper) => { swiperRef.current = swiper; }}
onSlideChange={(swiper) => setCurrentIndex(swiper.activeIndex)}
slidesPerView={1.12}
centeredSlides={true}
spaceBetween={0}
className="h-full !overflow-visible [&>.swiper-wrapper]:!overflow-visible"
style={{ padding: '8px 0' }}
>
{members.map((member, index) => {
const isFormer = member.is_former;
const age = calculateAge(member.birth_date);
{/* 전 멤버 */}
{formerMembers.length > 0 && (
<div className="px-4 mt-8">
<div className="flex items-center gap-2 mb-4">
<div className="h-px flex-1 bg-gray-200" />
<h2 className="text-sm font-medium text-gray-400 px-2"> 멤버</h2>
<div className="h-px flex-1 bg-gray-200" />
</div>
<div className="grid grid-cols-3 gap-3">
{formerMembers.map((member, index) => renderMemberCard(member, index, true))}
</div>
</div>
)}
return (
<SwiperSlide key={member.id} className="!flex items-center justify-center">
{({ isActive }) => (
<div
className={`relative w-full h-full max-h-[calc(100%-16px)] rounded-3xl overflow-hidden shadow-xl
transition-transform duration-300
${isActive ? 'scale-100' : 'scale-[0.92]'}`}
>
{/* 배경 이미지 */}
{member.image_url ? (
<img
src={member.image_url}
alt={member.name}
className={`absolute inset-0 w-full h-full object-cover
${isFormer ? 'grayscale' : ''}`}
/>
) : (
<div className="absolute inset-0 bg-gradient-to-br from-gray-300 to-gray-400" />
)}
{/* 멤버 상세 모달 - 드래그로 닫기 가능 */}
<AnimatePresence>
{selectedMember && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-[100] flex items-end"
onClick={closeModal}
>
<motion.div
initial={{ y: '100%' }}
animate={{ y: 0 }}
exit={{ y: '100%' }}
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
drag="y"
dragConstraints={{ top: 0, bottom: 0 }}
dragElastic={{ top: 0, bottom: 0.5 }}
onDragEnd={(_, info) => {
if (info.offset.y > 100 || info.velocity.y > 300) {
closeModal();
}
}}
className="bg-white w-full rounded-t-3xl overflow-hidden"
onClick={e => e.stopPropagation()}
>
{/* 드래그 핸들 */}
<div className="flex justify-center pt-3 pb-2 cursor-grab active:cursor-grabbing">
<div className="w-10 h-1 bg-gray-300 rounded-full" />
</div>
{/* 하단 그라데이션 오버레이 */}
<div className="absolute inset-x-0 bottom-0 h-[220px] bg-gradient-to-t from-black/80 via-black/30 to-transparent" />
{/* 헤더 */}
<div className="flex items-center justify-between px-5 pb-3 border-b border-gray-100">
<h3 className="text-lg font-bold">멤버 정보</h3>
<button onClick={closeModal} className="p-1.5">
<X size={20} className="text-gray-500" />
</button>
</div>
{/* 전 멤버 라벨 */}
{isFormer && (
<div className="absolute top-4 right-4 px-3 py-1.5 bg-black/60 rounded-full">
<span className="text-white/70 text-xs font-medium"> 멤버</span>
</div>
)}
{/* 멤버 정보 */}
<div className="absolute inset-x-0 bottom-0 p-6">
{/* 이름 */}
<h2 className="text-[32px] font-bold text-white drop-shadow-lg">
{member.name}
</h2>
{/* 포지션 */}
{member.position && (
<p className="mt-2 text-base text-white/90 font-medium">
{member.position}
</p>
)}
{/* 생일 정보 */}
{member.birth_date && (
<div className="flex items-center gap-1.5 mt-3 text-white/80">
<Calendar size={16} className="text-white/70" />
<span className="text-sm">
{member.birth_date?.slice(0, 10).replaceAll('-', '.')}
</span>
{age && (
<span className="ml-2 px-2 py-0.5 bg-white/20 rounded-lg text-xs text-white font-medium">
{age}
</span>
)}
</div>
)}
{/* 인스타그램 버튼 */}
{!isFormer && member.instagram && (
<a
href={member.instagram}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 mt-4 px-4 py-2.5
bg-gradient-to-r from-[#833AB4] via-[#E1306C] to-[#F77737]
rounded-full shadow-lg shadow-[#E1306C]/40
active:scale-95 transition-transform"
>
<Instagram size={18} className="text-white" />
<span className="text-white text-sm font-semibold">Instagram</span>
</a>
{/* 모달 콘텐츠 */}
<div className="px-5 py-4 pb-5">
<div className="flex gap-4">
{/* 프로필 이미지 - 원본 비율 */}
<div className={`w-28 flex-shrink-0 ${selectedMember.is_former ? 'grayscale' : ''}`}>
<div className="aspect-[3/4] rounded-2xl overflow-hidden bg-gradient-to-br from-gray-100 to-gray-200 shadow-lg">
{selectedMember.image_url && (
<img
src={selectedMember.image_url}
alt={selectedMember.name}
className="w-full h-full object-cover"
/>
)}
</div>
</div>
)}
</SwiperSlide>
);
})}
</Swiper>
</motion.div>
{/* 정보 */}
<div className="flex-1 flex flex-col justify-between py-1">
<div>
<h2 className="text-2xl font-bold text-gray-900">{selectedMember.name}</h2>
{selectedMember.position && (
<p className="text-gray-500 text-sm mt-1">{selectedMember.position}</p>
)}
{selectedMember.birth_date && (
<div className="flex items-center gap-1.5 mt-2 text-gray-400 text-sm">
<Calendar size={14} />
<span>
{selectedMember.birth_date?.slice(0, 10).replaceAll('-', '.')}
{calculateAge(selectedMember.birth_date) && (
<span className="ml-1 text-gray-300">
({calculateAge(selectedMember.birth_date)})
</span>
)}
</span>
</div>
)}
</div>
{/* 인스타그램 버튼 - 정보 영역 아래쪽 */}
{!selectedMember.is_former && selectedMember.instagram && (
<a
href={selectedMember.instagram}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 mt-3 px-4 py-2
bg-gradient-to-r from-purple-500 via-pink-500 to-orange-500
text-white text-sm rounded-full font-medium shadow-sm
hover:shadow-md transition-shadow w-fit"
>
<Instagram size={14} />
<span>Instagram</span>
</a>
)}
</div>
</div>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}

View file

@ -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