feat: 优化睡眠数据

This commit is contained in:
richarjiang
2025-09-09 10:01:11 +08:00
parent 9ccd15319e
commit cacfde064f
16 changed files with 1212 additions and 291 deletions

View File

@@ -6,15 +6,14 @@ import React, { useEffect, useState } from 'react';
import {
ActivityIndicator,
Animated,
Dimensions,
Modal,
Pressable,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View
} from 'react-native';
import Svg, { Circle } from 'react-native-svg';
import { HeaderBar } from '@/components/ui/HeaderBar';
import { Colors } from '@/constants/Colors';
@@ -24,195 +23,83 @@ import {
formatSleepTime,
formatTime,
getSleepStageColor,
getSleepStageDisplayName,
convertSleepSamplesToIntervals,
SleepDetailData,
SleepStage
} from '@/services/sleepService';
import { ensureHealthPermissions } from '@/utils/health';
const { width } = Dimensions.get('window');
// 圆形进度条组件
const CircularProgress = ({
size,
strokeWidth,
progress,
color,
backgroundColor = '#E5E7EB'
// 简化的睡眠阶段图表组件
const SleepStageChart = ({
sleepData,
onInfoPress
}: {
size: number;
strokeWidth: number;
progress: number; // 0-100
color: string;
backgroundColor?: string;
sleepData: SleepDetailData;
onInfoPress: () => void;
}) => {
const radius = (size - strokeWidth) / 2;
const circumference = radius * 2 * Math.PI;
const strokeDasharray = circumference;
const strokeDashoffset = circumference - (progress / 100) * circumference;
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme];
// 使用真实的睡眠阶段数据,如果没有则使用默认数据
const stages = sleepData.sleepStages.length > 0
? sleepData.sleepStages
.filter(stage => stage.percentage > 0) // 只显示有数据的阶段
.map(stage => ({
stage: stage.stage,
percentage: stage.percentage,
duration: stage.duration
}))
: [
{ stage: SleepStage.Awake, percentage: 1, duration: 3 },
{ stage: SleepStage.REM, percentage: 20, duration: 89 },
{ stage: SleepStage.Core, percentage: 67, duration: 295 },
{ stage: SleepStage.Deep, percentage: 12, duration: 51 }
];
return (
<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>
);
};
<View style={styles.simplifiedChartContainer}>
<View style={styles.chartTitleContainer}>
<Text style={styles.chartTitle}></Text>
<TouchableOpacity
style={styles.chartInfoButton}
onPress={onInfoPress}
>
<Ionicons name="help-circle-outline" size={20} color="#6B7280" />
</TouchableOpacity>
</View>
// 睡眠阶段图表组件
const SleepStageChart = ({ sleepData }: { sleepData: SleepDetailData }) => {
const chartWidth = width - 80;
const chartHeight = 120;
const coreBaselineHeight = chartHeight * 0.6; // 核心睡眠作为基准线
const blockHeight = 20; // 每个睡眠阶段块的固定高度
// 使用真实的 HealthKit 睡眠数据
const generateRealSleepData = () => {
// 如果没有睡眠数据,返回空数组
if (sleepData.totalSleepTime === 0 || !sleepData.rawSleepSamples || sleepData.rawSleepSamples.length === 0) {
console.log('没有可用的睡眠数据用于图表显示');
return [];
}
console.log('使用真实 HealthKit 睡眠数据生成图表,样本数量:', sleepData.rawSleepSamples.length);
// 使用新的转换函数将睡眠样本转换为15分钟间隔数据
const intervalData = convertSleepSamplesToIntervals(
sleepData.rawSleepSamples,
sleepData.bedtime,
sleepData.wakeupTime
);
if (intervalData.length === 0) {
console.log('无法生成睡眠阶段间隔数据 - 可能只有基本的InBed/Asleep数据');
// 如果没有详细的睡眠阶段数据,生成基本的模拟数据作为回退
return generateFallbackSleepData();
}
return intervalData;
};
// 回退方案:当没有详细睡眠阶段数据时使用
const generateFallbackSleepData = () => {
console.log('使用回退睡眠数据 - 用户可能没有Apple Watch或详细睡眠追踪');
const data: { time: string; stage: SleepStage }[] = [];
const bedtime = new Date(sleepData.bedtime);
const wakeupTime = new Date(sleepData.wakeupTime);
let currentTime = new Date(bedtime);
// 基于典型睡眠模式生成合理的睡眠阶段分布
while (currentTime < wakeupTime) {
const timeStr = `${String(currentTime.getHours()).padStart(2, '0')}:${String(currentTime.getMinutes()).padStart(2, '0')}`;
const sleepDuration = wakeupTime.getTime() - bedtime.getTime();
const currentProgress = (currentTime.getTime() - bedtime.getTime()) / sleepDuration;
let stage: SleepStage;
if (currentProgress < 0.15 || currentProgress > 0.85) {
stage = Math.random() < 0.6 ? SleepStage.Core : SleepStage.Awake;
} else if (currentProgress < 0.4) {
stage = Math.random() < 0.7 ? SleepStage.Deep : SleepStage.Core;
} else if (currentProgress < 0.7) {
const rand = Math.random();
stage = rand < 0.6 ? SleepStage.Core : (rand < 0.9 ? SleepStage.REM : SleepStage.Awake);
} else {
const rand = Math.random();
stage = rand < 0.5 ? SleepStage.REM : (rand < 0.9 ? SleepStage.Core : SleepStage.Awake);
}
data.push({ time: timeStr, stage });
currentTime.setMinutes(currentTime.getMinutes() + 15);
}
return data;
};
const sleepDataPoints = generateRealSleepData();
// 获取睡眠阶段在Y轴上的位置
const getStageYPosition = (stage: SleepStage) => {
switch (stage) {
case SleepStage.Awake:
return coreBaselineHeight - blockHeight * 2; // 最上方
case SleepStage.REM:
return coreBaselineHeight - blockHeight; // 上方
case SleepStage.Core:
return coreBaselineHeight; // 基准线
case SleepStage.Deep:
return coreBaselineHeight + blockHeight; // 下方
default:
return coreBaselineHeight;
}
};
// 获取时间标签
const getTimeLabels = () => {
if (sleepData.totalSleepTime === 0) {
return { startTime: '--:--', endTime: '--:--' };
}
return {
startTime: formatTime(sleepData.bedtime),
endTime: formatTime(sleepData.wakeupTime)
};
};
const { startTime, endTime } = getTimeLabels();
return (
<View style={styles.chartContainer}>
<View style={styles.chartHeader}>
<View style={styles.chartTimeLabel}>
<Text style={styles.chartTimeText}>🛏 {startTime}</Text>
{/* 入睡时间和起床时间显示 */}
<View style={styles.sleepTimeLabels}>
<View style={styles.sleepTimeLabel}>
<Text style={[styles.sleepTimeText, { color: colorTokens.textSecondary }]}>
</Text>
<Text style={[styles.sleepTimeValue, { color: colorTokens.text }]}>
{sleepData.bedtime ? formatTime(sleepData.bedtime) : '23:15'}
</Text>
</View>
<View style={styles.chartHeartRate}>
<Text style={styles.chartHeartRateText}> : {sleepData.averageHeartRate || '--'} BPM</Text>
</View>
<View style={styles.chartTimeLabel}>
<Text style={styles.chartTimeText}> {endTime}</Text>
<View style={styles.sleepTimeLabel}>
<Text style={[styles.sleepTimeText, { color: colorTokens.textSecondary }]}>
</Text>
<Text style={[styles.sleepTimeValue, { color: colorTokens.text }]}>
{sleepData.wakeupTime ? formatTime(sleepData.wakeupTime) : '06:52'}
</Text>
</View>
</View>
{/* 分层睡眠阶段图表 */}
<View style={[styles.layeredChartContainer, { height: chartHeight }]}>
{sleepDataPoints.map((dataPoint, index) => {
const blockWidth = chartWidth / sleepDataPoints.length - 1;
const yPosition = getStageYPosition(dataPoint.stage);
const color = getSleepStageColor(dataPoint.stage);
{/* 简化的睡眠阶段 */}
<View style={styles.simplifiedChartBar}>
{stages.map((stageData, index) => {
const color = getSleepStageColor(stageData.stage);
return (
<View
key={index}
style={[
styles.sleepBlock,
styles.stageSegment,
{
width: blockWidth,
height: blockHeight,
backgroundColor: color,
position: 'absolute',
left: index * (blockWidth),
top: yPosition,
flex: stageData.percentage || 1,
}
]}
/>
@@ -220,10 +107,28 @@ const SleepStageChart = ({ sleepData }: { sleepData: SleepDetailData }) => {
})}
</View>
{/* 时间刻度 */}
<View style={styles.chartTimeScale}>
<Text style={styles.chartTimeScaleText}>{startTime}</Text>
<Text style={styles.chartTimeScaleText}>{endTime}</Text>
{/* 图例 */}
<View style={styles.chartLegend}>
<View style={styles.legendRow}>
<View style={styles.legendItem}>
<View style={[styles.legendDot, { backgroundColor: getSleepStageColor(SleepStage.Awake) }]} />
<Text style={styles.legendText}></Text>
</View>
<View style={styles.legendItem}>
<View style={[styles.legendDot, { backgroundColor: getSleepStageColor(SleepStage.REM) }]} />
<Text style={styles.legendText}></Text>
</View>
</View>
<View style={styles.legendRow}>
<View style={styles.legendItem}>
<View style={[styles.legendDot, { backgroundColor: getSleepStageColor(SleepStage.Core) }]} />
<Text style={styles.legendText}></Text>
</View>
<View style={styles.legendItem}>
<View style={[styles.legendDot, { backgroundColor: getSleepStageColor(SleepStage.Deep) }]} />
<Text style={styles.legendText}></Text>
</View>
</View>
</View>
</View>
);
@@ -283,6 +188,151 @@ const SleepGradeCard = ({
);
};
// Sleep Stages Info Modal 组件
const SleepStagesInfoModal = ({
visible,
onClose
}: {
visible: boolean;
onClose: () => void;
}) => {
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme];
const slideAnim = useState(new Animated.Value(0))[0];
React.useEffect(() => {
if (visible) {
slideAnim.setValue(0);
Animated.spring(slideAnim, {
toValue: 1,
useNativeDriver: true,
tension: 100,
friction: 8,
}).start();
} else {
Animated.spring(slideAnim, {
toValue: 0,
useNativeDriver: true,
tension: 100,
friction: 8,
}).start();
}
}, [visible]);
const translateY = slideAnim.interpolate({
inputRange: [0, 1],
outputRange: [300, 0],
});
const opacity = slideAnim.interpolate({
inputRange: [0, 1],
outputRange: [0, 1],
});
return (
<Modal
transparent
visible={visible}
animationType="none"
onRequestClose={onClose}
>
<View style={styles.modalOverlay}>
<Pressable
style={StyleSheet.absoluteFillObject}
onPress={onClose}
/>
<Animated.View
style={[
styles.sleepStagesModalContent,
{
backgroundColor: colorTokens.background,
transform: [{ translateY }],
opacity,
}
]}
>
<View style={styles.sleepStagesModalInner}>
<View style={styles.modalHandle} />
<View style={styles.sleepStagesModalHeader}>
<Text style={[styles.sleepStagesModalTitle, { color: colorTokens.text }]}>
</Text>
<TouchableOpacity onPress={onClose} style={styles.infoModalCloseButton}>
<Ionicons name="close" size={24} color={colorTokens.textSecondary} />
</TouchableOpacity>
</View>
<ScrollView
style={styles.sleepStagesScrollView}
contentContainerStyle={styles.sleepStagesScrollContent}
showsVerticalScrollIndicator={false}
bounces={true}
scrollEnabled={true}
>
<Text style={[styles.sleepStagesDescription, { color: colorTokens.textSecondary }]}>
</Text>
{/* 清醒时间 */}
<View style={styles.sleepStageInfoCard}>
<View style={[styles.sleepStageInfoHeader, { borderBottomColor: colorTokens.border }]}>
<View style={styles.sleepStageInfoTitleContainer}>
<View style={[styles.sleepStageDot, { backgroundColor: '#F59E0B' }]} />
<Text style={[styles.sleepStageInfoTitle, { color: colorTokens.text }]}></Text>
</View>
</View>
<Text style={[styles.sleepStageInfoContent, { color: colorTokens.textSecondary }]}>
</Text>
</View>
{/* 快速动眼睡眠 */}
<View style={styles.sleepStageInfoCard}>
<View style={[styles.sleepStageInfoHeader, { borderBottomColor: colorTokens.border }]}>
<View style={styles.sleepStageInfoTitleContainer}>
<View style={[styles.sleepStageDot, { backgroundColor: '#EC4899' }]} />
<Text style={[styles.sleepStageInfoTitle, { color: colorTokens.text }]}></Text>
</View>
</View>
<Text style={[styles.sleepStageInfoContent, { color: colorTokens.textSecondary }]}>
</Text>
</View>
{/* 核心睡眠 */}
<View style={styles.sleepStageInfoCard}>
<View style={[styles.sleepStageInfoHeader, { borderBottomColor: colorTokens.border }]}>
<View style={styles.sleepStageInfoTitleContainer}>
<View style={[styles.sleepStageDot, { backgroundColor: '#8B5CF6' }]} />
<Text style={[styles.sleepStageInfoTitle, { color: colorTokens.text }]}></Text>
</View>
</View>
<Text style={[styles.sleepStageInfoContent, { color: colorTokens.textSecondary }]}>
</Text>
</View>
{/* 深度睡眠 */}
<View style={styles.sleepStageInfoCard}>
<View style={[styles.sleepStageInfoHeader, { borderBottomColor: colorTokens.border }]}>
<View style={styles.sleepStageInfoTitleContainer}>
<View style={[styles.sleepStageDot, { backgroundColor: '#3B82F6' }]} />
<Text style={[styles.sleepStageInfoTitle, { color: colorTokens.text }]}></Text>
</View>
</View>
<Text style={[styles.sleepStageInfoContent, { color: colorTokens.textSecondary }]}>
</Text>
</View>
</ScrollView>
</View>
</Animated.View>
</View>
</Modal>
);
};
// Info Modal 组件
const InfoModal = ({
visible,
@@ -441,6 +491,10 @@ export default function SleepDetailScreen() {
type: null
});
const [sleepStagesModal, setSleepStagesModal] = useState({
visible: false
});
useEffect(() => {
loadSleepData();
}, [selectedDate]);
@@ -511,6 +565,8 @@ export default function SleepDetailScreen() {
transparent={true}
/>
<ScrollView
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
@@ -518,18 +574,9 @@ export default function SleepDetailScreen() {
>
{/* 睡眠得分圆形显示 */}
<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 style={styles.scoreTextContainer}>
<Text style={styles.scoreNumber}>{displayData.sleepScore}</Text>
<Text style={styles.scoreLabel}></Text>
</View>
</View>
@@ -539,6 +586,26 @@ export default function SleepDetailScreen() {
{/* 建议文本 */}
<Text style={styles.recommendationText}>{displayData.recommendation}</Text>
{/* 调试信息 - 仅在开发模式下显示 */}
{__DEV__ && sleepData && sleepData.rawSleepSamples.length > 0 && (
<View style={[styles.debugContainer, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}>
<Text style={[styles.debugTitle, { color: colorTokens.text }]}>
({sleepData.rawSleepSamples.length} )
</Text>
<Text style={[styles.debugText, { color: colorTokens.textSecondary }]}>
: {[...new Set(sleepData.rawSleepSamples.map(s => s.value))].join(', ')}
</Text>
<Text style={[styles.debugText, { color: colorTokens.textSecondary }]}>
: {sleepData.rawSleepSamples.length > 0 ?
`${formatTime(sleepData.rawSleepSamples[0].startDate)} - ${formatTime(sleepData.rawSleepSamples[sleepData.rawSleepSamples.length - 1].endDate)}` :
'无数据'}
</Text>
<Text style={[styles.debugText, { color: colorTokens.textSecondary }]}>
: {displayData.timeInBed > 0 ? formatSleepTime(displayData.timeInBed) : '未知'}
</Text>
</View>
)}
{/* 睡眠统计卡片 */}
<View style={styles.statsContainer}>
<View style={[styles.newStatCard, { backgroundColor: colorTokens.background }]}>
@@ -597,86 +664,77 @@ export default function SleepDetailScreen() {
</View>
{/* 睡眠阶段图表 */}
<SleepStageChart sleepData={displayData} />
<SleepStageChart
sleepData={displayData}
onInfoPress={() => setSleepStagesModal({ visible: true })}
/>
{/* 睡眠阶段统计 */}
<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' ? '一般' : '偏低'}
{/* 睡眠阶段统计 - 2x2网格布局 */}
<View style={styles.stagesGridContainer}>
{/* 使用真实数据或默认数据确保包含所有4个阶段 */}
{(() => {
let stagesToDisplay;
if (displayData.sleepStages.length > 0) {
// 使用真实数据,确保所有阶段都存在
const existingStages = new Map(displayData.sleepStages.map(s => [s.stage, s]));
stagesToDisplay = [
existingStages.get(SleepStage.Awake) || { stage: SleepStage.Awake, duration: 0, percentage: 0, quality: 'good' as any },
existingStages.get(SleepStage.REM) || { stage: SleepStage.REM, duration: 0, percentage: 0, quality: 'good' as any },
existingStages.get(SleepStage.Core) || { stage: SleepStage.Core, duration: 0, percentage: 0, quality: 'good' as any },
existingStages.get(SleepStage.Deep) || { stage: SleepStage.Deep, duration: 0, percentage: 0, quality: 'good' as any }
];
} else {
// 使用默认数据
stagesToDisplay = [
{ stage: SleepStage.Awake, duration: 3, percentage: 1, quality: 'good' as any },
{ stage: SleepStage.REM, duration: 89, percentage: 20, quality: 'good' as any },
{ stage: SleepStage.Core, duration: 295, percentage: 67, quality: 'good' as any },
{ stage: SleepStage.Deep, duration: 51, percentage: 12, quality: 'poor' as any }
];
}
return stagesToDisplay;
})().map((stageData, index) => {
const getStageName = (stage: SleepStage) => {
switch (stage) {
case SleepStage.Awake: return '清醒时间';
case SleepStage.REM: return '快速眼动';
case SleepStage.Core: return '核心睡眠';
case SleepStage.Deep: return '深度睡眠';
default: return '未知';
}
};
const getQualityDisplay = (quality: any) => {
switch (quality) {
case 'excellent': return { text: '★ 优秀', color: '#10B981', bgColor: '#D1FAE5', progressColor: '#10B981', progressWidth: '100%' };
case 'good': return { text: '✓ 良好', color: '#065F46', bgColor: '#D1FAE5', progressColor: '#10B981', progressWidth: '85%' };
case 'fair': return { text: '○ 一般', color: '#92400E', bgColor: '#FEF3C7', progressColor: '#F59E0B', progressWidth: '65%' };
case 'poor': return { text: '⚠ 低', color: '#DC2626', bgColor: '#FECACA', progressColor: '#F59E0B', progressWidth: '45%' };
default: return { text: '✓ 正常', color: '#065F46', bgColor: '#D1FAE5', progressColor: '#10B981', progressWidth: '75%' };
}
};
const qualityInfo = getQualityDisplay(stageData.quality);
return (
<View key={index} style={[styles.stageCard, { backgroundColor: colorTokens.background }]}>
<Text style={[styles.stageCardTitle, { color: getSleepStageColor(stageData.stage) }]}>
{getStageName(stageData.stage)}
</Text>
</View>
</View>
)) : (
/* 当没有真实数据时,显示包含清醒时间的模拟数据 */
<>
{/* 深度睡眠 */}
<View style={styles.stageRow}>
<View style={styles.stageInfo}>
<View style={[styles.stageColorDot, { backgroundColor: getSleepStageColor(SleepStage.Deep) }]} />
<Text style={styles.stageName}>{getSleepStageDisplayName(SleepStage.Deep)}</Text>
</View>
<View style={styles.stageStats}>
<Text style={styles.stagePercentage}>28%</Text>
<Text style={styles.stageDuration}>2h 04m</Text>
<Text style={[styles.stageQuality, { color: '#10B981' }]}></Text>
<Text style={[styles.stageCardValue, { color: colorTokens.text }]}>
{formatSleepTime(stageData.duration)}
</Text>
<Text style={[styles.stageCardPercentage, { color: colorTokens.textSecondary }]}>
{stageData.percentage}%
</Text>
<View style={[styles.stageCardQuality, { backgroundColor: qualityInfo.bgColor }]}>
<Text style={[styles.stageCardQualityText, { color: qualityInfo.color }]}>
{qualityInfo.text}
</Text>
</View>
</View>
{/* REM睡眠 */}
<View style={styles.stageRow}>
<View style={styles.stageInfo}>
<View style={[styles.stageColorDot, { backgroundColor: getSleepStageColor(SleepStage.REM) }]} />
<Text style={styles.stageName}>{getSleepStageDisplayName(SleepStage.REM)}</Text>
</View>
<View style={styles.stageStats}>
<Text style={styles.stagePercentage}>22%</Text>
<Text style={styles.stageDuration}>1h 37m</Text>
<Text style={[styles.stageQuality, { color: '#10B981' }]}></Text>
</View>
</View>
{/* 核心睡眠 */}
<View style={styles.stageRow}>
<View style={styles.stageInfo}>
<View style={[styles.stageColorDot, { backgroundColor: getSleepStageColor(SleepStage.Core) }]} />
<Text style={styles.stageName}>{getSleepStageDisplayName(SleepStage.Core)}</Text>
</View>
<View style={styles.stageStats}>
<Text style={styles.stagePercentage}>38%</Text>
<Text style={styles.stageDuration}>2h 48m</Text>
<Text style={[styles.stageQuality, { color: '#059669' }]}></Text>
</View>
</View>
{/* 清醒时间 */}
<View style={styles.stageRow}>
<View style={styles.stageInfo}>
<View style={[styles.stageColorDot, { backgroundColor: getSleepStageColor(SleepStage.Awake) }]} />
<Text style={styles.stageName}>{getSleepStageDisplayName(SleepStage.Awake)}</Text>
</View>
<View style={styles.stageStats}>
<Text style={styles.stagePercentage}>12%</Text>
<Text style={styles.stageDuration}>54m</Text>
<Text style={[styles.stageQuality, { color: '#F59E0B' }]}></Text>
</View>
</View>
</>
)}
);
})}
</View>
</ScrollView>
@@ -689,6 +747,11 @@ export default function SleepDetailScreen() {
sleepData={displayData}
/>
)}
<SleepStagesInfoModal
visible={sleepStagesModal.visible}
onClose={() => setSleepStagesModal({ visible: false })}
/>
</View>
);
}
@@ -714,7 +777,6 @@ const styles = StyleSheet.create({
},
scoreContainer: {
alignItems: 'center',
marginVertical: 20,
},
circularProgressContainer: {
position: 'relative',
@@ -722,7 +784,6 @@ const styles = StyleSheet.create({
justifyContent: 'center',
},
scoreTextContainer: {
position: 'absolute',
alignItems: 'center',
justifyContent: 'center',
},
@@ -1097,4 +1158,234 @@ const styles = StyleSheet.create({
fontSize: 12,
fontWeight: '600',
},
// 简化睡眠阶段图表样式
simplifiedChartContainer: {
backgroundColor: 'rgba(255, 255, 255, 0.9)',
borderRadius: 16,
padding: 16,
marginBottom: 24,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 3,
},
chartTitleContainer: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 16,
},
chartTitle: {
fontSize: 16,
fontWeight: '600',
color: '#1F2937',
},
chartInfoButton: {
padding: 4,
},
simplifiedChartBar: {
flexDirection: 'row',
height: 24,
borderRadius: 12,
overflow: 'hidden',
marginBottom: 16,
},
stageSegment: {
height: '100%',
},
chartLegend: {
gap: 8,
},
legendRow: {
flexDirection: 'row',
justifyContent: 'space-between',
},
legendItem: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
},
legendDot: {
width: 8,
height: 8,
borderRadius: 4,
marginRight: 6,
},
legendText: {
fontSize: 12,
color: '#6B7280',
fontWeight: '500',
},
// 睡眠阶段卡片网格样式
stagesGridContainer: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 12,
paddingHorizontal: 4,
},
stageCard: {
width: '48%',
borderRadius: 20,
padding: 16,
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.08,
shadowRadius: 12,
elevation: 4,
borderWidth: 1,
borderColor: 'rgba(0, 0, 0, 0.06)',
},
stageCardTitle: {
fontSize: 14,
fontWeight: '500',
marginBottom: 8,
},
stageCardValue: {
fontSize: 24,
fontWeight: '700',
lineHeight: 28,
marginBottom: 4,
},
stageCardPercentage: {
fontSize: 12,
marginBottom: 12,
},
stageCardQuality: {
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 8,
alignSelf: 'flex-start',
},
normalQuality: {
backgroundColor: '#D1FAE5',
},
lowQuality: {
backgroundColor: '#FECACA',
},
stageCardQualityText: {
fontSize: 12,
fontWeight: '600',
},
normalQualityText: {
color: '#065F46',
},
lowQualityText: {
color: '#DC2626',
},
stageCardProgress: {
height: 6,
backgroundColor: '#E5E7EB',
borderRadius: 3,
overflow: 'hidden',
},
stageCardProgressBar: {
height: '100%',
borderRadius: 3,
},
// Sleep Stages Modal 样式
sleepStagesModalContent: {
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
height: '80%',
shadowColor: '#000',
shadowOffset: { width: 0, height: -4 },
shadowOpacity: 0.1,
shadowRadius: 16,
elevation: 8,
},
sleepStagesModalInner: {
flex: 1,
paddingTop: 12,
paddingHorizontal: 20,
paddingBottom: 34,
},
sleepStagesModalHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 20,
},
sleepStagesModalTitle: {
fontSize: 20,
fontWeight: '700',
letterSpacing: -0.4,
},
sleepStagesScrollView: {
flex: 1,
},
sleepStagesScrollContent: {
paddingBottom: 40,
},
sleepStagesDescription: {
fontSize: 15,
lineHeight: 22,
letterSpacing: -0.1,
marginBottom: 24,
},
sleepStageInfoCard: {
marginBottom: 20,
},
sleepStageInfoHeader: {
paddingBottom: 12,
marginBottom: 12,
},
sleepStageInfoTitleContainer: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
sleepStageDot: {
width: 12,
height: 12,
borderRadius: 6,
},
sleepStageInfoTitle: {
fontSize: 18,
fontWeight: '600',
letterSpacing: -0.2,
},
sleepStageInfoContent: {
fontSize: 15,
lineHeight: 22,
letterSpacing: -0.1,
},
// 睡眠时间标签样式
sleepTimeLabels: {
flexDirection: 'row',
justifyContent: 'space-between',
marginBottom: 16,
},
sleepTimeLabel: {
alignItems: 'center',
},
sleepTimeText: {
fontSize: 12,
fontWeight: '500',
marginBottom: 4,
},
sleepTimeValue: {
fontSize: 16,
fontWeight: '700',
letterSpacing: -0.2,
},
// 调试信息样式
debugContainer: {
marginHorizontal: 20,
marginBottom: 20,
padding: 16,
borderRadius: 12,
borderWidth: 1,
borderColor: 'rgba(0, 0, 0, 0.1)',
},
debugTitle: {
fontSize: 14,
fontWeight: '600',
marginBottom: 8,
},
debugText: {
fontSize: 12,
lineHeight: 16,
marginBottom: 4,
},
});

View File

@@ -0,0 +1,18 @@
//
// AppIntent.swift
// WaterWidget
//
// Created by richard on 2025/9/9.
//
import WidgetKit
import AppIntents
struct ConfigurationAppIntent: WidgetConfigurationIntent {
static var title: LocalizedStringResource { "Configuration" }
static var description: IntentDescription { "This is an example widget." }
// An example configurable parameter.
@Parameter(title: "Favorite Emoji", default: "😃")
var favoriteEmoji: String
}

View File

@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,35 @@
{
"images" : [
{
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "tinted"
}
],
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
</dict>
</dict>
</plist>

View File

@@ -0,0 +1,88 @@
//
// WaterWidget.swift
// WaterWidget
//
// Created by richard on 2025/9/9.
//
import WidgetKit
import SwiftUI
struct Provider: AppIntentTimelineProvider {
func placeholder(in context: Context) -> SimpleEntry {
SimpleEntry(date: Date(), configuration: ConfigurationAppIntent())
}
func snapshot(for configuration: ConfigurationAppIntent, in context: Context) async -> SimpleEntry {
SimpleEntry(date: Date(), configuration: configuration)
}
func timeline(for configuration: ConfigurationAppIntent, in context: Context) async -> Timeline<SimpleEntry> {
var entries: [SimpleEntry] = []
// Generate a timeline consisting of five entries an hour apart, starting from the current date.
let currentDate = Date()
for hourOffset in 0 ..< 5 {
let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)!
let entry = SimpleEntry(date: entryDate, configuration: configuration)
entries.append(entry)
}
return Timeline(entries: entries, policy: .atEnd)
}
// func relevances() async -> WidgetRelevances<ConfigurationAppIntent> {
// // Generate a list containing the contexts this widget is relevant in.
// }
}
struct SimpleEntry: TimelineEntry {
let date: Date
let configuration: ConfigurationAppIntent
}
struct WaterWidgetEntryView : View {
var entry: Provider.Entry
var body: some View {
VStack {
Text("Time:")
Text(entry.date, style: .time)
Text("Favorite Emoji:")
Text(entry.configuration.favoriteEmoji)
}
}
}
struct WaterWidget: Widget {
let kind: String = "WaterWidget"
var body: some WidgetConfiguration {
AppIntentConfiguration(kind: kind, intent: ConfigurationAppIntent.self, provider: Provider()) { entry in
WaterWidgetEntryView(entry: entry)
.containerBackground(.fill.tertiary, for: .widget)
}
}
}
extension ConfigurationAppIntent {
fileprivate static var smiley: ConfigurationAppIntent {
let intent = ConfigurationAppIntent()
intent.favoriteEmoji = "😀"
return intent
}
fileprivate static var starEyes: ConfigurationAppIntent {
let intent = ConfigurationAppIntent()
intent.favoriteEmoji = "🤩"
return intent
}
}
#Preview(as: .systemSmall) {
WaterWidget()
} timeline: {
SimpleEntry(date: .now, configuration: .smiley)
SimpleEntry(date: .now, configuration: .starEyes)
}

View File

@@ -0,0 +1,18 @@
//
// WaterWidgetBundle.swift
// WaterWidget
//
// Created by richard on 2025/9/9.
//
import WidgetKit
import SwiftUI
@main
struct WaterWidgetBundle: WidgetBundle {
var body: some Widget {
WaterWidget()
WaterWidgetControl()
WaterWidgetLiveActivity()
}
}

View File

@@ -0,0 +1,77 @@
//
// WaterWidgetControl.swift
// WaterWidget
//
// Created by richard on 2025/9/9.
//
import AppIntents
import SwiftUI
import WidgetKit
struct WaterWidgetControl: ControlWidget {
static let kind: String = "com.anonymous.digitalpilates.WaterWidget"
var body: some ControlWidgetConfiguration {
AppIntentControlConfiguration(
kind: Self.kind,
provider: Provider()
) { value in
ControlWidgetToggle(
"Start Timer",
isOn: value.isRunning,
action: StartTimerIntent(value.name)
) { isRunning in
Label(isRunning ? "On" : "Off", systemImage: "timer")
}
}
.displayName("Timer")
.description("A an example control that runs a timer.")
}
}
extension WaterWidgetControl {
struct Value {
var isRunning: Bool
var name: String
}
struct Provider: AppIntentControlValueProvider {
func previewValue(configuration: TimerConfiguration) -> Value {
WaterWidgetControl.Value(isRunning: false, name: configuration.timerName)
}
func currentValue(configuration: TimerConfiguration) async throws -> Value {
let isRunning = true // Check if the timer is running
return WaterWidgetControl.Value(isRunning: isRunning, name: configuration.timerName)
}
}
}
struct TimerConfiguration: ControlConfigurationIntent {
static let title: LocalizedStringResource = "Timer Name Configuration"
@Parameter(title: "Timer Name", default: "Timer")
var timerName: String
}
struct StartTimerIntent: SetValueIntent {
static let title: LocalizedStringResource = "Start a timer"
@Parameter(title: "Timer Name")
var name: String
@Parameter(title: "Timer is running")
var value: Bool
init() {}
init(_ name: String) {
self.name = name
}
func perform() async throws -> some IntentResult {
// Start the timer
return .result()
}
}

View File

@@ -0,0 +1,80 @@
//
// WaterWidgetLiveActivity.swift
// WaterWidget
//
// Created by richard on 2025/9/9.
//
import ActivityKit
import WidgetKit
import SwiftUI
struct WaterWidgetAttributes: ActivityAttributes {
public struct ContentState: Codable, Hashable {
// Dynamic stateful properties about your activity go here!
var emoji: String
}
// Fixed non-changing properties about your activity go here!
var name: String
}
struct WaterWidgetLiveActivity: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: WaterWidgetAttributes.self) { context in
// Lock screen/banner UI goes here
VStack {
Text("Hello \(context.state.emoji)")
}
.activityBackgroundTint(Color.cyan)
.activitySystemActionForegroundColor(Color.black)
} dynamicIsland: { context in
DynamicIsland {
// Expanded UI goes here. Compose the expanded UI through
// various regions, like leading/trailing/center/bottom
DynamicIslandExpandedRegion(.leading) {
Text("Leading")
}
DynamicIslandExpandedRegion(.trailing) {
Text("Trailing")
}
DynamicIslandExpandedRegion(.bottom) {
Text("Bottom \(context.state.emoji)")
// more content
}
} compactLeading: {
Text("L")
} compactTrailing: {
Text("T \(context.state.emoji)")
} minimal: {
Text(context.state.emoji)
}
.widgetURL(URL(string: "http://www.apple.com"))
.keylineTint(Color.red)
}
}
}
extension WaterWidgetAttributes {
fileprivate static var preview: WaterWidgetAttributes {
WaterWidgetAttributes(name: "World")
}
}
extension WaterWidgetAttributes.ContentState {
fileprivate static var smiley: WaterWidgetAttributes.ContentState {
WaterWidgetAttributes.ContentState(emoji: "😀")
}
fileprivate static var starEyes: WaterWidgetAttributes.ContentState {
WaterWidgetAttributes.ContentState(emoji: "🤩")
}
}
#Preview("Notification", as: .content, using: WaterWidgetAttributes.preview) {
WaterWidgetLiveActivity()
} contentStates: {
WaterWidgetAttributes.ContentState.smiley
WaterWidgetAttributes.ContentState.starEyes
}

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.anonymous.digitalpilates</string>
</array>
</dict>
</plist>

