Files
digital-pilates/components/challenges/ChallengeProgressCard.tsx
richarjiang d74bd214ed feat(challenges): 登录态守卫与进度条动画优化
- 在 _layout 中仅当已登录时才拉取挑战列表,避免未授权请求
- 挑战详情页加入 ensureLoggedIn 守卫,未登录时跳转登录
- ChallengeProgressCard 新增分段进度动画,提升视觉反馈
- 升级版本号至 1.0.15
2025-09-29 15:39:52 +08:00

245 lines
7.0 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;
};
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 calculateRemainingDays = (endAt?: string) => {
if (!endAt) return 0;
const endDate = dayjs(endAt);
if (!endDate.isValid()) return 0;
return Math.max(0, endDate.diff(dayjs(), 'd'));
};
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 remainingDays = useMemo(() => calculateRemainingDays(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 }]}> {remainingDays} </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;