diff --git a/app/(tabs)/challenges.tsx b/app/(tabs)/challenges.tsx index aad1ec5..5fe88a4 100644 --- a/app/(tabs)/challenges.tsx +++ b/app/(tabs)/challenges.tsx @@ -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() { + {ongoingChallenge ? ( + + router.push({ pathname: '/challenges/[id]', params: { id: ongoingChallenge.id } }) + } + > + + + ) : null} + {renderChallenges()} @@ -179,11 +210,6 @@ function ChallengeCard({ challenge, surfaceColor, textColor, mutedColor, onPress {statusLabel} {challenge.isJoined ? ' · 已加入' : ''} - {challenge.progress?.badge ? ( - - {challenge.progress.badge} - - ) : null} {challenge.avatars.length ? ( ) : null} @@ -264,6 +290,9 @@ const styles = StyleSheet.create({ cardsContainer: { gap: 18, }, + progressCardWrapper: { + marginBottom: 24, + }, stateContainer: { alignItems: 'center', justifyContent: 'center', diff --git a/app/_layout.tsx b/app/_layout.tsx index b463549..73cd97c 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -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(); diff --git a/app/challenges/[id].tsx b/app/challenges/[id].tsx index 185087a..4f83cbf 100644 --- a/app/challenges/[id].tsx +++ b/app/challenges/[id].tsx @@ -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 ( @@ -342,86 +326,13 @@ export default function ChallengeDetailScreen() { ) : null} - {progress && progressSegments ? ( - - - - - - {challenge.title} - - 挑战剩余 {dayjs(challenge.endAt).diff(dayjs(), 'd') || 0} 天 - - - - - {progress.completed} / {progress.target} - - - - - - {Array.from({ length: progressSegments.segmentsCount }).map((_, index) => { - const isComplete = index < progressSegments.completedSegments; - const isFirst = index === 0; - const isLast = index === progressSegments.segmentsCount - 1; - return ( - - ); - })} - - - {/* {isJoined ? ( - <> - - - - {progressStatus === 'loading' ? '打卡中…' : '打卡 +1'} - - - - - {leaveStatus === 'loading' ? '处理中…' : '退出挑战'} - - - - {progressActionError ? ( - {progressActionError} - ) : null} - - ) : null} */} - - - + {progress ? ( + ) : null} @@ -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', diff --git a/components/challenges/ChallengeProgressCard.tsx b/components/challenges/ChallengeProgressCard.tsx new file mode 100644 index 0000000..5ed2a58 --- /dev/null +++ b/components/challenges/ChallengeProgressCard.tsx @@ -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; + 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 = ({ + 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 ( + + + + + + {title} + + + 挑战剩余 {remainingDays} 天 + + + + + {progress.completed} / {progress.target} + + + + + + {Array.from({ length: segments.segmentsCount }).map((_, index) => { + const isComplete = index < segments.completedSegments; + const isFirst = index === 0; + const isLast = index === segments.segmentsCount - 1; + return ( + + ); + })} + + + + ); +}; + +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; diff --git a/hooks/useWaterData.ts b/hooks/useWaterData.ts index db753b1..e2c0ea6 100644 --- a/hooks/useWaterData.ts +++ b/hooks/useWaterData.ts @@ -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, }; -}; \ No newline at end of file +}; diff --git a/services/challengesApi.ts b/services/challengesApi.ts index caf8261..f6ec29d 100644 --- a/services/challengesApi.ts +++ b/services/challengesApi.ts @@ -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 { return api.post(`/challenges/${encodeURIComponent(id)}/leave`); } -export async function reportChallengeProgress(id: string, increment?: number): Promise { - const body = increment != null ? { increment } : undefined; +export async function reportChallengeProgress(id: string, value?: number): Promise { + const body = value != null ? { value } : undefined; return api.post(`/challenges/${encodeURIComponent(id)}/progress`, body); } diff --git a/store/challengeSlice.ts b/store/challengeSlice.ts deleted file mode 100644 index 8a8325d..0000000 --- a/store/challengeSlice.ts +++ /dev/null @@ -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) { - 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; - - diff --git a/store/challengesSlice.ts b/store/challengesSlice.ts index aa43e34..cc1be6f 100644 --- a/store/challengesSlice.ts +++ b/store/challengesSlice.ts @@ -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, diff --git a/store/index.ts b/store/index.ts index 1178b5e..b98a3c4 100644 --- a/store/index.ts +++ b/store/index.ts @@ -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,