feat(challenges): 新增 ChallengeProgressCard 组件并接入喝水挑战进度上报

- 抽离进度卡片为独立组件,支持主题色自定义与复用
- 挑战列表页顶部展示进行中的挑战进度
- 喝水记录自动上报至关联的水挑战
- 移除旧版 challengeSlice 与冗余进度样式
- 统一使用 value 字段上报进度,兼容多类型挑战
This commit is contained in:
richarjiang
2025-09-29 15:14:59 +08:00
parent 9c86b0e565
commit 970a4b8568
9 changed files with 364 additions and 464 deletions

View File

@@ -1,3 +1,4 @@
import ChallengeProgressCard from '@/components/challenges/ChallengeProgressCard';
import { HeaderBar } from '@/components/ui/HeaderBar';
import { Colors } from '@/constants/Colors';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
@@ -19,7 +20,6 @@ import {
} from '@/store/challengesSlice';
import { Toast } from '@/utils/toast.utils';
import { Ionicons } from '@expo/vector-icons';
import dayjs from 'dayjs';
import { BlurView } from 'expo-blur';
import { LinearGradient } from 'expo-linear-gradient';
import { useLocalSearchParams, useRouter } from 'expo-router';
@@ -137,19 +137,6 @@ export default function ChallengeDetailScreen() {
}, [showCelebration]);
const progress = challenge?.progress;
const hasProgress = Boolean(progress);
const progressTarget = progress?.target ?? 0;
const progressCompleted = progress?.completed ?? 0;
const progressSegments = useMemo(() => {
if (!hasProgress || progressTarget <= 0) return undefined;
const segmentsCount = Math.max(1, Math.min(progressTarget, 18));
const completedSegments = Math.min(
segmentsCount,
Math.round((progressCompleted / Math.max(progressTarget, 1)) * segmentsCount),
);
return { segmentsCount, completedSegments };
}, [hasProgress, progressCompleted, progressTarget]);
const rankingData = useMemo(() => challenge?.rankings ?? [], [challenge?.rankings]);
const participantAvatars = useMemo(
@@ -291,9 +278,6 @@ export default function ChallengeDetailScreen() {
const participantsLabel = formatParticipantsLabel(challenge.participantsCount);
const inlineErrorMessage = detailStatus === 'failed' && detailError ? detailError : undefined;
const progressActionError =
(progressStatus !== 'loading' && progressError) || (leaveStatus !== 'loading' && leaveError) || undefined;
return (
<View style={styles.safeArea}>
<StatusBar barStyle="light-content" />
@@ -342,86 +326,13 @@ export default function ChallengeDetailScreen() {
) : null}
</View>
{progress && progressSegments ? (
<View>
<View style={styles.progressCardShadow}>
<LinearGradient
colors={['#ffffff', '#ffffff']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.progressCard}
>
<View style={styles.progressHeaderRow}>
<View style={styles.progressHeadline}>
<Text style={styles.progressTitle}>{challenge.title}</Text>
</View>
<Text style={styles.progressRemaining}> {dayjs(challenge.endAt).diff(dayjs(), 'd') || 0} </Text>
</View>
<View style={styles.progressMetaRow}>
<Text style={styles.progressMetaValue}>
{progress.completed} / {progress.target}
<Text style={styles.progressMetaSuffix}> </Text>
</Text>
</View>
<View style={styles.progressBarTrack}>
{Array.from({ length: progressSegments.segmentsCount }).map((_, index) => {
const isComplete = index < progressSegments.completedSegments;
const isFirst = index === 0;
const isLast = index === progressSegments.segmentsCount - 1;
return (
<View
key={`progress-segment-${index}`}
style={[
styles.progressBarSegment,
isComplete && styles.progressBarSegmentActive,
isFirst && styles.progressBarSegmentFirst,
isLast && styles.progressBarSegmentLast,
]}
/>
);
})}
</View>
{/* {isJoined ? (
<>
<View style={styles.progressActionsRow}>
<TouchableOpacity
style={[
styles.progressPrimaryAction,
progressStatus === 'loading' && styles.progressActionDisabled,
]}
activeOpacity={0.9}
onPress={handleProgressReport}
disabled={progressStatus === 'loading'}
>
<Text style={styles.progressPrimaryActionText}>
{progressStatus === 'loading' ? '打卡中…' : '打卡 +1'}
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.progressSecondaryAction,
leaveStatus === 'loading' && styles.progressActionDisabled,
]}
activeOpacity={0.9}
onPress={handleLeave}
disabled={leaveStatus === 'loading'}
>
<Text style={styles.progressSecondaryActionText}>
{leaveStatus === 'loading' ? '处理中…' : '退出挑战'}
</Text>
</TouchableOpacity>
</View>
{progressActionError ? (
<Text style={styles.progressErrorText}>{progressActionError}</Text>
) : null}
</>
) : null} */}
</LinearGradient>
</View>
</View>
{progress ? (
<ChallengeProgressCard
title={challenge.title}
endAt={challenge.endAt}
progress={progress}
style={styles.progressCardWrapper}
/>
) : null}
<View style={styles.detailCard}>
@@ -584,157 +495,9 @@ const styles = StyleSheet.create({
scrollContent: {
paddingBottom: Platform.select({ ios: 40, default: 28 }),
},
progressCardShadow: {
progressCardWrapper: {
marginTop: 20,
marginHorizontal: 24,
shadowColor: 'rgba(104, 119, 255, 0.25)',
shadowOffset: { width: 0, height: 16 },
shadowOpacity: 0.24,
shadowRadius: 28,
elevation: 12,
borderRadius: 28,
},
progressCard: {
borderRadius: 28,
paddingVertical: 24,
paddingHorizontal: 22,
backgroundColor: '#ffffff',
},
progressHeaderRow: {
flexDirection: 'row',
alignItems: 'flex-start',
},
progressBadgeRing: {
width: 68,
height: 68,
borderRadius: 34,
backgroundColor: '#ffffff',
padding: 6,
shadowColor: 'rgba(67, 82, 186, 0.16)',
shadowOffset: { width: 0, height: 6 },
shadowOpacity: 0.4,
shadowRadius: 12,
elevation: 6,
marginRight: 16,
},
progressBadge: {
width: '100%',
height: '100%',
borderRadius: 28,
},
progressBadgeFallback: {
flex: 1,
height: '100%',
borderRadius: 22,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#EEF0FF',
paddingHorizontal: 6,
},
progressBadgeText: {
fontSize: 12,
fontWeight: '700',
color: '#4F5BD5',
textAlign: 'center',
},
progressHeadline: {
flex: 1,
},
progressTitle: {
fontSize: 18,
fontWeight: '700',
color: '#1c1f3a',
},
progressSubtitle: {
marginTop: 6,
fontSize: 13,
color: '#5f6a97',
},
progressRemaining: {
fontSize: 11,
fontWeight: '600',
color: '#707baf',
marginLeft: 16,
alignSelf: 'flex-start',
},
progressMetaRow: {
marginTop: 12,
},
progressMetaValue: {
fontSize: 14,
fontWeight: '700',
color: '#4F5BD5',
},
progressMetaSuffix: {
fontSize: 13,
fontWeight: '500',
color: '#7a86bb',
},
progressBarTrack: {
marginTop: 12,
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#eceffa',
borderRadius: 12,
paddingHorizontal: 6,
paddingVertical: 4,
},
progressBarSegment: {
flex: 1,
height: 4,
borderRadius: 4,
backgroundColor: '#dfe4f6',
marginHorizontal: 3,
},
progressBarSegmentActive: {
backgroundColor: '#5E8BFF',
},
progressBarSegmentFirst: {
marginLeft: 0,
},
progressBarSegmentLast: {
marginRight: 0,
},
progressActionsRow: {
flexDirection: 'row',
marginTop: 20,
},
progressPrimaryAction: {
flex: 1,
paddingVertical: 12,
borderRadius: 18,
backgroundColor: '#5E8BFF',
alignItems: 'center',
justifyContent: 'center',
marginRight: 12,
},
progressSecondaryAction: {
flex: 1,
paddingVertical: 12,
borderRadius: 18,
borderWidth: 1,
borderColor: '#d6dcff',
alignItems: 'center',
justifyContent: 'center',
},
progressActionDisabled: {
opacity: 0.6,
},
progressPrimaryActionText: {
fontSize: 14,
fontWeight: '700',
color: '#ffffff',
},
progressSecondaryActionText: {
fontSize: 14,
fontWeight: '700',
color: '#4F5BD5',
},
progressErrorText: {
marginTop: 12,
fontSize: 12,
color: '#FF6B6B',
textAlign: 'center',
},
floatingCTAContainer: {
position: 'absolute',