- Removed NitroModules and ReactNativeHealthkit from Podfile.lock and package files. - Updated Info.plist to increment app version from 2 to 3. - Refactored background task manager to define background tasks within the class. - Added new utility file for sleep data management, including fetching sleep samples, calculating sleep statistics, and generating sleep quality scores.
1067 lines
30 KiB
TypeScript
1067 lines
30 KiB
TypeScript
import {
|
||
fetchCompleteSleepData,
|
||
formatSleepTime,
|
||
formatTime,
|
||
getSleepStageColor,
|
||
SleepStage,
|
||
type CompleteSleepData
|
||
} from '@/utils/sleepHealthKit';
|
||
import { Ionicons } from '@expo/vector-icons';
|
||
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 { InfoModal, type SleepDetailData } from '@/components/sleep/InfoModal';
|
||
import { SleepStagesInfoModal } from '@/components/sleep/SleepStagesInfoModal';
|
||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||
import { Colors } from '@/constants/Colors';
|
||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||
|
||
|
||
|
||
// 简化的睡眠阶段图表组件
|
||
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<CompleteSleepData | 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
|
||
});
|
||
|
||
const loadSleepData = useCallback(async () => {
|
||
try {
|
||
setLoading(true);
|
||
console.log('开始加载睡眠数据...');
|
||
|
||
const data = await fetchCompleteSleepData(selectedDate);
|
||
setSleepData(data);
|
||
|
||
if (data) {
|
||
console.log('睡眠数据加载成功,得分:', data.sleepScore);
|
||
} else {
|
||
console.log('未找到睡眠数据');
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error('加载睡眠数据失败:', error);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [selectedDate]);
|
||
|
||
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: CompleteSleepData = 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: SleepStage) => {
|
||
switch (value) {
|
||
case SleepStage.InBed: return '在床上';
|
||
case SleepStage.Awake: return '清醒';
|
||
case SleepStage.Core: return '核心睡眠';
|
||
case SleepStage.Deep: return '深度睡眠';
|
||
case SleepStage.REM: return 'REM睡眠';
|
||
case SleepStage.Asleep: return '未指定睡眠';
|
||
default: return value;
|
||
}
|
||
};
|
||
|
||
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: getSleepStageColor(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 as SleepDetailData}
|
||
/>
|
||
)}
|
||
|
||
<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,
|
||
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',
|
||
},
|
||
}); |