- 新增 i18n 翻译资源,覆盖睡眠、饮水、体重、锻炼、用药 AI 识别、步数、健身圆环、基础代谢及设置等核心模块 - 重构相关页面及组件(如 SleepDetail, WaterDetail, WorkoutHistory 等)使用 `useI18n` 钩子替换硬编码文本 - 升级 `utils/date` 工具库与 `DateSelector` 组件,支持基于语言环境的日期格式化与显示 - 完善登录页、注销流程及权限申请弹窗的双语提示信息 - 优化部分页面的 UI 细节与字体样式以适配多语言显示
306 lines
10 KiB
TypeScript
306 lines
10 KiB
TypeScript
import ChallengeProgressCard from '@/components/challenges/ChallengeProgressCard';
|
|
import { ChallengeRankingItem } from '@/components/challenges/ChallengeRankingItem';
|
|
import { HeaderBar } from '@/components/ui/HeaderBar';
|
|
import { Colors } from '@/constants/Colors';
|
|
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
|
import { useI18n } from '@/hooks/useI18n';
|
|
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
|
import {
|
|
fetchChallengeDetail,
|
|
fetchChallengeRankings,
|
|
selectChallengeById,
|
|
selectChallengeDetailError,
|
|
selectChallengeDetailStatus,
|
|
selectChallengeRankingError,
|
|
selectChallengeRankingList,
|
|
selectChallengeRankingLoadMoreStatus,
|
|
selectChallengeRankingStatus,
|
|
} from '@/store/challengesSlice';
|
|
import { useLocalSearchParams, useRouter } from 'expo-router';
|
|
import React, { useEffect, useMemo } from 'react';
|
|
import type { NativeScrollEvent, NativeSyntheticEvent } from 'react-native';
|
|
import {
|
|
ActivityIndicator,
|
|
RefreshControl,
|
|
ScrollView,
|
|
StyleSheet,
|
|
Text,
|
|
View
|
|
} from 'react-native';
|
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
|
|
export default function ChallengeLeaderboardScreen() {
|
|
const safeAreaTop = useSafeAreaTop()
|
|
const { id } = useLocalSearchParams<{ id?: string }>();
|
|
const router = useRouter();
|
|
const dispatch = useAppDispatch();
|
|
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
|
const colorTokens = Colors[theme];
|
|
const insets = useSafeAreaInsets();
|
|
const { t } = useI18n();
|
|
|
|
const challengeSelector = useMemo(() => (id ? selectChallengeById(id) : undefined), [id]);
|
|
const challenge = useAppSelector((state) => (challengeSelector ? challengeSelector(state) : undefined));
|
|
|
|
const detailStatusSelector = useMemo(() => (id ? selectChallengeDetailStatus(id) : undefined), [id]);
|
|
const detailStatus = useAppSelector((state) => (detailStatusSelector ? detailStatusSelector(state) : 'idle'));
|
|
const detailErrorSelector = useMemo(() => (id ? selectChallengeDetailError(id) : undefined), [id]);
|
|
const detailError = useAppSelector((state) => (detailErrorSelector ? detailErrorSelector(state) : undefined));
|
|
|
|
const rankingListSelector = useMemo(() => (id ? selectChallengeRankingList(id) : undefined), [id]);
|
|
const rankingList = useAppSelector((state) => (rankingListSelector ? rankingListSelector(state) : undefined));
|
|
const rankingStatusSelector = useMemo(() => (id ? selectChallengeRankingStatus(id) : undefined), [id]);
|
|
const rankingStatus = useAppSelector((state) => (rankingStatusSelector ? rankingStatusSelector(state) : 'idle'));
|
|
const rankingLoadMoreStatusSelector = useMemo(
|
|
() => (id ? selectChallengeRankingLoadMoreStatus(id) : undefined),
|
|
[id]
|
|
);
|
|
const rankingLoadMoreStatus = useAppSelector((state) =>
|
|
rankingLoadMoreStatusSelector ? rankingLoadMoreStatusSelector(state) : 'idle'
|
|
);
|
|
const rankingErrorSelector = useMemo(() => (id ? selectChallengeRankingError(id) : undefined), [id]);
|
|
const rankingError = useAppSelector((state) => (rankingErrorSelector ? rankingErrorSelector(state) : undefined));
|
|
|
|
useEffect(() => {
|
|
if (id) {
|
|
void dispatch(fetchChallengeDetail(id));
|
|
}
|
|
}, [dispatch, id]);
|
|
|
|
useEffect(() => {
|
|
if (id && !rankingList) {
|
|
void dispatch(fetchChallengeRankings({ id }));
|
|
}
|
|
}, [dispatch, id, rankingList]);
|
|
|
|
if (!id) {
|
|
return (
|
|
<View style={[styles.safeArea, { backgroundColor: colorTokens.background }]}>
|
|
<HeaderBar title={t('challengeDetail.leaderboard.title')} onBack={() => router.back()} withSafeTop />
|
|
<View style={{
|
|
paddingTop: safeAreaTop
|
|
}} />
|
|
<View style={styles.missingContainer}>
|
|
<Text style={[styles.missingText, { color: colorTokens.textSecondary }]}>{t('challengeDetail.leaderboard.notFound')}</Text>
|
|
</View>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
if (detailStatus === 'loading' && !challenge) {
|
|
return (
|
|
<View style={[styles.safeArea, { backgroundColor: '#f3f4fb' }]}>
|
|
<HeaderBar title={t('challengeDetail.leaderboard.title')} onBack={() => router.back()} withSafeTop />
|
|
<View style={styles.loadingContainer}>
|
|
<ActivityIndicator color={colorTokens.primary} />
|
|
<Text style={[styles.loadingText, { color: colorTokens.textSecondary }]}>{t('challengeDetail.leaderboard.loading')}</Text>
|
|
</View>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const hasMore = rankingList?.hasMore ?? false;
|
|
const isRefreshing = rankingStatus === 'loading';
|
|
const isLoadingMore = rankingLoadMoreStatus === 'loading';
|
|
const defaultPageSize = rankingList?.pageSize ?? 20;
|
|
const showInitialRankingLoading = isRefreshing && (!rankingList || rankingList.items.length === 0);
|
|
|
|
const handleRefresh = () => {
|
|
if (!id) {
|
|
return;
|
|
}
|
|
void dispatch(fetchChallengeRankings({ id, page: 1, pageSize: defaultPageSize }));
|
|
};
|
|
|
|
const handleLoadMore = () => {
|
|
if (!id || !rankingList || !hasMore || isLoadingMore || rankingStatus === 'loading') {
|
|
return;
|
|
}
|
|
void dispatch(
|
|
fetchChallengeRankings({ id, page: rankingList.page + 1, pageSize: rankingList.pageSize })
|
|
);
|
|
};
|
|
|
|
const handleScroll = (event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
|
const { layoutMeasurement, contentOffset, contentSize } = event.nativeEvent;
|
|
const paddingToBottom = 160;
|
|
if (layoutMeasurement.height + contentOffset.y >= contentSize.height - paddingToBottom) {
|
|
handleLoadMore();
|
|
}
|
|
};
|
|
|
|
if (!challenge) {
|
|
return (
|
|
<View style={[styles.safeArea, { backgroundColor: '#f3f4fb' }]}>
|
|
<HeaderBar title={t('challengeDetail.leaderboard.title')} onBack={() => router.back()} withSafeTop />
|
|
<View style={styles.missingContainer}>
|
|
<Text style={[styles.missingText, { color: colorTokens.textSecondary }]}>
|
|
{detailError ?? t('challengeDetail.leaderboard.loadFailed')}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const rankingData = rankingList?.items ?? challenge.rankings ?? [];
|
|
const subtitle = challenge.rankingDescription ?? challenge.summary;
|
|
|
|
return (
|
|
<View style={[styles.safeArea, { backgroundColor: '#f3f4fb' }]}>
|
|
<HeaderBar title={t('challengeDetail.leaderboard.title')} onBack={() => router.back()} withSafeTop />
|
|
<ScrollView
|
|
style={styles.scrollView}
|
|
contentContainerStyle={{ paddingBottom: insets.bottom + 40, paddingTop: safeAreaTop }}
|
|
showsVerticalScrollIndicator={false}
|
|
refreshControl={
|
|
<RefreshControl
|
|
refreshing={isRefreshing}
|
|
onRefresh={handleRefresh}
|
|
tintColor={colorTokens.primary}
|
|
/>
|
|
}
|
|
onScroll={handleScroll}
|
|
scrollEventThrottle={16}
|
|
>
|
|
<View style={styles.pageHeader}>
|
|
<Text style={styles.challengeTitle}>{challenge.title}</Text>
|
|
{subtitle ? <Text style={styles.challengeSubtitle}>{subtitle}</Text> : null}
|
|
{challenge.progress ? (
|
|
<ChallengeProgressCard
|
|
title={challenge.title}
|
|
endAt={challenge.endAt}
|
|
progress={challenge.progress}
|
|
style={styles.progressCardWrapper}
|
|
/>
|
|
) : null}
|
|
</View>
|
|
|
|
<View style={styles.rankingCard}>
|
|
{showInitialRankingLoading ? (
|
|
<View style={styles.rankingLoading}>
|
|
<ActivityIndicator color={colorTokens.primary} />
|
|
<Text style={[styles.loadingText, { color: colorTokens.textSecondary }]}>{t('challengeDetail.leaderboard.loading')}</Text>
|
|
</View>
|
|
) : rankingData.length ? (
|
|
rankingData.map((item, index) => (
|
|
<ChallengeRankingItem
|
|
key={item.id ?? index}
|
|
item={item}
|
|
index={index}
|
|
showDivider={index > 0}
|
|
unit={challenge?.unit}
|
|
/>
|
|
))
|
|
) : rankingError ? (
|
|
<View style={styles.emptyRanking}>
|
|
<Text style={styles.rankingErrorText}>{rankingError}</Text>
|
|
</View>
|
|
) : (
|
|
<View style={styles.emptyRanking}>
|
|
<Text style={styles.emptyRankingText}>{t('challengeDetail.leaderboard.empty')}</Text>
|
|
</View>
|
|
)}
|
|
{isLoadingMore ? (
|
|
<View style={styles.loadMoreIndicator}>
|
|
<ActivityIndicator color={colorTokens.primary} size="small" />
|
|
<Text style={[styles.loadingText, { color: colorTokens.textSecondary, marginTop: 8 }]}>{t('challengeDetail.leaderboard.loadMore')}</Text>
|
|
</View>
|
|
) : null}
|
|
{rankingLoadMoreStatus === 'failed' ? (
|
|
<View style={styles.loadMoreIndicator}>
|
|
<Text style={styles.loadMoreErrorText}>{t('challengeDetail.leaderboard.loadMoreFailed')}</Text>
|
|
</View>
|
|
) : null}
|
|
</View>
|
|
</ScrollView>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
safeArea: {
|
|
flex: 1,
|
|
},
|
|
scrollView: {
|
|
flex: 1,
|
|
backgroundColor: 'transparent',
|
|
},
|
|
pageHeader: {
|
|
paddingHorizontal: 24,
|
|
paddingTop: 24,
|
|
},
|
|
challengeTitle: {
|
|
fontSize: 22,
|
|
fontWeight: '800',
|
|
color: '#1c1f3a',
|
|
},
|
|
challengeSubtitle: {
|
|
marginTop: 8,
|
|
fontSize: 14,
|
|
color: '#6f7ba7',
|
|
lineHeight: 20,
|
|
},
|
|
progressCardWrapper: {
|
|
marginTop: 20,
|
|
},
|
|
rankingCard: {
|
|
marginTop: 24,
|
|
marginHorizontal: 24,
|
|
borderRadius: 24,
|
|
backgroundColor: '#ffffff',
|
|
paddingVertical: 10,
|
|
shadowColor: 'rgba(30, 41, 59, 0.12)',
|
|
shadowOpacity: 0.16,
|
|
shadowRadius: 18,
|
|
shadowOffset: { width: 0, height: 10 },
|
|
elevation: 6,
|
|
},
|
|
emptyRanking: {
|
|
paddingVertical: 40,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
emptyRankingText: {
|
|
fontSize: 14,
|
|
color: '#6f7ba7',
|
|
},
|
|
rankingLoading: {
|
|
paddingVertical: 32,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
rankingErrorText: {
|
|
fontSize: 14,
|
|
color: '#eb5757',
|
|
},
|
|
loadMoreIndicator: {
|
|
paddingVertical: 16,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
loadMoreErrorText: {
|
|
fontSize: 13,
|
|
color: '#eb5757',
|
|
},
|
|
loadingContainer: {
|
|
flex: 1,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
loadingText: {
|
|
marginTop: 16,
|
|
fontSize: 14,
|
|
},
|
|
missingContainer: {
|
|
flex: 1,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
paddingHorizontal: 32,
|
|
},
|
|
missingText: {
|
|
fontSize: 14,
|
|
textAlign: 'center',
|
|
},
|
|
});
|