feat: 添加最大心率功能,更新用户资料编辑页面以显示最大心率数据,优化相关组件和服务

This commit is contained in:
2025-09-05 21:58:46 +08:00
parent aee291bb69
commit 3c416545db
6 changed files with 100 additions and 71 deletions

View File

@@ -3,7 +3,6 @@ import { DateSelector } from '@/components/DateSelector';
import { FitnessRingsCard } from '@/components/FitnessRingsCard';
import { MoodCard } from '@/components/MoodCard';
import { NutritionRadarCard } from '@/components/NutritionRadarCard';
import HeartRateCard from '@/components/statistic/HeartRateCard';
import OxygenSaturationCard from '@/components/statistic/OxygenSaturationCard';
import StepsCard from '@/components/StepsCard';
import { StressMeter } from '@/components/StressMeter';
@@ -655,13 +654,13 @@ export default function ExploreScreen() {
</FloatingCard>
{/* 心率卡片 */}
<FloatingCard style={styles.masonryCard} delay={2000}>
{/* <FloatingCard style={styles.masonryCard} delay={2000}>
<HeartRateCard
resetToken={animToken}
style={styles.basalMetabolismCardOverride}
heartRate={heartRate}
/>
</FloatingCard>
</FloatingCard> */}
<FloatingCard style={styles.masonryCard}>
<View style={styles.cardHeaderRow}>

View File

@@ -4,6 +4,7 @@ import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useColorScheme } from '@/hooks/useColorScheme';
import { useCosUpload } from '@/hooks/useCosUpload';
import { fetchMyProfile, updateUserProfile } from '@/store/userSlice';
import { fetchMaximumHeartRate } from '@/utils/health';
import { Ionicons } from '@expo/vector-icons';
import AsyncStorage from '@react-native-async-storage/async-storage';
import DateTimePicker from '@react-native-community/datetimepicker';
@@ -41,6 +42,7 @@ interface UserProfile {
avatarUri?: string | null;
avatarBase64?: string | null; // 兼容旧逻辑(不再上报)
activityLevel?: number; // 活动水平 1-4
maxHeartRate?: number; // 最大心率
}
const STORAGE_KEY = '@user_profile';
@@ -68,6 +70,7 @@ export default function EditProfileScreen() {
height: undefined,
avatarUri: null,
activityLevel: undefined,
maxHeartRate: undefined,
});
// 出生日期选择器
@@ -93,6 +96,7 @@ export default function EditProfileScreen() {
height: undefined,
avatarUri: null,
activityLevel: undefined,
maxHeartRate: undefined,
};
if (fromOnboarding) {
try {
@@ -122,6 +126,29 @@ export default function EditProfileScreen() {
loadLocalProfile();
}, []);
// 获取最大心率数据
useEffect(() => {
const loadMaximumHeartRate = async () => {
try {
const today = new Date();
const startDate = new Date(today.getTime() - 7 * 24 * 60 * 60 * 1000); // 过去7天
const maxHeartRate = await fetchMaximumHeartRate({
startDate: startDate.toISOString(),
endDate: today.toISOString(),
});
if (maxHeartRate !== null) {
setProfile(prev => ({ ...prev, maxHeartRate }));
}
} catch (error) {
console.warn('获取最大心率失败', error);
}
};
loadMaximumHeartRate();
}, []);
// 页面聚焦时拉取最新用户信息,并刷新本地 UI
useFocusEffect(
React.useCallback(() => {
@@ -152,6 +179,8 @@ export default function EditProfileScreen() {
weight: accountProfile?.weight ?? prev.weight ?? undefined,
height: accountProfile?.height ?? prev.height ?? undefined,
activityLevel: accountProfile?.activityLevel ?? prev.activityLevel ?? undefined,
// maxHeartRate 不从后端获取,保持本地状态
maxHeartRate: prev.maxHeartRate,
}));
}, [accountProfile]);
@@ -366,6 +395,20 @@ export default function EditProfileScreen() {
openDatePicker();
}}
/>
{/* 最大心率 */}
<ProfileCard
icon="heart"
iconColor="#FF6B9D"
title="最大心率"
value={profile.maxHeartRate ? `${Math.round(profile.maxHeartRate)}次/分钟` : '未获取'}
onPress={() => {
// 最大心率不可编辑,只显示
Alert.alert('提示', '最大心率数据从健康应用自动获取');
}}
disabled={true}
hideArrow={true}
/>
</View>
{/* 编辑弹窗 */}
@@ -460,16 +503,20 @@ export default function EditProfileScreen() {
);
}
function ProfileCard({ icon, iconUri, iconColor, title, value, onPress }: {
function ProfileCard({ icon, iconUri, iconColor, title, value, onPress, disabled, hideArrow }: {
icon?: keyof typeof Ionicons.glyphMap;
iconUri?: string;
iconColor?: string;
title: string;
value: string;
onPress: () => void;
disabled?: boolean;
hideArrow?: boolean;
}) {
const Container = disabled ? View : TouchableOpacity;
return (
<TouchableOpacity onPress={onPress} style={styles.profileCard} activeOpacity={0.8}>
<Container onPress={disabled ? undefined : onPress} style={styles.profileCard} {...(disabled ? {} : { activeOpacity: 0.8 })}>
<View style={styles.profileCardLeft}>
<View style={[styles.iconContainer]}>
{iconUri ? <Image
@@ -481,9 +528,9 @@ function ProfileCard({ icon, iconUri, iconColor, title, value, onPress }: {
</View>
<View style={styles.profileCardRight}>
<Text style={styles.profileCardValue}>{value}</Text>
<Ionicons name="chevron-forward" size={16} color="#C7C7CC" />
{!hideArrow && <Ionicons name="chevron-forward" size={16} color="#C7C7CC" />}
</View>
</TouchableOpacity>
</Container>
);
}

View File

@@ -2376,10 +2376,10 @@ SPEC CHECKSUMS:
ExpoSymbols: c5612a90fb9179cdaebcd19bea9d8c69e5d3b859
ExpoSystemUI: 433a971503b99020318518ed30a58204288bab2d
ExpoWebBrowser: dc39a88485f007e61a3dff05d6a75f22ab4a2e92
fast_float: 06eeec4fe712a76acc9376682e4808b05ce978b6
fast_float: 23278fd30b349f976d2014f4aec9e2d7bc1c3806
FBLazyVector: d2a9cd223302b6c9aa4aa34c1a775e9db609eb52
fmt: a40bb5bd0294ea969aaaba240a927bd33d878cdd
glog: 5683914934d5b6e4240e497e0f4a3b42d1854183
fmt: b85d977e8fe789fd71c77123f9f4920d88c4d170
glog: 682871fb30f4a65f657bf357581110656ea90b08
libavif: 84bbb62fb232c3018d6f1bab79beea87e35de7b7
libdav1d: 23581a4d8ec811ff171ed5e2e05cd27bad64c39f
libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8
@@ -2389,7 +2389,7 @@ SPEC CHECKSUMS:
QCloudCore: 6f8c67b96448472d2c6a92b9cfe1bdb5abbb1798
QCloudCOSXML: 92f50a787b4e8d9a7cb6ea8e626775256b4840a7
QCloudTrack: 20b79388365b4c8ed150019c82a56f1569f237f8
RCT-Folly: e78785aa9ba2ed998ea4151e314036f6c49e6d82
RCT-Folly: 031db300533e2dfa954cdc5a859b792d5c14ed7b
RCTDeprecation: 5f638f65935e273753b1f31a365db6a8d6dc53b5
RCTRequired: 8b46a520ea9071e2bc47d474aa9ca31b4a935bd8
RCTTypeSafety: cc4740278c2a52cbf740592b0a0a40df1587c9ab

View File

@@ -89,53 +89,5 @@
<string>Light</string>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
<key>UIApplicationShortcutItems</key>
<array>
<dict>
<key>UIApplicationShortcutItemIconFile</key>
<string>IconGlass</string>
<key>UIApplicationShortcutItemTitle</key>
<string>喝水</string>
<key>UIApplicationShortcutItemSubtitle</key>
<string>快速记录饮水</string>
<key>UIApplicationShortcutItemType</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER).drink_water</string>
<key>UIApplicationShortcutItemUserInfo</key>
<dict>
<key>amount</key>
<integer>250</integer>
</dict>
</dict>
<dict>
<key>UIApplicationShortcutItemIconFile</key>
<string>IconGlass</string>
<key>UIApplicationShortcutItemTitle</key>
<string>喝水 100ml</string>
<key>UIApplicationShortcutItemSubtitle</key>
<string>快速记录饮水</string>
<key>UIApplicationShortcutItemType</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER).drink_water_100</string>
<key>UIApplicationShortcutItemUserInfo</key>
<dict>
<key>amount</key>
<integer>100</integer>
</dict>
</dict>
<dict>
<key>UIApplicationShortcutItemIconFile</key>
<string>IconGlass</string>
<key>UIApplicationShortcutItemTitle</key>
<string>喝水 200ml</string>
<key>UIApplicationShortcutItemSubtitle</key>
<string>快速记录饮水</string>
<key>UIApplicationShortcutItemType</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER).drink_water_200</string>
<key>UIApplicationShortcutItemUserInfo</key>
<dict>
<key>amount</key>
<integer>200</integer>
</dict>
</dict>
</array>
</dict>
</plist>

View File

@@ -60,9 +60,7 @@ export const setupQuickActions = async () => {
params: { amount: quickAmount }
},
// 固定选项
QUICK_ACTIONS.DRINK_WATER_100,
QUICK_ACTIONS.DRINK_WATER_200,
QUICK_ACTIONS.DRINK_WATER_250
QUICK_ACTIONS.DRINK_WATER_100
];
// 设置快捷动作

View File

@@ -183,12 +183,6 @@ async function fetchStepCount(date: Date): Promise<number> {
});
}
// 获取指定日期每小时步数数据 (已弃用,使用 fetchHourlyStepSamples 替代)
// 保留此函数以防后向兼容需求
async function fetchHourlyStepCount(date: Date): Promise<HourlyStepData[]> {
// 直接调用更准确的样本数据获取函数
return fetchHourlyStepSamples(date);
}
// 使用样本数据获取每小时步数
async function fetchHourlyStepSamples(date: Date): Promise<HourlyStepData[]> {
@@ -483,7 +477,7 @@ async function fetchActivitySummary(options: HealthDataOptions): Promise<HealthA
return new Promise((resolve) => {
AppleHealthKit.getActivitySummary(
options,
(err: Object, results: HealthActivitySummary[]) => {
(err: string, results: HealthActivitySummary[]) => {
if (err) {
logError('ActivitySummary', err);
return resolve(null);
@@ -537,6 +531,44 @@ async function fetchHeartRate(options: HealthDataOptions): Promise<number | null
});
}
// 获取指定时间范围内的最大心率
export async function fetchMaximumHeartRate(options: HealthDataOptions): Promise<number | null> {
return new Promise((resolve) => {
AppleHealthKit.getHeartRateSamples(options, (err, res) => {
if (err) {
logError('最大心率', err);
return resolve(null);
}
if (!res || !Array.isArray(res) || res.length === 0) {
logWarning('最大心率', '为空或格式错误');
return resolve(null);
}
// 从所有心率样本中找出最大值
let maxHeartRate = 0;
let validSamplesCount = 0;
res.forEach((sample: any) => {
if (sample && sample.value !== undefined) {
const heartRate = validateHeartRate(sample.value);
if (heartRate !== null) {
maxHeartRate = Math.max(maxHeartRate, heartRate);
validSamplesCount++;
}
}
});
if (validSamplesCount > 0 && maxHeartRate > 0) {
logSuccess('最大心率', { maxHeartRate, validSamplesCount });
resolve(maxHeartRate);
} else {
logWarning('最大心率', '没有找到有效的样本数据');
resolve(null);
}
});
});
}
// 默认健康数据
function getDefaultHealthData(): TodayHealthData {
return {
@@ -721,7 +753,7 @@ export async function saveWaterIntakeToHealthKit(amount: number, recordedAt?: st
endDate: recordedAt ? new Date(recordedAt).toISOString() : new Date().toISOString(),
};
AppleHealthKit.saveWater(waterOptions, (error: Object, result) => {
AppleHealthKit.saveWater(waterOptions, (error: string, result) => {
if (error) {
console.error('添加饮水记录到 HealthKit 失败:', error);
resolve(false);
@@ -742,7 +774,7 @@ export async function saveWaterIntakeToHealthKit(amount: number, recordedAt?: st
// 获取 HealthKit 中的饮水记录
export async function getWaterIntakeFromHealthKit(options: HealthDataOptions): Promise<any[]> {
return new Promise((resolve) => {
AppleHealthKit.getWaterSamples(options, (error: Object, results: any[]) => {
AppleHealthKit.getWaterSamples(options, (error: string, results: any[]) => {
if (error) {
console.error('获取 HealthKit 饮水记录失败:', error);
resolve([]);
@@ -850,3 +882,4 @@ export async function fetchActivityRingsForDate(date: Date): Promise<ActivityRin
return null;
}
}