import dayjs from 'dayjs'; import ChallengeProgressCard from '@/components/challenges/ChallengeProgressCard'; import { ConfirmationSheet } from '@/components/ui/ConfirmationSheet'; import { Colors } from '@/constants/Colors'; import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useAuthGuard } from '@/hooks/useAuthGuard'; import { useColorScheme } from '@/hooks/useColorScheme'; import { useI18n } from '@/hooks/useI18n'; import { fetchChallenges, joinChallengeByCode, resetJoinByCodeState, selectChallengeCards, selectChallengesListError, selectChallengesListStatus, selectCustomChallengeCards, selectJoinByCodeError, selectJoinByCodeStatus, selectOfficialChallengeCards, type ChallengeCardViewModel, } from '@/store/challengesSlice'; import { Toast } from '@/utils/toast.utils'; import { Ionicons } from '@expo/vector-icons'; import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect'; import { Image } from 'expo-image'; import { LinearGradient } from 'expo-linear-gradient'; import { useRouter } from 'expo-router'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { ActivityIndicator, Animated, FlatList, ScrollView, StyleSheet, Text, TextInput, TouchableOpacity, View, useWindowDimensions } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; const AVATAR_SIZE = 36; const CARD_IMAGE_WIDTH = 132; const CARD_IMAGE_HEIGHT = 96; const CAROUSEL_ITEM_SPACING = 16; const MIN_CAROUSEL_CARD_WIDTH = 280; const DOT_BASE_SIZE = 6; export default function ChallengesScreen() { const theme = (useColorScheme() ?? 'light') as 'light' | 'dark'; const insets = useSafeAreaInsets(); const { t } = useI18n(); const { ensureLoggedIn } = useAuthGuard(); const colorTokens = Colors[theme]; const router = useRouter(); const dispatch = useAppDispatch(); const glassAvailable = isLiquidGlassAvailable(); const allChallenges = useAppSelector(selectChallengeCards); const customChallenges = useAppSelector(selectCustomChallengeCards); const officialChallenges = useAppSelector(selectOfficialChallengeCards); const joinedCustomChallenges = useMemo( () => customChallenges.filter((item) => item.isJoined), [customChallenges] ); const listStatus = useAppSelector(selectChallengesListStatus); const listError = useAppSelector(selectChallengesListError); const joinByCodeStatus = useAppSelector(selectJoinByCodeStatus); const joinByCodeError = useAppSelector(selectJoinByCodeError); const [joinModalVisible, setJoinModalVisible] = useState(false); const [shareCodeInput, setShareCodeInput] = useState(''); const ongoingChallenges = useMemo(() => { const now = dayjs(); return allChallenges.filter((challenge) => { if (challenge.status !== 'ongoing' || !challenge.isJoined || !challenge.progress) { return false; } if (challenge.endAt) { const endDate = dayjs(challenge.endAt); if (endDate.isValid() && endDate.isBefore(now)) { return false; } } return true; }); }, [allChallenges]); const progressTrackColor = theme === 'dark' ? 'rgba(255, 255, 255, 0.08)' : '#eceffa'; const progressInactiveColor = theme === 'dark' ? 'rgba(255, 255, 255, 0.24)' : '#dfe4f6'; useEffect(() => { if (listStatus === 'idle') { dispatch(fetchChallenges()); } }, [dispatch, listStatus]); const gradientColors: [string, string] = theme === 'dark' ? ['#1f2230', '#10131e'] : [colorTokens.backgroundGradientStart, colorTokens.backgroundGradientEnd]; useEffect(() => { if (!joinModalVisible) { dispatch(resetJoinByCodeState()); setShareCodeInput(''); } }, [dispatch, joinModalVisible]); const handleCreatePress = useCallback(async () => { const ok = await ensureLoggedIn(); if (!ok) return; router.push('/challenges/create-custom'); }, [ensureLoggedIn, router]); const handleOpenJoin = useCallback(async () => { const ok = await ensureLoggedIn(); if (!ok) return; setJoinModalVisible(true); }, [ensureLoggedIn]); const isJoiningByCode = joinByCodeStatus === 'loading'; const handleSubmitShareCode = useCallback(async () => { if (isJoiningByCode) return; const ok = await ensureLoggedIn(); if (!ok) return; if (!shareCodeInput.trim()) { Toast.warning(t('challenges.invalidInviteCode')); return; } const formatted = shareCodeInput.trim().toUpperCase(); try { const result = await dispatch(joinChallengeByCode(formatted)).unwrap(); await dispatch(fetchChallenges()); setJoinModalVisible(false); Toast.success(t('challenges.joinSuccess')); router.push({ pathname: '/challenges/[id]', params: { id: result.challenge.id } }); } catch (error) { const message = typeof error === 'string' ? error : t('challenges.joinFailed'); Toast.error(message); } }, [dispatch, ensureLoggedIn, isJoiningByCode, router, shareCodeInput]); const renderChallenges = () => { if (listStatus === 'loading' && allChallenges.length === 0) { return ( {t('challenges.loading')} ); } if (listStatus === 'failed' && allChallenges.length === 0) { return ( {listError ?? t('challenges.loadFailed')} dispatch(fetchChallenges())} > {t('challenges.retry')} ); } if (customChallenges.length === 0 && officialChallenges.length === 0) { return ( {t('challenges.empty')} ); } return ( {joinedCustomChallenges.length ? ( <> {t('challenges.customChallenges')} {joinedCustomChallenges.map((challenge) => ( router.push({ pathname: '/challenges/[id]', params: { id: challenge.id } }) } /> ))} ) : null} {t('challenges.officialChallengesTitle')} {officialChallenges.length ? ( {officialChallenges.map((challenge) => ( router.push({ pathname: '/challenges/[id]', params: { id: challenge.id } }) } /> ))} ) : ( {t('challenges.officialChallenges')} )} ); }; return ( {t('challenges.title')} {t('challenges.subtitle')} {glassAvailable ? ( {t('challenges.join')} ) : ( {t('challenges.join')} )} {glassAvailable ? ( ) : ( )} {ongoingChallenges.length ? ( router.push({ pathname: '/challenges/[id]', params: { id: challenge.id } }) } /> ) : null} {renderChallenges()} setJoinModalVisible(false)} onConfirm={handleSubmitShareCode} title={t('challenges.joinModal.title')} description={t('challenges.joinModal.description')} confirmText={isJoiningByCode ? t('challenges.joinModal.joining') : t('challenges.joinModal.confirm')} cancelText={t('challenges.joinModal.cancel')} loading={isJoiningByCode} content={ setShareCodeInput(text.toUpperCase())} autoCapitalize="characters" autoCorrect={false} keyboardType="default" maxLength={12} /> {joinByCodeError && joinModalVisible ? ( {joinByCodeError} ) : null} } /> ); } type ChallengeCardProps = { challenge: ChallengeCardViewModel; surfaceColor: string; textColor: string; mutedColor: string; onPress: () => void; }; function ChallengeCard({ challenge, surfaceColor, textColor, mutedColor, onPress }: ChallengeCardProps) { const { t } = useI18n(); const statusLabel = t(`challenges.statusLabels.${challenge.status}`) ?? challenge.status; return ( <> {statusLabel} {challenge.title} {challenge.dateRange} {challenge.participantsLabel} {challenge.isJoined ? ` ยท ${t('challenges.joined')}` : ''} {challenge.avatars.length ? ( ) : null} ); } type ThemeColorTokens = (typeof Colors)['light'] | (typeof Colors)['dark']; type OngoingChallengesCarouselProps = { challenges: ChallengeCardViewModel[]; colorTokens: ThemeColorTokens; trackColor: string; inactiveColor: string; onPress: (challenge: ChallengeCardViewModel) => void; }; function OngoingChallengesCarousel({ challenges, colorTokens, trackColor, inactiveColor, onPress, }: OngoingChallengesCarouselProps) { const { width } = useWindowDimensions(); const cardWidth = Math.max(width - 40, MIN_CAROUSEL_CARD_WIDTH); const snapInterval = cardWidth + CAROUSEL_ITEM_SPACING; const scrollX = useRef(new Animated.Value(0)).current; const listRef = useRef | null>(null); useEffect(() => { scrollX.setValue(0); listRef.current?.scrollToOffset({ offset: 0, animated: false }); }, [scrollX, challenges.length]); const onScroll = useMemo( () => Animated.event( [ { nativeEvent: { contentOffset: { x: scrollX }, }, }, ], { useNativeDriver: true } ), [scrollX] ); const renderItem = useCallback( ({ item, index }: { item: ChallengeCardViewModel; index: number }) => { const inputRange = [ (index - 1) * snapInterval, index * snapInterval, (index + 1) * snapInterval, ]; const scale = scrollX.interpolate({ inputRange, outputRange: [0.94, 1, 0.94], extrapolate: 'clamp', }); const translateY = scrollX.interpolate({ inputRange, outputRange: [10, 0, 10], extrapolate: 'clamp', }); return ( onPress(item)} > ); }, [cardWidth, colorTokens, inactiveColor, onPress, scrollX, snapInterval, trackColor] ); return ( item.id} horizontal showsHorizontalScrollIndicator={false} bounces decelerationRate="fast" snapToAlignment="start" snapToInterval={snapInterval} ItemSeparatorComponent={() => } onScroll={onScroll} scrollEventThrottle={16} overScrollMode="never" renderItem={renderItem} /> {challenges.length > 1 ? ( {challenges.map((challenge, index) => { const inputRange = [ (index - 1) * snapInterval, index * snapInterval, (index + 1) * snapInterval, ]; const scaleX = scrollX.interpolate({ inputRange, outputRange: [1, 2.6, 1], extrapolate: 'clamp', }); const dotOpacity = scrollX.interpolate({ inputRange, outputRange: [0.35, 1, 0.35], extrapolate: 'clamp', }); return ( ); })} ) : null} ); } type AvatarStackProps = { avatars: string[]; borderColor: string; }; function AvatarStack({ avatars, borderColor }: AvatarStackProps) { return ( {avatars .filter(Boolean) .map((avatar, index) => ( ))} ); } const styles = StyleSheet.create({ screen: { flex: 1, }, safeArea: { flex: 1, }, scrollContent: { paddingHorizontal: 20, paddingBottom: 120, }, headerRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginTop: 8, marginBottom: 26, }, title: { fontSize: 24, fontWeight: '700', letterSpacing: 1, fontFamily: 'AliBold' }, subtitle: { marginTop: 6, fontSize: 12, fontWeight: '500', opacity: 0.8, fontFamily: 'AliRegular' }, headerActions: { flexDirection: 'row', alignItems: 'center', }, joinButtonGlass: { paddingHorizontal: 14, paddingVertical: 8, borderRadius: 16, minWidth: 70, alignItems: 'center', justifyContent: 'center', borderWidth: StyleSheet.hairlineWidth, borderColor: 'rgba(255,255,255,0.45)', }, joinButtonLabel: { fontSize: 12, fontWeight: '700', color: '#0f1528', letterSpacing: 0.5, fontFamily: 'AliBold' }, joinButtonFallback: { backgroundColor: 'rgba(255,255,255,0.7)', }, createButton: { width: 36, height: 36, borderRadius: 20, alignItems: 'center', justifyContent: 'center', borderWidth: StyleSheet.hairlineWidth, borderColor: 'rgba(255,255,255,0.6)', backgroundColor: 'rgba(255,255,255,0.85)', }, createButtonFallback: { backgroundColor: 'rgba(255,255,255,0.75)', }, cardsContainer: { gap: 18, }, cardGroups: { gap: 20, }, sectionHeaderRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8, }, sectionHeaderText: { fontSize: 16, fontWeight: '800', }, customEmpty: { borderRadius: 18, backgroundColor: 'rgba(255,255,255,0.08)', }, primaryGhostButton: { marginTop: 12, paddingHorizontal: 16, paddingVertical: 8, borderWidth: StyleSheet.hairlineWidth, borderRadius: 14, }, carouselContainer: { marginBottom: 24, }, carouselCard: { width: '100%', }, carouselTouchable: { flex: 1, }, carouselProgressCard: { width: '100%', }, carouselIndicators: { marginTop: 18, flexDirection: 'row', justifyContent: 'center', alignItems: 'center', }, carouselDot: { width: DOT_BASE_SIZE, height: DOT_BASE_SIZE, borderRadius: DOT_BASE_SIZE / 2, marginHorizontal: 4, backgroundColor: 'transparent', }, stateContainer: { alignItems: 'center', justifyContent: 'center', paddingVertical: 40, paddingHorizontal: 20, }, stateText: { marginTop: 12, fontSize: 14, textAlign: 'center', lineHeight: 20, }, retryButton: { marginTop: 16, paddingHorizontal: 18, paddingVertical: 8, borderRadius: 18, borderWidth: StyleSheet.hairlineWidth, }, retryText: { fontSize: 13, fontWeight: '600', }, card: { borderRadius: 28, padding: 18, shadowOffset: { width: 0, height: 16 }, shadowOpacity: 0.18, shadowRadius: 24, elevation: 6, position: 'relative', overflow: 'hidden', }, cardInner: { flexDirection: 'row', alignItems: 'center', }, cardImage: { width: CARD_IMAGE_WIDTH, height: CARD_IMAGE_HEIGHT, borderRadius: 22, }, cardMedia: { borderRadius: 22, overflow: 'hidden', position: 'relative', }, cardContent: { flex: 1, marginLeft: 16, }, cardTitle: { fontSize: 18, fontWeight: '700', marginBottom: 4, fontFamily: 'AliBold', }, cardDate: { fontSize: 13, fontWeight: '500', marginBottom: 4, fontFamily: 'AliRegular', }, cardParticipants: { fontSize: 13, fontWeight: '500', fontFamily: 'AliRegular' }, cardExpired: { borderWidth: StyleSheet.hairlineWidth, borderColor: 'rgba(148, 163, 184, 0.22)', }, cardExpiredText: { opacity: 0.7, }, cardDimOverlay: { ...StyleSheet.absoluteFillObject, borderRadius: 28, }, cardImageOverlay: { ...StyleSheet.absoluteFillObject, }, expiredBadge: { position: 'absolute', left: 12, bottom: 12, paddingHorizontal: 10, paddingVertical: 4, borderRadius: 12, backgroundColor: 'rgba(12, 16, 28, 0.45)', }, expiredBadgeText: { fontSize: 12, fontWeight: '600', color: '#f7f9ff', letterSpacing: 0.3, fontFamily: 'AliRegular', }, cardProgress: { marginTop: 8, fontSize: 13, fontWeight: '600', }, avatarRow: { flexDirection: 'row', marginTop: 16, alignItems: 'center', }, avatar: { width: AVATAR_SIZE, height: AVATAR_SIZE, borderRadius: AVATAR_SIZE / 2, borderWidth: 2, }, avatarOffset: { marginLeft: -12, }, modalInputWrapper: { borderRadius: 14, borderWidth: 1, borderColor: '#e5e7eb', backgroundColor: '#f8fafc', paddingHorizontal: 12, paddingVertical: 10, gap: 6, }, modalInput: { paddingVertical: 12, fontSize: 16, fontWeight: '700', letterSpacing: 1.5, color: '#0f1528', }, modalError: { marginTop: 10, fontSize: 12, color: '#ef4444', }, });