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,