feat: 添加最大心率功能,更新用户资料编辑页面以显示最大心率数据,优化相关组件和服务
This commit is contained in:
@@ -3,7 +3,6 @@ import { DateSelector } from '@/components/DateSelector';
|
|||||||
import { FitnessRingsCard } from '@/components/FitnessRingsCard';
|
import { FitnessRingsCard } from '@/components/FitnessRingsCard';
|
||||||
import { MoodCard } from '@/components/MoodCard';
|
import { MoodCard } from '@/components/MoodCard';
|
||||||
import { NutritionRadarCard } from '@/components/NutritionRadarCard';
|
import { NutritionRadarCard } from '@/components/NutritionRadarCard';
|
||||||
import HeartRateCard from '@/components/statistic/HeartRateCard';
|
|
||||||
import OxygenSaturationCard from '@/components/statistic/OxygenSaturationCard';
|
import OxygenSaturationCard from '@/components/statistic/OxygenSaturationCard';
|
||||||
import StepsCard from '@/components/StepsCard';
|
import StepsCard from '@/components/StepsCard';
|
||||||
import { StressMeter } from '@/components/StressMeter';
|
import { StressMeter } from '@/components/StressMeter';
|
||||||
@@ -655,13 +654,13 @@ export default function ExploreScreen() {
|
|||||||
</FloatingCard>
|
</FloatingCard>
|
||||||
|
|
||||||
{/* 心率卡片 */}
|
{/* 心率卡片 */}
|
||||||
<FloatingCard style={styles.masonryCard} delay={2000}>
|
{/* <FloatingCard style={styles.masonryCard} delay={2000}>
|
||||||
<HeartRateCard
|
<HeartRateCard
|
||||||
resetToken={animToken}
|
resetToken={animToken}
|
||||||
style={styles.basalMetabolismCardOverride}
|
style={styles.basalMetabolismCardOverride}
|
||||||
heartRate={heartRate}
|
heartRate={heartRate}
|
||||||
/>
|
/>
|
||||||
</FloatingCard>
|
</FloatingCard> */}
|
||||||
|
|
||||||
<FloatingCard style={styles.masonryCard}>
|
<FloatingCard style={styles.masonryCard}>
|
||||||
<View style={styles.cardHeaderRow}>
|
<View style={styles.cardHeaderRow}>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
|||||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
import { useCosUpload } from '@/hooks/useCosUpload';
|
import { useCosUpload } from '@/hooks/useCosUpload';
|
||||||
import { fetchMyProfile, updateUserProfile } from '@/store/userSlice';
|
import { fetchMyProfile, updateUserProfile } from '@/store/userSlice';
|
||||||
|
import { fetchMaximumHeartRate } from '@/utils/health';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
import DateTimePicker from '@react-native-community/datetimepicker';
|
import DateTimePicker from '@react-native-community/datetimepicker';
|
||||||
@@ -41,6 +42,7 @@ interface UserProfile {
|
|||||||
avatarUri?: string | null;
|
avatarUri?: string | null;
|
||||||
avatarBase64?: string | null; // 兼容旧逻辑(不再上报)
|
avatarBase64?: string | null; // 兼容旧逻辑(不再上报)
|
||||||
activityLevel?: number; // 活动水平 1-4
|
activityLevel?: number; // 活动水平 1-4
|
||||||
|
maxHeartRate?: number; // 最大心率
|
||||||
}
|
}
|
||||||
|
|
||||||
const STORAGE_KEY = '@user_profile';
|
const STORAGE_KEY = '@user_profile';
|
||||||
@@ -68,6 +70,7 @@ export default function EditProfileScreen() {
|
|||||||
height: undefined,
|
height: undefined,
|
||||||
avatarUri: null,
|
avatarUri: null,
|
||||||
activityLevel: undefined,
|
activityLevel: undefined,
|
||||||
|
maxHeartRate: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 出生日期选择器
|
// 出生日期选择器
|
||||||
@@ -93,6 +96,7 @@ export default function EditProfileScreen() {
|
|||||||
height: undefined,
|
height: undefined,
|
||||||
avatarUri: null,
|
avatarUri: null,
|
||||||
activityLevel: undefined,
|
activityLevel: undefined,
|
||||||
|
maxHeartRate: undefined,
|
||||||
};
|
};
|
||||||
if (fromOnboarding) {
|
if (fromOnboarding) {
|
||||||
try {
|
try {
|
||||||
@@ -122,6 +126,29 @@ export default function EditProfileScreen() {
|
|||||||
loadLocalProfile();
|
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
|
// 页面聚焦时拉取最新用户信息,并刷新本地 UI
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
React.useCallback(() => {
|
React.useCallback(() => {
|
||||||
@@ -152,6 +179,8 @@ export default function EditProfileScreen() {
|
|||||||
weight: accountProfile?.weight ?? prev.weight ?? undefined,
|
weight: accountProfile?.weight ?? prev.weight ?? undefined,
|
||||||
height: accountProfile?.height ?? prev.height ?? undefined,
|
height: accountProfile?.height ?? prev.height ?? undefined,
|
||||||
activityLevel: accountProfile?.activityLevel ?? prev.activityLevel ?? undefined,
|
activityLevel: accountProfile?.activityLevel ?? prev.activityLevel ?? undefined,
|
||||||
|
// maxHeartRate 不从后端获取,保持本地状态
|
||||||
|
maxHeartRate: prev.maxHeartRate,
|
||||||
}));
|
}));
|
||||||
}, [accountProfile]);
|
}, [accountProfile]);
|
||||||
|
|
||||||
@@ -366,6 +395,20 @@ export default function EditProfileScreen() {
|
|||||||
openDatePicker();
|
openDatePicker();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* 最大心率 */}
|
||||||
|
<ProfileCard
|
||||||
|
icon="heart"
|
||||||
|
iconColor="#FF6B9D"
|
||||||
|
title="最大心率"
|
||||||
|
value={profile.maxHeartRate ? `${Math.round(profile.maxHeartRate)}次/分钟` : '未获取'}
|
||||||
|
onPress={() => {
|
||||||
|
// 最大心率不可编辑,只显示
|
||||||
|
Alert.alert('提示', '最大心率数据从健康应用自动获取');
|
||||||
|
}}
|
||||||
|
disabled={true}
|
||||||
|
hideArrow={true}
|
||||||
|
/>
|
||||||
</View>
|
</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;
|
icon?: keyof typeof Ionicons.glyphMap;
|
||||||
iconUri?: string;
|
iconUri?: string;
|
||||||
iconColor?: string;
|
iconColor?: string;
|
||||||
title: string;
|
title: string;
|
||||||
value: string;
|
value: string;
|
||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
hideArrow?: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const Container = disabled ? View : TouchableOpacity;
|
||||||
|
|
||||||
return (
|
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.profileCardLeft}>
|
||||||
<View style={[styles.iconContainer]}>
|
<View style={[styles.iconContainer]}>
|
||||||
{iconUri ? <Image
|
{iconUri ? <Image
|
||||||
@@ -481,9 +528,9 @@ function ProfileCard({ icon, iconUri, iconColor, title, value, onPress }: {
|
|||||||
</View>
|
</View>
|
||||||
<View style={styles.profileCardRight}>
|
<View style={styles.profileCardRight}>
|
||||||
<Text style={styles.profileCardValue}>{value}</Text>
|
<Text style={styles.profileCardValue}>{value}</Text>
|
||||||
<Ionicons name="chevron-forward" size={16} color="#C7C7CC" />
|
{!hideArrow && <Ionicons name="chevron-forward" size={16} color="#C7C7CC" />}
|
||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</Container>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2376,10 +2376,10 @@ SPEC CHECKSUMS:
|
|||||||
ExpoSymbols: c5612a90fb9179cdaebcd19bea9d8c69e5d3b859
|
ExpoSymbols: c5612a90fb9179cdaebcd19bea9d8c69e5d3b859
|
||||||
ExpoSystemUI: 433a971503b99020318518ed30a58204288bab2d
|
ExpoSystemUI: 433a971503b99020318518ed30a58204288bab2d
|
||||||
ExpoWebBrowser: dc39a88485f007e61a3dff05d6a75f22ab4a2e92
|
ExpoWebBrowser: dc39a88485f007e61a3dff05d6a75f22ab4a2e92
|
||||||
fast_float: 06eeec4fe712a76acc9376682e4808b05ce978b6
|
fast_float: 23278fd30b349f976d2014f4aec9e2d7bc1c3806
|
||||||
FBLazyVector: d2a9cd223302b6c9aa4aa34c1a775e9db609eb52
|
FBLazyVector: d2a9cd223302b6c9aa4aa34c1a775e9db609eb52
|
||||||
fmt: a40bb5bd0294ea969aaaba240a927bd33d878cdd
|
fmt: b85d977e8fe789fd71c77123f9f4920d88c4d170
|
||||||
glog: 5683914934d5b6e4240e497e0f4a3b42d1854183
|
glog: 682871fb30f4a65f657bf357581110656ea90b08
|
||||||
libavif: 84bbb62fb232c3018d6f1bab79beea87e35de7b7
|
libavif: 84bbb62fb232c3018d6f1bab79beea87e35de7b7
|
||||||
libdav1d: 23581a4d8ec811ff171ed5e2e05cd27bad64c39f
|
libdav1d: 23581a4d8ec811ff171ed5e2e05cd27bad64c39f
|
||||||
libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8
|
libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8
|
||||||
@@ -2389,7 +2389,7 @@ SPEC CHECKSUMS:
|
|||||||
QCloudCore: 6f8c67b96448472d2c6a92b9cfe1bdb5abbb1798
|
QCloudCore: 6f8c67b96448472d2c6a92b9cfe1bdb5abbb1798
|
||||||
QCloudCOSXML: 92f50a787b4e8d9a7cb6ea8e626775256b4840a7
|
QCloudCOSXML: 92f50a787b4e8d9a7cb6ea8e626775256b4840a7
|
||||||
QCloudTrack: 20b79388365b4c8ed150019c82a56f1569f237f8
|
QCloudTrack: 20b79388365b4c8ed150019c82a56f1569f237f8
|
||||||
RCT-Folly: e78785aa9ba2ed998ea4151e314036f6c49e6d82
|
RCT-Folly: 031db300533e2dfa954cdc5a859b792d5c14ed7b
|
||||||
RCTDeprecation: 5f638f65935e273753b1f31a365db6a8d6dc53b5
|
RCTDeprecation: 5f638f65935e273753b1f31a365db6a8d6dc53b5
|
||||||
RCTRequired: 8b46a520ea9071e2bc47d474aa9ca31b4a935bd8
|
RCTRequired: 8b46a520ea9071e2bc47d474aa9ca31b4a935bd8
|
||||||
RCTTypeSafety: cc4740278c2a52cbf740592b0a0a40df1587c9ab
|
RCTTypeSafety: cc4740278c2a52cbf740592b0a0a40df1587c9ab
|
||||||
|
|||||||
@@ -89,53 +89,5 @@
|
|||||||
<string>Light</string>
|
<string>Light</string>
|
||||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||||
<false/>
|
<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>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -60,9 +60,7 @@ export const setupQuickActions = async () => {
|
|||||||
params: { amount: quickAmount }
|
params: { amount: quickAmount }
|
||||||
},
|
},
|
||||||
// 固定选项
|
// 固定选项
|
||||||
QUICK_ACTIONS.DRINK_WATER_100,
|
QUICK_ACTIONS.DRINK_WATER_100
|
||||||
QUICK_ACTIONS.DRINK_WATER_200,
|
|
||||||
QUICK_ACTIONS.DRINK_WATER_250
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// 设置快捷动作
|
// 设置快捷动作
|
||||||
|
|||||||
@@ -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[]> {
|
async function fetchHourlyStepSamples(date: Date): Promise<HourlyStepData[]> {
|
||||||
@@ -483,7 +477,7 @@ async function fetchActivitySummary(options: HealthDataOptions): Promise<HealthA
|
|||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
AppleHealthKit.getActivitySummary(
|
AppleHealthKit.getActivitySummary(
|
||||||
options,
|
options,
|
||||||
(err: Object, results: HealthActivitySummary[]) => {
|
(err: string, results: HealthActivitySummary[]) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
logError('ActivitySummary', err);
|
logError('ActivitySummary', err);
|
||||||
return resolve(null);
|
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 {
|
function getDefaultHealthData(): TodayHealthData {
|
||||||
return {
|
return {
|
||||||
@@ -721,7 +753,7 @@ export async function saveWaterIntakeToHealthKit(amount: number, recordedAt?: st
|
|||||||
endDate: recordedAt ? new Date(recordedAt).toISOString() : new Date().toISOString(),
|
endDate: recordedAt ? new Date(recordedAt).toISOString() : new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
AppleHealthKit.saveWater(waterOptions, (error: Object, result) => {
|
AppleHealthKit.saveWater(waterOptions, (error: string, result) => {
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error('添加饮水记录到 HealthKit 失败:', error);
|
console.error('添加饮水记录到 HealthKit 失败:', error);
|
||||||
resolve(false);
|
resolve(false);
|
||||||
@@ -742,7 +774,7 @@ export async function saveWaterIntakeToHealthKit(amount: number, recordedAt?: st
|
|||||||
// 获取 HealthKit 中的饮水记录
|
// 获取 HealthKit 中的饮水记录
|
||||||
export async function getWaterIntakeFromHealthKit(options: HealthDataOptions): Promise<any[]> {
|
export async function getWaterIntakeFromHealthKit(options: HealthDataOptions): Promise<any[]> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
AppleHealthKit.getWaterSamples(options, (error: Object, results: any[]) => {
|
AppleHealthKit.getWaterSamples(options, (error: string, results: any[]) => {
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error('获取 HealthKit 饮水记录失败:', error);
|
console.error('获取 HealthKit 饮水记录失败:', error);
|
||||||
resolve([]);
|
resolve([]);
|
||||||
@@ -850,3 +882,4 @@ export async function fetchActivityRingsForDate(date: Date): Promise<ActivityRin
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user