Files
digital-pilates/app/sleep-detail.tsx

1505 lines
45 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Ionicons } from '@expo/vector-icons';
import {
AuthorizationRequestStatus,
queryCategorySamplesWithAnchor,
queryQuantitySamplesWithAnchor,
useHealthkitAuthorization
} from '@kingstinct/react-native-healthkit';
import dayjs from 'dayjs';
import { LinearGradient } from 'expo-linear-gradient';
import { router } from 'expo-router';
import React, { useCallback, useEffect, useState } from 'react';
import {
ActivityIndicator,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View
} from 'react-native';
import { HeaderBar } from '@/components/ui/HeaderBar';
import { InfoModal, type SleepDetailData } from '@/components/sleep/InfoModal';
import { SleepStagesInfoModal } from '@/components/sleep/SleepStagesInfoModal';
import { Colors } from '@/constants/Colors';
import { useColorScheme } from '@/hooks/useColorScheme';
// 睡眠阶段枚举
enum SleepStage {
InBed = 'INBED',
Asleep = 'ASLEEP',
Awake = 'AWAKE',
Core = 'CORE',
Deep = 'DEEP',
REM = 'REM'
}
// 睡眠质量评级
enum SleepQuality {
Poor = 'poor',
Fair = 'fair',
Good = 'good',
Excellent = 'excellent'
}
// 睡眠样本数据类型
type SleepSample = {
startDate: string;
endDate: string;
value: SleepStage;
sourceName?: string;
sourceId?: string;
};
// 睡眠阶段统计
type SleepStageStats = {
stage: SleepStage;
duration: number;
percentage: number;
quality: SleepQuality;
};
// 心率数据类型
type HeartRateData = {
timestamp: string;
value: number;
};
// 睡眠详情数据类型从 InfoModal 组件导入,但需要扩展以包含其他字段
type ExtendedSleepDetailData = SleepDetailData & {
sleepStages: SleepStageStats[];
rawSleepSamples: SleepSample[];
sleepHeartRateData: HeartRateData[];
};
// 工具函数
const formatSleepTime = (minutes: number): string => {
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
if (hours > 0 && mins > 0) {
return `${hours}h ${mins}m`;
} else if (hours > 0) {
return `${hours}h`;
} else {
return `${mins}m`;
}
};
const formatTime = (dateString: string): string => {
return dayjs(dateString).format('HH:mm');
};
const getSleepStageColor = (stage: SleepStage): string => {
switch (stage) {
case SleepStage.Deep:
return '#3B82F6';
case SleepStage.Core:
return '#8B5CF6';
case SleepStage.REM:
case SleepStage.Asleep:
return '#EC4899';
case SleepStage.Awake:
return '#F59E0B';
case SleepStage.InBed:
return '#6B7280';
default:
return '#9CA3AF';
}
};
// 创建睡眠日期范围
const createSleepDateRange = (date: Date): { startDate: Date; endDate: Date } => {
return {
startDate: dayjs(date).subtract(1, 'day').hour(18).minute(0).second(0).millisecond(0).toDate(),
endDate: dayjs(date).hour(12).minute(0).second(0).millisecond(0).toDate()
};
};
// 获取睡眠样本数据
const fetchSleepSamples = async (date: Date): Promise<SleepSample[]> => {
try {
const options = createSleepDateRange(date);
// 使用 queryCategorySamplesWithAnchor 查询睡眠分析数据
const { samples } = await queryCategorySamplesWithAnchor('HKCategoryTypeIdentifierSleepAnalysis', {
limit: 0,
filter: {
startDate: options.startDate,
endDate: options.endDate,
strictStartDate: true,
strictEndDate: true
}
});
if (!samples || !Array.isArray(samples)) {
console.warn('睡眠样本数据为空');
return [];
}
// 过滤指定日期范围内的样本
const startTime = new Date(options.startDate).getTime();
const endTime = new Date(options.endDate).getTime();
const filteredSamples = samples.filter(sample => {
const sampleStart = new Date(sample.startDate).getTime();
const sampleEnd = new Date(sample.endDate).getTime();
return (sampleStart >= startTime && sampleStart < endTime) ||
(sampleStart < endTime && sampleEnd > startTime);
});
console.log(`过滤前样本数量: ${samples.length}, 过滤后样本数量: ${filteredSamples.length}`);
// 转换数据格式
const sleepSamples: SleepSample[] = filteredSamples.map(sample => {
let mappedValue: SleepStage;
// HealthKit 睡眠分析值映射
switch (sample.value) {
case 0:
mappedValue = SleepStage.InBed;
break;
case 1:
mappedValue = SleepStage.Asleep;
break;
case 2:
mappedValue = SleepStage.Awake;
break;
case 3:
mappedValue = SleepStage.Core;
break;
case 4:
mappedValue = SleepStage.Deep;
break;
case 5:
mappedValue = SleepStage.REM;
break;
default:
mappedValue = SleepStage.Asleep;
}
return {
startDate: sample.startDate,
endDate: sample.endDate,
value: mappedValue,
sourceName: sample.sourceName,
sourceId: sample.uuid
};
});
console.log('获取到睡眠样本:', sleepSamples.length);
return sleepSamples;
} catch (error) {
console.error('获取睡眠样本失败:', error);
return [];
}
};
// 获取睡眠期间心率数据
const fetchSleepHeartRateData = async (bedtime: string, wakeupTime: string): Promise<HeartRateData[]> => {
try {
const { samples } = await queryQuantitySamplesWithAnchor('HKQuantityTypeIdentifierHeartRate', {
limit: 0
});
if (!samples || !Array.isArray(samples)) {
return [];
}
const bedtimeMs = new Date(bedtime).getTime();
const wakeupTimeMs = new Date(wakeupTime).getTime();
const sleepHeartRateData: HeartRateData[] = samples
.filter(sample => {
const sampleTime = new Date(sample.startDate).getTime();
return sampleTime >= bedtimeMs && sampleTime <= wakeupTimeMs;
})
.map(sample => ({
timestamp: sample.startDate,
value: Math.round(sample.quantity)
}))
.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
console.log('获取到睡眠心率数据:', sleepHeartRateData.length, '个样本');
return sleepHeartRateData;
} catch (error) {
console.error('获取睡眠心率数据失败:', error);
return [];
}
};
// 计算睡眠阶段统计
const calculateSleepStageStats = (samples: SleepSample[]): SleepStageStats[] => {
const stageMap = new Map<SleepStage, number>();
samples.forEach(sample => {
const startTime = dayjs(sample.startDate);
const endTime = dayjs(sample.endDate);
const duration = endTime.diff(startTime, 'minute');
const currentDuration = stageMap.get(sample.value) || 0;
stageMap.set(sample.value, currentDuration + duration);
});
const actualSleepTime = Array.from(stageMap.entries())
.reduce((total, [, duration]) => total + duration, 0);
const stats: SleepStageStats[] = [];
stageMap.forEach((duration, stage) => {
if (stage === SleepStage.InBed) return;
const percentage = actualSleepTime > 0 ? (duration / actualSleepTime) * 100 : 0;
let quality: SleepQuality;
switch (stage) {
case SleepStage.Deep:
quality = percentage >= 15 ? SleepQuality.Excellent :
percentage >= 10 ? SleepQuality.Good :
percentage >= 5 ? SleepQuality.Fair : SleepQuality.Poor;
break;
case SleepStage.REM:
quality = percentage >= 20 ? SleepQuality.Excellent :
percentage >= 15 ? SleepQuality.Good :
percentage >= 10 ? SleepQuality.Fair : SleepQuality.Poor;
break;
case SleepStage.Core:
quality = percentage >= 45 ? SleepQuality.Excellent :
percentage >= 35 ? SleepQuality.Good :
percentage >= 25 ? SleepQuality.Fair : SleepQuality.Poor;
break;
case SleepStage.Awake:
quality = percentage <= 5 ? SleepQuality.Excellent :
percentage <= 10 ? SleepQuality.Good :
percentage <= 15 ? SleepQuality.Fair : SleepQuality.Poor;
break;
default:
quality = SleepQuality.Fair;
}
stats.push({
stage,
duration,
percentage: Math.round(percentage),
quality
});
});
return stats.sort((a, b) => b.duration - a.duration);
};
// 计算睡眠得分
const calculateSleepScore = (sleepStages: SleepStageStats[], sleepEfficiency: number, totalSleepTime: number): number => {
let score = 0;
const idealSleepHours = 8 * 60;
const sleepDurationScore = Math.min(30, (totalSleepTime / idealSleepHours) * 30);
score += sleepDurationScore;
const efficiencyScore = (sleepEfficiency / 100) * 25;
score += efficiencyScore;
const deepSleepStage = sleepStages.find(stage => stage.stage === SleepStage.Deep);
const deepSleepScore = deepSleepStage ? Math.min(25, (deepSleepStage.percentage / 20) * 25) : 0;
score += deepSleepScore;
const remSleepStage = sleepStages.find(stage => stage.stage === SleepStage.REM);
const remSleepScore = remSleepStage ? Math.min(20, (remSleepStage.percentage / 25) * 20) : 0;
score += remSleepScore;
return Math.round(Math.min(100, score));
};
// 获取睡眠质量描述和建议
const getSleepQualityInfo = (sleepScore: number): { description: string; recommendation: string } => {
if (sleepScore >= 85) {
return {
description: '你身心愉悦并且精力充沛',
recommendation: '恭喜你获得优质的睡眠!如果你感到精力充沛,可以考虑中等强度的运动,以维持健康的生活方式,并进一步减轻压力,以获得最佳睡眠。'
};
} else if (sleepScore >= 70) {
return {
description: '睡眠质量良好,精神状态不错',
recommendation: '你的睡眠质量还不错,但还有改善空间。建议保持规律的睡眠时间,睡前避免使用电子设备,营造安静舒适的睡眠环境。'
};
} else if (sleepScore >= 50) {
return {
description: '睡眠质量一般,可能影响日间表现',
recommendation: '你的睡眠需要改善。建议制定固定的睡前例行程序,限制咖啡因摄入,确保卧室温度适宜,考虑进行轻度运动来改善睡眠质量。'
};
} else {
return {
description: '睡眠质量较差,建议重视睡眠健康',
recommendation: '你的睡眠质量需要严重关注。建议咨询医生或睡眠专家,检查是否有睡眠障碍,同时改善睡眠环境和习惯,避免睡前刺激性活动。'
};
}
};
// 主函数:获取完整的睡眠详情数据
const fetchSleepDetailData = async (date: Date): Promise<ExtendedSleepDetailData | null> => {
try {
console.log('开始获取睡眠详情数据...', date);
const sleepSamples = await fetchSleepSamples(date);
if (sleepSamples.length === 0) {
console.warn('没有找到睡眠数据');
return null;
}
let bedtime: string;
let wakeupTime: string;
if (sleepSamples.length > 0) {
const sortedSamples = sleepSamples.sort((a, b) =>
new Date(a.startDate).getTime() - new Date(b.startDate).getTime()
);
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('没有找到睡眠样本数据');
return null;
}
// 计算在床时间
let timeInBed: number;
const inBedSamples = sleepSamples.filter(sample => sample.value === SleepStage.InBed);
if (inBedSamples.length > 0) {
const sortedInBedSamples = inBedSamples.sort((a, b) =>
new Date(a.startDate).getTime() - new Date(b.startDate).getTime()
);
const inBedStart = sortedInBedSamples[0].startDate;
const inBedEnd = sortedInBedSamples[sortedInBedSamples.length - 1].endDate;
timeInBed = dayjs(inBedEnd).diff(dayjs(inBedStart), 'minute');
console.log('在床时间计算:');
console.log('- 上床时间:', dayjs(inBedStart).format('YYYY-MM-DD HH:mm:ss'));
console.log('- 离床时间:', dayjs(inBedEnd).format('YYYY-MM-DD HH:mm:ss'));
console.log('- 在床时长:', timeInBed, '分钟');
} else {
timeInBed = dayjs(wakeupTime).diff(dayjs(bedtime), 'minute');
console.log('没有INBED数据使用睡眠时间作为在床时间:', timeInBed, '分钟');
}
// 计算睡眠阶段统计
const sleepStages = calculateSleepStageStats(sleepSamples);
const totalSleepTime = sleepStages.reduce((total, stage) => total + stage.duration, 0);
const sleepEfficiency = timeInBed > 0 ? Math.round((totalSleepTime / timeInBed) * 100) : 0;
// 获取睡眠期间心率数据
const sleepHeartRateData = await fetchSleepHeartRateData(bedtime, wakeupTime);
const averageHeartRate = sleepHeartRateData.length > 0 ?
Math.round(sleepHeartRateData.reduce((sum, data) => sum + data.value, 0) / sleepHeartRateData.length) :
null;
// 计算睡眠得分
const sleepScore = calculateSleepScore(sleepStages, sleepEfficiency, totalSleepTime);
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('睡眠得分:', sleepScore);
console.log('========================');
const sleepDetailData: ExtendedSleepDetailData = {
sleepScore,
totalSleepTime,
sleepQualityPercentage: sleepScore,
bedtime,
wakeupTime,
timeInBed,
sleepStages,
rawSleepSamples: sleepSamples,
averageHeartRate,
sleepHeartRateData,
sleepEfficiency,
qualityDescription: qualityInfo.description,
recommendation: qualityInfo.recommendation
};
console.log('睡眠详情数据获取完成,睡眠得分:', sleepScore);
return sleepDetailData;
} catch (error) {
console.error('获取睡眠详情数据失败:', error);
return null;
}
};
// 简化的睡眠阶段图表组件
const SleepStageChart = ({
sleepData,
onInfoPress
}: {
sleepData: SleepDetailData;
onInfoPress: () => void;
}) => {
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme];
// 使用真实的睡眠阶段数据,如果没有则使用默认数据
const stages = sleepData.sleepStages.length > 0
? sleepData.sleepStages
.filter(stage => stage.percentage > 0) // 只显示有数据的阶段
.map(stage => ({
stage: stage.stage,
percentage: stage.percentage,
duration: stage.duration
}))
: [
{ stage: SleepStage.Awake, percentage: 1, duration: 3 },
{ stage: SleepStage.REM, percentage: 20, duration: 89 },
{ stage: SleepStage.Core, percentage: 67, duration: 295 },
{ stage: SleepStage.Deep, percentage: 12, duration: 51 }
];
return (
<View style={styles.simplifiedChartContainer}>
<View style={styles.chartTitleContainer}>
<Text style={styles.chartTitle}></Text>
<TouchableOpacity
style={styles.chartInfoButton}
onPress={onInfoPress}
>
<Ionicons name="help-circle-outline" size={20} color="#6B7280" />
</TouchableOpacity>
</View>
{/* 入睡时间和起床时间显示 */}
<View style={styles.sleepTimeLabels}>
<View style={styles.sleepTimeLabel}>
<Text style={[styles.sleepTimeText, { color: colorTokens.textSecondary }]}>
</Text>
<Text style={[styles.sleepTimeValue, { color: colorTokens.text }]}>
{sleepData.bedtime ? formatTime(sleepData.bedtime) : '--:--'}
</Text>
</View>
<View style={styles.sleepTimeLabel}>
<Text style={[styles.sleepTimeText, { color: colorTokens.textSecondary }]}>
</Text>
<Text style={[styles.sleepTimeValue, { color: colorTokens.text }]}>
{sleepData.wakeupTime ? formatTime(sleepData.wakeupTime) : '--:--'}
</Text>
</View>
</View>
{/* 简化的睡眠阶段条 */}
<View style={styles.simplifiedChartBar}>
{stages.map((stageData, index) => {
const color = getSleepStageColor(stageData.stage);
// 确保最小宽度,避免清醒阶段等小比例的阶段完全不可见
const flexValue = Math.max(stageData.percentage || 1, 3);
return (
<View
key={index}
style={[
styles.stageSegment,
{
backgroundColor: color,
flex: flexValue,
}
]}
/>
);
})}
</View>
{/* 图例 */}
<View style={styles.chartLegend}>
<View style={styles.legendRow}>
<View style={styles.legendItem}>
<View style={[styles.legendDot, { backgroundColor: getSleepStageColor(SleepStage.Awake) }]} />
<Text style={styles.legendText}></Text>
</View>
<View style={styles.legendItem}>
<View style={[styles.legendDot, { backgroundColor: getSleepStageColor(SleepStage.REM) }]} />
<Text style={styles.legendText}></Text>
</View>
<View style={styles.legendItem}>
<View style={[styles.legendDot, { backgroundColor: getSleepStageColor(SleepStage.Core) }]} />
<Text style={styles.legendText}></Text>
</View>
<View style={styles.legendItem}>
<View style={[styles.legendDot, { backgroundColor: getSleepStageColor(SleepStage.Deep) }]} />
<Text style={styles.legendText}></Text>
</View>
</View>
</View>
</View>
);
};
// SleepGradeCard 组件现在在 InfoModal 组件内部
// SleepStagesInfoModal 组件现在从独立文件导入
// InfoModal 组件现在从独立文件导入
export default function SleepDetailScreen() {
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme];
const [sleepData, setSleepData] = useState<ExtendedSleepDetailData | null>(null);
const [loading, setLoading] = useState(true);
const [selectedDate] = useState(dayjs().toDate());
const [infoModal, setInfoModal] = useState<{ visible: boolean; title: string; type: 'sleep-time' | 'sleep-quality' | null }>({
visible: false,
title: '',
type: null
});
const [sleepStagesModal, setSleepStagesModal] = useState({
visible: false
});
// 使用 HealthKit 权限 hook
const [authorizationStatus, requestAuthorization] = useHealthkitAuthorization([
'HKCategoryTypeIdentifierSleepAnalysis',
'HKQuantityTypeIdentifierHeartRate'
]);
const loadSleepData = useCallback(async () => {
try {
setLoading(true);
// 如果需要请求权限,先请求权限
if (authorizationStatus === AuthorizationRequestStatus.shouldRequest) {
console.log('请求 HealthKit 权限..., 当前状态:', authorizationStatus);
try {
await requestAuthorization();
// 请求权限后,等待状态更新
return;
} catch (permissionError) {
console.error('权限请求失败:', permissionError);
}
}
// 如果权限不需要或已经处理,尝试获取数据
console.log('尝试获取睡眠数据,权限状态:', authorizationStatus);
const data = await fetchSleepDetailData(selectedDate);
setSleepData(data);
} catch (error) {
console.error('加载睡眠数据失败:', error);
} finally {
setLoading(false);
}
}, [selectedDate, authorizationStatus, requestAuthorization]);
useEffect(() => {
loadSleepData();
}, [loadSleepData]);
if (loading) {
return (
<View style={[styles.container, styles.loadingContainer]}>
<ActivityIndicator size="large" color={Colors.light.primary} />
<Text style={styles.loadingText}>...</Text>
</View>
);
}
// 如果没有数据,使用默认数据结构
const displayData: ExtendedSleepDetailData = sleepData || {
sleepScore: 0,
totalSleepTime: 0,
sleepQualityPercentage: 0,
bedtime: '',
wakeupTime: '',
timeInBed: 0,
sleepStages: [],
rawSleepSamples: [], // 添加空的原始睡眠样本数据
averageHeartRate: null,
sleepHeartRateData: [],
sleepEfficiency: 0,
qualityDescription: '暂无睡眠数据',
recommendation: '请确保在真实iOS设备上运行并授权访问健康数据或等待有睡眠数据后再查看。'
};
return (
<View style={styles.container}>
{/* 背景渐变 */}
<LinearGradient
colors={['#f0f4ff', '#e6f2ff', '#ffffff']}
style={styles.gradientBackground}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
/>
{/* 顶部导航 */}
<HeaderBar
title={`今天, ${dayjs(selectedDate).format('M月DD日')}`}
onBack={() => router.back()}
withSafeTop={true}
transparent={true}
/>
<ScrollView
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
>
{/* 睡眠得分圆形显示 */}
<View style={styles.scoreContainer}>
<View style={styles.scoreTextContainer}>
<Text style={styles.scoreNumber}>{displayData.sleepScore}</Text>
<Text style={styles.scoreLabel}></Text>
</View>
</View>
{/* 睡眠质量描述 */}
<Text style={styles.qualityDescription}>{displayData.qualityDescription}</Text>
{/* 建议文本 */}
<Text style={styles.recommendationText}>{displayData.recommendation}</Text>
{/* 睡眠统计卡片 */}
<View style={styles.statsContainer}>
<View style={[styles.newStatCard, { backgroundColor: colorTokens.background }]}>
<View style={styles.statCardHeader}>
<View style={styles.statCardLeftGroup}>
<View style={styles.statCardIcon}>
<Ionicons name="moon-outline" size={18} color="#6B7280" />
</View>
<Text style={[styles.statLabel, { color: colorTokens.textSecondary }]}></Text>
</View>
<TouchableOpacity
style={styles.infoButton}
onPress={() => setInfoModal({
visible: true,
title: '睡眠时间',
type: 'sleep-time'
})}
>
<Ionicons name="information-circle-outline" size={18} color={colorTokens.textMuted} />
</TouchableOpacity>
</View>
<Text style={[styles.newStatValue, { color: colorTokens.text }]}>
{displayData.totalSleepTime > 0 ? formatSleepTime(displayData.totalSleepTime) : '7h 23m'}
</Text>
<View style={[styles.qualityBadge, styles.goodQualityBadge]}>
<Text style={[styles.qualityBadgeText, styles.goodQualityText]}> </Text>
</View>
</View>
<View style={[styles.newStatCard, { backgroundColor: colorTokens.background }]}>
<View style={styles.statCardHeader}>
<View style={styles.statCardLeftGroup}>
<View style={styles.statCardIcon}>
<Ionicons name="star-outline" size={18} color="#6B7280" />
</View>
<Text style={[styles.statLabel, { color: colorTokens.textSecondary }]}></Text>
</View>
<TouchableOpacity
style={styles.infoButton}
onPress={() => setInfoModal({
visible: true,
title: '睡眠质量',
type: 'sleep-quality'
})}
>
<Ionicons name="information-circle-outline" size={16} color={colorTokens.textMuted} />
</TouchableOpacity>
</View>
<Text style={[styles.newStatValue, { color: colorTokens.text }]}>
{displayData.sleepQualityPercentage > 0 ? `${displayData.sleepQualityPercentage}%` : '94%'}
</Text>
<View style={[styles.qualityBadge, styles.excellentQualityBadge]}>
<Text style={[styles.qualityBadgeText, styles.excellentQualityText]}> </Text>
</View>
</View>
</View>
{/* 睡眠阶段图表 */}
<SleepStageChart
sleepData={displayData}
onInfoPress={() => setSleepStagesModal({ visible: true })}
/>
{/* 睡眠阶段统计 - 2x2网格布局 */}
<View style={styles.stagesGridContainer}>
{/* 使用真实数据或默认数据确保包含所有4个阶段 */}
{(() => {
let stagesToDisplay;
if (displayData.sleepStages.length > 0) {
// 使用真实数据,确保所有阶段都存在
const existingStages = new Map(displayData.sleepStages.map(s => [s.stage, s]));
stagesToDisplay = [
existingStages.get(SleepStage.Awake) || { stage: SleepStage.Awake, duration: 0, percentage: 0, quality: 'good' as any },
existingStages.get(SleepStage.REM) || { stage: SleepStage.REM, duration: 0, percentage: 0, quality: 'good' as any },
existingStages.get(SleepStage.Core) || { stage: SleepStage.Core, duration: 0, percentage: 0, quality: 'good' as any },
existingStages.get(SleepStage.Deep) || { stage: SleepStage.Deep, duration: 0, percentage: 0, quality: 'good' as any }
];
} else {
// 使用默认数据
stagesToDisplay = [
{ stage: SleepStage.Awake, duration: 3, percentage: 1, quality: 'good' as any },
{ stage: SleepStage.REM, duration: 89, percentage: 20, quality: 'good' as any },
{ stage: SleepStage.Core, duration: 295, percentage: 67, quality: 'good' as any },
{ stage: SleepStage.Deep, duration: 51, percentage: 12, quality: 'poor' as any }
];
}
return stagesToDisplay;
})().map((stageData, index) => {
const getStageName = (stage: SleepStage) => {
switch (stage) {
case SleepStage.Awake: return '清醒时间';
case SleepStage.REM: return '快速眼动';
case SleepStage.Core: return '核心睡眠';
case SleepStage.Deep: return '深度睡眠';
default: return '未知';
}
};
const getQualityDisplay = (quality: any) => {
switch (quality) {
case 'excellent': return { text: '★ 优秀', color: '#10B981', bgColor: '#D1FAE5', progressColor: '#10B981', progressWidth: '100%' };
case 'good': return { text: '✓ 良好', color: '#065F46', bgColor: '#D1FAE5', progressColor: '#10B981', progressWidth: '85%' };
case 'fair': return { text: '○ 一般', color: '#92400E', bgColor: '#FEF3C7', progressColor: '#F59E0B', progressWidth: '65%' };
case 'poor': return { text: '⚠ 低', color: '#DC2626', bgColor: '#FECACA', progressColor: '#F59E0B', progressWidth: '45%' };
default: return { text: '✓ 正常', color: '#065F46', bgColor: '#D1FAE5', progressColor: '#10B981', progressWidth: '75%' };
}
};
const qualityInfo = getQualityDisplay(stageData.quality);
return (
<View key={index} style={[styles.stageCard, { backgroundColor: colorTokens.background }]}>
<Text style={[styles.stageCardTitle, { color: getSleepStageColor(stageData.stage) }]}>
{getStageName(stageData.stage)}
</Text>
<Text style={[styles.stageCardValue, { color: colorTokens.text }]}>
{formatSleepTime(stageData.duration)}
</Text>
<Text style={[styles.stageCardPercentage, { color: colorTokens.textSecondary }]}>
{stageData.percentage}%
</Text>
<View style={[styles.stageCardQuality, { backgroundColor: qualityInfo.bgColor }]}>
<Text style={[styles.stageCardQualityText, { color: qualityInfo.color }]}>
{qualityInfo.text}
</Text>
</View>
</View>
);
})}
</View>
{/* Raw Sleep Samples List - 显示所有原始睡眠数据 */}
{sleepData && sleepData.rawSleepSamples && sleepData.rawSleepSamples.length > 1 && (
<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 && (
<InfoModal
visible={infoModal.visible}
onClose={() => setInfoModal({ ...infoModal, visible: false })}
title={infoModal.title}
type={infoModal.type}
sleepData={displayData}
/>
)}
<SleepStagesInfoModal
visible={sleepStagesModal.visible}
onClose={() => setSleepStagesModal({ visible: false })}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#F8FAFC',
},
gradientBackground: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
},
scrollView: {
flex: 1,
},
scrollContent: {
paddingHorizontal: 20,
paddingBottom: 40,
},
scoreContainer: {
alignItems: 'center',
},
circularProgressContainer: {
position: 'relative',
alignItems: 'center',
justifyContent: 'center',
},
scoreTextContainer: {
alignItems: 'center',
justifyContent: 'center',
},
scoreNumber: {
fontSize: 48,
fontWeight: '800',
color: '#1F2937',
lineHeight: 48,
},
scoreLabel: {
fontSize: 14,
color: '#6B7280',
marginTop: 4,
},
qualityDescription: {
fontSize: 18,
fontWeight: '600',
color: '#1F2937',
textAlign: 'center',
marginBottom: 16,
lineHeight: 24,
},
recommendationText: {
fontSize: 14,
color: '#6B7280',
textAlign: 'center',
lineHeight: 20,
marginBottom: 32,
paddingHorizontal: 16,
},
statsContainer: {
flexDirection: 'row',
gap: 12,
marginBottom: 32,
paddingHorizontal: 4,
},
newStatCard: {
flex: 1,
borderRadius: 20,
padding: 16,
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.08,
shadowRadius: 12,
elevation: 4,
borderWidth: 1,
borderColor: 'rgba(0, 0, 0, 0.06)',
},
statCardHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 8,
},
statCardLeftGroup: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
statCardIcon: {
width: 20,
height: 20,
borderRadius: 4,
backgroundColor: 'rgba(120, 120, 128, 0.08)',
alignItems: 'center',
justifyContent: 'center',
alignSelf: 'center',
},
infoButton: {
padding: 4,
alignSelf: 'center',
},
statCard: {
flex: 1,
backgroundColor: 'rgba(255, 255, 255, 0.9)',
borderRadius: 16,
padding: 16,
alignItems: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 3,
},
statIcon: {
fontSize: 18,
},
statLabel: {
fontSize: 12,
fontWeight: '500',
letterSpacing: 0.2,
alignSelf: 'center',
},
newStatValue: {
fontSize: 20,
fontWeight: '600',
marginBottom: 12,
letterSpacing: -0.5,
},
qualityBadge: {
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 8,
alignSelf: 'flex-start',
},
goodQualityBadge: {
backgroundColor: '#D1FAE5',
},
excellentQualityBadge: {
backgroundColor: '#FEF3C7',
},
qualityBadgeText: {
fontSize: 12,
fontWeight: '600',
letterSpacing: 0.1,
},
goodQualityText: {
color: '#065F46',
},
excellentQualityText: {
color: '#92400E',
},
statValue: {
fontSize: 18,
fontWeight: '700',
color: '#1F2937',
marginBottom: 4,
},
statQuality: {
fontSize: 12,
color: '#10B981',
fontWeight: '500',
},
chartContainer: {
backgroundColor: 'rgba(255, 255, 255, 0.9)',
borderRadius: 16,
padding: 16,
marginBottom: 24,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 3,
},
chartHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 16,
},
chartTimeLabel: {
alignItems: 'center',
},
chartTimeText: {
fontSize: 12,
color: '#6B7280',
fontWeight: '500',
},
chartHeartRate: {
alignItems: 'center',
},
chartHeartRateText: {
fontSize: 12,
color: '#EF4444',
fontWeight: '600',
},
chartBars: {
flexDirection: 'row',
alignItems: 'flex-end',
height: 120,
gap: 2,
},
chartBar: {
borderRadius: 2,
minHeight: 8,
},
chartTimeScale: {
flexDirection: 'row',
justifyContent: 'space-between',
paddingHorizontal: 4,
marginTop: 8,
},
chartTimeScaleText: {
fontSize: 10,
color: '#9CA3AF',
textAlign: 'center',
},
layeredChartContainer: {
position: 'relative',
marginVertical: 16,
},
sleepBlock: {
borderRadius: 2,
borderWidth: 0.5,
borderColor: 'rgba(255, 255, 255, 0.2)',
},
baselineLine: {
height: 1,
backgroundColor: '#E5E7EB',
position: 'absolute',
},
stagesContainer: {
backgroundColor: 'rgba(255, 255, 255, 0.9)',
borderRadius: 16,
padding: 16,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 3,
},
stageRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 12,
borderBottomWidth: 1,
borderBottomColor: '#F3F4F6',
},
stageInfo: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
},
stageColorDot: {
width: 12,
height: 12,
borderRadius: 6,
marginRight: 12,
},
stageName: {
fontSize: 14,
color: '#374151',
fontWeight: '500',
},
stageStats: {
alignItems: 'flex-end',
},
stagePercentage: {
fontSize: 16,
fontWeight: '700',
color: '#1F2937',
},
stageDuration: {
fontSize: 12,
color: '#6B7280',
marginTop: 2,
},
stageQuality: {
fontSize: 11,
fontWeight: '600',
marginTop: 2,
},
loadingContainer: {
justifyContent: 'center',
alignItems: 'center',
},
loadingText: {
fontSize: 16,
color: '#6B7280',
marginTop: 16,
},
errorText: {
fontSize: 16,
color: '#6B7280',
marginBottom: 16,
},
retryButton: {
backgroundColor: Colors.light.primary,
borderRadius: 8,
paddingHorizontal: 24,
paddingVertical: 12,
},
retryButtonText: {
color: '#FFFFFF',
fontSize: 14,
fontWeight: '600',
},
noDataContainer: {
alignItems: 'center',
paddingVertical: 24,
},
noDataText: {
fontSize: 14,
color: '#9CA3AF',
fontStyle: 'italic',
},
// Info Modal 和 Grade Cards 样式已移动到独立组件中
mockDataToggle: {
paddingHorizontal: 12,
paddingVertical: 6,
backgroundColor: 'rgba(255, 255, 255, 0.2)',
borderRadius: 16,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.3)',
},
mockDataToggleText: {
fontSize: 12,
fontWeight: '600',
},
// 简化睡眠阶段图表样式
simplifiedChartContainer: {
backgroundColor: 'rgba(255, 255, 255, 0.9)',
borderRadius: 16,
padding: 16,
marginBottom: 24,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 3,
},
chartTitleContainer: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 16,
},
chartTitle: {
fontSize: 16,
fontWeight: '600',
color: '#1F2937',
},
chartInfoButton: {
padding: 4,
},
simplifiedChartBar: {
flexDirection: 'row',
height: 24,
borderRadius: 12,
overflow: 'hidden',
marginBottom: 16,
},
stageSegment: {
height: '100%',
},
chartLegend: {
gap: 8,
},
legendRow: {
flexDirection: 'row',
justifyContent: 'center',
},
legendItem: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
},
legendDot: {
width: 8,
height: 8,
borderRadius: 4,
marginRight: 6,
},
legendText: {
fontSize: 12,
color: '#6B7280',
fontWeight: '500',
},
// 睡眠阶段卡片网格样式
stagesGridContainer: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 12,
paddingHorizontal: 4,
},
stageCard: {
width: '48%',
borderRadius: 20,
padding: 16,
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.08,
shadowRadius: 12,
elevation: 4,
borderWidth: 1,
borderColor: 'rgba(0, 0, 0, 0.06)',
},
stageCardTitle: {
fontSize: 14,
fontWeight: '500',
marginBottom: 8,
},
stageCardValue: {
fontSize: 24,
fontWeight: '700',
lineHeight: 28,
marginBottom: 4,
},
stageCardPercentage: {
fontSize: 12,
marginBottom: 12,
},
stageCardQuality: {
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 8,
alignSelf: 'flex-start',
},
normalQuality: {
backgroundColor: '#D1FAE5',
},
lowQuality: {
backgroundColor: '#FECACA',
},
stageCardQualityText: {
fontSize: 12,
fontWeight: '600',
},
normalQualityText: {
color: '#065F46',
},
lowQualityText: {
color: '#DC2626',
},
stageCardProgress: {
height: 6,
backgroundColor: '#E5E7EB',
borderRadius: 3,
overflow: 'hidden',
},
stageCardProgressBar: {
height: '100%',
borderRadius: 3,
},
// Sleep Stages Modal 样式已移动到独立组件中
// 睡眠时间标签样式
sleepTimeLabels: {
flexDirection: 'row',
justifyContent: 'space-between',
marginBottom: 16,
},
sleepTimeLabel: {
alignItems: 'center',
},
sleepTimeText: {
fontSize: 12,
fontWeight: '500',
marginBottom: 4,
},
sleepTimeValue: {
fontSize: 16,
fontWeight: '700',
letterSpacing: -0.2,
},
// 调试信息样式
debugContainer: {
marginHorizontal: 20,
marginBottom: 20,
padding: 16,
borderRadius: 12,
borderWidth: 1,
borderColor: 'rgba(0, 0, 0, 0.1)',
},
debugTitle: {
fontSize: 14,
fontWeight: '600',
marginBottom: 8,
},
debugText: {
fontSize: 12,
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',
},
});