feat(i18n): Add common translations and mood-related strings in English and Chinese fix(i18n): Update metabolism titles for consistency in health translations chore: Update Podfile.lock to include SDWebImage 5.21.4 and other dependency versions refactor(moodCheckins): Improve mood configuration retrieval with optional translation support refactor(sleepHealthKit): Replace useI18n with direct i18n import for sleep quality descriptions
301 lines
8.9 KiB
TypeScript
301 lines
8.9 KiB
TypeScript
import { ROUTES } from '@/constants/Routes';
|
|
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
|
import { useI18n } from '@/hooks/useI18n';
|
|
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 { t } = useI18n();
|
|
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 units = useMemo(
|
|
() => ({
|
|
kcal: t('statistics.components.fitness.kcal'),
|
|
minutes: t('statistics.components.fitness.minutes'),
|
|
hours: t('statistics.components.fitness.hours'),
|
|
}),
|
|
[t]
|
|
);
|
|
|
|
const fitnessRows = useMemo(
|
|
() => [
|
|
{ key: 'active', value: Math.round(activeCalories), goal: activeCaloriesGoal, unit: units.kcal },
|
|
{ key: 'exercise', value: Math.round(exerciseMinutes), goal: exerciseMinutesGoal, unit: units.minutes },
|
|
{ key: 'stand', value: Math.round(standHours), goal: standHoursGoal, unit: units.hours },
|
|
],
|
|
[activeCalories, activeCaloriesGoal, exerciseMinutes, exerciseMinutesGoal, standHours, standHoursGoal, units]
|
|
);
|
|
|
|
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}>
|
|
{fitnessRows.map((row) => (
|
|
<View key={row.key} style={styles.dataRow}>
|
|
<Text style={styles.dataText}>
|
|
{loading ? (
|
|
<Text style={styles.dataValue}>--</Text>
|
|
) : (
|
|
<>
|
|
<Text style={styles.dataValue}>{row.value}</Text>
|
|
<Text style={styles.dataGoal}>
|
|
{t('statistics.components.fitnessRings.goal', { goal: row.goal })}
|
|
</Text>
|
|
</>
|
|
)}
|
|
</Text>
|
|
<Text style={styles.dataUnit}>{row.unit}</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,
|
|
fontFamily: 'AliBold',
|
|
},
|
|
dataValue: {
|
|
color: '#192126',
|
|
},
|
|
dataGoal: {
|
|
color: '#9AA3AE',
|
|
},
|
|
dataUnit: {
|
|
fontSize: 10,
|
|
color: '#9AA3AE',
|
|
fontWeight: '500',
|
|
minWidth: 25,
|
|
textAlign: 'right',
|
|
fontFamily: 'AliRegular',
|
|
},
|
|
});
|