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

1100 lines
32 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 dayjs from 'dayjs';
import { LinearGradient } from 'expo-linear-gradient';
import { router } from 'expo-router';
import React, { useEffect, useState } from 'react';
import {
ActivityIndicator,
Animated,
Dimensions,
Modal,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View
} from 'react-native';
import Svg, { Circle } from 'react-native-svg';
import { HeaderBar } from '@/components/ui/HeaderBar';
import { Colors } from '@/constants/Colors';
import { useColorScheme } from '@/hooks/useColorScheme';
import {
fetchSleepDetailForDate,
formatSleepTime,
formatTime,
getSleepStageColor,
getSleepStageDisplayName,
convertSleepSamplesToIntervals,
SleepDetailData,
SleepStage
} from '@/services/sleepService';
import { ensureHealthPermissions } from '@/utils/health';
const { width } = Dimensions.get('window');
// 圆形进度条组件
const CircularProgress = ({
size,
strokeWidth,
progress,
color,
backgroundColor = '#E5E7EB'
}: {
size: number;
strokeWidth: number;
progress: number; // 0-100
color: string;
backgroundColor?: string;
}) => {
const radius = (size - strokeWidth) / 2;
const circumference = radius * 2 * Math.PI;
const strokeDasharray = circumference;
const strokeDashoffset = circumference - (progress / 100) * circumference;
return (
<Svg width={size} height={size} style={{ transform: [{ rotateZ: '-90deg' }] }}>
{/* 背景圆环 */}
<Circle
cx={size / 2}
cy={size / 2}
r={radius}
stroke={backgroundColor}
strokeWidth={strokeWidth}
fill="none"
/>
{/* 进度圆环 */}
<Circle
cx={size / 2}
cy={size / 2}
r={radius}
stroke={color}
strokeWidth={strokeWidth}
fill="none"
strokeDasharray={strokeDasharray}
strokeDashoffset={strokeDashoffset}
strokeLinecap="round"
/>
</Svg>
);
};
// 睡眠阶段图表组件
const SleepStageChart = ({ sleepData }: { sleepData: SleepDetailData }) => {
const chartWidth = width - 80;
const chartHeight = 120;
const coreBaselineHeight = chartHeight * 0.6; // 核心睡眠作为基准线
const blockHeight = 20; // 每个睡眠阶段块的固定高度
// 使用真实的 HealthKit 睡眠数据
const generateRealSleepData = () => {
// 如果没有睡眠数据,返回空数组
if (sleepData.totalSleepTime === 0 || !sleepData.rawSleepSamples || sleepData.rawSleepSamples.length === 0) {
console.log('没有可用的睡眠数据用于图表显示');
return [];
}
console.log('使用真实 HealthKit 睡眠数据生成图表,样本数量:', sleepData.rawSleepSamples.length);
// 使用新的转换函数将睡眠样本转换为15分钟间隔数据
const intervalData = convertSleepSamplesToIntervals(
sleepData.rawSleepSamples,
sleepData.bedtime,
sleepData.wakeupTime
);
if (intervalData.length === 0) {
console.log('无法生成睡眠阶段间隔数据 - 可能只有基本的InBed/Asleep数据');
// 如果没有详细的睡眠阶段数据,生成基本的模拟数据作为回退
return generateFallbackSleepData();
}
return intervalData;
};
// 回退方案:当没有详细睡眠阶段数据时使用
const generateFallbackSleepData = () => {
console.log('使用回退睡眠数据 - 用户可能没有Apple Watch或详细睡眠追踪');
const data: { time: string; stage: SleepStage }[] = [];
const bedtime = new Date(sleepData.bedtime);
const wakeupTime = new Date(sleepData.wakeupTime);
let currentTime = new Date(bedtime);
// 基于典型睡眠模式生成合理的睡眠阶段分布
while (currentTime < wakeupTime) {
const timeStr = `${String(currentTime.getHours()).padStart(2, '0')}:${String(currentTime.getMinutes()).padStart(2, '0')}`;
const sleepDuration = wakeupTime.getTime() - bedtime.getTime();
const currentProgress = (currentTime.getTime() - bedtime.getTime()) / sleepDuration;
let stage: SleepStage;
if (currentProgress < 0.15 || currentProgress > 0.85) {
stage = Math.random() < 0.6 ? SleepStage.Core : SleepStage.Awake;
} else if (currentProgress < 0.4) {
stage = Math.random() < 0.7 ? SleepStage.Deep : SleepStage.Core;
} else if (currentProgress < 0.7) {
const rand = Math.random();
stage = rand < 0.6 ? SleepStage.Core : (rand < 0.9 ? SleepStage.REM : SleepStage.Awake);
} else {
const rand = Math.random();
stage = rand < 0.5 ? SleepStage.REM : (rand < 0.9 ? SleepStage.Core : SleepStage.Awake);
}
data.push({ time: timeStr, stage });
currentTime.setMinutes(currentTime.getMinutes() + 15);
}
return data;
};
const sleepDataPoints = generateRealSleepData();
// 获取睡眠阶段在Y轴上的位置
const getStageYPosition = (stage: SleepStage) => {
switch (stage) {
case SleepStage.Awake:
return coreBaselineHeight - blockHeight * 2; // 最上方
case SleepStage.REM:
return coreBaselineHeight - blockHeight; // 上方
case SleepStage.Core:
return coreBaselineHeight; // 基准线
case SleepStage.Deep:
return coreBaselineHeight + blockHeight; // 下方
default:
return coreBaselineHeight;
}
};
// 获取时间标签
const getTimeLabels = () => {
if (sleepData.totalSleepTime === 0) {
return { startTime: '--:--', endTime: '--:--' };
}
return {
startTime: formatTime(sleepData.bedtime),
endTime: formatTime(sleepData.wakeupTime)
};
};
const { startTime, endTime } = getTimeLabels();
return (
<View style={styles.chartContainer}>
<View style={styles.chartHeader}>
<View style={styles.chartTimeLabel}>
<Text style={styles.chartTimeText}>🛏 {startTime}</Text>
</View>
<View style={styles.chartHeartRate}>
<Text style={styles.chartHeartRateText}> : {sleepData.averageHeartRate || '--'} BPM</Text>
</View>
<View style={styles.chartTimeLabel}>
<Text style={styles.chartTimeText}> {endTime}</Text>
</View>
</View>
{/* 分层睡眠阶段图表 */}
<View style={[styles.layeredChartContainer, { height: chartHeight }]}>
{sleepDataPoints.map((dataPoint, index) => {
const blockWidth = chartWidth / sleepDataPoints.length - 1;
const yPosition = getStageYPosition(dataPoint.stage);
const color = getSleepStageColor(dataPoint.stage);
return (
<View
key={index}
style={[
styles.sleepBlock,
{
width: blockWidth,
height: blockHeight,
backgroundColor: color,
position: 'absolute',
left: index * (blockWidth),
top: yPosition,
}
]}
/>
);
})}
</View>
{/* 时间刻度 */}
<View style={styles.chartTimeScale}>
<Text style={styles.chartTimeScaleText}>{startTime}</Text>
<Text style={styles.chartTimeScaleText}>{endTime}</Text>
</View>
</View>
);
};
// Sleep Grade Component 睡眠等级组件
const SleepGradeCard = ({
icon,
grade,
range,
isActive = false
}: {
icon: string;
grade: string;
range: string;
isActive?: boolean;
}) => {
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme];
const getGradeColor = (grade: string) => {
switch (grade) {
case '低': case '较差': return { bg: '#FECACA', text: '#DC2626' };
case '正常': case '一般': return { bg: '#D1FAE5', text: '#065F46' };
case '良好': return { bg: '#D1FAE5', text: '#065F46' };
case '优秀': return { bg: '#FEF3C7', text: '#92400E' };
default: return { bg: colorTokens.pageBackgroundEmphasis, text: colorTokens.textSecondary };
}
};
const colors = getGradeColor(grade);
return (
<View style={[
styles.gradeCard,
{
backgroundColor: isActive ? colors.bg : colorTokens.pageBackgroundEmphasis,
borderColor: isActive ? colors.text : 'transparent',
}
]}>
<View style={styles.gradeCardLeft}>
<Ionicons name={icon as any} size={16} color={colors.text} />
<Text style={[
styles.gradeText,
{ color: isActive ? colors.text : colorTokens.textSecondary }
]}>
{grade}
</Text>
</View>
<Text style={[
styles.gradeRange,
{ color: isActive ? colors.text : colorTokens.textSecondary }
]}>
{range}
</Text>
</View>
);
};
// Info Modal 组件
const InfoModal = ({
visible,
onClose,
title,
type,
sleepData
}: {
visible: boolean;
onClose: () => void;
title: string;
type: 'sleep-time' | 'sleep-quality';
sleepData: SleepDetailData;
}) => {
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme];
const slideAnim = useState(new Animated.Value(0))[0];
React.useEffect(() => {
if (visible) {
// 重置动画值确保每次打开都有动画
slideAnim.setValue(0);
Animated.spring(slideAnim, {
toValue: 1,
useNativeDriver: true,
tension: 100,
friction: 8,
}).start();
} else {
Animated.spring(slideAnim, {
toValue: 0,
useNativeDriver: true,
tension: 100,
friction: 8,
}).start();
}
}, [visible]);
const translateY = slideAnim.interpolate({
inputRange: [0, 1],
outputRange: [300, 0],
});
const opacity = slideAnim.interpolate({
inputRange: [0, 1],
outputRange: [0, 1],
});
// 根据实际睡眠时间计算等级
const getSleepTimeGrade = (totalSleepMinutes: number) => {
const hours = totalSleepMinutes / 60;
if (hours < 6) return 0; // 低
if ((hours >= 6 && hours < 7) || hours > 9) return 1; // 正常
if (hours >= 7 && hours < 8) return 2; // 良好
if (hours >= 8 && hours <= 9) return 3; // 优秀
return 1; // 默认正常
};
// 根据实际睡眠质量百分比计算等级
const getSleepQualityGrade = (qualityPercentage: number) => {
if (qualityPercentage < 55) return 0; // 较差
if (qualityPercentage < 70) return 1; // 一般
if (qualityPercentage < 85) return 2; // 良好
return 3; // 优秀
};
const currentSleepTimeGrade = getSleepTimeGrade(sleepData.totalSleepTime || 443); // 默认7h23m
const currentSleepQualityGrade = getSleepQualityGrade(sleepData.sleepQualityPercentage || 94); // 默认94%
const sleepTimeGrades = [
{ icon: 'alert-circle-outline', grade: '低', range: '< 6h', isActive: currentSleepTimeGrade === 0 },
{ icon: 'checkmark-circle-outline', grade: '正常', range: '6h - 7h or > 9h', isActive: currentSleepTimeGrade === 1 },
{ icon: 'checkmark-circle', grade: '良好', range: '7h - 8h', isActive: currentSleepTimeGrade === 2 },
{ icon: 'star', grade: '优秀', range: '8h - 9h', isActive: currentSleepTimeGrade === 3 },
];
const sleepQualityGrades = [
{ icon: 'alert-circle-outline', grade: '较差', range: '< 55%', isActive: currentSleepQualityGrade === 0 },
{ icon: 'checkmark-circle-outline', grade: '一般', range: '55% - 69%', isActive: currentSleepQualityGrade === 1 },
{ icon: 'checkmark-circle', grade: '良好', range: '70% - 84%', isActive: currentSleepQualityGrade === 2 },
{ icon: 'star', grade: '优秀', range: '85% - 100%', isActive: currentSleepQualityGrade === 3 },
];
const currentGrades = type === 'sleep-time' ? sleepTimeGrades : sleepQualityGrades;
const getDescription = () => {
if (type === 'sleep-time') {
return '睡眠最重要 - 它占据了你睡眠得分的一半以上。长时间的睡眠可以减少睡眠债务,但是规律的睡眠时间对于高质量的休息至关重要。';
} else {
return '睡眠质量综合评估您的睡眠效率、深度睡眠时长、REM睡眠比例等多个指标。高质量的睡眠不仅仅取决于时长还包括睡眠的连续性和各睡眠阶段的平衡。';
}
};
return (
<Modal
transparent
visible={visible}
animationType="none"
onRequestClose={onClose}
>
<TouchableOpacity
style={styles.modalOverlay}
activeOpacity={1}
onPress={onClose}
>
<Animated.View style={[
styles.infoModalContent,
{
backgroundColor: colorTokens.background,
transform: [{ translateY }],
opacity,
}
]}>
<View style={styles.modalHandle} />
<View style={styles.infoModalHeader}>
<Text style={[styles.infoModalTitle, { color: colorTokens.text }]}>
{title}
</Text>
<TouchableOpacity onPress={onClose} style={styles.infoModalCloseButton}>
<Ionicons name="close" size={20} color={colorTokens.textSecondary} />
</TouchableOpacity>
</View>
{/* 等级卡片区域 */}
<View style={styles.gradesContainer}>
{currentGrades.map((grade, index) => (
<SleepGradeCard
key={index}
icon={grade.icon}
grade={grade.grade}
range={grade.range}
isActive={grade.isActive}
/>
))}
</View>
<Text style={[styles.infoModalText, { color: colorTokens.textSecondary }]}>
{getDescription()}
</Text>
</Animated.View>
</TouchableOpacity>
</Modal>
);
};
export default function SleepDetailScreen() {
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme];
const [sleepData, setSleepData] = useState<SleepDetailData | 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
});
useEffect(() => {
loadSleepData();
}, [selectedDate]);
const loadSleepData = async () => {
try {
setLoading(true);
// 确保有健康权限
const hasPermission = await ensureHealthPermissions();
if (!hasPermission) {
console.warn('没有健康数据权限');
return;
}
// 获取睡眠详情数据
const data = await fetchSleepDetailForDate(selectedDate);
setSleepData(data);
} catch (error) {
console.error('加载睡眠数据失败:', error);
} finally {
setLoading(false);
}
};
if (loading) {
return (
<View style={[styles.container, styles.loadingContainer]}>
<ActivityIndicator size="large" color={Colors.light.primary} />
<Text style={styles.loadingText}>...</Text>
</View>
);
}
// 如果没有数据,使用默认数据结构
const displayData: SleepDetailData = sleepData || {
sleepScore: 0,
totalSleepTime: 0,
sleepQualityPercentage: 0,
bedtime: new Date().toISOString(),
wakeupTime: new Date().toISOString(),
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.circularProgressContainer}>
<CircularProgress
size={200}
strokeWidth={12}
progress={displayData.sleepScore}
color="#8B5CF6"
backgroundColor="#E0E7FF"
/>
<View style={styles.scoreTextContainer}>
<Text style={styles.scoreNumber}>{displayData.sleepScore}</Text>
<Text style={styles.scoreLabel}></Text>
</View>
</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} />
{/* 睡眠阶段统计 */}
<View style={styles.stagesContainer}>
{displayData.sleepStages.length > 0 ? displayData.sleepStages.map((stage, index) => (
<View key={index} style={styles.stageRow}>
<View style={styles.stageInfo}>
<View style={[styles.stageColorDot, { backgroundColor: getSleepStageColor(stage.stage) }]} />
<Text style={styles.stageName}>{getSleepStageDisplayName(stage.stage)}</Text>
</View>
<View style={styles.stageStats}>
<Text style={styles.stagePercentage}>{stage.percentage}%</Text>
<Text style={styles.stageDuration}>{formatSleepTime(stage.duration)}</Text>
<Text style={[
styles.stageQuality,
{
color: stage.quality === 'excellent' ? '#10B981' :
stage.quality === 'good' ? '#059669' :
stage.quality === 'fair' ? '#F59E0B' : '#EF4444'
}
]}>
{stage.quality === 'excellent' ? '优秀' :
stage.quality === 'good' ? '良好' :
stage.quality === 'fair' ? '一般' : '偏低'}
</Text>
</View>
</View>
)) : (
/* 当没有真实数据时,显示包含清醒时间的模拟数据 */
<>
{/* 深度睡眠 */}
<View style={styles.stageRow}>
<View style={styles.stageInfo}>
<View style={[styles.stageColorDot, { backgroundColor: getSleepStageColor(SleepStage.Deep) }]} />
<Text style={styles.stageName}>{getSleepStageDisplayName(SleepStage.Deep)}</Text>
</View>
<View style={styles.stageStats}>
<Text style={styles.stagePercentage}>28%</Text>
<Text style={styles.stageDuration}>2h 04m</Text>
<Text style={[styles.stageQuality, { color: '#10B981' }]}></Text>
</View>
</View>
{/* REM睡眠 */}
<View style={styles.stageRow}>
<View style={styles.stageInfo}>
<View style={[styles.stageColorDot, { backgroundColor: getSleepStageColor(SleepStage.REM) }]} />
<Text style={styles.stageName}>{getSleepStageDisplayName(SleepStage.REM)}</Text>
</View>
<View style={styles.stageStats}>
<Text style={styles.stagePercentage}>22%</Text>
<Text style={styles.stageDuration}>1h 37m</Text>
<Text style={[styles.stageQuality, { color: '#10B981' }]}></Text>
</View>
</View>
{/* 核心睡眠 */}
<View style={styles.stageRow}>
<View style={styles.stageInfo}>
<View style={[styles.stageColorDot, { backgroundColor: getSleepStageColor(SleepStage.Core) }]} />
<Text style={styles.stageName}>{getSleepStageDisplayName(SleepStage.Core)}</Text>
</View>
<View style={styles.stageStats}>
<Text style={styles.stagePercentage}>38%</Text>
<Text style={styles.stageDuration}>2h 48m</Text>
<Text style={[styles.stageQuality, { color: '#059669' }]}></Text>
</View>
</View>
{/* 清醒时间 */}
<View style={styles.stageRow}>
<View style={styles.stageInfo}>
<View style={[styles.stageColorDot, { backgroundColor: getSleepStageColor(SleepStage.Awake) }]} />
<Text style={styles.stageName}>{getSleepStageDisplayName(SleepStage.Awake)}</Text>
</View>
<View style={styles.stageStats}>
<Text style={styles.stagePercentage}>12%</Text>
<Text style={styles.stageDuration}>54m</Text>
<Text style={[styles.stageQuality, { color: '#F59E0B' }]}></Text>
</View>
</View>
</>
)}
</View>
</ScrollView>
{infoModal.type && (
<InfoModal
visible={infoModal.visible}
onClose={() => setInfoModal({ ...infoModal, visible: false })}
title={infoModal.title}
type={infoModal.type}
sleepData={displayData}
/>
)}
</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',
marginVertical: 20,
},
circularProgressContainer: {
position: 'relative',
alignItems: 'center',
justifyContent: 'center',
},
scoreTextContainer: {
position: 'absolute',
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 样式
modalOverlay: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
justifyContent: 'flex-end',
},
infoModalContent: {
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
paddingTop: 12,
paddingHorizontal: 20,
paddingBottom: 34,
minHeight: 200,
shadowColor: '#000',
shadowOffset: { width: 0, height: -4 },
shadowOpacity: 0.1,
shadowRadius: 16,
elevation: 8,
},
modalHandle: {
width: 36,
height: 4,
backgroundColor: '#D1D5DB',
borderRadius: 2,
alignSelf: 'center',
marginBottom: 20,
},
infoModalHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 16,
},
infoModalTitle: {
fontSize: 18,
fontWeight: '700',
letterSpacing: -0.3,
},
infoModalCloseButton: {
padding: 4,
},
infoModalText: {
fontSize: 15,
lineHeight: 22,
letterSpacing: -0.1,
},
// Grade Cards 样式
gradesContainer: {
marginBottom: 20,
gap: 8,
},
gradeCard: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 12,
borderRadius: 12,
borderWidth: 1,
},
gradeCardLeft: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
gradeText: {
fontSize: 16,
fontWeight: '600',
letterSpacing: -0.2,
},
gradeRange: {
fontSize: 16,
fontWeight: '700',
letterSpacing: -0.3,
},
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',
},
});