Files
digital-pilates/app/challenges/[id]/index.tsx
richarjiang 518282ecb8 feat(challenges): 实现自定义挑战的编辑与删除功能并完善多语言支持
- 新增自定义挑战的编辑模式,支持修改挑战信息
- 在详情页为创建者添加删除(归档)挑战的功能入口
- 全面完善挑战创建页面的国际化(i18n)文案适配
- 优化个人中心页面的字体样式,统一使用 AliBold/Regular
- 更新 Store 逻辑以处理挑战更新、删除及列表数据映射调整
2025-11-26 19:07:19 +08:00

1537 lines
49 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 { ChallengeSource } from '@/services/challengesApi';
import {
archiveCustomChallengeThunk,
fetchChallengeDetail,
fetchChallengeRankings,
fetchChallenges,
joinChallenge,
leaveChallenge,
reportChallengeProgress,
selectArchiveError,
selectArchiveStatus,
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 dayjs from 'dayjs';
import { BlurView } from 'expo-blur';
import * as Clipboard from 'expo-clipboard';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import * as Haptics from 'expo-haptics';
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 { useTranslation } from 'react-i18next';
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 { t } = useTranslation();
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 archiveStatusSelector = useMemo(() => (id ? selectArchiveStatus(id) : undefined), [id]);
const archiveStatus = useAppSelector((state) => (archiveStatusSelector ? archiveStatusSelector(state) : 'idle'));
const archiveErrorSelector = useMemo(() => (id ? selectArchiveError(id) : undefined), [id]);
const archiveError = useAppSelector((state) => (archiveErrorSelector ? archiveErrorSelector(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 isJoined = challenge?.isJoined ?? false;
const isCustomChallenge = challenge?.source === ChallengeSource.CUSTOM;
const isCreator = challenge?.isCreator ?? false;
const isCustomCreator = isCustomChallenge && isCreator;
const canEdit = isCustomChallenge && isCreator;
const lastProgressAt = useMemo(() => {
const progressRecord = challenge?.progress as { lastProgressAt?: string; last_progress_at?: string } | undefined;
return progressRecord?.lastProgressAt ?? progressRecord?.last_progress_at;
}, [challenge?.progress]);
const hasCheckedInToday = useMemo(() => {
if (!challenge?.progress) {
return false;
}
if (lastProgressAt) {
const lastDate = dayjs(lastProgressAt);
if (lastDate.isValid() && lastDate.isSame(dayjs(), 'day')) {
return true;
}
}
return challenge.progress.checkedInToday ?? false;
}, [challenge?.progress, lastProgressAt]);
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 showShareCode = isJoined && Boolean(challenge?.shareCode);
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: t('challengeDetail.share.generating'),
});
// 捕获分享卡片视图
const uri = await captureRef(shareCardRef, {
format: 'png',
quality: 0.9,
});
// 分享图片
const shareMessage = isJoined && progress
? t('challengeDetail.share.messageJoined', { title: challenge.title, completed: progress.completed, target: progress.target })
: t('challengeDetail.share.messageNotJoined', { title: challenge.title });
await Share.share({
title: challenge.title,
message: shareMessage,
url: Platform.OS === 'ios' ? uri : `file://${uri}`,
});
} catch (error) {
console.warn('分享失败', error);
Toast.error(t('challengeDetail.share.failed'));
}
};
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(t('challengeDetail.alert.joinFailed'))
}
};
const handleLeave = async () => {
if (!id || leaveStatus === 'loading') {
return;
}
try {
await dispatch(leaveChallenge(id)).unwrap();
await dispatch(fetchChallengeDetail(id)).unwrap();
} catch (error) {
Toast.error(t('challengeDetail.alert.leaveFailed'));
}
};
const handleArchive = async () => {
if (!id || archiveStatus === 'loading') {
return;
}
try {
await dispatch(archiveCustomChallengeThunk(id)).unwrap();
Toast.success(t('challengeDetail.alert.archiveSuccess'));
await dispatch(fetchChallenges());
router.back();
} catch (error) {
Toast.error(t('challengeDetail.alert.archiveFailed'));
}
};
const handleLeaveConfirm = () => {
if (!id || leaveStatus === 'loading') {
return;
}
Alert.alert(
t('challengeDetail.alert.leaveConfirm.title'),
t('challengeDetail.alert.leaveConfirm.message'),
[
{ text: t('challengeDetail.alert.leaveConfirm.cancel'), style: 'cancel' },
{
text: t('challengeDetail.alert.leaveConfirm.confirm'),
style: 'destructive',
onPress: () => {
void handleLeave();
},
},
]
);
};
const handleArchiveConfirm = () => {
if (!id || archiveStatus === 'loading') {
return;
}
Alert.alert(
t('challengeDetail.alert.archiveConfirm.title'),
t('challengeDetail.alert.archiveConfirm.message'),
[
{ text: t('challengeDetail.alert.archiveConfirm.cancel'), style: 'cancel' },
{
text: t('challengeDetail.alert.archiveConfirm.confirm'),
style: 'destructive',
onPress: () => {
void handleArchive();
},
},
]
);
};
const handleProgressReport = async () => {
if (!id || progressStatus === 'loading') {
return;
}
if (hasCheckedInToday) {
Toast.info(t('challengeDetail.checkIn.toast.alreadyChecked'));
return;
}
if (challenge?.status === 'upcoming') {
Toast.info(t('challengeDetail.checkIn.toast.notStarted'));
return;
}
if (challenge?.status === 'expired') {
Toast.info(t('challengeDetail.checkIn.toast.expired'));
return;
}
const isLoggedIn = await ensureLoggedIn();
if (!isLoggedIn) {
return;
}
if (!isJoined) {
Toast.info(t('challengeDetail.checkIn.toast.mustJoin'));
return;
}
try {
await dispatch(reportChallengeProgress({ id, value: 1 })).unwrap();
Toast.success(t('challengeDetail.checkIn.toast.success'));
} catch (error) {
Toast.error(t('challengeDetail.checkIn.toast.failed'));
}
};
const handleCopyShareCode = async () => {
if (!challenge?.shareCode) return;
await Clipboard.setStringAsync(challenge.shareCode);
// 添加震动反馈
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
Toast.success(t('challengeDetail.shareCode.copied'));
};
const isLoadingInitial = detailStatus === 'loading' && !challenge;
if (!id) {
return (
<SafeAreaView style={[styles.safeArea, { backgroundColor: colorTokens.background }]}>
<HeaderBar title={t('challengeDetail.title')} onBack={() => router.back()} withSafeTop transparent={false} />
<View style={styles.missingContainer}>
<Text style={[styles.missingText, { color: colorTokens.textSecondary }]}>{t('challengeDetail.notFound')}</Text>
</View>
</SafeAreaView>
);
}
if (isLoadingInitial) {
return (
<SafeAreaView style={[styles.safeArea, { backgroundColor: colorTokens.background }]}>
<HeaderBar title={t('challengeDetail.title')} onBack={() => router.back()} withSafeTop transparent={false} />
<View style={styles.missingContainer}>
<ActivityIndicator color={colorTokens.primary} />
<Text style={[styles.missingText, { color: colorTokens.textSecondary, marginTop: 16 }]}>{t('challengeDetail.loading')}</Text>
</View>
</SafeAreaView>
);
}
if (!challenge) {
return (
<SafeAreaView style={[styles.safeArea, { backgroundColor: colorTokens.background }]}>
<HeaderBar title={t('challengeDetail.title')} onBack={() => router.back()} withSafeTop transparent={false} />
<View style={styles.missingContainer}>
<Text style={[styles.missingText, { color: colorTokens.textSecondary }]}>
{detailError ?? t('challengeDetail.notFound')}
</Text>
<TouchableOpacity
style={[styles.retryButton, { borderColor: colorTokens.primary }]}
activeOpacity={0.9}
onPress={() => dispatch(fetchChallengeDetail(id))}
>
<Text style={[styles.retryText, { color: colorTokens.primary }]}>{t('challengeDetail.retry')}</Text>
</TouchableOpacity>
</View>
</SafeAreaView>
);
}
const highlightTitle = challenge.highlightTitle ?? t('challengeDetail.highlight.join.title');
const highlightSubtitle = challenge.highlightSubtitle ?? t('challengeDetail.highlight.join.subtitle');
const joinCtaLabel = joinStatus === 'loading' ? t('challengeDetail.cta.joining') : challenge.ctaLabel ?? t('challengeDetail.cta.join');
const isUpcoming = challenge.status === 'upcoming';
const isExpired = challenge.status === 'expired';
const deleteCtaLabel = archiveStatus === 'loading'
? t('challengeDetail.cta.deleting')
: t('challengeDetail.cta.delete');
const upcomingStartLabel = formatMonthDay(challenge.startAt);
const upcomingHighlightTitle = t('challengeDetail.highlight.upcoming.title');
const upcomingHighlightSubtitle = upcomingStartLabel
? t('challengeDetail.highlight.upcoming.subtitle', { date: upcomingStartLabel })
: t('challengeDetail.highlight.upcoming.subtitleFallback');
const upcomingCtaLabel = t('challengeDetail.cta.upcoming');
const expiredEndLabel = formatMonthDay(challenge.endAt);
const expiredHighlightTitle = t('challengeDetail.highlight.expired.title');
const expiredHighlightSubtitle = expiredEndLabel
? t('challengeDetail.highlight.expired.subtitle', { date: expiredEndLabel })
: t('challengeDetail.highlight.expired.subtitleFallback');
const expiredCtaLabel = t('challengeDetail.cta.expired');
const leaveHighlightTitle = t('challengeDetail.highlight.leave.title');
const leaveHighlightSubtitle = t('challengeDetail.highlight.leave.subtitle');
const leaveCtaLabel = leaveStatus === 'loading' ? t('challengeDetail.cta.leaving') : t('challengeDetail.cta.leave');
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 = showShareCode
? `分享码 ${challenge?.shareCode ?? ''}`
: leaveHighlightTitle;
floatingHighlightSubtitle = showShareCode ? '' : leaveHighlightSubtitle;
if (isCustomCreator) {
floatingCtaLabel = deleteCtaLabel;
floatingOnPress = handleArchiveConfirm;
floatingDisabled = archiveStatus === 'loading';
floatingError = archiveError;
} else {
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;
const checkInDisabled =
progressStatus === 'loading' || hasCheckedInToday || !isJoined || isUpcoming || isExpired;
const checkInButtonLabel =
progressStatus === 'loading'
? t('challengeDetail.checkIn.button.checking')
: hasCheckedInToday
? t('challengeDetail.checkIn.button.checked')
: !isJoined
? t('challengeDetail.checkIn.button.notJoined')
: isUpcoming
? t('challengeDetail.checkIn.button.upcoming')
: isExpired
? t('challengeDetail.checkIn.button.expired')
: t('challengeDetail.checkIn.button.checkIn');
const checkInSubtitle = hasCheckedInToday
? t('challengeDetail.checkIn.subtitleChecked')
: t('challengeDetail.checkIn.subtitle');
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}>{t('challengeDetail.shareCard.progress.label')}</Text>
<Text style={styles.shareProgressValue}>
{t('challengeDetail.shareCard.progress.days', { completed: progress.completed, target: 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
? t('challengeDetail.shareCard.progress.completed')
: t('challengeDetail.shareCard.progress.remaining', { remaining: 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}>{t('challengeDetail.shareCard.info.checkInDaily')}</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}>{t('challengeDetail.shareCard.info.joinUs')}</Text>
</View>
</View>
</View>
)}
{/* 底部标识 */}
<View style={styles.shareCardFooter}>
<Text style={styles.shareCardFooterText}>{t('challengeDetail.shareCard.footer')}</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={
<View style={styles.headerButtons}>
{canEdit && (
isLiquidGlassAvailable() ? (
<TouchableOpacity
onPress={() => router.push({
pathname: '/challenges/create-custom',
params: { id, mode: 'edit' }
})}
activeOpacity={0.7}
style={styles.editButton}
>
<GlassView
style={styles.editButtonGlass}
glassEffectStyle="clear"
tintColor="rgba(255, 255, 255, 0.3)"
isInteractive={true}
>
<Ionicons name="create-outline" size={20} color="#ffffff" />
</GlassView>
</TouchableOpacity>
) : (
<TouchableOpacity
onPress={() => router.push({
pathname: '/challenges/create-custom',
params: { id, mode: 'edit' }
})}
activeOpacity={0.7}
style={[styles.editButton, styles.fallbackEditButton]}
>
<Ionicons name="create-outline" size={20} color="#ffffff" />
</TouchableOpacity>
)
)}
{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>
}
/>
</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}>{t('challengeDetail.detail.requirement')}</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}>{t('challengeDetail.participants.more')}</Text>
</TouchableOpacity>
) : null}
</View>
) : null}
</View>
</View>
{isCustomChallenge ? (
<View style={styles.checkInCard}>
<View style={styles.checkInCopy}>
<Text style={styles.checkInTitle}>{hasCheckedInToday ? t('challengeDetail.checkIn.todayChecked') : t('challengeDetail.checkIn.title')}</Text>
<Text style={styles.checkInSubtitle}>{checkInSubtitle}</Text>
</View>
<TouchableOpacity
activeOpacity={0.9}
onPress={handleProgressReport}
disabled={checkInDisabled}
style={styles.checkInButton}
>
<LinearGradient
colors={checkInDisabled ? CTA_DISABLED_GRADIENT : CTA_GRADIENT}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.checkInButtonBackground}
>
<Text
style={[
styles.checkInButtonLabel,
checkInDisabled && styles.checkInButtonLabelDisabled,
]}
>
{checkInButtonLabel}
</Text>
</LinearGradient>
</TouchableOpacity>
</View>
) : null}
</View>
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>{t('challengeDetail.ranking.title')}</Text>
<TouchableOpacity activeOpacity={0.8} onPress={handleViewAllRanking}>
<Text style={styles.sectionAction}>{t('challengeDetail.detail.viewAllRanking')}</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}>{t('challengeDetail.ranking.empty')}</Text>
</View>
)}
</View>
</ScrollView>
<View pointerEvents="box-none" style={[styles.floatingCTAContainer, { paddingBottom: insets.bottom || 20 }]}>
{isLiquidGlassAvailable() ? (
<View style={styles.glassWrapper}>
{/* 顶部高光线条 */}
<LinearGradient
colors={['rgba(255,255,255,0.9)', 'rgba(255,255,255,0.2)', 'transparent']}
start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 1 }}
style={styles.glassHighlight}
/>
<GlassView
style={styles.glassContainer}
glassEffectStyle="regular"
tintColor="rgba(243, 244, 251, 0.55)"
isInteractive={true}
>
{/* 内部微光渐变 */}
<LinearGradient
colors={['rgba(255,255,255,0.6)', 'rgba(255,255,255,0.0)']}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 0.6 }}
style={StyleSheet.absoluteFill}
pointerEvents="none"
/>
<View style={styles.floatingCTAContent}>
{showShareCode ? (
<View style={[styles.highlightCopy, styles.highlightCopyCompact]}>
<View style={styles.shareCodeRow}>
<Text style={styles.highlightTitle}>{floatingHighlightTitle}</Text>
<TouchableOpacity
activeOpacity={0.7}
style={styles.shareCodeIconButton}
onPress={handleCopyShareCode}
>
<Ionicons name="copy-outline" size={18} color="#4F5BD5" />
</TouchableOpacity>
</View>
{floatingHighlightSubtitle ? (
<Text style={styles.highlightSubtitle}>{floatingHighlightSubtitle}</Text>
) : null}
{floatingError ? <Text style={styles.ctaErrorText}>{floatingError}</Text> : null}
</View>
) : (
<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.85}
onPress={floatingOnPress}
disabled={floatingDisabled}
>
<LinearGradient
colors={floatingGradientColors}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.highlightButtonBackground}
>
{/* 按钮内部高光 */}
<LinearGradient
colors={['rgba(255,255,255,0.4)', 'transparent']}
start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 0.5 }}
style={StyleSheet.absoluteFill}
/>
<Text style={[styles.highlightButtonLabel, isDisabledButtonState && styles.highlightButtonLabelDisabled]}>
{floatingCtaLabel}
</Text>
</LinearGradient>
</TouchableOpacity>
</View>
</GlassView>
</View>
) : (
<BlurView intensity={20} tint="light" style={styles.floatingCTABlur}>
<View style={styles.floatingCTAContent}>
{showShareCode ? (
<View style={[styles.highlightCopy, styles.highlightCopyCompact]}>
<View style={styles.shareCodeRow}>
<Text style={styles.highlightTitle}>{floatingHighlightTitle}</Text>
<TouchableOpacity
activeOpacity={0.85}
style={styles.shareCodeIconButton}
onPress={handleCopyShareCode}
>
<Ionicons name="copy-outline" size={18} color="#4F5BD5" />
</TouchableOpacity>
</View>
{floatingHighlightSubtitle ? (
<Text style={styles.highlightSubtitle}>{floatingHighlightSubtitle}</Text>
) : null}
{floatingError ? <Text style={styles.ctaErrorText}>{floatingError}</Text> : null}
</View>
) : (
<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,
zIndex: 100,
},
floatingCTABlur: {
borderRadius: 24,
overflow: 'hidden',
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.6)',
backgroundColor: 'rgba(243, 244, 251, 0.9)',
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.1,
shadowRadius: 12,
elevation: 5,
},
glassWrapper: {
borderRadius: 24,
overflow: 'hidden',
backgroundColor: 'rgba(255, 255, 255, 0.05)',
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.3)',
shadowColor: '#5E8BFF',
shadowOffset: {
width: 0,
height: 8,
},
shadowOpacity: 0.18,
shadowRadius: 20,
elevation: 10,
},
glassHighlight: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: 1,
zIndex: 2,
opacity: 0.9,
},
glassContainer: {
borderRadius: 24,
overflow: 'hidden',
},
floatingCTAContent: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 16,
paddingHorizontal: 20,
},
highlightCopy: {
flex: 1,
marginRight: 16,
},
highlightCopyCompact: {
marginRight: 12,
gap: 4,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
shareCodeRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
flex: 1,
},
headerTextBlock: {
paddingHorizontal: 24,
marginTop: HERO_HEIGHT - 60,
alignItems: 'center',
},
periodLabel: {
fontSize: 14,
color: '#596095',
letterSpacing: 0.2,
fontFamily: 'AliRegular',
},
title: {
marginTop: 10,
fontSize: 24,
fontWeight: '800',
color: '#1c1f3a',
textAlign: 'center',
fontFamily: 'AliBold'
},
summary: {
marginTop: 12,
fontSize: 14,
lineHeight: 20,
color: '#7080b4',
textAlign: 'center',
fontFamily: 'AliRegular',
},
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',
fontFamily: 'AliBold',
},
detailMeta: {
marginTop: 4,
fontSize: 12,
color: '#6f7ba7',
fontFamily: 'AliRegular',
},
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',
fontFamily: 'AliRegular',
},
checkInCard: {
marginTop: 4,
padding: 14,
borderRadius: 18,
backgroundColor: '#f5f6ff',
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
checkInCopy: {
flex: 1,
},
checkInTitle: {
fontSize: 14,
fontWeight: '700',
color: '#1c1f3a',
fontFamily: 'AliBold',
},
checkInSubtitle: {
marginTop: 4,
fontSize: 12,
color: '#6f7ba7',
lineHeight: 18,
fontFamily: 'AliRegular',
},
checkInButton: {
borderRadius: 18,
overflow: 'hidden',
},
checkInButtonBackground: {
paddingVertical: 10,
paddingHorizontal: 14,
borderRadius: 18,
minWidth: 96,
alignItems: 'center',
justifyContent: 'center',
},
checkInButtonLabel: {
fontSize: 13,
fontWeight: '700',
color: '#ffffff',
fontFamily: 'AliBold',
},
checkInButtonLabelDisabled: {
color: '#6f7799',
},
sectionHeader: {
marginTop: 36,
marginHorizontal: 24,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
sectionTitle: {
fontSize: 18,
fontWeight: '700',
color: '#1c1f3a',
fontFamily: 'AliBold',
},
sectionAction: {
fontSize: 13,
fontWeight: '600',
color: '#5F6BF0',
fontFamily: 'AliBold',
},
sectionSubtitle: {
marginTop: 8,
marginHorizontal: 24,
fontSize: 13,
color: '#6f7ba7',
lineHeight: 18,
fontFamily: 'AliRegular',
},
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',
fontFamily: 'AliRegular',
},
highlightTitle: {
fontSize: 16,
fontWeight: '700',
color: '#1c1f3a',
fontFamily: 'AliBold',
},
highlightSubtitle: {
marginTop: 4,
fontSize: 12,
color: '#5f6a97',
lineHeight: 18,
fontFamily: 'AliRegular',
},
shareCodeIconButton: {
paddingHorizontal: 4,
paddingVertical: 4,
},
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',
fontFamily: 'AliBold',
},
highlightButtonLabelDisabled: {
color: '#6f7799',
},
headerButtons: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
editButton: {
width: 40,
height: 40,
borderRadius: 20,
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
},
editButtonGlass: {
width: 40,
height: 40,
borderRadius: 20,
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
},
fallbackEditButton: {
backgroundColor: 'rgba(255, 255, 255, 0.24)',
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.45)',
},
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',
fontFamily: 'AliRegular',
},
retryButton: {
marginTop: 18,
paddingHorizontal: 20,
paddingVertical: 10,
borderRadius: 22,
borderWidth: 1,
},
retryText: {
fontSize: 14,
fontWeight: '600',
fontFamily: 'AliBold',
},
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,
fontFamily: 'AliBold',
},
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,
fontFamily: 'AliRegular',
},
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',
fontFamily: 'AliBold',
},
shareInfoMeta: {
fontSize: 12,
color: '#707baf',
marginTop: 2,
fontFamily: 'AliRegular',
},
shareProgressHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 16,
},
shareProgressLabel: {
fontSize: 14,
fontWeight: '600',
color: '#1c1f3a',
fontFamily: 'AliBold',
},
shareProgressValue: {
fontSize: 18,
fontWeight: '800',
color: '#5E8BFF',
fontFamily: 'AliBold',
},
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',
fontFamily: 'AliRegular',
},
shareCardFooter: {
alignItems: 'center',
paddingTop: 16,
},
shareCardFooterText: {
fontSize: 12,
color: '#ffffff',
opacity: 0.8,
fontWeight: '600',
fontFamily: 'AliBold',
},
});