Files
digital-pilates/app/challenges/[id]/index.tsx
richarjiang 705d921c14 feat(badges): 添加勋章系统和展示功能
实现完整的勋章系统,包括勋章列表展示、自动弹窗展示和分享功能。

- 新增勋章列表页面,支持已获得和待解锁勋章的分类展示
- 在个人中心添加勋章预览模块,显示前3个勋章和总数统计
- 实现勋章展示弹窗,支持动画效果和玻璃态UI
- 添加勋章分享功能,可生成分享卡片
- 新增 badgesSlice 管理勋章状态,包括获取、排序和计数逻辑
- 添加勋章服务 API 封装,支持获取勋章列表和标记已展示
- 完善中英文国际化文案
2025-11-14 17:17:17 +08:00

1088 lines
33 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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,
fetchChallengeRankings,
joinChallenge,
leaveChallenge,
reportChallengeProgress,
selectChallengeById,
selectChallengeDetailError,
selectChallengeDetailStatus,
selectChallengeRankingList,
selectJoinError,
selectJoinStatus,
selectLeaveError,
selectLeaveStatus,
selectProgressStatus
} from '@/store/challengesSlice';
import { Toast } from '@/utils/toast.utils';
import { Ionicons } from '@expo/vector-icons';
import { BlurView } from 'expo-blur';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
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, useRef, 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';
import { captureRef } from 'react-native-view-shot';
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 shareCardRef = useRef<View>(null);
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 || !shareCardRef.current) {
return;
}
try {
Toast.show({
type: 'info',
text1: '正在生成分享卡片...',
});
// 捕获分享卡片视图
const uri = await captureRef(shareCardRef, {
format: 'png',
quality: 0.9,
});
// 分享图片
const shareMessage = isJoined && progress
? `我正在参与「${challenge.title}」挑战,已完成 ${progress.completed}/${progress.target} 天!一起加入吧!`
: `发现一个很棒的挑战「${challenge.title}」,一起来参与吧!`;
await Share.share({
title: challenge.title,
message: shareMessage,
url: Platform.OS === 'ios' ? uri : `file://${uri}`,
});
} catch (error) {
console.warn('分享失败', error);
Toast.error('分享失败,请稍后重试');
}
};
const handleJoin = async () => {
if (!id || joinStatus === 'loading') {
return;
}
const isLoggedIn = await ensureLoggedIn();
if (!isLoggedIn) {
// 如果未登录,用户会被重定向到登录页面
return;
}
try {
await dispatch(joinChallenge(id)).unwrap();
await dispatch(fetchChallengeDetail(id)).unwrap();
await dispatch(fetchChallengeRankings({ 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}>
{/* 隐藏的分享卡片,用于截图 */}
<View style={styles.offscreenContainer}>
<View ref={shareCardRef} style={styles.shareCard} collapsable={false}>
{/* 背景图片 */}
<Image
source={{ uri: challenge.image }}
style={styles.shareCardBg}
cachePolicy={'memory-disk'}
/>
<LinearGradient
colors={['rgba(0,0,0,0.4)', 'rgba(0,0,0,0.65)']}
style={StyleSheet.absoluteFillObject}
/>
{/* 分享卡片内容 */}
<View style={styles.shareCardContent}>
<Text style={styles.shareCardTitle}>{challenge.title}</Text>
{challenge.summary ? (
<Text style={styles.shareCardSummary} numberOfLines={2}>
{challenge.summary}
</Text>
) : null}
{/* 根据是否加入显示不同内容 */}
{isJoined && progress ? (
// 已加入:显示个人进度
<View style={styles.shareProgressContainer}>
<View style={styles.shareProgressHeader}>
<Text style={styles.shareProgressLabel}></Text>
<Text style={styles.shareProgressValue}>
{progress.completed} / {progress.target}
</Text>
</View>
{/* 进度条 */}
<View style={styles.shareProgressTrack}>
<View
style={[
styles.shareProgressBar,
{ width: `${Math.min(100, (progress.completed / progress.target) * 100)}%` }
]}
/>
</View>
<Text style={styles.shareProgressSubtext}>
{progress.completed === progress.target
? '🎉 已完成挑战!'
: `还差 ${progress.target - progress.completed} 天完成挑战`}
</Text>
</View>
) : (
// 未加入:显示挑战信息
<View style={styles.shareInfoContainer}>
<View style={styles.shareInfoRow}>
<View style={styles.shareInfoIconWrapper}>
<Ionicons name="calendar-outline" size={20} color="#5E8BFF" />
</View>
<View style={styles.shareInfoTextWrapper}>
<Text style={styles.shareInfoLabel}>{dateRangeLabel}</Text>
{challenge.durationLabel ? (
<Text style={styles.shareInfoMeta}>{challenge.durationLabel}</Text>
) : null}
</View>
</View>
<View style={styles.shareInfoRow}>
<View style={styles.shareInfoIconWrapper}>
<Ionicons name="flag-outline" size={20} color="#5E8BFF" />
</View>
<View style={styles.shareInfoTextWrapper}>
<Text style={styles.shareInfoLabel}>{challenge.requirementLabel}</Text>
<Text style={styles.shareInfoMeta}></Text>
</View>
</View>
<View style={styles.shareInfoRow}>
<View style={styles.shareInfoIconWrapper}>
<Ionicons name="people-outline" size={20} color="#5E8BFF" />
</View>
<View style={styles.shareInfoTextWrapper}>
<Text style={styles.shareInfoLabel}>{participantsLabel}</Text>
<Text style={styles.shareInfoMeta}></Text>
</View>
</View>
</View>
)}
{/* 底部标识 */}
<View style={styles.shareCardFooter}>
<Text style={styles.shareCardFooterText}>Out Live · </Text>
</View>
</View>
</View>
</View>
<StatusBar barStyle="light-content" />
<View style={styles.container}>
<View pointerEvents="box-none" style={[styles.headerOverlay, { paddingTop: insets.top }]}>
<HeaderBar
title=""
tone="light"
transparent
withSafeTop={false}
right={
isLiquidGlassAvailable() ? (
<TouchableOpacity
onPress={handleShare}
activeOpacity={0.7}
>
<GlassView
style={styles.shareButton}
glassEffectStyle="clear"
tintColor="rgba(255, 255, 255, 0.3)"
isInteractive={true}
>
<Ionicons name="share-social-outline" size={20} color="#ffffff" />
</GlassView>
</TouchableOpacity>
) : (
<TouchableOpacity
onPress={handleShare}
style={[styles.shareButton, styles.fallbackShareButton]}
activeOpacity={0.7}
>
<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}
unit={challenge?.unit}
/>
))
) : (
<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',
},
shareButton: {
width: 40,
height: 40,
borderRadius: 20,
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
},
fallbackShareButton: {
backgroundColor: 'rgba(255, 255, 255, 0.24)',
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.45)',
},
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,
},
// 分享卡片样式
offscreenContainer: {
position: 'absolute',
left: -9999,
top: -9999,
opacity: 0,
},
shareCard: {
width: 375,
height: 500,
backgroundColor: '#fff',
overflow: 'hidden',
borderRadius: 24,
},
shareCardBg: {
...StyleSheet.absoluteFillObject,
width: '100%',
height: '100%',
},
shareCardContent: {
flex: 1,
padding: 24,
justifyContent: 'space-between',
},
shareCardTitle: {
fontSize: 28,
fontWeight: '800',
color: '#ffffff',
marginTop: 20,
textShadowColor: 'rgba(0, 0, 0, 0.3)',
textShadowOffset: { width: 0, height: 2 },
textShadowRadius: 4,
},
shareCardSummary: {
fontSize: 15,
color: '#ffffff',
marginTop: 12,
lineHeight: 22,
opacity: 0.95,
textShadowColor: 'rgba(0, 0, 0, 0.25)',
textShadowOffset: { width: 0, height: 1 },
textShadowRadius: 3,
},
shareProgressContainer: {
backgroundColor: 'rgba(255, 255, 255, 0.95)',
borderRadius: 20,
padding: 20,
marginTop: 'auto',
},
shareInfoContainer: {
backgroundColor: 'rgba(255, 255, 255, 0.95)',
borderRadius: 20,
padding: 20,
marginTop: 'auto',
gap: 16,
},
shareInfoRow: {
flexDirection: 'row',
alignItems: 'center',
},
shareInfoIconWrapper: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: '#EEF0FF',
alignItems: 'center',
justifyContent: 'center',
},
shareInfoTextWrapper: {
marginLeft: 12,
flex: 1,
},
shareInfoLabel: {
fontSize: 14,
fontWeight: '600',
color: '#1c1f3a',
},
shareInfoMeta: {
fontSize: 12,
color: '#707baf',
marginTop: 2,
},
shareProgressHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 16,
},
shareProgressLabel: {
fontSize: 14,
fontWeight: '600',
color: '#1c1f3a',
},
shareProgressValue: {
fontSize: 18,
fontWeight: '800',
color: '#5E8BFF',
},
shareProgressTrack: {
height: 8,
backgroundColor: '#eceffa',
borderRadius: 4,
overflow: 'hidden',
},
shareProgressBar: {
height: '100%',
backgroundColor: '#5E8BFF',
borderRadius: 4,
},
shareProgressSubtext: {
fontSize: 13,
color: '#707baf',
marginTop: 12,
textAlign: 'center',
fontWeight: '500',
},
shareCardFooter: {
alignItems: 'center',
paddingTop: 16,
},
shareCardFooterText: {
fontSize: 12,
color: '#ffffff',
opacity: 0.8,
fontWeight: '600',
},
});