- 新增自定义挑战的编辑模式,支持修改挑战信息 - 在详情页为创建者添加删除(归档)挑战的功能入口 - 全面完善挑战创建页面的国际化(i18n)文案适配 - 优化个人中心页面的字体样式,统一使用 AliBold/Regular - 更新 Store 逻辑以处理挑战更新、删除及列表数据映射调整
1537 lines
49 KiB
TypeScript
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',
|
|
},
|
|
});
|