- 重构挑战列表为横向轮播,支持多进行中的挑战 - 新增挑战详情页 /challenges/[id]/index 与排行榜 /challenges/[id]/leaderboard - ChallengeProgressCard 支持小时级剩余时间显示 - 新增 ChallengeRankingItem 组件展示榜单项 - 排行榜支持分页加载、下拉刷新与错误重试 - 挑战卡片新增已结束角标与渐变遮罩 - 加入/退出挑战时展示庆祝动画与错误提示 - 统一背景渐变色与卡片阴影细节
817 lines
25 KiB
TypeScript
817 lines
25 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 { useAuthGuard } from '@/hooks/useAuthGuard';
|
||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||
import {
|
||
fetchChallengeDetail,
|
||
joinChallenge,
|
||
leaveChallenge,
|
||
reportChallengeProgress,
|
||
fetchChallengeRankings,
|
||
selectChallengeById,
|
||
selectChallengeDetailError,
|
||
selectChallengeDetailStatus,
|
||
selectJoinError,
|
||
selectJoinStatus,
|
||
selectLeaveError,
|
||
selectLeaveStatus,
|
||
selectProgressStatus,
|
||
selectChallengeRankingList
|
||
} from '@/store/challengesSlice';
|
||
import { Toast } from '@/utils/toast.utils';
|
||
import { Ionicons } from '@expo/vector-icons';
|
||
import { BlurView } from 'expo-blur';
|
||
import { Image } from 'expo-image';
|
||
import { LinearGradient } from 'expo-linear-gradient';
|
||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||
import LottieView from 'lottie-react-native';
|
||
import React, { useEffect, useMemo, useState } from 'react';
|
||
import {
|
||
ActivityIndicator,
|
||
Alert,
|
||
Dimensions,
|
||
Platform,
|
||
ScrollView,
|
||
Share,
|
||
StatusBar,
|
||
StyleSheet,
|
||
Text,
|
||
TouchableOpacity,
|
||
View,
|
||
} from 'react-native';
|
||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||
|
||
const { width } = Dimensions.get('window');
|
||
const HERO_HEIGHT = width * 0.76;
|
||
const CTA_GRADIENT: [string, string] = ['#5E8BFF', '#6B6CFF'];
|
||
const CTA_DISABLED_GRADIENT: [string, string] = ['#d3d7e8', '#c1c6da'];
|
||
|
||
const isHttpUrl = (value: string) => /^https?:\/\//i.test(value);
|
||
|
||
const formatMonthDay = (value?: string): string | undefined => {
|
||
if (!value) return undefined;
|
||
const date = new Date(value);
|
||
if (Number.isNaN(date.getTime())) return undefined;
|
||
return `${date.getMonth() + 1}月${date.getDate()}日`;
|
||
};
|
||
|
||
const buildDateRangeLabel = (challenge?: {
|
||
startAt?: string;
|
||
endAt?: string;
|
||
periodLabel?: string;
|
||
durationLabel?: string;
|
||
}): string => {
|
||
if (!challenge) return '';
|
||
const startLabel = formatMonthDay(challenge.startAt);
|
||
const endLabel = formatMonthDay(challenge.endAt);
|
||
if (startLabel && endLabel) {
|
||
return `${startLabel} - ${endLabel}`;
|
||
}
|
||
return challenge.periodLabel ?? challenge.durationLabel ?? '';
|
||
};
|
||
|
||
const formatParticipantsLabel = (count?: number): string => {
|
||
if (typeof count !== 'number') return '持续更新中';
|
||
return `${count.toLocaleString('zh-CN')} 人正在参与`;
|
||
};
|
||
|
||
export default function ChallengeDetailScreen() {
|
||
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 { ensureLoggedIn } = useAuthGuard();
|
||
|
||
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 joinStatusSelector = useMemo(() => (id ? selectJoinStatus(id) : undefined), [id]);
|
||
const joinStatus = useAppSelector((state) => (joinStatusSelector ? joinStatusSelector(state) : 'idle'));
|
||
const joinErrorSelector = useMemo(() => (id ? selectJoinError(id) : undefined), [id]);
|
||
const joinError = useAppSelector((state) => (joinErrorSelector ? joinErrorSelector(state) : undefined));
|
||
|
||
const leaveStatusSelector = useMemo(() => (id ? selectLeaveStatus(id) : undefined), [id]);
|
||
const leaveStatus = useAppSelector((state) => (leaveStatusSelector ? leaveStatusSelector(state) : 'idle'));
|
||
const leaveErrorSelector = useMemo(() => (id ? selectLeaveError(id) : undefined), [id]);
|
||
const leaveError = useAppSelector((state) => (leaveErrorSelector ? leaveErrorSelector(state) : undefined));
|
||
|
||
const progressStatusSelector = useMemo(() => (id ? selectProgressStatus(id) : undefined), [id]);
|
||
const progressStatus = useAppSelector((state) => (progressStatusSelector ? progressStatusSelector(state) : 'idle'));
|
||
|
||
const rankingListSelector = useMemo(() => (id ? selectChallengeRankingList(id) : undefined), [id]);
|
||
const rankingList = useAppSelector((state) => (rankingListSelector ? rankingListSelector(state) : undefined));
|
||
|
||
useEffect(() => {
|
||
const getData = async (id: string) => {
|
||
try {
|
||
await dispatch(fetchChallengeDetail(id)).unwrap;
|
||
} catch (error) {
|
||
}
|
||
}
|
||
|
||
if (id) {
|
||
getData(id);
|
||
}
|
||
|
||
}, [dispatch, id]);
|
||
|
||
useEffect(() => {
|
||
if (id && !rankingList) {
|
||
void dispatch(fetchChallengeRankings({ id }));
|
||
}
|
||
}, [dispatch, id, rankingList]);
|
||
|
||
|
||
const [showCelebration, setShowCelebration] = useState(false);
|
||
|
||
|
||
useEffect(() => {
|
||
if (!showCelebration) {
|
||
return;
|
||
}
|
||
const timer = setTimeout(() => {
|
||
setShowCelebration(false);
|
||
}, 2400);
|
||
return () => {
|
||
clearTimeout(timer);
|
||
};
|
||
}, [showCelebration]);
|
||
|
||
const progress = challenge?.progress;
|
||
|
||
const rankingData = useMemo(() => {
|
||
const source = rankingList?.items ?? challenge?.rankings ?? [];
|
||
return source.slice(0, 10);
|
||
}, [challenge?.rankings, rankingList?.items]);
|
||
|
||
const participantAvatars = useMemo(
|
||
() => rankingData.filter((item) => item.avatar).map((item) => item.avatar as string).slice(0, 6),
|
||
[rankingData],
|
||
);
|
||
|
||
const handleViewAllRanking = () => {
|
||
if (!id) {
|
||
return;
|
||
}
|
||
router.push({ pathname: '/challenges/[id]/leaderboard', params: { id } });
|
||
};
|
||
|
||
const dateRangeLabel = useMemo(
|
||
() =>
|
||
buildDateRangeLabel({
|
||
startAt: challenge?.startAt,
|
||
endAt: challenge?.endAt,
|
||
periodLabel: challenge?.periodLabel,
|
||
durationLabel: challenge?.durationLabel,
|
||
}),
|
||
[challenge?.startAt, challenge?.endAt, challenge?.periodLabel, challenge?.durationLabel],
|
||
);
|
||
|
||
const handleShare = async () => {
|
||
if (!challenge) {
|
||
return;
|
||
}
|
||
try {
|
||
await Share.share({
|
||
title: challenge.title,
|
||
message: `我正在参与「${challenge.title}」,一起坚持吧!`,
|
||
url: challenge.image,
|
||
});
|
||
} catch (error) {
|
||
console.warn('分享失败', error);
|
||
}
|
||
};
|
||
|
||
const handleJoin = async () => {
|
||
if (!id || joinStatus === 'loading') {
|
||
return;
|
||
}
|
||
|
||
const isLoggedIn = await ensureLoggedIn();
|
||
if (!isLoggedIn) {
|
||
// 如果未登录,用户会被重定向到登录页面
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await dispatch(joinChallenge(id));
|
||
setShowCelebration(true)
|
||
} catch (error) {
|
||
Toast.error('加入挑战失败')
|
||
}
|
||
};
|
||
|
||
const handleLeave = async () => {
|
||
if (!id || leaveStatus === 'loading') {
|
||
return;
|
||
}
|
||
try {
|
||
await dispatch(leaveChallenge(id)).unwrap();
|
||
await dispatch(fetchChallengeDetail(id)).unwrap();
|
||
} catch (error) {
|
||
Toast.error('退出挑战失败');
|
||
}
|
||
};
|
||
|
||
const handleLeaveConfirm = () => {
|
||
if (!id || leaveStatus === 'loading') {
|
||
return;
|
||
}
|
||
Alert.alert('确认退出挑战?', '退出后需要重新加入才能继续坚持。', [
|
||
{ text: '取消', style: 'cancel' },
|
||
{
|
||
text: '退出挑战',
|
||
style: 'destructive',
|
||
onPress: () => {
|
||
void handleLeave();
|
||
},
|
||
},
|
||
]);
|
||
};
|
||
|
||
const handleProgressReport = () => {
|
||
if (!id || progressStatus === 'loading') {
|
||
return;
|
||
}
|
||
dispatch(reportChallengeProgress({ id }));
|
||
};
|
||
|
||
const isJoined = challenge?.isJoined ?? false;
|
||
const isLoadingInitial = detailStatus === 'loading' && !challenge;
|
||
|
||
if (!id) {
|
||
return (
|
||
<SafeAreaView style={[styles.safeArea, { backgroundColor: colorTokens.background }]}>
|
||
<HeaderBar title="挑战详情" onBack={() => router.back()} withSafeTop transparent={false} />
|
||
<View style={styles.missingContainer}>
|
||
<Text style={[styles.missingText, { color: colorTokens.textSecondary }]}>未找到该挑战,稍后再试试吧。</Text>
|
||
</View>
|
||
</SafeAreaView>
|
||
);
|
||
}
|
||
|
||
if (isLoadingInitial) {
|
||
return (
|
||
<SafeAreaView style={[styles.safeArea, { backgroundColor: colorTokens.background }]}>
|
||
<HeaderBar title="挑战详情" onBack={() => router.back()} withSafeTop transparent={false} />
|
||
<View style={styles.missingContainer}>
|
||
<ActivityIndicator color={colorTokens.primary} />
|
||
<Text style={[styles.missingText, { color: colorTokens.textSecondary, marginTop: 16 }]}>加载挑战详情中…</Text>
|
||
</View>
|
||
</SafeAreaView>
|
||
);
|
||
}
|
||
|
||
if (!challenge) {
|
||
return (
|
||
<SafeAreaView style={[styles.safeArea, { backgroundColor: colorTokens.background }]}>
|
||
<HeaderBar title="挑战详情" onBack={() => router.back()} withSafeTop transparent={false} />
|
||
<View style={styles.missingContainer}>
|
||
<Text style={[styles.missingText, { color: colorTokens.textSecondary }]}>
|
||
{detailError ?? '未找到该挑战,稍后再试试吧。'}
|
||
</Text>
|
||
<TouchableOpacity
|
||
style={[styles.retryButton, { borderColor: colorTokens.primary }]}
|
||
activeOpacity={0.9}
|
||
onPress={() => dispatch(fetchChallengeDetail(id))}
|
||
>
|
||
<Text style={[styles.retryText, { color: colorTokens.primary }]}>重新加载</Text>
|
||
</TouchableOpacity>
|
||
</View>
|
||
</SafeAreaView>
|
||
);
|
||
}
|
||
|
||
const highlightTitle = challenge.highlightTitle ?? '立即加入挑战';
|
||
const highlightSubtitle = challenge.highlightSubtitle ?? '邀请好友一起坚持,更容易收获成果';
|
||
const joinCtaLabel = joinStatus === 'loading' ? '加入中…' : challenge.ctaLabel ?? '立即加入挑战';
|
||
const isUpcoming = challenge.status === 'upcoming';
|
||
const isExpired = challenge.status === 'expired';
|
||
const upcomingStartLabel = formatMonthDay(challenge.startAt);
|
||
const upcomingHighlightTitle = '挑战即将开始';
|
||
const upcomingHighlightSubtitle = upcomingStartLabel
|
||
? `${upcomingStartLabel} 开始,敬请期待`
|
||
: '挑战即将开启,敬请期待';
|
||
const upcomingCtaLabel = '挑战即将开始';
|
||
const expiredEndLabel = formatMonthDay(challenge.endAt);
|
||
const expiredHighlightTitle = '挑战已结束';
|
||
const expiredHighlightSubtitle = expiredEndLabel
|
||
? `${expiredEndLabel} 已截止,期待下一次挑战`
|
||
: '本轮挑战已结束,期待下一次挑战';
|
||
const expiredCtaLabel = '挑战已结束';
|
||
const leaveHighlightTitle = '先别急着离开';
|
||
const leaveHighlightSubtitle = '再坚持一下,下一个里程碑就要出现了';
|
||
const leaveCtaLabel = leaveStatus === 'loading' ? '退出中…' : '退出挑战';
|
||
|
||
let floatingHighlightTitle = highlightTitle;
|
||
let floatingHighlightSubtitle = highlightSubtitle;
|
||
let floatingCtaLabel = joinCtaLabel;
|
||
let floatingOnPress: (() => void) | undefined = handleJoin;
|
||
let floatingDisabled = joinStatus === 'loading';
|
||
let floatingError = joinError;
|
||
let isDisabledButtonState = false;
|
||
|
||
if (isJoined) {
|
||
floatingHighlightTitle = leaveHighlightTitle;
|
||
floatingHighlightSubtitle = leaveHighlightSubtitle;
|
||
floatingCtaLabel = leaveCtaLabel;
|
||
floatingOnPress = handleLeaveConfirm;
|
||
floatingDisabled = leaveStatus === 'loading';
|
||
floatingError = leaveError;
|
||
}
|
||
|
||
if (isUpcoming) {
|
||
floatingHighlightTitle = upcomingHighlightTitle;
|
||
floatingHighlightSubtitle = upcomingHighlightSubtitle;
|
||
floatingCtaLabel = upcomingCtaLabel;
|
||
floatingOnPress = undefined;
|
||
floatingDisabled = true;
|
||
floatingError = undefined;
|
||
isDisabledButtonState = true;
|
||
}
|
||
|
||
if (isExpired) {
|
||
floatingHighlightTitle = expiredHighlightTitle;
|
||
floatingHighlightSubtitle = expiredHighlightSubtitle;
|
||
floatingCtaLabel = expiredCtaLabel;
|
||
floatingOnPress = undefined;
|
||
floatingDisabled = true;
|
||
floatingError = undefined;
|
||
isDisabledButtonState = true;
|
||
}
|
||
const floatingGradientColors = isDisabledButtonState ? CTA_DISABLED_GRADIENT : CTA_GRADIENT;
|
||
const participantsLabel = formatParticipantsLabel(challenge.participantsCount);
|
||
|
||
const inlineErrorMessage = detailStatus === 'failed' && detailError ? detailError : undefined;
|
||
return (
|
||
<View style={styles.safeArea}>
|
||
<StatusBar barStyle="light-content" />
|
||
<View style={styles.container}>
|
||
<View pointerEvents="box-none" style={[styles.headerOverlay, { paddingTop: insets.top }]}>
|
||
<HeaderBar
|
||
title=""
|
||
backColor="white"
|
||
tone="light"
|
||
transparent
|
||
withSafeTop={false}
|
||
// right={
|
||
// <TouchableOpacity style={styles.circularButton} activeOpacity={0.85} onPress={handleShare}>
|
||
// <Ionicons name="share-social-outline" size={20} color="#ffffff" />
|
||
// </TouchableOpacity>
|
||
// }
|
||
/>
|
||
</View>
|
||
|
||
<ScrollView
|
||
style={styles.scrollView}
|
||
bounces
|
||
showsVerticalScrollIndicator={false}
|
||
contentContainerStyle={[
|
||
styles.scrollContent,
|
||
{ paddingBottom: (Platform.OS === 'ios' ? 180 : 160) + insets.bottom },
|
||
]}
|
||
>
|
||
<View style={styles.heroContainer}>
|
||
<Image source={{ uri: challenge.image }} style={styles.heroImage} cachePolicy={'memory-disk'} />
|
||
<LinearGradient
|
||
colors={['rgba(0,0,0,0.35)', 'rgba(0,0,0,0.15)', 'rgba(244, 246, 255, 1)']}
|
||
style={StyleSheet.absoluteFillObject}
|
||
/>
|
||
</View>
|
||
|
||
<View style={styles.headerTextBlock}>
|
||
<Text style={styles.title}>{challenge.title}</Text>
|
||
{challenge.summary ? <Text style={styles.summary}>{challenge.summary}</Text> : null}
|
||
{inlineErrorMessage ? (
|
||
<View style={styles.inlineError}>
|
||
<Ionicons name="warning-outline" size={14} color="#FF6B6B" />
|
||
<Text style={styles.inlineErrorText}>{inlineErrorMessage}</Text>
|
||
</View>
|
||
) : null}
|
||
</View>
|
||
|
||
{progress ? (
|
||
<ChallengeProgressCard
|
||
title={challenge.title}
|
||
endAt={challenge.endAt}
|
||
progress={progress}
|
||
style={styles.progressCardWrapper}
|
||
/>
|
||
) : null}
|
||
|
||
<View style={styles.detailCard}>
|
||
<View style={styles.detailRow}>
|
||
<View style={styles.detailIconWrapper}>
|
||
<Ionicons name="calendar-outline" size={20} color="#4F5BD5" />
|
||
</View>
|
||
<View style={styles.detailTextWrapper}>
|
||
<Text style={styles.detailLabel}>{dateRangeLabel}</Text>
|
||
<Text style={styles.detailMeta}>{challenge.durationLabel}</Text>
|
||
</View>
|
||
</View>
|
||
|
||
<View style={styles.detailRow}>
|
||
<View style={styles.detailIconWrapper}>
|
||
<Ionicons name="flag-outline" size={20} color="#4F5BD5" />
|
||
</View>
|
||
<View style={styles.detailTextWrapper}>
|
||
<Text style={styles.detailLabel}>{challenge.requirementLabel}</Text>
|
||
<Text style={styles.detailMeta}>按日打卡自动累计</Text>
|
||
</View>
|
||
</View>
|
||
|
||
<View style={styles.detailRow}>
|
||
<View style={styles.detailIconWrapper}>
|
||
<Ionicons name="people-outline" size={20} color="#4F5BD5" />
|
||
</View>
|
||
<View style={[styles.detailTextWrapper, { flex: 1 }]}>
|
||
<Text style={styles.detailLabel}>{participantsLabel}</Text>
|
||
{participantAvatars.length ? (
|
||
<View style={styles.avatarRow}>
|
||
{participantAvatars.map((avatar, index) => (
|
||
<Image
|
||
key={`${avatar}-${index}`}
|
||
source={{ uri: avatar }}
|
||
style={[styles.avatar, index > 0 && styles.avatarOffset]}
|
||
cachePolicy={'memory-disk'}
|
||
/>
|
||
))}
|
||
{challenge.participantsCount && challenge.participantsCount > participantAvatars.length ? (
|
||
<TouchableOpacity style={styles.moreAvatarButton}>
|
||
<Text style={styles.moreAvatarText}>更多</Text>
|
||
</TouchableOpacity>
|
||
) : null}
|
||
</View>
|
||
) : null}
|
||
</View>
|
||
</View>
|
||
</View>
|
||
|
||
<View style={styles.sectionHeader}>
|
||
<Text style={styles.sectionTitle}>排行榜</Text>
|
||
<TouchableOpacity activeOpacity={0.8} onPress={handleViewAllRanking}>
|
||
<Text style={styles.sectionAction}>查看全部</Text>
|
||
</TouchableOpacity>
|
||
</View>
|
||
|
||
{challenge.rankingDescription ? (
|
||
<Text style={styles.sectionSubtitle}>{challenge.rankingDescription}</Text>
|
||
) : null}
|
||
|
||
<View style={styles.rankingCard}>
|
||
{rankingData.length ? (
|
||
rankingData.map((item, index) => (
|
||
<ChallengeRankingItem key={item.id ?? index} item={item} index={index} showDivider={index > 0} />
|
||
))
|
||
) : (
|
||
<View style={styles.emptyRanking}>
|
||
<Text style={styles.emptyRankingText}>榜单即将开启,快来抢占席位。</Text>
|
||
</View>
|
||
)}
|
||
</View>
|
||
</ScrollView>
|
||
|
||
<View pointerEvents="box-none" style={[styles.floatingCTAContainer, { paddingBottom: insets.bottom }]}>
|
||
<BlurView intensity={10} tint="light" style={styles.floatingCTABlur}>
|
||
<View style={styles.floatingCTAContent}>
|
||
<View style={styles.highlightCopy}>
|
||
<Text style={styles.highlightTitle}>{floatingHighlightTitle}</Text>
|
||
<Text style={styles.highlightSubtitle}>{floatingHighlightSubtitle}</Text>
|
||
{floatingError ? <Text style={styles.ctaErrorText}>{floatingError}</Text> : null}
|
||
</View>
|
||
<TouchableOpacity
|
||
style={styles.highlightButton}
|
||
activeOpacity={0.9}
|
||
onPress={floatingOnPress}
|
||
disabled={floatingDisabled}
|
||
>
|
||
<LinearGradient
|
||
colors={floatingGradientColors}
|
||
start={{ x: 0, y: 0 }}
|
||
end={{ x: 1, y: 1 }}
|
||
style={styles.highlightButtonBackground}
|
||
>
|
||
<Text style={[styles.highlightButtonLabel, isDisabledButtonState && styles.highlightButtonLabelDisabled]}>
|
||
{floatingCtaLabel}
|
||
</Text>
|
||
</LinearGradient>
|
||
</TouchableOpacity>
|
||
</View>
|
||
</BlurView>
|
||
</View>
|
||
</View>
|
||
{showCelebration && (
|
||
<View pointerEvents="none" style={styles.celebrationOverlay}>
|
||
<LottieView
|
||
autoPlay
|
||
loop={false}
|
||
source={require('@/assets/lottie/Confetti.json')}
|
||
style={styles.celebrationAnimation}
|
||
/>
|
||
</View>
|
||
)}
|
||
</View>
|
||
);
|
||
}
|
||
|
||
const styles = StyleSheet.create({
|
||
container: {
|
||
flex: 1,
|
||
},
|
||
safeArea: {
|
||
flex: 1,
|
||
backgroundColor: '#f3f4fb',
|
||
},
|
||
headerOverlay: {
|
||
position: 'absolute',
|
||
left: 0,
|
||
right: 0,
|
||
top: 0,
|
||
zIndex: 20,
|
||
},
|
||
heroContainer: {
|
||
height: HERO_HEIGHT,
|
||
width: '100%',
|
||
overflow: 'hidden',
|
||
position: 'absolute',
|
||
top: 0
|
||
},
|
||
heroImage: {
|
||
width: '100%',
|
||
height: '100%',
|
||
},
|
||
scrollView: {
|
||
flex: 1,
|
||
},
|
||
scrollContent: {
|
||
paddingBottom: Platform.select({ ios: 40, default: 28 }),
|
||
},
|
||
progressCardWrapper: {
|
||
marginTop: 20,
|
||
marginHorizontal: 24,
|
||
},
|
||
floatingCTAContainer: {
|
||
position: 'absolute',
|
||
left: 0,
|
||
right: 0,
|
||
bottom: 0,
|
||
paddingHorizontal: 20,
|
||
},
|
||
floatingCTABlur: {
|
||
borderRadius: 24,
|
||
overflow: 'hidden',
|
||
borderWidth: 1,
|
||
borderColor: 'rgba(255,255,255,0.6)',
|
||
backgroundColor: 'rgba(243, 244, 251, 0.85)',
|
||
},
|
||
floatingCTAContent: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
paddingVertical: 16,
|
||
paddingHorizontal: 20,
|
||
},
|
||
highlightCopy: {
|
||
flex: 1,
|
||
marginRight: 16,
|
||
},
|
||
headerTextBlock: {
|
||
paddingHorizontal: 24,
|
||
marginTop: HERO_HEIGHT - 60,
|
||
alignItems: 'center',
|
||
},
|
||
periodLabel: {
|
||
fontSize: 14,
|
||
color: '#596095',
|
||
letterSpacing: 0.2,
|
||
},
|
||
title: {
|
||
marginTop: 10,
|
||
fontSize: 24,
|
||
fontWeight: '800',
|
||
color: '#1c1f3a',
|
||
textAlign: 'center',
|
||
},
|
||
summary: {
|
||
marginTop: 12,
|
||
fontSize: 14,
|
||
lineHeight: 20,
|
||
color: '#7080b4',
|
||
textAlign: 'center',
|
||
},
|
||
inlineError: {
|
||
marginTop: 12,
|
||
paddingHorizontal: 12,
|
||
paddingVertical: 8,
|
||
borderRadius: 12,
|
||
backgroundColor: 'rgba(255, 107, 107, 0.12)',
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
},
|
||
inlineErrorText: {
|
||
marginLeft: 6,
|
||
fontSize: 12,
|
||
color: '#FF6B6B',
|
||
flexShrink: 1,
|
||
},
|
||
detailCard: {
|
||
marginTop: 28,
|
||
marginHorizontal: 20,
|
||
padding: 20,
|
||
borderRadius: 28,
|
||
backgroundColor: '#ffffff',
|
||
shadowColor: 'rgba(30, 41, 59, 0.18)',
|
||
shadowOpacity: 0.2,
|
||
shadowRadius: 20,
|
||
shadowOffset: { width: 0, height: 12 },
|
||
elevation: 8,
|
||
gap: 20,
|
||
},
|
||
detailRow: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
},
|
||
detailIconWrapper: {
|
||
width: 42,
|
||
height: 42,
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
},
|
||
detailTextWrapper: {
|
||
marginLeft: 14,
|
||
},
|
||
detailLabel: {
|
||
fontSize: 15,
|
||
fontWeight: '600',
|
||
color: '#1c1f3a',
|
||
},
|
||
detailMeta: {
|
||
marginTop: 4,
|
||
fontSize: 12,
|
||
color: '#6f7ba7',
|
||
},
|
||
avatarRow: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
marginTop: 12,
|
||
},
|
||
avatar: {
|
||
width: 36,
|
||
height: 36,
|
||
borderRadius: 18,
|
||
borderWidth: 2,
|
||
borderColor: '#fff',
|
||
},
|
||
avatarOffset: {
|
||
marginLeft: -12,
|
||
},
|
||
moreAvatarButton: {
|
||
marginLeft: 12,
|
||
paddingHorizontal: 12,
|
||
paddingVertical: 6,
|
||
borderRadius: 14,
|
||
backgroundColor: '#EEF0FF',
|
||
},
|
||
moreAvatarText: {
|
||
fontSize: 12,
|
||
color: '#4F5BD5',
|
||
fontWeight: '600',
|
||
},
|
||
sectionHeader: {
|
||
marginTop: 36,
|
||
marginHorizontal: 24,
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between',
|
||
},
|
||
sectionTitle: {
|
||
fontSize: 18,
|
||
fontWeight: '700',
|
||
color: '#1c1f3a',
|
||
},
|
||
sectionAction: {
|
||
fontSize: 13,
|
||
fontWeight: '600',
|
||
color: '#5F6BF0',
|
||
},
|
||
sectionSubtitle: {
|
||
marginTop: 8,
|
||
marginHorizontal: 24,
|
||
fontSize: 13,
|
||
color: '#6f7ba7',
|
||
lineHeight: 18,
|
||
},
|
||
rankingCard: {
|
||
marginTop: 20,
|
||
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',
|
||
},
|
||
emptyRankingText: {
|
||
fontSize: 14,
|
||
color: '#6f7ba7',
|
||
},
|
||
highlightTitle: {
|
||
fontSize: 16,
|
||
fontWeight: '700',
|
||
color: '#1c1f3a',
|
||
},
|
||
highlightSubtitle: {
|
||
marginTop: 4,
|
||
fontSize: 12,
|
||
color: '#5f6a97',
|
||
lineHeight: 18,
|
||
},
|
||
ctaErrorText: {
|
||
marginTop: 8,
|
||
fontSize: 12,
|
||
color: '#FF6B6B',
|
||
},
|
||
highlightButton: {
|
||
borderRadius: 22,
|
||
overflow: 'hidden',
|
||
},
|
||
highlightButtonBackground: {
|
||
borderRadius: 22,
|
||
paddingVertical: 10,
|
||
paddingHorizontal: 18,
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
},
|
||
highlightButtonLabel: {
|
||
fontSize: 14,
|
||
fontWeight: '700',
|
||
color: '#ffffff',
|
||
},
|
||
highlightButtonLabelDisabled: {
|
||
color: '#6f7799',
|
||
},
|
||
circularButton: {
|
||
width: 40,
|
||
height: 40,
|
||
borderRadius: 20,
|
||
backgroundColor: 'rgba(255,255,255,0.24)',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
borderWidth: 1,
|
||
borderColor: 'rgba(255,255,255,0.45)',
|
||
},
|
||
shareIcon: {
|
||
fontSize: 18,
|
||
color: '#ffffff',
|
||
fontWeight: '700',
|
||
},
|
||
missingContainer: {
|
||
flex: 1,
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
paddingHorizontal: 32,
|
||
},
|
||
missingText: {
|
||
fontSize: 16,
|
||
textAlign: 'center',
|
||
},
|
||
retryButton: {
|
||
marginTop: 18,
|
||
paddingHorizontal: 20,
|
||
paddingVertical: 10,
|
||
borderRadius: 22,
|
||
borderWidth: 1,
|
||
},
|
||
retryText: {
|
||
fontSize: 14,
|
||
fontWeight: '600',
|
||
},
|
||
celebrationOverlay: {
|
||
...StyleSheet.absoluteFillObject,
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
zIndex: 40,
|
||
},
|
||
celebrationAnimation: {
|
||
width: width * 1.3,
|
||
height: width * 1.3,
|
||
},
|
||
});
|