diff --git a/app/challenges/[id].tsx b/app/challenges/[id].tsx
index ebbf660..bbccb07 100644
--- a/app/challenges/[id].tsx
+++ b/app/challenges/[id].tsx
@@ -44,6 +44,7 @@ import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
const { width } = Dimensions.get('window');
const HERO_HEIGHT = width * 0.76;
const CTA_GRADIENT: [string, string] = ['#5E8BFF', '#6B6CFF'];
+const CTA_DISABLED_GRADIENT: [string, string] = ['#d3d7e8', '#c1c6da'];
const isHttpUrl = (value: string) => /^https?:\/\//i.test(value);
@@ -274,15 +275,61 @@ export default function ChallengeDetailScreen() {
const highlightTitle = challenge.highlightTitle ?? '立即加入挑战';
const highlightSubtitle = challenge.highlightSubtitle ?? '邀请好友一起坚持,更容易收获成果';
const joinCtaLabel = joinStatus === 'loading' ? '加入中…' : challenge.ctaLabel ?? '立即加入挑战';
+ const isUpcoming = challenge.status === 'upcoming';
+ const isExpired = challenge.status === 'expired';
+ const upcomingStartLabel = formatMonthDay(challenge.startAt);
+ const upcomingHighlightTitle = '挑战即将开始';
+ const upcomingHighlightSubtitle = upcomingStartLabel
+ ? `${upcomingStartLabel} 开始,敬请期待`
+ : '挑战即将开启,敬请期待';
+ const upcomingCtaLabel = '挑战即将开始';
+ const expiredEndLabel = formatMonthDay(challenge.endAt);
+ const expiredHighlightTitle = '挑战已结束';
+ const expiredHighlightSubtitle = expiredEndLabel
+ ? `${expiredEndLabel} 已截止,期待下一次挑战`
+ : '本轮挑战已结束,期待下一次挑战';
+ const expiredCtaLabel = '挑战已结束';
const leaveHighlightTitle = '先别急着离开';
const leaveHighlightSubtitle = '再坚持一下,下一个里程碑就要出现了';
const leaveCtaLabel = leaveStatus === 'loading' ? '退出中…' : '退出挑战';
- const floatingHighlightTitle = isJoined ? leaveHighlightTitle : highlightTitle;
- const floatingHighlightSubtitle = isJoined ? leaveHighlightSubtitle : highlightSubtitle;
- const floatingCtaLabel = isJoined ? leaveCtaLabel : joinCtaLabel;
- const floatingOnPress = isJoined ? handleLeaveConfirm : handleJoin;
- const floatingDisabled = isJoined ? leaveStatus === 'loading' : joinStatus === 'loading';
- const floatingError = isJoined ? leaveError : joinError;
+
+ let floatingHighlightTitle = highlightTitle;
+ let floatingHighlightSubtitle = highlightSubtitle;
+ let floatingCtaLabel = joinCtaLabel;
+ let floatingOnPress: (() => void) | undefined = handleJoin;
+ let floatingDisabled = joinStatus === 'loading';
+ let floatingError = joinError;
+ let isDisabledButtonState = false;
+
+ if (isJoined) {
+ floatingHighlightTitle = leaveHighlightTitle;
+ floatingHighlightSubtitle = leaveHighlightSubtitle;
+ floatingCtaLabel = leaveCtaLabel;
+ floatingOnPress = handleLeaveConfirm;
+ floatingDisabled = leaveStatus === 'loading';
+ floatingError = leaveError;
+ }
+
+ if (isUpcoming) {
+ floatingHighlightTitle = upcomingHighlightTitle;
+ floatingHighlightSubtitle = upcomingHighlightSubtitle;
+ floatingCtaLabel = upcomingCtaLabel;
+ floatingOnPress = undefined;
+ floatingDisabled = true;
+ floatingError = undefined;
+ isDisabledButtonState = true;
+ }
+
+ if (isExpired) {
+ floatingHighlightTitle = expiredHighlightTitle;
+ floatingHighlightSubtitle = expiredHighlightSubtitle;
+ floatingCtaLabel = expiredCtaLabel;
+ floatingOnPress = undefined;
+ floatingDisabled = true;
+ floatingError = undefined;
+ isDisabledButtonState = true;
+ }
+ const floatingGradientColors = isDisabledButtonState ? CTA_DISABLED_GRADIENT : CTA_GRADIENT;
const participantsLabel = formatParticipantsLabel(challenge.participantsCount);
const inlineErrorMessage = detailStatus === 'failed' && detailError ? detailError : undefined;
@@ -323,7 +370,6 @@ export default function ChallengeDetailScreen() {
- {challenge.periodLabel ?? dateRangeLabel}
{challenge.title}
{challenge.summary ? {challenge.summary} : null}
{inlineErrorMessage ? (
@@ -446,12 +492,14 @@ export default function ChallengeDetailScreen() {
disabled={floatingDisabled}
>
- {floatingCtaLabel}
+
+ {floatingCtaLabel}
+
@@ -767,6 +815,9 @@ const styles = StyleSheet.create({
fontWeight: '700',
color: '#ffffff',
},
+ highlightButtonLabelDisabled: {
+ color: '#6f7799',
+ },
circularButton: {
width: 40,
height: 40,
diff --git a/components/statistic/SleepCard.tsx b/components/statistic/SleepCard.tsx
index a10f584..9fb484c 100644
--- a/components/statistic/SleepCard.tsx
+++ b/components/statistic/SleepCard.tsx
@@ -1,8 +1,12 @@
+import { useAppDispatch, useAppSelector } from '@/hooks/redux';
+import { ChallengeType } from '@/services/challengesApi';
+import { reportChallengeProgress, selectChallengeList } from '@/store/challengesSlice';
+import { logger } from '@/utils/logger';
import { fetchCompleteSleepData, formatSleepTime } from '@/utils/sleepHealthKit';
import dayjs from 'dayjs';
import { Image } from 'expo-image';
import { router } from 'expo-router';
-import React, { useEffect, useState } from 'react';
+import React, { useEffect, useMemo, useRef, useState } from 'react';
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
@@ -15,8 +19,15 @@ const SleepCard: React.FC = ({
selectedDate,
style,
}) => {
+ const dispatch = useAppDispatch();
+ const challenges = useAppSelector(selectChallengeList);
const [sleepDuration, setSleepDuration] = useState(null);
const [loading, setLoading] = useState(false);
+ const joinedSleepChallenges = useMemo(
+ () => challenges.filter((challenge) => challenge.type === ChallengeType.SLEEP && challenge.isJoined),
+ [challenges]
+ );
+ const lastReportedRef = useRef<{ date: string; value: number | null } | null>(null);
// 获取睡眠数据
useEffect(() => {
@@ -38,6 +49,41 @@ const SleepCard: React.FC = ({
loadSleepData();
}, [selectedDate]);
+ useEffect(() => {
+ if (!selectedDate || !sleepDuration || !joinedSleepChallenges.length) {
+ return;
+ }
+
+ // 如果当前日期不是今天,不上报
+ if (!dayjs(selectedDate).isSame(dayjs(), 'day')) {
+ return;
+ }
+
+ const dateKey = dayjs(selectedDate).format('YYYY-MM-DD');
+ const lastReport = lastReportedRef.current;
+
+ if (lastReport && lastReport.date === dateKey && lastReport.value === sleepDuration) {
+ return;
+ }
+
+ const reportProgress = async () => {
+ const sleepChallenge = joinedSleepChallenges.find((c) => c.type === ChallengeType.SLEEP);
+ if (!sleepChallenge) {
+ return;
+ }
+
+ try {
+ await dispatch(reportChallengeProgress({ id: sleepChallenge.id, value: sleepDuration })).unwrap();
+ } catch (error) {
+ logger.warn('SleepCard: 挑战进度上报失败', { error, challengeId: sleepChallenge.id });
+ }
+
+ lastReportedRef.current = { date: dateKey, value: sleepDuration };
+ };
+
+ reportProgress();
+ }, [dispatch, joinedSleepChallenges, selectedDate, sleepDuration]);
+
const CardContent = (
@@ -88,4 +134,4 @@ const styles = StyleSheet.create({
},
});
-export default SleepCard;
\ No newline at end of file
+export default SleepCard;