View File

@@ -3,7 +3,7 @@
archiveVersion = 1;
classes = {
};
objectVersion = 54;
objectVersion = 70;
objects = {
/* Begin PBXBuildFile section */
@@ -11,16 +11,47 @@
2C9C524987451393B76B9C7E /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 7EC44F9488C227087AA8DF97 /* PrivacyInfo.xcprivacy */; };
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; };
6B6021A2D1EB466803BE19D7 /* libPods-digitalpilates.a in Frameworks */ = {isa = PBXBuildFile; fileRef = F1A078ADDB1BCB06E0DBEFDA /* libPods-digitalpilates.a */; };
7996A1192E6FB82300371142 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7996A1182E6FB82300371142 /* WidgetKit.framework */; };
7996A11B2E6FB82300371142 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7996A11A2E6FB82300371142 /* SwiftUI.framework */; };
7996A12C2E6FB82300371142 /* WaterWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 7996A1172E6FB82300371142 /* WaterWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; };
DC3BFC72D3A68C7493D5B44A /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83D1B5F0EC906D7A2F599549 /* ExpoModulesProvider.swift */; };
F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11748412D0307B40044C1D9 /* AppDelegate.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
7996A12A2E6FB82300371142 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 83CBB9F71A601CBA00E9B192 /* Project object */;
proxyType = 1;
remoteGlobalIDString = 7996A1162E6FB82300371142;
remoteInfo = WaterWidgetExtension;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
7996A12D2E6FB82300371142 /* Embed Foundation Extensions */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 13;
files = (
7996A12C2E6FB82300371142 /* WaterWidgetExtension.appex in Embed Foundation Extensions */,
);
name = "Embed Foundation Extensions";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
13B07F961A680F5B00A75B9A /* digitalpilates.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = digitalpilates.app; sourceTree = BUILT_PRODUCTS_DIR; };
13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = digitalpilates/Images.xcassets; sourceTree = "<group>"; };
13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = digitalpilates/Info.plist; sourceTree = "<group>"; };
4D6B8E20DD8E5677F8B2EAA1 /* Pods-digitalpilates.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-digitalpilates.debug.xcconfig"; path = "Target Support Files/Pods-digitalpilates/Pods-digitalpilates.debug.xcconfig"; sourceTree = "<group>"; };
7996A1172E6FB82300371142 /* WaterWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = WaterWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
7996A1182E6FB82300371142 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; };
7996A11A2E6FB82300371142 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; };
7996A1322E6FB84A00371142 /* WaterWidgetExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = WaterWidgetExtension.entitlements; sourceTree = "<group>"; };
7EC44F9488C227087AA8DF97 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = digitalpilates/PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
83D1B5F0EC906D7A2F599549 /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-digitalpilates/ExpoModulesProvider.swift"; sourceTree = "<group>"; };
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = digitalpilates/SplashScreen.storyboard; sourceTree = "<group>"; };
@@ -32,6 +63,20 @@
F1A078ADDB1BCB06E0DBEFDA /* libPods-digitalpilates.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-digitalpilates.a"; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
7996A1302E6FB82300371142 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
);
target = 7996A1162E6FB82300371142 /* WaterWidgetExtension */;
};
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
7996A11C2E6FB82300371142 /* WaterWidget */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (7996A1302E6FB82300371142 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = WaterWidget; sourceTree = "<group>"; };
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
13B07F8C1A680F5B00A75B9A /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
@@ -41,6 +86,15 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
7996A1142E6FB82300371142 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
7996A11B2E6FB82300371142 /* SwiftUI.framework in Frameworks */,
7996A1192E6FB82300371142 /* WidgetKit.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
@@ -63,6 +117,8 @@
children = (
ED297162215061F000B7C4FE /* JavaScriptCore.framework */,
F1A078ADDB1BCB06E0DBEFDA /* libPods-digitalpilates.a */,
7996A1182E6FB82300371142 /* WidgetKit.framework */,
7996A11A2E6FB82300371142 /* SwiftUI.framework */,
);
name = Frameworks;
sourceTree = "<group>";
@@ -86,8 +142,10 @@
83CBB9F61A601CBA00E9B192 = {
isa = PBXGroup;
children = (
7996A1322E6FB84A00371142 /* WaterWidgetExtension.entitlements */,
13B07FAE1A68108700A75B9A /* digitalpilates */,
832341AE1AAA6A7D00B99B32 /* Libraries */,
7996A11C2E6FB82300371142 /* WaterWidget */,
83CBBA001A601CBA00E9B192 /* Products */,
2D16E6871FA4F8E400B85C8A /* Frameworks */,
3EE8D66219D64F4A63E8298D /* Pods */,
@@ -102,6 +160,7 @@
isa = PBXGroup;
children = (
13B07F961A680F5B00A75B9A /* digitalpilates.app */,
7996A1172E6FB82300371142 /* WaterWidgetExtension.appex */,
);
name = Products;
sourceTree = "<group>";
@@ -145,27 +204,55 @@
13B07F8E1A680F5B00A75B9A /* Resources */,
00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */,
800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */,
7996A12D2E6FB82300371142 /* Embed Foundation Extensions */,
);
buildRules = (
);
dependencies = (
7996A12B2E6FB82300371142 /* PBXTargetDependency */,
);
name = digitalpilates;
productName = digitalpilates;
productReference = 13B07F961A680F5B00A75B9A /* digitalpilates.app */;
productType = "com.apple.product-type.application";
};
7996A1162E6FB82300371142 /* WaterWidgetExtension */ = {
isa = PBXNativeTarget;
buildConfigurationList = 7996A1312E6FB82300371142 /* Build configuration list for PBXNativeTarget "WaterWidgetExtension" */;
buildPhases = (
7996A1132E6FB82300371142 /* Sources */,
7996A1142E6FB82300371142 /* Frameworks */,
7996A1152E6FB82300371142 /* Resources */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
7996A11C2E6FB82300371142 /* WaterWidget */,
);
name = WaterWidgetExtension;
packageProductDependencies = (
);
productName = WaterWidgetExtension;
productReference = 7996A1172E6FB82300371142 /* WaterWidgetExtension.appex */;
productType = "com.apple.product-type.app-extension";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
83CBB9F71A601CBA00E9B192 /* Project object */ = {
isa = PBXProject;
attributes = {
LastSwiftUpdateCheck = 1640;
LastUpgradeCheck = 1130;
TargetAttributes = {
13B07F861A680F5B00A75B9A = {
LastSwiftMigration = 1250;
};
7996A1162E6FB82300371142 = {
CreatedOnToolsVersion = 16.4;
};
};
};
buildConfigurationList = 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "digitalpilates" */;
@@ -182,6 +269,7 @@
projectRoot = "";
targets = (
13B07F861A680F5B00A75B9A /* digitalpilates */,
7996A1162E6FB82300371142 /* WaterWidgetExtension */,
);
};
/* End PBXProject section */
@@ -198,6 +286,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
7996A1152E6FB82300371142 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
@@ -329,8 +424,23 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
7996A1132E6FB82300371142 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
7996A12B2E6FB82300371142 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 7996A1162E6FB82300371142 /* WaterWidgetExtension */;
targetProxy = 7996A12A2E6FB82300371142 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
13B07F941A680F5B00A75B9A /* Debug */ = {
isa = XCBuildConfiguration;
@@ -412,6 +522,95 @@
};
name = Release;
};
7996A12E2E6FB82300371142 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_ENTITLEMENTS = WaterWidgetExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = 756WVXJ6MT;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = WaterWidget/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = WaterWidget;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 18.5;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.anonymous.digitalpilates.WaterWidget;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
7996A12F2E6FB82300371142 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_ENTITLEMENTS = WaterWidgetExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 1;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = 756WVXJ6MT;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = WaterWidget/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = WaterWidget;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 18.5;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.0;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.anonymous.digitalpilates.WaterWidget;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
83CBBA201A601CBA00E9B192 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
@@ -467,10 +666,7 @@
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\"";
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
OTHER_LDFLAGS = (
"$(inherited)",
" ",
);
OTHER_LDFLAGS = "$(inherited) ";
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG";
@@ -525,10 +721,7 @@
);
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\"";
MTL_ENABLE_DEBUG_INFO = NO;
OTHER_LDFLAGS = (
"$(inherited)",
" ",
);
OTHER_LDFLAGS = "$(inherited) ";
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
SDKROOT = iphoneos;
USE_HERMES = false;
@@ -548,6 +741,15 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
7996A1312E6FB82300371142 /* Build configuration list for PBXNativeTarget "WaterWidgetExtension" */ = {
isa = XCConfigurationList;
buildConfigurations = (
7996A12E2E6FB82300371142 /* Debug */,
7996A12F2E6FB82300371142 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "digitalpilates" */ = {
isa = XCConfigurationList;
buildConfigurations = (

View File

@@ -12,5 +12,9 @@
<true/>
<key>com.apple.developer.healthkit.background-delivery</key>
<true/>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.anonymous.digitalpilates</string>
</array>
</dict>
</plist>

View File

@@ -162,6 +162,8 @@ async function executeBackgroundTasks(): Promise<void> {
return;
}
await sendTestNotification()
// 执行喝水提醒检查任务
await executeWaterReminderTask();

View File

@@ -163,18 +163,19 @@ function calculateSleepStageStats(samples: SleepSample[]): SleepStageStats[] {
stageMap.set(sample.value, currentDuration + duration);
});
// 计算睡眠时间(排除在床时间)
const totalSleepTime = Array.from(stageMap.entries())
// 计算实际睡眠时间(包括所有睡眠阶段,排除在床时间)
const actualSleepTime = Array.from(stageMap.entries())
.filter(([stage]) => stage !== SleepStage.InBed)
.reduce((total, [, duration]) => total + duration, 0);
// 生成统计数据
// 生成统计数据,包含所有睡眠阶段(包括清醒时间)
const stats: SleepStageStats[] = [];
stageMap.forEach((duration, stage) => {
if (stage === SleepStage.InBed || stage === SleepStage.Awake) return;
// 只排除在床时间,保留清醒时间
if (stage === SleepStage.InBed) return;
const percentage = totalSleepTime > 0 ? (duration / totalSleepTime) * 100 : 0;
const percentage = actualSleepTime > 0 ? (duration / actualSleepTime) * 100 : 0;
let quality: SleepQuality;
// 根据睡眠阶段和比例判断质量
@@ -194,6 +195,12 @@ function calculateSleepStageStats(samples: SleepSample[]): SleepStageStats[] {
percentage >= 35 ? SleepQuality.Good :
percentage >= 25 ? SleepQuality.Fair : SleepQuality.Poor;
break;
case SleepStage.Awake:
// 清醒时间越少越好
quality = percentage <= 5 ? SleepQuality.Excellent :
percentage <= 10 ? SleepQuality.Good :
percentage <= 15 ? SleepQuality.Fair : SleepQuality.Poor;
break;
default:
quality = SleepQuality.Fair;
}
@@ -285,12 +292,12 @@ export function getSleepStageDisplayName(stage: SleepStage): string {
export function getSleepStageColor(stage: SleepStage): string {
switch (stage) {
case SleepStage.Deep:
return '#1E40AF'; // 深蓝色
case SleepStage.Core:
return '#3B82F6'; // 蓝色
case SleepStage.Core:
return '#8B5CF6'; // 紫色
case SleepStage.REM:
case SleepStage.Asleep:
return '#06B6D4'; //
return '#EC4899'; //
case SleepStage.Awake:
return '#F59E0B'; // 橙色
case SleepStage.InBed:
@@ -313,21 +320,71 @@ export async function fetchSleepDetailForDate(date: Date): Promise<SleepDetailDa
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 actualSleepSamples = sleepSamples.filter(sample =>
sample.value !== SleepStage.InBed && sample.value !== SleepStage.Awake
);
// 计算在床时间
const timeInBed = dayjs(wakeupTime).diff(dayjs(bedtime), 'minute');
// 入睡时间:第一个实际睡眠阶段的开始时间
// 起床时间:最后一个实际睡眠阶段的结束时间
let bedtime: string;
let wakeupTime: string;
if (actualSleepSamples.length > 0) {
// 按开始时间排序
const sortedSleepSamples = actualSleepSamples.sort((a, b) =>
new Date(a.startDate).getTime() - new Date(b.startDate).getTime()
);
bedtime = sortedSleepSamples[0].startDate;
wakeupTime = sortedSleepSamples[sortedSleepSamples.length - 1].endDate;
console.log('计算入睡和起床时间:');
console.log('- 入睡时间:', dayjs(bedtime).format('YYYY-MM-DD HH:mm:ss'));
console.log('- 起床时间:', dayjs(wakeupTime).format('YYYY-MM-DD HH:mm:ss'));
} else {
// 如果没有实际睡眠数据,回退到使用所有样本数据
console.warn('没有找到实际睡眠阶段数据,使用所有样本数据');
const sortedAllSamples = sleepSamples.sort((a, b) =>
new Date(a.startDate).getTime() - new Date(b.startDate).getTime()
);
bedtime = sortedAllSamples[0].startDate;
wakeupTime = sortedAllSamples[sortedAllSamples.length - 1].endDate;
}
// 计算在床时间 - 使用 INBED 样本数据
let timeInBed: number;
const inBedSamples = sleepSamples.filter(sample => sample.value === SleepStage.InBed);
if (inBedSamples.length > 0) {
// 使用 INBED 样本计算在床时间
const sortedInBedSamples = inBedSamples.sort((a, b) =>
new Date(a.startDate).getTime() - new Date(b.startDate).getTime()
);
const inBedStart = sortedInBedSamples[0].startDate;
const inBedEnd = sortedInBedSamples[sortedInBedSamples.length - 1].endDate;
timeInBed = dayjs(inBedEnd).diff(dayjs(inBedStart), 'minute');
console.log('在床时间计算:');
console.log('- 上床时间:', dayjs(inBedStart).format('YYYY-MM-DD HH:mm:ss'));
console.log('- 离床时间:', dayjs(inBedEnd).format('YYYY-MM-DD HH:mm:ss'));
console.log('- 在床时长:', timeInBed, '分钟');
} else {
// 如果没有 INBED 数据,使用睡眠时间作为在床时间
timeInBed = dayjs(wakeupTime).diff(dayjs(bedtime), 'minute');
console.log('没有INBED数据使用睡眠时间作为在床时间:', timeInBed, '分钟');
}
// 计算睡眠阶段统计
const sleepStages = calculateSleepStageStats(sleepSamples);
// 计算总睡眠时间
const totalSleepTime = sleepStages.reduce((total, stage) => total + stage.duration, 0);
// 计算总睡眠时间(排除清醒时间)
const totalSleepTime = sleepStages
.filter(stage => stage.stage !== SleepStage.Awake)
.reduce((total, stage) => total + stage.duration, 0);
// 计算睡眠效率
const sleepEfficiency = timeInBed > 0 ? Math.round((totalSleepTime / timeInBed) * 100) : 0;