feat: 添加睡眠详情页面,集成睡眠数据获取功能,优化健康数据权限管理,更新相关组件以支持睡眠统计和展示
This commit is contained in:
8
app.json
8
app.json
@@ -88,14 +88,6 @@
|
|||||||
{
|
{
|
||||||
"minimumInterval": 15
|
"minimumInterval": 15
|
||||||
}
|
}
|
||||||
],
|
|
||||||
[
|
|
||||||
"expo-task-manager",
|
|
||||||
{
|
|
||||||
"taskManagers": [
|
|
||||||
"background-health-reminders"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
],
|
],
|
||||||
"experiments": {
|
"experiments": {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { FitnessRingsCard } from '@/components/FitnessRingsCard';
|
|||||||
import { MoodCard } from '@/components/MoodCard';
|
import { MoodCard } from '@/components/MoodCard';
|
||||||
import { NutritionRadarCard } from '@/components/NutritionRadarCard';
|
import { NutritionRadarCard } from '@/components/NutritionRadarCard';
|
||||||
import OxygenSaturationCard from '@/components/statistic/OxygenSaturationCard';
|
import OxygenSaturationCard from '@/components/statistic/OxygenSaturationCard';
|
||||||
|
import SleepCard from '@/components/statistic/SleepCard';
|
||||||
import StepsCard from '@/components/StepsCard';
|
import StepsCard from '@/components/StepsCard';
|
||||||
import { StressMeter } from '@/components/StressMeter';
|
import { StressMeter } from '@/components/StressMeter';
|
||||||
import WaterIntakeCard from '@/components/WaterIntakeCard';
|
import WaterIntakeCard from '@/components/WaterIntakeCard';
|
||||||
@@ -12,6 +13,7 @@ import { Colors } from '@/constants/Colors';
|
|||||||
import { getTabBarBottomPadding } from '@/constants/TabBar';
|
import { getTabBarBottomPadding } from '@/constants/TabBar';
|
||||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||||
|
import { backgroundTaskManager } from '@/services/backgroundTaskManager';
|
||||||
import { selectHealthDataByDate, setHealthData } from '@/store/healthSlice';
|
import { selectHealthDataByDate, setHealthData } from '@/store/healthSlice';
|
||||||
import { fetchDailyMoodCheckins, selectLatestMoodRecordByDate } from '@/store/moodSlice';
|
import { fetchDailyMoodCheckins, selectLatestMoodRecordByDate } from '@/store/moodSlice';
|
||||||
import { fetchDailyNutritionData, selectNutritionSummaryByDate } from '@/store/nutritionSlice';
|
import { fetchDailyNutritionData, selectNutritionSummaryByDate } from '@/store/nutritionSlice';
|
||||||
@@ -21,7 +23,6 @@ import { ensureHealthPermissions, fetchHealthDataForDate, testHRVDataFetch } fro
|
|||||||
import { getTestHealthData } from '@/utils/mockHealthData';
|
import { getTestHealthData } from '@/utils/mockHealthData';
|
||||||
import { calculateNutritionGoals } from '@/utils/nutrition';
|
import { calculateNutritionGoals } from '@/utils/nutrition';
|
||||||
import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
|
import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
|
||||||
import { useFocusEffect } from '@react-navigation/native';
|
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
import { debounce } from 'lodash';
|
import { debounce } from 'lodash';
|
||||||
@@ -64,7 +65,8 @@ export default function ExploreScreen() {
|
|||||||
const userProfile = useAppSelector((s) => s.user.profile);
|
const userProfile = useAppSelector((s) => s.user.profile);
|
||||||
|
|
||||||
// 开发调试:设置为true来使用mock数据
|
// 开发调试:设置为true来使用mock数据
|
||||||
const useMockData = __DEV__; // 改为true来启用mock数据调试
|
// 在真机测试时,可以暂时设置为true来验证组件显示逻辑
|
||||||
|
const useMockData = __DEV__ || false; // 改为true来启用mock数据调试
|
||||||
|
|
||||||
const { pushIfAuthedElseLogin, isLoggedIn } = useAuthGuard();
|
const { pushIfAuthedElseLogin, isLoggedIn } = useAuthGuard();
|
||||||
|
|
||||||
@@ -97,9 +99,18 @@ export default function ExploreScreen() {
|
|||||||
const activeCalories = useMockData ? (mockData?.activeEnergyBurned ?? null) : (healthData?.activeEnergyBurned ?? null);
|
const activeCalories = useMockData ? (mockData?.activeEnergyBurned ?? null) : (healthData?.activeEnergyBurned ?? null);
|
||||||
const basalMetabolism: number | null = useMockData ? (mockData?.basalEnergyBurned ?? null) : (healthData?.basalEnergyBurned ?? null);
|
const basalMetabolism: number | null = useMockData ? (mockData?.basalEnergyBurned ?? null) : (healthData?.basalEnergyBurned ?? null);
|
||||||
const sleepDuration = useMockData ? (mockData?.sleepDuration ?? null) : (healthData?.sleepDuration ?? 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);
|
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 ? {
|
const fitnessRingsData = useMockData ? {
|
||||||
activeCalories: mockData?.activeCalories ?? 0,
|
activeCalories: mockData?.activeCalories ?? 0,
|
||||||
activeCaloriesGoal: mockData?.activeCaloriesGoal ?? 350,
|
activeCaloriesGoal: mockData?.activeCaloriesGoal ?? 350,
|
||||||
@@ -269,6 +280,8 @@ export default function ExploreScreen() {
|
|||||||
const data = await fetchHealthDataForDate(derivedDate);
|
const data = await fetchHealthDataForDate(derivedDate);
|
||||||
|
|
||||||
console.log('设置UI状态:', data);
|
console.log('设置UI状态:', data);
|
||||||
|
console.log('HRV数据详细信息:', data.hrv, typeof data.hrv);
|
||||||
|
|
||||||
// 仅当该请求仍是最新时,才应用结果
|
// 仅当该请求仍是最新时,才应用结果
|
||||||
if (latestRequestKeyRef.current === requestKey) {
|
if (latestRequestKeyRef.current === requestKey) {
|
||||||
const dateString = dayjs(derivedDate).format('YYYY-MM-DD');
|
const dateString = dayjs(derivedDate).format('YYYY-MM-DD');
|
||||||
@@ -276,7 +289,22 @@ export default function ExploreScreen() {
|
|||||||
// 使用 Redux 存储健康数据
|
// 使用 Redux 存储健康数据
|
||||||
dispatch(setHealthData({
|
dispatch(setHealthData({
|
||||||
date: dateString,
|
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数据时间
|
// 更新HRV数据时间
|
||||||
@@ -374,26 +402,33 @@ export default function ExploreScreen() {
|
|||||||
}, [executeLoadAllData, debouncedLoadAllData]);
|
}, [executeLoadAllData, debouncedLoadAllData]);
|
||||||
|
|
||||||
// 页面聚焦时的数据加载逻辑
|
// 页面聚焦时的数据加载逻辑
|
||||||
useFocusEffect(
|
// useFocusEffect(
|
||||||
React.useCallback(() => {
|
// React.useCallback(() => {
|
||||||
// 页面聚焦时加载数据,使用缓存机制避免频繁请求
|
// // 页面聚焦时加载数据,使用缓存机制避免频繁请求
|
||||||
console.log('页面聚焦,检查是否需要刷新数据...');
|
// console.log('页面聚焦,检查是否需要刷新数据...');
|
||||||
loadAllData(currentSelectedDate);
|
// loadAllData(currentSelectedDate);
|
||||||
}, [loadAllData, currentSelectedDate])
|
// }, [loadAllData, currentSelectedDate])
|
||||||
);
|
// );
|
||||||
|
|
||||||
// AppState 监听:应用从后台返回前台时的处理
|
// AppState 监听:应用从后台返回前台时的处理
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let appStateChangeTimeout: number;
|
|
||||||
|
|
||||||
const handleAppStateChange = (nextAppState: string) => {
|
const handleAppStateChange = (nextAppState: string) => {
|
||||||
if (nextAppState === 'active') {
|
if (nextAppState === 'active') {
|
||||||
// 延迟执行,避免与 useFocusEffect 重复触发
|
// 判断当前选中的日期是否是最新的(今天)
|
||||||
appStateChangeTimeout = setTimeout(() => {
|
const todayIndex = getTodayIndexInMonth();
|
||||||
console.log('应用从后台返回前台,强制刷新统计数据...');
|
const isTodaySelected = selectedIndex === todayIndex;
|
||||||
// 从后台返回时强制刷新数据
|
|
||||||
loadAllData(currentSelectedDate, true);
|
if (!isTodaySelected) {
|
||||||
}, 500);
|
// 如果当前不是选中今天,则切换到今天(这个更新会触发数据加载)
|
||||||
|
console.log('应用回到前台,切换到今天并加载数据');
|
||||||
|
setSelectedIndex(todayIndex);
|
||||||
|
// 注意:这里不直接调用loadAllData,因为setSelectedIndex会触发useEffect重新计算currentSelectedDate
|
||||||
|
// 然后onSelectDate会被调用,从而触发数据加载
|
||||||
|
} else {
|
||||||
|
// 如果已经是今天,则直接调用加载数据的方法
|
||||||
|
console.log('应用回到前台,当前已是今天,直接加载数据');
|
||||||
|
loadAllData(currentSelectedDate);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -401,11 +436,8 @@ export default function ExploreScreen() {
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
subscription?.remove();
|
subscription?.remove();
|
||||||
if (appStateChangeTimeout) {
|
|
||||||
clearTimeout(appStateChangeTimeout);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}, [loadAllData, currentSelectedDate]);
|
}, [loadAllData, currentSelectedDate, selectedIndex]);
|
||||||
|
|
||||||
|
|
||||||
// 日期点击时,加载对应日期数据
|
// 日期点击时,加载对应日期数据
|
||||||
@@ -463,7 +495,7 @@ export default function ExploreScreen() {
|
|||||||
style={styles.debugButton}
|
style={styles.debugButton}
|
||||||
onPress={async () => {
|
onPress={async () => {
|
||||||
console.log('🔧 手动触发后台任务测试...');
|
console.log('🔧 手动触发后台任务测试...');
|
||||||
// await backgroundTaskManager.triggerTaskForTesting();
|
await backgroundTaskManager.triggerTaskForTesting();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text style={styles.debugButtonText}>🔧</Text>
|
<Text style={styles.debugButtonText}>🔧</Text>
|
||||||
@@ -555,16 +587,10 @@ export default function ExploreScreen() {
|
|||||||
</FloatingCard> */}
|
</FloatingCard> */}
|
||||||
|
|
||||||
<FloatingCard style={styles.masonryCard}>
|
<FloatingCard style={styles.masonryCard}>
|
||||||
<View style={styles.cardHeaderRow}>
|
<SleepCard
|
||||||
<Text style={styles.cardTitle}>睡眠</Text>
|
sleepDuration={sleepDuration}
|
||||||
</View>
|
onPress={() => pushIfAuthedElseLogin('/sleep-detail')}
|
||||||
{sleepDuration != null ? (
|
/>
|
||||||
<Text style={styles.sleepValue}>
|
|
||||||
{Math.floor(sleepDuration / 60)}小时{Math.floor(sleepDuration % 60)}分钟
|
|
||||||
</Text>
|
|
||||||
) : (
|
|
||||||
<Text style={styles.sleepValue}>——</Text>
|
|
||||||
)}
|
|
||||||
</FloatingCard>
|
</FloatingCard>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -960,12 +986,6 @@ const styles = StyleSheet.create({
|
|||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
marginTop: 8,
|
marginTop: 8,
|
||||||
},
|
},
|
||||||
sleepValue: {
|
|
||||||
fontSize: 16,
|
|
||||||
color: '#1E40AF',
|
|
||||||
fontWeight: '700',
|
|
||||||
marginTop: 8,
|
|
||||||
},
|
|
||||||
weightCard: {
|
weightCard: {
|
||||||
backgroundColor: '#F0F9FF',
|
backgroundColor: '#F0F9FF',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -73,7 +73,8 @@ function Bootstrapper({ children }: { children: React.ReactNode }) {
|
|||||||
if (userDataLoaded && profile?.name) {
|
if (userDataLoaded && profile?.name) {
|
||||||
try {
|
try {
|
||||||
await notificationService.initialize();
|
await notificationService.initialize();
|
||||||
|
// 后台任务
|
||||||
|
await backgroundTaskManager.initialize()
|
||||||
// 注册午餐提醒(12:00)
|
// 注册午餐提醒(12:00)
|
||||||
await NutritionNotificationHelpers.scheduleDailyLunchReminder(profile.name);
|
await NutritionNotificationHelpers.scheduleDailyLunchReminder(profile.name);
|
||||||
console.log('午餐提醒已注册');
|
console.log('午餐提醒已注册');
|
||||||
@@ -86,8 +87,7 @@ function Bootstrapper({ children }: { children: React.ReactNode }) {
|
|||||||
await MoodNotificationHelpers.scheduleDailyMoodReminder(profile.name);
|
await MoodNotificationHelpers.scheduleDailyMoodReminder(profile.name);
|
||||||
console.log('心情提醒已注册');
|
console.log('心情提醒已注册');
|
||||||
|
|
||||||
// 注册喝水提醒后台任务
|
|
||||||
await backgroundTaskManager.registerWaterReminderTask();
|
|
||||||
console.log('喝水提醒后台任务已注册');
|
console.log('喝水提醒后台任务已注册');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('注册提醒失败:', 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;
|
value: number | null;
|
||||||
updateTime?: Date;
|
updateTime?: Date;
|
||||||
style?: any;
|
style?: any;
|
||||||
hrvValue: number;
|
hrvValue: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StressMeter({ value, updateTime, style, hrvValue }: StressMeterProps) {
|
export function StressMeter({ value, updateTime, style, hrvValue }: StressMeterProps) {
|
||||||
@@ -50,6 +50,13 @@ export function StressMeter({ value, updateTime, style, hrvValue }: StressMeterP
|
|||||||
// 使用传入的 hrvValue 进行转换
|
// 使用传入的 hrvValue 进行转换
|
||||||
const stressIndex = convertHrvToStressIndex(hrvValue);
|
const stressIndex = convertHrvToStressIndex(hrvValue);
|
||||||
|
|
||||||
|
// 调试信息
|
||||||
|
console.log('StressMeter 调试:', {
|
||||||
|
hrvValue,
|
||||||
|
stressIndex,
|
||||||
|
progressPercentage: stressIndex !== null ? Math.max(0, Math.min(100, stressIndex)) : 0
|
||||||
|
});
|
||||||
|
|
||||||
// 计算进度条位置(0-100%)
|
// 计算进度条位置(0-100%)
|
||||||
// 压力指数越高,进度条越满(红色区域越多)
|
// 压力指数越高,进度条越满(红色区域越多)
|
||||||
const progressPercentage = stressIndex !== null ? Math.max(0, Math.min(100, stressIndex)) : 0;
|
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',
|
FITNESS_RINGS_DETAIL: '/fitness-rings-detail',
|
||||||
|
SLEEP_DETAIL: '/sleep-detail',
|
||||||
|
|
||||||
// 任务相关路由
|
// 任务相关路由
|
||||||
TASK_DETAIL: '/task-detail',
|
TASK_DETAIL: '/task-detail',
|
||||||
|
|||||||
@@ -1948,7 +1948,7 @@ PODS:
|
|||||||
- ReactCommon/turbomodule/bridging
|
- ReactCommon/turbomodule/bridging
|
||||||
- ReactCommon/turbomodule/core
|
- ReactCommon/turbomodule/core
|
||||||
- Yoga
|
- Yoga
|
||||||
- RNSentry (6.21.0):
|
- RNSentry (7.0.1):
|
||||||
- DoubleConversion
|
- DoubleConversion
|
||||||
- glog
|
- glog
|
||||||
- RCT-Folly (= 2024.11.18.00)
|
- RCT-Folly (= 2024.11.18.00)
|
||||||
@@ -2478,7 +2478,7 @@ SPEC CHECKSUMS:
|
|||||||
RNPurchases: 7993b33416e67d5863140b5c62c682b34719f475
|
RNPurchases: 7993b33416e67d5863140b5c62c682b34719f475
|
||||||
RNReanimated: 34e90d19560aebd52a2ad583fdc2de2cf7651bbb
|
RNReanimated: 34e90d19560aebd52a2ad583fdc2de2cf7651bbb
|
||||||
RNScreens: 241cfe8fc82737f3e132dd45779f9512928075b8
|
RNScreens: 241cfe8fc82737f3e132dd45779f9512928075b8
|
||||||
RNSentry: 605b0108f57a8b921ca5ef7aa0b97d469a723c57
|
RNSentry: 5e404b7714164b2d7b61a5ae41d7e9fa103b308c
|
||||||
RNSVG: 3544def7b3ddc43c7ba69dade91bacf99f10ec46
|
RNSVG: 3544def7b3ddc43c7ba69dade91bacf99f10ec46
|
||||||
SDWebImage: f29024626962457f3470184232766516dee8dfea
|
SDWebImage: f29024626962457f3470184232766516dee8dfea
|
||||||
SDWebImageAVIFCoder: 00310d246aab3232ce77f1d8f0076f8c4b021d90
|
SDWebImageAVIFCoder: 00310d246aab3232ce77f1d8f0076f8c4b021d90
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<dict>
|
<dict>
|
||||||
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||||
<array>
|
<array>
|
||||||
<string>background-health-reminders</string>
|
<string>com.expo.modules.backgroundtask.processing</string>
|
||||||
</array>
|
</array>
|
||||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
|||||||
129
package-lock.json
generated
129
package-lock.json
generated
@@ -17,7 +17,7 @@
|
|||||||
"@react-navigation/elements": "^2.3.8",
|
"@react-navigation/elements": "^2.3.8",
|
||||||
"@react-navigation/native": "^7.1.6",
|
"@react-navigation/native": "^7.1.6",
|
||||||
"@reduxjs/toolkit": "^2.8.2",
|
"@reduxjs/toolkit": "^2.8.2",
|
||||||
"@sentry/react-native": "^6.20.0",
|
"@sentry/react-native": "^7.0.1",
|
||||||
"@types/lodash": "^4.17.20",
|
"@types/lodash": "^4.17.20",
|
||||||
"cos-js-sdk-v5": "^1.6.0",
|
"cos-js-sdk-v5": "^1.6.0",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
@@ -3443,78 +3443,78 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@sentry-internal/browser-utils": {
|
"node_modules/@sentry-internal/browser-utils": {
|
||||||
"version": "8.55.0",
|
"version": "10.8.0",
|
||||||
"resolved": "https://mirrors.tencent.com/npm/@sentry-internal/browser-utils/-/browser-utils-8.55.0.tgz",
|
"resolved": "https://mirrors.tencent.com/npm/@sentry-internal/browser-utils/-/browser-utils-10.8.0.tgz",
|
||||||
"integrity": "sha512-ROgqtQfpH/82AQIpESPqPQe0UyWywKJsmVIqi3c5Fh+zkds5LUxnssTj3yNd1x+kxaPDVB023jAP+3ibNgeNDw==",
|
"integrity": "sha512-FaQX9eefc8sh3h3ZQy16U73KiH0xgDldXnrFiWK6OeWg8X4bJpnYbLqEi96LgHiQhjnnz+UQP1GDzH5oFuu5fA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sentry/core": "8.55.0"
|
"@sentry/core": "10.8.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@sentry-internal/feedback": {
|
"node_modules/@sentry-internal/feedback": {
|
||||||
"version": "8.55.0",
|
"version": "10.8.0",
|
||||||
"resolved": "https://mirrors.tencent.com/npm/@sentry-internal/feedback/-/feedback-8.55.0.tgz",
|
"resolved": "https://mirrors.tencent.com/npm/@sentry-internal/feedback/-/feedback-10.8.0.tgz",
|
||||||
"integrity": "sha512-cP3BD/Q6pquVQ+YL+rwCnorKuTXiS9KXW8HNKu4nmmBAyf7urjs+F6Hr1k9MXP5yQ8W3yK7jRWd09Yu6DHWOiw==",
|
"integrity": "sha512-n7SqgFQItq4QSPG7bCjcZcIwK6AatKnnmSDJ/i6e8jXNIyLwkEuY2NyvTXACxVdO/kafGD5VmrwnTo3Ekc1AMg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sentry/core": "8.55.0"
|
"@sentry/core": "10.8.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@sentry-internal/replay": {
|
"node_modules/@sentry-internal/replay": {
|
||||||
"version": "8.55.0",
|
"version": "10.8.0",
|
||||||
"resolved": "https://mirrors.tencent.com/npm/@sentry-internal/replay/-/replay-8.55.0.tgz",
|
"resolved": "https://mirrors.tencent.com/npm/@sentry-internal/replay/-/replay-10.8.0.tgz",
|
||||||
"integrity": "sha512-roCDEGkORwolxBn8xAKedybY+Jlefq3xYmgN2fr3BTnsXjSYOPC7D1/mYqINBat99nDtvgFvNfRcZPiwwZ1hSw==",
|
"integrity": "sha512-9+qDEoEjv4VopLuOzK1zM4LcvcUsvB5N0iJ+FRCM3XzzOCbebJOniXTQbt5HflJc3XLnQNKFdKfTfgj8M/0RKQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sentry-internal/browser-utils": "8.55.0",
|
"@sentry-internal/browser-utils": "10.8.0",
|
||||||
"@sentry/core": "8.55.0"
|
"@sentry/core": "10.8.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@sentry-internal/replay-canvas": {
|
"node_modules/@sentry-internal/replay-canvas": {
|
||||||
"version": "8.55.0",
|
"version": "10.8.0",
|
||||||
"resolved": "https://mirrors.tencent.com/npm/@sentry-internal/replay-canvas/-/replay-canvas-8.55.0.tgz",
|
"resolved": "https://mirrors.tencent.com/npm/@sentry-internal/replay-canvas/-/replay-canvas-10.8.0.tgz",
|
||||||
"integrity": "sha512-nIkfgRWk1091zHdu4NbocQsxZF1rv1f7bbp3tTIlZYbrH62XVZosx5iHAuZG0Zc48AETLE7K4AX9VGjvQj8i9w==",
|
"integrity": "sha512-jC4OOwiNgrlIPeXIPMLkaW53BSS1do+toYHoWzzO5AXGpN6jRhanoSj36FpVuH2N3kFnxKVfVxrwh8L+/3vFWg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sentry-internal/replay": "8.55.0",
|
"@sentry-internal/replay": "10.8.0",
|
||||||
"@sentry/core": "8.55.0"
|
"@sentry/core": "10.8.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@sentry/babel-plugin-component-annotate": {
|
"node_modules/@sentry/babel-plugin-component-annotate": {
|
||||||
"version": "4.2.0",
|
"version": "4.3.0",
|
||||||
"resolved": "https://mirrors.tencent.com/npm/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-4.2.0.tgz",
|
"resolved": "https://mirrors.tencent.com/npm/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-4.3.0.tgz",
|
||||||
"integrity": "sha512-GFpS3REqaHuyX4LCNqlneAQZIKyHb5ePiI1802n0fhtYjk68I1DTQ3PnbzYi50od/vAsTQVCknaS5F6tidNqTQ==",
|
"integrity": "sha512-OuxqBprXRyhe8Pkfyz/4yHQJc5c3lm+TmYWSSx8u48g5yKewSQDOxkiLU5pAk3WnbLPy8XwU/PN+2BG0YFU9Nw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 14"
|
"node": ">= 14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@sentry/browser": {
|
"node_modules/@sentry/browser": {
|
||||||
"version": "8.55.0",
|
"version": "10.8.0",
|
||||||
"resolved": "https://mirrors.tencent.com/npm/@sentry/browser/-/browser-8.55.0.tgz",
|
"resolved": "https://mirrors.tencent.com/npm/@sentry/browser/-/browser-10.8.0.tgz",
|
||||||
"integrity": "sha512-1A31mCEWCjaMxJt6qGUK+aDnLDcK6AwLAZnqpSchNysGni1pSn1RWSmk9TBF8qyTds5FH8B31H480uxMPUJ7Cw==",
|
"integrity": "sha512-2J7HST8/ixCaboq17yFn/j/OEokXSXoCBMXRrFx4FKJggKWZ90e2Iau5mP/IPPhrW+W9zCptCgNMY0167wS4qA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sentry-internal/browser-utils": "8.55.0",
|
"@sentry-internal/browser-utils": "10.8.0",
|
||||||
"@sentry-internal/feedback": "8.55.0",
|
"@sentry-internal/feedback": "10.8.0",
|
||||||
"@sentry-internal/replay": "8.55.0",
|
"@sentry-internal/replay": "10.8.0",
|
||||||
"@sentry-internal/replay-canvas": "8.55.0",
|
"@sentry-internal/replay-canvas": "10.8.0",
|
||||||
"@sentry/core": "8.55.0"
|
"@sentry/core": "10.8.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@sentry/cli": {
|
"node_modules/@sentry/cli": {
|
||||||
@@ -3706,44 +3706,43 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@sentry/core": {
|
"node_modules/@sentry/core": {
|
||||||
"version": "8.55.0",
|
"version": "10.8.0",
|
||||||
"resolved": "https://mirrors.tencent.com/npm/@sentry/core/-/core-8.55.0.tgz",
|
"resolved": "https://mirrors.tencent.com/npm/@sentry/core/-/core-10.8.0.tgz",
|
||||||
"integrity": "sha512-6g7jpbefjHYs821Z+EBJ8r4Z7LT5h80YSWRJaylGS4nW5W5Z2KXzpdnyFarv37O7QjauzVC2E+PABmpkw5/JGA==",
|
"integrity": "sha512-scYzM/UOItu4PjEq6CpHLdArpXjIS0laHYxE4YjkIbYIH6VMcXGQbD/FSBClsnCr1wXRnlXfXBzj0hrQAFyw+Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@sentry/react": {
|
"node_modules/@sentry/react": {
|
||||||
"version": "8.55.0",
|
"version": "10.8.0",
|
||||||
"resolved": "https://mirrors.tencent.com/npm/@sentry/react/-/react-8.55.0.tgz",
|
"resolved": "https://mirrors.tencent.com/npm/@sentry/react/-/react-10.8.0.tgz",
|
||||||
"integrity": "sha512-/qNBvFLpvSa/Rmia0jpKfJdy16d4YZaAnH/TuKLAtm0BWlsPQzbXCU4h8C5Hsst0Do0zG613MEtEmWpWrVOqWA==",
|
"integrity": "sha512-w/dGLMCLJG2lp8gKVKX1jjeg2inXewKfPb73+PS1CDi9/ihvqZU2DAXxnaNsBA7YYtGwlWVJe1bLAqguwTEpqw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sentry/browser": "8.55.0",
|
"@sentry/browser": "10.8.0",
|
||||||
"@sentry/core": "8.55.0",
|
"@sentry/core": "10.8.0",
|
||||||
"hoist-non-react-statics": "^3.3.2"
|
"hoist-non-react-statics": "^3.3.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.18"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "^16.14.0 || 17.x || 18.x || 19.x"
|
"react": "^16.14.0 || 17.x || 18.x || 19.x"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@sentry/react-native": {
|
"node_modules/@sentry/react-native": {
|
||||||
"version": "6.21.0",
|
"version": "7.0.1",
|
||||||
"resolved": "https://mirrors.tencent.com/npm/@sentry/react-native/-/react-native-6.21.0.tgz",
|
"resolved": "https://mirrors.tencent.com/npm/@sentry/react-native/-/react-native-7.0.1.tgz",
|
||||||
"integrity": "sha512-r8kroioyJCwDtfAgyPGRSLzfNIjNBF0d28+ZHkm0q9fbvcuBlXN3wtDBR+J+0JEbcZrFpYm2QtZWws/2TzP3NQ==",
|
"integrity": "sha512-xz8ON51qSDvcHVFkdLo0b7rlrQVXpRVXqzm7e1+nHEZ07TX0o+utxx04akxD1Z4hmGPTWPmsHeMlm7diV9NtTQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sentry/babel-plugin-component-annotate": "4.2.0",
|
"@sentry/babel-plugin-component-annotate": "4.3.0",
|
||||||
"@sentry/browser": "8.55.0",
|
"@sentry/browser": "10.8.0",
|
||||||
"@sentry/cli": "2.53.0",
|
"@sentry/cli": "2.53.0",
|
||||||
"@sentry/core": "8.55.0",
|
"@sentry/core": "10.8.0",
|
||||||
"@sentry/react": "8.55.0",
|
"@sentry/react": "10.8.0",
|
||||||
"@sentry/types": "8.55.0",
|
"@sentry/types": "10.8.0"
|
||||||
"@sentry/utils": "8.55.0"
|
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"sentry-expo-upload-sourcemaps": "scripts/expo-upload-sourcemaps.js"
|
"sentry-expo-upload-sourcemaps": "scripts/expo-upload-sourcemaps.js"
|
||||||
@@ -3760,27 +3759,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@sentry/types": {
|
"node_modules/@sentry/types": {
|
||||||
"version": "8.55.0",
|
"version": "10.8.0",
|
||||||
"resolved": "https://mirrors.tencent.com/npm/@sentry/types/-/types-8.55.0.tgz",
|
"resolved": "https://mirrors.tencent.com/npm/@sentry/types/-/types-10.8.0.tgz",
|
||||||
"integrity": "sha512-6LRT0+r6NWQ+RtllrUW2yQfodST0cJnkOmdpHA75vONgBUhpKwiJ4H7AmgfoTET8w29pU6AnntaGOe0LJbOmog==",
|
"integrity": "sha512-xRe41/KvnNt4o6t5YeB+yBRTWvLUu6FJpft/VBOs4Bfh1/6rz+l78oxSCtpXo3MsfTd5185I0uuggAjEdD4Y6g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sentry/core": "8.55.0"
|
"@sentry/core": "10.8.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.18"
|
"node": ">=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_modules/@sinclair/typebox": {
|
"node_modules/@sinclair/typebox": {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
"@react-navigation/elements": "^2.3.8",
|
"@react-navigation/elements": "^2.3.8",
|
||||||
"@react-navigation/native": "^7.1.6",
|
"@react-navigation/native": "^7.1.6",
|
||||||
"@reduxjs/toolkit": "^2.8.2",
|
"@reduxjs/toolkit": "^2.8.2",
|
||||||
"@sentry/react-native": "^6.20.0",
|
"@sentry/react-native": "^7.0.1",
|
||||||
"@types/lodash": "^4.17.20",
|
"@types/lodash": "^4.17.20",
|
||||||
"cos-js-sdk-v5": "^1.6.0",
|
"cos-js-sdk-v5": "^1.6.0",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
@@ -80,4 +80,4 @@
|
|||||||
"typescript": "~5.8.3"
|
"typescript": "~5.8.3"
|
||||||
},
|
},
|
||||||
"private": true
|
"private": true
|
||||||
}
|
}
|
||||||
@@ -5,17 +5,11 @@ import * as BackgroundTask from 'expo-background-task';
|
|||||||
import * as TaskManager from 'expo-task-manager';
|
import * as TaskManager from 'expo-task-manager';
|
||||||
import { TaskManagerTaskBody } from 'expo-task-manager';
|
import { TaskManagerTaskBody } from 'expo-task-manager';
|
||||||
|
|
||||||
/**
|
const BACKGROUND_TASK_IDENTIFIER = 'background-task';
|
||||||
* 后台任务标识符
|
|
||||||
*/
|
|
||||||
export const BACKGROUND_TASK_IDS = {
|
|
||||||
WATER_REMINDER: 'water-reminder-task',
|
|
||||||
STAND_REMINDER: 'stand-reminder-task',
|
|
||||||
HEALTH_REMINDERS: 'background-health-reminders',
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
// 定义后台任务
|
// 定义后台任务
|
||||||
TaskManager.defineTask(BACKGROUND_TASK_IDS.HEALTH_REMINDERS, async (body: TaskManagerTaskBody) => {
|
TaskManager.defineTask(BACKGROUND_TASK_IDENTIFIER, async (body: TaskManagerTaskBody) => {
|
||||||
try {
|
try {
|
||||||
console.log('[BackgroundTask] 后台任务执行');
|
console.log('[BackgroundTask] 后台任务执行');
|
||||||
await executeBackgroundTasks();
|
await executeBackgroundTasks();
|
||||||
@@ -209,9 +203,7 @@ export class BackgroundTaskManager {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// 注册后台任务
|
// 注册后台任务
|
||||||
const status = await BackgroundTask.registerTaskAsync(BACKGROUND_TASK_IDS.HEALTH_REMINDERS, {
|
const status = await BackgroundTask.registerTaskAsync(BACKGROUND_TASK_IDENTIFIER);
|
||||||
minimumInterval: 15, // 15分钟
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('[BackgroundTask] 配置状态:', status);
|
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> {
|
async stop(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await BackgroundTask.unregisterTaskAsync(BACKGROUND_TASK_IDS.HEALTH_REMINDERS);
|
await BackgroundTask.unregisterTaskAsync(BACKGROUND_TASK_IDENTIFIER);
|
||||||
console.log('后台任务已停止');
|
console.log('后台任务已停止');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('停止后台任务失败:', 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;
|
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 {
|
function calculateSleepDuration(samples: any[]): number {
|
||||||
return samples.reduce((total, sample) => {
|
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) => {
|
return new Promise((resolve) => {
|
||||||
AppleHealthKit.getSleepSamples(options, (err, res) => {
|
// 使用睡眠专用的日期范围,包含前一天晚上的睡眠数据
|
||||||
|
const sleepOptions = createSleepDateRange(date);
|
||||||
|
|
||||||
|
AppleHealthKit.getSleepSamples(sleepOptions, (err, res) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
logError('睡眠数据', err);
|
logError('睡眠数据', err);
|
||||||
return resolve(0);
|
return resolve(0);
|
||||||
@@ -445,7 +456,24 @@ async function fetchSleepDuration(options: HealthDataOptions): Promise<number> {
|
|||||||
return resolve(0);
|
return resolve(0);
|
||||||
}
|
}
|
||||||
logSuccess('睡眠', res);
|
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),
|
fetchHourlyStepSamples(date),
|
||||||
fetchActiveEnergyBurned(options),
|
fetchActiveEnergyBurned(options),
|
||||||
fetchBasalEnergyBurned(options),
|
fetchBasalEnergyBurned(options),
|
||||||
fetchSleepDuration(options),
|
fetchSleepDuration(date), // 传入日期而不是options
|
||||||
fetchHeartRateVariability(options),
|
fetchHeartRateVariability(options),
|
||||||
fetchActivitySummary(options),
|
fetchActivitySummary(options),
|
||||||
fetchOxygenSaturation(options),
|
fetchOxygenSaturation(options),
|
||||||
|
|||||||
Reference in New Issue
Block a user