Compare commits
14 commits
255839a598
...
02fe9314e4
| Author | SHA1 | Date | |
|---|---|---|---|
| 02fe9314e4 | |||
| 88f15a3ec1 | |||
| fbe18b6157 | |||
| 36fb7bb310 | |||
| c4cbdc7d33 | |||
| 895d9c26a3 | |||
| 21bd887f5e | |||
| 671618442c | |||
| bc37abe473 | |||
| 221aaa2bb4 | |||
| 7a1e04b6ae | |||
| 3b5f8a93ca | |||
| 6b512f943e | |||
| 300fe18a8d |
29 changed files with 4113 additions and 678 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>
|
||||
|
|
|
|||
73
app/lib/controllers/album_controller.dart
Normal file
73
app/lib/controllers/album_controller.dart
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
/// 앨범 컨트롤러 (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,
|
||||
);
|
||||
93
app/lib/controllers/home_controller.dart
Normal file
93
app/lib/controllers/home_controller.dart
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
/// 홈 컨트롤러 (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,
|
||||
);
|
||||
111
app/lib/controllers/members_controller.dart
Normal file
111
app/lib/controllers/members_controller.dart
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
/// 멤버 컨트롤러 (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,
|
||||
);
|
||||
365
app/lib/controllers/schedule_controller.dart
Normal file
365
app/lib/controllers/schedule_controller.dart
Normal file
|
|
@ -0,0 +1,365 @@
|
|||
/// 일정 컨트롤러 (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,
|
||||
);
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
/// 트랙 상세 모델 (앨범 정보 포함)
|
||||
|
|
|
|||
|
|
@ -14,7 +14,32 @@ 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) {
|
||||
|
|
@ -24,21 +49,20 @@ Future<String?> downloadImage(String url, {String? fileName}) async {
|
|||
}
|
||||
}
|
||||
|
||||
// 다운로드 경로 설정
|
||||
Directory? directory;
|
||||
// 다운로드 경로 설정 (Pictures 폴더)
|
||||
final Directory directory;
|
||||
if (Platform.isAndroid) {
|
||||
directory = Directory('/storage/emulated/0/Download');
|
||||
directory = Directory('/storage/emulated/0/Pictures/fromis_9');
|
||||
if (!await directory.exists()) {
|
||||
directory = await getExternalStorageDirectory();
|
||||
await directory.create(recursive: true);
|
||||
}
|
||||
} else {
|
||||
directory = await getApplicationDocumentsDirectory();
|
||||
}
|
||||
|
||||
if (directory == null) return null;
|
||||
|
||||
// 파일명 생성
|
||||
final name = fileName ?? 'fromis9_${DateTime.now().millisecondsSinceEpoch}.jpg';
|
||||
// 파일명 생성 (URL에서 확장자 추출)
|
||||
final extension = _getExtensionFromUrl(url);
|
||||
final name = fileName ?? 'fromis9_${DateTime.now().millisecondsSinceEpoch}$extension';
|
||||
|
||||
// 다운로드 시작
|
||||
final taskId = await FlutterDownloader.enqueue(
|
||||
|
|
@ -47,6 +71,7 @@ Future<String?> downloadImage(String url, {String? fileName}) async {
|
|||
fileName: name,
|
||||
showNotification: true,
|
||||
openFileFromNotification: true,
|
||||
saveInPublicStorage: true, // 갤러리에 표시
|
||||
);
|
||||
|
||||
return taskId;
|
||||
|
|
|
|||
|
|
@ -31,3 +31,51 @@ 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 [];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
/// 앨범 상세 화면
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
import 'dart:ui';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
|
@ -13,8 +12,6 @@ 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';
|
||||
|
|
@ -132,6 +129,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 +148,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 +407,7 @@ class _MetaItem extends StatelessWidget {
|
|||
}
|
||||
}
|
||||
|
||||
/// 티저 포토 섹션
|
||||
/// 티저 이미지 섹션
|
||||
class _TeaserSection extends StatelessWidget {
|
||||
final List<Teaser> teasers;
|
||||
|
||||
|
|
@ -430,7 +428,7 @@ class _TeaserSection extends StatelessWidget {
|
|||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, 16, 16, 12),
|
||||
child: Text(
|
||||
'티저 포토',
|
||||
'티저 이미지',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
|
|
@ -480,70 +478,16 @@ class _TeaserSection extends StatelessWidget {
|
|||
}
|
||||
}
|
||||
|
||||
/// 티저 썸네일 (동영상의 경우 video_thumbnail으로 1초 프레임 추출)
|
||||
class _TeaserThumbnail extends StatefulWidget {
|
||||
/// 티저 썸네일
|
||||
class _TeaserThumbnail extends StatelessWidget {
|
||||
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,
|
||||
|
|
@ -557,8 +501,16 @@ class _TeaserThumbnailState extends State<_TeaserThumbnail> {
|
|||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
// 이미지 또는 동영상 썸네일
|
||||
_buildThumbnail(),
|
||||
// 썸네일 이미지
|
||||
if (imageUrl != null)
|
||||
CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (context, url) => Container(color: AppColors.divider),
|
||||
errorWidget: (context, url, error) => _buildPlaceholder(),
|
||||
)
|
||||
else
|
||||
_buildPlaceholder(),
|
||||
// 동영상 재생 버튼 오버레이
|
||||
if (isVideo)
|
||||
Container(
|
||||
|
|
@ -581,54 +533,6 @@ class _TeaserThumbnailState extends State<_TeaserThumbnail> {
|
|||
);
|
||||
}
|
||||
|
||||
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,
|
||||
|
|
@ -880,7 +784,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),
|
||||
|
|
@ -944,17 +848,12 @@ 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
|
||||
|
|
@ -963,33 +862,14 @@ class _TeaserViewerState extends State<_TeaserViewer> {
|
|||
super.dispose();
|
||||
}
|
||||
|
||||
/// 주변 이미지 프리로드 (좌우 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 이미지 다운로드 (시스템 다운로드 매니저 사용)
|
||||
Future<void> _downloadImage() async {
|
||||
/// 다운로드 (이미지 또는 동영상)
|
||||
Future<void> _download() async {
|
||||
final teaser = widget.teasers[_currentIndex];
|
||||
if (teaser.mediaType == 'video') return; // 동영상은 다운로드 안함
|
||||
final imageUrl = teaser.originalUrl;
|
||||
if (imageUrl == null || imageUrl.isEmpty) return;
|
||||
final isVideo = teaser.mediaType == 'video';
|
||||
final url = isVideo ? (teaser.videoUrl ?? teaser.originalUrl) : teaser.originalUrl;
|
||||
if (url == null || url.isEmpty) return;
|
||||
|
||||
final taskId = await downloadImage(imageUrl);
|
||||
final taskId = await downloadImage(url);
|
||||
if (taskId != null && mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
|
|
@ -1004,8 +884,6 @@ 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,
|
||||
|
|
@ -1017,9 +895,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) {
|
||||
|
|
@ -1095,23 +973,21 @@ class _TeaserViewerState extends State<_TeaserViewer> {
|
|||
fontFeatures: [FontFeature.tabularFigures()],
|
||||
),
|
||||
),
|
||||
// 오른쪽: 다운로드 버튼 (이미지만)
|
||||
// 오른쪽: 다운로드 버튼
|
||||
Expanded(
|
||||
child: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: GestureDetector(
|
||||
onTap: _download,
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.all(4),
|
||||
child: Icon(
|
||||
LucideIcons.download,
|
||||
color: Colors.white70,
|
||||
size: 22,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
|
|||
907
app/lib/views/album/album_gallery_view.dart
Normal file
907
app/lib/views/album/album_gallery_view.dart
Normal file
|
|
@ -0,0 +1,907 @@
|
|||
/// 앨범 컨셉포토 갤러리 화면
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,29 +1,30 @@
|
|||
/// 앨범 목록 화면
|
||||
/// 앨범 목록 화면 (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 '../../services/albums_service.dart';
|
||||
import '../../controllers/album_controller.dart';
|
||||
|
||||
class AlbumView extends StatefulWidget {
|
||||
class AlbumView extends ConsumerStatefulWidget {
|
||||
const AlbumView({super.key});
|
||||
|
||||
@override
|
||||
State<AlbumView> createState() => _AlbumViewState();
|
||||
ConsumerState<AlbumView> createState() => _AlbumViewState();
|
||||
}
|
||||
|
||||
class _AlbumViewState extends State<AlbumView> {
|
||||
late Future<List<Album>> _albumsFuture;
|
||||
class _AlbumViewState extends ConsumerState<AlbumView> {
|
||||
bool _initialLoadComplete = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_albumsFuture = getAlbums();
|
||||
// 초기 애니메이션 시간 후에는 새로 생성되는 카드에 애니메이션 적용 안함
|
||||
Future.delayed(const Duration(milliseconds: 600), () {
|
||||
if (mounted) {
|
||||
|
|
@ -34,69 +35,62 @@ class _AlbumViewState extends State<AlbumView> {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FutureBuilder<List<Album>>(
|
||||
future: _albumsFuture,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: AppColors.primary,
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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('다시 시도'),
|
||||
),
|
||||
],
|
||||
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('다시 시도'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
);
|
||||
},
|
||||
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,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,32 +1,25 @@
|
|||
/// 홈 화면 - 모바일 웹과 픽셀 단위까지 동일한 디자인 + 애니메이션
|
||||
/// 홈 화면 (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 '../../services/members_service.dart';
|
||||
import '../../services/albums_service.dart';
|
||||
import '../../services/schedules_service.dart';
|
||||
import '../../controllers/home_controller.dart';
|
||||
|
||||
class HomeView extends StatefulWidget {
|
||||
class HomeView extends ConsumerStatefulWidget {
|
||||
const HomeView({super.key});
|
||||
|
||||
@override
|
||||
State<HomeView> createState() => _HomeViewState();
|
||||
ConsumerState<HomeView> createState() => _HomeViewState();
|
||||
}
|
||||
|
||||
class _HomeViewState extends State<HomeView> with TickerProviderStateMixin {
|
||||
List<Member> _members = [];
|
||||
List<Album> _albums = [];
|
||||
List<Schedule> _schedules = [];
|
||||
bool _isLoading = true;
|
||||
bool _dataLoaded = false;
|
||||
|
||||
class _HomeViewState extends ConsumerState<HomeView> with TickerProviderStateMixin {
|
||||
// 애니메이션 컨트롤러
|
||||
late AnimationController _animController;
|
||||
|
||||
|
|
@ -43,12 +36,12 @@ class _HomeViewState extends State<HomeView> with TickerProviderStateMixin {
|
|||
|
||||
// 현재 경로 추적 (홈 탭 선택 시 애니메이션 재시작용)
|
||||
String? _previousPath;
|
||||
bool _animationStarted = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_setupAnimations();
|
||||
_loadData();
|
||||
}
|
||||
|
||||
void _setupAnimations() {
|
||||
|
|
@ -146,9 +139,10 @@ class _HomeViewState extends State<HomeView> with TickerProviderStateMixin {
|
|||
super.didChangeDependencies();
|
||||
// go_router에서 현재 경로 감지
|
||||
final currentPath = GoRouterState.of(context).uri.path;
|
||||
final homeState = ref.read(homeProvider);
|
||||
|
||||
// 다른 탭에서 홈('/')으로 돌아왔을 때 애니메이션 재시작
|
||||
if (_previousPath != null && _previousPath != '/' && currentPath == '/' && _dataLoaded) {
|
||||
if (_previousPath != null && _previousPath != '/' && currentPath == '/' && homeState.dataLoaded) {
|
||||
_startAnimations();
|
||||
}
|
||||
_previousPath = currentPath;
|
||||
|
|
@ -160,36 +154,19 @@ class _HomeViewState extends State<HomeView> with TickerProviderStateMixin {
|
|||
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) {
|
||||
if (_isLoading) {
|
||||
final homeState = ref.watch(homeProvider);
|
||||
|
||||
// 데이터 로드 완료 시 애니메이션 시작
|
||||
if (homeState.dataLoaded && !_animationStarted) {
|
||||
_animationStarted = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_startAnimations();
|
||||
});
|
||||
}
|
||||
|
||||
if (homeState.isLoading) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: AppColors.primary),
|
||||
);
|
||||
|
|
@ -202,10 +179,9 @@ class _HomeViewState extends State<HomeView> with TickerProviderStateMixin {
|
|||
child: Column(
|
||||
children: [
|
||||
_buildHeroSection(),
|
||||
_buildMembersSection(),
|
||||
_buildAlbumsSection(),
|
||||
_buildSchedulesSection(),
|
||||
const SizedBox(height: 16),
|
||||
_buildMembersSection(homeState),
|
||||
_buildAlbumsSection(homeState),
|
||||
_buildSchedulesSection(homeState),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
@ -306,7 +282,7 @@ class _HomeViewState extends State<HomeView> with TickerProviderStateMixin {
|
|||
}
|
||||
|
||||
/// 멤버 섹션 - px-4(16px) py-6(24px)
|
||||
Widget _buildMembersSection() {
|
||||
Widget _buildMembersSection(HomeState homeState) {
|
||||
return Opacity(
|
||||
opacity: _membersSectionOpacity.value,
|
||||
child: Transform.translate(
|
||||
|
|
@ -320,7 +296,7 @@ class _HomeViewState extends State<HomeView> with TickerProviderStateMixin {
|
|||
const SizedBox(height: 16),
|
||||
// grid-cols-5 gap-2(8px)
|
||||
Row(
|
||||
children: _members.asMap().entries.map((entry) {
|
||||
children: homeState.members.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final member = entry.value;
|
||||
// 멤버 아이템: delay 0.4+index*0.05s, duration 0.3s
|
||||
|
|
@ -362,7 +338,7 @@ class _HomeViewState extends State<HomeView> with TickerProviderStateMixin {
|
|||
? CachedNetworkImage(
|
||||
imageUrl: member.imageUrl!,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (_, __) => Container(color: Colors.grey[200]),
|
||||
placeholder: (context, url) => Container(color: Colors.grey[200]),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
|
|
@ -390,7 +366,7 @@ class _HomeViewState extends State<HomeView> with TickerProviderStateMixin {
|
|||
}
|
||||
|
||||
/// 앨범 섹션 - px-4(16px) py-6(24px)
|
||||
Widget _buildAlbumsSection() {
|
||||
Widget _buildAlbumsSection(HomeState homeState) {
|
||||
return Opacity(
|
||||
opacity: _albumsSectionOpacity.value,
|
||||
child: Transform.translate(
|
||||
|
|
@ -404,7 +380,7 @@ class _HomeViewState extends State<HomeView> with TickerProviderStateMixin {
|
|||
const SizedBox(height: 16),
|
||||
// grid-cols-2 gap-3(12px)
|
||||
Row(
|
||||
children: _albums.asMap().entries.map((entry) {
|
||||
children: homeState.albums.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final album = entry.value;
|
||||
// 앨범 아이템: delay 0.6+index*0.1s, duration 0.3s
|
||||
|
|
@ -458,7 +434,7 @@ class _HomeViewState extends State<HomeView> with TickerProviderStateMixin {
|
|||
? CachedNetworkImage(
|
||||
imageUrl: album.coverThumbUrl!,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (_, __) => Container(color: Colors.grey[200]),
|
||||
placeholder: (context, url) => Container(color: Colors.grey[200]),
|
||||
)
|
||||
: Container(color: Colors.grey[200]),
|
||||
),
|
||||
|
|
@ -501,7 +477,7 @@ class _HomeViewState extends State<HomeView> with TickerProviderStateMixin {
|
|||
}
|
||||
|
||||
/// 일정 섹션 - px-4(16px) py-4(16px)
|
||||
Widget _buildSchedulesSection() {
|
||||
Widget _buildSchedulesSection(HomeState homeState) {
|
||||
return Opacity(
|
||||
opacity: _schedulesSectionOpacity.value,
|
||||
child: Transform.translate(
|
||||
|
|
@ -513,7 +489,7 @@ class _HomeViewState extends State<HomeView> with TickerProviderStateMixin {
|
|||
// mb-4(16px)
|
||||
_buildSectionHeader('다가오는 일정', () => context.go('/schedule')),
|
||||
const SizedBox(height: 16),
|
||||
if (_schedules.isEmpty)
|
||||
if (homeState.schedules.isEmpty)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 32),
|
||||
child: Text(
|
||||
|
|
@ -524,7 +500,7 @@ class _HomeViewState extends State<HomeView> with TickerProviderStateMixin {
|
|||
else
|
||||
// space-y-3(12px)
|
||||
Column(
|
||||
children: _schedules.asMap().entries.map((entry) {
|
||||
children: homeState.schedules.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final schedule = entry.value;
|
||||
// 일정 아이템: delay 0.8+index*0.1s, duration 0.3s, x -20→0
|
||||
|
|
@ -545,7 +521,7 @@ class _HomeViewState extends State<HomeView> with TickerProviderStateMixin {
|
|||
);
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(bottom: index < _schedules.length - 1 ? 12 : 0),
|
||||
padding: EdgeInsets.only(bottom: index < homeState.schedules.length - 1 ? 12 : 0),
|
||||
child: Opacity(
|
||||
opacity: itemOpacity.value,
|
||||
child: Transform.translate(
|
||||
|
|
|
|||
|
|
@ -2,101 +2,69 @@
|
|||
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';
|
||||
final isSchedulePage = location.startsWith('/schedule');
|
||||
|
||||
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),
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
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: 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';
|
||||
|
|
|
|||
|
|
@ -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 '../../services/members_service.dart';
|
||||
import '../../controllers/members_controller.dart';
|
||||
|
||||
class MembersView extends StatefulWidget {
|
||||
class MembersView extends ConsumerStatefulWidget {
|
||||
const MembersView({super.key});
|
||||
|
||||
@override
|
||||
State<MembersView> createState() => _MembersViewState();
|
||||
ConsumerState<MembersView> createState() => _MembersViewState();
|
||||
}
|
||||
|
||||
class _MembersViewState extends State<MembersView> with TickerProviderStateMixin {
|
||||
List<Member> _members = [];
|
||||
bool _isLoading = true;
|
||||
int _currentIndex = 0;
|
||||
class _MembersViewState extends ConsumerState<MembersView> with TickerProviderStateMixin {
|
||||
late PageController _pageController;
|
||||
late ScrollController _indicatorScrollController;
|
||||
late AnimationController _animController;
|
||||
|
|
@ -37,6 +37,7 @@ class _MembersViewState extends State<MembersView> with TickerProviderStateMixin
|
|||
|
||||
// 이전 경로 저장 (탭 전환 감지용)
|
||||
String? _previousPath;
|
||||
bool _animationStarted = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
|
@ -48,7 +49,6 @@ class _MembersViewState extends State<MembersView> with TickerProviderStateMixin
|
|||
duration: const Duration(milliseconds: 800),
|
||||
);
|
||||
_setupAnimations();
|
||||
_loadData();
|
||||
}
|
||||
|
||||
/// 애니메이션 설정
|
||||
|
|
@ -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(
|
||||
|
|
@ -121,46 +123,6 @@ class _MembersViewState extends State<MembersView> with TickerProviderStateMixin
|
|||
);
|
||||
}
|
||||
|
||||
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 {
|
||||
|
|
@ -191,13 +153,24 @@ class _MembersViewState extends State<MembersView> with TickerProviderStateMixin
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_isLoading) {
|
||||
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) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: AppColors.primary),
|
||||
);
|
||||
}
|
||||
|
||||
if (_members.isEmpty) {
|
||||
if (membersState.members.isEmpty) {
|
||||
return const Center(
|
||||
child: Text('멤버 정보가 없습니다', style: TextStyle(color: AppColors.textSecondary)),
|
||||
);
|
||||
|
|
@ -213,7 +186,7 @@ class _MembersViewState extends State<MembersView> with TickerProviderStateMixin
|
|||
offset: Offset(0, _indicatorSlide.value),
|
||||
child: Opacity(
|
||||
opacity: _indicatorOpacity.value,
|
||||
child: _buildThumbnailIndicator(),
|
||||
child: _buildThumbnailIndicator(membersState),
|
||||
),
|
||||
),
|
||||
|
||||
|
|
@ -225,10 +198,10 @@ class _MembersViewState extends State<MembersView> with TickerProviderStateMixin
|
|||
opacity: _cardOpacity.value,
|
||||
child: PageView.builder(
|
||||
controller: _pageController,
|
||||
itemCount: _members.length,
|
||||
itemCount: membersState.members.length,
|
||||
padEnds: true,
|
||||
onPageChanged: (index) {
|
||||
setState(() => _currentIndex = index);
|
||||
controller.setCurrentIndex(index);
|
||||
HapticFeedback.selectionClick();
|
||||
_scrollIndicatorToIndex(index);
|
||||
},
|
||||
|
|
@ -243,7 +216,7 @@ class _MembersViewState extends State<MembersView> with TickerProviderStateMixin
|
|||
}
|
||||
return Transform.scale(
|
||||
scale: Curves.easeOut.transform(value),
|
||||
child: _buildMemberCard(_members[index], index),
|
||||
child: _buildMemberCard(membersState.members[index], controller),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
@ -259,9 +232,9 @@ class _MembersViewState extends State<MembersView> with TickerProviderStateMixin
|
|||
}
|
||||
|
||||
/// 멤버 카드
|
||||
Widget _buildMemberCard(Member member, int index) {
|
||||
Widget _buildMemberCard(Member member, MembersController controller) {
|
||||
final isFormer = member.isFormer;
|
||||
final age = _calculateAge(member.birthDate);
|
||||
final age = controller.calculateAge(member.birthDate);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
|
|
@ -290,7 +263,7 @@ class _MembersViewState extends State<MembersView> with TickerProviderStateMixin
|
|||
child: CachedNetworkImage(
|
||||
imageUrl: member.imageUrl!,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (_, _) => Container(
|
||||
placeholder: (context, url) => Container(
|
||||
color: Colors.grey[200],
|
||||
child: const Center(
|
||||
child: CircularProgressIndicator(color: AppColors.primary),
|
||||
|
|
@ -399,7 +372,7 @@ class _MembersViewState extends State<MembersView> with TickerProviderStateMixin
|
|||
_buildIcon('calendar', 16, Colors.white70),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
_formatBirthDate(member.birthDate),
|
||||
controller.formatBirthDate(member.birthDate),
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.white.withValues(alpha: 0.8),
|
||||
|
|
@ -475,7 +448,7 @@ class _MembersViewState extends State<MembersView> with TickerProviderStateMixin
|
|||
}
|
||||
|
||||
/// 상단 썸네일 인디케이터
|
||||
Widget _buildThumbnailIndicator() {
|
||||
Widget _buildThumbnailIndicator(MembersState membersState) {
|
||||
return Container(
|
||||
height: 88,
|
||||
decoration: BoxDecoration(
|
||||
|
|
@ -492,10 +465,10 @@ class _MembersViewState extends State<MembersView> with TickerProviderStateMixin
|
|||
controller: _indicatorScrollController,
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||
itemCount: _members.length,
|
||||
itemCount: membersState.members.length,
|
||||
itemBuilder: (context, index) {
|
||||
final member = _members[index];
|
||||
final isSelected = index == _currentIndex;
|
||||
final member = membersState.members[index];
|
||||
final isSelected = index == membersState.currentIndex;
|
||||
final isFormer = member.isFormer;
|
||||
|
||||
return GestureDetector(
|
||||
|
|
@ -545,7 +518,7 @@ class _MembersViewState extends State<MembersView> with TickerProviderStateMixin
|
|||
fit: BoxFit.cover,
|
||||
width: 48,
|
||||
height: 48,
|
||||
placeholder: (_, _) => Container(color: Colors.grey[300]),
|
||||
placeholder: (context, url) => Container(color: Colors.grey[300]),
|
||||
)
|
||||
: Container(
|
||||
width: 48,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
76
app/lib/views/schedule/widgets/member_chip.dart
Normal file
76
app/lib/views/schedule/widgets/member_chip.dart
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
/// 멤버 칩 위젯
|
||||
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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
253
app/lib/views/schedule/widgets/schedule_card.dart
Normal file
253
app/lib/views/schedule/widgets/schedule_card.dart
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
/// 일정 카드 위젯
|
||||
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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll(''', "'")
|
||||
.replaceAll(' ', ' ');
|
||||
}
|
||||
|
||||
/// 카테고리 색상 파싱
|
||||
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(),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
257
app/lib/views/schedule/widgets/search_card.dart
Normal file
257
app/lib/views/schedule/widgets/search_card.dart
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
/// 검색 결과 카드 위젯
|
||||
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(),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -169,6 +169,14 @@ 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:
|
||||
|
|
@ -981,14 +989,6 @@ 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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -9,6 +9,12 @@ 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;
|
||||
|
||||
/**
|
||||
* 영문만 포함된 검색어인지 확인
|
||||
*/
|
||||
|
|
@ -166,38 +172,21 @@ export async function getSuggestions(query, limit = 10) {
|
|||
|
||||
/**
|
||||
* 다음 단어 예측 (Bi-gram 기반)
|
||||
* 동적 임계값을 사용하므로 Redis 캐시를 사용하지 않음
|
||||
*/
|
||||
async function getNextWordSuggestions(lastWord, prefix, limit) {
|
||||
try {
|
||||
// 1. Redis 캐시 확인
|
||||
const cacheKey = `${SUGGESTION_PREFIX}${lastWord}`;
|
||||
let nextWords = await redis.zrevrange(cacheKey, 0, limit - 1);
|
||||
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]
|
||||
);
|
||||
|
||||
// 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}`);
|
||||
// prefix + 다음 단어 조합으로 반환
|
||||
return rows.map((r) => `${prefix} ${r.word2}`);
|
||||
} catch (error) {
|
||||
console.error("[SearchSuggestion] Bi-gram 조회 오류:", error.message);
|
||||
return [];
|
||||
|
|
@ -218,19 +207,21 @@ async function getPrefixSuggestions(prefix, koreanPrefix, limit) {
|
|||
// 영어 원본과 한글 변환 둘 다 검색
|
||||
[rows] = await pool.query(
|
||||
`SELECT query FROM search_queries
|
||||
WHERE query LIKE ? OR query LIKE ?
|
||||
WHERE (query LIKE ? OR query LIKE ?)
|
||||
AND count >= GREATEST((SELECT MAX(count) * ? FROM search_queries), ?)
|
||||
ORDER BY count DESC, last_searched_at DESC
|
||||
LIMIT ?`,
|
||||
[`${prefix}%`, `${koreanPrefix}%`, limit]
|
||||
[`${prefix}%`, `${koreanPrefix}%`, MIN_COUNT_RATIO, MIN_COUNT_FLOOR, 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}%`, limit]
|
||||
[`${prefix}%`, MIN_COUNT_RATIO, MIN_COUNT_FLOOR, limit]
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -91,7 +91,7 @@ function App() {
|
|||
<MobileView>
|
||||
<Routes>
|
||||
<Route path="/" element={<MobileLayout><MobileHome /></MobileLayout>} />
|
||||
<Route path="/members" element={<MobileLayout pageTitle="멤버"><MobileMembers /></MobileLayout>} />
|
||||
<Route path="/members" element={<MobileLayout pageTitle="멤버" noShadow><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>} />
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@ import { useEffect } from 'react';
|
|||
import '../../mobile.css';
|
||||
|
||||
// 모바일 헤더 컴포넌트
|
||||
function MobileHeader({ title }) {
|
||||
function MobileHeader({ title, noShadow = false }) {
|
||||
return (
|
||||
<header className="bg-white shadow-sm sticky top-0 z-50">
|
||||
<header className={`bg-white sticky top-0 z-50 ${noShadow ? '' : 'shadow-sm'}`}>
|
||||
<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 }) {
|
||||
function MobileLayout({ children, pageTitle, hideHeader = false, useCustomLayout = false, noShadow = 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} />}
|
||||
{!hideHeader && <MobileHeader title={pageTitle} noShadow={noShadow} />}
|
||||
<main className="mobile-content">{children}</main>
|
||||
<MobileBottomNav />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,12 +1,16 @@
|
|||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useState, useMemo } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useState, useMemo, useRef, useEffect } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Instagram, Calendar, X } from 'lucide-react';
|
||||
import { Instagram, Calendar } from 'lucide-react';
|
||||
import { Swiper, SwiperSlide } from 'swiper/react';
|
||||
import 'swiper/css';
|
||||
import { getMembers } from '../../../api/public/members';
|
||||
|
||||
// 모바일 멤버 페이지
|
||||
// 모바일 멤버 페이지 - 카드 스와이프 스타일
|
||||
function MobileMembers() {
|
||||
const [selectedMember, setSelectedMember] = useState(null);
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const swiperRef = useRef(null);
|
||||
const indicatorRef = useRef(null);
|
||||
|
||||
// useQuery로 멤버 데이터 로드
|
||||
const { data: allMembers = [] } = useQuery({
|
||||
|
|
@ -14,10 +18,15 @@ function MobileMembers() {
|
|||
queryFn: getMembers,
|
||||
});
|
||||
|
||||
// useMemo로 현재/전 멤버 분리
|
||||
const members = useMemo(() => allMembers.filter(m => !m.is_former), [allMembers]);
|
||||
const formerMembers = useMemo(() => allMembers.filter(m => m.is_former), [allMembers]);
|
||||
|
||||
// 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]);
|
||||
|
||||
// 나이 계산
|
||||
const calculateAge = (birthDate) => {
|
||||
|
|
@ -32,176 +41,188 @@ function MobileMembers() {
|
|||
return age;
|
||||
};
|
||||
|
||||
// 모달 닫기
|
||||
const closeModal = () => setSelectedMember(null);
|
||||
// 인디케이터 자동 스크롤
|
||||
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 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>
|
||||
container.scrollTo({
|
||||
left: Math.max(0, targetScroll),
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
}, [currentIndex, members.length]);
|
||||
|
||||
{/* 정보 영역 - 하단 그라데이션 오버레이 */}
|
||||
<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>
|
||||
// 인디케이터 클릭 핸들러
|
||||
const handleIndicatorClick = (index) => {
|
||||
if (swiperRef.current) {
|
||||
swiperRef.current.slideTo(index);
|
||||
}
|
||||
};
|
||||
|
||||
{/* 호버시 반짝이 효과 */}
|
||||
{!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" />
|
||||
)}
|
||||
if (members.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-[calc(100vh-200px)]">
|
||||
<p className="text-gray-400">멤버 정보가 없습니다</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<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>
|
||||
</div>
|
||||
<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;
|
||||
|
||||
{/* 전 멤버 */}
|
||||
{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>
|
||||
)}
|
||||
|
||||
{/* 멤버 상세 모달 - 드래그로 닫기 가능 */}
|
||||
<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="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>
|
||||
|
||||
{/* 모달 콘텐츠 */}
|
||||
<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"
|
||||
/>
|
||||
)}
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</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>
|
||||
{/* 메인 카드 영역 */}
|
||||
<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);
|
||||
|
||||
{selectedMember.position && (
|
||||
<p className="text-gray-500 text-sm mt-1">{selectedMember.position}</p>
|
||||
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" />
|
||||
)}
|
||||
|
||||
{/* 하단 그라데이션 오버레이 */}
|
||||
<div className="absolute inset-x-0 bottom-0 h-[220px] bg-gradient-to-t from-black/80 via-black/30 to-transparent" />
|
||||
|
||||
{/* 전 멤버 라벨 */}
|
||||
{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>
|
||||
)}
|
||||
|
||||
{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>
|
||||
)}
|
||||
{/* 생일 정보 */}
|
||||
{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>
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
{/* 인스타그램 버튼 */}
|
||||
{!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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)}
|
||||
</SwiperSlide>
|
||||
);
|
||||
})}
|
||||
</Swiper>
|
||||
</motion.div>
|
||||
</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