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 { 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',
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
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 { 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,46 +181,53 @@ export const useWaterData = () => {
|
|||||||
}, [getWaterRecordsByDate]);
|
}, [getWaterRecordsByDate]);
|
||||||
|
|
||||||
// 创建喝水记录
|
// 创建喝水记录
|
||||||
const addWaterRecord = useCallback(async (amount: number, recordedAt?: string) => {
|
const reportWaterChallengeProgress = useWaterChallengeProgressReporter();
|
||||||
try {
|
|
||||||
const recordTime = recordedAt || dayjs().toISOString();
|
|
||||||
|
|
||||||
// 保存到 HealthKit
|
const addWaterRecord = useCallback(
|
||||||
const healthKitSuccess = await saveWaterIntakeToHealthKit(amount, recordTime);
|
async (amount: number, recordedAt?: string) => {
|
||||||
if (!healthKitSuccess) {
|
try {
|
||||||
Toast.error('保存到 HealthKit 失败');
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
},
|
||||||
// 重新获取当前日期的数据以刷新界面
|
[dailyWaterGoal, getWaterRecordsByDate, reportWaterChallengeProgress]
|
||||||
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]);
|
|
||||||
|
|
||||||
// 更新喝水记录(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,44 +560,51 @@ export const useWaterDataByDate = (targetDate?: string) => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 创建喝水记录
|
// 创建喝水记录
|
||||||
const addWaterRecord = useCallback(async (amount: number, recordedAt?: string) => {
|
const reportWaterChallengeProgress = useWaterChallengeProgressReporter();
|
||||||
try {
|
|
||||||
const recordTime = recordedAt || dayjs().toISOString();
|
|
||||||
|
|
||||||
// 保存到 HealthKit
|
const addWaterRecord = useCallback(
|
||||||
const healthKitSuccess = await saveWaterIntakeToHealthKit(amount, recordTime);
|
async (amount: number, recordedAt?: string) => {
|
||||||
if (!healthKitSuccess) {
|
try {
|
||||||
Toast.error('保存到 HealthKit 失败');
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
},
|
||||||
// 重新获取当前日期的数据以刷新界面
|
[dailyWaterGoal, dateToUse, getWaterRecordsByDate, reportWaterChallengeProgress]
|
||||||
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]);
|
|
||||||
|
|
||||||
// 更新喝水记录
|
// 更新喝水记录
|
||||||
const updateWaterRecord = useCallback(async (id: string, amount?: number, note?: string, recordedAt?: string) => {
|
const updateWaterRecord = useCallback(async (id: string, amount?: number, note?: string, recordedAt?: string) => {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<
|
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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user