feat: 添加原始睡眠数据列表,优化睡眠详情数据处理逻辑,确保完整的睡眠周期计算

This commit is contained in:
richarjiang
2025-09-09 16:20:11 +08:00
parent e56ebe3636
commit 6daf9500fc
4 changed files with 247 additions and 24 deletions

View File

@@ -736,6 +736,109 @@ export default function SleepDetailScreen() {
); );
})} })}
</View> </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> </ScrollView>
{infoModal.type && ( {infoModal.type && (
@@ -1388,4 +1491,92 @@ const styles = StyleSheet.create({
lineHeight: 16, lineHeight: 16,
marginBottom: 4, 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
View File

@@ -9,6 +9,7 @@
"version": "1.0.2", "version": "1.0.2",
"dependencies": { "dependencies": {
"@expo/vector-icons": "^14.1.0", "@expo/vector-icons": "^14.1.0",
"@kingstinct/react-native-healthkit": "^10.1.0",
"@react-native-async-storage/async-storage": "^2.2.0", "@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-masked-view/masked-view": "^0.3.2",
@@ -55,6 +56,7 @@
"react-native-image-viewing": "^0.2.2", "react-native-image-viewing": "^0.2.2",
"react-native-markdown-display": "^7.0.2", "react-native-markdown-display": "^7.0.2",
"react-native-modal-datetime-picker": "^18.0.0", "react-native-modal-datetime-picker": "^18.0.0",
"react-native-nitro-modules": "^0.29.3",
"react-native-popover-view": "^6.1.0", "react-native-popover-view": "^6.1.0",
"react-native-purchases": "^9.2.2", "react-native-purchases": "^9.2.2",
"react-native-reanimated": "~3.17.4", "react-native-reanimated": "~3.17.4",
@@ -2728,6 +2730,21 @@
"react-native": "*" "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": { "node_modules/@napi-rs/wasm-runtime": {
"version": "0.2.12", "version": "0.2.12",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
@@ -11732,6 +11749,17 @@
"react-native": ">=0.65.0" "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": { "node_modules/react-native-popover-view": {
"version": "6.1.0", "version": "6.1.0",
"resolved": "https://mirrors.tencent.com/npm/react-native-popover-view/-/react-native-popover-view-6.1.0.tgz", "resolved": "https://mirrors.tencent.com/npm/react-native-popover-view/-/react-native-popover-view-6.1.0.tgz",

View File

@@ -13,6 +13,7 @@
}, },
"dependencies": { "dependencies": {
"@expo/vector-icons": "^14.1.0", "@expo/vector-icons": "^14.1.0",
"@kingstinct/react-native-healthkit": "^10.1.0",
"@react-native-async-storage/async-storage": "^2.2.0", "@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-masked-view/masked-view": "^0.3.2",
@@ -27,6 +28,7 @@
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"expo": "53.0.22", "expo": "53.0.22",
"expo-apple-authentication": "~7.2.4", "expo-apple-authentication": "~7.2.4",
"expo-background-task": "~0.2.8",
"expo-blur": "~14.1.5", "expo-blur": "~14.1.5",
"expo-camera": "^16.1.11", "expo-camera": "^16.1.11",
"expo-constants": "~17.1.7", "expo-constants": "~17.1.7",
@@ -58,6 +60,7 @@
"react-native-image-viewing": "^0.2.2", "react-native-image-viewing": "^0.2.2",
"react-native-markdown-display": "^7.0.2", "react-native-markdown-display": "^7.0.2",
"react-native-modal-datetime-picker": "^18.0.0", "react-native-modal-datetime-picker": "^18.0.0",
"react-native-nitro-modules": "^0.29.3",
"react-native-popover-view": "^6.1.0", "react-native-popover-view": "^6.1.0",
"react-native-purchases": "^9.2.2", "react-native-purchases": "^9.2.2",
"react-native-reanimated": "~3.17.4", "react-native-reanimated": "~3.17.4",
@@ -69,8 +72,7 @@
"react-native-web": "~0.20.0", "react-native-web": "~0.20.0",
"react-native-webview": "13.13.5", "react-native-webview": "13.13.5",
"react-native-wheel-picker-expo": "^0.5.4", "react-native-wheel-picker-expo": "^0.5.4",
"react-redux": "^9.2.0", "react-redux": "^9.2.0"
"expo-background-task": "~0.2.8"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.25.2", "@babel/core": "^7.25.2",

View File

@@ -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 bedtime: string;
let wakeupTime: 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() 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('计算入睡和起床时间:');
console.log('- 入睡时间:', dayjs(bedtime).format('YYYY-MM-DD HH:mm:ss')); console.log('- 入睡时间:', dayjs(bedtime).format('YYYY-MM-DD HH:mm:ss'));
console.log('- 起床时间:', dayjs(wakeupTime).format('YYYY-MM-DD HH:mm:ss')); console.log('- 起床时间:', dayjs(wakeupTime).format('YYYY-MM-DD HH:mm:ss'));
} else { } else {
// 如果没有实际睡眠数据,回退到使用所有样本数据 console.warn('没有找到睡眠样本数据');
console.warn('没有找到实际睡眠阶段数据,使用所有样本数据'); return null;
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;
} }
// 计算在床时间 - 使用 INBED 样本数据 // 计算在床时间 - 使用 INBED 样本数据
@@ -380,7 +370,6 @@ export async function fetchSleepDetailForDate(date: Date): Promise<SleepDetailDa
// 计算睡眠阶段统计 // 计算睡眠阶段统计
const sleepStages = calculateSleepStageStats(sleepSamples); const sleepStages = calculateSleepStageStats(sleepSamples);
// 计算总睡眠时间
const totalSleepTime = sleepStages const totalSleepTime = sleepStages
.reduce((total, stage) => total + stage.duration, 0); .reduce((total, stage) => total + stage.duration, 0);
@@ -401,6 +390,19 @@ export async function fetchSleepDetailForDate(date: Date): Promise<SleepDetailDa
// 获取质量描述和建议 // 获取质量描述和建议
const qualityInfo = getSleepQualityInfo(sleepScore); 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 = { const sleepDetailData: SleepDetailData = {
sleepScore, sleepScore,
totalSleepTime, totalSleepTime,
@@ -417,7 +419,7 @@ export async function fetchSleepDetailForDate(date: Date): Promise<SleepDetailDa
recommendation: qualityInfo.recommendation recommendation: qualityInfo.recommendation
}; };
console.log('睡眠详情数据获取完成:', sleepDetailData); console.log('睡眠详情数据获取完成,睡眠得分:', sleepScore);
return sleepDetailData; return sleepDetailData;
} catch (error) { } catch (error) {