feat(challenges): 支持即将开始与已结束挑战的禁用态及睡眠挑战自动进度上报

This commit is contained in:
richarjiang
2025-09-30 10:21:50 +08:00
parent 8f847465ef
commit d32a822604
2 changed files with 108 additions and 11 deletions

View File

@@ -44,6 +44,7 @@ import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
const { width } = Dimensions.get('window'); const { width } = Dimensions.get('window');
const HERO_HEIGHT = width * 0.76; const HERO_HEIGHT = width * 0.76;
const CTA_GRADIENT: [string, string] = ['#5E8BFF', '#6B6CFF']; const CTA_GRADIENT: [string, string] = ['#5E8BFF', '#6B6CFF'];
const CTA_DISABLED_GRADIENT: [string, string] = ['#d3d7e8', '#c1c6da'];
const isHttpUrl = (value: string) => /^https?:\/\//i.test(value); const isHttpUrl = (value: string) => /^https?:\/\//i.test(value);
@@ -274,15 +275,61 @@ export default function ChallengeDetailScreen() {
const highlightTitle = challenge.highlightTitle ?? '立即加入挑战'; const highlightTitle = challenge.highlightTitle ?? '立即加入挑战';
const highlightSubtitle = challenge.highlightSubtitle ?? '邀请好友一起坚持,更容易收获成果'; const highlightSubtitle = challenge.highlightSubtitle ?? '邀请好友一起坚持,更容易收获成果';
const joinCtaLabel = joinStatus === 'loading' ? '加入中…' : challenge.ctaLabel ?? '立即加入挑战'; 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 leaveHighlightTitle = '先别急着离开';
const leaveHighlightSubtitle = '再坚持一下,下一个里程碑就要出现了'; const leaveHighlightSubtitle = '再坚持一下,下一个里程碑就要出现了';
const leaveCtaLabel = leaveStatus === 'loading' ? '退出中…' : '退出挑战'; const leaveCtaLabel = leaveStatus === 'loading' ? '退出中…' : '退出挑战';
const floatingHighlightTitle = isJoined ? leaveHighlightTitle : highlightTitle;
const floatingHighlightSubtitle = isJoined ? leaveHighlightSubtitle : highlightSubtitle; let floatingHighlightTitle = highlightTitle;
const floatingCtaLabel = isJoined ? leaveCtaLabel : joinCtaLabel; let floatingHighlightSubtitle = highlightSubtitle;
const floatingOnPress = isJoined ? handleLeaveConfirm : handleJoin; let floatingCtaLabel = joinCtaLabel;
const floatingDisabled = isJoined ? leaveStatus === 'loading' : joinStatus === 'loading'; let floatingOnPress: (() => void) | undefined = handleJoin;
const floatingError = isJoined ? leaveError : joinError; 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 participantsLabel = formatParticipantsLabel(challenge.participantsCount);
const inlineErrorMessage = detailStatus === 'failed' && detailError ? detailError : undefined; const inlineErrorMessage = detailStatus === 'failed' && detailError ? detailError : undefined;
@@ -323,7 +370,6 @@ export default function ChallengeDetailScreen() {
</View> </View>
<View style={styles.headerTextBlock}> <View style={styles.headerTextBlock}>
<Text style={styles.periodLabel}>{challenge.periodLabel ?? dateRangeLabel}</Text>
<Text style={styles.title}>{challenge.title}</Text> <Text style={styles.title}>{challenge.title}</Text>
{challenge.summary ? <Text style={styles.summary}>{challenge.summary}</Text> : null} {challenge.summary ? <Text style={styles.summary}>{challenge.summary}</Text> : null}
{inlineErrorMessage ? ( {inlineErrorMessage ? (
@@ -446,12 +492,14 @@ export default function ChallengeDetailScreen() {
disabled={floatingDisabled} disabled={floatingDisabled}
> >
<LinearGradient <LinearGradient
colors={CTA_GRADIENT} colors={floatingGradientColors}
start={{ x: 0, y: 0 }} start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }} end={{ x: 1, y: 1 }}
style={styles.highlightButtonBackground} style={styles.highlightButtonBackground}
> >
<Text style={styles.highlightButtonLabel}>{floatingCtaLabel}</Text> <Text style={[styles.highlightButtonLabel, isDisabledButtonState && styles.highlightButtonLabelDisabled]}>
{floatingCtaLabel}
</Text>
</LinearGradient> </LinearGradient>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
@@ -767,6 +815,9 @@ const styles = StyleSheet.create({
fontWeight: '700', fontWeight: '700',
color: '#ffffff', color: '#ffffff',
}, },
highlightButtonLabelDisabled: {
color: '#6f7799',
},
circularButton: { circularButton: {
width: 40, width: 40,
height: 40, height: 40,

View File

@@ -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 { fetchCompleteSleepData, formatSleepTime } from '@/utils/sleepHealthKit';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { Image } from 'expo-image'; import { Image } from 'expo-image';
import { router } from 'expo-router'; 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'; import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
@@ -15,8 +19,15 @@ const SleepCard: React.FC<SleepCardProps> = ({
selectedDate, selectedDate,
style, style,
}) => { }) => {
const dispatch = useAppDispatch();
const challenges = useAppSelector(selectChallengeList);
const [sleepDuration, setSleepDuration] = useState<number | null>(null); const [sleepDuration, setSleepDuration] = useState<number | null>(null);
const [loading, setLoading] = useState(false); 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(() => { useEffect(() => {
@@ -38,6 +49,41 @@ const SleepCard: React.FC<SleepCardProps> = ({
loadSleepData(); loadSleepData();
}, [selectedDate]); }, [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 = ( const CardContent = (
<View style={[styles.container, style]}> <View style={[styles.container, style]}>
<View style={styles.cardHeaderRow}> <View style={styles.cardHeaderRow}>
@@ -88,4 +134,4 @@ const styles = StyleSheet.create({
}, },
}); });
export default SleepCard; export default SleepCard;