feat(health): 新增手腕温度监测和经期双向同步功能
新增手腕温度健康数据追踪,支持Apple Watch睡眠手腕温度数据展示和30天历史趋势分析 实现经期数据与HealthKit的完整双向同步,支持读取、写入和删除经期记录 优化经期预测算法,基于历史数据计算更准确的周期和排卵日预测 重构经期UI组件为模块化结构,提升代码可维护性 添加完整的中英文国际化支持,覆盖所有新增功能界面
This commit is contained in:
178
utils/health.ts
178
utils/health.ts
@@ -2,6 +2,7 @@ import { CompleteSleepData, fetchCompleteSleepData } from '@/utils/sleepHealthKi
|
||||
import dayjs from 'dayjs';
|
||||
import { AppState, AppStateStatus, NativeModules } from 'react-native';
|
||||
import i18n from '../i18n';
|
||||
import { logger } from './logger';
|
||||
import { SimpleEventEmitter } from './SimpleEventEmitter';
|
||||
|
||||
type HealthDataOptions = {
|
||||
@@ -343,6 +344,19 @@ export type TodayHealthData = {
|
||||
heartRate: number | null;
|
||||
};
|
||||
|
||||
export type MenstrualFlowSample = {
|
||||
id: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
value: number; // 1=unspecified, 2=light, 3=medium, 4=heavy, 5=none
|
||||
isStart: boolean;
|
||||
source: {
|
||||
name: string;
|
||||
bundleIdentifier: string;
|
||||
};
|
||||
metadata: Record<string, any>;
|
||||
};
|
||||
|
||||
// 更新:使用新的权限管理系统
|
||||
export async function ensureHealthPermissions(): Promise<boolean> {
|
||||
return await healthPermissionManager.requestPermission();
|
||||
@@ -370,15 +384,15 @@ function createDateRange(date: Date): HealthDataOptions {
|
||||
|
||||
// 通用错误处理
|
||||
function logError(operation: string, error: any): void {
|
||||
console.error(`获取${operation}失败:`, error);
|
||||
logger.error(`获取${operation}失败`, error);
|
||||
}
|
||||
|
||||
function logWarning(operation: string, message: string): void {
|
||||
console.warn(`${operation}数据${message}`);
|
||||
logger.warn(`${operation}数据${message}`);
|
||||
}
|
||||
|
||||
function logSuccess(operation: string, data: any): void {
|
||||
console.log(`${operation}数据:`, data);
|
||||
logger.info(`${operation}数据`, data);
|
||||
}
|
||||
|
||||
// 数值验证和转换
|
||||
@@ -784,6 +798,87 @@ export async function fetchOxygenSaturation(options: HealthDataOptions): Promise
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchWristTemperature(options: HealthDataOptions, targetDate?: Date): Promise<number | null> {
|
||||
try {
|
||||
const result = await HealthKitManager.getWristTemperatureSamples(options);
|
||||
|
||||
if (result && result.data && Array.isArray(result.data) && result.data.length > 0) {
|
||||
logSuccess('手腕温度', result);
|
||||
|
||||
const samples = result.data as Array<{ endDate?: string; value?: number }>;
|
||||
const dayStart = targetDate ? dayjs(targetDate).startOf('day') : dayjs(options.startDate);
|
||||
const dayEnd = targetDate ? dayjs(targetDate).endOf('day') : dayjs(options.endDate);
|
||||
|
||||
const sampleForSelectedDay = samples.find((sample) => {
|
||||
const sampleEnd = dayjs(sample.endDate);
|
||||
return sampleEnd.isValid() && !sampleEnd.isBefore(dayStart) && !sampleEnd.isAfter(dayEnd);
|
||||
});
|
||||
|
||||
const sampleToUse = sampleForSelectedDay ?? samples[samples.length - 1];
|
||||
|
||||
if (sampleToUse?.value !== undefined) {
|
||||
return Number(Number(sampleToUse.value).toFixed(1));
|
||||
}
|
||||
|
||||
logWarning('手腕温度', '未找到有效的温度值');
|
||||
return null;
|
||||
} else {
|
||||
logWarning('手腕温度', '为空或格式错误');
|
||||
return null;
|
||||
}
|
||||
} catch (error) {
|
||||
logError('手腕温度', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export interface WristTemperatureHistoryPoint {
|
||||
date: string;
|
||||
endDate: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export async function fetchWristTemperatureHistory(options: HealthDataOptions): Promise<WristTemperatureHistoryPoint[]> {
|
||||
try {
|
||||
const result = await HealthKitManager.getWristTemperatureSamples(options);
|
||||
|
||||
if (result && result.data && Array.isArray(result.data) && result.data.length > 0) {
|
||||
logSuccess('手腕温度历史', result);
|
||||
|
||||
const samples = result.data as Array<{ endDate?: string; value?: number }>;
|
||||
const dailyLatest: Record<string, WristTemperatureHistoryPoint> = {};
|
||||
|
||||
samples.forEach((sample) => {
|
||||
if (!sample?.endDate || sample.value === undefined) return;
|
||||
|
||||
const end = dayjs(sample.endDate);
|
||||
if (!end.isValid()) return;
|
||||
|
||||
const dayKey = end.format('YYYY-MM-DD');
|
||||
const numericValue = Number(sample.value);
|
||||
if (Number.isNaN(numericValue)) return;
|
||||
|
||||
const existing = dailyLatest[dayKey];
|
||||
if (!existing || end.isAfter(dayjs(existing.endDate))) {
|
||||
dailyLatest[dayKey] = {
|
||||
date: dayKey,
|
||||
endDate: sample.endDate,
|
||||
value: Number(numericValue.toFixed(2)),
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
return Object.values(dailyLatest).sort((a, b) => dayjs(a.date).diff(dayjs(b.date)));
|
||||
} else {
|
||||
logWarning('手腕温度历史', '为空或格式错误');
|
||||
return [];
|
||||
}
|
||||
} catch (error) {
|
||||
logError('手腕温度历史', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchHeartRateSamplesForRange(
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
@@ -1493,6 +1588,83 @@ export async function fetchHourlyStandHoursForDate(date: Date): Promise<HourlySt
|
||||
export { fetchCompleteSleepData };
|
||||
export type { CompleteSleepData };
|
||||
|
||||
// === 经期数据相关方法 ===
|
||||
|
||||
export async function fetchMenstrualFlowSamples(
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
limit: number = 100
|
||||
): Promise<MenstrualFlowSample[]> {
|
||||
try {
|
||||
const options = {
|
||||
startDate: dayjs(startDate).startOf('day').toISOString(),
|
||||
endDate: dayjs(endDate).endOf('day').toISOString(),
|
||||
limit,
|
||||
};
|
||||
|
||||
const result = await HealthKitManager.getMenstrualFlowSamples(options);
|
||||
|
||||
if (result && Array.isArray(result.data)) {
|
||||
logSuccess('经期数据', result);
|
||||
return result.data;
|
||||
}
|
||||
|
||||
logWarning('经期数据', '为空或格式错误');
|
||||
return [];
|
||||
} catch (error) {
|
||||
logError('经期数据', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveMenstrualFlow(
|
||||
date: Date,
|
||||
value: number = 1, // Default to unspecified
|
||||
isStart: boolean = false
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const options = {
|
||||
date: dayjs(date).toISOString(),
|
||||
value,
|
||||
isStart,
|
||||
};
|
||||
|
||||
const result = await HealthKitManager.saveMenstrualFlow(options);
|
||||
if (result && result.success) {
|
||||
console.log('经期数据保存成功');
|
||||
return true;
|
||||
}
|
||||
console.error('经期数据保存失败');
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('保存经期数据失败:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteMenstrualFlow(
|
||||
startDate: Date,
|
||||
endDate: Date
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const options = {
|
||||
startDate: dayjs(startDate).startOf('day').toISOString(),
|
||||
endDate: dayjs(endDate).endOf('day').toISOString(),
|
||||
};
|
||||
|
||||
const result = await HealthKitManager.deleteMenstrualFlow(options);
|
||||
if (result && result.success) {
|
||||
console.log(`经期数据删除成功,数量: ${result.count}`);
|
||||
return true;
|
||||
}
|
||||
console.error('经期数据删除失败');
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('删除经期数据失败:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 专门为活动圆环详情页获取精简的数据
|
||||
export async function fetchActivityRingsForDate(date: Date): Promise<ActivityRingsData | null> {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user