feat: Enhance Oxygen Saturation Card with health permissions and loading state management

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
This commit is contained in:
2025-11-28 23:48:38 +08:00
parent bca6670390
commit 83b77615cf
19 changed files with 512 additions and 254 deletions

View File

@@ -1,5 +1,6 @@
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';
@@ -26,6 +27,7 @@ export function FitnessRingsCard({
selectedDate,
resetToken,
}: FitnessRingsCardProps) {
const { t } = useI18n();
const dispatch = useAppDispatch();
const challenges = useAppSelector(selectChallengeList);
const [activityData, setActivityData] = useState<ActivityRingsData | null>(null);
@@ -135,6 +137,24 @@ export function FitnessRingsCard({
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);
};
@@ -191,47 +211,23 @@ export function FitnessRingsCard({
{/* 右侧数据显示 */}
<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>
{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>

View File

@@ -13,7 +13,7 @@ interface MoodCardProps {
export function MoodCard({ moodCheckin, onPress }: MoodCardProps) {
const { t } = useTranslation();
const moodConfig = moodCheckin ? getMoodConfig(moodCheckin.moodType) : null;
const moodConfig = moodCheckin ? getMoodConfig(moodCheckin.moodType, t) : null;
const animationRef = useRef<LottieView>(null);
useEffect(() => {
@@ -122,4 +122,4 @@ const styles = StyleSheet.create({
marginTop: 22,
fontFamily: 'AliRegular',
},
});
});

View File

@@ -1,3 +1,4 @@
import { useI18n } from '@/hooks/useI18n';
import { MoodCheckin, getMoodConfig } from '@/services/moodCheckins';
import dayjs from 'dayjs';
import React from 'react';
@@ -8,7 +9,9 @@ interface MoodHistoryCardProps {
title?: string;
}
export function MoodHistoryCard({ moodCheckins, title = '心情记录' }: MoodHistoryCardProps) {
export function MoodHistoryCard({ moodCheckins, title }: MoodHistoryCardProps) {
const { t } = useI18n();
const defaultTitle = t('mood.history.title');
// 计算心情统计
const moodStats = React.useMemo(() => {
const stats = {
@@ -26,7 +29,7 @@ export function MoodHistoryCard({ moodCheckins, title = '心情记录' }: MoodHi
// 计算心情分布
moodCheckins.forEach(checkin => {
const moodLabel = getMoodConfig(checkin.moodType)?.label || checkin.moodType;
const moodLabel = getMoodConfig(checkin.moodType, t)?.label || checkin.moodType;
stats.moodDistribution[moodLabel] = (stats.moodDistribution[moodLabel] || 0) + 1;
});
@@ -45,11 +48,11 @@ export function MoodHistoryCard({ moodCheckins, title = '心情记录' }: MoodHi
return (
<View style={styles.container}>
<Text style={styles.title}>{title}</Text>
<Text style={styles.title}>{title || defaultTitle}</Text>
{moodCheckins.length === 0 ? (
<View style={styles.emptyState}>
<Text style={styles.emptyText}></Text>
<Text style={styles.emptyText}>{t('mood.history.noRecords')}</Text>
</View>
) : (
<>
@@ -57,36 +60,36 @@ export function MoodHistoryCard({ moodCheckins, title = '心情记录' }: MoodHi
<View style={styles.statsContainer}>
<View style={styles.statItem}>
<Text style={styles.statValue}>{moodStats.total}</Text>
<Text style={styles.statLabel}></Text>
<Text style={styles.statLabel}>{t('mood.history.totalRecords')}</Text>
</View>
<View style={styles.statItem}>
<Text style={styles.statValue}>{moodStats.averageIntensity}</Text>
<Text style={styles.statLabel}></Text>
<Text style={styles.statLabel}>{t('mood.history.averageIntensity')}</Text>
</View>
<View style={styles.statItem}>
<Text style={styles.statValue}>{moodStats.mostFrequentMood}</Text>
<Text style={styles.statLabel}></Text>
<Text style={styles.statLabel}>{t('mood.history.mostFrequent')}</Text>
</View>
</View>
{/* 最近记录 */}
<View style={styles.recentContainer}>
<Text style={styles.sectionTitle}></Text>
<Text style={styles.sectionTitle}>{t('mood.history.recentRecords')}</Text>
{recentMoods.map((checkin, index) => {
const moodConfig = getMoodConfig(checkin.moodType);
const moodConfig = getMoodConfig(checkin.moodType, t);
return (
<View key={checkin.id} style={styles.moodItem}>
<View style={styles.moodInfo}>
<Text style={styles.moodEmoji}>{moodConfig?.emoji}</Text>
<Text style={styles.moodEmoji}>😊</Text>
<View style={styles.moodDetails}>
<Text style={styles.moodLabel}>{moodConfig?.label}</Text>
<Text style={styles.moodDate}>
{dayjs(checkin.createdAt).format('MM月DD日 HH:mm')}
{dayjs(checkin.createdAt).format(t('mood.history.dateTimeFormat'))}
</Text>
</View>
</View>
<View style={styles.moodIntensity}>
<Text style={styles.intensityText}> {checkin.intensity}</Text>
<Text style={styles.intensityText}>{t('mood.history.intensity')} {checkin.intensity}</Text>
</View>
</View>
);

View File

@@ -6,6 +6,7 @@ import {
Text,
View,
} from 'react-native';
import { useTranslation } from 'react-i18next';
import {
Gesture,
GestureDetector,
@@ -38,6 +39,7 @@ export default function MoodIntensitySlider({
width = 320,
height = 16, // 更粗的进度条
}: MoodIntensitySliderProps) {
const { t } = useTranslation();
const thumbSize = 32; // 合适的触摸区域
const translateX = useSharedValue(0);
const isDragging = useSharedValue(0);
@@ -175,8 +177,8 @@ export default function MoodIntensitySlider({
{/* 标签 */}
<View style={[styles.labelsContainer, { width: width }]}>
<Text style={styles.labelText}></Text>
<Text style={styles.labelText}></Text>
<Text style={styles.labelText}>{t('mood.edit.intensityLow')}</Text>
<Text style={styles.labelText}>{t('mood.edit.intensityHigh')}</Text>
</View>
{/* 刻度 */}

View File

@@ -6,11 +6,13 @@ import {
TouchableOpacity,
View
} from 'react-native';
import { useI18n } from '../hooks/useI18n';
import { useNotifications } from '../hooks/useNotifications';
import { ThemedText } from './ThemedText';
import { ThemedView } from './ThemedView';
export const NotificationTest: React.FC = () => {
const { t } = useI18n();
const {
isInitialized,
permissionStatus,
@@ -95,8 +97,8 @@ export const NotificationTest: React.FC = () => {
const handleSendMoodCheckinReminder = async () => {
try {
await sendMoodCheckinReminder('心情打卡', '记得记录今天的心情状态哦');
Alert.alert('成功', '心情打卡提醒已发送');
await sendMoodCheckinReminder(t('notifications.moodReminder.title'), t('notifications.moodReminder.body'));
Alert.alert(t('common.success'), t('notifications.moodReminder.sent'));
} catch (error) {
Alert.alert('错误', '发送心情打卡提醒失败');
}

View File

@@ -1,7 +1,8 @@
import { fetchOxygenSaturation } from '@/utils/health';
import { useFocusEffect } from '@react-navigation/native';
import { ensureHealthPermissions, fetchOxygenSaturation } from '@/utils/health';
import { HealthKitUtils } from '@/utils/healthKit';
import { useIsFocused } from '@react-navigation/native';
import dayjs from 'dayjs';
import React, { useCallback, useRef, useState } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import HealthDataCard from './HealthDataCard';
@@ -15,42 +16,52 @@ const OxygenSaturationCard: React.FC<OxygenSaturationCardProps> = ({
selectedDate
}) => {
const { t } = useTranslation();
const isFocused = useIsFocused();
const [oxygenSaturation, setOxygenSaturation] = useState<number | null>(null);
const [loading, setLoading] = useState(false);
const loadingRef = useRef(false);
// 获取血氧饱和度数据 - 在页面聚焦、日期变化时触发
useFocusEffect(
useCallback(() => {
const loadOxygenSaturationData = async () => {
const dateToUse = selectedDate || new Date();
useEffect(() => {
const loadOxygenSaturationData = async () => {
const dateToUse = selectedDate || new Date();
// 防止重复请求
if (loadingRef.current) return;
if (!isFocused) return;
if (!HealthKitUtils.isAvailable()) {
setOxygenSaturation(null);
return;
}
try {
loadingRef.current = true;
setLoading(true);
// 防止重复请求
if (loadingRef.current) return;
const options = {
startDate: dayjs(dateToUse).startOf('day').toDate().toISOString(),
endDate: dayjs(dateToUse).endOf('day').toDate().toISOString()
};
try {
loadingRef.current = true;
setLoading(true);
const data = await fetchOxygenSaturation(options);
setOxygenSaturation(data);
} catch (error) {
console.error('OxygenSaturationCard: Failed to get blood oxygen data:', error);
const hasPermission = await ensureHealthPermissions();
if (!hasPermission) {
setOxygenSaturation(null);
} finally {
setLoading(false);
loadingRef.current = false;
return;
}
};
loadOxygenSaturationData();
}, [selectedDate])
);
const options = {
startDate: dayjs(dateToUse).startOf('day').toDate().toISOString(),
endDate: dayjs(dateToUse).endOf('day').toDate().toISOString()
};
const data = await fetchOxygenSaturation(options);
setOxygenSaturation(data);
} catch (error) {
console.error('OxygenSaturationCard: Failed to get blood oxygen data:', error);
setOxygenSaturation(null);
} finally {
setLoading(false);
loadingRef.current = false;
}
};
loadOxygenSaturationData();
}, [isFocused, selectedDate]);
return (
<HealthDataCard
@@ -62,4 +73,4 @@ const OxygenSaturationCard: React.FC<OxygenSaturationCardProps> = ({
);
};
export default OxygenSaturationCard;
export default OxygenSaturationCard;