feat: 支持 healthkit

This commit is contained in:
richarjiang
2025-09-17 18:05:11 +08:00
parent 63ed820e93
commit 6b7776e51d
15 changed files with 1675 additions and 532 deletions

View 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;