feat(challenges): 接入真实接口并完善挑战列表与详情状态管理
- 新增 challengesApi 服务层,支持列表/详情/加入/退出/打卡接口 - 重构 challengesSlice,使用 createAsyncThunk 管理异步状态 - 列表页支持加载、空态、错误重试及状态标签 - 详情页支持进度展示、打卡、退出及错误提示 - 统一卡片与详情数据模型,支持动态状态更新
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user