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; }; 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 = ({ 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 remainingTime = useMemo(() => calculateRemainingTime(endAt), [endAt]); if (!hasValidProgress || !progress || !segments) { return null; } return ( {title} 挑战剩余 {remainingTime.value} {remainingTime.unit} {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', fontFamily: 'AliBold' }, remaining: { fontSize: 11, fontWeight: '600', alignSelf: 'flex-start', fontFamily: 'AliRegular' }, metaRow: { marginTop: 12, }, metaValue: { fontSize: 14, fontWeight: '700', fontFamily: 'AliBold' }, metaSuffix: { fontSize: 13, fontWeight: '500', fontFamily: 'AliBold' }, 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;