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 { IconSymbol } from '@/components/ui/IconSymbol';
import { Colors } from '@/constants/Colors';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
@@ -12,7 +13,7 @@ import {
import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient';
import { useRouter } from 'expo-router';
import React, { useEffect } from 'react';
import React, { useEffect, useMemo } from 'react';
import {
ActivityIndicator,
ScrollView,
@@ -41,9 +42,15 @@ export default function ChallengesScreen() {
const challenges = useAppSelector(selectChallengeCards);
const listStatus = useAppSelector(selectChallengesListStatus);
const listError = useAppSelector(selectChallengesListError);
console.log('challenges', challenges);
const ongoingChallenge = useMemo(
() =>
challenges.find(
(challenge) => challenge.status === 'ongoing' && challenge.isJoined && challenge.progress
),
[challenges]
);
const progressTrackColor = theme === 'dark' ? 'rgba(255, 255, 255, 0.08)' : '#eceffa';
const progressInactiveColor = theme === 'dark' ? 'rgba(255, 255, 255, 0.24)' : '#dfe4f6';
useEffect(() => {
if (listStatus === 'idle') {
@@ -132,6 +139,30 @@ export default function ChallengesScreen() {
</TouchableOpacity>
</View>
{ongoingChallenge ? (
<TouchableOpacity
activeOpacity={0.92}
onPress={() =>
router.push({ pathname: '/challenges/[id]', params: { id: ongoingChallenge.id } })
}
>
<ChallengeProgressCard
title={ongoingChallenge.title}
endAt={ongoingChallenge.endAt}
progress={ongoingChallenge.progress}
style={styles.progressCardWrapper}
backgroundColors={[colorTokens.card, colorTokens.card]}
titleColor={colorTokens.text}
subtitleColor={colorTokens.textSecondary}
metaColor={colorTokens.primary}
metaSuffixColor={colorTokens.textSecondary}
accentColor={colorTokens.primary}
trackColor={progressTrackColor}
inactiveColor={progressInactiveColor}
/>
</TouchableOpacity>
) : null}
<View style={styles.cardsContainer}>{renderChallenges()}</View>
</ScrollView>
</SafeAreaView>
@@ -179,11 +210,6 @@ function ChallengeCard({ challenge, surfaceColor, textColor, mutedColor, onPress
{statusLabel}
{challenge.isJoined ? ' · 已加入' : ''}
</Text>
{challenge.progress?.badge ? (
<Text style={[styles.cardProgress, { color: textColor }]} numberOfLines={1}>
{challenge.progress.badge}
</Text>
) : null}
{challenge.avatars.length ? (
<AvatarStack avatars={challenge.avatars} borderColor={surfaceColor} />
) : null}
@@ -264,6 +290,9 @@ const styles = StyleSheet.create({
cardsContainer: {
gap: 18,
},
progressCardWrapper: {
marginBottom: 24,
},
stateContainer: {
alignItems: 'center',
justifyContent: 'center',

View File

@@ -14,6 +14,7 @@ import { setupQuickActions } from '@/services/quickActions';
import { initializeWaterRecordBridge } from '@/services/waterRecordBridge';
import { WaterRecordSource } from '@/services/waterRecords';
import { store } from '@/store';
import { fetchChallenges } from '@/store/challengesSlice';
import { fetchMyProfile, setPrivacyAgreed } from '@/store/userSlice';
import { createWaterRecordAction } from '@/store/waterSlice';
import { ensureHealthPermissions, initializeHealthPermissions } from '@/utils/health';
@@ -127,6 +128,7 @@ function Bootstrapper({ children }: { children: React.ReactNode }) {
loadUserData();
initHealthPermissions();
initializeNotifications();
dispatch(fetchChallenges());
// 冷启动时清空 AI 教练会话缓存
clearAiCoachSessionCache();

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',