feat(challenges): 接入真实接口并完善挑战列表与详情状态管理

- 新增 challengesApi 服务层,支持列表/详情/加入/退出/打卡接口
- 重构 challengesSlice,使用 createAsyncThunk 管理异步状态
- 列表页支持加载、空态、错误重试及状态标签
- 详情页支持进度展示、打卡、退出及错误提示
- 统一卡片与详情数据模型,支持动态状态更新
This commit is contained in:
richarjiang
2025-09-28 14:16:32 +08:00
parent 2b86ac17a6
commit 7259bd7a2c
4 changed files with 898 additions and 355 deletions

View File

@@ -1,29 +1,107 @@
import { IconSymbol } from '@/components/ui/IconSymbol';
import { Colors } from '@/constants/Colors';
import { useAppSelector } from '@/hooks/redux';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useColorScheme } from '@/hooks/useColorScheme';
import { selectChallengeCards, type ChallengeViewModel } from '@/store/challengesSlice';
import {
fetchChallenges,
selectChallengeCards,
selectChallengesListError,
selectChallengesListStatus,
type ChallengeCardViewModel,
} from '@/store/challengesSlice';
import { LinearGradient } from 'expo-linear-gradient';
import { useRouter } from 'expo-router';
import React from 'react';
import { Image, ScrollView, StatusBar, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import React, { useEffect } from 'react';
import {
ActivityIndicator,
Image,
ScrollView,
StatusBar,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
const AVATAR_SIZE = 36;
const CARD_IMAGE_WIDTH = 132;
const CARD_IMAGE_HEIGHT = 96;
const STATUS_LABELS: Record<'upcoming' | 'ongoing' | 'expired', string> = {
upcoming: '即将开始',
ongoing: '进行中',
expired: '已结束',
};
export default function ChallengesScreen() {
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme];
const router = useRouter();
const dispatch = useAppDispatch();
const challenges = useAppSelector(selectChallengeCards);
const listStatus = useAppSelector(selectChallengesListStatus);
const listError = useAppSelector(selectChallengesListError);
useEffect(() => {
if (listStatus === 'idle') {
dispatch(fetchChallenges());
}
}, [dispatch, listStatus]);
const gradientColors: [string, string] =
theme === 'dark'
? ['#1f2230', '#10131e']
: [colorTokens.backgroundGradientStart, colorTokens.backgroundGradientEnd];
const renderChallenges = () => {
if (listStatus === 'loading' && challenges.length === 0) {
return (
<View style={styles.stateContainer}>
<ActivityIndicator color={colorTokens.primary} />
<Text style={[styles.stateText, { color: colorTokens.textSecondary }]}></Text>
</View>
);
}
if (listStatus === 'failed' && challenges.length === 0) {
return (
<View style={styles.stateContainer}>
<Text style={[styles.stateText, { color: colorTokens.textSecondary }]}>
{listError ?? '加载挑战失败,请稍后重试'}
</Text>
<TouchableOpacity
style={[styles.retryButton, { borderColor: colorTokens.primary }]}
activeOpacity={0.9}
onPress={() => dispatch(fetchChallenges())}
>
<Text style={[styles.retryText, { color: colorTokens.primary }]}></Text>
</TouchableOpacity>
</View>
);
}
if (challenges.length === 0) {
return (
<View style={styles.stateContainer}>
<Text style={[styles.stateText, { color: colorTokens.textSecondary }]}></Text>
</View>
);
}
return challenges.map((challenge) => (
<ChallengeCard
key={challenge.id}
challenge={challenge}
surfaceColor={colorTokens.surface}
textColor={colorTokens.text}
mutedColor={colorTokens.textSecondary}
onPress={() =>
router.push({ pathname: '/challenges/[id]', params: { id: challenge.id } })
}
/>
));
};
return (
<View style={[styles.screen, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}>
<StatusBar barStyle={theme === 'dark' ? 'light-content' : 'dark-content'} />
@@ -51,20 +129,7 @@ export default function ChallengesScreen() {
</TouchableOpacity>
</View>
<View style={styles.cardsContainer}>
{challenges.map((challenge) => (
<ChallengeCard
key={challenge.id}
challenge={challenge}
surfaceColor={colorTokens.surface}
textColor={colorTokens.text}
mutedColor={colorTokens.textSecondary}
onPress={() =>
router.push({ pathname: '/challenges/[id]', params: { id: challenge.id } })
}
/>
))}
</View>
<View style={styles.cardsContainer}>{renderChallenges()}</View>
</ScrollView>
</SafeAreaView>
</View>
@@ -72,7 +137,7 @@ export default function ChallengesScreen() {
}
type ChallengeCardProps = {
challenge: ChallengeViewModel;
challenge: ChallengeCardViewModel;
surfaceColor: string;
textColor: string;
mutedColor: string;
@@ -80,6 +145,8 @@ type ChallengeCardProps = {
};
function ChallengeCard({ challenge, surfaceColor, textColor, mutedColor, onPress }: ChallengeCardProps) {
const statusLabel = STATUS_LABELS[challenge.status] ?? challenge.status;
return (
<TouchableOpacity
activeOpacity={0.92}
@@ -103,8 +170,20 @@ function ChallengeCard({ challenge, surfaceColor, textColor, mutedColor, onPress
{challenge.title}
</Text>
<Text style={[styles.cardDate, { color: mutedColor }]}>{challenge.dateRange}</Text>
<Text style={[styles.cardParticipants, { color: mutedColor }]}>{challenge.participantsLabel}</Text>
<AvatarStack avatars={challenge.avatars} borderColor={surfaceColor} />
<Text style={[styles.cardParticipants, { color: mutedColor }]}>
{challenge.participantsLabel}
{' · '}
{statusLabel}
{challenge.isJoined ? ' · 已加入' : ''}
</Text>
{challenge.progress?.badge ? (
<Text style={[styles.cardProgress, { color: textColor }]} numberOfLines={1}>
{challenge.progress.badge}
</Text>
) : null}
{challenge.avatars.length ? (
<AvatarStack avatars={challenge.avatars} borderColor={surfaceColor} />
) : null}
</View>
</TouchableOpacity>
);
@@ -118,17 +197,19 @@ type AvatarStackProps = {
function AvatarStack({ avatars, borderColor }: AvatarStackProps) {
return (
<View style={styles.avatarRow}>
{avatars.map((avatar, index) => (
<Image
key={`${avatar}-${index}`}
source={{ uri: avatar }}
style={[
styles.avatar,
{ borderColor },
index === 0 ? null : styles.avatarOffset,
]}
/>
))}
{avatars
.filter(Boolean)
.map((avatar, index) => (
<Image
key={`${avatar}-${index}`}
source={{ uri: avatar }}
style={[
styles.avatar,
{ borderColor },
index === 0 ? null : styles.avatarOffset,
]}
/>
))}
</View>
);
}
@@ -180,6 +261,29 @@ const styles = StyleSheet.create({
cardsContainer: {
gap: 18,
},
stateContainer: {
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 40,
paddingHorizontal: 20,
},
stateText: {
marginTop: 12,
fontSize: 14,
textAlign: 'center',
lineHeight: 20,
},
retryButton: {
marginTop: 16,
paddingHorizontal: 18,
paddingVertical: 8,
borderRadius: 18,
borderWidth: StyleSheet.hairlineWidth,
},
retryText: {
fontSize: 13,
fontWeight: '600',
},
card: {
flexDirection: 'row',
borderRadius: 28,
@@ -213,6 +317,11 @@ const styles = StyleSheet.create({
fontSize: 13,
fontWeight: '500',
},
cardProgress: {
marginTop: 8,
fontSize: 13,
fontWeight: '600',
},
avatarRow: {
flexDirection: 'row',
marginTop: 16,

View File

@@ -1,8 +1,22 @@
import { HeaderBar } from '@/components/ui/HeaderBar';
import { Colors } from '@/constants/Colors';
import { useAppSelector } from '@/hooks/redux';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useColorScheme } from '@/hooks/useColorScheme';
import { selectChallengeViewById } from '@/store/challengesSlice';
import {
fetchChallengeDetail,
joinChallenge,
leaveChallenge,
reportChallengeProgress,
selectChallengeById,
selectChallengeDetailError,
selectChallengeDetailStatus,
selectJoinError,
selectJoinStatus,
selectLeaveError,
selectLeaveStatus,
selectProgressError,
selectProgressStatus,
} from '@/store/challengesSlice';
import { Ionicons } from '@expo/vector-icons';
import { BlurView } from 'expo-blur';
import { LinearGradient } from 'expo-linear-gradient';
@@ -10,6 +24,7 @@ import { useLocalSearchParams, useRouter } from 'expo-router';
import LottieView from 'lottie-react-native';
import React, { useEffect, useMemo, useState } from 'react';
import {
ActivityIndicator,
Dimensions,
Image,
Platform,
@@ -25,183 +40,134 @@ import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
const { width } = Dimensions.get('window');
const HERO_HEIGHT = width * 0.86;
const CTA_GRADIENT: [string, string] = ['#5E8BFF', '#6B6CFF'];
type ChallengeProgress = {
completedDays: number;
totalDays: number;
remainingDays: number;
badge: string;
subtitle?: string;
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()}`;
};
type ChallengeDetail = {
image: string;
periodLabel: string;
durationLabel: string;
requirementLabel: string;
summary?: string;
participantsCount: number;
rankingDescription?: string;
rankings: RankingItem[];
highlightTitle: string;
highlightSubtitle: string;
ctaLabel: string;
progress?: ChallengeProgress;
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 ?? '';
};
type RankingItem = {
id: string;
name: string;
avatar: string;
metric: string;
badge?: string;
};
const DETAIL_PRESETS: Record<string, ChallengeDetail> = {
'hydration-hippo': {
image:
'https://images.unsplash.com/photo-1616628182503-5ef2941510da?auto=format&fit=crop&w=240&q=80',
periodLabel: '9月01日 - 9月30日 · 剩余 4 天',
durationLabel: '30 天',
requirementLabel: '喝水 1500ml 15 天以上',
summary: '与河马一起练就最佳补水习惯,让身体如湖水般澄澈充盈。',
participantsCount: 9009,
rankingDescription: '榜单实时更新,记录每位补水达人每日平均饮水量。',
rankings: [
{
id: 'all-1',
name: '湖光暮色',
avatar: 'https://images.unsplash.com/photo-1500648767791-00dcc994a43e?auto=format&fit=crop&w=140&q=80',
metric: '平均 3,200 ml',
badge: '金冠冠军',
},
{
id: 'all-2',
name: '温柔潮汐',
avatar: 'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?auto=format&fit=crop&w=140&q=80',
metric: '平均 2,980 ml',
},
{
id: 'all-3',
name: '晨雾河岸',
avatar: 'https://images.unsplash.com/photo-1544723795-432537f48b2b?auto=format&fit=crop&w=140&q=80',
metric: '平均 2,860 ml',
},
{
id: 'male-1',
name: '北岸微风',
avatar: 'https://images.unsplash.com/photo-1488426862026-3ee34a7d66df?auto=format&fit=crop&w=140&q=80',
metric: '平均 3,120 ml',
},
{
id: 'male-2',
name: '静水晚霞',
avatar: 'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?auto=format&fit=crop&w=140&q=80',
metric: '平均 2,940 ml',
},
{
id: 'female-1',
name: '露珠初晓',
avatar: 'https://images.unsplash.com/photo-1544723795-3fb6469f5b39?auto=format&fit=crop&w=140&q=80',
metric: '平均 3,060 ml',
},
{
id: 'female-2',
name: '桔梗水语',
avatar: 'https://images.unsplash.com/photo-1521572267360-ee0c2909d518?auto=format&fit=crop&w=140&q=80',
metric: '平均 2,880 ml',
},
],
highlightTitle: '加入挑战',
highlightSubtitle: '畅饮打卡越多,专属奖励越丰厚',
ctaLabel: '立即加入挑战',
progress: {
completedDays: 12,
totalDays: 15,
remainingDays: 3,
badge: 'https://images.unsplash.com/photo-1582719478250-c89cae4dc85b?auto=format&fit=crop&w=160&q=80',
subtitle: '学河马饮,做补水人',
},
},
};
const DEFAULT_DETAIL: ChallengeDetail = {
image: 'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?auto=format&fit=crop&w=240&q=80',
periodLabel: '本周进行中',
durationLabel: '30 天',
requirementLabel: '保持专注完成每日任务',
participantsCount: 3200,
highlightTitle: '立即参加,点燃动力',
highlightSubtitle: '邀请好友一起坚持,更容易收获成果',
ctaLabel: '立即加入挑战',
rankings: [],
progress: {
completedDays: 4,
totalDays: 21,
remainingDays: 5,
badge: 'https://images.unsplash.com/photo-1529257414771-1960d69cc2b3?auto=format&fit=crop&w=160&q=80',
subtitle: '坚持让好习惯生根发芽',
},
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 challengeSelector = useMemo(() => (id ? selectChallengeViewById(id) : undefined), [id]);
const challengeSelector = useMemo(() => (id ? selectChallengeById(id) : undefined), [id]);
const challenge = useAppSelector((state) => (challengeSelector ? challengeSelector(state) : undefined));
const detail = useMemo<ChallengeDetail>(() => {
if (!id) return DEFAULT_DETAIL;
return DETAIL_PRESETS[id] ?? {
...DEFAULT_DETAIL,
periodLabel: challenge?.dateRange ?? DEFAULT_DETAIL.periodLabel,
highlightTitle: `加入 ${challenge?.title ?? '挑战'}`,
};
}, [challenge?.dateRange, challenge?.title, id]);
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 [hasJoined, setHasJoined] = useState(false);
const [progress, setProgress] = useState<ChallengeProgress | undefined>(undefined);
const [showCelebration, setShowCelebration] = useState(false);
const rankingData = detail.rankings ?? [];
const ctaGradientColors: [string, string] = ['#5E8BFF', '#6B6CFF'];
const progressSegments = useMemo(() => {
if (!progress) return undefined;
const segmentsCount = Math.max(1, Math.min(progress.totalDays, 18));
const completedSegments = Math.min(
segmentsCount,
Math.round((progress.completedDays / Math.max(progress.totalDays, 1)) * segmentsCount),
);
return { segmentsCount, completedSegments };
}, [progress]);
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 progressErrorSelector = useMemo(() => (id ? selectProgressError(id) : undefined), [id]);
const progressError = useAppSelector((state) => (progressErrorSelector ? progressErrorSelector(state) : undefined));
useEffect(() => {
setHasJoined(false);
setProgress(undefined);
if (id) {
dispatch(fetchChallengeDetail(id));
}
}, [dispatch, id]);
const [showCelebration, setShowCelebration] = useState(false);
useEffect(() => {
setShowCelebration(false);
}, [id]);
useEffect(() => {
if (joinStatus === 'succeeded') {
setShowCelebration(true);
}
}, [joinStatus]);
useEffect(() => {
if (!showCelebration) {
return;
}
const timer = setTimeout(() => {
setShowCelebration(false);
}, 2400);
return () => {
clearTimeout(timer);
};
}, [showCelebration]);
const progress = challenge?.progress;
const hasProgress = Boolean(progress);
const progressTarget = progress?.target ?? 0;
const progressCompleted = progress?.completed ?? 0;
const progressSegments = useMemo(() => {
if (!hasProgress || progressTarget <= 0) return undefined;
const segmentsCount = Math.max(1, Math.min(progressTarget, 18));
const completedSegments = Math.min(
segmentsCount,
Math.round((progressCompleted / Math.max(progressTarget, 1)) * segmentsCount),
);
return { segmentsCount, completedSegments };
}, [hasProgress, progressCompleted, progressTarget]);
const rankingData = useMemo(() => challenge?.rankings ?? [], [challenge?.rankings]);
const participantAvatars = useMemo(
() => rankingData.filter((item) => item.avatar).map((item) => item.avatar as string).slice(0, 6),
[rankingData],
);
const dateRangeLabel = useMemo(
() =>
buildDateRangeLabel({
startAt: challenge?.startAt,
endAt: challenge?.endAt,
periodLabel: challenge?.periodLabel,
durationLabel: challenge?.durationLabel,
}),
[challenge?.startAt, challenge?.endAt, challenge?.periodLabel, challenge?.durationLabel],
);
const handleShare = async () => {
if (!challenge) {
return;
}
try {
await Share.share({
title: challenge.title,
@@ -214,16 +180,30 @@ export default function ChallengeDetailScreen() {
};
const handleJoin = () => {
if (hasJoined) {
if (!id || joinStatus === 'loading') {
return;
}
setHasJoined(true);
setProgress(detail.progress);
setShowCelebration(true);
dispatch(joinChallenge(id));
};
if (!challenge) {
const handleLeave = () => {
if (!id || leaveStatus === 'loading') {
return;
}
dispatch(leaveChallenge(id));
};
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} />
@@ -234,14 +214,52 @@ export default function ChallengeDetailScreen() {
);
}
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 ctaLabel = joinStatus === 'loading' ? '加入中…' : challenge.ctaLabel ?? '立即加入挑战';
const participantsLabel = formatParticipantsLabel(challenge.participantsCount);
const inlineErrorMessage = detailStatus === 'failed' && detailError ? detailError : undefined;
const progressActionError =
(progressStatus !== 'loading' && progressError) || (leaveStatus !== 'loading' && leaveError) || undefined;
return (
<View style={styles.safeArea}>
<StatusBar barStyle="light-content" />
<View style={styles.container}>
<View
pointerEvents="box-none"
style={[styles.headerOverlay, { paddingTop: insets.top }]}
>
<View pointerEvents="box-none" style={[styles.headerOverlay, { paddingTop: insets.top }]}>
<HeaderBar
title=""
backColor="white"
@@ -274,9 +292,15 @@ export default function ChallengeDetailScreen() {
</View>
<View style={styles.headerTextBlock}>
<Text style={styles.periodLabel}>{detail.periodLabel}</Text>
<Text style={styles.periodLabel}>{challenge.periodLabel ?? dateRangeLabel}</Text>
<Text style={styles.title}>{challenge.title}</Text>
{detail.summary ? <Text style={styles.summary}>{detail.summary}</Text> : null}
{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 && progressSegments ? (
@@ -290,20 +314,30 @@ export default function ChallengeDetailScreen() {
>
<View style={styles.progressHeaderRow}>
<View style={styles.progressBadgeRing}>
<Image source={{ uri: progress.badge }} style={styles.progressBadge} />
{progress.badge ? (
isHttpUrl(progress.badge) ? (
<Image source={{ uri: progress.badge }} style={styles.progressBadge} />
) : (
<View style={styles.progressBadgeFallback}>
<Text style={styles.progressBadgeText}>{progress.badge}</Text>
</View>
)
) : (
<View style={styles.progressBadgeFallback}>
<Text style={styles.progressBadgeText}></Text>
</View>
)}
</View>
<View style={styles.progressHeadline}>
<Text style={styles.progressTitle}>{challenge.title}</Text>
{progress.subtitle ? (
<Text style={styles.progressSubtitle}>{progress.subtitle}</Text>
) : null}
{progress.subtitle ? <Text style={styles.progressSubtitle}>{progress.subtitle}</Text> : null}
</View>
<Text style={styles.progressRemaining}> {progress.remainingDays} </Text>
<Text style={styles.progressRemaining}> {progress.remaining} </Text>
</View>
<View style={styles.progressMetaRow}>
<Text style={styles.progressMetaValue}>
{progress.completedDays} / {progress.totalDays}
{progress.completed} / {progress.target}
<Text style={styles.progressMetaSuffix}> </Text>
</Text>
</View>
@@ -326,6 +360,42 @@ export default function ChallengeDetailScreen() {
);
})}
</View>
{isJoined ? (
<>
<View style={styles.progressActionsRow}>
<TouchableOpacity
style={[
styles.progressPrimaryAction,
progressStatus === 'loading' && styles.progressActionDisabled,
]}
activeOpacity={0.9}
onPress={handleProgressReport}
disabled={progressStatus === 'loading'}
>
<Text style={styles.progressPrimaryActionText}>
{progressStatus === 'loading' ? '打卡中…' : '打卡 +1'}
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.progressSecondaryAction,
leaveStatus === 'loading' && styles.progressActionDisabled,
]}
activeOpacity={0.9}
onPress={handleLeave}
disabled={leaveStatus === 'loading'}
>
<Text style={styles.progressSecondaryActionText}>
{leaveStatus === 'loading' ? '处理中…' : '退出挑战'}
</Text>
</TouchableOpacity>
</View>
{progressActionError ? (
<Text style={styles.progressErrorText}>{progressActionError}</Text>
) : null}
</>
) : null}
</LinearGradient>
</View>
</View>
@@ -337,8 +407,8 @@ export default function ChallengeDetailScreen() {
<Ionicons name="calendar-outline" size={20} color="#4F5BD5" />
</View>
<View style={styles.detailTextWrapper}>
<Text style={styles.detailLabel}>{challenge.dateRange}</Text>
<Text style={styles.detailMeta}>{detail.durationLabel}</Text>
<Text style={styles.detailLabel}>{dateRangeLabel}</Text>
<Text style={styles.detailMeta}>{challenge.durationLabel}</Text>
</View>
</View>
@@ -347,7 +417,7 @@ export default function ChallengeDetailScreen() {
<Ionicons name="flag-outline" size={20} color="#4F5BD5" />
</View>
<View style={styles.detailTextWrapper}>
<Text style={styles.detailLabel}>{detail.requirementLabel}</Text>
<Text style={styles.detailLabel}>{challenge.requirementLabel}</Text>
<Text style={styles.detailMeta}></Text>
</View>
</View>
@@ -356,21 +426,24 @@ export default function ChallengeDetailScreen() {
<View style={styles.detailIconWrapper}>
<Ionicons name="people-outline" size={20} color="#4F5BD5" />
</View>
<View style={[styles.detailTextWrapper, { flex: 1 }]}
>
<Text style={styles.detailLabel}>{detail.participantsCount.toLocaleString('zh-CN')} </Text>
<View style={styles.avatarRow}>
{challenge.avatars.slice(0, 6).map((avatar, index) => (
<Image
key={`${avatar}-${index}`}
source={{ uri: avatar }}
style={[styles.avatar, index > 0 && styles.avatarOffset]}
/>
))}
<TouchableOpacity style={styles.moreAvatarButton}>
<Text style={styles.moreAvatarText}></Text>
</TouchableOpacity>
</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]}
/>
))}
{challenge.participantsCount && challenge.participantsCount > participantAvatars.length ? (
<TouchableOpacity style={styles.moreAvatarButton}>
<Text style={styles.moreAvatarText}></Text>
</TouchableOpacity>
) : null}
</View>
) : null}
</View>
</View>
</View>
@@ -382,8 +455,8 @@ export default function ChallengeDetailScreen() {
</TouchableOpacity>
</View>
{detail.rankingDescription ? (
<Text style={styles.sectionSubtitle}>{detail.rankingDescription}</Text>
{challenge.rankingDescription ? (
<Text style={styles.sectionSubtitle}>{challenge.rankingDescription}</Text>
) : null}
<View style={styles.rankingCard}>
@@ -393,7 +466,13 @@ export default function ChallengeDetailScreen() {
<View style={styles.rankingOrderCircle}>
<Text style={styles.rankingOrder}>{index + 1}</Text>
</View>
<Image source={{ uri: item.avatar }} style={styles.rankingAvatar} />
{item.avatar ? (
<Image source={{ uri: item.avatar }} style={styles.rankingAvatar} />
) : (
<View style={styles.rankingAvatarPlaceholder}>
<Ionicons name="person-outline" size={20} color="#6f7ba7" />
</View>
)}
<View style={styles.rankingInfo}>
<Text style={styles.rankingName}>{item.name}</Text>
<Text style={styles.rankingMetric}>{item.metric}</Text>
@@ -407,31 +486,30 @@ export default function ChallengeDetailScreen() {
</View>
)}
</View>
</ScrollView>
{!hasJoined && (
<View
pointerEvents="box-none"
style={[styles.floatingCTAContainer, { paddingBottom: insets.bottom }]}
>
{!isJoined && (
<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}>{detail.highlightTitle}</Text>
<Text style={styles.highlightSubtitle}>{detail.highlightSubtitle}</Text>
<Text style={styles.highlightTitle}>{highlightTitle}</Text>
<Text style={styles.highlightSubtitle}>{highlightSubtitle}</Text>
{joinError ? <Text style={styles.ctaErrorText}>{joinError}</Text> : null}
</View>
<TouchableOpacity
style={styles.highlightButton}
activeOpacity={0.9}
onPress={handleJoin}
disabled={joinStatus === 'loading'}
>
<LinearGradient
colors={ctaGradientColors}
colors={CTA_GRADIENT}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.highlightButtonBackground}
>
<Text style={styles.highlightButtonLabel}>{detail.ctaLabel}</Text>
<Text style={styles.highlightButtonLabel}>{ctaLabel}</Text>
</LinearGradient>
</TouchableOpacity>
</View>
@@ -523,6 +601,21 @@ const styles = StyleSheet.create({
height: '100%',
borderRadius: 28,
},
progressBadgeFallback: {
flex: 1,
height: '100%',
borderRadius: 22,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#EEF0FF',
paddingHorizontal: 6,
},
progressBadgeText: {
fontSize: 12,
fontWeight: '700',
color: '#4F5BD5',
textAlign: 'center',
},
progressHeadline: {
flex: 1,
},
@@ -581,6 +674,47 @@ const styles = StyleSheet.create({
progressBarSegmentLast: {
marginRight: 0,
},
progressActionsRow: {
flexDirection: 'row',
marginTop: 20,
},
progressPrimaryAction: {
flex: 1,
paddingVertical: 12,
borderRadius: 18,
backgroundColor: '#5E8BFF',
alignItems: 'center',
justifyContent: 'center',
marginRight: 12,
},
progressSecondaryAction: {
flex: 1,
paddingVertical: 12,
borderRadius: 18,
borderWidth: 1,
borderColor: '#d6dcff',
alignItems: 'center',
justifyContent: 'center',
},
progressActionDisabled: {
opacity: 0.6,
},
progressPrimaryActionText: {
fontSize: 14,
fontWeight: '700',
color: '#ffffff',
},
progressSecondaryActionText: {
fontSize: 14,
fontWeight: '700',
color: '#4F5BD5',
},
progressErrorText: {
marginTop: 12,
fontSize: 12,
color: '#FF6B6B',
textAlign: 'center',
},
floatingCTAContainer: {
position: 'absolute',
left: 0,
@@ -629,6 +763,21 @@ const styles = StyleSheet.create({
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,
@@ -760,6 +909,15 @@ const styles = StyleSheet.create({
borderRadius: 22,
marginRight: 14,
},
rankingAvatarPlaceholder: {
width: 44,
height: 44,
borderRadius: 22,
marginRight: 14,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#EEF0FF',
},
rankingInfo: {
flex: 1,
},
@@ -797,6 +955,11 @@ const styles = StyleSheet.create({
color: '#5f6a97',
lineHeight: 18,
},
ctaErrorText: {
marginTop: 8,
fontSize: 12,
color: '#FF6B6B',
},
highlightButton: {
borderRadius: 22,
overflow: 'hidden',
@@ -838,6 +1001,17 @@ const styles = StyleSheet.create({
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',