feat: 添加原始睡眠数据列表,优化睡眠详情数据处理逻辑,确保完整的睡眠周期计算
This commit is contained in:
@@ -736,6 +736,109 @@ export default function SleepDetailScreen() {
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
|
||||
{/* Raw Sleep Samples List - 显示所有原始睡眠数据 */}
|
||||
{sleepData && sleepData.rawSleepSamples && sleepData.rawSleepSamples.length > 0 && (
|
||||
<View style={[styles.rawSamplesContainer, { backgroundColor: colorTokens.background }]}>
|
||||
<View style={styles.rawSamplesHeader}>
|
||||
<Text style={[styles.rawSamplesTitle, { color: colorTokens.text }]}>
|
||||
原始睡眠数据 ({sleepData.rawSleepSamples.length} 条记录)
|
||||
</Text>
|
||||
<Text style={[styles.rawSamplesSubtitle, { color: colorTokens.textSecondary }]}>
|
||||
查看数据间隔和可能的gap
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<ScrollView style={styles.rawSamplesScrollView} nestedScrollEnabled={true}>
|
||||
{sleepData.rawSleepSamples.map((sample, index) => {
|
||||
// 计算与前一个样本的时间间隔
|
||||
const prevSample = index > 0 ? sleepData.rawSleepSamples[index - 1] : null;
|
||||
let gapMinutes = 0;
|
||||
let hasGap = false;
|
||||
|
||||
if (prevSample) {
|
||||
const prevEndTime = new Date(prevSample.endDate).getTime();
|
||||
const currentStartTime = new Date(sample.startDate).getTime();
|
||||
gapMinutes = (currentStartTime - prevEndTime) / (1000 * 60);
|
||||
hasGap = gapMinutes > 1; // 大于1分钟视为有间隔
|
||||
}
|
||||
|
||||
const startTime = formatTime(sample.startDate);
|
||||
const endTime = formatTime(sample.endDate);
|
||||
const duration = Math.round((new Date(sample.endDate).getTime() - new Date(sample.startDate).getTime()) / (1000 * 60));
|
||||
|
||||
// 获取睡眠阶段中文名称
|
||||
const getStageName = (value: string) => {
|
||||
switch (value) {
|
||||
case 'HKCategoryValueSleepAnalysisInBed': return '在床上';
|
||||
case 'HKCategoryValueSleepAnalysisAwake': return '清醒';
|
||||
case 'HKCategoryValueSleepAnalysisAsleepCore': return '核心睡眠';
|
||||
case 'HKCategoryValueSleepAnalysisAsleepDeep': return '深度睡眠';
|
||||
case 'HKCategoryValueSleepAnalysisAsleepREM': return 'REM睡眠';
|
||||
case 'HKCategoryValueSleepAnalysisAsleepUnspecified': return '未指定睡眠';
|
||||
default: return value;
|
||||
}
|
||||
};
|
||||
|
||||
// 获取状态颜色
|
||||
const getStageColor = (value: string) => {
|
||||
switch (value) {
|
||||
case 'HKCategoryValueSleepAnalysisInBed': return '#9CA3AF';
|
||||
case 'HKCategoryValueSleepAnalysisAwake': return '#F59E0B';
|
||||
case 'HKCategoryValueSleepAnalysisAsleepCore': return '#8B5CF6';
|
||||
case 'HKCategoryValueSleepAnalysisAsleepDeep': return '#3B82F6';
|
||||
case 'HKCategoryValueSleepAnalysisAsleepREM': return '#EC4899';
|
||||
case 'HKCategoryValueSleepAnalysisAsleepUnspecified': return '#6B7280';
|
||||
default: return '#6B7280';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View key={index}>
|
||||
{/* 显示数据间隔 */}
|
||||
{hasGap && (
|
||||
<View style={[styles.gapIndicator, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}>
|
||||
<Ionicons name="alert-circle-outline" size={14} color="#F59E0B" />
|
||||
<Text style={[styles.gapText, { color: '#F59E0B' }]}>
|
||||
数据间隔: {Math.round(gapMinutes)}分钟
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 睡眠样本条目 */}
|
||||
<View style={[styles.rawSampleItem, { borderColor: colorTokens.border }]}>
|
||||
<View style={styles.sampleHeader}>
|
||||
<View style={styles.sampleLeft}>
|
||||
<View
|
||||
style={[
|
||||
styles.stageDot,
|
||||
{ backgroundColor: getStageColor(sample.value) }
|
||||
]}
|
||||
/>
|
||||
<Text style={[styles.sampleStage, { color: colorTokens.text }]}>
|
||||
{getStageName(sample.value)}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={[styles.sampleDuration, { color: colorTokens.textSecondary }]}>
|
||||
{duration}分钟
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.sampleTimeRange}>
|
||||
<Text style={[styles.sampleTime, { color: colorTokens.textSecondary }]}>
|
||||
{startTime} - {endTime}
|
||||
</Text>
|
||||
<Text style={[styles.sampleIndex, { color: colorTokens.textMuted }]}>
|
||||
#{index + 1}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
{infoModal.type && (
|
||||
@@ -1388,4 +1491,92 @@ const styles = StyleSheet.create({
|
||||
lineHeight: 16,
|
||||
marginBottom: 4,
|
||||
},
|
||||
// Raw Sleep Samples List 样式
|
||||
rawSamplesContainer: {
|
||||
borderRadius: 16,
|
||||
padding: 16,
|
||||
marginBottom: 24,
|
||||
marginHorizontal: 4,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 8,
|
||||
elevation: 3,
|
||||
},
|
||||
rawSamplesHeader: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
rawSamplesTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
marginBottom: 4,
|
||||
},
|
||||
rawSamplesSubtitle: {
|
||||
fontSize: 12,
|
||||
fontWeight: '500',
|
||||
},
|
||||
rawSamplesScrollView: {
|
||||
maxHeight: 400, // 限制高度,避免列表过长
|
||||
},
|
||||
rawSampleItem: {
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 16,
|
||||
borderLeftWidth: 3,
|
||||
borderLeftColor: 'transparent',
|
||||
marginBottom: 8,
|
||||
borderRadius: 8,
|
||||
backgroundColor: 'rgba(248, 250, 252, 0.5)',
|
||||
},
|
||||
sampleHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 4,
|
||||
},
|
||||
sampleLeft: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
},
|
||||
stageDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
marginRight: 8,
|
||||
},
|
||||
sampleStage: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
flex: 1,
|
||||
},
|
||||
sampleDuration: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
},
|
||||
sampleTimeRange: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
sampleTime: {
|
||||
fontSize: 12,
|
||||
},
|
||||
sampleIndex: {
|
||||
fontSize: 10,
|
||||
fontWeight: '500',
|
||||
},
|
||||
gapIndicator: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 12,
|
||||
marginVertical: 4,
|
||||
borderRadius: 8,
|
||||
gap: 6,
|
||||
},
|
||||
gapText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
28
package-lock.json
generated
28
package-lock.json
generated
@@ -9,6 +9,7 @@
|
||||
"version": "1.0.2",
|
||||
"dependencies": {
|
||||
"@expo/vector-icons": "^14.1.0",
|
||||
"@kingstinct/react-native-healthkit": "^10.1.0",
|
||||
"@react-native-async-storage/async-storage": "^2.2.0",
|
||||
"@react-native-community/datetimepicker": "^8.4.4",
|
||||
"@react-native-masked-view/masked-view": "^0.3.2",
|
||||
@@ -55,6 +56,7 @@
|
||||
"react-native-image-viewing": "^0.2.2",
|
||||
"react-native-markdown-display": "^7.0.2",
|
||||
"react-native-modal-datetime-picker": "^18.0.0",
|
||||
"react-native-nitro-modules": "^0.29.3",
|
||||
"react-native-popover-view": "^6.1.0",
|
||||
"react-native-purchases": "^9.2.2",
|
||||
"react-native-reanimated": "~3.17.4",
|
||||
@@ -2728,6 +2730,21 @@
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@kingstinct/react-native-healthkit": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://mirrors.tencent.com/npm/@kingstinct/react-native-healthkit/-/react-native-healthkit-10.1.0.tgz",
|
||||
"integrity": "sha512-p6f3Uf4p6GXs+8xIc5NHu8DPnNJC9kxGvI+4qmgGk5U24hVZBZFAwFT53jkQMoIHZIoQmtuXJDp8jMJ7WzeZ+Q==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/kingstinct"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "*",
|
||||
"react-native": "*",
|
||||
"react-native-nitro-modules": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "0.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
|
||||
@@ -11732,6 +11749,17 @@
|
||||
"react-native": ">=0.65.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-nitro-modules": {
|
||||
"version": "0.29.3",
|
||||
"resolved": "https://mirrors.tencent.com/npm/react-native-nitro-modules/-/react-native-nitro-modules-0.29.3.tgz",
|
||||
"integrity": "sha512-gGaCueHKaZSw2rlrKrPgMZE6O6qvsnTJwNysJgk4ZEHMwnVe6Auk5hc4+sJPQLOVd6o+HMHdVhVQhZZv1u19Eg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "*",
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-popover-view": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://mirrors.tencent.com/npm/react-native-popover-view/-/react-native-popover-view-6.1.0.tgz",
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/vector-icons": "^14.1.0",
|
||||
"@kingstinct/react-native-healthkit": "^10.1.0",
|
||||
"@react-native-async-storage/async-storage": "^2.2.0",
|
||||
"@react-native-community/datetimepicker": "^8.4.4",
|
||||
"@react-native-masked-view/masked-view": "^0.3.2",
|
||||
@@ -27,6 +28,7 @@
|
||||
"dayjs": "^1.11.13",
|
||||
"expo": "53.0.22",
|
||||
"expo-apple-authentication": "~7.2.4",
|
||||
"expo-background-task": "~0.2.8",
|
||||
"expo-blur": "~14.1.5",
|
||||
"expo-camera": "^16.1.11",
|
||||
"expo-constants": "~17.1.7",
|
||||
@@ -58,6 +60,7 @@
|
||||
"react-native-image-viewing": "^0.2.2",
|
||||
"react-native-markdown-display": "^7.0.2",
|
||||
"react-native-modal-datetime-picker": "^18.0.0",
|
||||
"react-native-nitro-modules": "^0.29.3",
|
||||
"react-native-popover-view": "^6.1.0",
|
||||
"react-native-purchases": "^9.2.2",
|
||||
"react-native-reanimated": "~3.17.4",
|
||||
@@ -69,8 +72,7 @@
|
||||
"react-native-web": "~0.20.0",
|
||||
"react-native-webview": "13.13.5",
|
||||
"react-native-wheel-picker-expo": "^0.5.4",
|
||||
"react-redux": "^9.2.0",
|
||||
"expo-background-task": "~0.2.8"
|
||||
"react-redux": "^9.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.25.2",
|
||||
|
||||
@@ -320,37 +320,27 @@ export async function fetchSleepDetailForDate(date: Date): Promise<SleepDetailDa
|
||||
}
|
||||
|
||||
// 找到入睡时间和起床时间
|
||||
// 过滤出实际睡眠阶段(排除在床时间和清醒时间)
|
||||
const actualSleepSamples = sleepSamples.filter(sample =>
|
||||
sample.value !== SleepStage.InBed && sample.value !== SleepStage.Awake
|
||||
);
|
||||
|
||||
// 入睡时间:第一个实际睡眠阶段的开始时间
|
||||
// 起床时间:最后一个实际睡眠阶段的结束时间
|
||||
// 使用所有样本数据来确定完整的睡眠周期(包含清醒时间)
|
||||
let bedtime: string;
|
||||
let wakeupTime: string;
|
||||
|
||||
if (actualSleepSamples.length > 0) {
|
||||
if (sleepSamples.length > 0) {
|
||||
// 按开始时间排序
|
||||
const sortedSleepSamples = actualSleepSamples.sort((a, b) =>
|
||||
const sortedSamples = sleepSamples.sort((a, b) =>
|
||||
new Date(a.startDate).getTime() - new Date(b.startDate).getTime()
|
||||
);
|
||||
|
||||
bedtime = sortedSleepSamples[0].startDate;
|
||||
wakeupTime = sortedSleepSamples[sortedSleepSamples.length - 1].endDate;
|
||||
// 入睡时间:第一个样本的开始时间(包含清醒时间,确保完整性)
|
||||
bedtime = sortedSamples[0].startDate;
|
||||
// 起床时间:最后一个样本的结束时间
|
||||
wakeupTime = sortedSamples[sortedSamples.length - 1].endDate;
|
||||
|
||||
console.log('计算入睡和起床时间:');
|
||||
console.log('- 入睡时间:', dayjs(bedtime).format('YYYY-MM-DD HH:mm:ss'));
|
||||
console.log('- 起床时间:', dayjs(wakeupTime).format('YYYY-MM-DD HH:mm:ss'));
|
||||
} else {
|
||||
// 如果没有实际睡眠数据,回退到使用所有样本数据
|
||||
console.warn('没有找到实际睡眠阶段数据,使用所有样本数据');
|
||||
const sortedAllSamples = sleepSamples.sort((a, b) =>
|
||||
new Date(a.startDate).getTime() - new Date(b.startDate).getTime()
|
||||
);
|
||||
|
||||
bedtime = sortedAllSamples[0].startDate;
|
||||
wakeupTime = sortedAllSamples[sortedAllSamples.length - 1].endDate;
|
||||
console.warn('没有找到睡眠样本数据');
|
||||
return null;
|
||||
}
|
||||
|
||||
// 计算在床时间 - 使用 INBED 样本数据
|
||||
@@ -380,7 +370,6 @@ export async function fetchSleepDetailForDate(date: Date): Promise<SleepDetailDa
|
||||
// 计算睡眠阶段统计
|
||||
const sleepStages = calculateSleepStageStats(sleepSamples);
|
||||
|
||||
// 计算总睡眠时间
|
||||
const totalSleepTime = sleepStages
|
||||
.reduce((total, stage) => total + stage.duration, 0);
|
||||
|
||||
@@ -401,6 +390,19 @@ export async function fetchSleepDetailForDate(date: Date): Promise<SleepDetailDa
|
||||
// 获取质量描述和建议
|
||||
const qualityInfo = getSleepQualityInfo(sleepScore);
|
||||
|
||||
// 详细的调试信息
|
||||
console.log('=== 睡眠数据处理结果 ===');
|
||||
console.log('时间范围:', dayjs(bedtime).format('HH:mm'), '-', dayjs(wakeupTime).format('HH:mm'));
|
||||
console.log('在床时间:', timeInBed, '分钟');
|
||||
console.log('总睡眠时间:', totalSleepTime, '分钟');
|
||||
console.log('睡眠效率:', sleepEfficiency, '%');
|
||||
console.log('睡眠阶段统计:');
|
||||
sleepStages.forEach(stage => {
|
||||
console.log(` ${getSleepStageDisplayName(stage.stage)}: ${stage.duration}分钟 (${stage.percentage}%)`);
|
||||
});
|
||||
|
||||
console.log('========================');
|
||||
|
||||
const sleepDetailData: SleepDetailData = {
|
||||
sleepScore,
|
||||
totalSleepTime,
|
||||
@@ -417,7 +419,7 @@ export async function fetchSleepDetailForDate(date: Date): Promise<SleepDetailDa
|
||||
recommendation: qualityInfo.recommendation
|
||||
};
|
||||
|
||||
console.log('睡眠详情数据获取完成:', sleepDetailData);
|
||||
console.log('睡眠详情数据获取完成,睡眠得分:', sleepScore);
|
||||
return sleepDetailData;
|
||||
|
||||
} catch (error) {
|
||||
|
||||
Reference in New Issue
Block a user