feat(challenges): 排行榜支持单位显示与健身圆环自动上报进度
- ChallengeRankingItem 新增 unit 字段,支持按单位格式化今日进度 - FitnessRingsCard 监听圆环闭合,自动向进行中的运动挑战上报 1 次进度 - 过滤已结束挑战,确保睡眠、喝水、运动进度仅上报进行中活动 - 移除 StressMeter 调试日志与 challengesSlice 多余打印
This commit is contained in:
@@ -473,7 +473,13 @@ export default function ChallengeDetailScreen() {
|
|||||||
<View style={styles.rankingCard}>
|
<View style={styles.rankingCard}>
|
||||||
{rankingData.length ? (
|
{rankingData.length ? (
|
||||||
rankingData.map((item, index) => (
|
rankingData.map((item, index) => (
|
||||||
<ChallengeRankingItem key={item.id ?? index} item={item} index={index} showDivider={index > 0} />
|
<ChallengeRankingItem
|
||||||
|
key={item.id ?? index}
|
||||||
|
item={item}
|
||||||
|
index={index}
|
||||||
|
showDivider={index > 0}
|
||||||
|
unit={challenge?.unit}
|
||||||
|
/>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<View style={styles.emptyRanking}>
|
<View style={styles.emptyRanking}>
|
||||||
|
|||||||
@@ -177,7 +177,13 @@ export default function ChallengeLeaderboardScreen() {
|
|||||||
</View>
|
</View>
|
||||||
) : rankingData.length ? (
|
) : rankingData.length ? (
|
||||||
rankingData.map((item, index) => (
|
rankingData.map((item, index) => (
|
||||||
<ChallengeRankingItem key={item.id ?? index} item={item} index={index} showDivider={index > 0} />
|
<ChallengeRankingItem
|
||||||
|
key={item.id ?? index}
|
||||||
|
item={item}
|
||||||
|
index={index}
|
||||||
|
showDivider={index > 0}
|
||||||
|
unit={challenge?.unit}
|
||||||
|
/>
|
||||||
))
|
))
|
||||||
) : rankingError ? (
|
) : rankingError ? (
|
||||||
<View style={styles.emptyRanking}>
|
<View style={styles.emptyRanking}>
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
import React, { useState, useCallback, useRef } from 'react';
|
|
||||||
import { StyleSheet, Text, View, TouchableOpacity } from 'react-native';
|
|
||||||
import { router } from 'expo-router';
|
|
||||||
import { useFocusEffect } from '@react-navigation/native';
|
|
||||||
import { CircularRing } from './CircularRing';
|
|
||||||
import { ROUTES } from '@/constants/Routes';
|
import { ROUTES } from '@/constants/Routes';
|
||||||
import { fetchActivityRingsForDate, ActivityRingsData } from '@/utils/health';
|
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||||
|
import { ChallengeType } from '@/services/challengesApi';
|
||||||
|
import { reportChallengeProgress, selectChallengeList } from '@/store/challengesSlice';
|
||||||
|
import { ActivityRingsData, fetchActivityRingsForDate } from '@/utils/health';
|
||||||
|
import { logger } from '@/utils/logger';
|
||||||
|
import { useFocusEffect } from '@react-navigation/native';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { router } from 'expo-router';
|
||||||
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||||
|
import { CircularRing } from './CircularRing';
|
||||||
|
|
||||||
type FitnessRingsCardProps = {
|
type FitnessRingsCardProps = {
|
||||||
style?: any;
|
style?: any;
|
||||||
@@ -21,9 +26,17 @@ export function FitnessRingsCard({
|
|||||||
selectedDate,
|
selectedDate,
|
||||||
resetToken,
|
resetToken,
|
||||||
}: FitnessRingsCardProps) {
|
}: FitnessRingsCardProps) {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const challenges = useAppSelector(selectChallengeList);
|
||||||
const [activityData, setActivityData] = useState<ActivityRingsData | null>(null);
|
const [activityData, setActivityData] = useState<ActivityRingsData | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const loadingRef = useRef(false);
|
const loadingRef = useRef(false);
|
||||||
|
const lastReportedRef = useRef<{ date: string } | null>(null);
|
||||||
|
|
||||||
|
const joinedExerciseChallenges = useMemo(
|
||||||
|
() => challenges.filter((challenge) => challenge.type === ChallengeType.EXERCISE && challenge.isJoined && challenge.status === 'ongoing'),
|
||||||
|
[challenges]
|
||||||
|
);
|
||||||
|
|
||||||
// 获取健身圆环数据 - 在页面聚焦、日期变化、从后台切换到前台时触发
|
// 获取健身圆环数据 - 在页面聚焦、日期变化、从后台切换到前台时触发
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
@@ -52,6 +65,63 @@ export function FitnessRingsCard({
|
|||||||
}, [selectedDate])
|
}, [selectedDate])
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedDate || !activityData || !joinedExerciseChallenges.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!dayjs(selectedDate).isSame(dayjs(), 'day')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
activeEnergyBurned,
|
||||||
|
activeEnergyBurnedGoal,
|
||||||
|
appleExerciseTime,
|
||||||
|
appleExerciseTimeGoal,
|
||||||
|
appleStandHours,
|
||||||
|
appleStandHoursGoal,
|
||||||
|
} = activityData;
|
||||||
|
|
||||||
|
if (
|
||||||
|
activeEnergyBurnedGoal <= 0 ||
|
||||||
|
appleExerciseTimeGoal <= 0 ||
|
||||||
|
appleStandHoursGoal <= 0
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allRingsClosed =
|
||||||
|
activeEnergyBurned >= activeEnergyBurnedGoal &&
|
||||||
|
appleExerciseTime >= appleExerciseTimeGoal &&
|
||||||
|
appleStandHours >= appleStandHoursGoal;
|
||||||
|
|
||||||
|
if (!allRingsClosed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateKey = dayjs(selectedDate).format('YYYY-MM-DD');
|
||||||
|
if (lastReportedRef.current?.date === dateKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const exerciseChallenge = joinedExerciseChallenges[0];
|
||||||
|
if (!exerciseChallenge) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reportProgressAsync = async () => {
|
||||||
|
try {
|
||||||
|
await dispatch(reportChallengeProgress({ id: exerciseChallenge.id, value: 1 })).unwrap();
|
||||||
|
lastReportedRef.current = { date: dateKey };
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('FitnessRingsCard: 挑战进度上报失败', { error, challengeId: exerciseChallenge.id });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
reportProgressAsync();
|
||||||
|
}, [activityData, dispatch, joinedExerciseChallenges, selectedDate]);
|
||||||
|
|
||||||
// 使用获取到的数据或默认值
|
// 使用获取到的数据或默认值
|
||||||
const activeCalories = activityData?.activeEnergyBurned ?? 0;
|
const activeCalories = activityData?.activeEnergyBurned ?? 0;
|
||||||
const activeCaloriesGoal = activityData?.activeEnergyBurnedGoal ?? 350;
|
const activeCaloriesGoal = activityData?.activeEnergyBurnedGoal ?? 350;
|
||||||
|
|||||||
@@ -58,13 +58,6 @@ export function StressMeter({ curDate }: StressMeterProps) {
|
|||||||
// 使用传入的 hrvValue 进行转换
|
// 使用传入的 hrvValue 进行转换
|
||||||
const stressIndex = convertHrvToStressIndex(hrvValue);
|
const stressIndex = convertHrvToStressIndex(hrvValue);
|
||||||
|
|
||||||
// 调试信息
|
|
||||||
console.log('StressMeter 调试:', {
|
|
||||||
hrvValue,
|
|
||||||
stressIndex,
|
|
||||||
progressPercentage: stressIndex !== null ? Math.max(0, Math.min(100, stressIndex)) : 0
|
|
||||||
});
|
|
||||||
|
|
||||||
// 计算进度条位置(0-100%)
|
// 计算进度条位置(0-100%)
|
||||||
// 压力指数越高,进度条越满(红色区域越多)
|
// 压力指数越高,进度条越满(红色区域越多)
|
||||||
const progressPercentage = stressIndex !== null ? Math.max(0, Math.min(100, stressIndex)) : 0;
|
const progressPercentage = stressIndex !== null ? Math.max(0, Math.min(100, stressIndex)) : 0;
|
||||||
|
|||||||
@@ -8,9 +8,46 @@ type ChallengeRankingItemProps = {
|
|||||||
item: RankingItem;
|
item: RankingItem;
|
||||||
index: number;
|
index: number;
|
||||||
showDivider?: boolean;
|
showDivider?: boolean;
|
||||||
|
unit?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ChallengeRankingItem({ item, index, showDivider = false }: ChallengeRankingItemProps) {
|
const formatNumber = (value: number): string => {
|
||||||
|
if (Number.isInteger(value)) {
|
||||||
|
return value.toString();
|
||||||
|
}
|
||||||
|
return value.toFixed(2).replace(/0+$/, '').replace(/\.$/, '');
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatMinutes = (value: number): string => {
|
||||||
|
const safeValue = Math.max(0, Math.round(value));
|
||||||
|
const hours = safeValue / 60;
|
||||||
|
return `${hours.toFixed(1)} 小时`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatValueWithUnit = (value: number | undefined, unit?: string): string | undefined => {
|
||||||
|
if (typeof value !== 'number' || Number.isNaN(value)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (unit === 'min') {
|
||||||
|
return formatMinutes(value);
|
||||||
|
}
|
||||||
|
const formatted = formatNumber(value);
|
||||||
|
return unit ? `${formatted} ${unit}` : formatted;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ChallengeRankingItem({ item, index, showDivider = false, unit }: ChallengeRankingItemProps) {
|
||||||
|
console.log('unit', unit);
|
||||||
|
|
||||||
|
const reportedLabel = formatValueWithUnit(item.todayReportedValue, unit);
|
||||||
|
const targetLabel = formatValueWithUnit(item.todayTargetValue, unit);
|
||||||
|
const progressLabel = reportedLabel && targetLabel
|
||||||
|
? `今日 ${reportedLabel} / ${targetLabel}`
|
||||||
|
: reportedLabel
|
||||||
|
? `今日 ${reportedLabel}`
|
||||||
|
: targetLabel
|
||||||
|
? `今日目标 ${targetLabel}`
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.rankingRow, showDivider && styles.rankingRowDivider]}>
|
<View style={[styles.rankingRow, showDivider && styles.rankingRowDivider]}>
|
||||||
<View style={styles.rankingOrderCircle}>
|
<View style={styles.rankingOrderCircle}>
|
||||||
@@ -28,6 +65,11 @@ export function ChallengeRankingItem({ item, index, showDivider = false }: Chall
|
|||||||
{item.name}
|
{item.name}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={styles.rankingMetric}>{item.metric}</Text>
|
<Text style={styles.rankingMetric}>{item.metric}</Text>
|
||||||
|
{progressLabel ? (
|
||||||
|
<Text style={styles.rankingProgress} numberOfLines={1}>
|
||||||
|
{progressLabel}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
{item.badge ? <Text style={styles.rankingBadge}>{item.badge}</Text> : null}
|
{item.badge ? <Text style={styles.rankingBadge}>{item.badge}</Text> : null}
|
||||||
</View>
|
</View>
|
||||||
@@ -85,9 +127,14 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
rankingMetric: {
|
rankingMetric: {
|
||||||
marginTop: 4,
|
marginTop: 4,
|
||||||
fontSize: 13,
|
fontSize: 12,
|
||||||
color: '#6f7ba7',
|
color: '#6f7ba7',
|
||||||
},
|
},
|
||||||
|
rankingProgress: {
|
||||||
|
marginTop: 2,
|
||||||
|
fontSize: 10,
|
||||||
|
color: '#8a94c1',
|
||||||
|
},
|
||||||
rankingBadge: {
|
rankingBadge: {
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: '#A67CFF',
|
color: '#A67CFF',
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ const SleepCard: React.FC<SleepCardProps> = ({
|
|||||||
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(
|
const joinedSleepChallenges = useMemo(
|
||||||
() => challenges.filter((challenge) => challenge.type === ChallengeType.SLEEP && challenge.isJoined),
|
() => challenges.filter((challenge) => challenge.type === ChallengeType.SLEEP && challenge.isJoined && challenge.status === 'ongoing'),
|
||||||
[challenges]
|
[challenges]
|
||||||
);
|
);
|
||||||
const lastReportedRef = useRef<{ date: string; value: number | null } | null>(null);
|
const lastReportedRef = useRef<{ date: string; value: number | null } | null>(null);
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ const useWaterChallengeProgressReporter = () => {
|
|||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const allChallenges = useAppSelector(selectChallengeList);
|
const allChallenges = useAppSelector(selectChallengeList);
|
||||||
const joinedWaterChallenges = useMemo(
|
const joinedWaterChallenges = useMemo(
|
||||||
() => allChallenges.filter((challenge) => challenge.type === ChallengeType.WATER && challenge.isJoined),
|
() => allChallenges.filter((challenge) => challenge.type === ChallengeType.WATER && challenge.isJoined && challenge.status === 'ongoing'),
|
||||||
[allChallenges]
|
[allChallenges]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export type ChallengeListItemDto = {
|
|||||||
periodLabel?: string;
|
periodLabel?: string;
|
||||||
durationLabel: string;
|
durationLabel: string;
|
||||||
requirementLabel: string;
|
requirementLabel: string;
|
||||||
|
unit?: string;
|
||||||
status: ChallengeStatus;
|
status: ChallengeStatus;
|
||||||
participantsCount: number;
|
participantsCount: number;
|
||||||
rankingDescription?: string;
|
rankingDescription?: string;
|
||||||
|
|||||||
@@ -366,8 +366,6 @@ const formatMonthDay = (input: string | undefined): string | undefined => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const buildDateRangeLabel = (challenge: ChallengeEntity): string => {
|
const buildDateRangeLabel = (challenge: ChallengeEntity): string => {
|
||||||
console.log('!!!!!!', challenge);
|
|
||||||
|
|
||||||
const startLabel = formatMonthDay(challenge.startAt);
|
const startLabel = formatMonthDay(challenge.startAt);
|
||||||
const endLabel = formatMonthDay(challenge.endAt);
|
const endLabel = formatMonthDay(challenge.endAt);
|
||||||
if (startLabel && endLabel) {
|
if (startLabel && endLabel) {
|
||||||
|
|||||||
Reference in New Issue
Block a user