From cacfde064f0d2b8a44ec60b3c60919096e90771b Mon Sep 17 00:00:00 2001 From: richarjiang Date: Tue, 9 Sep 2025 10:01:11 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E7=9D=A1=E7=9C=A0?= =?UTF-8?q?=E6=95=B0=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/sleep-detail.tsx | 819 ++++++++++++------ ios/WaterWidget/AppIntent.swift | 18 + .../AccentColor.colorset/Contents.json | 11 + .../AppIcon.appiconset/Contents.json | 35 + ios/WaterWidget/Assets.xcassets/Contents.json | 6 + .../WidgetBackground.colorset/Contents.json | 11 + ios/WaterWidget/Info.plist | 11 + ios/WaterWidget/WaterWidget.swift | 88 ++ ios/WaterWidget/WaterWidgetBundle.swift | 18 + ios/WaterWidget/WaterWidgetControl.swift | 77 ++ ios/WaterWidget/WaterWidgetLiveActivity.swift | 80 ++ ios/WaterWidgetExtension.entitlements | 10 + ios/digitalpilates.xcodeproj/project.pbxproj | 220 ++++- .../digitalpilates.entitlements | 4 + services/backgroundTaskManager.ts | 2 + services/sleepService.ts | 93 +- 16 files changed, 1212 insertions(+), 291 deletions(-) create mode 100644 ios/WaterWidget/AppIntent.swift create mode 100644 ios/WaterWidget/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 ios/WaterWidget/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 ios/WaterWidget/Assets.xcassets/Contents.json create mode 100644 ios/WaterWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json create mode 100644 ios/WaterWidget/Info.plist create mode 100644 ios/WaterWidget/WaterWidget.swift create mode 100644 ios/WaterWidget/WaterWidgetBundle.swift create mode 100644 ios/WaterWidget/WaterWidgetControl.swift create mode 100644 ios/WaterWidget/WaterWidgetLiveActivity.swift create mode 100644 ios/WaterWidgetExtension.entitlements 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;