feat: 添加最大心率功能,更新用户资料编辑页面以显示最大心率数据,优化相关组件和服务
This commit is contained in:
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
];
|
||||
|
||||
// 设置快捷动作
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user