feat(challenges): 支持即将开始与已结束挑战的禁用态及睡眠挑战自动进度上报
This commit is contained in:
@@ -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() {
|
||||
</View>
|
||||
|
||||
<View style={styles.headerTextBlock}>
|
||||
<Text style={styles.periodLabel}>{challenge.periodLabel ?? dateRangeLabel}</Text>
|
||||
<Text style={styles.title}>{challenge.title}</Text>
|
||||
{challenge.summary ? <Text style={styles.summary}>{challenge.summary}</Text> : null}
|
||||
{inlineErrorMessage ? (
|
||||
@@ -446,12 +492,14 @@ export default function ChallengeDetailScreen() {
|
||||
disabled={floatingDisabled}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={CTA_GRADIENT}
|
||||
colors={floatingGradientColors}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.highlightButtonBackground}
|
||||
>
|
||||
<Text style={styles.highlightButtonLabel}>{floatingCtaLabel}</Text>
|
||||
<Text style={[styles.highlightButtonLabel, isDisabledButtonState && styles.highlightButtonLabelDisabled]}>
|
||||
{floatingCtaLabel}
|
||||
</Text>
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
@@ -767,6 +815,9 @@ const styles = StyleSheet.create({
|
||||
fontWeight: '700',
|
||||
color: '#ffffff',
|
||||
},
|
||||
highlightButtonLabelDisabled: {
|
||||
color: '#6f7799',
|
||||
},
|
||||
circularButton: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
|
||||
@@ -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<SleepCardProps> = ({
|
||||
selectedDate,
|
||||
style,
|
||||
}) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const challenges = useAppSelector(selectChallengeList);
|
||||
const [sleepDuration, setSleepDuration] = useState<number | null>(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<SleepCardProps> = ({
|
||||
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 = (
|
||||
<View style={[styles.container, style]}>
|
||||
<View style={styles.cardHeaderRow}>
|
||||
|
||||
Reference in New Issue
Block a user