From d32a82260455ba7a6a1efdc2add2b55ba5b0df8e Mon Sep 17 00:00:00 2001 From: richarjiang Date: Tue, 30 Sep 2025 10:21:50 +0800 Subject: [PATCH] =?UTF-8?q?feat(challenges):=20=E6=94=AF=E6=8C=81=E5=8D=B3?= =?UTF-8?q?=E5=B0=86=E5=BC=80=E5=A7=8B=E4=B8=8E=E5=B7=B2=E7=BB=93=E6=9D=9F?= =?UTF-8?q?=E6=8C=91=E6=88=98=E7=9A=84=E7=A6=81=E7=94=A8=E6=80=81=E5=8F=8A?= =?UTF-8?q?=E7=9D=A1=E7=9C=A0=E6=8C=91=E6=88=98=E8=87=AA=E5=8A=A8=E8=BF=9B?= =?UTF-8?q?=E5=BA=A6=E4=B8=8A=E6=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/challenges/[id].tsx | 69 ++++++++++++++++++++++++++---- components/statistic/SleepCard.tsx | 50 +++++++++++++++++++++- 2 files changed, 108 insertions(+), 11 deletions(-) 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;