feat(challenges): 新增 ChallengeProgressCard 组件并接入喝水挑战进度上报
- 抽离进度卡片为独立组件,支持主题色自定义与复用 - 挑战列表页顶部展示进行中的挑战进度 - 喝水记录自动上报至关联的水挑战 - 移除旧版 challengeSlice 与冗余进度样式 - 统一使用 value 字段上报进度,兼容多类型挑战
This commit is contained in:
180
components/challenges/ChallengeProgressCard.tsx
Normal file
180
components/challenges/ChallengeProgressCard.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import dayjs from 'dayjs';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import React, { useMemo } from 'react';
|
||||
import { 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 segments = useMemo(() => {
|
||||
if (!hasValidProgress || !progress) return undefined;
|
||||
return clampSegments(progress.target, progress.completed);
|
||||
}, [hasValidProgress, progress]);
|
||||
|
||||
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 isComplete = index < segments.completedSegments;
|
||||
const isFirst = index === 0;
|
||||
const isLast = index === segments.segmentsCount - 1;
|
||||
return (
|
||||
<View
|
||||
key={`progress-segment-${index}`}
|
||||
style={[
|
||||
styles.segment,
|
||||
{ backgroundColor: isComplete ? accentColor : inactiveColor },
|
||||
isFirst && styles.segmentFirst,
|
||||
isLast && styles.segmentLast,
|
||||
]}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</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,
|
||||
},
|
||||
segmentFirst: {
|
||||
marginLeft: 0,
|
||||
},
|
||||
segmentLast: {
|
||||
marginRight: 0,
|
||||
},
|
||||
});
|
||||
|
||||
export default ChallengeProgressCard;
|
||||
Reference in New Issue
Block a user