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 { 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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user