feat: 支持 healthkit
This commit is contained in:
@@ -18,7 +18,7 @@ import { fetchDailyMoodCheckins, selectLatestMoodRecordByDate } from '@/store/mo
|
||||
import { fetchDailyNutritionData, selectNutritionSummaryByDate } from '@/store/nutritionSlice';
|
||||
import { fetchTodayWaterStats } from '@/store/waterSlice';
|
||||
import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date';
|
||||
import { ensureHealthPermissions, fetchHealthDataForDate, testHRVDataFetch } from '@/utils/health';
|
||||
import { fetchHealthDataForDate, testHRVDataFetch } from '@/utils/health';
|
||||
import { getTestHealthData } from '@/utils/mockHealthData';
|
||||
import { calculateNutritionGoals } from '@/utils/nutrition';
|
||||
import dayjs from 'dayjs';
|
||||
@@ -248,12 +248,12 @@ export default function ExploreScreen() {
|
||||
loadingRef.current.health = true;
|
||||
console.log('=== 开始HealthKit初始化流程 ===');
|
||||
|
||||
const ok = await ensureHealthPermissions();
|
||||
if (!ok) {
|
||||
const errorMsg = '无法获取健康权限,请确保在真实iOS设备上运行并授权应用访问健康数据';
|
||||
console.warn(errorMsg);
|
||||
return;
|
||||
}
|
||||
// const ok = await ensureHealthPermissions();
|
||||
// if (!ok) {
|
||||
// const errorMsg = '无法获取健康权限,请确保在真实iOS设备上运行并授权应用访问健康数据';
|
||||
// console.warn(errorMsg);
|
||||
// return;
|
||||
// }
|
||||
|
||||
latestRequestKeyRef.current = requestKey;
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import { WaterRecordSource } from '@/services/waterRecords';
|
||||
import { store } from '@/store';
|
||||
import { rehydrateUserSync, setPrivacyAgreed } from '@/store/userSlice';
|
||||
import { createWaterRecordAction } from '@/store/waterSlice';
|
||||
import { ensureHealthPermissions } from '@/utils/health';
|
||||
import { DailySummaryNotificationHelpers, MoodNotificationHelpers, NutritionNotificationHelpers } from '@/utils/notificationHelpers';
|
||||
import { clearPendingWaterRecords, syncPendingWidgetChanges } from '@/utils/widgetDataSync';
|
||||
import React from 'react';
|
||||
@@ -41,9 +42,20 @@ function Bootstrapper({ children }: { children: React.ReactNode }) {
|
||||
await dispatch(rehydrateUserSync());
|
||||
setUserDataLoaded(true);
|
||||
};
|
||||
|
||||
const initHealthPermissions = async () => {
|
||||
// 初始化 HealthKit 权限
|
||||
try {
|
||||
console.log('开始请求 HealthKit 权限...');
|
||||
await ensureHealthPermissions();
|
||||
console.log('HealthKit 权限初始化完成');
|
||||
} catch (error) {
|
||||
console.warn('HealthKit 权限初始化失败,可能在模拟器上运行:', error);
|
||||
}
|
||||
}
|
||||
|
||||
const initializeNotifications = async () => {
|
||||
try {
|
||||
|
||||
await BackgroundTaskManager.getInstance().initialize();
|
||||
// 初始化通知服务
|
||||
await notificationService.initialize();
|
||||
@@ -102,6 +114,7 @@ function Bootstrapper({ children }: { children: React.ReactNode }) {
|
||||
};
|
||||
|
||||
loadUserData();
|
||||
initHealthPermissions();
|
||||
initializeNotifications();
|
||||
// 冷启动时清空 AI 教练会话缓存
|
||||
clearAiCoachSessionCache();
|
||||
|
||||
383
components/HealthKitTest.tsx
Normal file
383
components/HealthKitTest.tsx
Normal file
@@ -0,0 +1,383 @@
|
||||
/**
|
||||
* HealthKit测试组件
|
||||
* 用于测试和演示HealthKit native module的功能
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Platform,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import HealthKitManager, { HealthKitUtils, SleepDataSample } from '../utils/healthKit';
|
||||
|
||||
interface HealthKitTestState {
|
||||
isAvailable: boolean;
|
||||
isAuthorized: boolean;
|
||||
sleepData: SleepDataSample[];
|
||||
lastNightSleep: any;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const HealthKitTest: React.FC = () => {
|
||||
const [state, setState] = useState<HealthKitTestState>({
|
||||
isAvailable: false,
|
||||
isAuthorized: false,
|
||||
sleepData: [],
|
||||
lastNightSleep: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// 检查HealthKit可用性
|
||||
const available = HealthKitUtils.isAvailable();
|
||||
setState(prev => ({ ...prev, isAvailable: available }));
|
||||
|
||||
if (!available && Platform.OS === 'ios') {
|
||||
Alert.alert('提示', 'HealthKit在当前设备上不可用,可能是因为运行在模拟器上。');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleRequestAuthorization = async () => {
|
||||
if (!state.isAvailable) {
|
||||
Alert.alert('错误', 'HealthKit不可用');
|
||||
return;
|
||||
}
|
||||
|
||||
setState(prev => ({ ...prev, loading: true, error: null }));
|
||||
|
||||
try {
|
||||
const result = await HealthKitManager.requestAuthorization();
|
||||
|
||||
if (result.success) {
|
||||
const sleepPermission = result.permissions['HKCategoryTypeIdentifierSleepAnalysis'];
|
||||
const authorized = sleepPermission === 'authorized';
|
||||
|
||||
setState(prev => ({ ...prev, isAuthorized: authorized, loading: false }));
|
||||
|
||||
Alert.alert(
|
||||
'授权结果',
|
||||
authorized ? '已获得睡眠数据访问权限' : `睡眠数据权限状态: ${sleepPermission}`,
|
||||
[{ text: '确定' }]
|
||||
);
|
||||
} else {
|
||||
setState(prev => ({ ...prev, loading: false }));
|
||||
Alert.alert('授权失败', '用户拒绝了HealthKit权限请求');
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
setState(prev => ({ ...prev, loading: false, error: errorMessage }));
|
||||
Alert.alert('错误', `授权失败: ${errorMessage}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGetSleepData = async () => {
|
||||
if (!state.isAuthorized) {
|
||||
Alert.alert('错误', '请先获取HealthKit授权');
|
||||
return;
|
||||
}
|
||||
|
||||
setState(prev => ({ ...prev, loading: true, error: null }));
|
||||
|
||||
try {
|
||||
const endDate = new Date();
|
||||
const startDate = new Date();
|
||||
startDate.setDate(endDate.getDate() - 7); // 获取最近7天的数据
|
||||
|
||||
const result = await HealthKitManager.getSleepData({
|
||||
startDate: startDate.toISOString(),
|
||||
endDate: endDate.toISOString(),
|
||||
limit: 50,
|
||||
});
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
sleepData: result.data,
|
||||
loading: false,
|
||||
}));
|
||||
|
||||
Alert.alert('成功', `获取到 ${result.count} 条睡眠记录`);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
setState(prev => ({ ...prev, loading: false, error: errorMessage }));
|
||||
Alert.alert('错误', `获取睡眠数据失败: ${errorMessage}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGetLastNightSleep = async () => {
|
||||
if (!state.isAuthorized) {
|
||||
Alert.alert('错误', '请先获取HealthKit授权');
|
||||
return;
|
||||
}
|
||||
|
||||
setState(prev => ({ ...prev, loading: true, error: null }));
|
||||
|
||||
try {
|
||||
const today = new Date();
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(today.getDate() - 1);
|
||||
|
||||
const startDate = new Date(yesterday);
|
||||
startDate.setHours(18, 0, 0, 0);
|
||||
|
||||
const endDate = new Date(today);
|
||||
endDate.setHours(12, 0, 0, 0);
|
||||
|
||||
const result = await HealthKitManager.getSleepData({
|
||||
startDate: startDate.toISOString(),
|
||||
endDate: endDate.toISOString(),
|
||||
limit: 20,
|
||||
});
|
||||
|
||||
const sleepSamples = result.data.filter(sample =>
|
||||
['asleep', 'core', 'deep', 'rem'].includes(sample.categoryType)
|
||||
);
|
||||
|
||||
if (sleepSamples.length > 0) {
|
||||
const sleepStart = new Date(Math.min(...sleepSamples.map(s => new Date(s.startDate).getTime())));
|
||||
const sleepEnd = new Date(Math.max(...sleepSamples.map(s => new Date(s.endDate).getTime())));
|
||||
const totalDuration = sleepSamples.reduce((sum, s) => sum + s.duration, 0);
|
||||
|
||||
const lastNightData = {
|
||||
hasData: true,
|
||||
sleepStart: sleepStart.toISOString(),
|
||||
sleepEnd: sleepEnd.toISOString(),
|
||||
totalDuration,
|
||||
totalDurationFormatted: HealthKitUtils.formatDuration(totalDuration),
|
||||
samples: sleepSamples,
|
||||
bedTime: sleepStart.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }),
|
||||
wakeTime: sleepEnd.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }),
|
||||
};
|
||||
|
||||
setState(prev => ({ ...prev, lastNightSleep: lastNightData, loading: false }));
|
||||
Alert.alert('昨晚睡眠', `睡眠时间: ${lastNightData.bedTime} - ${lastNightData.wakeTime}\n睡眠时长: ${lastNightData.totalDurationFormatted}`);
|
||||
} else {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
lastNightSleep: { hasData: false, message: '未找到昨晚的睡眠数据' },
|
||||
loading: false
|
||||
}));
|
||||
Alert.alert('提示', '未找到昨晚的睡眠数据');
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
setState(prev => ({ ...prev, loading: false, error: errorMessage }));
|
||||
Alert.alert('错误', `获取昨晚睡眠数据失败: ${errorMessage}`);
|
||||
}
|
||||
};
|
||||
|
||||
const renderSleepSample = (sample: SleepDataSample, index: number) => (
|
||||
<View key={sample.id} style={styles.sampleItem}>
|
||||
<Text style={styles.sampleTitle}>样本 #{index + 1}</Text>
|
||||
<Text style={styles.sampleText}>类型: {sample.categoryType}</Text>
|
||||
<Text style={styles.sampleText}>时长: {HealthKitUtils.formatDuration(sample.duration)}</Text>
|
||||
<Text style={styles.sampleText}>
|
||||
时间: {new Date(sample.startDate).toLocaleString('zh-CN')} - {new Date(sample.endDate).toLocaleTimeString('zh-CN')}
|
||||
</Text>
|
||||
<Text style={styles.sampleText}>来源: {sample.source.name}</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<ScrollView style={styles.container}>
|
||||
<Text style={styles.title}>HealthKit 测试</Text>
|
||||
|
||||
{/* 状态显示 */}
|
||||
<View style={styles.statusContainer}>
|
||||
<Text style={styles.statusTitle}>状态信息</Text>
|
||||
<Text style={styles.statusText}>平台: {Platform.OS}</Text>
|
||||
<Text style={styles.statusText}>HealthKit可用: {state.isAvailable ? '是' : '否'}</Text>
|
||||
<Text style={styles.statusText}>已授权: {state.isAuthorized ? '是' : '否'}</Text>
|
||||
<Text style={styles.statusText}>睡眠数据条数: {state.sleepData.length}</Text>
|
||||
{state.error && <Text style={styles.errorText}>错误: {state.error}</Text>}
|
||||
</View>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<View style={styles.buttonContainer}>
|
||||
<TouchableOpacity
|
||||
style={[styles.button, !state.isAvailable && styles.buttonDisabled]}
|
||||
onPress={handleRequestAuthorization}
|
||||
disabled={!state.isAvailable || state.loading}
|
||||
>
|
||||
<Text style={styles.buttonText}>
|
||||
{state.loading ? '请求中...' : '请求HealthKit授权'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.button, (!state.isAuthorized || state.loading) && styles.buttonDisabled]}
|
||||
onPress={handleGetSleepData}
|
||||
disabled={!state.isAuthorized || state.loading}
|
||||
>
|
||||
<Text style={styles.buttonText}>
|
||||
{state.loading ? '获取中...' : '获取睡眠数据(7天)'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.button, (!state.isAuthorized || state.loading) && styles.buttonDisabled]}
|
||||
onPress={handleGetLastNightSleep}
|
||||
disabled={!state.isAuthorized || state.loading}
|
||||
>
|
||||
<Text style={styles.buttonText}>
|
||||
{state.loading ? '获取中...' : '获取昨晚睡眠'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* 昨晚睡眠数据 */}
|
||||
{state.lastNightSleep?.hasData && (
|
||||
<View style={styles.resultContainer}>
|
||||
<Text style={styles.resultTitle}>昨晚睡眠数据</Text>
|
||||
<Text style={styles.resultText}>睡眠时间: {state.lastNightSleep.bedTime} - {state.lastNightSleep.wakeTime}</Text>
|
||||
<Text style={styles.resultText}>睡眠时长: {state.lastNightSleep.totalDurationFormatted}</Text>
|
||||
<Text style={styles.resultText}>样本数量: {state.lastNightSleep.samples.length}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 睡眠数据列表 */}
|
||||
{state.sleepData.length > 0 && (
|
||||
<View style={styles.dataContainer}>
|
||||
<Text style={styles.dataTitle}>睡眠数据 (最近{state.sleepData.length}条)</Text>
|
||||
{state.sleepData.slice(0, 10).map(renderSleepSample)}
|
||||
{state.sleepData.length > 10 && (
|
||||
<Text style={styles.moreText}>还有 {state.sleepData.length - 10} 条数据...</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
padding: 16,
|
||||
backgroundColor: '#f5f5f5',
|
||||
},
|
||||
title: {
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
textAlign: 'center',
|
||||
marginBottom: 20,
|
||||
color: '#333',
|
||||
},
|
||||
statusContainer: {
|
||||
backgroundColor: 'white',
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
marginBottom: 16,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 3,
|
||||
},
|
||||
statusTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 8,
|
||||
color: '#333',
|
||||
},
|
||||
statusText: {
|
||||
fontSize: 14,
|
||||
marginBottom: 4,
|
||||
color: '#666',
|
||||
},
|
||||
errorText: {
|
||||
fontSize: 14,
|
||||
color: '#e74c3c',
|
||||
marginTop: 8,
|
||||
},
|
||||
buttonContainer: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
button: {
|
||||
backgroundColor: '#007AFF',
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
marginBottom: 12,
|
||||
alignItems: 'center',
|
||||
},
|
||||
buttonDisabled: {
|
||||
backgroundColor: '#ccc',
|
||||
},
|
||||
buttonText: {
|
||||
color: 'white',
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
resultContainer: {
|
||||
backgroundColor: 'white',
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
marginBottom: 16,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 3,
|
||||
},
|
||||
resultTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 8,
|
||||
color: '#333',
|
||||
},
|
||||
resultText: {
|
||||
fontSize: 14,
|
||||
marginBottom: 4,
|
||||
color: '#666',
|
||||
},
|
||||
dataContainer: {
|
||||
backgroundColor: 'white',
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
marginBottom: 16,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 3,
|
||||
},
|
||||
dataTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 12,
|
||||
color: '#333',
|
||||
},
|
||||
sampleItem: {
|
||||
backgroundColor: '#f8f9fa',
|
||||
padding: 12,
|
||||
borderRadius: 6,
|
||||
marginBottom: 8,
|
||||
borderLeftWidth: 3,
|
||||
borderLeftColor: '#007AFF',
|
||||
},
|
||||
sampleTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 4,
|
||||
color: '#333',
|
||||
},
|
||||
sampleText: {
|
||||
fontSize: 12,
|
||||
marginBottom: 2,
|
||||
color: '#666',
|
||||
},
|
||||
moreText: {
|
||||
textAlign: 'center',
|
||||
fontSize: 14,
|
||||
color: '#999',
|
||||
fontStyle: 'italic',
|
||||
marginTop: 8,
|
||||
},
|
||||
});
|
||||
|
||||
export default HealthKitTest;
|
||||
229
docs/healthkit-implementation.md
Normal file
229
docs/healthkit-implementation.md
Normal file
@@ -0,0 +1,229 @@
|
||||
# HealthKit Native Module 实现文档
|
||||
|
||||
本文档描述了为React Native应用添加HealthKit支持的完整实现,包括授权和睡眠数据获取功能。
|
||||
|
||||
## 功能概述
|
||||
|
||||
这个native module提供了以下功能:
|
||||
1. **HealthKit授权** - 请求用户授权访问健康数据
|
||||
2. **睡眠数据获取** - 从HealthKit读取用户的睡眠分析数据
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
ios/digitalpilates/
|
||||
├── HealthKitManager.swift # Swift native module实现
|
||||
├── HealthKitManager.m # Objective-C桥接文件
|
||||
├── digitalpilates.entitlements # HealthKit权限配置
|
||||
└── Info.plist # 权限描述
|
||||
|
||||
utils/
|
||||
├── healthKit.ts # TypeScript接口定义
|
||||
├── healthKitExample.ts # 使用示例
|
||||
└── health.ts # 现有健康相关工具
|
||||
```
|
||||
|
||||
## 权限配置
|
||||
|
||||
### 1. Entitlements文件
|
||||
`ios/digitalpilates/digitalpilates.entitlements` 已包含:
|
||||
```xml
|
||||
<key>com.apple.developer.healthkit</key>
|
||||
<true/>
|
||||
<key>com.apple.developer.healthkit.background-delivery</key>
|
||||
<true/>
|
||||
```
|
||||
|
||||
### 2. Info.plist权限描述
|
||||
`ios/digitalpilates/Info.plist` 已包含:
|
||||
```xml
|
||||
<key>NSHealthShareUsageDescription</key>
|
||||
<string>应用需要访问您的健康数据(步数、能量消耗、心率变异性等)以展示运动统计和压力分析。</string>
|
||||
<key>NSHealthUpdateUsageDescription</key>
|
||||
<string>应用需要更新您的健康数据(体重信息)以记录您的健身进度。</string>
|
||||
```
|
||||
|
||||
## Swift实现详情
|
||||
|
||||
### HealthKitManager.swift
|
||||
核心功能实现:
|
||||
|
||||
#### 授权方法
|
||||
```swift
|
||||
@objc func requestAuthorization(
|
||||
_ resolver: @escaping RCTPromiseResolveBlock,
|
||||
rejecter: @escaping RCTPromiseRejectBlock
|
||||
)
|
||||
```
|
||||
|
||||
请求的权限包括:
|
||||
- 睡眠分析 (SleepAnalysis)
|
||||
- 步数 (StepCount)
|
||||
- 心率 (HeartRate)
|
||||
- 静息心率 (RestingHeartRate)
|
||||
- 心率变异性 (HeartRateVariabilitySDNN)
|
||||
- 活动能量消耗 (ActiveEnergyBurned)
|
||||
- 体重 (BodyMass) - 写入权限
|
||||
|
||||
#### 睡眠数据获取方法
|
||||
```swift
|
||||
@objc func getSleepData(
|
||||
_ options: NSDictionary,
|
||||
resolver: @escaping RCTPromiseResolveBlock,
|
||||
rejecter: @escaping RCTPromiseRejectBlock
|
||||
)
|
||||
```
|
||||
|
||||
支持的睡眠阶段:
|
||||
- `inBed` - 在床上
|
||||
- `asleep` - 睡眠(未分类)
|
||||
- `awake` - 清醒
|
||||
- `core` - 核心睡眠
|
||||
- `deep` - 深度睡眠
|
||||
- `rem` - REM睡眠
|
||||
|
||||
## TypeScript接口
|
||||
|
||||
### 主要接口
|
||||
|
||||
```typescript
|
||||
interface HealthKitManagerInterface {
|
||||
requestAuthorization(): Promise<HealthKitAuthorizationResult>;
|
||||
getSleepData(options?: SleepDataOptions): Promise<SleepDataResult>;
|
||||
}
|
||||
```
|
||||
|
||||
### 数据类型
|
||||
|
||||
```typescript
|
||||
interface SleepDataSample {
|
||||
id: string;
|
||||
startDate: string; // ISO8601格式
|
||||
endDate: string; // ISO8601格式
|
||||
value: number;
|
||||
categoryType: 'inBed' | 'asleep' | 'awake' | 'core' | 'deep' | 'rem' | 'unknown';
|
||||
duration: number; // 持续时间(秒)
|
||||
source: SleepDataSource;
|
||||
metadata: Record<string, any>;
|
||||
}
|
||||
```
|
||||
|
||||
## 使用示例
|
||||
|
||||
### 基本用法
|
||||
|
||||
```typescript
|
||||
import HealthKitManager, { HealthKitUtils } from './utils/healthKit';
|
||||
|
||||
// 1. 检查可用性并请求授权
|
||||
const initHealthKit = async () => {
|
||||
if (!HealthKitUtils.isAvailable()) {
|
||||
console.log('HealthKit不可用');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await HealthKitManager.requestAuthorization();
|
||||
console.log('授权结果:', result);
|
||||
return result.success;
|
||||
} catch (error) {
|
||||
console.error('授权失败:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// 2. 获取睡眠数据
|
||||
const getSleepData = async () => {
|
||||
try {
|
||||
const result = await HealthKitManager.getSleepData({
|
||||
startDate: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(), // 7天前
|
||||
endDate: new Date().toISOString(), // 现在
|
||||
limit: 100
|
||||
});
|
||||
|
||||
console.log(`获取到 ${result.count} 条睡眠记录`);
|
||||
return result.data;
|
||||
} catch (error) {
|
||||
console.error('获取睡眠数据失败:', error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### 高级用法
|
||||
|
||||
使用提供的 `HealthKitService` 类:
|
||||
|
||||
```typescript
|
||||
import { HealthKitService } from './utils/healthKitExample';
|
||||
|
||||
// 初始化并获取昨晚睡眠数据
|
||||
const checkLastNightSleep = async () => {
|
||||
const initialized = await HealthKitService.initializeHealthKit();
|
||||
if (!initialized) return;
|
||||
|
||||
const sleepData = await HealthKitService.getLastNightSleep();
|
||||
if (sleepData.hasData) {
|
||||
console.log(`睡眠时间: ${sleepData.bedTime} - ${sleepData.wakeTime}`);
|
||||
console.log(`睡眠时长: ${sleepData.totalDurationFormatted}`);
|
||||
}
|
||||
};
|
||||
|
||||
// 分析一周睡眠质量
|
||||
const analyzeSleep = async () => {
|
||||
const analysis = await HealthKitService.analyzeSleepQuality(7);
|
||||
if (analysis.hasData) {
|
||||
console.log(`平均睡眠: ${analysis.summary.averageSleepFormatted}`);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## 工具函数
|
||||
|
||||
`HealthKitUtils` 类提供了实用的工具方法:
|
||||
|
||||
- `formatDuration(seconds)` - 格式化时长显示
|
||||
- `getTotalSleepDuration(samples, date)` - 计算特定日期的总睡眠时长
|
||||
- `groupSamplesByDate(samples)` - 按日期分组睡眠数据
|
||||
- `getSleepQualityMetrics(samples)` - 分析睡眠质量指标
|
||||
- `isAvailable()` - 检查HealthKit是否可用
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **仅iOS支持** - HealthKit仅在iOS设备上可用,Android设备会返回不可用状态
|
||||
2. **用户权限** - 用户可以拒绝或部分授权,需要优雅处理权限被拒绝的情况
|
||||
3. **数据可用性** - 并非所有用户都有睡眠数据,特别是没有Apple Watch的用户
|
||||
4. **隐私保护** - 严格遵循Apple的隐私指南,只请求必要的权限
|
||||
5. **后台更新** - 已配置后台HealthKit数据传输权限
|
||||
|
||||
## 错误处理
|
||||
|
||||
常见错误类型:
|
||||
- `HEALTHKIT_NOT_AVAILABLE` - HealthKit不可用
|
||||
- `AUTHORIZATION_ERROR` - 授权过程出错
|
||||
- `AUTHORIZATION_DENIED` - 用户拒绝授权
|
||||
- `NOT_AUTHORIZED` - 未授权访问特定数据类型
|
||||
- `QUERY_ERROR` - 数据查询失败
|
||||
|
||||
## 扩展功能
|
||||
|
||||
如需添加更多HealthKit数据类型,可以:
|
||||
|
||||
1. 在Swift文件中的 `readTypes` 数组添加新的数据类型
|
||||
2. 实现对应的查询方法
|
||||
3. 在TypeScript接口中定义新的方法和数据类型
|
||||
4. 更新Objective-C桥接文件暴露新方法
|
||||
|
||||
## 测试建议
|
||||
|
||||
1. 在真实iOS设备上测试(模拟器不支持HealthKit)
|
||||
2. 使用不同的授权状态测试
|
||||
3. 测试没有睡眠数据的情况
|
||||
4. 验证数据格式和时区处理
|
||||
5. 测试错误场景的处理
|
||||
|
||||
## 相关资源
|
||||
|
||||
- [Apple HealthKit文档](https://developer.apple.com/documentation/healthkit)
|
||||
- [React Native Native Modules](https://reactnative.dev/docs/native-modules-ios)
|
||||
- [iOS应用权限指南](https://developer.apple.com/documentation/bundleresources/information_property_list/protected_resources)
|
||||
@@ -3,14 +3,14 @@ PODS:
|
||||
- DoubleConversion (1.1.6)
|
||||
- EXApplication (7.0.7):
|
||||
- ExpoModulesCore
|
||||
- EXConstants (18.0.8):
|
||||
- EXConstants (18.0.9):
|
||||
- ExpoModulesCore
|
||||
- EXImageLoader (6.0.0):
|
||||
- ExpoModulesCore
|
||||
- React-Core
|
||||
- EXNotifications (0.32.11):
|
||||
- ExpoModulesCore
|
||||
- Expo (54.0.7):
|
||||
- Expo (54.0.8):
|
||||
- boost
|
||||
- DoubleConversion
|
||||
- ExpoModulesCore
|
||||
@@ -41,7 +41,7 @@ PODS:
|
||||
- ReactCommon/turbomodule/core
|
||||
- SocketRocket
|
||||
- Yoga
|
||||
- ExpoAppleAuthentication (8.0.6):
|
||||
- ExpoAppleAuthentication (8.0.7):
|
||||
- ExpoModulesCore
|
||||
- ExpoAsset (12.0.8):
|
||||
- ExpoModulesCore
|
||||
@@ -49,7 +49,7 @@ PODS:
|
||||
- ExpoModulesCore
|
||||
- ExpoBlur (15.0.7):
|
||||
- ExpoModulesCore
|
||||
- ExpoCamera (17.0.7):
|
||||
- ExpoCamera (17.0.8):
|
||||
- ExpoModulesCore
|
||||
- ZXingObjC/OneD
|
||||
- ZXingObjC/PDF417
|
||||
@@ -57,11 +57,11 @@ PODS:
|
||||
- ExpoModulesCore
|
||||
- ExpoFont (14.0.8):
|
||||
- ExpoModulesCore
|
||||
- ExpoGlassEffect (0.1.3):
|
||||
- ExpoGlassEffect (0.1.4):
|
||||
- ExpoModulesCore
|
||||
- ExpoHaptics (15.0.6):
|
||||
- ExpoHaptics (15.0.7):
|
||||
- ExpoModulesCore
|
||||
- ExpoHead (6.0.4):
|
||||
- ExpoHead (6.0.6):
|
||||
- ExpoModulesCore
|
||||
- RNScreens
|
||||
- ExpoImage (3.0.8):
|
||||
@@ -71,15 +71,15 @@ PODS:
|
||||
- SDWebImageAVIFCoder (~> 0.11.0)
|
||||
- SDWebImageSVGCoder (~> 1.7.0)
|
||||
- SDWebImageWebPCoder (~> 0.14.6)
|
||||
- ExpoImagePicker (17.0.7):
|
||||
- ExpoImagePicker (17.0.8):
|
||||
- ExpoModulesCore
|
||||
- ExpoKeepAwake (15.0.7):
|
||||
- ExpoModulesCore
|
||||
- ExpoLinearGradient (15.0.6):
|
||||
- ExpoLinearGradient (15.0.7):
|
||||
- ExpoModulesCore
|
||||
- ExpoLinking (8.0.8):
|
||||
- ExpoModulesCore
|
||||
- ExpoModulesCore (3.0.15):
|
||||
- ExpoModulesCore (3.0.16):
|
||||
- boost
|
||||
- DoubleConversion
|
||||
- fast_float
|
||||
@@ -108,19 +108,19 @@ PODS:
|
||||
- ReactCommon/turbomodule/core
|
||||
- SocketRocket
|
||||
- Yoga
|
||||
- ExpoQuickActions (5.0.0):
|
||||
- ExpoQuickActions (6.0.0):
|
||||
- ExpoModulesCore
|
||||
- ExpoSplashScreen (31.0.8):
|
||||
- ExpoSplashScreen (31.0.10):
|
||||
- ExpoModulesCore
|
||||
- ExpoSQLite (16.0.8):
|
||||
- ExpoModulesCore
|
||||
- ExpoSymbols (1.0.6):
|
||||
- ExpoSymbols (1.0.7):
|
||||
- ExpoModulesCore
|
||||
- ExpoSystemUI (6.0.7):
|
||||
- ExpoModulesCore
|
||||
- ExpoUI (0.2.0-beta.2):
|
||||
- ExpoUI (0.2.0-beta.3):
|
||||
- ExpoModulesCore
|
||||
- ExpoWebBrowser (15.0.6):
|
||||
- ExpoWebBrowser (15.0.7):
|
||||
- ExpoModulesCore
|
||||
- EXTaskManager (14.0.7):
|
||||
- ExpoModulesCore
|
||||
@@ -2012,7 +2012,7 @@ PODS:
|
||||
- Yoga
|
||||
- react-native-voice (3.2.4):
|
||||
- React-Core
|
||||
- react-native-webview (13.15.0):
|
||||
- react-native-webview (13.16.0):
|
||||
- boost
|
||||
- DoubleConversion
|
||||
- fast_float
|
||||
@@ -2604,7 +2604,7 @@ PODS:
|
||||
- ReactCommon/turbomodule/core
|
||||
- SocketRocket
|
||||
- Yoga
|
||||
- RNCPicker (2.11.1):
|
||||
- RNCPicker (2.11.2):
|
||||
- boost
|
||||
- DoubleConversion
|
||||
- fast_float
|
||||
@@ -2632,7 +2632,7 @@ PODS:
|
||||
- ReactCommon/turbomodule/core
|
||||
- SocketRocket
|
||||
- Yoga
|
||||
- RNDateTimePicker (8.4.4):
|
||||
- RNDateTimePicker (8.4.5):
|
||||
- boost
|
||||
- DoubleConversion
|
||||
- fast_float
|
||||
@@ -2844,7 +2844,7 @@ PODS:
|
||||
- ReactCommon/turbomodule/core
|
||||
- SocketRocket
|
||||
- Yoga
|
||||
- RNSentry (6.20.0):
|
||||
- RNSentry (7.0.1):
|
||||
- boost
|
||||
- DoubleConversion
|
||||
- fast_float
|
||||
@@ -2874,7 +2874,7 @@ PODS:
|
||||
- Sentry/HybridSDK (= 8.53.2)
|
||||
- SocketRocket
|
||||
- Yoga
|
||||
- RNSVG (15.12.1):
|
||||
- RNSVG (15.13.0):
|
||||
- boost
|
||||
- DoubleConversion
|
||||
- fast_float
|
||||
@@ -2900,10 +2900,10 @@ PODS:
|
||||
- ReactCodegen
|
||||
- ReactCommon/turbomodule/bridging
|
||||
- ReactCommon/turbomodule/core
|
||||
- RNSVG/common (= 15.12.1)
|
||||
- RNSVG/common (= 15.13.0)
|
||||
- SocketRocket
|
||||
- Yoga
|
||||
- RNSVG/common (15.12.1):
|
||||
- RNSVG/common (15.13.0):
|
||||
- boost
|
||||
- DoubleConversion
|
||||
- fast_float
|
||||
@@ -3020,10 +3020,10 @@ PODS:
|
||||
- ReactCommon/turbomodule/core
|
||||
- SocketRocket
|
||||
- Yoga
|
||||
- SDWebImage (5.21.1):
|
||||
- SDWebImage/Core (= 5.21.1)
|
||||
- SDWebImage/Core (5.21.1)
|
||||
- SDWebImageAVIFCoder (0.11.0):
|
||||
- SDWebImage (5.21.2):
|
||||
- SDWebImage/Core (= 5.21.2)
|
||||
- SDWebImage/Core (5.21.2)
|
||||
- SDWebImageAVIFCoder (0.11.1):
|
||||
- libavif/core (>= 0.11.0)
|
||||
- SDWebImage (~> 5.10)
|
||||
- SDWebImageSVGCoder (1.7.0):
|
||||
@@ -3425,33 +3425,33 @@ SPEC CHECKSUMS:
|
||||
boost: 7e761d76ca2ce687f7cc98e698152abd03a18f90
|
||||
DoubleConversion: cb417026b2400c8f53ae97020b2be961b59470cb
|
||||
EXApplication: 296622817d459f46b6c5fe8691f4aac44d2b79e7
|
||||
EXConstants: 7e4654405af367ff908c863fe77a8a22d60bd37d
|
||||
EXConstants: a95804601ee4a6aa7800645f9b070d753b1142b3
|
||||
EXImageLoader: 189e3476581efe3ad4d1d3fb4735b7179eb26f05
|
||||
EXNotifications: 7a2975f4e282b827a0bc78bb1d232650cb569bbd
|
||||
Expo: b7d4314594ebd7fe5eefd1a06c3b0d92b718cde0
|
||||
ExpoAppleAuthentication: 9eb1ec7213ee9c9797951df89975136db89bf8ac
|
||||
Expo: f75c4161ba6b82f264daee5f52a50ac2a55d6d67
|
||||
ExpoAppleAuthentication: bc9de6e9ff3340604213ab9031d4c4f7f802623e
|
||||
ExpoAsset: 84810d6fed8179f04d4a7a4a6b37028bbd726e26
|
||||
ExpoBackgroundTask: 22ed53b129d4d5e15c39be9fa68e45d25f6781a1
|
||||
ExpoBlur: 2dd8f64aa31f5d405652c21d3deb2d2588b1852f
|
||||
ExpoCamera: ae1d6691b05b753261a845536d2b19a9788a8750
|
||||
ExpoCamera: e75f6807a2c047f3338bbadd101af4c71a1d13a5
|
||||
ExpoFileSystem: 4fb06865906e781329eb67166bd64fc4749c3019
|
||||
ExpoFont: 86ceec09ffed1c99cfee36ceb79ba149074901b5
|
||||
ExpoGlassEffect: e48c949ee7dcf2072cca31389bf8fa776c1727a0
|
||||
ExpoHaptics: e0912a9cf05ba958eefdc595f1990b8f89aa1f3f
|
||||
ExpoHead: 2aad68c730f967d2533599dabb64d1d2cd9f765a
|
||||
ExpoGlassEffect: 744bf0c58c26a1b0212dff92856be07b98d01d8c
|
||||
ExpoHaptics: 807476b0c39e9d82b7270349d6487928ce32df84
|
||||
ExpoHead: 78f14a8573ae5b882123b272c0af20a80bfa58f6
|
||||
ExpoImage: e88f500585913969b930e13a4be47277eb7c6de8
|
||||
ExpoImagePicker: 66195293e95879fa5ee3eb1319f10b5de0ffccbb
|
||||
ExpoImagePicker: d251aab45a1b1857e4156fed88511b278b4eee1c
|
||||
ExpoKeepAwake: 1a2e820692e933c94a565ec3fbbe38ac31658ffe
|
||||
ExpoLinearGradient: 74d67832cdb0d2ef91f718d50dd82b273ce2812e
|
||||
ExpoLinearGradient: a464898cb95153125e3b81894fd479bcb1c7dd27
|
||||
ExpoLinking: f051f28e50ea9269ff539317c166adec81d9342d
|
||||
ExpoModulesCore: 5d150c790fb491ab10fe431fb794014af841258f
|
||||
ExpoQuickActions: fdbda7f5874aed3dd2b1d891ec00ab3300dc7541
|
||||
ExpoSplashScreen: 1665809071bd907c6fdbfd9c09583ee4d51b41d4
|
||||
ExpoModulesCore: 654d2976c18a4a764a528928e73c4a25c8eb0e5a
|
||||
ExpoQuickActions: 31a70aa6a606128de4416a4830e09cfabfe6667f
|
||||
ExpoSplashScreen: cbb839de72110dea1851dd3e85080b7923af2540
|
||||
ExpoSQLite: 7fa091ba5562474093fef09be644161a65e11b3f
|
||||
ExpoSymbols: 3efee6865b1955fe3805ca88b36e8674ce6970dd
|
||||
ExpoSymbols: 1ae04ce686de719b9720453b988d8bc5bf776c68
|
||||
ExpoSystemUI: 6cd74248a2282adf6dec488a75fa532d69dee314
|
||||
ExpoUI: 0f109b0549d1ae2fd955d3b8733b290c5cdeec7e
|
||||
ExpoWebBrowser: 84d4438464d9754a4c1f1eaa97cd747f3752187e
|
||||
ExpoUI: 68238da1f16a814f77bc64712a269440174ee898
|
||||
ExpoWebBrowser: 533bc2a1b188eec1c10e4926decf658f1687b5e5
|
||||
EXTaskManager: cf225704fab8de8794a6f57f7fa41a90c0e2cd47
|
||||
fast_float: b32c788ed9c6a8c584d114d0047beda9664e7cc6
|
||||
FBLazyVector: 941bef1c8eeabd9fe1f501e30a5220beee913886
|
||||
@@ -3500,7 +3500,7 @@ SPEC CHECKSUMS:
|
||||
react-native-render-html: 5afc4751f1a98621b3009432ef84c47019dcb2bd
|
||||
react-native-safe-area-context: c6e2edd1c1da07bdce287fa9d9e60c5f7b514616
|
||||
react-native-voice: 908a0eba96c8c3d643e4f98b7232c6557d0a6f9c
|
||||
react-native-webview: 4cbb7f05f2c50671a7dcff4012d3e85faad271e4
|
||||
react-native-webview: 654f794a7686b47491cf43aa67f7f428bea00eed
|
||||
React-NativeModulesApple: 879fbdc5dcff7136abceb7880fe8a2022a1bd7c3
|
||||
React-oscompat: 93b5535ea7f7dff46aaee4f78309a70979bdde9d
|
||||
React-perflogger: 5536d2df3d18fe0920263466f7b46a56351c0510
|
||||
@@ -3535,18 +3535,18 @@ SPEC CHECKSUMS:
|
||||
RNAppleHealthKit: 86ef7ab70f762b802f5c5289372de360cca701f9
|
||||
RNCAsyncStorage: 29f0230e1a25f36c20b05f65e2eb8958d6526e82
|
||||
RNCMaskedView: 5ef8c95cbab95334a32763b72896a7b7d07e6299
|
||||
RNCPicker: 66c392786945ecee5275242c148e6a4601221d3a
|
||||
RNDateTimePicker: cda4c045beca864cebb3209ef9cc4094f974864c
|
||||
RNCPicker: a7e5555ebf53e17e06c1fde62195cf07b685d26c
|
||||
RNDateTimePicker: 113004837aad399a525cd391ac70b7951219ff2f
|
||||
RNDeviceInfo: d863506092aef7e7af3a1c350c913d867d795047
|
||||
RNGestureHandler: 3a73f098d74712952870e948b3d9cf7b6cae9961
|
||||
RNPurchases: 1bc60e3a69af65d9cfe23967328421dd1df1763c
|
||||
RNReanimated: 9de34f0313c4177a34c079ca9fce6f1f278bff24
|
||||
RNScreens: 0bbf16c074ae6bb1058a7bf2d1ae017f4306797c
|
||||
RNSentry: f2c39f1113e22413c9bb6e3faa6b27f110d95eaf
|
||||
RNSVG: 6f39605a4c4d200b11435c35bd077553c6b5963a
|
||||
RNSentry: 6c63debc7b22a00cbf7d1c9ed8de43e336216545
|
||||
RNSVG: 6c39befcfad06eec55b40c19a030b2d9eca63334
|
||||
RNWorklets: ad0606bee2a8103c14adb412149789c60b72bfb2
|
||||
SDWebImage: f29024626962457f3470184232766516dee8dfea
|
||||
SDWebImageAVIFCoder: 00310d246aab3232ce77f1d8f0076f8c4b021d90
|
||||
SDWebImage: 9f177d83116802728e122410fb25ad88f5c7608a
|
||||
SDWebImageAVIFCoder: afe194a084e851f70228e4be35ef651df0fc5c57
|
||||
SDWebImageSVGCoder: 15a300a97ec1c8ac958f009c02220ac0402e936c
|
||||
SDWebImageWebPCoder: e38c0a70396191361d60c092933e22c20d5b1380
|
||||
Sentry: 59993bffde4a1ac297ba6d268dc4bbce068d7c1b
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 60;
|
||||
objectVersion = 70;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
@@ -14,6 +14,8 @@
|
||||
7996A1192E6FB82300371142 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7996A1182E6FB82300371142 /* WidgetKit.framework */; };
|
||||
7996A11B2E6FB82300371142 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7996A11A2E6FB82300371142 /* SwiftUI.framework */; };
|
||||
7996A12C2E6FB82300371142 /* WaterWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 7996A1172E6FB82300371142 /* WaterWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
79B2CB032E7ABBC400B51753 /* HealthKitManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79B2CB012E7ABBC400B51753 /* HealthKitManager.swift */; };
|
||||
79B2CB042E7ABBC400B51753 /* HealthKitManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 79B2CB022E7ABBC400B51753 /* HealthKitManager.m */; };
|
||||
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; };
|
||||
DC3BFC72D3A68C7493D5B44A /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83D1B5F0EC906D7A2F599549 /* ExpoModulesProvider.swift */; };
|
||||
F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11748412D0307B40044C1D9 /* AppDelegate.swift */; };
|
||||
@@ -52,6 +54,8 @@
|
||||
7996A1182E6FB82300371142 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; };
|
||||
7996A11A2E6FB82300371142 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; };
|
||||
7996A1322E6FB84A00371142 /* WaterWidgetExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = WaterWidgetExtension.entitlements; sourceTree = "<group>"; };
|
||||
79B2CB012E7ABBC400B51753 /* HealthKitManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = HealthKitManager.swift; path = digitalpilates/HealthKitManager.swift; sourceTree = "<group>"; };
|
||||
79B2CB022E7ABBC400B51753 /* HealthKitManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = HealthKitManager.m; path = digitalpilates/HealthKitManager.m; sourceTree = "<group>"; };
|
||||
7EC44F9488C227087AA8DF97 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = digitalpilates/PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
|
||||
83D1B5F0EC906D7A2F599549 /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-digitalpilates/ExpoModulesProvider.swift"; sourceTree = "<group>"; };
|
||||
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = digitalpilates/SplashScreen.storyboard; sourceTree = "<group>"; };
|
||||
@@ -64,7 +68,7 @@
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
7996A1302E6FB82300371142 /* Exceptions for "WaterWidget" folder in "WaterWidgetExtension" target */ = {
|
||||
7996A1302E6FB82300371142 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
Info.plist,
|
||||
@@ -74,18 +78,7 @@
|
||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
7996A11C2E6FB82300371142 /* WaterWidget */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
7996A1302E6FB82300371142 /* Exceptions for "WaterWidget" folder in "WaterWidgetExtension" target */,
|
||||
);
|
||||
explicitFileTypes = {
|
||||
};
|
||||
explicitFolders = (
|
||||
);
|
||||
path = WaterWidget;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
7996A11C2E6FB82300371142 /* WaterWidget */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (7996A1302E6FB82300371142 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = WaterWidget; sourceTree = "<group>"; };
|
||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -153,6 +146,8 @@
|
||||
83CBB9F61A601CBA00E9B192 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
79B2CB022E7ABBC400B51753 /* HealthKitManager.m */,
|
||||
79B2CB012E7ABBC400B51753 /* HealthKitManager.swift */,
|
||||
7996A1322E6FB84A00371142 /* WaterWidgetExtension.entitlements */,
|
||||
13B07FAE1A68108700A75B9A /* digitalpilates */,
|
||||
832341AE1AAA6A7D00B99B32 /* Libraries */,
|
||||
@@ -450,6 +445,8 @@
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
79B2CB032E7ABBC400B51753 /* HealthKitManager.swift in Sources */,
|
||||
79B2CB042E7ABBC400B51753 /* HealthKitManager.m in Sources */,
|
||||
F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */,
|
||||
DC3BFC72D3A68C7493D5B44A /* ExpoModulesProvider.swift in Sources */,
|
||||
);
|
||||
@@ -490,7 +487,7 @@
|
||||
);
|
||||
INFOPLIST_FILE = digitalpilates/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Out Live";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.6;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
@@ -528,7 +525,7 @@
|
||||
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = x86_64;
|
||||
INFOPLIST_FILE = digitalpilates/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Out Live";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.6;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
@@ -699,10 +696,7 @@
|
||||
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\"";
|
||||
MTL_ENABLE_DEBUG_INFO = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
" ",
|
||||
);
|
||||
OTHER_LDFLAGS = "$(inherited) ";
|
||||
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG";
|
||||
@@ -757,10 +751,7 @@
|
||||
);
|
||||
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\"";
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
" ",
|
||||
);
|
||||
OTHER_LDFLAGS = "$(inherited) ";
|
||||
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
|
||||
SDKROOT = iphoneos;
|
||||
USE_HERMES = true;
|
||||
|
||||
22
ios/digitalpilates/HealthKitManager.m
Normal file
22
ios/digitalpilates/HealthKitManager.m
Normal file
@@ -0,0 +1,22 @@
|
||||
//
|
||||
// HealthKitManager.m
|
||||
// digitalpilates
|
||||
//
|
||||
// React Native bridge for HealthKitManager
|
||||
//
|
||||
|
||||
#import <React/RCTBridgeModule.h>
|
||||
|
||||
@interface RCT_EXTERN_MODULE(HealthKitManager, NSObject)
|
||||
|
||||
// Authorization method
|
||||
RCT_EXTERN_METHOD(requestAuthorization:(RCTPromiseResolveBlock)resolver
|
||||
rejecter:(RCTPromiseRejectBlock)rejecter)
|
||||
|
||||
// Sleep data method
|
||||
RCT_EXTERN_METHOD(getSleepData:(NSDictionary *)options
|
||||
resolver:(RCTPromiseResolveBlock)resolver
|
||||
rejecter:(RCTPromiseRejectBlock)rejecter)
|
||||
|
||||
|
||||
@end
|
||||
216
ios/digitalpilates/HealthKitManager.swift
Normal file
216
ios/digitalpilates/HealthKitManager.swift
Normal file
@@ -0,0 +1,216 @@
|
||||
//
|
||||
// HealthKitManager.swift
|
||||
// digitalpilates
|
||||
//
|
||||
// Native module for HealthKit authorization and sleep data access
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import React
|
||||
import HealthKit
|
||||
|
||||
@objc(HealthKitManager)
|
||||
class HealthKitManager: NSObject, RCTBridgeModule {
|
||||
|
||||
// HealthKit store instance
|
||||
private let healthStore = HKHealthStore()
|
||||
|
||||
static func moduleName() -> String! {
|
||||
return "HealthKitManager"
|
||||
}
|
||||
|
||||
static func requiresMainQueueSetup() -> Bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: - Authorization
|
||||
|
||||
@objc
|
||||
func requestAuthorization(
|
||||
_ resolver: @escaping RCTPromiseResolveBlock,
|
||||
rejecter: @escaping RCTPromiseRejectBlock
|
||||
) {
|
||||
|
||||
// Check if HealthKit is available on the device
|
||||
guard HKHealthStore.isHealthDataAvailable() else {
|
||||
rejecter("HEALTHKIT_NOT_AVAILABLE", "HealthKit is not available on this device", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Define the data types we want to read
|
||||
let readTypes: Set<HKObjectType> = [
|
||||
HKObjectType.categoryType(forIdentifier: .sleepAnalysis)!,
|
||||
HKObjectType.quantityType(forIdentifier: .stepCount)!,
|
||||
HKObjectType.quantityType(forIdentifier: .heartRate)!,
|
||||
HKObjectType.quantityType(forIdentifier: .restingHeartRate)!,
|
||||
HKObjectType.quantityType(forIdentifier: .heartRateVariabilitySDNN)!,
|
||||
HKObjectType.quantityType(forIdentifier: .activeEnergyBurned)!
|
||||
]
|
||||
|
||||
// Define the data types we want to write (if any)
|
||||
let writeTypes: Set<HKSampleType> = [
|
||||
HKObjectType.quantityType(forIdentifier: .bodyMass)!
|
||||
]
|
||||
|
||||
// Request authorization
|
||||
healthStore.requestAuthorization(toShare: writeTypes, read: readTypes) { [weak self] (success, error) in
|
||||
DispatchQueue.main.async {
|
||||
if let error = error {
|
||||
rejecter("AUTHORIZATION_ERROR", "Failed to authorize HealthKit: \(error.localizedDescription)", error)
|
||||
return
|
||||
}
|
||||
|
||||
if success {
|
||||
// Check individual permissions
|
||||
var permissions: [String: Any] = [:]
|
||||
|
||||
for type in readTypes {
|
||||
let status = self?.healthStore.authorizationStatus(for: type)
|
||||
let statusString = self?.authorizationStatusToString(status)
|
||||
permissions[type.identifier] = statusString
|
||||
}
|
||||
|
||||
let result: [String: Any] = [
|
||||
"success": true,
|
||||
"permissions": permissions
|
||||
]
|
||||
|
||||
resolver(result)
|
||||
} else {
|
||||
rejecter("AUTHORIZATION_DENIED", "User denied HealthKit authorization", nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sleep Data
|
||||
|
||||
@objc
|
||||
func getSleepData(
|
||||
_ options: NSDictionary,
|
||||
resolver: @escaping RCTPromiseResolveBlock,
|
||||
rejecter: @escaping RCTPromiseRejectBlock
|
||||
) {
|
||||
|
||||
guard HKHealthStore.isHealthDataAvailable() else {
|
||||
rejecter("HEALTHKIT_NOT_AVAILABLE", "HealthKit is not available on this device", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Check authorization status for sleep analysis
|
||||
let sleepType = HKObjectType.categoryType(forIdentifier: .sleepAnalysis)!
|
||||
let authStatus = healthStore.authorizationStatus(for: sleepType)
|
||||
|
||||
guard authStatus == .sharingAuthorized else {
|
||||
rejecter("NOT_AUTHORIZED", "Not authorized to read sleep data", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse options
|
||||
let startDate = parseDate(from: options["startDate"] as? String) ?? Calendar.current.date(byAdding: .day, value: -7, to: Date())!
|
||||
let endDate = parseDate(from: options["endDate"] as? String) ?? Date()
|
||||
let limit = options["limit"] as? Int ?? 100
|
||||
|
||||
// Create predicate for date range
|
||||
let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
|
||||
|
||||
// Create sort descriptor to get latest data first
|
||||
let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierEndDate, ascending: false)
|
||||
|
||||
// Create query
|
||||
let query = HKSampleQuery(
|
||||
sampleType: sleepType,
|
||||
predicate: predicate,
|
||||
limit: limit,
|
||||
sortDescriptors: [sortDescriptor]
|
||||
) { [weak self] (query, samples, error) in
|
||||
|
||||
DispatchQueue.main.async {
|
||||
if let error = error {
|
||||
rejecter("QUERY_ERROR", "Failed to query sleep data: \(error.localizedDescription)", error)
|
||||
return
|
||||
}
|
||||
|
||||
guard let sleepSamples = samples as? [HKCategorySample] else {
|
||||
resolver([])
|
||||
return
|
||||
}
|
||||
|
||||
let sleepData = sleepSamples.map { sample in
|
||||
return [
|
||||
"id": sample.uuid.uuidString,
|
||||
"startDate": self?.dateToISOString(sample.startDate) ?? "",
|
||||
"endDate": self?.dateToISOString(sample.endDate) ?? "",
|
||||
"value": sample.value,
|
||||
"categoryType": self?.sleepValueToString(sample.value) ?? "unknown",
|
||||
"duration": sample.endDate.timeIntervalSince(sample.startDate),
|
||||
"source": [
|
||||
"name": sample.sourceRevision.source.name,
|
||||
"bundleIdentifier": sample.sourceRevision.source.bundleIdentifier
|
||||
],
|
||||
"metadata": sample.metadata ?? [:]
|
||||
]
|
||||
}
|
||||
|
||||
let result: [String: Any] = [
|
||||
"data": sleepData,
|
||||
"count": sleepData.count,
|
||||
"startDate": self?.dateToISOString(startDate) ?? "",
|
||||
"endDate": self?.dateToISOString(endDate) ?? ""
|
||||
]
|
||||
|
||||
resolver(result)
|
||||
}
|
||||
}
|
||||
|
||||
healthStore.execute(query)
|
||||
}
|
||||
|
||||
// MARK: - Helper Methods
|
||||
|
||||
private func authorizationStatusToString(_ status: HKAuthorizationStatus?) -> String {
|
||||
guard let status = status else { return "notDetermined" }
|
||||
|
||||
switch status {
|
||||
case .notDetermined:
|
||||
return "notDetermined"
|
||||
case .sharingDenied:
|
||||
return "denied"
|
||||
case .sharingAuthorized:
|
||||
return "authorized"
|
||||
@unknown default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
private func sleepValueToString(_ value: Int) -> String {
|
||||
switch value {
|
||||
case HKCategoryValueSleepAnalysis.inBed.rawValue:
|
||||
return "inBed"
|
||||
case HKCategoryValueSleepAnalysis.asleepUnspecified.rawValue:
|
||||
return "asleep"
|
||||
case HKCategoryValueSleepAnalysis.awake.rawValue:
|
||||
return "awake"
|
||||
case HKCategoryValueSleepAnalysis.asleepCore.rawValue:
|
||||
return "core"
|
||||
case HKCategoryValueSleepAnalysis.asleepDeep.rawValue:
|
||||
return "deep"
|
||||
case HKCategoryValueSleepAnalysis.asleepREM.rawValue:
|
||||
return "rem"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
private func parseDate(from string: String?) -> Date? {
|
||||
guard let string = string else { return nil }
|
||||
|
||||
let formatter = ISO8601DateFormatter()
|
||||
return formatter.date(from: string)
|
||||
}
|
||||
|
||||
private func dateToISOString(_ date: Date) -> String {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
//
|
||||
// Use this file to import your target's public headers that you would like to expose to Swift.
|
||||
//
|
||||
#import <React/RCTBridgeModule.h>
|
||||
#import <React/RCTViewManager.h>
|
||||
600
package-lock.json
generated
600
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
54
package.json
54
package.json
@@ -12,44 +12,44 @@
|
||||
"lint": "expo lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/ui": "~0.2.0-beta.2",
|
||||
"@expo/ui": "~0.2.0-beta.3",
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
"@react-native-async-storage/async-storage": "^2.2.0",
|
||||
"@react-native-community/datetimepicker": "^8.4.4",
|
||||
"@react-native-community/datetimepicker": "8.4.4",
|
||||
"@react-native-masked-view/masked-view": "^0.3.2",
|
||||
"@react-native-picker/picker": "^2.11.1",
|
||||
"@react-native-picker/picker": "2.11.1",
|
||||
"@react-native-voice/voice": "^3.2.4",
|
||||
"@react-navigation/bottom-tabs": "^7.4.7",
|
||||
"@react-navigation/bottom-tabs": "^7.4.0",
|
||||
"@react-navigation/elements": "^2.6.4",
|
||||
"@react-navigation/native": "^7.1.17",
|
||||
"@react-navigation/native": "^7.1.8",
|
||||
"@reduxjs/toolkit": "^2.9.0",
|
||||
"@sentry/react-native": "~6.20.0",
|
||||
"@types/lodash": "^4.17.20",
|
||||
"cos-js-sdk-v5": "^1.6.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"expo": "^54.0.7",
|
||||
"expo-apple-authentication": "~8.0.6",
|
||||
"dayjs": "^1.11.18",
|
||||
"expo": "^54.0.8",
|
||||
"expo-apple-authentication": "~8.0.7",
|
||||
"expo-background-task": "~1.0.7",
|
||||
"expo-blur": "~15.0.7",
|
||||
"expo-camera": "~17.0.7",
|
||||
"expo-constants": "~18.0.8",
|
||||
"expo-camera": "~17.0.8",
|
||||
"expo-constants": "~18.0.9",
|
||||
"expo-font": "~14.0.8",
|
||||
"expo-glass-effect": "^0.1.3",
|
||||
"expo-haptics": "~15.0.6",
|
||||
"expo-glass-effect": "^0.1.4",
|
||||
"expo-haptics": "~15.0.7",
|
||||
"expo-image": "~3.0.8",
|
||||
"expo-image-picker": "~17.0.7",
|
||||
"expo-linear-gradient": "~15.0.6",
|
||||
"expo-image-picker": "~17.0.8",
|
||||
"expo-linear-gradient": "~15.0.7",
|
||||
"expo-linking": "~8.0.8",
|
||||
"expo-notifications": "~0.32.11",
|
||||
"expo-quick-actions": "^5.0.0",
|
||||
"expo-router": "~6.0.4",
|
||||
"expo-splash-screen": "~31.0.8",
|
||||
"expo-quick-actions": "^6.0.0",
|
||||
"expo-router": "~6.0.6",
|
||||
"expo-splash-screen": "~31.0.10",
|
||||
"expo-sqlite": "^16.0.8",
|
||||
"expo-status-bar": "~3.0.7",
|
||||
"expo-symbols": "~1.0.6",
|
||||
"expo-status-bar": "~3.0.8",
|
||||
"expo-symbols": "~1.0.7",
|
||||
"expo-system-ui": "~6.0.7",
|
||||
"expo-task-manager": "~14.0.6",
|
||||
"expo-web-browser": "~15.0.6",
|
||||
"expo-web-browser": "~15.0.7",
|
||||
"lodash": "^4.17.21",
|
||||
"lottie-react-native": "^7.3.4",
|
||||
"react": "19.1.0",
|
||||
@@ -62,22 +62,22 @@
|
||||
"react-native-markdown-display": "^7.0.2",
|
||||
"react-native-modal-datetime-picker": "^18.0.0",
|
||||
"react-native-popover-view": "^6.1.0",
|
||||
"react-native-purchases": "^9.4.3",
|
||||
"react-native-purchases": "^9.2.2",
|
||||
"react-native-reanimated": "~4.1.0",
|
||||
"react-native-render-html": "^6.3.4",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-safe-area-context": "~5.6.1",
|
||||
"react-native-screens": "~4.16.0",
|
||||
"react-native-svg": "^15.12.1",
|
||||
"react-native-svg": "15.12.1",
|
||||
"react-native-toast-message": "^2.3.3",
|
||||
"react-native-web": "^0.21.0",
|
||||
"react-native-web": "^0.21.1",
|
||||
"react-native-webview": "13.15.0",
|
||||
"react-native-wheel-picker-expo": "^0.5.4",
|
||||
"react-redux": "^9.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.25.2",
|
||||
"@types/react": "~19.1.12",
|
||||
"eslint": "^9.25.0",
|
||||
"@babel/core": "^7.28.4",
|
||||
"@types/react": "~19.1.13",
|
||||
"eslint": "^9.35.0",
|
||||
"eslint-config-expo": "~10.0.0",
|
||||
"typescript": "~5.9.2"
|
||||
},
|
||||
|
||||
@@ -73,7 +73,6 @@ export async function ensureHealthPermissions(): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
console.log('开始初始化HealthKit...');
|
||||
|
||||
resolve(true)
|
||||
AppleHealthKit.initHealthKit(PERMISSIONS, (error) => {
|
||||
if (error) {
|
||||
console.error('HealthKit初始化失败:', error);
|
||||
|
||||
147
utils/healthKit.ts
Normal file
147
utils/healthKit.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* HealthKit Native Module Interface
|
||||
* React Native TypeScript bindings for iOS HealthKit access
|
||||
*/
|
||||
|
||||
import { NativeModules } from 'react-native';
|
||||
|
||||
export interface HealthKitPermissions {
|
||||
[key: string]: 'notDetermined' | 'denied' | 'authorized' | 'unknown';
|
||||
}
|
||||
|
||||
export interface HealthKitAuthorizationResult {
|
||||
success: boolean;
|
||||
permissions: HealthKitPermissions;
|
||||
}
|
||||
|
||||
export interface SleepDataSource {
|
||||
name: string;
|
||||
bundleIdentifier: string;
|
||||
}
|
||||
|
||||
export interface SleepDataSample {
|
||||
id: string;
|
||||
startDate: string; // ISO8601 format
|
||||
endDate: string; // ISO8601 format
|
||||
value: number;
|
||||
categoryType: 'inBed' | 'asleep' | 'awake' | 'core' | 'deep' | 'rem' | 'unknown';
|
||||
duration: number; // Duration in seconds
|
||||
source: SleepDataSource;
|
||||
metadata: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface SleepDataOptions {
|
||||
startDate?: string; // ISO8601 format, defaults to 7 days ago
|
||||
endDate?: string; // ISO8601 format, defaults to now
|
||||
limit?: number; // Maximum number of samples, defaults to 100
|
||||
}
|
||||
|
||||
export interface SleepDataResult {
|
||||
data: SleepDataSample[];
|
||||
count: number;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
}
|
||||
|
||||
export interface HealthKitManagerInterface {
|
||||
/**
|
||||
* Request authorization to access HealthKit data
|
||||
* This will prompt the user for permission to read/write health data
|
||||
*/
|
||||
requestAuthorization(): Promise<HealthKitAuthorizationResult>;
|
||||
|
||||
/**
|
||||
* Get sleep analysis data from HealthKit
|
||||
* @param options Query options including date range and limit
|
||||
*/
|
||||
getSleepData(options?: SleepDataOptions): Promise<SleepDataResult>;
|
||||
}
|
||||
|
||||
console.log('NativeModules', NativeModules);
|
||||
|
||||
|
||||
// Native module interface
|
||||
const HealthKitManager: HealthKitManagerInterface = NativeModules.HealthKitManager;
|
||||
|
||||
export default HealthKitManager;
|
||||
|
||||
// Utility functions for working with sleep data
|
||||
export class HealthKitUtils {
|
||||
/**
|
||||
* Convert seconds to hours and minutes
|
||||
*/
|
||||
static formatDuration(seconds: number): string {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes}m`;
|
||||
}
|
||||
return `${minutes}m`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total sleep duration from sleep samples for a specific date
|
||||
*/
|
||||
static getTotalSleepDuration(samples: SleepDataSample[], date: Date): number {
|
||||
const targetDate = date.toISOString().split('T')[0];
|
||||
|
||||
return samples
|
||||
.filter(sample => {
|
||||
const sampleDate = new Date(sample.startDate).toISOString().split('T')[0];
|
||||
return sampleDate === targetDate &&
|
||||
['asleep', 'core', 'deep', 'rem'].includes(sample.categoryType);
|
||||
})
|
||||
.reduce((total, sample) => total + sample.duration, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Group sleep samples by date
|
||||
*/
|
||||
static groupSamplesByDate(samples: SleepDataSample[]): Record<string, SleepDataSample[]> {
|
||||
return samples.reduce((grouped, sample) => {
|
||||
const date = new Date(sample.startDate).toISOString().split('T')[0];
|
||||
if (!grouped[date]) {
|
||||
grouped[date] = [];
|
||||
}
|
||||
grouped[date].push(sample);
|
||||
return grouped;
|
||||
}, {} as Record<string, SleepDataSample[]>);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get sleep quality metrics from samples
|
||||
*/
|
||||
static getSleepQualityMetrics(samples: SleepDataSample[]) {
|
||||
const sleepSamples = samples.filter(s =>
|
||||
['asleep', 'core', 'deep', 'rem'].includes(s.categoryType)
|
||||
);
|
||||
|
||||
if (sleepSamples.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const totalDuration = sleepSamples.reduce((sum, s) => sum + s.duration, 0);
|
||||
const deepSleepDuration = sleepSamples
|
||||
.filter(s => s.categoryType === 'deep')
|
||||
.reduce((sum, s) => sum + s.duration, 0);
|
||||
const remSleepDuration = sleepSamples
|
||||
.filter(s => s.categoryType === 'rem')
|
||||
.reduce((sum, s) => sum + s.duration, 0);
|
||||
|
||||
return {
|
||||
totalDuration,
|
||||
deepSleepDuration,
|
||||
remSleepDuration,
|
||||
deepSleepPercentage: totalDuration > 0 ? (deepSleepDuration / totalDuration) * 100 : 0,
|
||||
remSleepPercentage: totalDuration > 0 ? (remSleepDuration / totalDuration) * 100 : 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if HealthKit is available (iOS only)
|
||||
*/
|
||||
static isAvailable(): boolean {
|
||||
return HealthKitManager != null;
|
||||
}
|
||||
}
|
||||
236
utils/healthKitExample.ts
Normal file
236
utils/healthKitExample.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* HealthKit Native Module Usage Example
|
||||
* 展示如何使用HealthKit native module的示例代码
|
||||
*/
|
||||
|
||||
import HealthKitManager, { HealthKitUtils, SleepDataSample } from './healthKit';
|
||||
|
||||
export class HealthKitService {
|
||||
|
||||
/**
|
||||
* 初始化HealthKit并请求权限
|
||||
*/
|
||||
static async initializeHealthKit(): Promise<boolean> {
|
||||
try {
|
||||
// 检查HealthKit是否可用
|
||||
if (!HealthKitUtils.isAvailable()) {
|
||||
console.log('HealthKit不可用,可能运行在Android设备或模拟器上');
|
||||
return false;
|
||||
}
|
||||
|
||||
// 请求授权
|
||||
const result = await HealthKitManager.requestAuthorization();
|
||||
|
||||
if (result.success) {
|
||||
console.log('HealthKit授权成功');
|
||||
console.log('权限状态:', result.permissions);
|
||||
|
||||
// 检查睡眠数据权限
|
||||
const sleepPermission = result.permissions['HKCategoryTypeIdentifierSleepAnalysis'];
|
||||
if (sleepPermission === 'authorized') {
|
||||
console.log('睡眠数据访问权限已获得');
|
||||
return true;
|
||||
} else {
|
||||
console.log('睡眠数据访问权限未获得:', sleepPermission);
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
console.log('HealthKit授权失败');
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('HealthKit初始化失败:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取最近的睡眠数据
|
||||
*/
|
||||
static async getRecentSleepData(days: number = 7): Promise<SleepDataSample[]> {
|
||||
try {
|
||||
const endDate = new Date();
|
||||
const startDate = new Date();
|
||||
startDate.setDate(endDate.getDate() - days);
|
||||
|
||||
const result = await HealthKitManager.getSleepData({
|
||||
startDate: startDate.toISOString(),
|
||||
endDate: endDate.toISOString(),
|
||||
limit: 100
|
||||
});
|
||||
|
||||
console.log(`获取到 ${result.count} 条睡眠记录`);
|
||||
return result.data;
|
||||
} catch (error) {
|
||||
console.error('获取睡眠数据失败:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 分析睡眠质量
|
||||
*/
|
||||
static async analyzeSleepQuality(days: number = 7): Promise<any> {
|
||||
try {
|
||||
const sleepData = await this.getRecentSleepData(days);
|
||||
|
||||
if (sleepData.length === 0) {
|
||||
return {
|
||||
error: '没有找到睡眠数据',
|
||||
hasData: false
|
||||
};
|
||||
}
|
||||
|
||||
// 按日期分组
|
||||
const groupedData = HealthKitUtils.groupSamplesByDate(sleepData);
|
||||
const dates = Object.keys(groupedData).sort().reverse(); // 最新的日期在前
|
||||
|
||||
const analysis = dates.slice(0, days).map(date => {
|
||||
const daySamples = groupedData[date];
|
||||
const totalSleepDuration = HealthKitUtils.getTotalSleepDuration(daySamples, new Date(date));
|
||||
const qualityMetrics = HealthKitUtils.getSleepQualityMetrics(daySamples);
|
||||
|
||||
return {
|
||||
date,
|
||||
totalSleepDuration,
|
||||
totalSleepFormatted: HealthKitUtils.formatDuration(totalSleepDuration),
|
||||
qualityMetrics,
|
||||
samplesCount: daySamples.length
|
||||
};
|
||||
});
|
||||
|
||||
// 计算平均值
|
||||
const validDays = analysis.filter(day => day.totalSleepDuration > 0);
|
||||
const averageSleepDuration = validDays.length > 0
|
||||
? validDays.reduce((sum, day) => sum + day.totalSleepDuration, 0) / validDays.length
|
||||
: 0;
|
||||
|
||||
return {
|
||||
hasData: true,
|
||||
days: analysis,
|
||||
summary: {
|
||||
averageSleepDuration,
|
||||
averageSleepFormatted: HealthKitUtils.formatDuration(averageSleepDuration),
|
||||
daysWithData: validDays.length,
|
||||
totalDaysAnalyzed: days
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('睡眠质量分析失败:', error);
|
||||
return {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
hasData: false
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取昨晚的睡眠数据
|
||||
*/
|
||||
static async getLastNightSleep(): Promise<any> {
|
||||
try {
|
||||
const today = new Date();
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(today.getDate() - 1);
|
||||
|
||||
// 设置查询范围:昨天下午6点到今天上午12点
|
||||
const startDate = new Date(yesterday);
|
||||
startDate.setHours(18, 0, 0, 0);
|
||||
|
||||
const endDate = new Date(today);
|
||||
endDate.setHours(12, 0, 0, 0);
|
||||
|
||||
const result = await HealthKitManager.getSleepData({
|
||||
startDate: startDate.toISOString(),
|
||||
endDate: endDate.toISOString(),
|
||||
limit: 50
|
||||
});
|
||||
|
||||
if (result.data.length === 0) {
|
||||
return {
|
||||
hasData: false,
|
||||
message: '未找到昨晚的睡眠数据'
|
||||
};
|
||||
}
|
||||
|
||||
const sleepSamples = result.data.filter(sample =>
|
||||
['asleep', 'core', 'deep', 'rem'].includes(sample.categoryType)
|
||||
);
|
||||
|
||||
if (sleepSamples.length === 0) {
|
||||
return {
|
||||
hasData: false,
|
||||
message: '未找到有效的睡眠阶段数据'
|
||||
};
|
||||
}
|
||||
|
||||
// 找到睡眠的开始和结束时间
|
||||
const sleepStart = new Date(Math.min(...sleepSamples.map(s => new Date(s.startDate).getTime())));
|
||||
const sleepEnd = new Date(Math.max(...sleepSamples.map(s => new Date(s.endDate).getTime())));
|
||||
const totalDuration = sleepSamples.reduce((sum, s) => sum + s.duration, 0);
|
||||
|
||||
const qualityMetrics = HealthKitUtils.getSleepQualityMetrics(sleepSamples);
|
||||
|
||||
return {
|
||||
hasData: true,
|
||||
sleepStart: sleepStart.toISOString(),
|
||||
sleepEnd: sleepEnd.toISOString(),
|
||||
totalDuration,
|
||||
totalDurationFormatted: HealthKitUtils.formatDuration(totalDuration),
|
||||
qualityMetrics,
|
||||
samples: sleepSamples,
|
||||
bedTime: sleepStart.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }),
|
||||
wakeTime: sleepEnd.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('获取昨晚睡眠数据失败:', error);
|
||||
return {
|
||||
hasData: false,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 使用示例
|
||||
export const useHealthKitExample = async () => {
|
||||
console.log('=== HealthKit 使用示例 ===');
|
||||
|
||||
// 1. 初始化和授权
|
||||
const initialized = await HealthKitService.initializeHealthKit();
|
||||
if (!initialized) {
|
||||
console.log('HealthKit初始化失败,无法继续');
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 获取昨晚的睡眠数据
|
||||
console.log('\n--- 昨晚睡眠数据 ---');
|
||||
const lastNightSleep = await HealthKitService.getLastNightSleep();
|
||||
if (lastNightSleep.hasData) {
|
||||
console.log(`睡眠时间: ${lastNightSleep.bedTime} - ${lastNightSleep.wakeTime}`);
|
||||
console.log(`睡眠时长: ${lastNightSleep.totalDurationFormatted}`);
|
||||
if (lastNightSleep.qualityMetrics) {
|
||||
console.log(`深睡眠: ${lastNightSleep.qualityMetrics.deepSleepPercentage.toFixed(1)}%`);
|
||||
console.log(`REM睡眠: ${lastNightSleep.qualityMetrics.remSleepPercentage.toFixed(1)}%`);
|
||||
}
|
||||
} else {
|
||||
console.log(lastNightSleep.message || '未找到睡眠数据');
|
||||
}
|
||||
|
||||
// 3. 分析最近一周的睡眠质量
|
||||
console.log('\n--- 最近一周睡眠分析 ---');
|
||||
const weeklyAnalysis = await HealthKitService.analyzeSleepQuality(7);
|
||||
if (weeklyAnalysis.hasData) {
|
||||
console.log(`平均睡眠时长: ${weeklyAnalysis.summary.averageSleepFormatted}`);
|
||||
console.log(`有数据的天数: ${weeklyAnalysis.summary.daysWithData}/${weeklyAnalysis.summary.totalDaysAnalyzed}`);
|
||||
|
||||
console.log('\n每日睡眠详情:');
|
||||
weeklyAnalysis.days.forEach((day: any) => {
|
||||
if (day.totalSleepDuration > 0) {
|
||||
console.log(`${day.date}: ${day.totalSleepFormatted}`);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
console.log(weeklyAnalysis.error || '睡眠分析失败');
|
||||
}
|
||||
};
|
||||
@@ -1,6 +1,5 @@
|
||||
import dayjs from 'dayjs';
|
||||
import HealthKit from 'react-native-health';
|
||||
import { ensureHealthPermissions } from './health';
|
||||
import HealthKitManager, { HealthKitUtils } from './healthKit';
|
||||
|
||||
// 睡眠阶段枚举
|
||||
export enum SleepStage {
|
||||
@@ -116,54 +115,54 @@ export const fetchSleepSamples = async (date: Date): Promise<SleepSample[]> => {
|
||||
endDate: dateRange.endDate.toISOString(),
|
||||
};
|
||||
|
||||
return new Promise((resolve) => {
|
||||
HealthKit.getSleepSamples(options, (error: string, results: any[]) => {
|
||||
if (error) {
|
||||
console.error('[Sleep] 获取睡眠数据失败:', error);
|
||||
resolve([]); // 返回空数组而非拒绝,以便于处理
|
||||
return;
|
||||
}
|
||||
// return new Promise((resolve) => {
|
||||
// HealthKitManager.(options, (error: string, results: any[]) => {
|
||||
// if (error) {
|
||||
// console.error('[Sleep] 获取睡眠数据失败:', error);
|
||||
// resolve([]); // 返回空数组而非拒绝,以便于处理
|
||||
// return;
|
||||
// }
|
||||
|
||||
if (!results || results.length === 0) {
|
||||
console.warn('[Sleep] 未找到睡眠数据');
|
||||
resolve([]);
|
||||
return;
|
||||
}
|
||||
// if (!results || results.length === 0) {
|
||||
// console.warn('[Sleep] 未找到睡眠数据');
|
||||
// resolve([]);
|
||||
// return;
|
||||
// }
|
||||
|
||||
console.log('[Sleep] 获取到原始睡眠样本:', results.length, '条');
|
||||
// console.log('[Sleep] 获取到原始睡眠样本:', results.length, '条');
|
||||
|
||||
// 过滤并转换数据格式
|
||||
const sleepSamples: SleepSample[] = results
|
||||
.filter(sample => {
|
||||
const sampleStart = new Date(sample.startDate).getTime();
|
||||
const sampleEnd = new Date(sample.endDate).getTime();
|
||||
const rangeStart = dateRange.startDate.getTime();
|
||||
const rangeEnd = dateRange.endDate.getTime();
|
||||
// // 过滤并转换数据格式
|
||||
// const sleepSamples: SleepSample[] = results
|
||||
// .filter(sample => {
|
||||
// const sampleStart = new Date(sample.startDate).getTime();
|
||||
// const sampleEnd = new Date(sample.endDate).getTime();
|
||||
// const rangeStart = dateRange.startDate.getTime();
|
||||
// const rangeEnd = dateRange.endDate.getTime();
|
||||
|
||||
return (sampleStart >= rangeStart && sampleStart < rangeEnd) ||
|
||||
(sampleStart < rangeEnd && sampleEnd > rangeStart);
|
||||
})
|
||||
.map(sample => {
|
||||
console.log('[Sleep] 原始睡眠样本:', {
|
||||
startDate: sample.startDate,
|
||||
endDate: sample.endDate,
|
||||
value: sample.value,
|
||||
sourceName: sample.sourceName
|
||||
});
|
||||
// return (sampleStart >= rangeStart && sampleStart < rangeEnd) ||
|
||||
// (sampleStart < rangeEnd && sampleEnd > rangeStart);
|
||||
// })
|
||||
// .map(sample => {
|
||||
// console.log('[Sleep] 原始睡眠样本:', {
|
||||
// startDate: sample.startDate,
|
||||
// endDate: sample.endDate,
|
||||
// value: sample.value,
|
||||
// sourceName: sample.sourceName
|
||||
// });
|
||||
|
||||
return {
|
||||
startDate: sample.startDate,
|
||||
endDate: sample.endDate,
|
||||
value: mapHealthKitSleepValue(sample.value),
|
||||
sourceName: sample.sourceName,
|
||||
sourceId: sample.sourceId || sample.uuid
|
||||
};
|
||||
});
|
||||
// return {
|
||||
// startDate: sample.startDate,
|
||||
// endDate: sample.endDate,
|
||||
// value: mapHealthKitSleepValue(sample.value),
|
||||
// sourceName: sample.sourceName,
|
||||
// sourceId: sample.sourceId || sample.uuid
|
||||
// };
|
||||
// });
|
||||
|
||||
console.log('[Sleep] 过滤后的睡眠样本:', sleepSamples.length, '条');
|
||||
resolve(sleepSamples);
|
||||
});
|
||||
});
|
||||
// console.log('[Sleep] 过滤后的睡眠样本:', sleepSamples.length, '条');
|
||||
// resolve(sleepSamples);
|
||||
// });
|
||||
// });
|
||||
|
||||
} catch (error) {
|
||||
console.error('[Sleep] 获取睡眠样本失败:', error);
|
||||
@@ -181,37 +180,37 @@ export const fetchSleepHeartRateData = async (bedtime: string, wakeupTime: strin
|
||||
endDate: wakeupTime,
|
||||
};
|
||||
|
||||
return new Promise((resolve) => {
|
||||
HealthKit.getHeartRateSamples(options, (error: string, results: any[]) => {
|
||||
if (error) {
|
||||
console.error('[Sleep] 获取心率数据失败:', error);
|
||||
resolve([]);
|
||||
return;
|
||||
}
|
||||
// return new Promise((resolve) => {
|
||||
// HealthKit.getHeartRateSamples(options, (error: string, results: any[]) => {
|
||||
// if (error) {
|
||||
// console.error('[Sleep] 获取心率数据失败:', error);
|
||||
// resolve([]);
|
||||
// return;
|
||||
// }
|
||||
|
||||
if (!results || results.length === 0) {
|
||||
console.log('[Sleep] 未找到心率数据');
|
||||
resolve([]);
|
||||
return;
|
||||
}
|
||||
// if (!results || results.length === 0) {
|
||||
// console.log('[Sleep] 未找到心率数据');
|
||||
// resolve([]);
|
||||
// return;
|
||||
// }
|
||||
|
||||
const heartRateData: HeartRateData[] = results
|
||||
.filter(sample => {
|
||||
const sampleTime = new Date(sample.startDate).getTime();
|
||||
const bedtimeMs = new Date(bedtime).getTime();
|
||||
const wakeupTimeMs = new Date(wakeupTime).getTime();
|
||||
return sampleTime >= bedtimeMs && sampleTime <= wakeupTimeMs;
|
||||
})
|
||||
.map(sample => ({
|
||||
timestamp: sample.startDate,
|
||||
value: Math.round(sample.value)
|
||||
}))
|
||||
.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
|
||||
// const heartRateData: HeartRateData[] = results
|
||||
// .filter(sample => {
|
||||
// const sampleTime = new Date(sample.startDate).getTime();
|
||||
// const bedtimeMs = new Date(bedtime).getTime();
|
||||
// const wakeupTimeMs = new Date(wakeupTime).getTime();
|
||||
// return sampleTime >= bedtimeMs && sampleTime <= wakeupTimeMs;
|
||||
// })
|
||||
// .map(sample => ({
|
||||
// timestamp: sample.startDate,
|
||||
// value: Math.round(sample.value)
|
||||
// }))
|
||||
// .sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
|
||||
|
||||
console.log('[Sleep] 获取到睡眠心率数据:', heartRateData.length, '个样本');
|
||||
resolve(heartRateData);
|
||||
});
|
||||
});
|
||||
// console.log('[Sleep] 获取到睡眠心率数据:', heartRateData.length, '个样本');
|
||||
// resolve(heartRateData);
|
||||
// });
|
||||
// });
|
||||
|
||||
} catch (error) {
|
||||
console.error('[Sleep] 获取睡眠心率数据失败:', error);
|
||||
@@ -369,8 +368,14 @@ export const getSleepQualityInfo = (sleepScore: number): { description: string;
|
||||
export const fetchCompleteSleepData = async (date: Date): Promise<CompleteSleepData | null> => {
|
||||
try {
|
||||
console.log('[Sleep] 开始获取完整睡眠数据...', dayjs(date).format('YYYY-MM-DD'));
|
||||
// 检查HealthKit是否可用
|
||||
if (!HealthKitUtils.isAvailable()) {
|
||||
console.log('HealthKit不可用,可能运行在Android设备或模拟器上');
|
||||
return null;
|
||||
}
|
||||
|
||||
await ensureHealthPermissions()
|
||||
await HealthKitManager.requestAuthorization()
|
||||
// await ensureHealthPermissions()
|
||||
// 获取睡眠样本
|
||||
const sleepSamples = await fetchSleepSamples(date);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user