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