feat(challenges): 新增 ChallengeProgressCard 组件并接入喝水挑战进度上报
- 抽离进度卡片为独立组件,支持主题色自定义与复用 - 挑战列表页顶部展示进行中的挑战进度 - 喝水记录自动上报至关联的水挑战 - 移除旧版 challengeSlice 与冗余进度样式 - 统一使用 value 字段上报进度,兼容多类型挑战
This commit is contained in:
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user