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',
|
||||
|
||||
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;
|
||||
@@ -1,3 +1,6 @@
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { ChallengeType } from '@/services/challengesApi';
|
||||
import { reportChallengeProgress, selectChallengeList } from '@/store/challengesSlice';
|
||||
import { deleteWaterIntakeFromHealthKit, getWaterIntakeFromHealthKit, saveWaterIntakeToHealthKit } from '@/utils/health';
|
||||
import { logger } from '@/utils/logger';
|
||||
import { Toast } from '@/utils/toast.utils';
|
||||
@@ -41,6 +44,32 @@ function createDateRange(date: string): { startDate: string; endDate: string } {
|
||||
};
|
||||
}
|
||||
|
||||
const useWaterChallengeProgressReporter = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const allChallenges = useAppSelector(selectChallengeList);
|
||||
const joinedWaterChallenges = useMemo(
|
||||
() => allChallenges.filter((challenge) => challenge.type === ChallengeType.WATER && challenge.isJoined),
|
||||
[allChallenges]
|
||||
);
|
||||
|
||||
return useCallback(
|
||||
async (value: number) => {
|
||||
if (!joinedWaterChallenges.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const challenge of joinedWaterChallenges) {
|
||||
try {
|
||||
await dispatch(reportChallengeProgress({ id: challenge.id, value })).unwrap();
|
||||
} catch (error) {
|
||||
console.warn('挑战进度上报失败', { error, challengeId: challenge.id });
|
||||
}
|
||||
}
|
||||
},
|
||||
[dispatch, joinedWaterChallenges]
|
||||
);
|
||||
};
|
||||
|
||||
export const useWaterData = () => {
|
||||
// 本地状态管理
|
||||
const [loading, setLoading] = useState({
|
||||
@@ -152,46 +181,53 @@ export const useWaterData = () => {
|
||||
}, [getWaterRecordsByDate]);
|
||||
|
||||
// 创建喝水记录
|
||||
const addWaterRecord = useCallback(async (amount: number, recordedAt?: string) => {
|
||||
try {
|
||||
const recordTime = recordedAt || dayjs().toISOString();
|
||||
const reportWaterChallengeProgress = useWaterChallengeProgressReporter();
|
||||
|
||||
// 保存到 HealthKit
|
||||
const healthKitSuccess = await saveWaterIntakeToHealthKit(amount, recordTime);
|
||||
if (!healthKitSuccess) {
|
||||
Toast.error('保存到 HealthKit 失败');
|
||||
const addWaterRecord = useCallback(
|
||||
async (amount: number, recordedAt?: string) => {
|
||||
try {
|
||||
const recordTime = recordedAt || dayjs().toISOString();
|
||||
const date = dayjs(recordTime).format('YYYY-MM-DD');
|
||||
const isToday = dayjs(recordTime).isSame(dayjs(), 'day');
|
||||
|
||||
// 保存到 HealthKit
|
||||
const healthKitSuccess = await saveWaterIntakeToHealthKit(amount, recordTime);
|
||||
if (!healthKitSuccess) {
|
||||
Toast.error('保存到 HealthKit 失败');
|
||||
return false;
|
||||
}
|
||||
|
||||
// 重新获取当前日期的数据以刷新界面
|
||||
const updatedRecords = await getWaterRecordsByDate(date);
|
||||
const totalAmount = updatedRecords.reduce((sum, record) => sum + record.amount, 0);
|
||||
|
||||
// 如果是今天的数据,更新Widget
|
||||
if (isToday) {
|
||||
const quickAddAmount = await getQuickWaterAmount();
|
||||
|
||||
try {
|
||||
await syncWaterDataToWidget({
|
||||
currentIntake: totalAmount,
|
||||
dailyGoal: dailyWaterGoal,
|
||||
quickAddAmount,
|
||||
});
|
||||
await refreshWidget();
|
||||
} catch (widgetError) {
|
||||
console.error('Widget 同步错误:', widgetError);
|
||||
}
|
||||
}
|
||||
|
||||
await reportWaterChallengeProgress(totalAmount);
|
||||
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
console.error('添加喝水记录失败:', error);
|
||||
Toast.error(error?.message || '添加喝水记录失败');
|
||||
return false;
|
||||
}
|
||||
|
||||
// 重新获取当前日期的数据以刷新界面
|
||||
const date = dayjs(recordTime).format('YYYY-MM-DD');
|
||||
await getWaterRecordsByDate(date);
|
||||
|
||||
// 如果是今天的数据,更新Widget
|
||||
if (date === dayjs().format('YYYY-MM-DD')) {
|
||||
const todayRecords = waterRecords[date] || [];
|
||||
const totalAmount = todayRecords.reduce((sum, record) => sum + record.amount, 0);
|
||||
const quickAddAmount = await getQuickWaterAmount();
|
||||
|
||||
try {
|
||||
await syncWaterDataToWidget({
|
||||
currentIntake: totalAmount,
|
||||
dailyGoal: dailyWaterGoal,
|
||||
quickAddAmount,
|
||||
});
|
||||
await refreshWidget();
|
||||
} catch (widgetError) {
|
||||
console.error('Widget 同步错误:', widgetError);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
console.error('添加喝水记录失败:', error);
|
||||
Toast.error(error?.message || '添加喝水记录失败');
|
||||
return false;
|
||||
}
|
||||
}, [getWaterRecordsByDate, waterRecords, dailyWaterGoal]);
|
||||
},
|
||||
[dailyWaterGoal, getWaterRecordsByDate, reportWaterChallengeProgress]
|
||||
);
|
||||
|
||||
// 更新喝水记录(HealthKit不支持更新,只能删除后重新添加)
|
||||
const updateWaterRecord = useCallback(async (id: string, amount?: number, note?: string, recordedAt?: string) => {
|
||||
@@ -524,44 +560,51 @@ export const useWaterDataByDate = (targetDate?: string) => {
|
||||
}, []);
|
||||
|
||||
// 创建喝水记录
|
||||
const addWaterRecord = useCallback(async (amount: number, recordedAt?: string) => {
|
||||
try {
|
||||
const recordTime = recordedAt || dayjs().toISOString();
|
||||
const reportWaterChallengeProgress = useWaterChallengeProgressReporter();
|
||||
|
||||
// 保存到 HealthKit
|
||||
const healthKitSuccess = await saveWaterIntakeToHealthKit(amount, recordTime);
|
||||
if (!healthKitSuccess) {
|
||||
Toast.error('保存到 HealthKit 失败');
|
||||
const addWaterRecord = useCallback(
|
||||
async (amount: number, recordedAt?: string) => {
|
||||
try {
|
||||
const recordTime = recordedAt || dayjs().toISOString();
|
||||
|
||||
// 保存到 HealthKit
|
||||
const healthKitSuccess = await saveWaterIntakeToHealthKit(amount, recordTime);
|
||||
if (!healthKitSuccess) {
|
||||
Toast.error('保存到 HealthKit 失败');
|
||||
return false;
|
||||
}
|
||||
|
||||
// 重新获取当前日期的数据以刷新界面
|
||||
const updatedRecords = await getWaterRecordsByDate(dateToUse);
|
||||
const totalAmount = updatedRecords.reduce((sum, record) => sum + record.amount, 0);
|
||||
|
||||
// 如果是今天的数据,更新Widget
|
||||
if (dateToUse === dayjs().format('YYYY-MM-DD')) {
|
||||
const quickAddAmount = await getQuickWaterAmount();
|
||||
|
||||
try {
|
||||
await syncWaterDataToWidget({
|
||||
currentIntake: totalAmount,
|
||||
dailyGoal: dailyWaterGoal,
|
||||
quickAddAmount,
|
||||
});
|
||||
await refreshWidget();
|
||||
} catch (widgetError) {
|
||||
console.error('Widget 同步错误:', widgetError);
|
||||
}
|
||||
}
|
||||
|
||||
await reportWaterChallengeProgress(totalAmount);
|
||||
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
console.error('添加喝水记录失败:', error);
|
||||
Toast.error(error?.message || '添加喝水记录失败');
|
||||
return false;
|
||||
}
|
||||
|
||||
// 重新获取当前日期的数据以刷新界面
|
||||
await getWaterRecordsByDate(dateToUse);
|
||||
|
||||
// 如果是今天的数据,更新Widget
|
||||
if (dateToUse === dayjs().format('YYYY-MM-DD')) {
|
||||
const totalAmount = waterRecords.reduce((sum, record) => sum + record.amount, 0) + amount;
|
||||
const quickAddAmount = await getQuickWaterAmount();
|
||||
|
||||
try {
|
||||
await syncWaterDataToWidget({
|
||||
currentIntake: totalAmount,
|
||||
dailyGoal: dailyWaterGoal,
|
||||
quickAddAmount,
|
||||
});
|
||||
await refreshWidget();
|
||||
} catch (widgetError) {
|
||||
console.error('Widget 同步错误:', widgetError);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
console.error('添加喝水记录失败:', error);
|
||||
Toast.error(error?.message || '添加喝水记录失败');
|
||||
return false;
|
||||
}
|
||||
}, [getWaterRecordsByDate, dateToUse, waterRecords, dailyWaterGoal]);
|
||||
},
|
||||
[dailyWaterGoal, dateToUse, getWaterRecordsByDate, reportWaterChallengeProgress]
|
||||
);
|
||||
|
||||
// 更新喝水记录
|
||||
const updateWaterRecord = useCallback(async (id: string, amount?: number, note?: string, recordedAt?: string) => {
|
||||
@@ -708,4 +751,4 @@ export const useWaterDataByDate = (targetDate?: string) => {
|
||||
updateWaterGoal,
|
||||
getWaterRecordsByDate,
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -16,6 +16,17 @@ export type RankingItemDto = {
|
||||
badge?: string;
|
||||
};
|
||||
|
||||
export enum ChallengeType {
|
||||
WATER = 'water',
|
||||
EXERCISE = 'exercise',
|
||||
DIET = 'diet',
|
||||
MOOD = 'mood',
|
||||
SLEEP = 'sleep',
|
||||
WEIGHT = 'weight',
|
||||
}
|
||||
|
||||
|
||||
|
||||
export type ChallengeListItemDto = {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -34,6 +45,7 @@ export type ChallengeListItemDto = {
|
||||
startAt?: string;
|
||||
endAt?: string;
|
||||
minimumCheckInDays: number; // 最小打卡天数
|
||||
type: ChallengeType;
|
||||
};
|
||||
|
||||
export type ChallengeDetailDto = ChallengeListItemDto & {
|
||||
@@ -58,7 +70,7 @@ export async function leaveChallenge(id: string): Promise<boolean> {
|
||||
return api.post<boolean>(`/challenges/${encodeURIComponent(id)}/leave`);
|
||||
}
|
||||
|
||||
export async function reportChallengeProgress(id: string, increment?: number): Promise<ChallengeProgressDto> {
|
||||
const body = increment != null ? { increment } : undefined;
|
||||
export async function reportChallengeProgress(id: string, value?: number): Promise<ChallengeProgressDto> {
|
||||
const body = value != null ? { value } : undefined;
|
||||
return api.post<ChallengeProgressDto>(`/challenges/${encodeURIComponent(id)}/progress`, body);
|
||||
}
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
import { buildDefaultCustomFromPlan, DayPlan, ExerciseCustomConfig, generatePilates30DayPlan, PilatesLevel } from '@/utils/pilatesPlan';
|
||||
import AsyncStorage from '@/utils/kvStore';
|
||||
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
|
||||
export type DayStatus = 'locked' | 'available' | 'completed';
|
||||
|
||||
export type ChallengeDayState = {
|
||||
plan: DayPlan;
|
||||
status: DayStatus;
|
||||
completedAt?: string | null; // ISO
|
||||
notes?: string;
|
||||
custom?: ExerciseCustomConfig[]; // 用户自定义:启用/禁用、组数、时长
|
||||
};
|
||||
|
||||
export type ChallengeState = {
|
||||
startedAt?: string | null;
|
||||
level: PilatesLevel;
|
||||
days: ChallengeDayState[]; // 1..30
|
||||
streak: number; // 连续天数
|
||||
};
|
||||
|
||||
const STORAGE_KEY = '@pilates_challenge_30d';
|
||||
|
||||
const initialState: ChallengeState = {
|
||||
startedAt: null,
|
||||
level: 'beginner',
|
||||
days: [],
|
||||
streak: 0,
|
||||
};
|
||||
|
||||
function computeStreak(days: ChallengeDayState[]): number {
|
||||
// 连续从第1天开始的已完成天数
|
||||
let s = 0;
|
||||
for (let i = 0; i < days.length; i += 1) {
|
||||
if (days[i].status === 'completed') s += 1; else break;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
export const initChallenge = createAsyncThunk(
|
||||
'challenge/init',
|
||||
async (_: void, { getState }) => {
|
||||
const persisted = await AsyncStorage.getItem(STORAGE_KEY);
|
||||
if (persisted) {
|
||||
try {
|
||||
const parsed = JSON.parse(persisted) as ChallengeState;
|
||||
return parsed;
|
||||
} catch {}
|
||||
}
|
||||
// 默认生成
|
||||
const level: PilatesLevel = 'beginner';
|
||||
const plans = generatePilates30DayPlan(level);
|
||||
const days: ChallengeDayState[] = plans.map((p, idx) => ({
|
||||
plan: p,
|
||||
status: idx === 0 ? 'available' : 'locked',
|
||||
custom: buildDefaultCustomFromPlan(p),
|
||||
}));
|
||||
const state: ChallengeState = {
|
||||
startedAt: new Date().toISOString(),
|
||||
level,
|
||||
days,
|
||||
streak: 0,
|
||||
};
|
||||
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
||||
return state;
|
||||
}
|
||||
);
|
||||
|
||||
export const persistChallenge = createAsyncThunk(
|
||||
'challenge/persist',
|
||||
async (_: void, { getState }) => {
|
||||
const s = (getState() as any).challenge as ChallengeState;
|
||||
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(s));
|
||||
return true;
|
||||
}
|
||||
);
|
||||
|
||||
export const completeDay = createAsyncThunk(
|
||||
'challenge/completeDay',
|
||||
async (dayNumber: number, { getState, dispatch }) => {
|
||||
const state = (getState() as any).challenge as ChallengeState;
|
||||
const idx = dayNumber - 1;
|
||||
const days = [...state.days];
|
||||
if (!days[idx] || days[idx].status === 'completed') return state;
|
||||
days[idx] = { ...days[idx], status: 'completed', completedAt: new Date().toISOString() };
|
||||
if (days[idx + 1]) {
|
||||
days[idx + 1] = { ...days[idx + 1], status: 'available' };
|
||||
}
|
||||
const next: ChallengeState = {
|
||||
...state,
|
||||
days,
|
||||
streak: computeStreak(days),
|
||||
};
|
||||
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
||||
return next;
|
||||
}
|
||||
);
|
||||
|
||||
const challengeSlice = createSlice({
|
||||
name: 'challenge',
|
||||
initialState,
|
||||
reducers: {
|
||||
setLevel(state, action: PayloadAction<PilatesLevel>) {
|
||||
state.level = action.payload;
|
||||
},
|
||||
setNote(state, action: PayloadAction<{ dayNumber: number; notes: string }>) {
|
||||
const idx = action.payload.dayNumber - 1;
|
||||
if (state.days[idx]) state.days[idx].notes = action.payload.notes;
|
||||
},
|
||||
setCustom(state, action: PayloadAction<{ dayNumber: number; custom: ExerciseCustomConfig[] }>) {
|
||||
const idx = action.payload.dayNumber - 1;
|
||||
if (state.days[idx]) state.days[idx].custom = action.payload.custom;
|
||||
},
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
builder
|
||||
.addCase(initChallenge.fulfilled, (_state, action) => {
|
||||
return action.payload as ChallengeState;
|
||||
})
|
||||
.addCase(completeDay.fulfilled, (_state, action) => {
|
||||
return action.payload as ChallengeState;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const { setLevel, setNote, setCustom } = challengeSlice.actions;
|
||||
export default challengeSlice.reducer;
|
||||
|
||||
|
||||
@@ -117,11 +117,11 @@ export const leaveChallenge = createAsyncThunk<{ id: string }, string, { rejectV
|
||||
|
||||
export const reportChallengeProgress = createAsyncThunk<
|
||||
{ id: string; progress: ChallengeProgress },
|
||||
{ id: string; increment?: number },
|
||||
{ id: string; value?: number },
|
||||
{ rejectValue: string }
|
||||
>('challenges/reportProgress', async ({ id, increment }, { rejectWithValue }) => {
|
||||
>('challenges/reportProgress', async ({ id, value }, { rejectWithValue }) => {
|
||||
try {
|
||||
const progress = await reportChallengeProgressApi(id, increment);
|
||||
const progress = await reportChallengeProgressApi(id, value);
|
||||
return { id, progress };
|
||||
} catch (error) {
|
||||
return rejectWithValue(toErrorMessage(error));
|
||||
@@ -311,6 +311,7 @@ export type ChallengeCardViewModel = {
|
||||
participantsLabel: string;
|
||||
status: ChallengeStatus;
|
||||
isJoined: boolean;
|
||||
endAt?: string;
|
||||
periodLabel?: string;
|
||||
durationLabel: string;
|
||||
requirementLabel: string;
|
||||
@@ -330,6 +331,7 @@ export const selectChallengeCards = createSelector([selectChallengeList], (chall
|
||||
participantsLabel: `${formatNumberWithSeparator(challenge.participantsCount)} 人参与`,
|
||||
status: challenge.status,
|
||||
isJoined: challenge.isJoined,
|
||||
endAt: challenge.endAt,
|
||||
periodLabel: challenge.periodLabel,
|
||||
durationLabel: challenge.durationLabel,
|
||||
requirementLabel: challenge.requirementLabel,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { configureStore, createListenerMiddleware } from '@reduxjs/toolkit';
|
||||
import challengeReducer from './challengeSlice';
|
||||
import challengesReducer from './challengesSlice';
|
||||
import checkinReducer, { addExercise, autoSyncCheckin, removeExercise, replaceExercises, setNote, toggleExerciseCompleted } from './checkinSlice';
|
||||
import circumferenceReducer from './circumferenceSlice';
|
||||
@@ -48,7 +47,6 @@ syncActions.forEach(action => {
|
||||
export const store = configureStore({
|
||||
reducer: {
|
||||
user: userReducer,
|
||||
challenge: challengeReducer,
|
||||
challenges: challengesReducer,
|
||||
checkin: checkinReducer,
|
||||
circumference: circumferenceReducer,
|
||||
|
||||
Reference in New Issue
Block a user