diff --git a/.kilocode/rules/memory-bank/context.md b/.kilocode/rules/memory-bank/context.md index 37a7f9e..84b551e 100644 --- a/.kilocode/rules/memory-bank/context.md +++ b/.kilocode/rules/memory-bank/context.md @@ -21,10 +21,11 @@ ### 健康数据追踪 ✅ -- HealthKit 集成完成,支持步数、心率、HRV、睡眠等数据 +- HealthKit 集成完成,支持步数、心率、HRV、睡眠、手腕温度等数据 - 活动圆环显示(活动卡路里、锻炼分钟、站立小时) - 实时健康数据监控和历史数据查看 - 健康权限管理系统 +- 经期跟踪与 HealthKit 同步 ### 营养管理 ✅ @@ -95,11 +96,12 @@ ### 近期更新 -1. **多语言支持**: 完善挑战页面的多语言翻译支持,建立翻译最佳实践指南 -2. **性能优化**: 优化健康数据加载和图表渲染性能 -3. **用户体验**: 改进 Liquid Glass 设计效果和交互动画 -4. **数据同步**: 增强离线功能和数据同步稳定性 -5. **AI 功能**: 扩展 AI 教练对话能力和分析精度 +1. **健康数据**: 新增手腕温度监测功能(支持 Apple Watch 睡眠手腕温度) +2. **健康数据**: 实现经期数据与 HealthKit 的双向同步(读写与删除) +3. **多语言支持**: 完善挑战页面的多语言翻译支持,建立翻译最佳实践指南 +4. **用户体验**: 改进 Liquid Glass 设计效果和交互动画 +5. **数据同步**: 增强离线功能和数据同步稳定性 +6. **AI 功能**: 扩展 AI 教练对话能力和分析精度 ### 待解决问题 diff --git a/.kilocode/rules/memory-bank/product.md b/.kilocode/rules/memory-bank/product.md index be199ba..1b46154 100644 --- a/.kilocode/rules/memory-bank/product.md +++ b/.kilocode/rules/memory-bank/product.md @@ -1,9 +1,11 @@ # 产品概述 ## 产品定位 + Out Live(超越生命)是一款专注于健康、减肥、瘦身和生活习惯养成的 iOS 应用。该应用通过整合健康数据追踪、AI 教练指导、目标管理和社区挑战等功能,为用户提供全方位的健康生活管理解决方案。 ## 目标用户 + - 关注健康和体重管理的用户 - 希望养成良好生活习惯的用户 - 对普拉提和健身感兴趣的用户 @@ -11,6 +13,7 @@ Out Live(超越生命)是一款专注于健康、减肥、瘦身和生活习 - 希望通过 AI 获得个性化健康指导的用户 ## 核心价值主张 + 1. **全方位健康数据管理**:整合 HealthKit 数据,提供步数、心率、睡眠、饮水量等多维度健康指标追踪 2. **AI 智能教练**:基于用户健康数据提供个性化的健康建议和指导 3. **目标管理系统**:帮助用户设定、追踪和完成健康目标 @@ -20,50 +23,59 @@ Out Live(超越生命)是一款专注于健康、减肥、瘦身和生活习 ## 主要功能模块 ### 健康数据追踪 + - **活动圆环**:展示活动卡路里、锻炼分钟和站立小时 - **步数统计**:按小时显示步数数据和趋势 - **心率监测**:实时心率和心率变异性(HRV)分析 - **睡眠分析**:睡眠质量和时长追踪 +- **手腕温度**:追踪睡眠期间的手腕温度变化 - **体重管理**:体重记录和 BMI 计算 - **饮水量追踪**:每日饮水目标设定和记录 ### 营养管理 + - **饮食记录**:支持文字、语音和拍照识别食物 - **营养分析**:卡路里、蛋白质、碳水化合物等营养成分分析 - **食物库**:丰富的食物数据库和自定义食物功能 - **营养标签识别**:通过拍照识别食品营养标签 ### 目标与习惯管理 + - **目标设定**:支持日、周、月重复模式的目标设定 - **任务管理**:将目标分解为可执行的任务 - **进度追踪**:可视化目标完成进度 - **提醒功能**:智能提醒帮助用户坚持目标 ### 轻断食功能 -- **断食计划**:多种预设断食方案(16:8、18:6等) + +- **断食计划**:多种预设断食方案(16:8、18:6 等) - **断食追踪**:实时显示断食进度和状态 - **智能提醒**:断食开始和结束提醒 - **断食历史**:记录和分析断食历史数据 ### AI 教练系统 + - **智能对话**:基于用户健康数据提供个性化建议 - **体态评估**:通过 AI 分析用户体态照片 - **健康指导**:提供运动、营养和生活方式建议 - **情绪分析**:基于 HRV 数据分析压力水平 ### 社区与挑战 + - **挑战赛**:参与各种健康主题挑战 - **排行榜**:与好友或其他用户比较进度 - **成就系统**:完成目标获得成就奖励 - **社交分享**:分享健康成果到社交平台 ### 训练计划 + - **个性化计划**:基于用户目标生成训练计划 - **运动库**:丰富的运动动作库和指导 - **进度追踪**:记录训练完成情况和效果 - **智能推荐**:根据用户表现调整训练计划 ## 用户体验特色 + 1. **Liquid Glass 设计风格**:采用现代化的毛玻璃效果设计 2. **数据可视化**:丰富的图表和动画展示健康数据 3. **快捷操作**:支持快捷动作和小组件快速记录 @@ -71,6 +83,7 @@ Out Live(超越生命)是一款专注于健康、减肥、瘦身和生活习 5. **隐私保护**:严格保护用户健康数据隐私 ## 技术亮点 + - **HealthKit 深度集成**:充分利用 iOS 健康生态系统 - **实时数据同步**:支持多设备数据实时同步 - **智能通知系统**:基于用户行为的智能提醒 @@ -78,13 +91,15 @@ Out Live(超越生命)是一款专注于健康、减肥、瘦身和生活习 - **无障碍支持**:完整的无障碍功能支持 ## 商业模式 + - **免费增值模式**:基础功能免费,高级功能付费 - **VIP 会员**:提供更多个性化功能和专业指导 - **企业健康**:面向企业提供的员工健康管理解决方案 ## 竞争优势 + 1. **全平台整合**:深度整合 iOS 健康生态系统 2. **AI 技术应用**:先进的 AI 分析和个性化推荐 3. **用户体验**:优秀的界面设计和交互体验 4. **数据安全**:严格的数据隐私保护措施 -5. **专业内容**:基于科学研究的健康指导内容 \ No newline at end of file +5. **专业内容**:基于科学研究的健康指导内容 diff --git a/.kilocode/rules/memory-bank/tech.md b/.kilocode/rules/memory-bank/tech.md index 485084e..d738a01 100644 --- a/.kilocode/rules/memory-bank/tech.md +++ b/.kilocode/rules/memory-bank/tech.md @@ -3,17 +3,20 @@ ## 核心技术 ### 前端框架 + - **React Native**: 0.81.4 - 跨平台移动应用开发框架 - **Expo SDK**: 54.0.13 - React Native 开发平台和工具链 - **Expo Router**: 6.0.12 - 基于文件系统的路由库 - **TypeScript**: 5.9.2 - 类型安全的 JavaScript 超集 ### 状态管理 + - **Redux Toolkit**: 2.9.0 - 状态管理解决方案 - **React Redux**: 9.2.0 - React Redux 绑定 - **Redux Listener Middleware**: 自定义中间件用于自动同步 ### UI 框架和样式 + - **React Native Elements**: UI 组件库 - **Expo UI**: 0.2.0-beta.7 - Expo UI 组件 - **Expo Glass Effect**: 0.1.4 - Liquid Glass 毛玻璃效果, 优先使用 @@ -22,17 +25,20 @@ - **React Native SVG**: 15.12.1 - SVG 图形支持 ### 导航 + - **Expo Router**: 6.0.12 - 文件系统路由 - **React Navigation**: 7.x - 导航库 ## 数据和存储 ### 本地存储 + - **Expo SQLite**: 16.0.8 - SQLite 数据库 - **Expo SQLite KV Store**: 键值存储 - **Async Storage**: 2.2.0 - 异步存储(兼容层) ### 网络和 API + - **Fetch API**: 原生网络请求 - **XMLHttpRequest**: 流式请求支持 - **Axios**: HTTP 客户端(可选) @@ -40,16 +46,19 @@ ## 原生功能集成 ### HealthKit 集成 + - **自定义 HealthKit Manager**: iOS 原生模块 -- **健康数据类型**: 步数、心率、HRV、睡眠、活动圆环等 +- **健康数据类型**: 步数、心率、HRV、睡眠、活动圆环、手腕温度(appleSleepingWristTemperature)等 - **权限管理**: 动态权限请求和状态监控 ### 通知系统 + - **Expo Notifications**: 0.32.12 - 本地和推送通知 - **后台任务**: Expo Task Manager - **推送通知**: 远程推送支持 ### 设备功能 + - **Expo Camera**: 17.0.8 - 相机功能 - **Expo Image Picker**: 17.0.8 - 图片选择 - **Expo Haptics**: 15.0.7 - 触觉反馈 @@ -59,17 +68,20 @@ ## 开发工具和构建 ### 构建系统 + - **Expo Prebuild**: 原生构建生成 - **Metro**: JavaScript 打包工具 - **Babel**: JavaScript 编译器 ### 代码质量 + - **ESLint**: 9.35.0 - 代码检查 - **ESLint Config Expo**: 10.0.0 - Expo ESLint 配置 - **Prettier**: 代码格式化 - **TypeScript**: 类型检查 ### 开发环境 + - **VS Code**: 主要开发 IDE - **Expo Go**: 开发调试 - **iOS Simulator**: iOS 模拟器 @@ -78,21 +90,25 @@ ## 第三方服务 ### 云存储 + - **腾讯云 COS**: 图片和文件存储 - **上传服务**: 自定义上传实现 ### AI 服务 + - **AI 教练**: 自定义 AI 对话服务 - **图像识别**: 食物识别 - **语音识别**: 语音转文字 ### 分析和监控 + - **Sentry**: 7.2.0 - 错误监控和性能分析 - **崩溃报告**: 自动崩溃收集 ## UI 组件库 ### 基础组件 + - **ThemedView**: 主题化视图组件 - **ThemedText**: 主题化文本组件 - **IconSymbol**: 图标组件 @@ -100,6 +116,7 @@ - **AnimatedNumber**: 数字动画组件 ### 业务组件 + - **FitnessRingsCard**: 健身圆环卡片 - **StepsCard**: 步数卡片 - **NutritionRadarCard**: 营养雷达图 @@ -109,6 +126,7 @@ - **TaskCard**: 任务卡片 ### 图表组件 + - **RadarChart**: 雷达图 - **CircularRing**: 圆形进度环 - **CalorieRingChart**: 卡路里环形图 @@ -117,17 +135,20 @@ ## 开发依赖 ### 类型定义 + - **React Types**: 19.1.13 - **React Native Types**: 内置 - **Expo Types**: 内置 ### 工具库 + - **Day.js**: 1.11.18 - 日期处理 - **Lodash**: 4.17.21 - 工具函数库 - **React Native Chart Kit**: 6.12.0 - 图表库 - **Lottie React Native**: 7.3.4 - 动画库 ### 音频和媒体 + - **React Native Voice**: 3.2.4 - 语音识别 - **Expo Media Library**: 18.2.0 - 媒体库 - **Expo Audio**: 音频处理 @@ -135,12 +156,14 @@ ## 平台特定配置 ### iOS 配置 + - **最低版本**: iOS 16.0 - **Bundle ID**: com.anonymous.digitalpilates - **Team ID**: 756WVXJ6MT - **权限配置**: 相机、相册、麦克风、健康数据、通知等 ### 构建配置 + - **New Arch**: 启用 - **JS Engine**: JSC - **Metro 配置**: 自定义配置 @@ -149,18 +172,21 @@ ## 性能优化 ### 渲染优化 + - **React.memo**: 组件记忆化 - **useMemo/useCallback**: 钩子优化 - **FlatList**: 大列表优化 - **InteractionManager**: 延迟渲染 ### 数据优化 + - **Redux Toolkit**: 自动优化 - **数据分页**: 分页加载 - **缓存策略**: 智能缓存 - **后台同步**: 异步同步 ### 资源优化 + - **图片优化**: WebP 格式 - **Bundle 分割**: 代码分割 - **内存管理**: 资源释放 @@ -169,12 +195,14 @@ ## 安全措施 ### 数据安全 + - **HTTPS**: 加密通信 - **Token 管理**: JWT 存储 - **数据加密**: 本地加密 - **权限控制**: 细粒度权限 ### 隐私保护 + - **数据脱敏**: 敏感数据处理 - **权限最小化**: 最小权限原则 - **用户控制**: 数据控制权 @@ -183,11 +211,13 @@ ## 测试框架 ### 单元测试 + - **Jest**: 测试框架 - **React Native Testing Library**: 组件测试 - **Mock**: 模拟数据和服务 ### 集成测试 + - **Detox**: E2E 测试(可选) - **手动测试**: 功能验证 - **性能测试**: 性能基准 @@ -195,12 +225,14 @@ ## 部署和发布 ### 构建流程 + - **Expo EAS Build**: 云端构建 - **App Store Connect**: 应用商店发布 - **OTA 更新**: 热更新 - **版本管理**: 语义化版本 ### 持续集成 + - **GitHub Actions**: 自动化流程 - **代码检查**: 自动化检查 - **测试执行**: 自动化测试 @@ -209,19 +241,22 @@ ## 开发规范 ### 代码规范 + - **ESLint**: 代码检查 - **Prettier**: 代码格式化 - **TypeScript**: 类型安全 - **命名规范**: 统一命名 ### Git 工作流 + - **Conventional Commits**: 提交规范 - **分支策略**: Git Flow - **代码审查**: PR 流程 - **版本标签**: 标签管理 ### 文档规范 + - **JSDoc**: 代码注释 - **README**: 项目文档 - **API 文档**: 接口文档 -- **组件文档**: 组件说明 \ No newline at end of file +- **组件文档**: 组件说明 diff --git a/app/(tabs)/personal.tsx b/app/(tabs)/personal.tsx index c908c06..e825e20 100644 --- a/app/(tabs)/personal.tsx +++ b/app/(tabs)/personal.tsx @@ -10,7 +10,7 @@ import { useVersionCheck } from '@/contexts/VersionCheckContext'; import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useAuthGuard } from '@/hooks/useAuthGuard'; import { useColorScheme } from '@/hooks/useColorScheme'; -import { useI18n } from '@/hooks/useI18n'; + import type { BadgeDto } from '@/services/badges'; import { reportBadgeShowcaseDisplayed } from '@/services/badges'; import { updateUser, type UserLanguage } from '@/services/users'; diff --git a/app/(tabs)/statistics.tsx b/app/(tabs)/statistics.tsx index 0d28674..ea8d343 100644 --- a/app/(tabs)/statistics.tsx +++ b/app/(tabs)/statistics.tsx @@ -7,6 +7,7 @@ import { NutritionRadarCard } from '@/components/NutritionRadarCard'; import CircumferenceCard from '@/components/statistic/CircumferenceCard'; import OxygenSaturationCard from '@/components/statistic/OxygenSaturationCard'; import SleepCard from '@/components/statistic/SleepCard'; +import WristTemperatureCard from '@/components/statistic/WristTemperatureCard'; import StepsCard from '@/components/StepsCard'; import { StressMeter } from '@/components/StressMeter'; import WaterIntakeCard from '@/components/WaterIntakeCard'; @@ -109,6 +110,7 @@ export default function ExploreScreen() { showWater: true, showBasalMetabolism: true, showOxygenSaturation: true, + showWristTemperature: true, showMenstrualCycle: true, showWeight: true, showCircumference: true, @@ -443,7 +445,7 @@ export default function ExploreScreen() { style={styles.scrollView} contentContainerStyle={{ paddingTop: insets.top, - paddingBottom: 60, + paddingBottom: 100, paddingHorizontal: 20 }} showsVerticalScrollIndicator={false} @@ -615,6 +617,15 @@ export default function ExploreScreen() { /> ) }, + temperature: { + visible: cardVisibility.showWristTemperature, + component: ( + + ) + }, menstrual: { visible: cardVisibility.showMenstrualCycle, component: ( diff --git a/app/menstrual-cycle.tsx b/app/menstrual-cycle.tsx index 9b8ce60..31f4daf 100644 --- a/app/menstrual-cycle.tsx +++ b/app/menstrual-cycle.tsx @@ -4,152 +4,51 @@ import { LinearGradient } from 'expo-linear-gradient'; import { Stack, useRouter } from 'expo-router'; import React, { useEffect, useMemo, useRef, useState } from 'react'; import { - DimensionValue, FlatList, StyleSheet, Text, TouchableOpacity, View, } from 'react-native'; +import { useTranslation } from 'react-i18next'; -import { Colors } from '@/constants/Colors'; +import { InlineTip, ITEM_HEIGHT, Legend, MonthBlock } from '@/components/menstrual-cycle'; import { + deleteMenstrualFlow, + fetchMenstrualFlowSamples, + saveMenstrualFlow +} from '@/utils/health'; +import { + buildMenstrualTimeline, + convertHealthKitSamplesToCycleRecords, CycleRecord, - DEFAULT_PERIOD_LENGTH, - MenstrualDayCell, - MenstrualDayStatus, - MenstrualTimeline, - buildMenstrualTimeline + DEFAULT_PERIOD_LENGTH } from '@/utils/menstrualCycle'; type TabKey = 'cycle' | 'analysis'; -const ITEM_HEIGHT = 380; -const STATUS_COLORS: Record = { - period: { bg: '#f5679f', text: '#fff' }, - 'predicted-period': { bg: '#f8d9e9', text: '#9b2c6a' }, - fertile: { bg: '#d9d2ff', text: '#5a52c5' }, - 'ovulation-day': { bg: '#5b4ee4', text: '#fff' }, -}; - -const WEEK_LABELS = ['一', '二', '三', '四', '五', '六', '日']; - -const chunkArray = (array: T[], size: number): T[][] => { - const result: T[][] = []; - for (let i = 0; i < array.length; i += size) { - result.push(array.slice(i, i + size)); - } - return result; -}; - -const DayCell = ({ - cell, - isSelected, - onPress, -}: { - cell: Extract; - isSelected: boolean; - onPress: () => void; -}) => { - const status = cell.info?.status; - const colors = status ? STATUS_COLORS[status] : undefined; - - return ( - - - - {cell.label} - - - {cell.isToday && 今天} - - ); -}; - -const MonthBlock = ({ - month, - selectedDateKey, - onSelect, - renderTip, -}: { - month: MenstrualTimeline['months'][number]; - selectedDateKey: string; - onSelect: (dateKey: string) => void; - renderTip: (colIndex: number) => React.ReactNode; -}) => { - const weeks = useMemo(() => chunkArray(month.cells, 7), [month.cells]); - - return ( - - - {month.title} - {month.subtitle} - - - {WEEK_LABELS.map((label) => ( - - {label} - - ))} - - - {weeks.map((week, weekIndex) => { - const selectedIndex = week.findIndex( - (c) => c.type === 'day' && c.date.format('YYYY-MM-DD') === selectedDateKey - ); - - return ( - - - {week.map((cell) => { - if (cell.type === 'placeholder') { - return ; - } - const dateKey = cell.date.format('YYYY-MM-DD'); - return ( - onSelect(dateKey)} - /> - ); - })} - - {selectedIndex !== -1 && ( - - {renderTip(selectedIndex)} - - )} - - ); - })} - - - ); -}; - export default function MenstrualCycleScreen() { const router = useRouter(); + const { t } = useTranslation(); const [records, setRecords] = useState([]); const [windowConfig, setWindowConfig] = useState({ before: 2, after: 3 }); + + // Load data from HealthKit + useEffect(() => { + const loadData = async () => { + // Calculate date range based on windowConfig + const today = dayjs(); + const startDate = today.subtract(windowConfig.before, 'month').startOf('month').toDate(); + const endDate = today.add(windowConfig.after, 'month').endOf('month').toDate(); + + const samples = await fetchMenstrualFlowSamples(startDate, endDate); + const convertedRecords = convertHealthKitSamplesToCycleRecords(samples); + setRecords(convertedRecords); + }; + + loadData(); + }, [windowConfig]); + const timeline = useMemo( () => buildMenstrualTimeline({ @@ -173,10 +72,10 @@ export default function MenstrualCycleScreen() { const selectedDate = dayjs(selectedDateKey); - const handleMarkStart = () => { + const handleMarkStart = async () => { if (selectedDate.isAfter(dayjs(), 'day')) return; - // Check if the selected date is already covered by an existing record (including duration) + // Check if the selected date is already covered const isCovered = records.some((r) => { const start = dayjs(r.startDate); const end = start.add((r.periodLength ?? DEFAULT_PERIOD_LENGTH) - 1, 'day'); @@ -187,45 +86,36 @@ export default function MenstrualCycleScreen() { }); if (isCovered) return; + // Optimistic Update + const originalRecords = [...records]; setRecords((prev) => { const updated = [...prev]; - - // 1. Check if selectedDate is immediately after an existing period + // Logic for optimistic UI update (same as original logic) const prevRecordIndex = updated.findIndex((r) => { const start = dayjs(r.startDate); const end = start.add((r.periodLength ?? DEFAULT_PERIOD_LENGTH) - 1, 'day'); return end.add(1, 'day').isSame(selectedDate, 'day'); }); - - // 2. Check if selectedDate is immediately before an existing period const nextRecordIndex = updated.findIndex((r) => { return dayjs(r.startDate).subtract(1, 'day').isSame(selectedDate, 'day'); }); if (prevRecordIndex !== -1 && nextRecordIndex !== -1) { - // Merge three parts: Prev + Selected + Next const prevRecord = updated[prevRecordIndex]; const nextRecord = updated[nextRecordIndex]; const newLength = (prevRecord.periodLength ?? DEFAULT_PERIOD_LENGTH) + 1 + (nextRecord.periodLength ?? DEFAULT_PERIOD_LENGTH); - - updated[prevRecordIndex] = { - ...prevRecord, - periodLength: newLength, - }; - // Remove the next record since it's merged + updated[prevRecordIndex] = { ...prevRecord, periodLength: newLength }; updated.splice(nextRecordIndex, 1); } else if (prevRecordIndex !== -1) { - // Extend previous record const prevRecord = updated[prevRecordIndex]; updated[prevRecordIndex] = { ...prevRecord, periodLength: (prevRecord.periodLength ?? DEFAULT_PERIOD_LENGTH) + 1, }; } else if (nextRecordIndex !== -1) { - // Extend next record (start earlier) const nextRecord = updated[nextRecordIndex]; updated[nextRecordIndex] = { ...nextRecord, @@ -233,7 +123,6 @@ export default function MenstrualCycleScreen() { periodLength: (nextRecord.periodLength ?? DEFAULT_PERIOD_LENGTH) + 1, }; } else { - // Create new isolated record const newRecord: CycleRecord = { startDate: selectedDate.format('YYYY-MM-DD'), periodLength: 7, @@ -241,18 +130,59 @@ export default function MenstrualCycleScreen() { }; updated.push(newRecord); } - - return updated.sort( - (a, b) => dayjs(a.startDate).valueOf() - dayjs(b.startDate).valueOf() - ); + return updated.sort((a, b) => dayjs(a.startDate).valueOf() - dayjs(b.startDate).valueOf()); }); + + try { + // Determine what to save to HealthKit + // If we are merging or extending, we are effectively adding one day of flow + // If we are creating a new record, we default to 7 days + // However, accurate HealthKit logging should be per day. + // The previous UI logic "creates" a 7-day period for a single tap. + // We should replicate this behavior in HealthKit for consistency. + + const isNewIsolatedRecord = !records.some((r) => { + const start = dayjs(r.startDate); + const end = start.add((r.periodLength ?? DEFAULT_PERIOD_LENGTH) - 1, 'day'); + // Check adjacency + return ( + end.add(1, 'day').isSame(selectedDate, 'day') || + dayjs(r.startDate).subtract(1, 'day').isSame(selectedDate, 'day') + ); + }); + + if (isNewIsolatedRecord) { + // Save 7 days of flow starting from selectedDate + const promises = []; + for (let i = 0; i < 7; i++) { + const date = selectedDate.add(i, 'day'); + // Don't save future dates if they exceed today (though logic allows predicting) + // But for flow logging, we usually only log past/present. + // However, UI allows setting a period that might extend slightly? + // Let's stick to the selected date logic. + // Wait, if I tap "Mark Start", it creates a 7 day period. + // Should I write 7 samples? Yes, to match the UI state. + promises.push(saveMenstrualFlow(date.toDate(), 1, i === 0)); // 1=unspecified + } + await Promise.all(promises); + } else { + // Just adding a single day to bridge/extend + await saveMenstrualFlow(selectedDate.toDate(), 1, false); + } + } catch (error) { + console.error('Failed to save to HealthKit', error); + // Revert optimistic update + setRecords(originalRecords); + } }; - const handleCancelMark = () => { + const handleCancelMark = async () => { if (!selectedInfo || !selectedInfo.confirmed) return; if (selectedDate.isAfter(dayjs(), 'day')) return; const target = selectedDate; + // Optimistic Update + const originalRecords = [...records]; setRecords((prev) => { const updated: CycleRecord[] = []; prev.forEach((record) => { @@ -264,21 +194,47 @@ export default function MenstrualCycleScreen() { updated.push(record); return; } - - if (diff === 0) { - // 取消开始日:移除整段记录 - return; - } - - // diff > 0 且在区间内:将该日标记为结束日 (选中当日也被取消,所以长度为 diff) - updated.push({ - ...record, - periodLength: diff, - }); + if (diff === 0) return; // Remove entire record (or start of it) + updated.push({ ...record, periodLength: diff }); // Shorten it }); - return updated; }); + + try { + // Logic: + // 1. Find the record covering the target date + const record = records.find((r) => { + const start = dayjs(r.startDate); + const end = start.add((r.periodLength ?? DEFAULT_PERIOD_LENGTH) - 1, 'day'); + return ( + (target.isSame(start, 'day') || target.isAfter(start, 'day')) && + (target.isSame(end, 'day') || target.isBefore(end, 'day')) + ); + }); + + if (record) { + const start = dayjs(record.startDate); + const diff = target.diff(start, 'day'); + + if (diff === 0) { + // If cancelling the start date, the UI removes the ENTIRE period record. + // So we should delete all samples for this period range. + const periodLength = record.periodLength ?? DEFAULT_PERIOD_LENGTH; + const endDate = start.add(periodLength - 1, 'day'); + await deleteMenstrualFlow(start.toDate(), endDate.toDate()); + } else { + // If cancelling a middle/end date, the UI shortens the period to end BEFORE target. + // So we delete from target date onwards to the original end date. + const periodLength = record.periodLength ?? DEFAULT_PERIOD_LENGTH; + const originalEnd = start.add(periodLength - 1, 'day'); + // Delete from target to originalEnd + await deleteMenstrualFlow(target.toDate(), originalEnd.toDate()); + } + } + } catch (error) { + console.error('Failed to delete from HealthKit', error); + setRecords(originalRecords); + } }; const handleLoadPrevious = () => { @@ -314,27 +270,7 @@ export default function MenstrualCycleScreen() { } }).current; - const renderLegend = () => ( - - {[ - { label: '经期', key: 'period' as const }, - { label: '预测经期', key: 'predicted-period' as const }, - { label: '排卵期', key: 'fertile' as const }, - { label: '排卵日', key: 'ovulation-day' as const }, - ].map((item) => ( - - - {item.label} - - ))} - - ); + const listData = useMemo(() => { return timeline.months.map((m) => ({ @@ -344,40 +280,19 @@ export default function MenstrualCycleScreen() { })); }, [timeline.months]); - const renderInlineTip = (columnIndex: number) => { - // 14.28% per cell. Center is 7.14%. - const pointerLeft = `${columnIndex * 14.2857 + 7.1428}%` as DimensionValue; - const isFuture = selectedDate.isAfter(dayjs(), 'day'); - - const base = ( - - - - - - {selectedDate.format('M月D日')} - - {!isFuture && (!selectedInfo || !selectedInfo.confirmed) && ( - - - 标记经期 - - )} - {!isFuture && selectedInfo?.confirmed && selectedInfo.status === 'period' && ( - - 取消标记 - - )} - - - ); - - return base; - }; + const renderInlineTip = (columnIndex: number) => ( + + ); const renderCycleTab = () => ( - {renderLegend()} + ( - 分析 + {t('menstrual.screen.analysis.title')} - 基于最近 6 个周期的记录,计算平均经期和周期长度,后续会展示趋势和预测准确度。 + {t('menstrual.screen.analysis.description')} @@ -433,7 +348,7 @@ export default function MenstrualCycleScreen() { router.back()} style={styles.headerIcon}> - 生理周期 + {t('menstrual.screen.header')} @@ -441,8 +356,8 @@ export default function MenstrualCycleScreen() { {([ - { key: 'cycle', label: '生理周期' }, - { key: 'analysis', label: '分析' }, + { key: 'cycle', label: t('menstrual.screen.tabs.cycle') }, + { key: 'analysis', label: t('menstrual.screen.tabs.analysis') }, ] as { key: TabKey; label: string }[]).map((tab) => { const active = activeTab === tab.key; return ( @@ -525,32 +440,7 @@ const styles = StyleSheet.create({ tabContent: { flex: 1, }, - legendRow: { - flexDirection: 'row', - flexWrap: 'wrap', - gap: 12, - marginBottom: 12, - paddingHorizontal: 4, - }, - legendItem: { - flexDirection: 'row', - alignItems: 'center', - }, - legendDot: { - width: 16, - height: 16, - borderRadius: 8, - marginRight: 6, - }, - legendDotRing: { - borderWidth: 2, - borderColor: '#fff', - }, - legendLabel: { - fontSize: 13, - color: '#111827', - fontFamily: 'AliRegular', - }, + selectedCard: { backgroundColor: '#fff', borderRadius: 16, @@ -569,206 +459,7 @@ const styles = StyleSheet.create({ fontWeight: '700', fontFamily: 'AliBold', }, - tipCard: { - backgroundColor: '#f4f3ff', - borderRadius: 14, - padding: 12, - marginTop: 10, - borderWidth: 1, - borderColor: '#ede9fe', - }, - tipTitle: { - fontSize: 14, - color: '#111827', - fontWeight: '700', - marginBottom: 4, - fontFamily: 'AliBold', - }, - tipDesc: { - fontSize: 12, - color: '#6b7280', - lineHeight: 18, - marginBottom: 8, - fontFamily: 'AliRegular', - }, - tipButton: { - backgroundColor: Colors.light.primary, - paddingVertical: 10, - borderRadius: 12, - alignItems: 'center', - }, - tipButtonText: { - color: '#fff', - fontSize: 14, - fontWeight: '700', - fontFamily: 'AliBold', - }, - tipSecondaryButton: { - backgroundColor: '#fff', - paddingVertical: 10, - borderRadius: 12, - alignItems: 'center', - borderWidth: 1, - borderColor: '#e5e7eb', - }, - tipSecondaryButtonText: { - color: '#0f172a', - fontSize: 14, - fontWeight: '700', - fontFamily: 'AliBold', - }, - inlineTipCard: { - backgroundColor: '#e8e7ff', - borderRadius: 18, - paddingVertical: 10, - paddingHorizontal: 12, - shadowColor: '#000', - shadowOpacity: 0.04, - shadowRadius: 6, - shadowOffset: { width: 0, height: 2 }, - elevation: 1, - }, - inlineTipPointer: { - position: 'absolute', - top: -6, - width: 12, - height: 12, - marginLeft: -6, - backgroundColor: '#e8e7ff', - transform: [{ rotate: '45deg' }], - borderRadius: 3, - }, - inlineTipRow: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - gap: 8, - }, - inlineTipDate: { - flexDirection: 'row', - alignItems: 'center', - gap: 6, - }, - inlineTipDateText: { - fontSize: 14, - color: '#111827', - fontWeight: '800', - fontFamily: 'AliBold', - }, - inlinePrimaryBtn: { - flexDirection: 'row', - alignItems: 'center', - backgroundColor: Colors.light.primary, - paddingHorizontal: 12, - paddingVertical: 8, - borderRadius: 14, - gap: 6, - }, - inlinePrimaryText: { - color: '#fff', - fontSize: 13, - fontWeight: '700', - fontFamily: 'AliBold', - }, - inlineSecondaryBtn: { - paddingHorizontal: 12, - paddingVertical: 8, - borderRadius: 14, - backgroundColor: '#fff', - borderWidth: 1, - borderColor: '#d1d5db', - }, - inlineSecondaryText: { - color: '#111827', - fontSize: 13, - fontWeight: '700', - fontFamily: 'AliBold', - }, - monthCard: { - backgroundColor: '#fff', - borderRadius: 16, - padding: 14, - marginBottom: 12, - shadowColor: '#000', - shadowOpacity: 0.08, - shadowRadius: 8, - shadowOffset: { width: 0, height: 6 }, - elevation: 2, - }, - monthHeader: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - marginBottom: 8, - }, - monthTitle: { - fontSize: 17, - fontWeight: '800', - color: '#0f172a', - fontFamily: 'AliBold', - }, - monthSubtitle: { - fontSize: 12, - color: '#6b7280', - fontFamily: 'AliRegular', - }, - weekRow: { - flexDirection: 'row', - justifyContent: 'space-between', - marginBottom: 6, - paddingHorizontal: 4, - }, - weekLabel: { - width: '14.28%', - textAlign: 'center', - fontSize: 12, - color: '#94a3b8', - fontFamily: 'AliRegular', - }, - monthGrid: { - flexDirection: 'column', - }, - daysRow: { - flexDirection: 'row', - }, - dayCell: { - width: '14.28%', - alignItems: 'center', - marginVertical: 6, - }, - inlineTipContainer: { - paddingBottom: 6, - marginBottom: 6, - }, - dayCircle: { - width: 40, - height: 40, - borderRadius: 20, - alignItems: 'center', - justifyContent: 'center', - backgroundColor: '#f3f4f6', - }, - dayCircleSelected: { - borderWidth: 2, - borderColor: Colors.light.primary, - }, - todayOutline: { - borderWidth: 2, - borderColor: '#94a3b8', - }, - dayLabel: { - fontSize: 15, - fontFamily: 'AliBold', - }, - dayLabelDefault: { - color: '#111827', - }, - todayText: { - fontSize: 10, - color: '#9ca3af', - marginTop: 2, - fontFamily: 'AliRegular', - }, + listContent: { paddingBottom: 80, }, diff --git a/app/statistics-customization.tsx b/app/statistics-customization.tsx index 0ccba49..9b9be9b 100644 --- a/app/statistics-customization.tsx +++ b/app/statistics-customization.tsx @@ -46,6 +46,7 @@ export default function StatisticsCustomizationScreen() { water: { icon: 'water-outline', titleKey: 'statisticsCustomization.items.water', visibilityKey: 'showWater' }, metabolism: { icon: 'flame-outline', titleKey: 'statisticsCustomization.items.basalMetabolism', visibilityKey: 'showBasalMetabolism' }, oxygen: { icon: 'water-outline', titleKey: 'statisticsCustomization.items.oxygenSaturation', visibilityKey: 'showOxygenSaturation' }, + temperature: { icon: 'thermometer-outline', titleKey: 'statisticsCustomization.items.wristTemperature', visibilityKey: 'showWristTemperature' }, menstrual: { icon: 'rose-outline', titleKey: 'statisticsCustomization.items.menstrualCycle', visibilityKey: 'showMenstrualCycle' }, weight: { icon: 'scale-outline', titleKey: 'statisticsCustomization.items.weight', visibilityKey: 'showWeight' }, circumference: { icon: 'body-outline', titleKey: 'statisticsCustomization.items.circumference', visibilityKey: 'showCircumference' }, diff --git a/components/MenstrualCycleCard.tsx b/components/MenstrualCycleCard.tsx index bd3e7a7..82ba0b2 100644 --- a/components/MenstrualCycleCard.tsx +++ b/components/MenstrualCycleCard.tsx @@ -1,14 +1,33 @@ import { LinearGradient } from 'expo-linear-gradient'; -import React, { useMemo } from 'react'; +import dayjs, { Dayjs } from 'dayjs'; +import React, { useEffect, useMemo, useState } from 'react'; import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { useTranslation } from 'react-i18next'; import { Colors } from '@/constants/Colors'; -import { buildMenstrualTimeline } from '@/utils/menstrualCycle'; +import { fetchMenstrualFlowSamples } from '@/utils/health'; +import { + buildMenstrualTimeline, + convertHealthKitSamplesToCycleRecords, + CycleRecord, + DEFAULT_PERIOD_LENGTH, + MenstrualDayInfo, + MenstrualDayStatus, + MenstrualTimeline, +} from '@/utils/menstrualCycle'; type Props = { onPress?: () => void; }; +type Summary = { + state: string; + prefix?: string; + suffix?: string; + number?: number; + fallbackText: string; +}; + const RingIcon = () => ( ( ); export const MenstrualCycleCard: React.FC = ({ onPress }) => { - const { todayInfo, periodLength } = useMemo(() => buildMenstrualTimeline(), []); + const { t } = useTranslation(); + const [records, setRecords] = useState([]); + const [loading, setLoading] = useState(false); + + useEffect(() => { + let mounted = true; + const loadMenstrualData = async () => { + setLoading(true); + try { + const today = dayjs(); + const startDate = today.subtract(3, 'month').startOf('month').toDate(); + const endDate = today.add(4, 'month').endOf('month').toDate(); + + const samples = await fetchMenstrualFlowSamples(startDate, endDate); + if (!mounted) return; + const converted = convertHealthKitSamplesToCycleRecords(samples); + setRecords(converted); + } catch (error) { + console.error('Failed to load menstrual flow samples', error); + if (mounted) { + setRecords([]); + } + } finally { + if (mounted) { + setLoading(false); + } + } + }; + + loadMenstrualData(); + return () => { + mounted = false; + }; + }, []); + + const timeline = useMemo( + () => + buildMenstrualTimeline({ + records, + monthsBefore: 2, + monthsAfter: 4, + defaultPeriodLength: DEFAULT_PERIOD_LENGTH, + }), + [records] + ); const summary = useMemo(() => { - if (!todayInfo) { + if (loading && records.length === 0) { return { - state: '待记录', - dayText: '点击记录本次经期', - number: undefined, + state: t('menstrual.card.syncingState'), + fallbackText: t('menstrual.card.syncingDesc'), }; } - if (todayInfo.status === 'period' || todayInfo.status === 'predicted-period') { - return { - state: todayInfo.status === 'period' ? '经期' : '预测经期', - dayText: '天', - number: todayInfo.dayOfCycle ?? 1, - }; - } - - if (todayInfo.status === 'ovulation-day') { - return { - state: '排卵日', - dayText: '易孕窗口', - number: undefined, - }; - } - - return { - state: '排卵期', - dayText: `距离排卵日${Math.max(periodLength - 1, 1)}天`, - number: undefined, - }; - }, [periodLength, todayInfo]); + return deriveSummary(timeline, records.length > 0, t); + }, [loading, records.length, timeline, t]); return ( - 生理周期 + {t('menstrual.card.title')} @@ -71,10 +113,12 @@ export const MenstrualCycleCard: React.FC = ({ onPress }) => { {summary.number !== undefined ? ( <> - 第 {summary.number} {summary.dayText} + {summary.prefix} + {summary.number} + {summary.suffix} ) : ( - summary.dayText + summary.fallbackText )} @@ -82,6 +126,199 @@ export const MenstrualCycleCard: React.FC = ({ onPress }) => { ); }; +const periodStatuses = new Set(['period', 'predicted-period']); +const fertileStatuses = new Set(['fertile', 'ovulation-day']); +const ovulationStatuses = new Set(['ovulation-day']); + +const deriveSummary = ( + timeline: MenstrualTimeline, + hasRecords: boolean, + t: (key: string, options?: Record) => string +): Summary => { + const today = dayjs(); + const { dayMap, todayInfo } = timeline; + + if (!hasRecords || !Object.keys(dayMap).length) { + return { + state: t('menstrual.card.emptyState'), + fallbackText: t('menstrual.card.emptyDesc'), + }; + } + + const sortedInfos = Object.values(dayMap).sort( + (a, b) => a.date.valueOf() - b.date.valueOf() + ); + + const findContinuousRange = ( + date: Dayjs, + targetStatuses: Set + ): { start: Dayjs; end: Dayjs } | null => { + const key = date.format('YYYY-MM-DD'); + if (!targetStatuses.has(dayMap[key]?.status)) return null; + + let start = date; + let end = date; + + while (true) { + const prev = start.subtract(1, 'day'); + const prevInfo = dayMap[prev.format('YYYY-MM-DD')]; + if (prevInfo && targetStatuses.has(prevInfo.status)) { + start = prev; + } else { + break; + } + } + + while (true) { + const next = end.add(1, 'day'); + const nextInfo = dayMap[next.format('YYYY-MM-DD')]; + if (nextInfo && targetStatuses.has(nextInfo.status)) { + end = next; + } else { + break; + } + } + + return { start, end }; + }; + + const findFutureStatus = ( + targetStatuses: Set, + inclusive = true + ): MenstrualDayInfo | undefined => { + return sortedInfos.find((info) => { + const isInRange = inclusive + ? !info.date.isBefore(today, 'day') + : info.date.isAfter(today, 'day'); + return isInRange && targetStatuses.has(info.status); + }); + }; + + const findPastStatus = (targetStatuses: Set) => { + for (let i = sortedInfos.length - 1; i >= 0; i -= 1) { + const info = sortedInfos[i]; + if (!info.date.isAfter(today, 'day') && targetStatuses.has(info.status)) { + return info; + } + } + return undefined; + }; + + if (todayInfo && periodStatuses.has(todayInfo.status)) { + const range = findContinuousRange(today, periodStatuses); + const end = range?.end ?? today; + const daysLeft = Math.max(end.diff(today, 'day'), 0); + + if (daysLeft === 0) { + return { + state: + todayInfo.status === 'period' + ? t('menstrual.card.periodState') + : t('menstrual.card.predictedPeriodState'), + fallbackText: t('menstrual.card.periodEndToday', { + date: end.format(t('menstrual.dateFormatShort')), + }), + }; + } + + return { + state: + todayInfo.status === 'period' + ? t('menstrual.card.periodState') + : t('menstrual.card.predictedPeriodState'), + prefix: t('menstrual.card.periodEndPrefix'), + number: daysLeft, + suffix: t('menstrual.card.periodEndSuffix', { + date: end.format(t('menstrual.dateFormatShort')), + }), + fallbackText: '', + }; + } + + const nextPeriod = findFutureStatus(periodStatuses, false); + const lastPeriodInfo = findPastStatus(periodStatuses); + const lastPeriodStart = lastPeriodInfo + ? findContinuousRange(lastPeriodInfo.date, periodStatuses)?.start + : undefined; + + const ovulationThisCycle = sortedInfos.find((info) => { + if (!ovulationStatuses.has(info.status)) return false; + if (lastPeriodStart && info.date.isBefore(lastPeriodStart, 'day')) return false; + if (nextPeriod && !info.date.isBefore(nextPeriod.date, 'day')) return false; + return true; + }); + + if (todayInfo?.status === 'fertile') { + const targetOvulation = ovulationThisCycle ?? findFutureStatus(ovulationStatuses); + if (targetOvulation) { + const days = Math.max(targetOvulation.date.diff(today, 'day'), 0); + if (days === 0) { + return { + state: t('menstrual.card.fertileState'), + fallbackText: t('menstrual.card.ovulationToday'), + }; + } + return { + state: t('menstrual.card.fertileState'), + prefix: t('menstrual.card.ovulationCountdownPrefix'), + number: days, + suffix: t('menstrual.card.ovulationCountdownSuffix'), + fallbackText: '', + }; + } + } + + const nextFertile = findFutureStatus(fertileStatuses); + if (nextFertile && (!nextPeriod || nextFertile.date.isBefore(nextPeriod.date))) { + const days = Math.max(nextFertile.date.diff(today, 'day'), 0); + if (days === 0) { + return { + state: t('menstrual.card.fertileState'), + fallbackText: t('menstrual.card.fertileToday'), + }; + } + return { + state: t('menstrual.card.fertileState'), + prefix: t('menstrual.card.fertileCountdownPrefix'), + number: days, + suffix: t('menstrual.card.fertileCountdownSuffix'), + fallbackText: '', + }; + } + + if ( + ovulationThisCycle && + nextPeriod && + today.isAfter(ovulationThisCycle.date, 'day') && + today.isBefore(nextPeriod.date, 'day') + ) { + const days = Math.max(nextPeriod.date.diff(today, 'day'), 0); + return { + state: t('menstrual.card.periodState'), + prefix: t('menstrual.card.nextPeriodPrefix'), + number: days, + suffix: t('menstrual.card.nextPeriodSuffix'), + fallbackText: '', + }; + } + + if (nextPeriod) { + const days = Math.max(nextPeriod.date.diff(today, 'day'), 0); + return { + state: t('menstrual.card.periodState'), + prefix: t('menstrual.card.nextPeriodPrefix'), + number: days, + suffix: t('menstrual.card.nextPeriodSuffix'), + fallbackText: '', + }; + } + + return { + state: t('menstrual.card.emptyState'), + fallbackText: t('menstrual.card.emptyDesc'), + }; +}; + const styles = StyleSheet.create({ wrapper: { width: '100%', diff --git a/components/menstrual-cycle/DayCell.tsx b/components/menstrual-cycle/DayCell.tsx new file mode 100644 index 0000000..48fb0da --- /dev/null +++ b/components/menstrual-cycle/DayCell.tsx @@ -0,0 +1,75 @@ +import { Colors } from '@/constants/Colors'; +import React from 'react'; +import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { STATUS_COLORS } from './constants'; +import { DayCellProps } from './types'; + +export const DayCell: React.FC = ({ cell, isSelected, onPress }) => { + const status = cell.info?.status; + const colors = status ? STATUS_COLORS[status] : undefined; + + return ( + + + + {cell.label} + + + {cell.isToday && 今天} + + ); +}; + +const styles = StyleSheet.create({ + dayCell: { + width: '14.28%', + alignItems: 'center', + marginVertical: 6, + }, + dayCircle: { + width: 40, + height: 40, + borderRadius: 20, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: '#f3f4f6', + }, + dayCircleSelected: { + borderWidth: 2, + borderColor: Colors.light.primary, + }, + todayOutline: { + borderWidth: 2, + borderColor: '#94a3b8', + }, + dayLabel: { + fontSize: 15, + fontFamily: 'AliBold', + }, + dayLabelDefault: { + color: '#111827', + }, + todayText: { + fontSize: 10, + color: '#9ca3af', + marginTop: 2, + fontFamily: 'AliRegular', + }, +}); diff --git a/components/menstrual-cycle/InlineTip.tsx b/components/menstrual-cycle/InlineTip.tsx new file mode 100644 index 0000000..c7340e0 --- /dev/null +++ b/components/menstrual-cycle/InlineTip.tsx @@ -0,0 +1,111 @@ +import { Colors } from '@/constants/Colors'; +import { Ionicons } from '@expo/vector-icons'; +import dayjs from 'dayjs'; +import React from 'react'; +import { DimensionValue, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { InlineTipProps } from './types'; + +export const InlineTip: React.FC = ({ + selectedDate, + selectedInfo, + columnIndex, + onMarkStart, + onCancelMark, +}) => { + // 14.28% per cell. Center is 7.14%. + const pointerLeft = `${columnIndex * 14.2857 + 7.1428}%` as DimensionValue; + const isFuture = selectedDate.isAfter(dayjs(), 'day'); + + return ( + + + + + + {selectedDate.format('M月D日')} + + {!isFuture && (!selectedInfo || !selectedInfo.confirmed) && ( + + + 标记经期 + + )} + {!isFuture && selectedInfo?.confirmed && selectedInfo.status === 'period' && ( + + 取消标记 + + )} + + + ); +}; + +const styles = StyleSheet.create({ + inlineTipCard: { + backgroundColor: '#e8e7ff', + borderRadius: 18, + paddingVertical: 10, + paddingHorizontal: 12, + shadowColor: '#000', + shadowOpacity: 0.04, + shadowRadius: 6, + shadowOffset: { width: 0, height: 2 }, + elevation: 1, + }, + inlineTipPointer: { + position: 'absolute', + top: -6, + width: 12, + height: 12, + marginLeft: -6, + backgroundColor: '#e8e7ff', + transform: [{ rotate: '45deg' }], + borderRadius: 3, + }, + inlineTipRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + gap: 8, + }, + inlineTipDate: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + }, + inlineTipDateText: { + fontSize: 14, + color: '#111827', + fontWeight: '800', + fontFamily: 'AliBold', + }, + inlinePrimaryBtn: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: Colors.light.primary, + paddingHorizontal: 12, + paddingVertical: 8, + borderRadius: 14, + gap: 6, + }, + inlinePrimaryText: { + color: '#fff', + fontSize: 13, + fontWeight: '700', + fontFamily: 'AliBold', + }, + inlineSecondaryBtn: { + paddingHorizontal: 12, + paddingVertical: 8, + borderRadius: 14, + backgroundColor: '#fff', + borderWidth: 1, + borderColor: '#d1d5db', + }, + inlineSecondaryText: { + color: '#111827', + fontSize: 13, + fontWeight: '700', + fontFamily: 'AliBold', + }, +}); diff --git a/components/menstrual-cycle/Legend.tsx b/components/menstrual-cycle/Legend.tsx new file mode 100644 index 0000000..05aabce --- /dev/null +++ b/components/menstrual-cycle/Legend.tsx @@ -0,0 +1,59 @@ +import React from 'react'; +import { StyleSheet, Text, View } from 'react-native'; +import { STATUS_COLORS } from './constants'; +import { LegendItem } from './types'; + +const LEGEND_ITEMS: LegendItem[] = [ + { label: '经期', key: 'period' }, + { label: '预测经期', key: 'predicted-period' }, + { label: '排卵期', key: 'fertile' }, + { label: '排卵日', key: 'ovulation-day' }, +]; + +export const Legend: React.FC = () => { + return ( + + {LEGEND_ITEMS.map((item) => ( + + + {item.label} + + ))} + + ); +}; + +const styles = StyleSheet.create({ + legendRow: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 12, + marginBottom: 12, + paddingHorizontal: 4, + }, + legendItem: { + flexDirection: 'row', + alignItems: 'center', + }, + legendDot: { + width: 16, + height: 16, + borderRadius: 8, + marginRight: 6, + }, + legendDotRing: { + borderWidth: 2, + borderColor: '#fff', + }, + legendLabel: { + fontSize: 13, + color: '#111827', + fontFamily: 'AliRegular', + }, +}); diff --git a/components/menstrual-cycle/MonthBlock.tsx b/components/menstrual-cycle/MonthBlock.tsx new file mode 100644 index 0000000..b776439 --- /dev/null +++ b/components/menstrual-cycle/MonthBlock.tsx @@ -0,0 +1,137 @@ +import { MenstrualTimeline } from '@/utils/menstrualCycle'; +import React, { useMemo } from 'react'; +import { StyleSheet, Text, View } from 'react-native'; +import { DayCell } from './DayCell'; +import { WEEK_LABELS } from './constants'; + +const chunkArray = (array: T[], size: number): T[][] => { + const result: T[][] = []; + for (let i = 0; i < array.length; i += size) { + result.push(array.slice(i, i + size)); + } + return result; +}; + +interface MonthBlockProps { + month: MenstrualTimeline['months'][number]; + selectedDateKey: string; + onSelect: (dateKey: string) => void; + renderTip: (colIndex: number) => React.ReactNode; +} + +export const MonthBlock: React.FC = ({ + month, + selectedDateKey, + onSelect, + renderTip, +}) => { + const weeks = useMemo(() => chunkArray(month.cells, 7), [month.cells]); + + return ( + + + {month.title} + {month.subtitle} + + + {WEEK_LABELS.map((label) => ( + + {label} + + ))} + + + {weeks.map((week, weekIndex) => { + const selectedIndex = week.findIndex( + (c) => c.type === 'day' && c.date.format('YYYY-MM-DD') === selectedDateKey + ); + + return ( + + + {week.map((cell) => { + if (cell.type === 'placeholder') { + return ; + } + const dateKey = cell.date.format('YYYY-MM-DD'); + return ( + onSelect(dateKey)} + /> + ); + })} + + {selectedIndex !== -1 && ( + + {renderTip(selectedIndex)} + + )} + + ); + })} + + + ); +}; + +const styles = StyleSheet.create({ + monthCard: { + backgroundColor: '#fff', + borderRadius: 16, + padding: 14, + marginBottom: 12, + shadowColor: '#000', + shadowOpacity: 0.08, + shadowRadius: 8, + shadowOffset: { width: 0, height: 6 }, + elevation: 2, + }, + monthHeader: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: 8, + }, + monthTitle: { + fontSize: 17, + fontWeight: '800', + color: '#0f172a', + fontFamily: 'AliBold', + }, + monthSubtitle: { + fontSize: 12, + color: '#6b7280', + fontFamily: 'AliRegular', + }, + weekRow: { + flexDirection: 'row', + justifyContent: 'space-between', + marginBottom: 6, + paddingHorizontal: 4, + }, + weekLabel: { + width: '14.28%', + textAlign: 'center', + fontSize: 12, + color: '#94a3b8', + fontFamily: 'AliRegular', + }, + monthGrid: { + flexDirection: 'column', + }, + daysRow: { + flexDirection: 'row', + }, + dayCell: { + width: '14.28%', + alignItems: 'center', + marginVertical: 6, + }, + inlineTipContainer: { + paddingBottom: 6, + marginBottom: 6, + }, +}); diff --git a/components/menstrual-cycle/constants.ts b/components/menstrual-cycle/constants.ts new file mode 100644 index 0000000..301a101 --- /dev/null +++ b/components/menstrual-cycle/constants.ts @@ -0,0 +1,12 @@ +import { MenstrualDayStatus } from '@/utils/menstrualCycle'; + +export const STATUS_COLORS: Record = { + period: { bg: '#f5679f', text: '#fff' }, + 'predicted-period': { bg: '#f8d9e9', text: '#9b2c6a' }, + fertile: { bg: '#d9d2ff', text: '#5a52c5' }, + 'ovulation-day': { bg: '#5b4ee4', text: '#fff' }, +}; + +export const WEEK_LABELS = ['一', '二', '三', '四', '五', '六', '日']; + +export const ITEM_HEIGHT = 380; diff --git a/components/menstrual-cycle/index.ts b/components/menstrual-cycle/index.ts new file mode 100644 index 0000000..297790a --- /dev/null +++ b/components/menstrual-cycle/index.ts @@ -0,0 +1,7 @@ +export { ITEM_HEIGHT, STATUS_COLORS, WEEK_LABELS } from './constants'; +export { DayCell } from './DayCell'; +export { InlineTip } from './InlineTip'; +export { Legend } from './Legend'; +export { MonthBlock } from './MonthBlock'; +export type { DayCellProps, InlineTipProps, LegendItem } from './types'; + diff --git a/components/menstrual-cycle/types.ts b/components/menstrual-cycle/types.ts new file mode 100644 index 0000000..a4c603a --- /dev/null +++ b/components/menstrual-cycle/types.ts @@ -0,0 +1,21 @@ +import { MenstrualDayCell, MenstrualDayInfo } from '@/utils/menstrualCycle'; +import { Dayjs } from 'dayjs'; + +export interface DayCellProps { + cell: Extract; + isSelected: boolean; + onPress: () => void; +} + +export interface InlineTipProps { + selectedDate: Dayjs; + selectedInfo: MenstrualDayInfo | undefined; + columnIndex: number; + onMarkStart: () => void; + onCancelMark: () => void; +} + +export interface LegendItem { + label: string; + key: 'period' | 'predicted-period' | 'fertile' | 'ovulation-day'; +} diff --git a/components/statistic/HealthDataCard.tsx b/components/statistic/HealthDataCard.tsx index e8604ba..38119de 100644 --- a/components/statistic/HealthDataCard.tsx +++ b/components/statistic/HealthDataCard.tsx @@ -1,6 +1,6 @@ import { Image } from 'expo-image'; import React from 'react'; -import { StyleSheet, Text, View } from 'react-native'; +import { Pressable, StyleSheet, Text, View } from 'react-native'; import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'; interface HealthDataCardProps { @@ -8,37 +8,36 @@ interface HealthDataCardProps { value: string; unit: string; style?: object; + onPress?: () => void; } const HealthDataCard: React.FC = ({ title, value, unit, - style + style, + onPress }) => { + const Container = onPress ? Pressable : View; + return ( - - - - + + + + {title} {value} {unit} - + ); }; @@ -62,6 +61,11 @@ const styles = StyleSheet.create({ flex: 1, justifyContent: 'center', }, + headerRow: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 14, + }, titleIcon: { width: 16, height: 16, @@ -94,4 +98,4 @@ const styles = StyleSheet.create({ }, }); -export default HealthDataCard; \ No newline at end of file +export default HealthDataCard; diff --git a/components/statistic/WristTemperatureCard.tsx b/components/statistic/WristTemperatureCard.tsx new file mode 100644 index 0000000..964459b --- /dev/null +++ b/components/statistic/WristTemperatureCard.tsx @@ -0,0 +1,568 @@ +import { + ensureHealthPermissions, + fetchWristTemperature, + fetchWristTemperatureHistory, + WristTemperatureHistoryPoint +} from '@/utils/health'; +import { HealthKitUtils } from '@/utils/healthKit'; +import { Ionicons } from '@expo/vector-icons'; +import { useIsFocused } from '@react-navigation/native'; +import dayjs from 'dayjs'; +import { LinearGradient } from 'expo-linear-gradient'; +import { BlurView } from 'expo-blur'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Dimensions, + Modal, + Platform, + Pressable, + StyleSheet, + Text, + View +} from 'react-native'; +import Svg, { + Circle, + Defs, + Line, + Path, + Stop, + LinearGradient as SvgLinearGradient +} from 'react-native-svg'; +import HealthDataCard from './HealthDataCard'; + +interface WristTemperatureCardProps { + style?: object; + selectedDate?: Date; +} + +const screenWidth = Dimensions.get('window').width; +const INITIAL_CHART_WIDTH = screenWidth - 32; +const CHART_HEIGHT = 240; +const CHART_HORIZONTAL_PADDING = 20; +const LABEL_ESTIMATED_WIDTH = 44; + +const WristTemperatureCard: React.FC = ({ + style, + selectedDate +}) => { + const { t } = useTranslation(); + const isFocused = useIsFocused(); + const [temperature, setTemperature] = useState(null); + const [loading, setLoading] = useState(false); + const loadingRef = useRef(false); + const [historyVisible, setHistoryVisible] = useState(false); + const [history, setHistory] = useState([]); + const [historyLoading, setHistoryLoading] = useState(false); + const historyLoadingRef = useRef(false); + const [chartWidth, setChartWidth] = useState(INITIAL_CHART_WIDTH); + + useEffect(() => { + const loadData = async () => { + const dateToUse = selectedDate || new Date(); + + if (!isFocused) return; + if (!HealthKitUtils.isAvailable()) { + setTemperature(null); + return; + } + + // 防止重复请求 + if (loadingRef.current) return; + + try { + loadingRef.current = true; + setLoading(true); + + const hasPermission = await ensureHealthPermissions(); + if (!hasPermission) { + setTemperature(null); + return; + } + + const dayStart = dayjs(dateToUse).startOf('day'); + // wrist temperature samples often start于前一晚,查询时向前扩展一天以包含跨夜数据 + const options = { + startDate: dayStart.subtract(1, 'day').toDate().toISOString(), + endDate: dayStart.endOf('day').toDate().toISOString() + }; + + const data = await fetchWristTemperature(options, dateToUse); + setTemperature(data); + } catch (error) { + console.error('WristTemperatureCard: Failed to get wrist temperature data:', error); + setTemperature(null); + } finally { + setLoading(false); + loadingRef.current = false; + } + }; + + loadData(); + }, [isFocused, selectedDate]); + + useEffect(() => { + if (!historyVisible || !isFocused) return; + + const loadHistory = async () => { + if (historyLoadingRef.current) return; + if (!HealthKitUtils.isAvailable()) { + setHistory([]); + return; + } + + try { + historyLoadingRef.current = true; + setHistoryLoading(true); + + const hasPermission = await ensureHealthPermissions(); + if (!hasPermission) { + setHistory([]); + return; + } + + const end = dayjs(selectedDate || new Date()).endOf('day'); + const start = end.subtract(30, 'day').startOf('day').subtract(1, 'day'); + const options = { + startDate: start.toDate().toISOString(), + endDate: end.toDate().toISOString(), + limit: 1200 + }; + + const historyData = await fetchWristTemperatureHistory(options); + setHistory(historyData); + } catch (error) { + console.error('WristTemperatureCard: Failed to get wrist temperature history:', error); + setHistory([]); + } finally { + historyLoadingRef.current = false; + setHistoryLoading(false); + } + }; + + loadHistory(); + }, [historyVisible, selectedDate, isFocused]); + + const baseline = useMemo(() => { + if (!history.length) return null; + const avg = history.reduce((sum, point) => sum + point.value, 0) / history.length; + return Number(avg.toFixed(2)); + }, [history]); + + const chartRange = useMemo(() => { + if (!history.length) return { min: -1, max: 1 }; + + const values = history.map((p) => p.value); + const minValue = Math.min(...values); + const maxValue = Math.max(...values); + const center = baseline ?? (minValue + maxValue) / 2; + const maxDeviation = Math.max(Math.abs(maxValue - center), Math.abs(minValue - center), 0.2); + const padding = Math.max(maxDeviation * 0.25, 0.15); + + return { + min: center - maxDeviation - padding, + max: center + maxDeviation + padding + }; + }, [baseline, history]); + + const xStep = useMemo(() => { + if (history.length <= 1) return 0; + return (chartWidth - CHART_HORIZONTAL_PADDING * 2) / (history.length - 1); + }, [history.length, chartWidth]); + + const valueToY = useCallback( + (value: number) => { + const range = chartRange.max - chartRange.min || 1; + return ((chartRange.max - value) / range) * CHART_HEIGHT; + }, + [chartRange.max, chartRange.min] + ); + + const linePath = useMemo(() => { + if (!history.length) return ''; + return history.reduce((path, point, index) => { + const x = CHART_HORIZONTAL_PADDING + xStep * index; + const y = valueToY(point.value); + if (index === 0) return `M ${x} ${y}`; + return `${path} L ${x} ${y}`; + }, ''); + }, [history, valueToY, xStep]); + + const latestValue = history.length ? history[history.length - 1].value : null; + const latestChange = baseline !== null && latestValue !== null ? latestValue - baseline : null; + + const dateLabels = useMemo(() => { + if (!history.length) return []; + const first = history[0]; + const middle = history[Math.floor(history.length / 2)]; + const last = history[history.length - 1]; + const uniqueDates = [first, middle, last].filter((item, idx, arr) => { + if (!item) return false; + return arr.findIndex((it) => it?.date === item.date) === idx; + }); + + return uniqueDates.map((point) => { + const index = history.findIndex((p) => p.date === point.date); + const positionIndex = index >= 0 ? index : 0; + + return { + date: point.date, + label: dayjs(point.date).format('MM.DD'), + x: CHART_HORIZONTAL_PADDING + positionIndex * xStep + }; + }); + }, [history, xStep]); + + const openHistory = useCallback(() => { + setHistoryVisible(true); + }, []); + + const closeHistory = useCallback(() => { + setHistoryVisible(false); + }, []); + + return ( + <> + + + + + + + + + {t('statistics.components.wristTemperature.title')} + {t('statistics.components.wristTemperature.last30Days')} + + + + + + + + + + {historyLoading ? ( + {t('statistics.components.wristTemperature.syncing')} + ) : null} + + {history.length === 0 ? ( + + {t('statistics.components.wristTemperature.noData')} + + ) : ( + { + const nextWidth = event.nativeEvent.layout.width; + if (nextWidth > 120 && Math.abs(nextWidth - chartWidth) > 2) { + setChartWidth(nextWidth); + } + }} + > + + + + + + + + + + + + + {history.map((point, index) => { + const x = CHART_HORIZONTAL_PADDING + xStep * index; + const y = valueToY(point.value); + return ( + + ); + })} + + + {dateLabels.map((item) => { + const clampedLeft = Math.min( + Math.max(item.x - LABEL_ESTIMATED_WIDTH / 2, CHART_HORIZONTAL_PADDING), + chartWidth - CHART_HORIZONTAL_PADDING - LABEL_ESTIMATED_WIDTH + ); + return ( + + {item.label} + + ); + })} + + + + {t('statistics.components.wristTemperature.baseline')} + {baseline !== null && ( + + {baseline.toFixed(1)} + °C + + )} + + + {latestChange !== null && ( + + + {latestChange >= 0 ? '+' : ''} + {latestChange.toFixed(1)}°C + + + )} + + + )} + + + + {t('statistics.components.wristTemperature.average')} + + {baseline !== null ? baseline.toFixed(1) : '--'} + °C + + + + {t('statistics.components.wristTemperature.latest')} + + {latestValue !== null ? latestValue.toFixed(1) : '--'} + °C + + {latestChange !== null && ( + + {latestChange >= 0 ? '+' : ''} + {latestChange.toFixed(1)}°C {t('statistics.components.wristTemperature.vsBaseline')} + + )} + + + + + + + ); +}; + +export default WristTemperatureCard; + +const styles = StyleSheet.create({ + modalSafeArea: { + flex: 1, + backgroundColor: '#FFFFFF', + paddingTop: Platform.OS === 'ios' ? 10 : 0 + }, + modalContainer: { + flex: 1, + paddingHorizontal: 20, + paddingTop: 22 + }, + modalHeader: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: 14 + }, + modalTitle: { + fontSize: 22, + fontWeight: '700', + color: '#1C1C28', + fontFamily: 'AliBold' + }, + modalSubtitle: { + fontSize: 13, + color: '#6B7280', + marginTop: 4, + fontFamily: 'AliRegular' + }, + closeButton: { + width: 36, + height: 36, + borderRadius: 18, + backgroundColor: 'rgba(255,255,255,0.42)', + alignItems: 'center', + justifyContent: 'center', + overflow: 'hidden', + borderWidth: 0.5, + borderColor: 'rgba(255,255,255,0.6)', + shadowColor: '#0F172A', + shadowOpacity: 0.08, + shadowRadius: 8, + shadowOffset: { width: 0, height: 4 }, + elevation: 2 + }, + closeButtonInner: { + flex: 1, + alignItems: 'center', + justifyContent: 'center' + }, + chartCard: { + backgroundColor: '#FFFFFF', + borderRadius: 24, + paddingVertical: 12, + paddingHorizontal: 12, + shadowColor: '#000', + shadowOpacity: 0.04, + shadowRadius: 12, + shadowOffset: { width: 0, height: 10 }, + elevation: 4, + marginTop: 8, + marginBottom: 14, + borderWidth: 1, + borderColor: '#F1F5F9' + }, + labelRow: { + marginTop: -6, + paddingHorizontal: 12, + height: 44, + justifyContent: 'center' + }, + axisLabel: { + position: 'absolute', + bottom: 0, + fontSize: 11, + color: '#94A3B8', + fontFamily: 'AliRegular', + textAlign: 'center' + }, + baselineLabelWrapper: { + position: 'absolute', + left: 0, + top: -4, + flexDirection: 'row', + alignItems: 'center' + }, + baselinePill: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 10, + paddingVertical: 6, + backgroundColor: '#F1F5F9', + borderRadius: 14, + borderWidth: 1, + borderColor: '#E2E8F0', + gap: 6 + }, + baselineDot: { + width: 8, + height: 8, + borderRadius: 4, + backgroundColor: '#94A3B8' + }, + axisHint: { + fontSize: 12, + color: '#6B7280', + fontFamily: 'AliRegular' + }, + axisHintValue: { + fontSize: 13, + color: '#111827', + fontWeight: '700', + fontFamily: 'AliBold' + }, + deviationBadge: { + position: 'absolute', + right: 12, + bottom: 2, + backgroundColor: '#ECFEFF', + borderRadius: 12, + paddingHorizontal: 12, + paddingVertical: 5, + borderWidth: 1, + borderColor: '#CFFAFE' + }, + deviationBadgeText: { + fontSize: 12, + color: '#0EA5E9', + fontWeight: '700', + fontFamily: 'AliBold' + }, + metricsRow: { + flexDirection: 'row', + justifyContent: 'space-between', + gap: 12, + paddingVertical: 6 + }, + metric: { + flex: 1, + backgroundColor: '#F8FAFC', + borderRadius: 18, + padding: 14, + borderWidth: 1, + borderColor: '#E2E8F0' + }, + metricLabel: { + fontSize: 12, + color: '#6B7280', + marginBottom: 6, + fontFamily: 'AliRegular' + }, + metricValue: { + fontSize: 20, + color: '#111827', + fontWeight: '700', + fontFamily: 'AliBold' + }, + metricUnit: { + fontSize: 12, + color: '#6B7280', + marginLeft: 4, + fontWeight: '500', + fontFamily: 'AliRegular' + }, + metricHint: { + marginTop: 6, + fontSize: 12, + color: '#6B21A8', + fontFamily: 'AliRegular' + }, + emptyState: { + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 32 + }, + emptyText: { + fontSize: 14, + color: '#94A3B8', + fontFamily: 'AliRegular' + }, + hintText: { + fontSize: 12, + color: '#6B7280', + marginBottom: 6, + fontFamily: 'AliRegular' + } +}); diff --git a/i18n/en/health.ts b/i18n/en/health.ts index 328059a..c355b92 100644 --- a/i18n/en/health.ts +++ b/i18n/en/health.ts @@ -142,6 +142,16 @@ export const statistics = { oxygen: { title: 'Blood Oxygen', }, + wristTemperature: { + title: 'Wrist Temperature', + last30Days: 'Last 30 days', + syncing: 'Syncing Health data...', + noData: 'No wrist temperature data yet', + baseline: 'Baseline', + average: '30-day avg', + latest: 'Latest', + vsBaseline: 'vs baseline' + }, circumference: { title: 'Circumference (cm)', setTitle: 'Set {{label}}', diff --git a/i18n/en/index.ts b/i18n/en/index.ts index 32a6558..dbfd7bd 100644 --- a/i18n/en/index.ts +++ b/i18n/en/index.ts @@ -2,6 +2,7 @@ import * as Challenge from './challenge'; import * as Common from './common'; import * as Diet from './diet'; import * as Health from './health'; +import * as Menstrual from './menstrual'; import * as Medication from './medication'; import * as Mood from './mood'; import * as Personal from './personal'; @@ -15,6 +16,7 @@ export default { ...Weight, ...Challenge, ...Mood, + ...Menstrual, ...Common, ...Common.common, // 确保通用翻译被正确导出 }; diff --git a/i18n/en/menstrual.ts b/i18n/en/menstrual.ts new file mode 100644 index 0000000..157cf19 --- /dev/null +++ b/i18n/en/menstrual.ts @@ -0,0 +1,37 @@ +export const menstrual = { + dateFormatShort: 'MMM D', + card: { + title: 'Menstrual cycle', + syncingState: 'Syncing', + syncingDesc: 'Reading menstrual data…', + emptyState: 'Not logged', + emptyDesc: 'Tap to record this period', + periodState: 'Period', + predictedPeriodState: 'Predicted period', + periodEndToday: 'Expected to end today ({{date}})', + periodEndPrefix: 'Ends in ', + periodEndSuffix: ' days ({{date}})', + fertileState: 'Fertile window', + fertileToday: 'Fertile window starts today', + fertileCountdownPrefix: 'Enters fertile window in ', + fertileCountdownSuffix: ' days', + ovulationState: 'Ovulation', + ovulationToday: 'Today is ovulation day', + ovulationCountdownPrefix: 'Ovulation in ', + ovulationCountdownSuffix: ' days', + nextPeriodPrefix: 'Next period in ', + nextPeriodSuffix: ' days', + }, + screen: { + header: 'Menstrual Cycle', + tabs: { + cycle: 'Cycle', + analysis: 'Analysis', + }, + analysis: { + title: 'Analysis', + description: + 'Based on the latest 6 cycles, we will calculate average period and cycle length. Trends and prediction accuracy will be shown here.', + }, + }, +}; diff --git a/i18n/en/personal.ts b/i18n/en/personal.ts index 0af3f55..90a67a1 100644 --- a/i18n/en/personal.ts +++ b/i18n/en/personal.ts @@ -127,6 +127,7 @@ export const statisticsCustomization = { water: 'Water Intake', basalMetabolism: 'Basal Metabolism', oxygenSaturation: 'Oxygen Saturation', + wristTemperature: 'Wrist Temperature', menstrualCycle: 'Menstrual Cycle', weight: 'Weight', circumference: 'Circumference', diff --git a/i18n/zh/health.ts b/i18n/zh/health.ts index ba746ff..1c73d5b 100644 --- a/i18n/zh/health.ts +++ b/i18n/zh/health.ts @@ -143,6 +143,16 @@ export const statistics = { oxygen: { title: '血氧饱和度', }, + wristTemperature: { + title: '手腕温度', + last30Days: '最近30天', + syncing: '正在同步健康数据...', + noData: '暂无手腕温度数据', + baseline: '基线', + average: '30天均值', + latest: '最新值', + vsBaseline: '相对基线' + }, circumference: { title: '围度 (cm)', setTitle: '设置{{label}}', diff --git a/i18n/zh/index.ts b/i18n/zh/index.ts index 32a6558..dbfd7bd 100644 --- a/i18n/zh/index.ts +++ b/i18n/zh/index.ts @@ -2,6 +2,7 @@ import * as Challenge from './challenge'; import * as Common from './common'; import * as Diet from './diet'; import * as Health from './health'; +import * as Menstrual from './menstrual'; import * as Medication from './medication'; import * as Mood from './mood'; import * as Personal from './personal'; @@ -15,6 +16,7 @@ export default { ...Weight, ...Challenge, ...Mood, + ...Menstrual, ...Common, ...Common.common, // 确保通用翻译被正确导出 }; diff --git a/i18n/zh/menstrual.ts b/i18n/zh/menstrual.ts new file mode 100644 index 0000000..1e1cc13 --- /dev/null +++ b/i18n/zh/menstrual.ts @@ -0,0 +1,36 @@ +export const menstrual = { + dateFormatShort: 'M月D日', + card: { + title: '生理周期', + syncingState: '同步中', + syncingDesc: '正在读取经期数据…', + emptyState: '待记录', + emptyDesc: '点击记录本次经期', + periodState: '经期', + predictedPeriodState: '预测经期', + periodEndToday: '预计今日结束({{date}})', + periodEndPrefix: '预计', + periodEndSuffix: '天后结束({{date}})', + fertileState: '排卵期', + fertileToday: '今天进入排卵期', + fertileCountdownPrefix: '还有', + fertileCountdownSuffix: '天进入排卵期', + ovulationState: '排卵日', + ovulationToday: '今天是排卵日', + ovulationCountdownPrefix: '距离排卵日', + ovulationCountdownSuffix: '天', + nextPeriodPrefix: '距离下次月经', + nextPeriodSuffix: '天', + }, + screen: { + header: '生理周期', + tabs: { + cycle: '生理周期', + analysis: '分析', + }, + analysis: { + title: '分析', + description: '基于最近 6 个周期的记录,计算平均经期和周期长度,后续会展示趋势和预测准确度。', + }, + }, +}; diff --git a/i18n/zh/personal.ts b/i18n/zh/personal.ts index 61dc8ac..cc77a91 100644 --- a/i18n/zh/personal.ts +++ b/i18n/zh/personal.ts @@ -127,6 +127,7 @@ export const statisticsCustomization = { water: '饮水', basalMetabolism: '基础代谢', oxygenSaturation: '血氧', + wristTemperature: '手腕温度', menstrualCycle: '经期', weight: '体重', circumference: '围度', diff --git a/ios/OutLive/HealthKitManager.m b/ios/OutLive/HealthKitManager.m index 1d878a1..fa38127 100644 --- a/ios/OutLive/HealthKitManager.m +++ b/ios/OutLive/HealthKitManager.m @@ -43,6 +43,10 @@ RCT_EXTERN_METHOD(getOxygenSaturationSamples:(NSDictionary *)options resolver:(RCTPromiseResolveBlock)resolver rejecter:(RCTPromiseRejectBlock)rejecter) +RCT_EXTERN_METHOD(getWristTemperatureSamples:(NSDictionary *)options + resolver:(RCTPromiseResolveBlock)resolver + rejecter:(RCTPromiseRejectBlock)rejecter) + RCT_EXTERN_METHOD(getHeartRateSamples:(NSDictionary *)options resolver:(RCTPromiseResolveBlock)resolver rejecter:(RCTPromiseRejectBlock)rejecter) @@ -135,4 +139,17 @@ RCT_EXTERN_METHOD(saveWeight:(NSDictionary *)options resolver:(RCTPromiseResolveBlock)resolver rejecter:(RCTPromiseRejectBlock)rejecter) +// Menstrual Cycle Methods +RCT_EXTERN_METHOD(getMenstrualFlowSamples:(NSDictionary *)options + resolver:(RCTPromiseResolveBlock)resolver + rejecter:(RCTPromiseRejectBlock)rejecter) + +RCT_EXTERN_METHOD(saveMenstrualFlow:(NSDictionary *)options + resolver:(RCTPromiseResolveBlock)resolver + rejecter:(RCTPromiseRejectBlock)rejecter) + +RCT_EXTERN_METHOD(deleteMenstrualFlow:(NSDictionary *)options + resolver:(RCTPromiseResolveBlock)resolver + rejecter:(RCTPromiseRejectBlock)rejecter) + @end diff --git a/ios/OutLive/HealthKitManager.swift b/ios/OutLive/HealthKitManager.swift index bd964cb..2bbbf35 100644 --- a/ios/OutLive/HealthKitManager.swift +++ b/ios/OutLive/HealthKitManager.swift @@ -68,6 +68,16 @@ class HealthKitManager: RCTEventEmitter { static var dateOfBirth: HKCharacteristicType { return HKObjectType.characteristicType(forIdentifier: .dateOfBirth)! } + static var menstrualFlow: HKCategoryType? { + return HKObjectType.categoryType(forIdentifier: .menstrualFlow) + } + static var appleSleepingWristTemperature: HKQuantityType? { + if #available(iOS 16.0, *) { + return HKObjectType.quantityType(forIdentifier: .appleSleepingWristTemperature) + } else { + return nil + } + } static var all: Set { var types: Set = [activitySummary, workout, dateOfBirth] @@ -83,6 +93,8 @@ class HealthKitManager: RCTEventEmitter { if let dietaryWater = dietaryWater { types.insert(dietaryWater) } if let height = height { types.insert(height) } if let bodyMass = bodyMass { types.insert(bodyMass) } + if let menstrualFlow = menstrualFlow { types.insert(menstrualFlow) } + if let appleSleepingWristTemperature = appleSleepingWristTemperature { types.insert(appleSleepingWristTemperature) } return types } @@ -111,6 +123,9 @@ class HealthKitManager: RCTEventEmitter { static var dietaryCarbohydrates: HKQuantityType? { return HKObjectType.quantityType(forIdentifier: .dietaryCarbohydrates) } + static var menstrualFlow: HKCategoryType? { + return HKObjectType.categoryType(forIdentifier: .menstrualFlow) + } static var all: Set { var types: Set = [] @@ -120,6 +135,7 @@ class HealthKitManager: RCTEventEmitter { if let dietaryProtein = dietaryProtein { types.insert(dietaryProtein) } if let dietaryFatTotal = dietaryFatTotal { types.insert(dietaryFatTotal) } if let dietaryCarbohydrates = dietaryCarbohydrates { types.insert(dietaryCarbohydrates) } + if let menstrualFlow = menstrualFlow { types.insert(menstrualFlow) } return types } } @@ -852,6 +868,86 @@ class HealthKitManager: RCTEventEmitter { healthStore.execute(query) } + @objc + func getWristTemperatureSamples( + _ options: NSDictionary, + resolver: @escaping RCTPromiseResolveBlock, + rejecter: @escaping RCTPromiseRejectBlock + ) { + guard HKHealthStore.isHealthDataAvailable() else { + rejecter("HEALTHKIT_NOT_AVAILABLE", "HealthKit is not available on this device", nil) + return + } + + guard let tempType = ReadTypes.appleSleepingWristTemperature else { + rejecter("TYPE_NOT_AVAILABLE", "Wrist temperature type is not available", nil) + return + } + + let startDate: Date + if let startString = options["startDate"] as? String, let d = parseDate(from: startString) { + startDate = d + } else { + startDate = Calendar.current.startOfDay(for: Date()) + } + + let endDate: Date + if let endString = options["endDate"] as? String, let d = parseDate(from: endString) { + endDate = d + } else { + endDate = Date() + } + + let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate) + let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierEndDate, ascending: false) + let limit = options["limit"] as? Int ?? HKObjectQueryNoLimit + + let query = HKSampleQuery(sampleType: tempType, + predicate: predicate, + limit: limit, + sortDescriptors: [sortDescriptor]) { [weak self] (query, samples, error) in + DispatchQueue.main.async { + if let error = error { + rejecter("QUERY_ERROR", "Failed to query wrist temperature: \(error.localizedDescription)", error) + return + } + + guard let tempSamples = samples as? [HKQuantitySample] else { + resolver([ + "data": [], + "count": 0, + "startDate": self?.dateToISOString(startDate) ?? "", + "endDate": self?.dateToISOString(endDate) ?? "" + ]) + return + } + + let tempData = tempSamples.map { sample in + [ + "id": sample.uuid.uuidString, + "startDate": self?.dateToISOString(sample.startDate) ?? "", + "endDate": self?.dateToISOString(sample.endDate) ?? "", + "value": sample.quantity.doubleValue(for: HKUnit.degreeCelsius()), + "source": [ + "name": sample.sourceRevision.source.name, + "bundleIdentifier": sample.sourceRevision.source.bundleIdentifier + ], + "metadata": sample.metadata ?? [:] + ] as [String : Any] + } + + let result: [String: Any] = [ + "data": tempData, + "count": tempData.count, + "startDate": self?.dateToISOString(startDate) ?? "", + "endDate": self?.dateToISOString(endDate) ?? "" + ] + resolver(result) + } + } + healthStore.execute(query) + } + @objc func getHeartRateSamples( _ options: NSDictionary, @@ -2548,6 +2644,210 @@ func saveWeight( } } +// MARK: - Menstrual Cycle Methods + +@objc +func getMenstrualFlowSamples( + _ options: NSDictionary, + resolver: @escaping RCTPromiseResolveBlock, + rejecter: @escaping RCTPromiseRejectBlock +) { + guard HKHealthStore.isHealthDataAvailable() else { + rejecter("HEALTHKIT_NOT_AVAILABLE", "HealthKit is not available on this device", nil) + return + } + + guard let menstrualType = ReadTypes.menstrualFlow else { + rejecter("TYPE_NOT_AVAILABLE", "Menstrual flow type is not available", nil) + return + } + + let startDate: Date + if let startString = options["startDate"] as? String, let d = parseDate(from: startString) { + startDate = d + } else { + startDate = Calendar.current.date(byAdding: .month, value: -3, to: Date()) ?? Date() + } + + let endDate: Date + if let endString = options["endDate"] as? String, let d = parseDate(from: endString) { + endDate = d + } else { + endDate = Date() + } + + let limit = options["limit"] as? Int ?? HKObjectQueryNoLimit + + let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate) + let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: true) + + let query = HKSampleQuery(sampleType: menstrualType, + predicate: predicate, + limit: limit, + sortDescriptors: [sortDescriptor]) { [weak self] (query, samples, error) in + DispatchQueue.main.async { + if let error = error { + rejecter("QUERY_ERROR", "Failed to query menstrual flow: \(error.localizedDescription)", error) + return + } + + guard let flowSamples = samples as? [HKCategorySample] else { + resolver([ + "data": [], + "count": 0, + "startDate": self?.dateToISOString(startDate) ?? "", + "endDate": self?.dateToISOString(endDate) ?? "" + ]) + return + } + + let flowData = flowSamples.map { sample in + [ + "id": sample.uuid.uuidString, + "startDate": self?.dateToISOString(sample.startDate) ?? "", + "endDate": self?.dateToISOString(sample.endDate) ?? "", + "value": sample.value, + "isStart": sample.metadata?[HKMetadataKeyMenstrualCycleStart] as? Bool ?? false, + "source": [ + "name": sample.sourceRevision.source.name, + "bundleIdentifier": sample.sourceRevision.source.bundleIdentifier + ], + "metadata": sample.metadata ?? [:] + ] as [String : Any] + } + + let result: [String: Any] = [ + "data": flowData, + "count": flowData.count, + "startDate": self?.dateToISOString(startDate) ?? "", + "endDate": self?.dateToISOString(endDate) ?? "" + ] + resolver(result) + } + } + healthStore.execute(query) +} + +@objc +func saveMenstrualFlow( + _ options: NSDictionary, + resolver: @escaping RCTPromiseResolveBlock, + rejecter: @escaping RCTPromiseRejectBlock +) { + guard HKHealthStore.isHealthDataAvailable() else { + rejecter("HEALTHKIT_NOT_AVAILABLE", "HealthKit is not available on this device", nil) + return + } + + let date: Date + if let dateString = options["date"] as? String, let d = parseDate(from: dateString) { + date = d + } else { + rejecter("INVALID_PARAMETERS", "Date is required", nil) + return + } + + // Default to unspecified (1) if not provided. + // HKCategoryValueMenstrualFlow: unspecified=1, light=2, medium=3, heavy=4, none=5 + let value = options["value"] as? Int ?? HKCategoryValueMenstrualFlow.unspecified.rawValue + let isStart = options["isStart"] as? Bool ?? false + + guard let menstrualType = WriteTypes.menstrualFlow else { + rejecter("TYPE_NOT_AVAILABLE", "Menstrual flow type is not available", nil) + return + } + + // Normalize date to start of day and end of day for the sample + let calendar = Calendar.current + let startOfDay = calendar.startOfDay(for: date) + // HealthKit docs suggest menstrual samples should represent the day. + // Often recorded as start of day to next day or specific time. + // Standard practice for cycle tracking is usually per-day samples. + guard let endOfDay = calendar.date(byAdding: .day, value: 1, to: startOfDay) else { + rejecter("DATE_ERROR", "Failed to calculate end of day", nil) + return + } + + var metadata: [String: Any] = [:] + // HKMetadataKeyMenstrualCycleStart is REQUIRED for HKCategoryTypeIdentifierMenstrualFlow + // It indicates whether this sample represents the start of a menstrual cycle. + metadata[HKMetadataKeyMenstrualCycleStart] = isStart + metadata[HKMetadataKeyWasUserEntered] = true + + let sample = HKCategorySample( + type: menstrualType, + value: value, + start: startOfDay, + end: endOfDay, // Using full day duration + metadata: metadata + ) + + healthStore.save(sample) { [weak self] (success, error) in + DispatchQueue.main.async { + if let error = error { + rejecter("SAVE_ERROR", "Failed to save menstrual flow: \(error.localizedDescription)", error) + return + } + + if success { + resolver(["success": true]) + } else { + rejecter("SAVE_FAILED", "Failed to save menstrual flow", nil) + } + } + } +} + +@objc +func deleteMenstrualFlow( + _ options: NSDictionary, + resolver: @escaping RCTPromiseResolveBlock, + rejecter: @escaping RCTPromiseRejectBlock +) { + guard HKHealthStore.isHealthDataAvailable() else { + rejecter("HEALTHKIT_NOT_AVAILABLE", "HealthKit is not available on this device", nil) + return + } + + let startDate: Date + if let startString = options["startDate"] as? String, let d = parseDate(from: startString) { + startDate = d + } else { + rejecter("INVALID_PARAMETERS", "Start date is required", nil) + return + } + + let endDate: Date + if let endString = options["endDate"] as? String, let d = parseDate(from: endString) { + endDate = d + } else { + rejecter("INVALID_PARAMETERS", "End date is required", nil) + return + } + + guard let menstrualType = WriteTypes.menstrualFlow else { + rejecter("TYPE_NOT_AVAILABLE", "Menstrual flow type is not available", nil) + return + } + + let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate) + + healthStore.deleteObjects(of: menstrualType, predicate: predicate) { (success, count, error) in + DispatchQueue.main.async { + if let error = error { + rejecter("DELETE_ERROR", "Failed to delete menstrual flow: \(error.localizedDescription)", error) + return + } + + if success { + resolver(["success": true, "count": count]) + } else { + rejecter("DELETE_FAILED", "Failed to delete menstrual flow", nil) + } + } + } +} + // MARK: - RCTEventEmitter Overrides override func supportedEvents() -> [String]! { diff --git a/store/tabBarConfigSlice.ts b/store/tabBarConfigSlice.ts index b7eafb7..620e2c2 100644 --- a/store/tabBarConfigSlice.ts +++ b/store/tabBarConfigSlice.ts @@ -31,7 +31,7 @@ export const DEFAULT_TAB_CONFIGS: TabConfig[] = [ { id: 'statistics', icon: 'chart.pie.fill', - titleKey: 'statistics.tabs.health', + titleKey: 'health.tabs.health', enabled: true, canBeDisabled: false, order: 1, @@ -39,7 +39,7 @@ export const DEFAULT_TAB_CONFIGS: TabConfig[] = [ { id: 'medications', icon: 'pills.fill', - titleKey: 'statistics.tabs.medications', + titleKey: 'health.tabs.medications', enabled: true, canBeDisabled: true, // 用药管理可以被关闭 order: 2, @@ -47,7 +47,7 @@ export const DEFAULT_TAB_CONFIGS: TabConfig[] = [ { id: 'fasting', icon: 'timer', - titleKey: 'statistics.tabs.fasting', + titleKey: 'health.tabs.fasting', enabled: true, canBeDisabled: true, // 断食可以被关闭 order: 3, @@ -55,7 +55,7 @@ export const DEFAULT_TAB_CONFIGS: TabConfig[] = [ { id: 'challenges', icon: 'trophy.fill', - titleKey: 'statistics.tabs.challenges', + titleKey: 'health.tabs.challenges', enabled: true, canBeDisabled: true, // 挑战可以被关闭 order: 4, @@ -63,7 +63,7 @@ export const DEFAULT_TAB_CONFIGS: TabConfig[] = [ { id: 'personal', icon: 'person.fill', - titleKey: 'statistics.tabs.personal', + titleKey: 'health.tabs.personal', enabled: true, canBeDisabled: false, order: 5, @@ -227,4 +227,4 @@ export const selectIsInitialized = (state: RootState) => state.tabBarConfig.isIn export const selectTabConfigById = (tabId: string) => (state: RootState) => state.tabBarConfig.configs.find(config => config.id === tabId); -export default tabBarConfigSlice.reducer; \ No newline at end of file +export default tabBarConfigSlice.reducer; diff --git a/utils/health.ts b/utils/health.ts index 519bd1f..c3d2897 100644 --- a/utils/health.ts +++ b/utils/health.ts @@ -2,6 +2,7 @@ import { CompleteSleepData, fetchCompleteSleepData } from '@/utils/sleepHealthKi import dayjs from 'dayjs'; import { AppState, AppStateStatus, NativeModules } from 'react-native'; import i18n from '../i18n'; +import { logger } from './logger'; import { SimpleEventEmitter } from './SimpleEventEmitter'; type HealthDataOptions = { @@ -343,6 +344,19 @@ export type TodayHealthData = { heartRate: number | null; }; +export type MenstrualFlowSample = { + id: string; + startDate: string; + endDate: string; + value: number; // 1=unspecified, 2=light, 3=medium, 4=heavy, 5=none + isStart: boolean; + source: { + name: string; + bundleIdentifier: string; + }; + metadata: Record; +}; + // 更新:使用新的权限管理系统 export async function ensureHealthPermissions(): Promise { return await healthPermissionManager.requestPermission(); @@ -370,15 +384,15 @@ function createDateRange(date: Date): HealthDataOptions { // 通用错误处理 function logError(operation: string, error: any): void { - console.error(`获取${operation}失败:`, error); + logger.error(`获取${operation}失败`, error); } function logWarning(operation: string, message: string): void { - console.warn(`${operation}数据${message}`); + logger.warn(`${operation}数据${message}`); } function logSuccess(operation: string, data: any): void { - console.log(`${operation}数据:`, data); + logger.info(`${operation}数据`, data); } // 数值验证和转换 @@ -784,6 +798,87 @@ export async function fetchOxygenSaturation(options: HealthDataOptions): Promise } } +export async function fetchWristTemperature(options: HealthDataOptions, targetDate?: Date): Promise { + try { + const result = await HealthKitManager.getWristTemperatureSamples(options); + + if (result && result.data && Array.isArray(result.data) && result.data.length > 0) { + logSuccess('手腕温度', result); + + const samples = result.data as Array<{ endDate?: string; value?: number }>; + const dayStart = targetDate ? dayjs(targetDate).startOf('day') : dayjs(options.startDate); + const dayEnd = targetDate ? dayjs(targetDate).endOf('day') : dayjs(options.endDate); + + const sampleForSelectedDay = samples.find((sample) => { + const sampleEnd = dayjs(sample.endDate); + return sampleEnd.isValid() && !sampleEnd.isBefore(dayStart) && !sampleEnd.isAfter(dayEnd); + }); + + const sampleToUse = sampleForSelectedDay ?? samples[samples.length - 1]; + + if (sampleToUse?.value !== undefined) { + return Number(Number(sampleToUse.value).toFixed(1)); + } + + logWarning('手腕温度', '未找到有效的温度值'); + return null; + } else { + logWarning('手腕温度', '为空或格式错误'); + return null; + } + } catch (error) { + logError('手腕温度', error); + return null; + } +} + +export interface WristTemperatureHistoryPoint { + date: string; + endDate: string; + value: number; +} + +export async function fetchWristTemperatureHistory(options: HealthDataOptions): Promise { + try { + const result = await HealthKitManager.getWristTemperatureSamples(options); + + if (result && result.data && Array.isArray(result.data) && result.data.length > 0) { + logSuccess('手腕温度历史', result); + + const samples = result.data as Array<{ endDate?: string; value?: number }>; + const dailyLatest: Record = {}; + + samples.forEach((sample) => { + if (!sample?.endDate || sample.value === undefined) return; + + const end = dayjs(sample.endDate); + if (!end.isValid()) return; + + const dayKey = end.format('YYYY-MM-DD'); + const numericValue = Number(sample.value); + if (Number.isNaN(numericValue)) return; + + const existing = dailyLatest[dayKey]; + if (!existing || end.isAfter(dayjs(existing.endDate))) { + dailyLatest[dayKey] = { + date: dayKey, + endDate: sample.endDate, + value: Number(numericValue.toFixed(2)), + }; + } + }); + + return Object.values(dailyLatest).sort((a, b) => dayjs(a.date).diff(dayjs(b.date))); + } else { + logWarning('手腕温度历史', '为空或格式错误'); + return []; + } + } catch (error) { + logError('手腕温度历史', error); + return []; + } +} + export async function fetchHeartRateSamplesForRange( startDate: Date, endDate: Date, @@ -1493,6 +1588,83 @@ export async function fetchHourlyStandHoursForDate(date: Date): Promise { + try { + const options = { + startDate: dayjs(startDate).startOf('day').toISOString(), + endDate: dayjs(endDate).endOf('day').toISOString(), + limit, + }; + + const result = await HealthKitManager.getMenstrualFlowSamples(options); + + if (result && Array.isArray(result.data)) { + logSuccess('经期数据', result); + return result.data; + } + + logWarning('经期数据', '为空或格式错误'); + return []; + } catch (error) { + logError('经期数据', error); + return []; + } +} + +export async function saveMenstrualFlow( + date: Date, + value: number = 1, // Default to unspecified + isStart: boolean = false +): Promise { + try { + const options = { + date: dayjs(date).toISOString(), + value, + isStart, + }; + + const result = await HealthKitManager.saveMenstrualFlow(options); + if (result && result.success) { + console.log('经期数据保存成功'); + return true; + } + console.error('经期数据保存失败'); + return false; + } catch (error) { + console.error('保存经期数据失败:', error); + return false; + } +} + +export async function deleteMenstrualFlow( + startDate: Date, + endDate: Date +): Promise { + try { + const options = { + startDate: dayjs(startDate).startOf('day').toISOString(), + endDate: dayjs(endDate).endOf('day').toISOString(), + }; + + const result = await HealthKitManager.deleteMenstrualFlow(options); + if (result && result.success) { + console.log(`经期数据删除成功,数量: ${result.count}`); + return true; + } + console.error('经期数据删除失败'); + return false; + } catch (error) { + console.error('删除经期数据失败:', error); + return false; + } +} + // 专门为活动圆环详情页获取精简的数据 export async function fetchActivityRingsForDate(date: Date): Promise { try { diff --git a/utils/menstrualCycle.ts b/utils/menstrualCycle.ts index d2515d9..9ce8b04 100644 --- a/utils/menstrualCycle.ts +++ b/utils/menstrualCycle.ts @@ -1,4 +1,5 @@ import dayjs, { Dayjs } from 'dayjs'; +import { MenstrualFlowSample } from './health'; export type MenstrualDayStatus = 'period' | 'predicted-period' | 'fertile' | 'ovulation-day'; @@ -54,18 +55,42 @@ const STATUS_PRIORITY: Record = { export const DEFAULT_CYCLE_LENGTH = 28; export const DEFAULT_PERIOD_LENGTH = 5; +const MIN_CYCLE_LENGTH = 21; +const MAX_CYCLE_LENGTH = 45; +const LOOKBACK_WINDOW = 6; +const LUTEAL_PHASE_MEAN = 13; // 12–14 天之间较为稳定 -export const createDefaultRecords = (): CycleRecord[] => { - const today = dayjs(); - const latestStart = today.subtract(4, 'day'); // 默认让今天处于经期第5天 - const previousStart = latestStart.subtract(DEFAULT_CYCLE_LENGTH, 'day'); - const olderStart = previousStart.subtract(DEFAULT_CYCLE_LENGTH, 'day'); +const clampCycleLength = (value: number, fallback: number) => { + if (!Number.isFinite(value) || value <= 0) return fallback; + return Math.max(MIN_CYCLE_LENGTH, Math.min(MAX_CYCLE_LENGTH, Math.round(value))); +}; - return [ - { startDate: olderStart.format('YYYY-MM-DD'), periodLength: DEFAULT_PERIOD_LENGTH }, - { startDate: previousStart.format('YYYY-MM-DD'), periodLength: DEFAULT_PERIOD_LENGTH }, - { startDate: latestStart.format('YYYY-MM-DD'), periodLength: DEFAULT_PERIOD_LENGTH }, - ]; +const calcMedian = (values: number[]) => { + if (!values.length) return undefined; + const sorted = [...values].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + if (sorted.length % 2 === 0) { + return (sorted[mid - 1] + sorted[mid]) / 2; + } + return sorted[mid]; +}; + +const calcTrimmedMean = (values: number[], trim = 1) => { + if (!values.length) return undefined; + const sorted = [...values].sort((a, b) => a - b); + const start = Math.min(trim, sorted.length); + const end = Math.max(sorted.length - trim, start); + const sliced = sorted.slice(start, end); + if (!sliced.length) return undefined; + return sliced.reduce((sum, cur) => sum + cur, 0) / sliced.length; +}; + +const calcStdDev = (values: number[]) => { + if (values.length < 2) return 0; + const mean = values.reduce((s, v) => s + v, 0) / values.length; + const variance = + values.reduce((s, v) => s + (v - mean) * (v - mean), 0) / (values.length - 1); + return Math.sqrt(variance); }; const calcAverageCycleLength = (records: CycleRecord[], fallback = DEFAULT_CYCLE_LENGTH) => { @@ -81,8 +106,11 @@ const calcAverageCycleLength = (records: CycleRecord[], fallback = DEFAULT_CYCLE } } if (!intervals.length) return fallback; - const avg = intervals.reduce((sum, cur) => sum + cur, 0) / intervals.length; - return Math.round(avg); + const recent = intervals.slice(-LOOKBACK_WINDOW); + const median = calcMedian(recent); + const trimmed = calcTrimmedMean(recent); + const blended = median ?? trimmed ?? calcTrimmedMean(intervals) ?? fallback; + return clampCycleLength(blended, fallback); }; const calcAveragePeriodLength = (records: CycleRecord[], fallback = DEFAULT_PERIOD_LENGTH) => { @@ -90,8 +118,11 @@ const calcAveragePeriodLength = (records: CycleRecord[], fallback = DEFAULT_PERI .map((r) => r.periodLength) .filter((l): l is number => typeof l === 'number' && l > 0); if (!lengths.length) return fallback; - const avg = lengths.reduce((sum, cur) => sum + cur, 0) / lengths.length; - return Math.round(avg); + const recent = lengths.slice(-LOOKBACK_WINDOW); + const median = calcMedian(recent); + const trimmed = calcTrimmedMean(recent); + const blended = median ?? trimmed ?? calcTrimmedMean(lengths) ?? fallback; + return Math.max(3, Math.round(blended)); // 极端短周期时仍保持生理期合理下限 }; const addDayInfo = ( @@ -110,9 +141,10 @@ const addDayInfo = ( }; const getOvulationDay = (cycleStart: Dayjs, cycleLength: number) => { - // 默认排卵日位于周期的中间偏后,兼容短/长周期 - const daysFromStart = Math.max(12, Math.round(cycleLength / 2)); - return cycleStart.add(daysFromStart, 'day'); + // 排卵日约在下次月经前 12-14 天,较稳定;对极端周期做边界约束 + const daysFromStart = cycleLength - LUTEAL_PHASE_MEAN; + const offset = Math.min(Math.max(daysFromStart, 11), 16); + return cycleStart.add(offset, 'day'); }; export const buildMenstrualTimeline = (options?: { @@ -136,6 +168,23 @@ export const buildMenstrualTimeline = (options?: { options?.defaultCycleLength ?? calcAverageCycleLength(records, DEFAULT_CYCLE_LENGTH); const avgPeriodLength = options?.defaultPeriodLength ?? calcAveragePeriodLength(records, DEFAULT_PERIOD_LENGTH); + const sortedStarts = [...records] + .sort((a, b) => dayjs(a.startDate).valueOf() - dayjs(b.startDate).valueOf()) + .map((r) => dayjs(r.startDate)); + const cycleIntervals: number[] = []; + for (let i = 1; i < sortedStarts.length; i += 1) { + const diff = sortedStarts[i].diff(sortedStarts[i - 1], 'day'); + if (diff > 0) cycleIntervals.push(diff); + } + const recentIntervals = cycleIntervals.slice(-LOOKBACK_WINDOW); + const cycleVariability = calcStdDev(recentIntervals); + const lastInterval = + recentIntervals.length > 0 ? recentIntervals[recentIntervals.length - 1] : avgCycleLength; + // 对未来预测使用稳健均值 + 最新趋势的折中,以避免单次异常牵动过大 + const predictedCycleLength = clampCycleLength( + 0.55 * avgCycleLength + 0.45 * lastInterval, + avgCycleLength + ); const cycles = records.map((record) => ({ start: dayjs(record.startDate), @@ -144,19 +193,36 @@ export const buildMenstrualTimeline = (options?: { cycleLength: record.cycleLength ?? avgCycleLength, })); + // 基于真实相邻开始日期矫正已记录周期长度,便于后续计算排卵日/易孕期 + for (let i = 0; i < cycles.length; i += 1) { + const next = cycles[i + 1]; + if (next) { + const interval = next.start.diff(cycles[i].start, 'day'); + if (interval > 0) { + cycles[i].cycleLength = clampCycleLength(interval, cycles[i].cycleLength); + } + } + } + // 只有当存在历史记录时,才进行后续预测 if (cycles.length > 0) { const lastConfirmed = cycles[cycles.length - 1]; let cursorStart = lastConfirmed.start; + let nextCycleLength = predictedCycleLength; while (cursorStart.isBefore(endMonth)) { - cursorStart = cursorStart.add(avgCycleLength, 'day'); + cursorStart = cursorStart.add(nextCycleLength, 'day'); cycles.push({ start: cursorStart, confirmed: false, periodLength: avgPeriodLength, - cycleLength: avgCycleLength, + cycleLength: nextCycleLength, }); + // 趋势逐步回归稳健均值,避免长期漂移 + nextCycleLength = clampCycleLength( + Math.round(nextCycleLength * 0.65 + avgCycleLength * 0.35), + avgCycleLength + ); } } @@ -164,7 +230,9 @@ export const buildMenstrualTimeline = (options?: { cycles.forEach((cycle) => { const ovulationDay = getOvulationDay(cycle.start, cycle.cycleLength); - const fertileStart = ovulationDay.subtract(5, 'day'); + // 经前 luteal 稳定,易孕窗口 5-7 天:排卵日前 5 天 + 排卵日当日;高波动人群额外向前扩 1 天 + const fertileWindow = Math.min(7, 6 + (cycle.confirmed ? 0 : Math.round(cycleVariability))); + const fertileStart = ovulationDay.subtract(fertileWindow, 'day'); for (let i = 0; i < cycle.periodLength; i += 1) { const date = cycle.start.add(i, 'day'); @@ -177,7 +245,7 @@ export const buildMenstrualTimeline = (options?: { }); } - for (let i = 0; i < 5; i += 1) { + for (let i = 0; i < fertileWindow; i += 1) { const date = fertileStart.add(i, 'day'); if (date.isBefore(startMonth) || date.isAfter(endMonth)) continue; addDayInfo(dayMap, date, { @@ -245,7 +313,7 @@ export const buildMenstrualTimeline = (options?: { return { months, dayMap, - cycleLength: avgCycleLength, + cycleLength: predictedCycleLength, periodLength: avgPeriodLength, todayInfo: dayMap[todayKey], }; @@ -258,3 +326,62 @@ export const getMenstrualSummaryForDate = ( const key = date.format('YYYY-MM-DD'); return dayMap[key]; }; + +export const convertHealthKitSamplesToCycleRecords = ( + samples: MenstrualFlowSample[] +): CycleRecord[] => { + if (!samples.length) return []; + + // 1. Sort samples by date + const sortedSamples = [...samples].sort( + (a, b) => dayjs(a.startDate).valueOf() - dayjs(b.startDate).valueOf() + ); + + const records: CycleRecord[] = []; + let currentStart: Dayjs | null = null; + let currentEnd: Dayjs | null = null; + + // 2. Iterate and merge consecutive days + for (const sample of sortedSamples) { + const sampleDate = dayjs(sample.startDate); + + // If we have no current period being tracked, start a new one + if (!currentStart) { + currentStart = sampleDate; + currentEnd = sampleDate; + continue; + } + + // Check if this sample is contiguous with the current period + // Allow 1 day gap? Standard logic usually assumes contiguous days for a single period. + // However, spotting might cause gaps. For now, we'll merge if gap <= 1 day. + const diff = sampleDate.diff(currentEnd, 'day'); + + if (diff <= 1) { + // Extend current period + currentEnd = sampleDate; + } else { + // Gap is too large, finalize current period and start new one + if (currentStart && currentEnd) { + records.push({ + startDate: currentStart.format('YYYY-MM-DD'), + periodLength: currentEnd.diff(currentStart, 'day') + 1, + source: 'healthkit', + }); + } + currentStart = sampleDate; + currentEnd = sampleDate; + } + } + + // Push the last record + if (currentStart && currentEnd) { + records.push({ + startDate: currentStart.format('YYYY-MM-DD'), + periodLength: currentEnd.diff(currentStart, 'day') + 1, + source: 'healthkit', + }); + } + + return records; +}; diff --git a/utils/userPreferences.ts b/utils/userPreferences.ts index 6fa3326..9ab5eb0 100644 --- a/utils/userPreferences.ts +++ b/utils/userPreferences.ts @@ -28,6 +28,7 @@ const PREFERENCES_KEYS = { SHOW_MENSTRUAL_CYCLE_CARD: 'user_preference_show_menstrual_cycle_card', SHOW_WEIGHT_CARD: 'user_preference_show_weight_card', SHOW_CIRCUMFERENCE_CARD: 'user_preference_show_circumference_card', + SHOW_WRIST_TEMPERATURE_CARD: 'user_preference_show_wrist_temperature_card', // 首页身体指标卡片排序设置 STATISTICS_CARD_ORDER: 'user_preference_statistics_card_order', @@ -46,6 +47,7 @@ export interface StatisticsCardsVisibility { showMenstrualCycle: boolean; showWeight: boolean; showCircumference: boolean; + showWristTemperature: boolean; } // 默认卡片顺序 @@ -58,6 +60,7 @@ export const DEFAULT_CARD_ORDER: string[] = [ 'water', 'metabolism', 'oxygen', + 'temperature', 'menstrual', 'weight', 'circumference', @@ -109,6 +112,7 @@ const DEFAULT_PREFERENCES: UserPreferences = { showMenstrualCycle: true, showWeight: true, showCircumference: true, + showWristTemperature: true, // 默认卡片顺序 cardOrder: DEFAULT_CARD_ORDER, @@ -145,6 +149,7 @@ export const getUserPreferences = async (): Promise => { const showMenstrualCycle = await AsyncStorage.getItem(PREFERENCES_KEYS.SHOW_MENSTRUAL_CYCLE_CARD); const showWeight = await AsyncStorage.getItem(PREFERENCES_KEYS.SHOW_WEIGHT_CARD); const showCircumference = await AsyncStorage.getItem(PREFERENCES_KEYS.SHOW_CIRCUMFERENCE_CARD); + const showWristTemperature = await AsyncStorage.getItem(PREFERENCES_KEYS.SHOW_WRIST_TEMPERATURE_CARD); const cardOrderStr = await AsyncStorage.getItem(PREFERENCES_KEYS.STATISTICS_CARD_ORDER); const cardOrder = cardOrderStr ? JSON.parse(cardOrderStr) : DEFAULT_PREFERENCES.cardOrder; @@ -174,6 +179,7 @@ export const getUserPreferences = async (): Promise => { showMenstrualCycle: showMenstrualCycle !== null ? showMenstrualCycle === 'true' : DEFAULT_PREFERENCES.showMenstrualCycle, showWeight: showWeight !== null ? showWeight === 'true' : DEFAULT_PREFERENCES.showWeight, showCircumference: showCircumference !== null ? showCircumference === 'true' : DEFAULT_PREFERENCES.showCircumference, + showWristTemperature: showWristTemperature !== null ? showWristTemperature === 'true' : DEFAULT_PREFERENCES.showWristTemperature, cardOrder, }; } catch (error) { @@ -611,6 +617,7 @@ export const getStatisticsCardsVisibility = async (): Promise