feat: 添加睡眠详情页面,集成睡眠数据获取功能,优化健康数据权限管理,更新相关组件以支持睡眠统计和展示

This commit is contained in:
richarjiang
2025-09-08 09:54:33 +08:00
parent df7f04808e
commit e91283fe4e
14 changed files with 1186 additions and 261 deletions

View File

@@ -88,14 +88,6 @@
{ {
"minimumInterval": 15 "minimumInterval": 15
} }
],
[
"expo-task-manager",
{
"taskManagers": [
"background-health-reminders"
]
}
] ]
], ],
"experiments": { "experiments": {

View File

@@ -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',
}, },

View File

@@ -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
View 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',
},
});

View File

@@ -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;

View 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;

View File

@@ -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',

View File

@@ -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

View File

@@ -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
View File

@@ -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": {

View File

@@ -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
} }

View File

@@ -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
View 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');
}

View File

@@ -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),