fromis_9/app/src/screens/HomeScreen.tsx

596 lines
16 KiB
TypeScript
Raw Normal View History

import React, { useEffect, useState, useRef } from 'react';
import {
View,
Text,
ScrollView,
Image,
TouchableOpacity,
ActivityIndicator,
RefreshControl,
StyleSheet,
Animated,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { ChevronRight, Clock, Tag } from 'lucide-react-native';
import { getAlbums, Album } from '../api/albums';
import { getMembers, Member } from '../api/members';
import { getUpcomingSchedules, Schedule } from '../api/schedules';
import { colors } from '../constants/colors';
import Header from '../components/common/Header';
import type { AlbumStackParamList } from '../navigation/AppNavigator';
type NavigationProp = NativeStackNavigationProp<AlbumStackParamList>;
// 전체 배경색 (tailwind bg-gray-50)
const BG_GRAY = '#F9FAFB';
export default function HomeScreen() {
const navigation = useNavigation<NavigationProp>();
const [members, setMembers] = useState<Member[]>([]);
const [albums, setAlbums] = useState<Album[]>([]);
const [schedules, setSchedules] = useState<Schedule[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
// 애니메이션
const fadeAnim = useRef(new Animated.Value(0)).current;
const slideAnim = useRef(new Animated.Value(20)).current;
const fetchData = async () => {
try {
const [membersData, albumsData, schedulesData] = await Promise.all([
getMembers(),
getAlbums(),
getUpcomingSchedules(3),
]);
setMembers(membersData.filter(m => !m.is_former));
setAlbums(albumsData.slice(0, 2));
setSchedules(schedulesData || []);
// 애니메이션 시작
Animated.parallel([
Animated.timing(fadeAnim, {
toValue: 1,
duration: 500,
useNativeDriver: true,
}),
Animated.timing(slideAnim, {
toValue: 0,
duration: 500,
useNativeDriver: true,
}),
]).start();
} catch (error) {
console.error('데이터 로드 오류:', error);
} finally {
setLoading(false);
setRefreshing(false);
}
};
useEffect(() => {
fetchData();
}, []);
const onRefresh = () => {
setRefreshing(true);
fadeAnim.setValue(0);
slideAnim.setValue(20);
fetchData();
};
// 날짜 포맷
const formatScheduleDate = (dateStr: string) => {
const date = new Date(dateStr);
const today = new Date();
const currentYear = today.getFullYear();
const currentMonth = today.getMonth();
const scheduleYear = date.getFullYear();
const scheduleMonth = date.getMonth();
const isCurrentYear = scheduleYear === currentYear;
const isCurrentMonth = isCurrentYear && scheduleMonth === currentMonth;
const weekdays = ['일', '월', '화', '수', '목', '금', '토'];
return {
day: date.getDate(),
weekday: weekdays[date.getDay()],
year: scheduleYear,
month: scheduleMonth + 1,
isCurrentYear,
isCurrentMonth,
};
};
if (loading) {
return (
<SafeAreaView style={styles.container} edges={[]}>
<Header />
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colors.primary} />
</View>
</SafeAreaView>
);
}
return (
<SafeAreaView style={styles.container} edges={[]}>
{/* 공통 헤더 */}
<Header />
<ScrollView
style={styles.scrollView}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
}
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.scrollContent}
>
{/* 히어로 섹션 - bg-gradient-to-br from-primary to-primary-dark py-12 px-4 */}
<Animated.View style={[styles.heroSection, { opacity: fadeAnim }]}>
<View style={styles.heroOverlay} />
<View style={styles.heroContent}>
<Text style={styles.heroTitle}>fromis_9</Text>
<Text style={styles.heroSubtitle}></Text>
<Text style={styles.heroText}>
. , !{'\n'}
,{'\n'}
!
</Text>
</View>
{/* 장식 원 */}
<View style={[styles.decorCircle, styles.decorCircle1]} />
<View style={[styles.decorCircle, styles.decorCircle2]} />
</Animated.View>
{/* 멤버 섹션 */}
<Animated.View
style={[
styles.section,
{ opacity: fadeAnim, transform: [{ translateY: slideAnim }] }
]}
>
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}></Text>
<TouchableOpacity style={styles.moreButton}>
<Text style={styles.moreButtonText}></Text>
<ChevronRight size={16} color={colors.primary} />
</TouchableOpacity>
</View>
<View style={styles.membersGrid}>
{members.slice(0, 5).map((member) => (
<View key={member.id} style={styles.memberItem}>
<View style={styles.memberImageContainer}>
{member.image_url && (
<Image
source={{ uri: member.image_url }}
style={styles.memberImage}
/>
)}
</View>
<Text style={styles.memberName} numberOfLines={1}>
{member.name}
</Text>
</View>
))}
</View>
</Animated.View>
{/* 앨범 섹션 */}
<Animated.View
style={[
styles.section,
{ opacity: fadeAnim, transform: [{ translateY: slideAnim }] }
]}
>
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}></Text>
<TouchableOpacity
style={styles.moreButton}
onPress={() => navigation.navigate('AlbumList')}
>
<Text style={styles.moreButtonText}></Text>
<ChevronRight size={16} color={colors.primary} />
</TouchableOpacity>
</View>
<View style={styles.albumsGrid}>
{albums.map((album) => (
<TouchableOpacity
key={album.id}
style={styles.albumCard}
onPress={() => navigation.navigate('AlbumDetail', { name: album.folder_name })}
activeOpacity={0.98}
>
<View style={styles.albumImageContainer}>
<Image
source={{ uri: album.cover_thumb_url || album.cover_medium_url }}
style={styles.albumImage}
/>
</View>
<View style={styles.albumInfo}>
<Text style={styles.albumTitle} numberOfLines={1}>
{album.title}
</Text>
<Text style={styles.albumYear}>
{album.release_date?.slice(0, 4)}
</Text>
</View>
</TouchableOpacity>
))}
</View>
</Animated.View>
{/* 일정 섹션 */}
<Animated.View
style={[
styles.scheduleSection,
{ opacity: fadeAnim, transform: [{ translateY: slideAnim }] }
]}
>
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}> </Text>
<TouchableOpacity style={styles.moreButton}>
<Text style={styles.moreButtonText}></Text>
<ChevronRight size={16} color={colors.primary} />
</TouchableOpacity>
</View>
{schedules.length > 0 ? (
<View style={styles.schedulesList}>
{schedules.map((schedule) => {
const dateInfo = formatScheduleDate(schedule.date);
const memberList = schedule.member_names
? schedule.member_names.split(',').map(n => n.trim()).filter(Boolean)
: [];
return (
<TouchableOpacity
key={schedule.id}
style={styles.scheduleCard}
activeOpacity={0.98}
>
{/* 날짜 영역 */}
<View style={styles.scheduleDateContainer}>
{!dateInfo.isCurrentYear && (
<Text style={styles.scheduleDateExtra}>
{dateInfo.year}.{dateInfo.month}
</Text>
)}
{dateInfo.isCurrentYear && !dateInfo.isCurrentMonth && (
<Text style={styles.scheduleDateExtra}>
{dateInfo.month}
</Text>
)}
<Text style={styles.scheduleDay}>{dateInfo.day}</Text>
<Text style={styles.scheduleWeekday}>{dateInfo.weekday}</Text>
</View>
{/* 세로 구분선 */}
<View style={styles.scheduleDivider} />
{/* 내용 영역 */}
<View style={styles.scheduleContent}>
<Text style={styles.scheduleTitle} numberOfLines={2}>
{schedule.title}
</Text>
<View style={styles.scheduleMeta}>
{schedule.time && (
<View style={styles.scheduleMetaItem}>
<Clock size={12} color="#9CA3AF" />
<Text style={styles.scheduleMetaText}>
{schedule.time.slice(0, 5)}
</Text>
</View>
)}
{schedule.category_name && (
<View style={styles.scheduleMetaItem}>
<Tag size={12} color="#9CA3AF" />
<Text style={styles.scheduleMetaText}>
{schedule.category_name}
</Text>
</View>
)}
</View>
{memberList.length > 0 && (
<View style={styles.memberTags}>
{(memberList.length >= 5 ? ['프로미스나인'] : memberList).map((name, i) => (
<View key={i} style={styles.memberTag}>
<Text style={styles.memberTagText}>{name}</Text>
</View>
))}
</View>
)}
</View>
</TouchableOpacity>
);
})}
</View>
) : (
<View style={styles.emptySchedule}>
<Text style={styles.emptyText}> </Text>
</View>
)}
</Animated.View>
</ScrollView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: BG_GRAY,
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: BG_GRAY,
},
// 헤더
header: {
backgroundColor: '#FFFFFF',
paddingVertical: 14,
alignItems: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
elevation: 2,
},
headerTitle: {
fontSize: 20,
fontWeight: 'bold',
color: colors.primary,
},
scrollView: {
flex: 1,
},
scrollContent: {
paddingBottom: 0,
},
// 히어로 섹션
heroSection: {
backgroundColor: colors.primary,
paddingVertical: 48,
paddingHorizontal: 16,
position: 'relative',
overflow: 'hidden',
},
heroOverlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0,0,0,0.1)',
},
heroContent: {
alignItems: 'center',
},
heroTitle: {
fontSize: 30,
fontWeight: 'bold',
color: '#FFFFFF',
marginBottom: 4,
},
heroSubtitle: {
fontSize: 18,
color: '#FFFFFF',
fontWeight: '300',
marginBottom: 12,
},
heroText: {
fontSize: 14,
color: 'rgba(255,255,255,0.8)',
textAlign: 'center',
lineHeight: 22,
},
decorCircle: {
position: 'absolute',
borderRadius: 999,
backgroundColor: 'rgba(255,255,255,0.1)',
},
decorCircle1: {
width: 128,
height: 128,
right: -32,
top: -32,
},
decorCircle2: {
width: 96,
height: 96,
left: -24,
bottom: -24,
backgroundColor: 'rgba(255,255,255,0.05)',
},
// 섹션 공통
section: {
paddingHorizontal: 16,
paddingVertical: 24,
},
scheduleSection: {
paddingHorizontal: 16,
paddingTop: 16,
paddingBottom: 16,
},
sectionHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 16,
},
sectionTitle: {
fontSize: 18,
fontWeight: 'bold',
color: '#111827',
},
moreButton: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
},
moreButtonText: {
fontSize: 14,
color: colors.primary,
},
// 멤버 섹션
membersGrid: {
flexDirection: 'row',
justifyContent: 'space-between',
},
memberItem: {
alignItems: 'center',
width: '18%',
},
memberImageContainer: {
width: 56,
height: 56,
borderRadius: 28,
overflow: 'hidden',
backgroundColor: '#E5E7EB',
marginBottom: 4,
},
memberImage: {
width: '100%',
height: '100%',
},
memberName: {
fontSize: 12,
fontWeight: '500',
color: '#111827',
textAlign: 'center',
},
// 앨범 섹션
albumsGrid: {
flexDirection: 'row',
gap: 12,
},
albumCard: {
flex: 1,
backgroundColor: '#FFFFFF',
borderRadius: 12,
overflow: 'hidden',
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 3,
elevation: 2,
},
albumImageContainer: {
aspectRatio: 1,
backgroundColor: '#E5E7EB',
},
albumImage: {
width: '100%',
height: '100%',
},
albumInfo: {
padding: 12,
},
albumTitle: {
fontSize: 14,
fontWeight: '500',
color: '#111827',
},
albumYear: {
fontSize: 12,
color: '#9CA3AF',
marginTop: 2,
},
// 일정 섹션
schedulesList: {
gap: 12,
},
scheduleCard: {
flexDirection: 'row',
backgroundColor: '#FFFFFF',
padding: 16,
borderRadius: 12,
borderWidth: 1,
borderColor: '#F3F4F6',
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.03,
shadowRadius: 2,
elevation: 1,
},
scheduleDateContainer: {
alignItems: 'center',
justifyContent: 'center',
minWidth: 50,
},
scheduleDateExtra: {
fontSize: 10,
color: '#9CA3AF',
fontWeight: '500',
},
scheduleDay: {
fontSize: 24,
fontWeight: 'bold',
color: colors.primary,
},
scheduleWeekday: {
fontSize: 12,
color: '#9CA3AF',
fontWeight: '500',
},
scheduleDivider: {
width: 1,
backgroundColor: '#F3F4F6',
marginHorizontal: 16,
},
scheduleContent: {
flex: 1,
},
scheduleTitle: {
fontSize: 14,
fontWeight: '600',
color: '#1F2937',
lineHeight: 20,
},
scheduleMeta: {
flexDirection: 'row',
gap: 12,
marginTop: 8,
},
scheduleMetaItem: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
},
scheduleMetaText: {
fontSize: 12,
color: '#9CA3AF',
},
memberTags: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 4,
marginTop: 8,
},
memberTag: {
backgroundColor: colors.primary + '1A',
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 10,
},
memberTagText: {
fontSize: 10,
color: colors.primary,
fontWeight: '500',
},
emptySchedule: {
paddingVertical: 32,
alignItems: 'center',
},
emptyText: {
color: '#9CA3AF',
},
});