feat: 添加睡眠详情页面,集成睡眠数据获取功能,优化健康数据权限管理,更新相关组件以支持睡眠统计和展示
This commit is contained in:
8
app.json
8
app.json
@@ -88,14 +88,6 @@
|
||||
{
|
||||
"minimumInterval": 15
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-task-manager",
|
||||
{
|
||||
"taskManagers": [
|
||||
"background-health-reminders"
|
||||
]
|
||||
}
|
||||
]
|
||||
],
|
||||
"experiments": {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { FitnessRingsCard } from '@/components/FitnessRingsCard';
|
||||
import { MoodCard } from '@/components/MoodCard';
|
||||
import { NutritionRadarCard } from '@/components/NutritionRadarCard';
|
||||
import OxygenSaturationCard from '@/components/statistic/OxygenSaturationCard';
|
||||
import SleepCard from '@/components/statistic/SleepCard';
|
||||
import StepsCard from '@/components/StepsCard';
|
||||
import { StressMeter } from '@/components/StressMeter';
|
||||
import WaterIntakeCard from '@/components/WaterIntakeCard';
|
||||
@@ -12,6 +13,7 @@ import { Colors } from '@/constants/Colors';
|
||||
import { getTabBarBottomPadding } from '@/constants/TabBar';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||
import { backgroundTaskManager } from '@/services/backgroundTaskManager';
|
||||
import { selectHealthDataByDate, setHealthData } from '@/store/healthSlice';
|
||||
import { fetchDailyMoodCheckins, selectLatestMoodRecordByDate } from '@/store/moodSlice';
|
||||
import { fetchDailyNutritionData, selectNutritionSummaryByDate } from '@/store/nutritionSlice';
|
||||
@@ -21,7 +23,6 @@ import { ensureHealthPermissions, fetchHealthDataForDate, testHRVDataFetch } fro
|
||||
import { getTestHealthData } from '@/utils/mockHealthData';
|
||||
import { calculateNutritionGoals } from '@/utils/nutrition';
|
||||
import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
import dayjs from 'dayjs';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { debounce } from 'lodash';
|
||||
@@ -64,7 +65,8 @@ export default function ExploreScreen() {
|
||||
const userProfile = useAppSelector((s) => s.user.profile);
|
||||
|
||||
// 开发调试:设置为true来使用mock数据
|
||||
const useMockData = __DEV__; // 改为true来启用mock数据调试
|
||||
// 在真机测试时,可以暂时设置为true来验证组件显示逻辑
|
||||
const useMockData = __DEV__ || false; // 改为true来启用mock数据调试
|
||||
|
||||
const { pushIfAuthedElseLogin, isLoggedIn } = useAuthGuard();
|
||||
|
||||
@@ -97,9 +99,18 @@ export default function ExploreScreen() {
|
||||
const activeCalories = useMockData ? (mockData?.activeEnergyBurned ?? null) : (healthData?.activeEnergyBurned ?? null);
|
||||
const basalMetabolism: number | null = useMockData ? (mockData?.basalEnergyBurned ?? null) : (healthData?.basalEnergyBurned ?? null);
|
||||
const sleepDuration = useMockData ? (mockData?.sleepDuration ?? null) : (healthData?.sleepDuration ?? null);
|
||||
const hrvValue = useMockData ? (mockData?.hrv ?? 0) : (healthData?.hrv ?? 0);
|
||||
const hrvValue = useMockData ? (mockData?.hrv ?? null) : (healthData?.hrv ?? null);
|
||||
const oxygenSaturation = useMockData ? (mockData?.oxygenSaturation ?? null) : (healthData?.oxygenSaturation ?? null);
|
||||
|
||||
// 调试HRV数据
|
||||
console.log('=== HRV数据调试 ===');
|
||||
console.log('useMockData:', useMockData);
|
||||
console.log('mockData?.hrv:', mockData?.hrv);
|
||||
console.log('healthData?.hrv:', healthData?.hrv);
|
||||
console.log('final hrvValue:', hrvValue);
|
||||
console.log('healthData:', healthData);
|
||||
console.log('==================');
|
||||
|
||||
const fitnessRingsData = useMockData ? {
|
||||
activeCalories: mockData?.activeCalories ?? 0,
|
||||
activeCaloriesGoal: mockData?.activeCaloriesGoal ?? 350,
|
||||
@@ -269,6 +280,8 @@ export default function ExploreScreen() {
|
||||
const data = await fetchHealthDataForDate(derivedDate);
|
||||
|
||||
console.log('设置UI状态:', data);
|
||||
console.log('HRV数据详细信息:', data.hrv, typeof data.hrv);
|
||||
|
||||
// 仅当该请求仍是最新时,才应用结果
|
||||
if (latestRequestKeyRef.current === requestKey) {
|
||||
const dateString = dayjs(derivedDate).format('YYYY-MM-DD');
|
||||
@@ -276,7 +289,22 @@ export default function ExploreScreen() {
|
||||
// 使用 Redux 存储健康数据
|
||||
dispatch(setHealthData({
|
||||
date: dateString,
|
||||
data: data
|
||||
data: {
|
||||
steps: data.steps,
|
||||
activeCalories: data.activeEnergyBurned,
|
||||
basalEnergyBurned: data.basalEnergyBurned,
|
||||
sleepDuration: data.sleepDuration,
|
||||
hrv: data.hrv,
|
||||
oxygenSaturation: data.oxygenSaturation,
|
||||
heartRate: data.heartRate,
|
||||
activeEnergyBurned: data.activeEnergyBurned,
|
||||
activeCaloriesGoal: data.activeCaloriesGoal,
|
||||
exerciseMinutes: data.exerciseMinutes,
|
||||
exerciseMinutesGoal: data.exerciseMinutesGoal,
|
||||
standHours: data.standHours,
|
||||
standHoursGoal: data.standHoursGoal,
|
||||
hourlySteps: data.hourlySteps,
|
||||
}
|
||||
}));
|
||||
|
||||
// 更新HRV数据时间
|
||||
@@ -374,26 +402,33 @@ export default function ExploreScreen() {
|
||||
}, [executeLoadAllData, debouncedLoadAllData]);
|
||||
|
||||
// 页面聚焦时的数据加载逻辑
|
||||
useFocusEffect(
|
||||
React.useCallback(() => {
|
||||
// 页面聚焦时加载数据,使用缓存机制避免频繁请求
|
||||
console.log('页面聚焦,检查是否需要刷新数据...');
|
||||
loadAllData(currentSelectedDate);
|
||||
}, [loadAllData, currentSelectedDate])
|
||||
);
|
||||
// useFocusEffect(
|
||||
// React.useCallback(() => {
|
||||
// // 页面聚焦时加载数据,使用缓存机制避免频繁请求
|
||||
// console.log('页面聚焦,检查是否需要刷新数据...');
|
||||
// loadAllData(currentSelectedDate);
|
||||
// }, [loadAllData, currentSelectedDate])
|
||||
// );
|
||||
|
||||
// AppState 监听:应用从后台返回前台时的处理
|
||||
useEffect(() => {
|
||||
let appStateChangeTimeout: number;
|
||||
|
||||
const handleAppStateChange = (nextAppState: string) => {
|
||||
if (nextAppState === 'active') {
|
||||
// 延迟执行,避免与 useFocusEffect 重复触发
|
||||
appStateChangeTimeout = setTimeout(() => {
|
||||
console.log('应用从后台返回前台,强制刷新统计数据...');
|
||||
// 从后台返回时强制刷新数据
|
||||
loadAllData(currentSelectedDate, true);
|
||||
}, 500);
|
||||
// 判断当前选中的日期是否是最新的(今天)
|
||||
const todayIndex = getTodayIndexInMonth();
|
||||
const isTodaySelected = selectedIndex === todayIndex;
|
||||
|
||||
if (!isTodaySelected) {
|
||||
// 如果当前不是选中今天,则切换到今天(这个更新会触发数据加载)
|
||||
console.log('应用回到前台,切换到今天并加载数据');
|
||||
setSelectedIndex(todayIndex);
|
||||
// 注意:这里不直接调用loadAllData,因为setSelectedIndex会触发useEffect重新计算currentSelectedDate
|
||||
// 然后onSelectDate会被调用,从而触发数据加载
|
||||
} else {
|
||||
// 如果已经是今天,则直接调用加载数据的方法
|
||||
console.log('应用回到前台,当前已是今天,直接加载数据');
|
||||
loadAllData(currentSelectedDate);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -401,11 +436,8 @@ export default function ExploreScreen() {
|
||||
|
||||
return () => {
|
||||
subscription?.remove();
|
||||
if (appStateChangeTimeout) {
|
||||
clearTimeout(appStateChangeTimeout);
|
||||
}
|
||||
};
|
||||
}, [loadAllData, currentSelectedDate]);
|
||||
}, [loadAllData, currentSelectedDate, selectedIndex]);
|
||||
|
||||
|
||||
// 日期点击时,加载对应日期数据
|
||||
@@ -463,7 +495,7 @@ export default function ExploreScreen() {
|
||||
style={styles.debugButton}
|
||||
onPress={async () => {
|
||||
console.log('🔧 手动触发后台任务测试...');
|
||||
// await backgroundTaskManager.triggerTaskForTesting();
|
||||
await backgroundTaskManager.triggerTaskForTesting();
|
||||
}}
|
||||
>
|
||||
<Text style={styles.debugButtonText}>🔧</Text>
|
||||
@@ -555,16 +587,10 @@ export default function ExploreScreen() {
|
||||
</FloatingCard> */}
|
||||
|
||||
<FloatingCard style={styles.masonryCard}>
|
||||
<View style={styles.cardHeaderRow}>
|
||||
<Text style={styles.cardTitle}>睡眠</Text>
|
||||
</View>
|
||||
{sleepDuration != null ? (
|
||||
<Text style={styles.sleepValue}>
|
||||
{Math.floor(sleepDuration / 60)}小时{Math.floor(sleepDuration % 60)}分钟
|
||||
</Text>
|
||||
) : (
|
||||
<Text style={styles.sleepValue}>——</Text>
|
||||
)}
|
||||
<SleepCard
|
||||
sleepDuration={sleepDuration}
|
||||
onPress={() => pushIfAuthedElseLogin('/sleep-detail')}
|
||||
/>
|
||||
</FloatingCard>
|
||||
</View>
|
||||
|
||||
@@ -960,12 +986,6 @@ const styles = StyleSheet.create({
|
||||
justifyContent: 'space-between',
|
||||
marginTop: 8,
|
||||
},
|
||||
sleepValue: {
|
||||
fontSize: 16,
|
||||
color: '#1E40AF',
|
||||
fontWeight: '700',
|
||||
marginTop: 8,
|
||||
},
|
||||
weightCard: {
|
||||
backgroundColor: '#F0F9FF',
|
||||
},
|
||||
|
||||
@@ -73,7 +73,8 @@ function Bootstrapper({ children }: { children: React.ReactNode }) {
|
||||
if (userDataLoaded && profile?.name) {
|
||||
try {
|
||||
await notificationService.initialize();
|
||||
|
||||
// 后台任务
|
||||
await backgroundTaskManager.initialize()
|
||||
// 注册午餐提醒(12:00)
|
||||
await NutritionNotificationHelpers.scheduleDailyLunchReminder(profile.name);
|
||||
console.log('午餐提醒已注册');
|
||||
@@ -86,8 +87,7 @@ function Bootstrapper({ children }: { children: React.ReactNode }) {
|
||||
await MoodNotificationHelpers.scheduleDailyMoodReminder(profile.name);
|
||||
console.log('心情提醒已注册');
|
||||
|
||||
// 注册喝水提醒后台任务
|
||||
await backgroundTaskManager.registerWaterReminderTask();
|
||||
|
||||
console.log('喝水提醒后台任务已注册');
|
||||
} catch (error) {
|
||||
console.error('注册提醒失败:', error);
|
||||
|
||||
569
app/sleep-detail.tsx
Normal file
569
app/sleep-detail.tsx
Normal file
@@ -0,0 +1,569 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
Dimensions,
|
||||
ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { router } from 'expo-router';
|
||||
import dayjs from 'dayjs';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import Svg, { Circle } from 'react-native-svg';
|
||||
|
||||
import {
|
||||
fetchSleepDetailForDate,
|
||||
SleepDetailData,
|
||||
SleepStage,
|
||||
getSleepStageDisplayName,
|
||||
getSleepStageColor,
|
||||
formatSleepTime,
|
||||
formatTime
|
||||
} from '@/services/sleepService';
|
||||
import { ensureHealthPermissions } from '@/utils/health';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
|
||||
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 maxHeight = 120;
|
||||
|
||||
// 生成24小时的睡眠阶段数据(模拟数据,实际应根据真实样本计算)
|
||||
const hourlyData = Array.from({ length: 24 }, (_, hour) => {
|
||||
// 如果没有数据,显示空状态
|
||||
if (sleepData.totalSleepTime === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 根据时间判断可能的睡眠状态
|
||||
if (hour >= 0 && hour <= 6) {
|
||||
// 凌晨0-6点,主要睡眠时间
|
||||
if (hour <= 2) return SleepStage.Core;
|
||||
if (hour <= 4) return SleepStage.Deep;
|
||||
return SleepStage.REM;
|
||||
} else if (hour >= 22) {
|
||||
// 晚上10点后开始入睡
|
||||
return SleepStage.Core;
|
||||
}
|
||||
return null; // 清醒时间
|
||||
});
|
||||
|
||||
return (
|
||||
<View style={styles.chartContainer}>
|
||||
<View style={styles.chartHeader}>
|
||||
<View style={styles.chartTimeLabel}>
|
||||
<Text style={styles.chartTimeText}>🛏️ {sleepData.totalSleepTime > 0 ? formatTime(sleepData.bedtime) : '--:--'}</Text>
|
||||
</View>
|
||||
<View style={styles.chartHeartRate}>
|
||||
<Text style={styles.chartHeartRateText}>❤️ 平均心率: {sleepData.averageHeartRate || '--'} BPM</Text>
|
||||
</View>
|
||||
<View style={styles.chartTimeLabel}>
|
||||
<Text style={styles.chartTimeText}>☀️ {sleepData.totalSleepTime > 0 ? formatTime(sleepData.wakeupTime) : '--:--'}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.chartBars}>
|
||||
{hourlyData.map((stage, index) => {
|
||||
const barHeight = stage ? Math.random() * 0.6 + 0.4 : 0.1; // 随机高度模拟真实数据
|
||||
const color = stage ? getSleepStageColor(stage) : '#E5E7EB';
|
||||
|
||||
return (
|
||||
<View
|
||||
key={index}
|
||||
style={[
|
||||
styles.chartBar,
|
||||
{
|
||||
height: barHeight * maxHeight,
|
||||
backgroundColor: color,
|
||||
width: chartWidth / 24 - 2,
|
||||
}
|
||||
]}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default function SleepDetailScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const [sleepData, setSleepData] = useState<SleepDetailData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedDate] = useState(dayjs().toDate());
|
||||
|
||||
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: [],
|
||||
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 }}
|
||||
/>
|
||||
|
||||
{/* 顶部导航 */}
|
||||
<View style={[styles.header, { paddingTop: insets.top }]}>
|
||||
<TouchableOpacity style={styles.backButton} onPress={() => router.back()}>
|
||||
<Text style={styles.backButtonText}>‹</Text>
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.headerTitle}>今天, {dayjs(selectedDate).format('M月DD日')}</Text>
|
||||
<TouchableOpacity style={styles.navButton}>
|
||||
<Text style={styles.navButtonText}>›</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<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.statCard}>
|
||||
<Text style={styles.statIcon}>🌙</Text>
|
||||
<Text style={styles.statLabel}>睡眠时间</Text>
|
||||
<Text style={styles.statValue}>{displayData.totalSleepTime > 0 ? formatSleepTime(displayData.totalSleepTime) : '--'}</Text>
|
||||
<Text style={styles.statQuality}>
|
||||
{displayData.totalSleepTime > 0 ? '良好' : '--'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.statCard}>
|
||||
<Text style={styles.statIcon}>💎</Text>
|
||||
<Text style={styles.statLabel}>睡眠质量</Text>
|
||||
<Text style={styles.statValue}>{displayData.sleepQualityPercentage > 0 ? `${displayData.sleepQualityPercentage}%` : '--'}</Text>
|
||||
<Text style={styles.statQuality}>
|
||||
{displayData.sleepQualityPercentage > 0 ? '优秀' : '--'}
|
||||
</Text>
|
||||
</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.noDataContainer}>
|
||||
<Text style={styles.noDataText}>暂无睡眠阶段数据</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#F8FAFC',
|
||||
},
|
||||
gradientBackground: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: 20,
|
||||
paddingBottom: 16,
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
backButton: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.8)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
backButtonText: {
|
||||
fontSize: 24,
|
||||
fontWeight: '300',
|
||||
color: '#374151',
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#111827',
|
||||
},
|
||||
navButton: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.8)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
navButtonText: {
|
||||
fontSize: 24,
|
||||
fontWeight: '300',
|
||||
color: '#9CA3AF',
|
||||
},
|
||||
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: 16,
|
||||
marginBottom: 32,
|
||||
},
|
||||
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: 24,
|
||||
marginBottom: 8,
|
||||
},
|
||||
statLabel: {
|
||||
fontSize: 12,
|
||||
color: '#6B7280',
|
||||
marginBottom: 4,
|
||||
},
|
||||
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,
|
||||
},
|
||||
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',
|
||||
},
|
||||
});
|
||||
@@ -8,7 +8,7 @@ interface StressMeterProps {
|
||||
value: number | null;
|
||||
updateTime?: Date;
|
||||
style?: any;
|
||||
hrvValue: number;
|
||||
hrvValue: number | null;
|
||||
}
|
||||
|
||||
export function StressMeter({ value, updateTime, style, hrvValue }: StressMeterProps) {
|
||||
@@ -50,6 +50,13 @@ export function StressMeter({ value, updateTime, style, hrvValue }: StressMeterP
|
||||
// 使用传入的 hrvValue 进行转换
|
||||
const stressIndex = convertHrvToStressIndex(hrvValue);
|
||||
|
||||
// 调试信息
|
||||
console.log('StressMeter 调试:', {
|
||||
hrvValue,
|
||||
stressIndex,
|
||||
progressPercentage: stressIndex !== null ? Math.max(0, Math.min(100, stressIndex)) : 0
|
||||
});
|
||||
|
||||
// 计算进度条位置(0-100%)
|
||||
// 压力指数越高,进度条越满(红色区域越多)
|
||||
const progressPercentage = stressIndex !== null ? Math.max(0, Math.min(100, stressIndex)) : 0;
|
||||
|
||||
64
components/statistic/SleepCard.tsx
Normal file
64
components/statistic/SleepCard.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import React from 'react';
|
||||
import { StyleSheet, Text, View, TouchableOpacity } from 'react-native';
|
||||
|
||||
interface SleepCardProps {
|
||||
sleepDuration?: number | null;
|
||||
style?: object;
|
||||
onPress?: () => void;
|
||||
}
|
||||
|
||||
const SleepCard: React.FC<SleepCardProps> = ({
|
||||
sleepDuration,
|
||||
style,
|
||||
onPress
|
||||
}) => {
|
||||
const formatSleepDuration = (duration: number): string => {
|
||||
const hours = Math.floor(duration / 60);
|
||||
const minutes = Math.floor(duration % 60);
|
||||
return `${hours}小时${minutes}分钟`;
|
||||
};
|
||||
|
||||
const CardContent = (
|
||||
<View style={[styles.container, style]}>
|
||||
<View style={styles.cardHeaderRow}>
|
||||
<Text style={styles.cardTitle}>睡眠</Text>
|
||||
</View>
|
||||
<Text style={styles.sleepValue}>
|
||||
{sleepDuration != null ? formatSleepDuration(sleepDuration) : '——'}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
if (onPress) {
|
||||
return (
|
||||
<TouchableOpacity onPress={onPress} activeOpacity={0.7}>
|
||||
{CardContent}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
return CardContent;
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
// Container styles will be inherited from parent (FloatingCard)
|
||||
},
|
||||
cardHeaderRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
cardTitle: {
|
||||
fontSize: 14,
|
||||
color: '#192126',
|
||||
},
|
||||
sleepValue: {
|
||||
fontSize: 16,
|
||||
color: '#1E40AF',
|
||||
fontWeight: '700',
|
||||
marginTop: 8,
|
||||
},
|
||||
});
|
||||
|
||||
export default SleepCard;
|
||||
@@ -43,6 +43,7 @@ export const ROUTES = {
|
||||
|
||||
// 健康相关路由
|
||||
FITNESS_RINGS_DETAIL: '/fitness-rings-detail',
|
||||
SLEEP_DETAIL: '/sleep-detail',
|
||||
|
||||
// 任务相关路由
|
||||
TASK_DETAIL: '/task-detail',
|
||||
|
||||
@@ -1948,7 +1948,7 @@ PODS:
|
||||
- ReactCommon/turbomodule/bridging
|
||||
- ReactCommon/turbomodule/core
|
||||
- Yoga
|
||||
- RNSentry (6.21.0):
|
||||
- RNSentry (7.0.1):
|
||||
- DoubleConversion
|
||||
- glog
|
||||
- RCT-Folly (= 2024.11.18.00)
|
||||
@@ -2478,7 +2478,7 @@ SPEC CHECKSUMS:
|
||||
RNPurchases: 7993b33416e67d5863140b5c62c682b34719f475
|
||||
RNReanimated: 34e90d19560aebd52a2ad583fdc2de2cf7651bbb
|
||||
RNScreens: 241cfe8fc82737f3e132dd45779f9512928075b8
|
||||
RNSentry: 605b0108f57a8b921ca5ef7aa0b97d469a723c57
|
||||
RNSentry: 5e404b7714164b2d7b61a5ae41d7e9fa103b308c
|
||||
RNSVG: 3544def7b3ddc43c7ba69dade91bacf99f10ec46
|
||||
SDWebImage: f29024626962457f3470184232766516dee8dfea
|
||||
SDWebImageAVIFCoder: 00310d246aab3232ce77f1d8f0076f8c4b021d90
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<dict>
|
||||
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||
<array>
|
||||
<string>background-health-reminders</string>
|
||||
<string>com.expo.modules.backgroundtask.processing</string>
|
||||
</array>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
|
||||
129
package-lock.json
generated
129
package-lock.json
generated
@@ -17,7 +17,7 @@
|
||||
"@react-navigation/elements": "^2.3.8",
|
||||
"@react-navigation/native": "^7.1.6",
|
||||
"@reduxjs/toolkit": "^2.8.2",
|
||||
"@sentry/react-native": "^6.20.0",
|
||||
"@sentry/react-native": "^7.0.1",
|
||||
"@types/lodash": "^4.17.20",
|
||||
"cos-js-sdk-v5": "^1.6.0",
|
||||
"dayjs": "^1.11.13",
|
||||
@@ -3443,78 +3443,78 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@sentry-internal/browser-utils": {
|
||||
"version": "8.55.0",
|
||||
"resolved": "https://mirrors.tencent.com/npm/@sentry-internal/browser-utils/-/browser-utils-8.55.0.tgz",
|
||||
"integrity": "sha512-ROgqtQfpH/82AQIpESPqPQe0UyWywKJsmVIqi3c5Fh+zkds5LUxnssTj3yNd1x+kxaPDVB023jAP+3ibNgeNDw==",
|
||||
"version": "10.8.0",
|
||||
"resolved": "https://mirrors.tencent.com/npm/@sentry-internal/browser-utils/-/browser-utils-10.8.0.tgz",
|
||||
"integrity": "sha512-FaQX9eefc8sh3h3ZQy16U73KiH0xgDldXnrFiWK6OeWg8X4bJpnYbLqEi96LgHiQhjnnz+UQP1GDzH5oFuu5fA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sentry/core": "8.55.0"
|
||||
"@sentry/core": "10.8.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.18"
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry-internal/feedback": {
|
||||
"version": "8.55.0",
|
||||
"resolved": "https://mirrors.tencent.com/npm/@sentry-internal/feedback/-/feedback-8.55.0.tgz",
|
||||
"integrity": "sha512-cP3BD/Q6pquVQ+YL+rwCnorKuTXiS9KXW8HNKu4nmmBAyf7urjs+F6Hr1k9MXP5yQ8W3yK7jRWd09Yu6DHWOiw==",
|
||||
"version": "10.8.0",
|
||||
"resolved": "https://mirrors.tencent.com/npm/@sentry-internal/feedback/-/feedback-10.8.0.tgz",
|
||||
"integrity": "sha512-n7SqgFQItq4QSPG7bCjcZcIwK6AatKnnmSDJ/i6e8jXNIyLwkEuY2NyvTXACxVdO/kafGD5VmrwnTo3Ekc1AMg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sentry/core": "8.55.0"
|
||||
"@sentry/core": "10.8.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.18"
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry-internal/replay": {
|
||||
"version": "8.55.0",
|
||||
"resolved": "https://mirrors.tencent.com/npm/@sentry-internal/replay/-/replay-8.55.0.tgz",
|
||||
"integrity": "sha512-roCDEGkORwolxBn8xAKedybY+Jlefq3xYmgN2fr3BTnsXjSYOPC7D1/mYqINBat99nDtvgFvNfRcZPiwwZ1hSw==",
|
||||
"version": "10.8.0",
|
||||
"resolved": "https://mirrors.tencent.com/npm/@sentry-internal/replay/-/replay-10.8.0.tgz",
|
||||
"integrity": "sha512-9+qDEoEjv4VopLuOzK1zM4LcvcUsvB5N0iJ+FRCM3XzzOCbebJOniXTQbt5HflJc3XLnQNKFdKfTfgj8M/0RKQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sentry-internal/browser-utils": "8.55.0",
|
||||
"@sentry/core": "8.55.0"
|
||||
"@sentry-internal/browser-utils": "10.8.0",
|
||||
"@sentry/core": "10.8.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.18"
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry-internal/replay-canvas": {
|
||||
"version": "8.55.0",
|
||||
"resolved": "https://mirrors.tencent.com/npm/@sentry-internal/replay-canvas/-/replay-canvas-8.55.0.tgz",
|
||||
"integrity": "sha512-nIkfgRWk1091zHdu4NbocQsxZF1rv1f7bbp3tTIlZYbrH62XVZosx5iHAuZG0Zc48AETLE7K4AX9VGjvQj8i9w==",
|
||||
"version": "10.8.0",
|
||||
"resolved": "https://mirrors.tencent.com/npm/@sentry-internal/replay-canvas/-/replay-canvas-10.8.0.tgz",
|
||||
"integrity": "sha512-jC4OOwiNgrlIPeXIPMLkaW53BSS1do+toYHoWzzO5AXGpN6jRhanoSj36FpVuH2N3kFnxKVfVxrwh8L+/3vFWg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sentry-internal/replay": "8.55.0",
|
||||
"@sentry/core": "8.55.0"
|
||||
"@sentry-internal/replay": "10.8.0",
|
||||
"@sentry/core": "10.8.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.18"
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/babel-plugin-component-annotate": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://mirrors.tencent.com/npm/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-4.2.0.tgz",
|
||||
"integrity": "sha512-GFpS3REqaHuyX4LCNqlneAQZIKyHb5ePiI1802n0fhtYjk68I1DTQ3PnbzYi50od/vAsTQVCknaS5F6tidNqTQ==",
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://mirrors.tencent.com/npm/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-4.3.0.tgz",
|
||||
"integrity": "sha512-OuxqBprXRyhe8Pkfyz/4yHQJc5c3lm+TmYWSSx8u48g5yKewSQDOxkiLU5pAk3WnbLPy8XwU/PN+2BG0YFU9Nw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/browser": {
|
||||
"version": "8.55.0",
|
||||
"resolved": "https://mirrors.tencent.com/npm/@sentry/browser/-/browser-8.55.0.tgz",
|
||||
"integrity": "sha512-1A31mCEWCjaMxJt6qGUK+aDnLDcK6AwLAZnqpSchNysGni1pSn1RWSmk9TBF8qyTds5FH8B31H480uxMPUJ7Cw==",
|
||||
"version": "10.8.0",
|
||||
"resolved": "https://mirrors.tencent.com/npm/@sentry/browser/-/browser-10.8.0.tgz",
|
||||
"integrity": "sha512-2J7HST8/ixCaboq17yFn/j/OEokXSXoCBMXRrFx4FKJggKWZ90e2Iau5mP/IPPhrW+W9zCptCgNMY0167wS4qA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sentry-internal/browser-utils": "8.55.0",
|
||||
"@sentry-internal/feedback": "8.55.0",
|
||||
"@sentry-internal/replay": "8.55.0",
|
||||
"@sentry-internal/replay-canvas": "8.55.0",
|
||||
"@sentry/core": "8.55.0"
|
||||
"@sentry-internal/browser-utils": "10.8.0",
|
||||
"@sentry-internal/feedback": "10.8.0",
|
||||
"@sentry-internal/replay": "10.8.0",
|
||||
"@sentry-internal/replay-canvas": "10.8.0",
|
||||
"@sentry/core": "10.8.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.18"
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/cli": {
|
||||
@@ -3706,44 +3706,43 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/core": {
|
||||
"version": "8.55.0",
|
||||
"resolved": "https://mirrors.tencent.com/npm/@sentry/core/-/core-8.55.0.tgz",
|
||||
"integrity": "sha512-6g7jpbefjHYs821Z+EBJ8r4Z7LT5h80YSWRJaylGS4nW5W5Z2KXzpdnyFarv37O7QjauzVC2E+PABmpkw5/JGA==",
|
||||
"version": "10.8.0",
|
||||
"resolved": "https://mirrors.tencent.com/npm/@sentry/core/-/core-10.8.0.tgz",
|
||||
"integrity": "sha512-scYzM/UOItu4PjEq6CpHLdArpXjIS0laHYxE4YjkIbYIH6VMcXGQbD/FSBClsnCr1wXRnlXfXBzj0hrQAFyw+Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.18"
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/react": {
|
||||
"version": "8.55.0",
|
||||
"resolved": "https://mirrors.tencent.com/npm/@sentry/react/-/react-8.55.0.tgz",
|
||||
"integrity": "sha512-/qNBvFLpvSa/Rmia0jpKfJdy16d4YZaAnH/TuKLAtm0BWlsPQzbXCU4h8C5Hsst0Do0zG613MEtEmWpWrVOqWA==",
|
||||
"version": "10.8.0",
|
||||
"resolved": "https://mirrors.tencent.com/npm/@sentry/react/-/react-10.8.0.tgz",
|
||||
"integrity": "sha512-w/dGLMCLJG2lp8gKVKX1jjeg2inXewKfPb73+PS1CDi9/ihvqZU2DAXxnaNsBA7YYtGwlWVJe1bLAqguwTEpqw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sentry/browser": "8.55.0",
|
||||
"@sentry/core": "8.55.0",
|
||||
"@sentry/browser": "10.8.0",
|
||||
"@sentry/core": "10.8.0",
|
||||
"hoist-non-react-statics": "^3.3.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.18"
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.14.0 || 17.x || 18.x || 19.x"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/react-native": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://mirrors.tencent.com/npm/@sentry/react-native/-/react-native-6.21.0.tgz",
|
||||
"integrity": "sha512-r8kroioyJCwDtfAgyPGRSLzfNIjNBF0d28+ZHkm0q9fbvcuBlXN3wtDBR+J+0JEbcZrFpYm2QtZWws/2TzP3NQ==",
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://mirrors.tencent.com/npm/@sentry/react-native/-/react-native-7.0.1.tgz",
|
||||
"integrity": "sha512-xz8ON51qSDvcHVFkdLo0b7rlrQVXpRVXqzm7e1+nHEZ07TX0o+utxx04akxD1Z4hmGPTWPmsHeMlm7diV9NtTQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sentry/babel-plugin-component-annotate": "4.2.0",
|
||||
"@sentry/browser": "8.55.0",
|
||||
"@sentry/babel-plugin-component-annotate": "4.3.0",
|
||||
"@sentry/browser": "10.8.0",
|
||||
"@sentry/cli": "2.53.0",
|
||||
"@sentry/core": "8.55.0",
|
||||
"@sentry/react": "8.55.0",
|
||||
"@sentry/types": "8.55.0",
|
||||
"@sentry/utils": "8.55.0"
|
||||
"@sentry/core": "10.8.0",
|
||||
"@sentry/react": "10.8.0",
|
||||
"@sentry/types": "10.8.0"
|
||||
},
|
||||
"bin": {
|
||||
"sentry-expo-upload-sourcemaps": "scripts/expo-upload-sourcemaps.js"
|
||||
@@ -3760,27 +3759,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/types": {
|
||||
"version": "8.55.0",
|
||||
"resolved": "https://mirrors.tencent.com/npm/@sentry/types/-/types-8.55.0.tgz",
|
||||
"integrity": "sha512-6LRT0+r6NWQ+RtllrUW2yQfodST0cJnkOmdpHA75vONgBUhpKwiJ4H7AmgfoTET8w29pU6AnntaGOe0LJbOmog==",
|
||||
"version": "10.8.0",
|
||||
"resolved": "https://mirrors.tencent.com/npm/@sentry/types/-/types-10.8.0.tgz",
|
||||
"integrity": "sha512-xRe41/KvnNt4o6t5YeB+yBRTWvLUu6FJpft/VBOs4Bfh1/6rz+l78oxSCtpXo3MsfTd5185I0uuggAjEdD4Y6g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sentry/core": "8.55.0"
|
||||
"@sentry/core": "10.8.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/utils": {
|
||||
"version": "8.55.0",
|
||||
"resolved": "https://mirrors.tencent.com/npm/@sentry/utils/-/utils-8.55.0.tgz",
|
||||
"integrity": "sha512-cYcl39+xcOivBpN9d8ZKbALl+DxZKo/8H0nueJZ0PO4JA+MJGhSm6oHakXxLPaiMoNLTX7yor8ndnQIuFg+vmQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sentry/core": "8.55.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.18"
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sinclair/typebox": {
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"@react-navigation/elements": "^2.3.8",
|
||||
"@react-navigation/native": "^7.1.6",
|
||||
"@reduxjs/toolkit": "^2.8.2",
|
||||
"@sentry/react-native": "^6.20.0",
|
||||
"@sentry/react-native": "^7.0.1",
|
||||
"@types/lodash": "^4.17.20",
|
||||
"cos-js-sdk-v5": "^1.6.0",
|
||||
"dayjs": "^1.11.13",
|
||||
@@ -80,4 +80,4 @@
|
||||
"typescript": "~5.8.3"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
}
|
||||
@@ -5,17 +5,11 @@ import * as BackgroundTask from 'expo-background-task';
|
||||
import * as TaskManager from 'expo-task-manager';
|
||||
import { TaskManagerTaskBody } from 'expo-task-manager';
|
||||
|
||||
/**
|
||||
* 后台任务标识符
|
||||
*/
|
||||
export const BACKGROUND_TASK_IDS = {
|
||||
WATER_REMINDER: 'water-reminder-task',
|
||||
STAND_REMINDER: 'stand-reminder-task',
|
||||
HEALTH_REMINDERS: 'background-health-reminders',
|
||||
} as const;
|
||||
const BACKGROUND_TASK_IDENTIFIER = 'background-task';
|
||||
|
||||
|
||||
// 定义后台任务
|
||||
TaskManager.defineTask(BACKGROUND_TASK_IDS.HEALTH_REMINDERS, async (body: TaskManagerTaskBody) => {
|
||||
TaskManager.defineTask(BACKGROUND_TASK_IDENTIFIER, async (body: TaskManagerTaskBody) => {
|
||||
try {
|
||||
console.log('[BackgroundTask] 后台任务执行');
|
||||
await executeBackgroundTasks();
|
||||
@@ -209,9 +203,7 @@ export class BackgroundTaskManager {
|
||||
|
||||
try {
|
||||
// 注册后台任务
|
||||
const status = await BackgroundTask.registerTaskAsync(BACKGROUND_TASK_IDS.HEALTH_REMINDERS, {
|
||||
minimumInterval: 15, // 15分钟
|
||||
});
|
||||
const status = await BackgroundTask.registerTaskAsync(BACKGROUND_TASK_IDENTIFIER);
|
||||
|
||||
console.log('[BackgroundTask] 配置状态:', status);
|
||||
|
||||
@@ -226,26 +218,13 @@ export class BackgroundTaskManager {
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 启动后台任务
|
||||
*/
|
||||
async start(): Promise<void> {
|
||||
try {
|
||||
await BackgroundTask.registerTaskAsync(BACKGROUND_TASK_IDS.HEALTH_REMINDERS, {
|
||||
minimumInterval: 15,
|
||||
});
|
||||
console.log('后台任务已启动');
|
||||
} catch (error) {
|
||||
console.error('启动后台任务失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止后台任务
|
||||
*/
|
||||
async stop(): Promise<void> {
|
||||
try {
|
||||
await BackgroundTask.unregisterTaskAsync(BACKGROUND_TASK_IDS.HEALTH_REMINDERS);
|
||||
await BackgroundTask.unregisterTaskAsync(BACKGROUND_TASK_IDENTIFIER);
|
||||
console.log('后台任务已停止');
|
||||
} catch (error) {
|
||||
console.error('停止后台任务失败:', error);
|
||||
@@ -281,6 +260,11 @@ export class BackgroundTaskManager {
|
||||
}
|
||||
}
|
||||
|
||||
async triggerTaskForTesting(): Promise<void> {
|
||||
await BackgroundTask.triggerTaskWorkerForTestingAsync();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 测试后台任务
|
||||
*/
|
||||
@@ -296,42 +280,6 @@ export class BackgroundTaskManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册喝水提醒后台任务
|
||||
*/
|
||||
async registerWaterReminderTask(): Promise<void> {
|
||||
console.log('注册喝水提醒后台任务...');
|
||||
|
||||
try {
|
||||
// 检查是否已经初始化
|
||||
if (!this.isInitialized) {
|
||||
await this.initialize();
|
||||
}
|
||||
|
||||
// 启动后台任务
|
||||
await this.start();
|
||||
|
||||
console.log('喝水提醒后台任务注册成功');
|
||||
} catch (error) {
|
||||
console.error('注册喝水提醒后台任务失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消喝水提醒后台任务
|
||||
*/
|
||||
async unregisterWaterReminderTask(): Promise<void> {
|
||||
console.log('取消喝水提醒后台任务...');
|
||||
|
||||
try {
|
||||
await this.stop();
|
||||
console.log('喝水提醒后台任务已取消');
|
||||
} catch (error) {
|
||||
console.error('取消喝水提醒后台任务失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取最后一次后台检查时间
|
||||
@@ -345,73 +293,6 @@ export class BackgroundTaskManager {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册站立提醒后台任务
|
||||
*/
|
||||
async registerStandReminderTask(): Promise<void> {
|
||||
console.log('注册站立提醒后台任务...');
|
||||
|
||||
try {
|
||||
// 检查是否已经初始化
|
||||
if (!this.isInitialized) {
|
||||
await this.initialize();
|
||||
}
|
||||
|
||||
// 启动后台任务
|
||||
await this.start();
|
||||
|
||||
console.log('站立提醒后台任务注册成功');
|
||||
} catch (error) {
|
||||
console.error('注册站立提醒后台任务失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消站立提醒后台任务
|
||||
*/
|
||||
async unregisterStandReminderTask(): Promise<void> {
|
||||
console.log('取消站立提醒后台任务...');
|
||||
|
||||
try {
|
||||
// 取消所有相关通知
|
||||
await StandReminderHelpers.cancelStandReminders();
|
||||
console.log('站立提醒后台任务已取消');
|
||||
} catch (error) {
|
||||
console.error('取消站立提醒后台任务失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取最后一次站立检查时间
|
||||
*/
|
||||
async getLastStandCheckTime(): Promise<number | null> {
|
||||
try {
|
||||
const lastCheck = await AsyncStorage.getItem('@last_background_stand_check');
|
||||
return lastCheck ? parseInt(lastCheck) : null;
|
||||
} catch (error) {
|
||||
console.error('获取最后站立检查时间失败:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试站立提醒任务
|
||||
*/
|
||||
async testStandReminderTask(): Promise<void> {
|
||||
console.log('开始测试站立提醒后台任务...');
|
||||
|
||||
try {
|
||||
// 手动触发站立提醒任务执行
|
||||
await executeStandReminderTask();
|
||||
console.log('站立提醒后台任务测试完成');
|
||||
} catch (error) {
|
||||
console.error('站立提醒后台任务测试失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
376
services/sleepService.ts
Normal file
376
services/sleepService.ts
Normal file
@@ -0,0 +1,376 @@
|
||||
import dayjs from 'dayjs';
|
||||
import AppleHealthKit, { HealthKitPermissions } from 'react-native-health';
|
||||
|
||||
// 睡眠阶段枚举(与 HealthKit 保持一致)
|
||||
export enum SleepStage {
|
||||
InBed = 'INBED',
|
||||
Asleep = 'ASLEEP',
|
||||
Awake = 'AWAKE',
|
||||
Core = 'CORE',
|
||||
Deep = 'DEEP',
|
||||
REM = 'REM'
|
||||
}
|
||||
|
||||
// 睡眠质量评级
|
||||
export enum SleepQuality {
|
||||
Poor = 'poor', // 差
|
||||
Fair = 'fair', // 一般
|
||||
Good = 'good', // 良好
|
||||
Excellent = 'excellent' // 优秀
|
||||
}
|
||||
|
||||
// 睡眠样本数据类型
|
||||
export type SleepSample = {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
value: SleepStage;
|
||||
sourceName?: string;
|
||||
sourceId?: string;
|
||||
};
|
||||
|
||||
// 睡眠阶段统计
|
||||
export type SleepStageStats = {
|
||||
stage: SleepStage;
|
||||
duration: number; // 分钟
|
||||
percentage: number; // 百分比
|
||||
quality: SleepQuality;
|
||||
};
|
||||
|
||||
// 心率数据类型
|
||||
export type HeartRateData = {
|
||||
timestamp: string;
|
||||
value: number; // BPM
|
||||
};
|
||||
|
||||
// 睡眠详情数据类型
|
||||
export type SleepDetailData = {
|
||||
// 基础睡眠信息
|
||||
sleepScore: number; // 睡眠得分 0-100
|
||||
totalSleepTime: number; // 总睡眠时间(分钟)
|
||||
sleepQualityPercentage: number; // 睡眠质量百分比
|
||||
|
||||
// 睡眠时间信息
|
||||
bedtime: string; // 上床时间
|
||||
wakeupTime: string; // 起床时间
|
||||
timeInBed: number; // 在床时间(分钟)
|
||||
|
||||
// 睡眠阶段统计
|
||||
sleepStages: SleepStageStats[];
|
||||
|
||||
// 心率数据
|
||||
averageHeartRate: number | null; // 平均心率
|
||||
sleepHeartRateData: HeartRateData[]; // 睡眠期间心率数据
|
||||
|
||||
// 睡眠效率
|
||||
sleepEfficiency: number; // 睡眠效率百分比 (总睡眠时间/在床时间)
|
||||
|
||||
// 建议和评价
|
||||
qualityDescription: string; // 睡眠质量描述
|
||||
recommendation: string; // 睡眠建议
|
||||
};
|
||||
|
||||
// 日期范围工具函数
|
||||
function createSleepDateRange(date: Date): { startDate: string; endDate: string } {
|
||||
// 睡眠数据通常跨越两天,从前一天18:00到当天12:00
|
||||
return {
|
||||
startDate: dayjs(date).subtract(1, 'day').hour(18).minute(0).second(0).millisecond(0).toISOString(),
|
||||
endDate: dayjs(date).hour(12).minute(0).second(0).millisecond(0).toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
// 获取睡眠样本数据
|
||||
async function fetchSleepSamples(date: Date): Promise<SleepSample[]> {
|
||||
return new Promise((resolve) => {
|
||||
const options = createSleepDateRange(date);
|
||||
|
||||
AppleHealthKit.getSleepSamples(options, (err, results) => {
|
||||
if (err) {
|
||||
console.error('获取睡眠样本失败:', err);
|
||||
resolve([]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!results || !Array.isArray(results)) {
|
||||
console.warn('睡眠样本数据为空');
|
||||
resolve([]);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('获取到睡眠样本:', results.length);
|
||||
resolve(results as SleepSample[]);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 获取睡眠期间心率数据
|
||||
async function fetchSleepHeartRateData(bedtime: string, wakeupTime: string): Promise<HeartRateData[]> {
|
||||
return new Promise((resolve) => {
|
||||
const options = {
|
||||
startDate: bedtime,
|
||||
endDate: wakeupTime,
|
||||
ascending: true
|
||||
};
|
||||
|
||||
AppleHealthKit.getHeartRateSamples(options, (err, results) => {
|
||||
if (err) {
|
||||
console.error('获取睡眠心率数据失败:', err);
|
||||
resolve([]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!results || !Array.isArray(results)) {
|
||||
resolve([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const heartRateData: HeartRateData[] = results.map(sample => ({
|
||||
timestamp: sample.startDate,
|
||||
value: Math.round(sample.value)
|
||||
}));
|
||||
|
||||
console.log('获取到睡眠心率数据:', heartRateData.length, '个样本');
|
||||
resolve(heartRateData);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 计算睡眠阶段统计
|
||||
function 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 totalSleepTime = Array.from(stageMap.entries())
|
||||
.filter(([stage]) => stage !== SleepStage.InBed && stage !== SleepStage.Awake)
|
||||
.reduce((total, [, duration]) => total + duration, 0);
|
||||
|
||||
// 生成统计数据
|
||||
const stats: SleepStageStats[] = [];
|
||||
|
||||
stageMap.forEach((duration, stage) => {
|
||||
if (stage === SleepStage.InBed || stage === SleepStage.Awake) return;
|
||||
|
||||
const percentage = totalSleepTime > 0 ? (duration / totalSleepTime) * 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;
|
||||
default:
|
||||
quality = SleepQuality.Fair;
|
||||
}
|
||||
|
||||
stats.push({
|
||||
stage,
|
||||
duration,
|
||||
percentage: Math.round(percentage),
|
||||
quality
|
||||
});
|
||||
});
|
||||
|
||||
// 按持续时间排序
|
||||
return stats.sort((a, b) => b.duration - a.duration);
|
||||
}
|
||||
|
||||
// 计算睡眠得分
|
||||
function calculateSleepScore(sleepStages: SleepStageStats[], sleepEfficiency: number, totalSleepTime: number): number {
|
||||
let score = 0;
|
||||
|
||||
// 睡眠时长得分 (30分)
|
||||
const idealSleepHours = 8 * 60; // 8小时
|
||||
const sleepDurationScore = Math.min(30, (totalSleepTime / idealSleepHours) * 30);
|
||||
score += sleepDurationScore;
|
||||
|
||||
// 睡眠效率得分 (25分)
|
||||
const efficiencyScore = (sleepEfficiency / 100) * 25;
|
||||
score += efficiencyScore;
|
||||
|
||||
// 深度睡眠得分 (25分)
|
||||
const deepSleepStage = sleepStages.find(stage => stage.stage === SleepStage.Deep);
|
||||
const deepSleepScore = deepSleepStage ? Math.min(25, (deepSleepStage.percentage / 20) * 25) : 0;
|
||||
score += deepSleepScore;
|
||||
|
||||
// REM睡眠得分 (20分)
|
||||
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));
|
||||
}
|
||||
|
||||
// 获取睡眠质量描述和建议
|
||||
function 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: '你的睡眠质量需要严重关注。建议咨询医生或睡眠专家,检查是否有睡眠障碍,同时改善睡眠环境和习惯,避免睡前刺激性活动。'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 获取睡眠阶段中文名称
|
||||
export function getSleepStageDisplayName(stage: SleepStage): string {
|
||||
switch (stage) {
|
||||
case SleepStage.Deep:
|
||||
return '深度';
|
||||
case SleepStage.Core:
|
||||
return '核心';
|
||||
case SleepStage.REM:
|
||||
return '快速眼动';
|
||||
case SleepStage.Asleep:
|
||||
return '浅睡';
|
||||
case SleepStage.Awake:
|
||||
return '清醒';
|
||||
case SleepStage.InBed:
|
||||
return '在床';
|
||||
default:
|
||||
return '未知';
|
||||
}
|
||||
}
|
||||
|
||||
// 获取睡眠质量颜色
|
||||
export function getSleepStageColor(stage: SleepStage): string {
|
||||
switch (stage) {
|
||||
case SleepStage.Deep:
|
||||
return '#1E40AF'; // 深蓝色
|
||||
case SleepStage.Core:
|
||||
return '#3B82F6'; // 蓝色
|
||||
case SleepStage.REM:
|
||||
return '#8B5CF6'; // 紫色
|
||||
case SleepStage.Asleep:
|
||||
return '#06B6D4'; // 青色
|
||||
case SleepStage.Awake:
|
||||
return '#F59E0B'; // 橙色
|
||||
case SleepStage.InBed:
|
||||
return '#6B7280'; // 灰色
|
||||
default:
|
||||
return '#9CA3AF';
|
||||
}
|
||||
}
|
||||
|
||||
// 主函数:获取完整的睡眠详情数据
|
||||
export async function fetchSleepDetailForDate(date: Date): Promise<SleepDetailData | null> {
|
||||
try {
|
||||
console.log('开始获取睡眠详情数据...', date);
|
||||
|
||||
// 获取睡眠样本数据
|
||||
const sleepSamples = await fetchSleepSamples(date);
|
||||
|
||||
if (sleepSamples.length === 0) {
|
||||
console.warn('没有找到睡眠数据');
|
||||
return null;
|
||||
}
|
||||
|
||||
// 找到上床时间和起床时间
|
||||
const inBedSamples = sleepSamples.filter(sample => sample.value === SleepStage.InBed);
|
||||
const bedtime = inBedSamples.length > 0 ? inBedSamples[0].startDate : sleepSamples[0].startDate;
|
||||
const wakeupTime = inBedSamples.length > 0 ?
|
||||
inBedSamples[inBedSamples.length - 1].endDate :
|
||||
sleepSamples[sleepSamples.length - 1].endDate;
|
||||
|
||||
// 计算在床时间
|
||||
const timeInBed = dayjs(wakeupTime).diff(dayjs(bedtime), 'minute');
|
||||
|
||||
// 计算睡眠阶段统计
|
||||
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);
|
||||
|
||||
const sleepDetailData: SleepDetailData = {
|
||||
sleepScore,
|
||||
totalSleepTime,
|
||||
sleepQualityPercentage: sleepScore, // 使用睡眠得分作为质量百分比
|
||||
bedtime,
|
||||
wakeupTime,
|
||||
timeInBed,
|
||||
sleepStages,
|
||||
averageHeartRate,
|
||||
sleepHeartRateData,
|
||||
sleepEfficiency,
|
||||
qualityDescription: qualityInfo.description,
|
||||
recommendation: qualityInfo.recommendation
|
||||
};
|
||||
|
||||
console.log('睡眠详情数据获取完成:', sleepDetailData);
|
||||
return sleepDetailData;
|
||||
|
||||
} catch (error) {
|
||||
console.error('获取睡眠详情数据失败:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化睡眠时间显示
|
||||
export function 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`;
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化时间显示 (HH:MM)
|
||||
export function formatTime(dateString: string): string {
|
||||
return dayjs(dateString).format('HH:mm');
|
||||
}
|
||||
@@ -103,6 +103,14 @@ function createDateRange(date: Date): HealthDataOptions {
|
||||
};
|
||||
}
|
||||
|
||||
// 睡眠数据专用的日期范围函数 - 从前一天晚上到当天结束
|
||||
function createSleepDateRange(date: Date): HealthDataOptions {
|
||||
return {
|
||||
startDate: dayjs(date).subtract(1, 'day').hour(18).minute(0).second(0).millisecond(0).toDate().toISOString(), // 前一天18:00开始
|
||||
endDate: dayjs(date).endOf('day').toDate().toISOString() // 当天结束
|
||||
};
|
||||
}
|
||||
|
||||
// 睡眠时长计算
|
||||
function calculateSleepDuration(samples: any[]): number {
|
||||
return samples.reduce((total, sample) => {
|
||||
@@ -433,9 +441,12 @@ async function fetchBasalEnergyBurned(options: HealthDataOptions): Promise<numbe
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchSleepDuration(options: HealthDataOptions): Promise<number> {
|
||||
async function fetchSleepDuration(date: Date): Promise<number> {
|
||||
return new Promise((resolve) => {
|
||||
AppleHealthKit.getSleepSamples(options, (err, res) => {
|
||||
// 使用睡眠专用的日期范围,包含前一天晚上的睡眠数据
|
||||
const sleepOptions = createSleepDateRange(date);
|
||||
|
||||
AppleHealthKit.getSleepSamples(sleepOptions, (err, res) => {
|
||||
if (err) {
|
||||
logError('睡眠数据', err);
|
||||
return resolve(0);
|
||||
@@ -445,7 +456,24 @@ async function fetchSleepDuration(options: HealthDataOptions): Promise<number> {
|
||||
return resolve(0);
|
||||
}
|
||||
logSuccess('睡眠', res);
|
||||
resolve(calculateSleepDuration(res));
|
||||
|
||||
// 过滤睡眠数据,只计算主睡眠时间段
|
||||
const filteredSamples = res.filter(sample => {
|
||||
if (!sample || !sample.startDate || !sample.endDate) return false;
|
||||
|
||||
const startDate = dayjs(sample.startDate);
|
||||
const endDate = dayjs(sample.endDate);
|
||||
const targetDate = dayjs(date);
|
||||
|
||||
// 判断这个睡眠段是否属于当天的主睡眠
|
||||
// 睡眠段的结束时间应该在当天,或者睡眠段跨越了前一天晚上到当天早上
|
||||
const isMainSleepPeriod = endDate.isSame(targetDate, 'day') ||
|
||||
(startDate.isBefore(targetDate, 'day') && endDate.isAfter(targetDate.startOf('day')));
|
||||
|
||||
return isMainSleepPeriod;
|
||||
});
|
||||
|
||||
resolve(calculateSleepDuration(filteredSamples));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -634,7 +662,7 @@ export async function fetchHealthDataForDate(date: Date): Promise<TodayHealthDat
|
||||
fetchHourlyStepSamples(date),
|
||||
fetchActiveEnergyBurned(options),
|
||||
fetchBasalEnergyBurned(options),
|
||||
fetchSleepDuration(options),
|
||||
fetchSleepDuration(date), // 传入日期而不是options
|
||||
fetchHeartRateVariability(options),
|
||||
fetchActivitySummary(options),
|
||||
fetchOxygenSaturation(options),
|
||||
|
||||
Reference in New Issue
Block a user