feat(challenges): 排行榜支持单位显示与健身圆环自动上报进度

- ChallengeRankingItem 新增 unit 字段,支持按单位格式化今日进度
- FitnessRingsCard 监听圆环闭合,自动向进行中的运动挑战上报 1 次进度
- 过滤已结束挑战,确保睡眠、喝水、运动进度仅上报进行中活动
- 移除 StressMeter 调试日志与 challengesSlice 多余打印
This commit is contained in:
richarjiang
2025-09-30 14:37:15 +08:00
parent b0602b0a99
commit 3e6f55d804
9 changed files with 143 additions and 22 deletions

View File

@@ -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 { 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 = {
style?: any;
@@ -21,9 +26,17 @@ export function FitnessRingsCard({
selectedDate,
resetToken,
}: FitnessRingsCardProps) {
const dispatch = useAppDispatch();
const challenges = useAppSelector(selectChallengeList);
const [activityData, setActivityData] = useState<ActivityRingsData | null>(null);
const [loading, setLoading] = useState(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(
@@ -52,6 +65,63 @@ export function FitnessRingsCard({
}, [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 activeCaloriesGoal = activityData?.activeEnergyBurnedGoal ?? 350;
@@ -229,4 +299,4 @@ const styles = StyleSheet.create({
minWidth: 25,
textAlign: 'right',
},
});
});

View File

@@ -58,13 +58,6 @@ export function StressMeter({ curDate }: StressMeterProps) {
// 使用传入的 hrvValue 进行转换
const stressIndex = convertHrvToStressIndex(hrvValue);
// 调试信息
console.log('StressMeter 调试:', {
hrvValue,
stressIndex,
progressPercentage: stressIndex !== null ? Math.max(0, Math.min(100, stressIndex)) : 0
});
// 计算进度条位置0-100%
// 压力指数越高,进度条越满(红色区域越多)
const progressPercentage = stressIndex !== null ? Math.max(0, Math.min(100, stressIndex)) : 0;

View File

@@ -8,9 +8,46 @@ type ChallengeRankingItemProps = {
item: RankingItem;
index: number;
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 (
<View style={[styles.rankingRow, showDivider && styles.rankingRowDivider]}>
<View style={styles.rankingOrderCircle}>
@@ -28,6 +65,11 @@ export function ChallengeRankingItem({ item, index, showDivider = false }: Chall
{item.name}
</Text>
<Text style={styles.rankingMetric}>{item.metric}</Text>
{progressLabel ? (
<Text style={styles.rankingProgress} numberOfLines={1}>
{progressLabel}
</Text>
) : null}
</View>
{item.badge ? <Text style={styles.rankingBadge}>{item.badge}</Text> : null}
</View>
@@ -85,9 +127,14 @@ const styles = StyleSheet.create({
},
rankingMetric: {
marginTop: 4,
fontSize: 13,
fontSize: 12,
color: '#6f7ba7',
},
rankingProgress: {
marginTop: 2,
fontSize: 10,
color: '#8a94c1',
},
rankingBadge: {
fontSize: 12,
color: '#A67CFF',

View File

@@ -24,7 +24,7 @@ const SleepCard: React.FC<SleepCardProps> = ({
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.filter((challenge) => challenge.type === ChallengeType.SLEEP && challenge.isJoined && challenge.status === 'ongoing'),
[challenges]
);
const lastReportedRef = useRef<{ date: string; value: number | null } | null>(null);