Files
digital-pilates/components/FitnessRingsCard.tsx
richarjiang 3e6f55d804 feat(challenges): 排行榜支持单位显示与健身圆环自动上报进度
- ChallengeRankingItem 新增 unit 字段,支持按单位格式化今日进度
- FitnessRingsCard 监听圆环闭合,自动向进行中的运动挑战上报 1 次进度
- 过滤已结束挑战,确保睡眠、喝水、运动进度仅上报进行中活动
- 移除 StressMeter 调试日志与 challengesSlice 多余打印
2025-09-30 14:37:15 +08:00

303 lines
8.9 KiB
TypeScript

import { ROUTES } from '@/constants/Routes';
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;
selectedDate?: Date;
// 动画重置令牌
resetToken?: unknown;
};
/**
* 健身圆环卡片组件,模仿 Apple Watch 的健身圆环
*/
export function FitnessRingsCard({
style,
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(
useCallback(() => {
const loadActivityData = async () => {
if (!selectedDate) return;
// 防止重复请求
if (loadingRef.current) return;
try {
loadingRef.current = true;
setLoading(true);
const data = await fetchActivityRingsForDate(selectedDate);
setActivityData(data);
} catch (error) {
console.error('FitnessRingsCard: 获取健身圆环数据失败:', error);
setActivityData(null);
} finally {
setLoading(false);
loadingRef.current = false;
}
};
loadActivityData();
}, [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;
const exerciseMinutes = activityData?.appleExerciseTime ?? 0;
const exerciseMinutesGoal = activityData?.appleExerciseTimeGoal ?? 30;
const standHours = activityData?.appleStandHours ?? 0;
const standHoursGoal = activityData?.appleStandHoursGoal ?? 12;
// 计算进度百分比
const caloriesProgress = Math.min(1, Math.max(0, activeCalories / activeCaloriesGoal));
const exerciseProgress = Math.min(1, Math.max(0, exerciseMinutes / exerciseMinutesGoal));
const standProgress = Math.min(1, Math.max(0, standHours / standHoursGoal));
const handlePress = () => {
router.push(ROUTES.FITNESS_RINGS_DETAIL);
};
return (
<TouchableOpacity style={[styles.container, style]} onPress={handlePress}>
<View style={styles.contentContainer}>
{/* 左侧圆环 */}
<View style={styles.ringsContainer}>
<View style={styles.ringWrapper}>
{/* 外圈 - 活动卡路里 (红色) */}
<View style={[styles.ringPosition]}>
<CircularRing
size={36}
strokeWidth={2.5}
trackColor="rgba(255, 59, 48, 0.15)"
progressColor="#FF3B30"
progress={caloriesProgress}
showCenterText={false}
resetToken={resetToken}
startAngleDeg={-90}
/>
</View>
{/* 中圈 - 锻炼分钟 (橙色) */}
<View style={[styles.ringPosition]}>
<CircularRing
size={26}
strokeWidth={2}
trackColor="rgba(255, 149, 0, 0.15)"
progressColor="#FF9500"
progress={exerciseProgress}
showCenterText={false}
resetToken={resetToken}
startAngleDeg={-90}
/>
</View>
{/* 内圈 - 站立小时 (蓝色) */}
<View style={[styles.ringPosition]}>
<CircularRing
size={16}
strokeWidth={1.5}
trackColor="rgba(0, 122, 255, 0.15)"
progressColor="#007AFF"
progress={standProgress}
showCenterText={false}
resetToken={resetToken}
startAngleDeg={-90}
/>
</View>
</View>
</View>
{/* 右侧数据显示 */}
<View style={styles.dataContainer}>
<View style={styles.dataRow}>
<Text style={styles.dataText}>
{loading ? (
<Text style={styles.dataValue}>--</Text>
) : (
<>
<Text style={styles.dataValue}>{Math.round(activeCalories)}</Text>
<Text style={styles.dataGoal}>/{activeCaloriesGoal}</Text>
</>
)}
</Text>
<Text style={styles.dataUnit}></Text>
</View>
<View style={styles.dataRow}>
<Text style={styles.dataText}>
{loading ? (
<Text style={styles.dataValue}>--</Text>
) : (
<>
<Text style={styles.dataValue}>{Math.round(exerciseMinutes)}</Text>
<Text style={styles.dataGoal}>/{exerciseMinutesGoal}</Text>
</>
)}
</Text>
<Text style={styles.dataUnit}></Text>
</View>
<View style={styles.dataRow}>
<Text style={styles.dataText}>
{loading ? (
<Text style={styles.dataValue}>--</Text>
) : (
<>
<Text style={styles.dataValue}>{Math.round(standHours)}</Text>
<Text style={styles.dataGoal}>/{standHoursGoal}</Text>
</>
)}
</Text>
<Text style={styles.dataUnit}></Text>
</View>
</View>
</View>
</TouchableOpacity>
);
}
const styles = StyleSheet.create({
container: {
borderRadius: 16,
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.08,
shadowRadius: 8,
elevation: 3,
},
contentContainer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
ringsContainer: {
alignItems: 'center',
justifyContent: 'center',
marginRight: 12,
},
ringWrapper: {
position: 'relative',
width: 36,
height: 36,
alignItems: 'center',
justifyContent: 'center',
},
ringPosition: {
position: 'absolute',
alignItems: 'center',
justifyContent: 'center',
},
dataContainer: {
flex: 1,
gap: 3,
},
dataRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
dataText: {
fontSize: 12,
fontWeight: '700',
flex: 1,
},
dataValue: {
color: '#192126',
},
dataGoal: {
color: '#9AA3AE',
},
dataUnit: {
fontSize: 10,
color: '#9AA3AE',
fontWeight: '500',
minWidth: 25,
textAlign: 'right',
},
});