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,