diff --git a/app/sleep-detail.tsx b/app/sleep-detail.tsx
index 4cb16e4..b81c589 100644
--- a/app/sleep-detail.tsx
+++ b/app/sleep-detail.tsx
@@ -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 (
-
- );
-};
+
+
+ 睡眠阶段分析
+
+
+
+
-// 睡眠阶段图表组件
-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 (
-
-
-
- 🛏️ {startTime}
+ {/* 入睡时间和起床时间显示 */}
+
+
+
+ 入睡时间
+
+
+ {sleepData.bedtime ? formatTime(sleepData.bedtime) : '23:15'}
+
-
- ❤️ 平均心率: {sleepData.averageHeartRate || '--'} BPM
-
-
- ☀️ {endTime}
+
+
+ 起床时间
+
+
+ {sleepData.wakeupTime ? formatTime(sleepData.wakeupTime) : '06:52'}
+
- {/* 分层睡眠阶段图表 */}
-
- {sleepDataPoints.map((dataPoint, index) => {
- const blockWidth = chartWidth / sleepDataPoints.length - 1;
- const yPosition = getStageYPosition(dataPoint.stage);
- const color = getSleepStageColor(dataPoint.stage);
-
+ {/* 简化的睡眠阶段条 */}
+
+ {stages.map((stageData, index) => {
+ const color = getSleepStageColor(stageData.stage);
return (
@@ -220,10 +107,28 @@ const SleepStageChart = ({ sleepData }: { sleepData: SleepDetailData }) => {
})}
- {/* 时间刻度 */}
-
- {startTime}
- {endTime}
+ {/* 图例 */}
+
+
+
+
+ 清醒时间
+
+
+
+ 快速眼动
+
+
+
+
+
+ 核心睡眠
+
+
+
+ 深度睡眠
+
+
);
@@ -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 (
+
+
+
+
+
+
+
+
+
+ 了解你的睡眠阶段
+
+
+
+
+
+
+
+
+ 人们对睡眠阶段和睡眠质量有许多误解。有些人可能需要更多深度睡眠,其他人则不然。科学家和医生仍在探索不同睡眠阶段的作用及其对身体的影响。通过跟踪睡眠阶段并留意每天清晨的感受,你或许能深入了解自己的睡眠。
+
+
+ {/* 清醒时间 */}
+
+
+
+
+ 清醒时间
+
+
+
+ 一次睡眠期间,你可能会醒来几次。偶尔醒来很正常。可能你会立刻再次入睡,并不记得曾在夜间醒来。
+
+
+
+ {/* 快速动眼睡眠 */}
+
+
+
+
+ 快速动眼睡眠
+
+
+
+ 这一睡眠阶段可能对学习和记忆产生一定影响。在此阶段,你的肌肉最为放松,眼球也会快速左右移动。这也是你大多数梦境出现的阶段。
+
+
+
+ {/* 核心睡眠 */}
+
+
+
+
+ 核心睡眠
+
+
+
+ 这一阶段有时也称为浅睡期,与其他阶段一样重要。此阶段通常占据你每晚大部分的睡眠时间。对于认知至关重要的脑电波会在这一阶段产生。
+
+
+
+ {/* 深度睡眠 */}
+
+
+
+
+ 深度睡眠
+
+
+
+ 因为脑电波的特征,这一阶段也称为慢波睡眠。在此阶段,身体组织得到修复,并释放重要荷尔蒙。它通常出现在睡眠的前半段,且持续时间较长。深度睡眠期间,身体非常放松,因此相较于其他阶段,你可能更难在此阶段醒来。
+
+
+
+
+
+
+
+ );
+};
+
// 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}
/>
+
+
{/* 睡眠得分圆形显示 */}
-
-
-
- {displayData.sleepScore}
- 睡眠得分
-
+
+ {displayData.sleepScore}
+ 睡眠得分
@@ -539,6 +586,26 @@ export default function SleepDetailScreen() {
{/* 建议文本 */}
{displayData.recommendation}
+ {/* 调试信息 - 仅在开发模式下显示 */}
+ {__DEV__ && sleepData && sleepData.rawSleepSamples.length > 0 && (
+
+
+ 调试信息 ({sleepData.rawSleepSamples.length} 个睡眠样本)
+
+
+ 原始睡眠样本类型: {[...new Set(sleepData.rawSleepSamples.map(s => s.value))].join(', ')}
+
+
+ 时间范围: {sleepData.rawSleepSamples.length > 0 ?
+ `${formatTime(sleepData.rawSleepSamples[0].startDate)} - ${formatTime(sleepData.rawSleepSamples[sleepData.rawSleepSamples.length - 1].endDate)}` :
+ '无数据'}
+
+
+ 在床时长: {displayData.timeInBed > 0 ? formatSleepTime(displayData.timeInBed) : '未知'}
+
+
+ )}
+
{/* 睡眠统计卡片 */}
@@ -597,86 +664,77 @@ export default function SleepDetailScreen() {
{/* 睡眠阶段图表 */}
-
+ setSleepStagesModal({ visible: true })}
+ />
- {/* 睡眠阶段统计 */}
-
- {displayData.sleepStages.length > 0 ? displayData.sleepStages.map((stage, index) => (
-
-
-
- {getSleepStageDisplayName(stage.stage)}
-
-
- {stage.percentage}%
- {formatSleepTime(stage.duration)}
-
- {stage.quality === 'excellent' ? '优秀' :
- stage.quality === 'good' ? '良好' :
- stage.quality === 'fair' ? '一般' : '偏低'}
+ {/* 睡眠阶段统计 - 2x2网格布局 */}
+
+ {/* 使用真实数据或默认数据,确保包含所有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 (
+
+
+ {getStageName(stageData.stage)}
-
-
- )) : (
- /* 当没有真实数据时,显示包含清醒时间的模拟数据 */
- <>
- {/* 深度睡眠 */}
-
-
-
- {getSleepStageDisplayName(SleepStage.Deep)}
-
-
- 28%
- 2h 04m
- 良好
+
+ {formatSleepTime(stageData.duration)}
+
+
+ 占总体睡眠的 {stageData.percentage}%
+
+
+
+ {qualityInfo.text}
+
- {/* REM睡眠 */}
-
-
-
- {getSleepStageDisplayName(SleepStage.REM)}
-
-
- 22%
- 1h 37m
- 优秀
-
-
- {/* 核心睡眠 */}
-
-
-
- {getSleepStageDisplayName(SleepStage.Core)}
-
-
- 38%
- 2h 48m
- 良好
-
-
- {/* 清醒时间 */}
-
-
-
- {getSleepStageDisplayName(SleepStage.Awake)}
-
-
- 12%
- 54m
- 正常
-
-
- >
- )}
+ );
+ })}
@@ -689,6 +747,11 @@ export default function SleepDetailScreen() {
sleepData={displayData}
/>
)}
+
+ setSleepStagesModal({ visible: false })}
+ />
);
}
@@ -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,
+ },
});
\ No newline at end of file
diff --git a/ios/WaterWidget/AppIntent.swift b/ios/WaterWidget/AppIntent.swift
new file mode 100644
index 0000000..a98c787
--- /dev/null
+++ b/ios/WaterWidget/AppIntent.swift
@@ -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
+}
diff --git a/ios/WaterWidget/Assets.xcassets/AccentColor.colorset/Contents.json b/ios/WaterWidget/Assets.xcassets/AccentColor.colorset/Contents.json
new file mode 100644
index 0000000..eb87897
--- /dev/null
+++ b/ios/WaterWidget/Assets.xcassets/AccentColor.colorset/Contents.json
@@ -0,0 +1,11 @@
+{
+ "colors" : [
+ {
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/ios/WaterWidget/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/WaterWidget/Assets.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 0000000..2305880
--- /dev/null
+++ b/ios/WaterWidget/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -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
+ }
+}
diff --git a/ios/WaterWidget/Assets.xcassets/Contents.json b/ios/WaterWidget/Assets.xcassets/Contents.json
new file mode 100644
index 0000000..73c0059
--- /dev/null
+++ b/ios/WaterWidget/Assets.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/ios/WaterWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json b/ios/WaterWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json
new file mode 100644
index 0000000..eb87897
--- /dev/null
+++ b/ios/WaterWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json
@@ -0,0 +1,11 @@
+{
+ "colors" : [
+ {
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/ios/WaterWidget/Info.plist b/ios/WaterWidget/Info.plist
new file mode 100644
index 0000000..0f118fb
--- /dev/null
+++ b/ios/WaterWidget/Info.plist
@@ -0,0 +1,11 @@
+
+
+
+
+ NSExtension
+
+ NSExtensionPointIdentifier
+ com.apple.widgetkit-extension
+
+
+
diff --git a/ios/WaterWidget/WaterWidget.swift b/ios/WaterWidget/WaterWidget.swift
new file mode 100644
index 0000000..8cc571c
--- /dev/null
+++ b/ios/WaterWidget/WaterWidget.swift
@@ -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 {
+ 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 {
+// // 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)
+}
diff --git a/ios/WaterWidget/WaterWidgetBundle.swift b/ios/WaterWidget/WaterWidgetBundle.swift
new file mode 100644
index 0000000..35795a0
--- /dev/null
+++ b/ios/WaterWidget/WaterWidgetBundle.swift
@@ -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()
+ }
+}
diff --git a/ios/WaterWidget/WaterWidgetControl.swift b/ios/WaterWidget/WaterWidgetControl.swift
new file mode 100644
index 0000000..2176654
--- /dev/null
+++ b/ios/WaterWidget/WaterWidgetControl.swift
@@ -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()
+ }
+}
diff --git a/ios/WaterWidget/WaterWidgetLiveActivity.swift b/ios/WaterWidget/WaterWidgetLiveActivity.swift
new file mode 100644
index 0000000..29acc5d
--- /dev/null
+++ b/ios/WaterWidget/WaterWidgetLiveActivity.swift
@@ -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
+}
diff --git a/ios/WaterWidgetExtension.entitlements b/ios/WaterWidgetExtension.entitlements
new file mode 100644
index 0000000..d47d8ba
--- /dev/null
+++ b/ios/WaterWidgetExtension.entitlements
@@ -0,0 +1,10 @@
+
+
+
+
+ com.apple.security.application-groups
+
+ group.com.anonymous.digitalpilates
+
+
+
diff --git a/ios/digitalpilates.xcodeproj/project.pbxproj b/ios/digitalpilates.xcodeproj/project.pbxproj
index f985030..3f3419e 100644
--- a/ios/digitalpilates.xcodeproj/project.pbxproj
+++ b/ios/digitalpilates.xcodeproj/project.pbxproj
@@ -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 = ""; };
13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = digitalpilates/Info.plist; sourceTree = ""; };
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 = ""; };
+ 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 = ""; };
7EC44F9488C227087AA8DF97 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = digitalpilates/PrivacyInfo.xcprivacy; sourceTree = ""; };
83D1B5F0EC906D7A2F599549 /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-digitalpilates/ExpoModulesProvider.swift"; sourceTree = ""; };
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = digitalpilates/SplashScreen.storyboard; sourceTree = ""; };
@@ -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 = ""; };
+/* 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 = "";
@@ -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 = "";
@@ -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 = (
diff --git a/ios/digitalpilates/digitalpilates.entitlements b/ios/digitalpilates/digitalpilates.entitlements
index f31d5d9..75ceb06 100644
--- a/ios/digitalpilates/digitalpilates.entitlements
+++ b/ios/digitalpilates/digitalpilates.entitlements
@@ -12,5 +12,9 @@
com.apple.developer.healthkit.background-delivery
+ com.apple.security.application-groups
+
+ group.com.anonymous.digitalpilates
+
diff --git a/services/backgroundTaskManager.ts b/services/backgroundTaskManager.ts
index 66074ce..e3aaae1 100644
--- a/services/backgroundTaskManager.ts
+++ b/services/backgroundTaskManager.ts
@@ -162,6 +162,8 @@ async function executeBackgroundTasks(): Promise {
return;
}
+ await sendTestNotification()
+
// 执行喝水提醒检查任务
await executeWaterReminderTask();
diff --git a/services/sleepService.ts b/services/sleepService.ts
index 8560808..d44087e 100644
--- a/services/sleepService.ts
+++ b/services/sleepService.ts
@@ -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 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
+ );
+
+ // 入睡时间:第一个实际睡眠阶段的开始时间
+ // 起床时间:最后一个实际睡眠阶段的结束时间
+ 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;
+ }
- // 计算在床时间
- const timeInBed = dayjs(wakeupTime).diff(dayjs(bedtime), 'minute');
+ // 计算在床时间 - 使用 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;