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 { IconSymbol } from '@/components/ui/IconSymbol';
import { Colors } from '@/constants/Colors'; import { Colors } from '@/constants/Colors';
import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useAppDispatch, useAppSelector } from '@/hooks/redux';
@@ -12,7 +13,7 @@ import {
import { Image } from 'expo-image'; import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import React, { useEffect } from 'react'; import React, { useEffect, useMemo } from 'react';
import { import {
ActivityIndicator, ActivityIndicator,
ScrollView, ScrollView,
@@ -41,9 +42,15 @@ export default function ChallengesScreen() {
const challenges = useAppSelector(selectChallengeCards); const challenges = useAppSelector(selectChallengeCards);
const listStatus = useAppSelector(selectChallengesListStatus); const listStatus = useAppSelector(selectChallengesListStatus);
const listError = useAppSelector(selectChallengesListError); const listError = useAppSelector(selectChallengesListError);
const ongoingChallenge = useMemo(
console.log('challenges', challenges); () =>
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(() => { useEffect(() => {
if (listStatus === 'idle') { if (listStatus === 'idle') {
@@ -132,6 +139,30 @@ export default function ChallengesScreen() {
</TouchableOpacity> </TouchableOpacity>
</View> </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> <View style={styles.cardsContainer}>{renderChallenges()}</View>
</ScrollView> </ScrollView>
</SafeAreaView> </SafeAreaView>
@@ -179,11 +210,6 @@ function ChallengeCard({ challenge, surfaceColor, textColor, mutedColor, onPress
{statusLabel} {statusLabel}
{challenge.isJoined ? ' · 已加入' : ''} {challenge.isJoined ? ' · 已加入' : ''}
</Text> </Text>
{challenge.progress?.badge ? (
<Text style={[styles.cardProgress, { color: textColor }]} numberOfLines={1}>
{challenge.progress.badge}
</Text>
) : null}
{challenge.avatars.length ? ( {challenge.avatars.length ? (
<AvatarStack avatars={challenge.avatars} borderColor={surfaceColor} /> <AvatarStack avatars={challenge.avatars} borderColor={surfaceColor} />
) : null} ) : null}
@@ -264,6 +290,9 @@ const styles = StyleSheet.create({
cardsContainer: { cardsContainer: {
gap: 18, gap: 18,
}, },
progressCardWrapper: {
marginBottom: 24,
},
stateContainer: { stateContainer: {
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',

View File

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

View File

@@ -1,3 +1,4 @@
import ChallengeProgressCard from '@/components/challenges/ChallengeProgressCard';
import { HeaderBar } from '@/components/ui/HeaderBar'; import { HeaderBar } from '@/components/ui/HeaderBar';
import { Colors } from '@/constants/Colors'; import { Colors } from '@/constants/Colors';
import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useAppDispatch, useAppSelector } from '@/hooks/redux';
@@ -19,7 +20,6 @@ import {
} from '@/store/challengesSlice'; } from '@/store/challengesSlice';
import { Toast } from '@/utils/toast.utils'; import { Toast } from '@/utils/toast.utils';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import dayjs from 'dayjs';
import { BlurView } from 'expo-blur'; import { BlurView } from 'expo-blur';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import { useLocalSearchParams, useRouter } from 'expo-router'; import { useLocalSearchParams, useRouter } from 'expo-router';
@@ -137,19 +137,6 @@ export default function ChallengeDetailScreen() {
}, [showCelebration]); }, [showCelebration]);
const progress = challenge?.progress; 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 rankingData = useMemo(() => challenge?.rankings ?? [], [challenge?.rankings]);
const participantAvatars = useMemo( const participantAvatars = useMemo(
@@ -291,9 +278,6 @@ export default function ChallengeDetailScreen() {
const participantsLabel = formatParticipantsLabel(challenge.participantsCount); const participantsLabel = formatParticipantsLabel(challenge.participantsCount);
const inlineErrorMessage = detailStatus === 'failed' && detailError ? detailError : undefined; const inlineErrorMessage = detailStatus === 'failed' && detailError ? detailError : undefined;
const progressActionError =
(progressStatus !== 'loading' && progressError) || (leaveStatus !== 'loading' && leaveError) || undefined;
return ( return (
<View style={styles.safeArea}> <View style={styles.safeArea}>
<StatusBar barStyle="light-content" /> <StatusBar barStyle="light-content" />
@@ -342,86 +326,13 @@ export default function ChallengeDetailScreen() {
) : null} ) : null}
</View> </View>
{progress && progressSegments ? ( {progress ? (
<View> <ChallengeProgressCard
<View style={styles.progressCardShadow}> title={challenge.title}
<LinearGradient endAt={challenge.endAt}
colors={['#ffffff', '#ffffff']} progress={progress}
start={{ x: 0, y: 0 }} style={styles.progressCardWrapper}
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>
) : null} ) : null}
<View style={styles.detailCard}> <View style={styles.detailCard}>
@@ -584,157 +495,9 @@ const styles = StyleSheet.create({
scrollContent: { scrollContent: {
paddingBottom: Platform.select({ ios: 40, default: 28 }), paddingBottom: Platform.select({ ios: 40, default: 28 }),
}, },
progressCardShadow: { progressCardWrapper: {
marginTop: 20, marginTop: 20,
marginHorizontal: 24, 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: { floatingCTAContainer: {
position: 'absolute', position: 'absolute',

View 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;

View File

@@ -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 { deleteWaterIntakeFromHealthKit, getWaterIntakeFromHealthKit, saveWaterIntakeToHealthKit } from '@/utils/health';
import { logger } from '@/utils/logger'; import { logger } from '@/utils/logger';
import { Toast } from '@/utils/toast.utils'; 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 = () => { export const useWaterData = () => {
// 本地状态管理 // 本地状态管理
const [loading, setLoading] = useState({ const [loading, setLoading] = useState({
@@ -152,9 +181,14 @@ export const useWaterData = () => {
}, [getWaterRecordsByDate]); }, [getWaterRecordsByDate]);
// 创建喝水记录 // 创建喝水记录
const addWaterRecord = useCallback(async (amount: number, recordedAt?: string) => { const reportWaterChallengeProgress = useWaterChallengeProgressReporter();
const addWaterRecord = useCallback(
async (amount: number, recordedAt?: string) => {
try { try {
const recordTime = recordedAt || dayjs().toISOString(); const recordTime = recordedAt || dayjs().toISOString();
const date = dayjs(recordTime).format('YYYY-MM-DD');
const isToday = dayjs(recordTime).isSame(dayjs(), 'day');
// 保存到 HealthKit // 保存到 HealthKit
const healthKitSuccess = await saveWaterIntakeToHealthKit(amount, recordTime); const healthKitSuccess = await saveWaterIntakeToHealthKit(amount, recordTime);
@@ -164,13 +198,11 @@ export const useWaterData = () => {
} }
// 重新获取当前日期的数据以刷新界面 // 重新获取当前日期的数据以刷新界面
const date = dayjs(recordTime).format('YYYY-MM-DD'); const updatedRecords = await getWaterRecordsByDate(date);
await getWaterRecordsByDate(date); const totalAmount = updatedRecords.reduce((sum, record) => sum + record.amount, 0);
// 如果是今天的数据更新Widget // 如果是今天的数据更新Widget
if (date === dayjs().format('YYYY-MM-DD')) { if (isToday) {
const todayRecords = waterRecords[date] || [];
const totalAmount = todayRecords.reduce((sum, record) => sum + record.amount, 0);
const quickAddAmount = await getQuickWaterAmount(); const quickAddAmount = await getQuickWaterAmount();
try { try {
@@ -185,13 +217,17 @@ export const useWaterData = () => {
} }
} }
await reportWaterChallengeProgress(totalAmount);
return true; return true;
} catch (error: any) { } catch (error: any) {
console.error('添加喝水记录失败:', error); console.error('添加喝水记录失败:', error);
Toast.error(error?.message || '添加喝水记录失败'); Toast.error(error?.message || '添加喝水记录失败');
return false; return false;
} }
}, [getWaterRecordsByDate, waterRecords, dailyWaterGoal]); },
[dailyWaterGoal, getWaterRecordsByDate, reportWaterChallengeProgress]
);
// 更新喝水记录HealthKit不支持更新只能删除后重新添加 // 更新喝水记录HealthKit不支持更新只能删除后重新添加
const updateWaterRecord = useCallback(async (id: string, amount?: number, note?: string, recordedAt?: string) => { const updateWaterRecord = useCallback(async (id: string, amount?: number, note?: string, recordedAt?: string) => {
@@ -524,7 +560,10 @@ export const useWaterDataByDate = (targetDate?: string) => {
}, []); }, []);
// 创建喝水记录 // 创建喝水记录
const addWaterRecord = useCallback(async (amount: number, recordedAt?: string) => { const reportWaterChallengeProgress = useWaterChallengeProgressReporter();
const addWaterRecord = useCallback(
async (amount: number, recordedAt?: string) => {
try { try {
const recordTime = recordedAt || dayjs().toISOString(); const recordTime = recordedAt || dayjs().toISOString();
@@ -536,11 +575,11 @@ export const useWaterDataByDate = (targetDate?: string) => {
} }
// 重新获取当前日期的数据以刷新界面 // 重新获取当前日期的数据以刷新界面
await getWaterRecordsByDate(dateToUse); const updatedRecords = await getWaterRecordsByDate(dateToUse);
const totalAmount = updatedRecords.reduce((sum, record) => sum + record.amount, 0);
// 如果是今天的数据更新Widget // 如果是今天的数据更新Widget
if (dateToUse === dayjs().format('YYYY-MM-DD')) { if (dateToUse === dayjs().format('YYYY-MM-DD')) {
const totalAmount = waterRecords.reduce((sum, record) => sum + record.amount, 0) + amount;
const quickAddAmount = await getQuickWaterAmount(); const quickAddAmount = await getQuickWaterAmount();
try { try {
@@ -555,13 +594,17 @@ export const useWaterDataByDate = (targetDate?: string) => {
} }
} }
await reportWaterChallengeProgress(totalAmount);
return true; return true;
} catch (error: any) { } catch (error: any) {
console.error('添加喝水记录失败:', error); console.error('添加喝水记录失败:', error);
Toast.error(error?.message || '添加喝水记录失败'); Toast.error(error?.message || '添加喝水记录失败');
return false; return false;
} }
}, [getWaterRecordsByDate, dateToUse, waterRecords, dailyWaterGoal]); },
[dailyWaterGoal, dateToUse, getWaterRecordsByDate, reportWaterChallengeProgress]
);
// 更新喝水记录 // 更新喝水记录
const updateWaterRecord = useCallback(async (id: string, amount?: number, note?: string, recordedAt?: string) => { const updateWaterRecord = useCallback(async (id: string, amount?: number, note?: string, recordedAt?: string) => {

View File

@@ -16,6 +16,17 @@ export type RankingItemDto = {
badge?: string; badge?: string;
}; };
export enum ChallengeType {
WATER = 'water',
EXERCISE = 'exercise',
DIET = 'diet',
MOOD = 'mood',
SLEEP = 'sleep',
WEIGHT = 'weight',
}
export type ChallengeListItemDto = { export type ChallengeListItemDto = {
id: string; id: string;
title: string; title: string;
@@ -34,6 +45,7 @@ export type ChallengeListItemDto = {
startAt?: string; startAt?: string;
endAt?: string; endAt?: string;
minimumCheckInDays: number; // 最小打卡天数 minimumCheckInDays: number; // 最小打卡天数
type: ChallengeType;
}; };
export type ChallengeDetailDto = ChallengeListItemDto & { export type ChallengeDetailDto = ChallengeListItemDto & {
@@ -58,7 +70,7 @@ export async function leaveChallenge(id: string): Promise<boolean> {
return api.post<boolean>(`/challenges/${encodeURIComponent(id)}/leave`); return api.post<boolean>(`/challenges/${encodeURIComponent(id)}/leave`);
} }
export async function reportChallengeProgress(id: string, increment?: number): Promise<ChallengeProgressDto> { export async function reportChallengeProgress(id: string, value?: number): Promise<ChallengeProgressDto> {
const body = increment != null ? { increment } : undefined; const body = value != null ? { value } : undefined;
return api.post<ChallengeProgressDto>(`/challenges/${encodeURIComponent(id)}/progress`, body); return api.post<ChallengeProgressDto>(`/challenges/${encodeURIComponent(id)}/progress`, body);
} }

View File

@@ -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;

View File

@@ -117,11 +117,11 @@ export const leaveChallenge = createAsyncThunk<{ id: string }, string, { rejectV
export const reportChallengeProgress = createAsyncThunk< export const reportChallengeProgress = createAsyncThunk<
{ id: string; progress: ChallengeProgress }, { id: string; progress: ChallengeProgress },
{ id: string; increment?: number }, { id: string; value?: number },
{ rejectValue: string } { rejectValue: string }
>('challenges/reportProgress', async ({ id, increment }, { rejectWithValue }) => { >('challenges/reportProgress', async ({ id, value }, { rejectWithValue }) => {
try { try {
const progress = await reportChallengeProgressApi(id, increment); const progress = await reportChallengeProgressApi(id, value);
return { id, progress }; return { id, progress };
} catch (error) { } catch (error) {
return rejectWithValue(toErrorMessage(error)); return rejectWithValue(toErrorMessage(error));
@@ -311,6 +311,7 @@ export type ChallengeCardViewModel = {
participantsLabel: string; participantsLabel: string;
status: ChallengeStatus; status: ChallengeStatus;
isJoined: boolean; isJoined: boolean;
endAt?: string;
periodLabel?: string; periodLabel?: string;
durationLabel: string; durationLabel: string;
requirementLabel: string; requirementLabel: string;
@@ -330,6 +331,7 @@ export const selectChallengeCards = createSelector([selectChallengeList], (chall
participantsLabel: `${formatNumberWithSeparator(challenge.participantsCount)} 人参与`, participantsLabel: `${formatNumberWithSeparator(challenge.participantsCount)} 人参与`,
status: challenge.status, status: challenge.status,
isJoined: challenge.isJoined, isJoined: challenge.isJoined,
endAt: challenge.endAt,
periodLabel: challenge.periodLabel, periodLabel: challenge.periodLabel,
durationLabel: challenge.durationLabel, durationLabel: challenge.durationLabel,
requirementLabel: challenge.requirementLabel, requirementLabel: challenge.requirementLabel,

View File

@@ -1,5 +1,4 @@
import { configureStore, createListenerMiddleware } from '@reduxjs/toolkit'; import { configureStore, createListenerMiddleware } from '@reduxjs/toolkit';
import challengeReducer from './challengeSlice';
import challengesReducer from './challengesSlice'; import challengesReducer from './challengesSlice';
import checkinReducer, { addExercise, autoSyncCheckin, removeExercise, replaceExercises, setNote, toggleExerciseCompleted } from './checkinSlice'; import checkinReducer, { addExercise, autoSyncCheckin, removeExercise, replaceExercises, setNote, toggleExerciseCompleted } from './checkinSlice';
import circumferenceReducer from './circumferenceSlice'; import circumferenceReducer from './circumferenceSlice';
@@ -48,7 +47,6 @@ syncActions.forEach(action => {
export const store = configureStore({ export const store = configureStore({
reducer: { reducer: {
user: userReducer, user: userReducer,
challenge: challengeReducer,
challenges: challengesReducer, challenges: challengesReducer,
checkin: checkinReducer, checkin: checkinReducer,
circumference: circumferenceReducer, circumference: circumferenceReducer,