- 重构挑战列表为横向轮播,支持多进行中的挑战 - 新增挑战详情页 /challenges/[id]/index 与排行榜 /challenges/[id]/leaderboard - ChallengeProgressCard 支持小时级剩余时间显示 - 新增 ChallengeRankingItem 组件展示榜单项 - 排行榜支持分页加载、下拉刷新与错误重试 - 挑战卡片新增已结束角标与渐变遮罩 - 加入/退出挑战时展示庆祝动画与错误提示 - 统一背景渐变色与卡片阴影细节
263 lines
7.4 KiB
TypeScript
263 lines
7.4 KiB
TypeScript
import dayjs from 'dayjs';
|
|
import { LinearGradient } from 'expo-linear-gradient';
|
|
import React, { useEffect, useMemo, useRef } from 'react';
|
|
import { Animated, Easing, StyleSheet, Text, View, type StyleProp, type ViewStyle } from 'react-native';
|
|
|
|
import type { ChallengeProgress } from '@/store/challengesSlice';
|
|
|
|
type ChallengeProgressCardProps = {
|
|
title: string;
|
|
endAt?: string;
|
|
progress?: ChallengeProgress;
|
|
style?: StyleProp<ViewStyle>;
|
|
backgroundColors?: [string, string];
|
|
titleColor?: string;
|
|
subtitleColor?: string;
|
|
metaColor?: string;
|
|
metaSuffixColor?: string;
|
|
accentColor?: string;
|
|
trackColor?: string;
|
|
inactiveColor?: string;
|
|
};
|
|
|
|
type RemainingTime = {
|
|
value: number;
|
|
unit: '天' | '小时';
|
|
};
|
|
|
|
const DEFAULT_BACKGROUND: [string, string] = ['#ffffff', '#ffffff'];
|
|
const DEFAULT_TITLE_COLOR = '#1c1f3a';
|
|
const DEFAULT_SUBTITLE_COLOR = '#707baf';
|
|
const DEFAULT_META_COLOR = '#4F5BD5';
|
|
const DEFAULT_META_SUFFIX_COLOR = '#7a86bb';
|
|
const DEFAULT_ACCENT_COLOR = '#5E8BFF';
|
|
const DEFAULT_TRACK_COLOR = '#eceffa';
|
|
const DEFAULT_INACTIVE_COLOR = '#dfe4f6';
|
|
|
|
const clampSegments = (target: number, completed: number) => {
|
|
const segmentsCount = Math.max(1, Math.min(target, 18));
|
|
const completedSegments = Math.min(
|
|
segmentsCount,
|
|
Math.round((completed / Math.max(target, 1)) * segmentsCount)
|
|
);
|
|
return { segmentsCount, completedSegments };
|
|
};
|
|
|
|
const calculateRemainingTime = (endAt?: string): RemainingTime => {
|
|
if (!endAt) return { value: 0, unit: '天' };
|
|
const endDate = dayjs(endAt);
|
|
if (!endDate.isValid()) return { value: 0, unit: '天' };
|
|
|
|
const diffMilliseconds = endDate.diff(dayjs());
|
|
if (diffMilliseconds <= 0) {
|
|
return { value: 0, unit: '天' };
|
|
}
|
|
|
|
const diffHours = diffMilliseconds / (60 * 60 * 1000);
|
|
if (diffHours < 24) {
|
|
return { value: Math.max(1, Math.floor(diffHours)), unit: '小时' };
|
|
}
|
|
|
|
return { value: Math.floor(diffHours / 24), unit: '天' };
|
|
};
|
|
|
|
export const ChallengeProgressCard: React.FC<ChallengeProgressCardProps> = ({
|
|
title,
|
|
endAt,
|
|
progress,
|
|
style,
|
|
backgroundColors = DEFAULT_BACKGROUND,
|
|
titleColor = DEFAULT_TITLE_COLOR,
|
|
subtitleColor = DEFAULT_SUBTITLE_COLOR,
|
|
metaColor = DEFAULT_META_COLOR,
|
|
metaSuffixColor = DEFAULT_META_SUFFIX_COLOR,
|
|
accentColor = DEFAULT_ACCENT_COLOR,
|
|
trackColor = DEFAULT_TRACK_COLOR,
|
|
inactiveColor = DEFAULT_INACTIVE_COLOR,
|
|
}) => {
|
|
const hasValidProgress = Boolean(progress && progress.target && progress.target > 0);
|
|
const segmentAnimations = useRef<Animated.Value[]>([]);
|
|
|
|
const segments = useMemo(() => {
|
|
if (!hasValidProgress || !progress) return undefined;
|
|
return clampSegments(progress.target, progress.completed);
|
|
}, [hasValidProgress, progress]);
|
|
|
|
if (segments) {
|
|
if (segmentAnimations.current.length < segments.segmentsCount) {
|
|
const additional = Array.from(
|
|
{ length: segments.segmentsCount - segmentAnimations.current.length },
|
|
() => new Animated.Value(0)
|
|
);
|
|
segmentAnimations.current = [...segmentAnimations.current, ...additional];
|
|
} else if (segmentAnimations.current.length > segments.segmentsCount) {
|
|
segmentAnimations.current = segmentAnimations.current.slice(0, segments.segmentsCount);
|
|
}
|
|
} else if (segmentAnimations.current.length) {
|
|
segmentAnimations.current = [];
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (!segments) return;
|
|
|
|
segmentAnimations.current.forEach((animation, index) => {
|
|
const isComplete = index < segments.completedSegments;
|
|
Animated.timing(animation, {
|
|
toValue: isComplete ? 1 : 0,
|
|
duration: isComplete ? 460 : 240,
|
|
delay: isComplete ? index * 55 : 0,
|
|
easing: isComplete ? Easing.out(Easing.cubic) : Easing.out(Easing.quad),
|
|
useNativeDriver: true,
|
|
}).start();
|
|
});
|
|
}, [segments?.completedSegments, segments?.segmentsCount]);
|
|
|
|
const remainingTime = useMemo(() => calculateRemainingTime(endAt), [endAt]);
|
|
|
|
if (!hasValidProgress || !progress || !segments) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<View style={[styles.shadow, style]}>
|
|
<LinearGradient colors={backgroundColors} start={{ x: 0, y: 0 }} end={{ x: 1, y: 1 }} style={styles.card}>
|
|
<View style={styles.headerRow}>
|
|
<View style={styles.headline}>
|
|
<Text style={[styles.title, { color: titleColor }]} numberOfLines={1}>
|
|
{title}
|
|
</Text>
|
|
</View>
|
|
<Text style={[styles.remaining, { color: subtitleColor }]}>
|
|
挑战剩余 {remainingTime.value} {remainingTime.unit}
|
|
</Text>
|
|
</View>
|
|
|
|
<View style={styles.metaRow}>
|
|
<Text style={[styles.metaValue, { color: metaColor }]}>
|
|
{progress.completed} / {progress.target}
|
|
<Text style={[styles.metaSuffix, { color: metaSuffixColor }]}> 天</Text>
|
|
</Text>
|
|
</View>
|
|
|
|
<View style={[styles.track, { backgroundColor: trackColor }]}>
|
|
{Array.from({ length: segments.segmentsCount }).map((_, index) => {
|
|
const isFirst = index === 0;
|
|
const isLast = index === segments.segmentsCount - 1;
|
|
const animation = segmentAnimations.current[index];
|
|
|
|
if (!animation) {
|
|
return null;
|
|
}
|
|
|
|
const scaleY = animation.interpolate({
|
|
inputRange: [0, 1],
|
|
outputRange: [0.55, 1],
|
|
});
|
|
const scaleX = animation.interpolate({
|
|
inputRange: [0, 1],
|
|
outputRange: [0.7, 1],
|
|
});
|
|
const opacity = animation.interpolate({
|
|
inputRange: [0, 1],
|
|
outputRange: [0.25, 1],
|
|
});
|
|
|
|
return (
|
|
<View
|
|
key={`progress-segment-${index}`}
|
|
style={[
|
|
styles.segment,
|
|
{ backgroundColor: inactiveColor },
|
|
isFirst && styles.segmentFirst,
|
|
isLast && styles.segmentLast,
|
|
]}
|
|
>
|
|
<Animated.View
|
|
style={[
|
|
styles.segmentFill,
|
|
{
|
|
backgroundColor: accentColor,
|
|
opacity,
|
|
transform: [{ scaleX }, { scaleY }],
|
|
},
|
|
]}
|
|
/>
|
|
</View>
|
|
);
|
|
})}
|
|
</View>
|
|
</LinearGradient>
|
|
</View>
|
|
);
|
|
};
|
|
|
|
const styles = StyleSheet.create({
|
|
shadow: {
|
|
borderRadius: 28,
|
|
shadowColor: 'rgba(104, 119, 255, 0.25)',
|
|
shadowOffset: { width: 0, height: 16 },
|
|
shadowOpacity: 0.24,
|
|
shadowRadius: 28,
|
|
elevation: 12,
|
|
},
|
|
card: {
|
|
borderRadius: 28,
|
|
paddingVertical: 24,
|
|
paddingHorizontal: 22,
|
|
},
|
|
headerRow: {
|
|
flexDirection: 'row',
|
|
alignItems: 'flex-start',
|
|
},
|
|
headline: {
|
|
flex: 1,
|
|
},
|
|
title: {
|
|
fontSize: 18,
|
|
fontWeight: '700',
|
|
},
|
|
remaining: {
|
|
fontSize: 11,
|
|
fontWeight: '600',
|
|
alignSelf: 'flex-start',
|
|
},
|
|
metaRow: {
|
|
marginTop: 12,
|
|
},
|
|
metaValue: {
|
|
fontSize: 14,
|
|
fontWeight: '700',
|
|
},
|
|
metaSuffix: {
|
|
fontSize: 13,
|
|
fontWeight: '500',
|
|
},
|
|
track: {
|
|
marginTop: 12,
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
borderRadius: 12,
|
|
paddingHorizontal: 6,
|
|
paddingVertical: 4,
|
|
},
|
|
segment: {
|
|
flex: 1,
|
|
height: 4,
|
|
borderRadius: 4,
|
|
marginHorizontal: 3,
|
|
overflow: 'hidden',
|
|
},
|
|
segmentFirst: {
|
|
marginLeft: 0,
|
|
},
|
|
segmentLast: {
|
|
marginRight: 0,
|
|
},
|
|
segmentFill: {
|
|
flex: 1,
|
|
borderRadius: 4,
|
|
},
|
|
});
|
|
|
|
export default ChallengeProgressCard;
|