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; 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 = ({ 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([]); 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 ( {title} 挑战剩余 {remainingDays} 天 {progress.completed} / {progress.target} {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 ( ); })} ); }; 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;