feat(health): 新增手腕温度监测和经期双向同步功能
新增手腕温度健康数据追踪,支持Apple Watch睡眠手腕温度数据展示和30天历史趋势分析 实现经期数据与HealthKit的完整双向同步,支持读取、写入和删除经期记录 优化经期预测算法,基于历史数据计算更准确的周期和排卵日预测 重构经期UI组件为模块化结构,提升代码可维护性 添加完整的中英文国际化支持,覆盖所有新增功能界面
This commit is contained in:
@@ -21,10 +21,11 @@
|
|||||||
|
|
||||||
### 健康数据追踪 ✅
|
### 健康数据追踪 ✅
|
||||||
|
|
||||||
- HealthKit 集成完成,支持步数、心率、HRV、睡眠等数据
|
- HealthKit 集成完成,支持步数、心率、HRV、睡眠、手腕温度等数据
|
||||||
- 活动圆环显示(活动卡路里、锻炼分钟、站立小时)
|
- 活动圆环显示(活动卡路里、锻炼分钟、站立小时)
|
||||||
- 实时健康数据监控和历史数据查看
|
- 实时健康数据监控和历史数据查看
|
||||||
- 健康权限管理系统
|
- 健康权限管理系统
|
||||||
|
- 经期跟踪与 HealthKit 同步
|
||||||
|
|
||||||
### 营养管理 ✅
|
### 营养管理 ✅
|
||||||
|
|
||||||
@@ -95,11 +96,12 @@
|
|||||||
|
|
||||||
### 近期更新
|
### 近期更新
|
||||||
|
|
||||||
1. **多语言支持**: 完善挑战页面的多语言翻译支持,建立翻译最佳实践指南
|
1. **健康数据**: 新增手腕温度监测功能(支持 Apple Watch 睡眠手腕温度)
|
||||||
2. **性能优化**: 优化健康数据加载和图表渲染性能
|
2. **健康数据**: 实现经期数据与 HealthKit 的双向同步(读写与删除)
|
||||||
3. **用户体验**: 改进 Liquid Glass 设计效果和交互动画
|
3. **多语言支持**: 完善挑战页面的多语言翻译支持,建立翻译最佳实践指南
|
||||||
4. **数据同步**: 增强离线功能和数据同步稳定性
|
4. **用户体验**: 改进 Liquid Glass 设计效果和交互动画
|
||||||
5. **AI 功能**: 扩展 AI 教练对话能力和分析精度
|
5. **数据同步**: 增强离线功能和数据同步稳定性
|
||||||
|
6. **AI 功能**: 扩展 AI 教练对话能力和分析精度
|
||||||
|
|
||||||
### 待解决问题
|
### 待解决问题
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
# 产品概述
|
# 产品概述
|
||||||
|
|
||||||
## 产品定位
|
## 产品定位
|
||||||
|
|
||||||
Out Live(超越生命)是一款专注于健康、减肥、瘦身和生活习惯养成的 iOS 应用。该应用通过整合健康数据追踪、AI 教练指导、目标管理和社区挑战等功能,为用户提供全方位的健康生活管理解决方案。
|
Out Live(超越生命)是一款专注于健康、减肥、瘦身和生活习惯养成的 iOS 应用。该应用通过整合健康数据追踪、AI 教练指导、目标管理和社区挑战等功能,为用户提供全方位的健康生活管理解决方案。
|
||||||
|
|
||||||
## 目标用户
|
## 目标用户
|
||||||
|
|
||||||
- 关注健康和体重管理的用户
|
- 关注健康和体重管理的用户
|
||||||
- 希望养成良好生活习惯的用户
|
- 希望养成良好生活习惯的用户
|
||||||
- 对普拉提和健身感兴趣的用户
|
- 对普拉提和健身感兴趣的用户
|
||||||
@@ -11,6 +13,7 @@ Out Live(超越生命)是一款专注于健康、减肥、瘦身和生活习
|
|||||||
- 希望通过 AI 获得个性化健康指导的用户
|
- 希望通过 AI 获得个性化健康指导的用户
|
||||||
|
|
||||||
## 核心价值主张
|
## 核心价值主张
|
||||||
|
|
||||||
1. **全方位健康数据管理**:整合 HealthKit 数据,提供步数、心率、睡眠、饮水量等多维度健康指标追踪
|
1. **全方位健康数据管理**:整合 HealthKit 数据,提供步数、心率、睡眠、饮水量等多维度健康指标追踪
|
||||||
2. **AI 智能教练**:基于用户健康数据提供个性化的健康建议和指导
|
2. **AI 智能教练**:基于用户健康数据提供个性化的健康建议和指导
|
||||||
3. **目标管理系统**:帮助用户设定、追踪和完成健康目标
|
3. **目标管理系统**:帮助用户设定、追踪和完成健康目标
|
||||||
@@ -20,50 +23,59 @@ Out Live(超越生命)是一款专注于健康、减肥、瘦身和生活习
|
|||||||
## 主要功能模块
|
## 主要功能模块
|
||||||
|
|
||||||
### 健康数据追踪
|
### 健康数据追踪
|
||||||
|
|
||||||
- **活动圆环**:展示活动卡路里、锻炼分钟和站立小时
|
- **活动圆环**:展示活动卡路里、锻炼分钟和站立小时
|
||||||
- **步数统计**:按小时显示步数数据和趋势
|
- **步数统计**:按小时显示步数数据和趋势
|
||||||
- **心率监测**:实时心率和心率变异性(HRV)分析
|
- **心率监测**:实时心率和心率变异性(HRV)分析
|
||||||
- **睡眠分析**:睡眠质量和时长追踪
|
- **睡眠分析**:睡眠质量和时长追踪
|
||||||
|
- **手腕温度**:追踪睡眠期间的手腕温度变化
|
||||||
- **体重管理**:体重记录和 BMI 计算
|
- **体重管理**:体重记录和 BMI 计算
|
||||||
- **饮水量追踪**:每日饮水目标设定和记录
|
- **饮水量追踪**:每日饮水目标设定和记录
|
||||||
|
|
||||||
### 营养管理
|
### 营养管理
|
||||||
|
|
||||||
- **饮食记录**:支持文字、语音和拍照识别食物
|
- **饮食记录**:支持文字、语音和拍照识别食物
|
||||||
- **营养分析**:卡路里、蛋白质、碳水化合物等营养成分分析
|
- **营养分析**:卡路里、蛋白质、碳水化合物等营养成分分析
|
||||||
- **食物库**:丰富的食物数据库和自定义食物功能
|
- **食物库**:丰富的食物数据库和自定义食物功能
|
||||||
- **营养标签识别**:通过拍照识别食品营养标签
|
- **营养标签识别**:通过拍照识别食品营养标签
|
||||||
|
|
||||||
### 目标与习惯管理
|
### 目标与习惯管理
|
||||||
|
|
||||||
- **目标设定**:支持日、周、月重复模式的目标设定
|
- **目标设定**:支持日、周、月重复模式的目标设定
|
||||||
- **任务管理**:将目标分解为可执行的任务
|
- **任务管理**:将目标分解为可执行的任务
|
||||||
- **进度追踪**:可视化目标完成进度
|
- **进度追踪**:可视化目标完成进度
|
||||||
- **提醒功能**:智能提醒帮助用户坚持目标
|
- **提醒功能**:智能提醒帮助用户坚持目标
|
||||||
|
|
||||||
### 轻断食功能
|
### 轻断食功能
|
||||||
- **断食计划**:多种预设断食方案(16:8、18:6等)
|
|
||||||
|
- **断食计划**:多种预设断食方案(16:8、18:6 等)
|
||||||
- **断食追踪**:实时显示断食进度和状态
|
- **断食追踪**:实时显示断食进度和状态
|
||||||
- **智能提醒**:断食开始和结束提醒
|
- **智能提醒**:断食开始和结束提醒
|
||||||
- **断食历史**:记录和分析断食历史数据
|
- **断食历史**:记录和分析断食历史数据
|
||||||
|
|
||||||
### AI 教练系统
|
### AI 教练系统
|
||||||
|
|
||||||
- **智能对话**:基于用户健康数据提供个性化建议
|
- **智能对话**:基于用户健康数据提供个性化建议
|
||||||
- **体态评估**:通过 AI 分析用户体态照片
|
- **体态评估**:通过 AI 分析用户体态照片
|
||||||
- **健康指导**:提供运动、营养和生活方式建议
|
- **健康指导**:提供运动、营养和生活方式建议
|
||||||
- **情绪分析**:基于 HRV 数据分析压力水平
|
- **情绪分析**:基于 HRV 数据分析压力水平
|
||||||
|
|
||||||
### 社区与挑战
|
### 社区与挑战
|
||||||
|
|
||||||
- **挑战赛**:参与各种健康主题挑战
|
- **挑战赛**:参与各种健康主题挑战
|
||||||
- **排行榜**:与好友或其他用户比较进度
|
- **排行榜**:与好友或其他用户比较进度
|
||||||
- **成就系统**:完成目标获得成就奖励
|
- **成就系统**:完成目标获得成就奖励
|
||||||
- **社交分享**:分享健康成果到社交平台
|
- **社交分享**:分享健康成果到社交平台
|
||||||
|
|
||||||
### 训练计划
|
### 训练计划
|
||||||
|
|
||||||
- **个性化计划**:基于用户目标生成训练计划
|
- **个性化计划**:基于用户目标生成训练计划
|
||||||
- **运动库**:丰富的运动动作库和指导
|
- **运动库**:丰富的运动动作库和指导
|
||||||
- **进度追踪**:记录训练完成情况和效果
|
- **进度追踪**:记录训练完成情况和效果
|
||||||
- **智能推荐**:根据用户表现调整训练计划
|
- **智能推荐**:根据用户表现调整训练计划
|
||||||
|
|
||||||
## 用户体验特色
|
## 用户体验特色
|
||||||
|
|
||||||
1. **Liquid Glass 设计风格**:采用现代化的毛玻璃效果设计
|
1. **Liquid Glass 设计风格**:采用现代化的毛玻璃效果设计
|
||||||
2. **数据可视化**:丰富的图表和动画展示健康数据
|
2. **数据可视化**:丰富的图表和动画展示健康数据
|
||||||
3. **快捷操作**:支持快捷动作和小组件快速记录
|
3. **快捷操作**:支持快捷动作和小组件快速记录
|
||||||
@@ -71,6 +83,7 @@ Out Live(超越生命)是一款专注于健康、减肥、瘦身和生活习
|
|||||||
5. **隐私保护**:严格保护用户健康数据隐私
|
5. **隐私保护**:严格保护用户健康数据隐私
|
||||||
|
|
||||||
## 技术亮点
|
## 技术亮点
|
||||||
|
|
||||||
- **HealthKit 深度集成**:充分利用 iOS 健康生态系统
|
- **HealthKit 深度集成**:充分利用 iOS 健康生态系统
|
||||||
- **实时数据同步**:支持多设备数据实时同步
|
- **实时数据同步**:支持多设备数据实时同步
|
||||||
- **智能通知系统**:基于用户行为的智能提醒
|
- **智能通知系统**:基于用户行为的智能提醒
|
||||||
@@ -78,11 +91,13 @@ Out Live(超越生命)是一款专注于健康、减肥、瘦身和生活习
|
|||||||
- **无障碍支持**:完整的无障碍功能支持
|
- **无障碍支持**:完整的无障碍功能支持
|
||||||
|
|
||||||
## 商业模式
|
## 商业模式
|
||||||
|
|
||||||
- **免费增值模式**:基础功能免费,高级功能付费
|
- **免费增值模式**:基础功能免费,高级功能付费
|
||||||
- **VIP 会员**:提供更多个性化功能和专业指导
|
- **VIP 会员**:提供更多个性化功能和专业指导
|
||||||
- **企业健康**:面向企业提供的员工健康管理解决方案
|
- **企业健康**:面向企业提供的员工健康管理解决方案
|
||||||
|
|
||||||
## 竞争优势
|
## 竞争优势
|
||||||
|
|
||||||
1. **全平台整合**:深度整合 iOS 健康生态系统
|
1. **全平台整合**:深度整合 iOS 健康生态系统
|
||||||
2. **AI 技术应用**:先进的 AI 分析和个性化推荐
|
2. **AI 技术应用**:先进的 AI 分析和个性化推荐
|
||||||
3. **用户体验**:优秀的界面设计和交互体验
|
3. **用户体验**:优秀的界面设计和交互体验
|
||||||
|
|||||||
@@ -3,17 +3,20 @@
|
|||||||
## 核心技术
|
## 核心技术
|
||||||
|
|
||||||
### 前端框架
|
### 前端框架
|
||||||
|
|
||||||
- **React Native**: 0.81.4 - 跨平台移动应用开发框架
|
- **React Native**: 0.81.4 - 跨平台移动应用开发框架
|
||||||
- **Expo SDK**: 54.0.13 - React Native 开发平台和工具链
|
- **Expo SDK**: 54.0.13 - React Native 开发平台和工具链
|
||||||
- **Expo Router**: 6.0.12 - 基于文件系统的路由库
|
- **Expo Router**: 6.0.12 - 基于文件系统的路由库
|
||||||
- **TypeScript**: 5.9.2 - 类型安全的 JavaScript 超集
|
- **TypeScript**: 5.9.2 - 类型安全的 JavaScript 超集
|
||||||
|
|
||||||
### 状态管理
|
### 状态管理
|
||||||
|
|
||||||
- **Redux Toolkit**: 2.9.0 - 状态管理解决方案
|
- **Redux Toolkit**: 2.9.0 - 状态管理解决方案
|
||||||
- **React Redux**: 9.2.0 - React Redux 绑定
|
- **React Redux**: 9.2.0 - React Redux 绑定
|
||||||
- **Redux Listener Middleware**: 自定义中间件用于自动同步
|
- **Redux Listener Middleware**: 自定义中间件用于自动同步
|
||||||
|
|
||||||
### UI 框架和样式
|
### UI 框架和样式
|
||||||
|
|
||||||
- **React Native Elements**: UI 组件库
|
- **React Native Elements**: UI 组件库
|
||||||
- **Expo UI**: 0.2.0-beta.7 - Expo UI 组件
|
- **Expo UI**: 0.2.0-beta.7 - Expo UI 组件
|
||||||
- **Expo Glass Effect**: 0.1.4 - Liquid Glass 毛玻璃效果, 优先使用
|
- **Expo Glass Effect**: 0.1.4 - Liquid Glass 毛玻璃效果, 优先使用
|
||||||
@@ -22,17 +25,20 @@
|
|||||||
- **React Native SVG**: 15.12.1 - SVG 图形支持
|
- **React Native SVG**: 15.12.1 - SVG 图形支持
|
||||||
|
|
||||||
### 导航
|
### 导航
|
||||||
|
|
||||||
- **Expo Router**: 6.0.12 - 文件系统路由
|
- **Expo Router**: 6.0.12 - 文件系统路由
|
||||||
- **React Navigation**: 7.x - 导航库
|
- **React Navigation**: 7.x - 导航库
|
||||||
|
|
||||||
## 数据和存储
|
## 数据和存储
|
||||||
|
|
||||||
### 本地存储
|
### 本地存储
|
||||||
|
|
||||||
- **Expo SQLite**: 16.0.8 - SQLite 数据库
|
- **Expo SQLite**: 16.0.8 - SQLite 数据库
|
||||||
- **Expo SQLite KV Store**: 键值存储
|
- **Expo SQLite KV Store**: 键值存储
|
||||||
- **Async Storage**: 2.2.0 - 异步存储(兼容层)
|
- **Async Storage**: 2.2.0 - 异步存储(兼容层)
|
||||||
|
|
||||||
### 网络和 API
|
### 网络和 API
|
||||||
|
|
||||||
- **Fetch API**: 原生网络请求
|
- **Fetch API**: 原生网络请求
|
||||||
- **XMLHttpRequest**: 流式请求支持
|
- **XMLHttpRequest**: 流式请求支持
|
||||||
- **Axios**: HTTP 客户端(可选)
|
- **Axios**: HTTP 客户端(可选)
|
||||||
@@ -40,16 +46,19 @@
|
|||||||
## 原生功能集成
|
## 原生功能集成
|
||||||
|
|
||||||
### HealthKit 集成
|
### HealthKit 集成
|
||||||
|
|
||||||
- **自定义 HealthKit Manager**: iOS 原生模块
|
- **自定义 HealthKit Manager**: iOS 原生模块
|
||||||
- **健康数据类型**: 步数、心率、HRV、睡眠、活动圆环等
|
- **健康数据类型**: 步数、心率、HRV、睡眠、活动圆环、手腕温度(appleSleepingWristTemperature)等
|
||||||
- **权限管理**: 动态权限请求和状态监控
|
- **权限管理**: 动态权限请求和状态监控
|
||||||
|
|
||||||
### 通知系统
|
### 通知系统
|
||||||
|
|
||||||
- **Expo Notifications**: 0.32.12 - 本地和推送通知
|
- **Expo Notifications**: 0.32.12 - 本地和推送通知
|
||||||
- **后台任务**: Expo Task Manager
|
- **后台任务**: Expo Task Manager
|
||||||
- **推送通知**: 远程推送支持
|
- **推送通知**: 远程推送支持
|
||||||
|
|
||||||
### 设备功能
|
### 设备功能
|
||||||
|
|
||||||
- **Expo Camera**: 17.0.8 - 相机功能
|
- **Expo Camera**: 17.0.8 - 相机功能
|
||||||
- **Expo Image Picker**: 17.0.8 - 图片选择
|
- **Expo Image Picker**: 17.0.8 - 图片选择
|
||||||
- **Expo Haptics**: 15.0.7 - 触觉反馈
|
- **Expo Haptics**: 15.0.7 - 触觉反馈
|
||||||
@@ -59,17 +68,20 @@
|
|||||||
## 开发工具和构建
|
## 开发工具和构建
|
||||||
|
|
||||||
### 构建系统
|
### 构建系统
|
||||||
|
|
||||||
- **Expo Prebuild**: 原生构建生成
|
- **Expo Prebuild**: 原生构建生成
|
||||||
- **Metro**: JavaScript 打包工具
|
- **Metro**: JavaScript 打包工具
|
||||||
- **Babel**: JavaScript 编译器
|
- **Babel**: JavaScript 编译器
|
||||||
|
|
||||||
### 代码质量
|
### 代码质量
|
||||||
|
|
||||||
- **ESLint**: 9.35.0 - 代码检查
|
- **ESLint**: 9.35.0 - 代码检查
|
||||||
- **ESLint Config Expo**: 10.0.0 - Expo ESLint 配置
|
- **ESLint Config Expo**: 10.0.0 - Expo ESLint 配置
|
||||||
- **Prettier**: 代码格式化
|
- **Prettier**: 代码格式化
|
||||||
- **TypeScript**: 类型检查
|
- **TypeScript**: 类型检查
|
||||||
|
|
||||||
### 开发环境
|
### 开发环境
|
||||||
|
|
||||||
- **VS Code**: 主要开发 IDE
|
- **VS Code**: 主要开发 IDE
|
||||||
- **Expo Go**: 开发调试
|
- **Expo Go**: 开发调试
|
||||||
- **iOS Simulator**: iOS 模拟器
|
- **iOS Simulator**: iOS 模拟器
|
||||||
@@ -78,21 +90,25 @@
|
|||||||
## 第三方服务
|
## 第三方服务
|
||||||
|
|
||||||
### 云存储
|
### 云存储
|
||||||
|
|
||||||
- **腾讯云 COS**: 图片和文件存储
|
- **腾讯云 COS**: 图片和文件存储
|
||||||
- **上传服务**: 自定义上传实现
|
- **上传服务**: 自定义上传实现
|
||||||
|
|
||||||
### AI 服务
|
### AI 服务
|
||||||
|
|
||||||
- **AI 教练**: 自定义 AI 对话服务
|
- **AI 教练**: 自定义 AI 对话服务
|
||||||
- **图像识别**: 食物识别
|
- **图像识别**: 食物识别
|
||||||
- **语音识别**: 语音转文字
|
- **语音识别**: 语音转文字
|
||||||
|
|
||||||
### 分析和监控
|
### 分析和监控
|
||||||
|
|
||||||
- **Sentry**: 7.2.0 - 错误监控和性能分析
|
- **Sentry**: 7.2.0 - 错误监控和性能分析
|
||||||
- **崩溃报告**: 自动崩溃收集
|
- **崩溃报告**: 自动崩溃收集
|
||||||
|
|
||||||
## UI 组件库
|
## UI 组件库
|
||||||
|
|
||||||
### 基础组件
|
### 基础组件
|
||||||
|
|
||||||
- **ThemedView**: 主题化视图组件
|
- **ThemedView**: 主题化视图组件
|
||||||
- **ThemedText**: 主题化文本组件
|
- **ThemedText**: 主题化文本组件
|
||||||
- **IconSymbol**: 图标组件
|
- **IconSymbol**: 图标组件
|
||||||
@@ -100,6 +116,7 @@
|
|||||||
- **AnimatedNumber**: 数字动画组件
|
- **AnimatedNumber**: 数字动画组件
|
||||||
|
|
||||||
### 业务组件
|
### 业务组件
|
||||||
|
|
||||||
- **FitnessRingsCard**: 健身圆环卡片
|
- **FitnessRingsCard**: 健身圆环卡片
|
||||||
- **StepsCard**: 步数卡片
|
- **StepsCard**: 步数卡片
|
||||||
- **NutritionRadarCard**: 营养雷达图
|
- **NutritionRadarCard**: 营养雷达图
|
||||||
@@ -109,6 +126,7 @@
|
|||||||
- **TaskCard**: 任务卡片
|
- **TaskCard**: 任务卡片
|
||||||
|
|
||||||
### 图表组件
|
### 图表组件
|
||||||
|
|
||||||
- **RadarChart**: 雷达图
|
- **RadarChart**: 雷达图
|
||||||
- **CircularRing**: 圆形进度环
|
- **CircularRing**: 圆形进度环
|
||||||
- **CalorieRingChart**: 卡路里环形图
|
- **CalorieRingChart**: 卡路里环形图
|
||||||
@@ -117,17 +135,20 @@
|
|||||||
## 开发依赖
|
## 开发依赖
|
||||||
|
|
||||||
### 类型定义
|
### 类型定义
|
||||||
|
|
||||||
- **React Types**: 19.1.13
|
- **React Types**: 19.1.13
|
||||||
- **React Native Types**: 内置
|
- **React Native Types**: 内置
|
||||||
- **Expo Types**: 内置
|
- **Expo Types**: 内置
|
||||||
|
|
||||||
### 工具库
|
### 工具库
|
||||||
|
|
||||||
- **Day.js**: 1.11.18 - 日期处理
|
- **Day.js**: 1.11.18 - 日期处理
|
||||||
- **Lodash**: 4.17.21 - 工具函数库
|
- **Lodash**: 4.17.21 - 工具函数库
|
||||||
- **React Native Chart Kit**: 6.12.0 - 图表库
|
- **React Native Chart Kit**: 6.12.0 - 图表库
|
||||||
- **Lottie React Native**: 7.3.4 - 动画库
|
- **Lottie React Native**: 7.3.4 - 动画库
|
||||||
|
|
||||||
### 音频和媒体
|
### 音频和媒体
|
||||||
|
|
||||||
- **React Native Voice**: 3.2.4 - 语音识别
|
- **React Native Voice**: 3.2.4 - 语音识别
|
||||||
- **Expo Media Library**: 18.2.0 - 媒体库
|
- **Expo Media Library**: 18.2.0 - 媒体库
|
||||||
- **Expo Audio**: 音频处理
|
- **Expo Audio**: 音频处理
|
||||||
@@ -135,12 +156,14 @@
|
|||||||
## 平台特定配置
|
## 平台特定配置
|
||||||
|
|
||||||
### iOS 配置
|
### iOS 配置
|
||||||
|
|
||||||
- **最低版本**: iOS 16.0
|
- **最低版本**: iOS 16.0
|
||||||
- **Bundle ID**: com.anonymous.digitalpilates
|
- **Bundle ID**: com.anonymous.digitalpilates
|
||||||
- **Team ID**: 756WVXJ6MT
|
- **Team ID**: 756WVXJ6MT
|
||||||
- **权限配置**: 相机、相册、麦克风、健康数据、通知等
|
- **权限配置**: 相机、相册、麦克风、健康数据、通知等
|
||||||
|
|
||||||
### 构建配置
|
### 构建配置
|
||||||
|
|
||||||
- **New Arch**: 启用
|
- **New Arch**: 启用
|
||||||
- **JS Engine**: JSC
|
- **JS Engine**: JSC
|
||||||
- **Metro 配置**: 自定义配置
|
- **Metro 配置**: 自定义配置
|
||||||
@@ -149,18 +172,21 @@
|
|||||||
## 性能优化
|
## 性能优化
|
||||||
|
|
||||||
### 渲染优化
|
### 渲染优化
|
||||||
|
|
||||||
- **React.memo**: 组件记忆化
|
- **React.memo**: 组件记忆化
|
||||||
- **useMemo/useCallback**: 钩子优化
|
- **useMemo/useCallback**: 钩子优化
|
||||||
- **FlatList**: 大列表优化
|
- **FlatList**: 大列表优化
|
||||||
- **InteractionManager**: 延迟渲染
|
- **InteractionManager**: 延迟渲染
|
||||||
|
|
||||||
### 数据优化
|
### 数据优化
|
||||||
|
|
||||||
- **Redux Toolkit**: 自动优化
|
- **Redux Toolkit**: 自动优化
|
||||||
- **数据分页**: 分页加载
|
- **数据分页**: 分页加载
|
||||||
- **缓存策略**: 智能缓存
|
- **缓存策略**: 智能缓存
|
||||||
- **后台同步**: 异步同步
|
- **后台同步**: 异步同步
|
||||||
|
|
||||||
### 资源优化
|
### 资源优化
|
||||||
|
|
||||||
- **图片优化**: WebP 格式
|
- **图片优化**: WebP 格式
|
||||||
- **Bundle 分割**: 代码分割
|
- **Bundle 分割**: 代码分割
|
||||||
- **内存管理**: 资源释放
|
- **内存管理**: 资源释放
|
||||||
@@ -169,12 +195,14 @@
|
|||||||
## 安全措施
|
## 安全措施
|
||||||
|
|
||||||
### 数据安全
|
### 数据安全
|
||||||
|
|
||||||
- **HTTPS**: 加密通信
|
- **HTTPS**: 加密通信
|
||||||
- **Token 管理**: JWT 存储
|
- **Token 管理**: JWT 存储
|
||||||
- **数据加密**: 本地加密
|
- **数据加密**: 本地加密
|
||||||
- **权限控制**: 细粒度权限
|
- **权限控制**: 细粒度权限
|
||||||
|
|
||||||
### 隐私保护
|
### 隐私保护
|
||||||
|
|
||||||
- **数据脱敏**: 敏感数据处理
|
- **数据脱敏**: 敏感数据处理
|
||||||
- **权限最小化**: 最小权限原则
|
- **权限最小化**: 最小权限原则
|
||||||
- **用户控制**: 数据控制权
|
- **用户控制**: 数据控制权
|
||||||
@@ -183,11 +211,13 @@
|
|||||||
## 测试框架
|
## 测试框架
|
||||||
|
|
||||||
### 单元测试
|
### 单元测试
|
||||||
|
|
||||||
- **Jest**: 测试框架
|
- **Jest**: 测试框架
|
||||||
- **React Native Testing Library**: 组件测试
|
- **React Native Testing Library**: 组件测试
|
||||||
- **Mock**: 模拟数据和服务
|
- **Mock**: 模拟数据和服务
|
||||||
|
|
||||||
### 集成测试
|
### 集成测试
|
||||||
|
|
||||||
- **Detox**: E2E 测试(可选)
|
- **Detox**: E2E 测试(可选)
|
||||||
- **手动测试**: 功能验证
|
- **手动测试**: 功能验证
|
||||||
- **性能测试**: 性能基准
|
- **性能测试**: 性能基准
|
||||||
@@ -195,12 +225,14 @@
|
|||||||
## 部署和发布
|
## 部署和发布
|
||||||
|
|
||||||
### 构建流程
|
### 构建流程
|
||||||
|
|
||||||
- **Expo EAS Build**: 云端构建
|
- **Expo EAS Build**: 云端构建
|
||||||
- **App Store Connect**: 应用商店发布
|
- **App Store Connect**: 应用商店发布
|
||||||
- **OTA 更新**: 热更新
|
- **OTA 更新**: 热更新
|
||||||
- **版本管理**: 语义化版本
|
- **版本管理**: 语义化版本
|
||||||
|
|
||||||
### 持续集成
|
### 持续集成
|
||||||
|
|
||||||
- **GitHub Actions**: 自动化流程
|
- **GitHub Actions**: 自动化流程
|
||||||
- **代码检查**: 自动化检查
|
- **代码检查**: 自动化检查
|
||||||
- **测试执行**: 自动化测试
|
- **测试执行**: 自动化测试
|
||||||
@@ -209,18 +241,21 @@
|
|||||||
## 开发规范
|
## 开发规范
|
||||||
|
|
||||||
### 代码规范
|
### 代码规范
|
||||||
|
|
||||||
- **ESLint**: 代码检查
|
- **ESLint**: 代码检查
|
||||||
- **Prettier**: 代码格式化
|
- **Prettier**: 代码格式化
|
||||||
- **TypeScript**: 类型安全
|
- **TypeScript**: 类型安全
|
||||||
- **命名规范**: 统一命名
|
- **命名规范**: 统一命名
|
||||||
|
|
||||||
### Git 工作流
|
### Git 工作流
|
||||||
|
|
||||||
- **Conventional Commits**: 提交规范
|
- **Conventional Commits**: 提交规范
|
||||||
- **分支策略**: Git Flow
|
- **分支策略**: Git Flow
|
||||||
- **代码审查**: PR 流程
|
- **代码审查**: PR 流程
|
||||||
- **版本标签**: 标签管理
|
- **版本标签**: 标签管理
|
||||||
|
|
||||||
### 文档规范
|
### 文档规范
|
||||||
|
|
||||||
- **JSDoc**: 代码注释
|
- **JSDoc**: 代码注释
|
||||||
- **README**: 项目文档
|
- **README**: 项目文档
|
||||||
- **API 文档**: 接口文档
|
- **API 文档**: 接口文档
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { useVersionCheck } from '@/contexts/VersionCheckContext';
|
|||||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
import { useI18n } from '@/hooks/useI18n';
|
|
||||||
import type { BadgeDto } from '@/services/badges';
|
import type { BadgeDto } from '@/services/badges';
|
||||||
import { reportBadgeShowcaseDisplayed } from '@/services/badges';
|
import { reportBadgeShowcaseDisplayed } from '@/services/badges';
|
||||||
import { updateUser, type UserLanguage } from '@/services/users';
|
import { updateUser, type UserLanguage } from '@/services/users';
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { NutritionRadarCard } from '@/components/NutritionRadarCard';
|
|||||||
import CircumferenceCard from '@/components/statistic/CircumferenceCard';
|
import CircumferenceCard from '@/components/statistic/CircumferenceCard';
|
||||||
import OxygenSaturationCard from '@/components/statistic/OxygenSaturationCard';
|
import OxygenSaturationCard from '@/components/statistic/OxygenSaturationCard';
|
||||||
import SleepCard from '@/components/statistic/SleepCard';
|
import SleepCard from '@/components/statistic/SleepCard';
|
||||||
|
import WristTemperatureCard from '@/components/statistic/WristTemperatureCard';
|
||||||
import StepsCard from '@/components/StepsCard';
|
import StepsCard from '@/components/StepsCard';
|
||||||
import { StressMeter } from '@/components/StressMeter';
|
import { StressMeter } from '@/components/StressMeter';
|
||||||
import WaterIntakeCard from '@/components/WaterIntakeCard';
|
import WaterIntakeCard from '@/components/WaterIntakeCard';
|
||||||
@@ -109,6 +110,7 @@ export default function ExploreScreen() {
|
|||||||
showWater: true,
|
showWater: true,
|
||||||
showBasalMetabolism: true,
|
showBasalMetabolism: true,
|
||||||
showOxygenSaturation: true,
|
showOxygenSaturation: true,
|
||||||
|
showWristTemperature: true,
|
||||||
showMenstrualCycle: true,
|
showMenstrualCycle: true,
|
||||||
showWeight: true,
|
showWeight: true,
|
||||||
showCircumference: true,
|
showCircumference: true,
|
||||||
@@ -443,7 +445,7 @@ export default function ExploreScreen() {
|
|||||||
style={styles.scrollView}
|
style={styles.scrollView}
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingTop: insets.top,
|
paddingTop: insets.top,
|
||||||
paddingBottom: 60,
|
paddingBottom: 100,
|
||||||
paddingHorizontal: 20
|
paddingHorizontal: 20
|
||||||
}}
|
}}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
@@ -615,6 +617,15 @@ export default function ExploreScreen() {
|
|||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
temperature: {
|
||||||
|
visible: cardVisibility.showWristTemperature,
|
||||||
|
component: (
|
||||||
|
<WristTemperatureCard
|
||||||
|
selectedDate={currentSelectedDate}
|
||||||
|
style={styles.basalMetabolismCardOverride}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
menstrual: {
|
menstrual: {
|
||||||
visible: cardVisibility.showMenstrualCycle,
|
visible: cardVisibility.showMenstrualCycle,
|
||||||
component: (
|
component: (
|
||||||
|
|||||||
@@ -4,152 +4,51 @@ import { LinearGradient } from 'expo-linear-gradient';
|
|||||||
import { Stack, useRouter } from 'expo-router';
|
import { Stack, useRouter } from 'expo-router';
|
||||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
DimensionValue,
|
|
||||||
FlatList,
|
FlatList,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
Text,
|
Text,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
View,
|
View,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { Colors } from '@/constants/Colors';
|
import { InlineTip, ITEM_HEIGHT, Legend, MonthBlock } from '@/components/menstrual-cycle';
|
||||||
import {
|
import {
|
||||||
|
deleteMenstrualFlow,
|
||||||
|
fetchMenstrualFlowSamples,
|
||||||
|
saveMenstrualFlow
|
||||||
|
} from '@/utils/health';
|
||||||
|
import {
|
||||||
|
buildMenstrualTimeline,
|
||||||
|
convertHealthKitSamplesToCycleRecords,
|
||||||
CycleRecord,
|
CycleRecord,
|
||||||
DEFAULT_PERIOD_LENGTH,
|
DEFAULT_PERIOD_LENGTH
|
||||||
MenstrualDayCell,
|
|
||||||
MenstrualDayStatus,
|
|
||||||
MenstrualTimeline,
|
|
||||||
buildMenstrualTimeline
|
|
||||||
} from '@/utils/menstrualCycle';
|
} from '@/utils/menstrualCycle';
|
||||||
|
|
||||||
type TabKey = 'cycle' | 'analysis';
|
type TabKey = 'cycle' | 'analysis';
|
||||||
|
|
||||||
const ITEM_HEIGHT = 380;
|
|
||||||
const STATUS_COLORS: Record<MenstrualDayStatus, { bg: string; text: string }> = {
|
|
||||||
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 = <T,>(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<MenstrualDayCell, { type: 'day' }>;
|
|
||||||
isSelected: boolean;
|
|
||||||
onPress: () => void;
|
|
||||||
}) => {
|
|
||||||
const status = cell.info?.status;
|
|
||||||
const colors = status ? STATUS_COLORS[status] : undefined;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TouchableOpacity
|
|
||||||
activeOpacity={0.8}
|
|
||||||
style={styles.dayCell}
|
|
||||||
onPress={onPress}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
styles.dayCircle,
|
|
||||||
colors && { backgroundColor: colors.bg },
|
|
||||||
isSelected && styles.dayCircleSelected,
|
|
||||||
cell.isToday && styles.todayOutline,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
style={[
|
|
||||||
styles.dayLabel,
|
|
||||||
colors && { color: colors.text },
|
|
||||||
!colors && styles.dayLabelDefault,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{cell.label}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
{cell.isToday && <Text style={styles.todayText}>今天</Text>}
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<View style={styles.monthCard}>
|
|
||||||
<View style={styles.monthHeader}>
|
|
||||||
<Text style={styles.monthTitle}>{month.title}</Text>
|
|
||||||
<Text style={styles.monthSubtitle}>{month.subtitle}</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.weekRow}>
|
|
||||||
{WEEK_LABELS.map((label) => (
|
|
||||||
<Text key={label} style={styles.weekLabel}>
|
|
||||||
{label}
|
|
||||||
</Text>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
<View style={styles.monthGrid}>
|
|
||||||
{weeks.map((week, weekIndex) => {
|
|
||||||
const selectedIndex = week.findIndex(
|
|
||||||
(c) => c.type === 'day' && c.date.format('YYYY-MM-DD') === selectedDateKey
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<React.Fragment key={weekIndex}>
|
|
||||||
<View style={styles.daysRow}>
|
|
||||||
{week.map((cell) => {
|
|
||||||
if (cell.type === 'placeholder') {
|
|
||||||
return <View key={cell.key} style={styles.dayCell} />;
|
|
||||||
}
|
|
||||||
const dateKey = cell.date.format('YYYY-MM-DD');
|
|
||||||
return (
|
|
||||||
<DayCell
|
|
||||||
key={cell.key}
|
|
||||||
cell={cell}
|
|
||||||
isSelected={selectedDateKey === dateKey}
|
|
||||||
onPress={() => onSelect(dateKey)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</View>
|
|
||||||
{selectedIndex !== -1 && (
|
|
||||||
<View style={styles.inlineTipContainer}>
|
|
||||||
{renderTip(selectedIndex)}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</React.Fragment>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function MenstrualCycleScreen() {
|
export default function MenstrualCycleScreen() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { t } = useTranslation();
|
||||||
const [records, setRecords] = useState<CycleRecord[]>([]);
|
const [records, setRecords] = useState<CycleRecord[]>([]);
|
||||||
const [windowConfig, setWindowConfig] = useState({ before: 2, after: 3 });
|
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(
|
const timeline = useMemo(
|
||||||
() =>
|
() =>
|
||||||
buildMenstrualTimeline({
|
buildMenstrualTimeline({
|
||||||
@@ -173,10 +72,10 @@ export default function MenstrualCycleScreen() {
|
|||||||
const selectedDate = dayjs(selectedDateKey);
|
const selectedDate = dayjs(selectedDateKey);
|
||||||
|
|
||||||
|
|
||||||
const handleMarkStart = () => {
|
const handleMarkStart = async () => {
|
||||||
if (selectedDate.isAfter(dayjs(), 'day')) return;
|
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 isCovered = records.some((r) => {
|
||||||
const start = dayjs(r.startDate);
|
const start = dayjs(r.startDate);
|
||||||
const end = start.add((r.periodLength ?? DEFAULT_PERIOD_LENGTH) - 1, 'day');
|
const end = start.add((r.periodLength ?? DEFAULT_PERIOD_LENGTH) - 1, 'day');
|
||||||
@@ -187,45 +86,36 @@ export default function MenstrualCycleScreen() {
|
|||||||
});
|
});
|
||||||
if (isCovered) return;
|
if (isCovered) return;
|
||||||
|
|
||||||
|
// Optimistic Update
|
||||||
|
const originalRecords = [...records];
|
||||||
setRecords((prev) => {
|
setRecords((prev) => {
|
||||||
const updated = [...prev];
|
const updated = [...prev];
|
||||||
|
// Logic for optimistic UI update (same as original logic)
|
||||||
// 1. Check if selectedDate is immediately after an existing period
|
|
||||||
const prevRecordIndex = updated.findIndex((r) => {
|
const prevRecordIndex = updated.findIndex((r) => {
|
||||||
const start = dayjs(r.startDate);
|
const start = dayjs(r.startDate);
|
||||||
const end = start.add((r.periodLength ?? DEFAULT_PERIOD_LENGTH) - 1, 'day');
|
const end = start.add((r.periodLength ?? DEFAULT_PERIOD_LENGTH) - 1, 'day');
|
||||||
return end.add(1, 'day').isSame(selectedDate, 'day');
|
return end.add(1, 'day').isSame(selectedDate, 'day');
|
||||||
});
|
});
|
||||||
|
|
||||||
// 2. Check if selectedDate is immediately before an existing period
|
|
||||||
const nextRecordIndex = updated.findIndex((r) => {
|
const nextRecordIndex = updated.findIndex((r) => {
|
||||||
return dayjs(r.startDate).subtract(1, 'day').isSame(selectedDate, 'day');
|
return dayjs(r.startDate).subtract(1, 'day').isSame(selectedDate, 'day');
|
||||||
});
|
});
|
||||||
|
|
||||||
if (prevRecordIndex !== -1 && nextRecordIndex !== -1) {
|
if (prevRecordIndex !== -1 && nextRecordIndex !== -1) {
|
||||||
// Merge three parts: Prev + Selected + Next
|
|
||||||
const prevRecord = updated[prevRecordIndex];
|
const prevRecord = updated[prevRecordIndex];
|
||||||
const nextRecord = updated[nextRecordIndex];
|
const nextRecord = updated[nextRecordIndex];
|
||||||
const newLength =
|
const newLength =
|
||||||
(prevRecord.periodLength ?? DEFAULT_PERIOD_LENGTH) +
|
(prevRecord.periodLength ?? DEFAULT_PERIOD_LENGTH) +
|
||||||
1 +
|
1 +
|
||||||
(nextRecord.periodLength ?? DEFAULT_PERIOD_LENGTH);
|
(nextRecord.periodLength ?? DEFAULT_PERIOD_LENGTH);
|
||||||
|
updated[prevRecordIndex] = { ...prevRecord, periodLength: newLength };
|
||||||
updated[prevRecordIndex] = {
|
|
||||||
...prevRecord,
|
|
||||||
periodLength: newLength,
|
|
||||||
};
|
|
||||||
// Remove the next record since it's merged
|
|
||||||
updated.splice(nextRecordIndex, 1);
|
updated.splice(nextRecordIndex, 1);
|
||||||
} else if (prevRecordIndex !== -1) {
|
} else if (prevRecordIndex !== -1) {
|
||||||
// Extend previous record
|
|
||||||
const prevRecord = updated[prevRecordIndex];
|
const prevRecord = updated[prevRecordIndex];
|
||||||
updated[prevRecordIndex] = {
|
updated[prevRecordIndex] = {
|
||||||
...prevRecord,
|
...prevRecord,
|
||||||
periodLength: (prevRecord.periodLength ?? DEFAULT_PERIOD_LENGTH) + 1,
|
periodLength: (prevRecord.periodLength ?? DEFAULT_PERIOD_LENGTH) + 1,
|
||||||
};
|
};
|
||||||
} else if (nextRecordIndex !== -1) {
|
} else if (nextRecordIndex !== -1) {
|
||||||
// Extend next record (start earlier)
|
|
||||||
const nextRecord = updated[nextRecordIndex];
|
const nextRecord = updated[nextRecordIndex];
|
||||||
updated[nextRecordIndex] = {
|
updated[nextRecordIndex] = {
|
||||||
...nextRecord,
|
...nextRecord,
|
||||||
@@ -233,7 +123,6 @@ export default function MenstrualCycleScreen() {
|
|||||||
periodLength: (nextRecord.periodLength ?? DEFAULT_PERIOD_LENGTH) + 1,
|
periodLength: (nextRecord.periodLength ?? DEFAULT_PERIOD_LENGTH) + 1,
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
// Create new isolated record
|
|
||||||
const newRecord: CycleRecord = {
|
const newRecord: CycleRecord = {
|
||||||
startDate: selectedDate.format('YYYY-MM-DD'),
|
startDate: selectedDate.format('YYYY-MM-DD'),
|
||||||
periodLength: 7,
|
periodLength: 7,
|
||||||
@@ -241,18 +130,59 @@ export default function MenstrualCycleScreen() {
|
|||||||
};
|
};
|
||||||
updated.push(newRecord);
|
updated.push(newRecord);
|
||||||
}
|
}
|
||||||
|
return updated.sort((a, b) => dayjs(a.startDate).valueOf() - dayjs(b.startDate).valueOf());
|
||||||
|
});
|
||||||
|
|
||||||
return updated.sort(
|
try {
|
||||||
(a, b) => dayjs(a.startDate).valueOf() - dayjs(b.startDate).valueOf()
|
// 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 (!selectedInfo || !selectedInfo.confirmed) return;
|
||||||
if (selectedDate.isAfter(dayjs(), 'day')) return;
|
if (selectedDate.isAfter(dayjs(), 'day')) return;
|
||||||
const target = selectedDate;
|
const target = selectedDate;
|
||||||
|
|
||||||
|
// Optimistic Update
|
||||||
|
const originalRecords = [...records];
|
||||||
setRecords((prev) => {
|
setRecords((prev) => {
|
||||||
const updated: CycleRecord[] = [];
|
const updated: CycleRecord[] = [];
|
||||||
prev.forEach((record) => {
|
prev.forEach((record) => {
|
||||||
@@ -264,21 +194,47 @@ export default function MenstrualCycleScreen() {
|
|||||||
updated.push(record);
|
updated.push(record);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (diff === 0) return; // Remove entire record (or start of it)
|
||||||
if (diff === 0) {
|
updated.push({ ...record, periodLength: diff }); // Shorten it
|
||||||
// 取消开始日:移除整段记录
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// diff > 0 且在区间内:将该日标记为结束日 (选中当日也被取消,所以长度为 diff)
|
|
||||||
updated.push({
|
|
||||||
...record,
|
|
||||||
periodLength: diff,
|
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
return updated;
|
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 = () => {
|
const handleLoadPrevious = () => {
|
||||||
@@ -314,27 +270,7 @@ export default function MenstrualCycleScreen() {
|
|||||||
}
|
}
|
||||||
}).current;
|
}).current;
|
||||||
|
|
||||||
const renderLegend = () => (
|
|
||||||
<View style={styles.legendRow}>
|
|
||||||
{[
|
|
||||||
{ label: '经期', key: 'period' as const },
|
|
||||||
{ label: '预测经期', key: 'predicted-period' as const },
|
|
||||||
{ label: '排卵期', key: 'fertile' as const },
|
|
||||||
{ label: '排卵日', key: 'ovulation-day' as const },
|
|
||||||
].map((item) => (
|
|
||||||
<View key={item.key} style={styles.legendItem}>
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
styles.legendDot,
|
|
||||||
{ backgroundColor: STATUS_COLORS[item.key].bg },
|
|
||||||
item.key === 'ovulation-day' && styles.legendDotRing,
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<Text style={styles.legendLabel}>{item.label}</Text>
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
const listData = useMemo(() => {
|
const listData = useMemo(() => {
|
||||||
return timeline.months.map((m) => ({
|
return timeline.months.map((m) => ({
|
||||||
@@ -344,40 +280,19 @@ export default function MenstrualCycleScreen() {
|
|||||||
}));
|
}));
|
||||||
}, [timeline.months]);
|
}, [timeline.months]);
|
||||||
|
|
||||||
const renderInlineTip = (columnIndex: number) => {
|
const renderInlineTip = (columnIndex: number) => (
|
||||||
// 14.28% per cell. Center is 7.14%.
|
<InlineTip
|
||||||
const pointerLeft = `${columnIndex * 14.2857 + 7.1428}%` as DimensionValue;
|
selectedDate={selectedDate}
|
||||||
const isFuture = selectedDate.isAfter(dayjs(), 'day');
|
selectedInfo={selectedInfo}
|
||||||
|
columnIndex={columnIndex}
|
||||||
const base = (
|
onMarkStart={handleMarkStart}
|
||||||
<View style={styles.inlineTipCard}>
|
onCancelMark={handleCancelMark}
|
||||||
<View style={[styles.inlineTipPointer, { left: pointerLeft }]} />
|
/>
|
||||||
<View style={styles.inlineTipRow}>
|
|
||||||
<View style={styles.inlineTipDate}>
|
|
||||||
<Ionicons name="calendar-outline" size={16} color="#111827" />
|
|
||||||
<Text style={styles.inlineTipDateText}>{selectedDate.format('M月D日')}</Text>
|
|
||||||
</View>
|
|
||||||
{!isFuture && (!selectedInfo || !selectedInfo.confirmed) && (
|
|
||||||
<TouchableOpacity style={styles.inlinePrimaryBtn} onPress={handleMarkStart}>
|
|
||||||
<Ionicons name="add" size={14} color="#fff" />
|
|
||||||
<Text style={styles.inlinePrimaryText}>标记经期</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
{!isFuture && selectedInfo?.confirmed && selectedInfo.status === 'period' && (
|
|
||||||
<TouchableOpacity style={styles.inlineSecondaryBtn} onPress={handleCancelMark}>
|
|
||||||
<Text style={styles.inlineSecondaryText}>取消标记</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return base;
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderCycleTab = () => (
|
const renderCycleTab = () => (
|
||||||
<View style={styles.tabContent}>
|
<View style={styles.tabContent}>
|
||||||
{renderLegend()}
|
<Legend />
|
||||||
|
|
||||||
|
|
||||||
<FlatList
|
<FlatList
|
||||||
@@ -411,9 +326,9 @@ export default function MenstrualCycleScreen() {
|
|||||||
const renderAnalysisTab = () => (
|
const renderAnalysisTab = () => (
|
||||||
<View style={styles.tabContent}>
|
<View style={styles.tabContent}>
|
||||||
<View style={styles.analysisCard}>
|
<View style={styles.analysisCard}>
|
||||||
<Text style={styles.analysisTitle}>分析</Text>
|
<Text style={styles.analysisTitle}>{t('menstrual.screen.analysis.title')}</Text>
|
||||||
<Text style={styles.analysisBody}>
|
<Text style={styles.analysisBody}>
|
||||||
基于最近 6 个周期的记录,计算平均经期和周期长度,后续会展示趋势和预测准确度。
|
{t('menstrual.screen.analysis.description')}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -433,7 +348,7 @@ export default function MenstrualCycleScreen() {
|
|||||||
<TouchableOpacity onPress={() => router.back()} style={styles.headerIcon}>
|
<TouchableOpacity onPress={() => router.back()} style={styles.headerIcon}>
|
||||||
<Ionicons name="chevron-back" size={22} color="#0f172a" />
|
<Ionicons name="chevron-back" size={22} color="#0f172a" />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<Text style={styles.headerTitle}>生理周期</Text>
|
<Text style={styles.headerTitle}>{t('menstrual.screen.header')}</Text>
|
||||||
<TouchableOpacity style={styles.headerIcon}>
|
<TouchableOpacity style={styles.headerIcon}>
|
||||||
<Ionicons name="settings-outline" size={20} color="#0f172a" />
|
<Ionicons name="settings-outline" size={20} color="#0f172a" />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -441,8 +356,8 @@ export default function MenstrualCycleScreen() {
|
|||||||
|
|
||||||
<View style={styles.tabSwitcher}>
|
<View style={styles.tabSwitcher}>
|
||||||
{([
|
{([
|
||||||
{ key: 'cycle', label: '生理周期' },
|
{ key: 'cycle', label: t('menstrual.screen.tabs.cycle') },
|
||||||
{ key: 'analysis', label: '分析' },
|
{ key: 'analysis', label: t('menstrual.screen.tabs.analysis') },
|
||||||
] as { key: TabKey; label: string }[]).map((tab) => {
|
] as { key: TabKey; label: string }[]).map((tab) => {
|
||||||
const active = activeTab === tab.key;
|
const active = activeTab === tab.key;
|
||||||
return (
|
return (
|
||||||
@@ -525,32 +440,7 @@ const styles = StyleSheet.create({
|
|||||||
tabContent: {
|
tabContent: {
|
||||||
flex: 1,
|
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: {
|
selectedCard: {
|
||||||
backgroundColor: '#fff',
|
backgroundColor: '#fff',
|
||||||
borderRadius: 16,
|
borderRadius: 16,
|
||||||
@@ -569,206 +459,7 @@ const styles = StyleSheet.create({
|
|||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
fontFamily: 'AliBold',
|
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: {
|
listContent: {
|
||||||
paddingBottom: 80,
|
paddingBottom: 80,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export default function StatisticsCustomizationScreen() {
|
|||||||
water: { icon: 'water-outline', titleKey: 'statisticsCustomization.items.water', visibilityKey: 'showWater' },
|
water: { icon: 'water-outline', titleKey: 'statisticsCustomization.items.water', visibilityKey: 'showWater' },
|
||||||
metabolism: { icon: 'flame-outline', titleKey: 'statisticsCustomization.items.basalMetabolism', visibilityKey: 'showBasalMetabolism' },
|
metabolism: { icon: 'flame-outline', titleKey: 'statisticsCustomization.items.basalMetabolism', visibilityKey: 'showBasalMetabolism' },
|
||||||
oxygen: { icon: 'water-outline', titleKey: 'statisticsCustomization.items.oxygenSaturation', visibilityKey: 'showOxygenSaturation' },
|
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' },
|
menstrual: { icon: 'rose-outline', titleKey: 'statisticsCustomization.items.menstrualCycle', visibilityKey: 'showMenstrualCycle' },
|
||||||
weight: { icon: 'scale-outline', titleKey: 'statisticsCustomization.items.weight', visibilityKey: 'showWeight' },
|
weight: { icon: 'scale-outline', titleKey: 'statisticsCustomization.items.weight', visibilityKey: 'showWeight' },
|
||||||
circumference: { icon: 'body-outline', titleKey: 'statisticsCustomization.items.circumference', visibilityKey: 'showCircumference' },
|
circumference: { icon: 'body-outline', titleKey: 'statisticsCustomization.items.circumference', visibilityKey: 'showCircumference' },
|
||||||
|
|||||||
@@ -1,14 +1,33 @@
|
|||||||
import { LinearGradient } from 'expo-linear-gradient';
|
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 { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { Colors } from '@/constants/Colors';
|
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 = {
|
type Props = {
|
||||||
onPress?: () => void;
|
onPress?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type Summary = {
|
||||||
|
state: string;
|
||||||
|
prefix?: string;
|
||||||
|
suffix?: string;
|
||||||
|
number?: number;
|
||||||
|
fallbackText: string;
|
||||||
|
};
|
||||||
|
|
||||||
const RingIcon = () => (
|
const RingIcon = () => (
|
||||||
<View style={styles.iconWrapper}>
|
<View style={styles.iconWrapper}>
|
||||||
<LinearGradient
|
<LinearGradient
|
||||||
@@ -23,45 +42,68 @@ const RingIcon = () => (
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const MenstrualCycleCard: React.FC<Props> = ({ onPress }) => {
|
export const MenstrualCycleCard: React.FC<Props> = ({ onPress }) => {
|
||||||
const { todayInfo, periodLength } = useMemo(() => buildMenstrualTimeline(), []);
|
const { t } = useTranslation();
|
||||||
|
const [records, setRecords] = useState<CycleRecord[]>([]);
|
||||||
|
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(() => {
|
const summary = useMemo(() => {
|
||||||
if (!todayInfo) {
|
if (loading && records.length === 0) {
|
||||||
return {
|
return {
|
||||||
state: '待记录',
|
state: t('menstrual.card.syncingState'),
|
||||||
dayText: '点击记录本次经期',
|
fallbackText: t('menstrual.card.syncingDesc'),
|
||||||
number: undefined,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (todayInfo.status === 'period' || todayInfo.status === 'predicted-period') {
|
return deriveSummary(timeline, records.length > 0, t);
|
||||||
return {
|
}, [loading, records.length, timeline, t]);
|
||||||
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 (
|
return (
|
||||||
<TouchableOpacity activeOpacity={0.92} onPress={onPress} style={styles.wrapper}>
|
<TouchableOpacity activeOpacity={0.92} onPress={onPress} style={styles.wrapper}>
|
||||||
<View style={styles.headerRow}>
|
<View style={styles.headerRow}>
|
||||||
<RingIcon />
|
<RingIcon />
|
||||||
<Text style={styles.title}>生理周期</Text>
|
<Text style={styles.title}>{t('menstrual.card.title')}</Text>
|
||||||
<View style={styles.badgeOuter}>
|
<View style={styles.badgeOuter}>
|
||||||
<View style={styles.badgeInner} />
|
<View style={styles.badgeInner} />
|
||||||
</View>
|
</View>
|
||||||
@@ -71,10 +113,12 @@ export const MenstrualCycleCard: React.FC<Props> = ({ onPress }) => {
|
|||||||
<Text style={styles.dayRow}>
|
<Text style={styles.dayRow}>
|
||||||
{summary.number !== undefined ? (
|
{summary.number !== undefined ? (
|
||||||
<>
|
<>
|
||||||
第 <Text style={styles.dayNumber}>{summary.number}</Text> {summary.dayText}
|
{summary.prefix}
|
||||||
|
<Text style={styles.dayNumber}>{summary.number}</Text>
|
||||||
|
{summary.suffix}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
summary.dayText
|
summary.fallbackText
|
||||||
)}
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -82,6 +126,199 @@ export const MenstrualCycleCard: React.FC<Props> = ({ onPress }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const periodStatuses = new Set<MenstrualDayStatus>(['period', 'predicted-period']);
|
||||||
|
const fertileStatuses = new Set<MenstrualDayStatus>(['fertile', 'ovulation-day']);
|
||||||
|
const ovulationStatuses = new Set<MenstrualDayStatus>(['ovulation-day']);
|
||||||
|
|
||||||
|
const deriveSummary = (
|
||||||
|
timeline: MenstrualTimeline,
|
||||||
|
hasRecords: boolean,
|
||||||
|
t: (key: string, options?: Record<string, any>) => 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<MenstrualDayStatus>
|
||||||
|
): { 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<MenstrualDayStatus>,
|
||||||
|
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<MenstrualDayStatus>) => {
|
||||||
|
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({
|
const styles = StyleSheet.create({
|
||||||
wrapper: {
|
wrapper: {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
|
|||||||
75
components/menstrual-cycle/DayCell.tsx
Normal file
75
components/menstrual-cycle/DayCell.tsx
Normal file
@@ -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<DayCellProps> = ({ cell, isSelected, onPress }) => {
|
||||||
|
const status = cell.info?.status;
|
||||||
|
const colors = status ? STATUS_COLORS[status] : undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
activeOpacity={0.8}
|
||||||
|
style={styles.dayCell}
|
||||||
|
onPress={onPress}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.dayCircle,
|
||||||
|
colors && { backgroundColor: colors.bg },
|
||||||
|
isSelected && styles.dayCircleSelected,
|
||||||
|
cell.isToday && styles.todayOutline,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.dayLabel,
|
||||||
|
colors && { color: colors.text },
|
||||||
|
!colors && styles.dayLabelDefault,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{cell.label}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{cell.isToday && <Text style={styles.todayText}>今天</Text>}
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
});
|
||||||
111
components/menstrual-cycle/InlineTip.tsx
Normal file
111
components/menstrual-cycle/InlineTip.tsx
Normal file
@@ -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<InlineTipProps> = ({
|
||||||
|
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 (
|
||||||
|
<View style={styles.inlineTipCard}>
|
||||||
|
<View style={[styles.inlineTipPointer, { left: pointerLeft }]} />
|
||||||
|
<View style={styles.inlineTipRow}>
|
||||||
|
<View style={styles.inlineTipDate}>
|
||||||
|
<Ionicons name="calendar-outline" size={16} color="#111827" />
|
||||||
|
<Text style={styles.inlineTipDateText}>{selectedDate.format('M月D日')}</Text>
|
||||||
|
</View>
|
||||||
|
{!isFuture && (!selectedInfo || !selectedInfo.confirmed) && (
|
||||||
|
<TouchableOpacity style={styles.inlinePrimaryBtn} onPress={onMarkStart}>
|
||||||
|
<Ionicons name="add" size={14} color="#fff" />
|
||||||
|
<Text style={styles.inlinePrimaryText}>标记经期</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
{!isFuture && selectedInfo?.confirmed && selectedInfo.status === 'period' && (
|
||||||
|
<TouchableOpacity style={styles.inlineSecondaryBtn} onPress={onCancelMark}>
|
||||||
|
<Text style={styles.inlineSecondaryText}>取消标记</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
});
|
||||||
59
components/menstrual-cycle/Legend.tsx
Normal file
59
components/menstrual-cycle/Legend.tsx
Normal file
@@ -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 (
|
||||||
|
<View style={styles.legendRow}>
|
||||||
|
{LEGEND_ITEMS.map((item) => (
|
||||||
|
<View key={item.key} style={styles.legendItem}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.legendDot,
|
||||||
|
{ backgroundColor: STATUS_COLORS[item.key].bg },
|
||||||
|
item.key === 'ovulation-day' && styles.legendDotRing,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Text style={styles.legendLabel}>{item.label}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
});
|
||||||
137
components/menstrual-cycle/MonthBlock.tsx
Normal file
137
components/menstrual-cycle/MonthBlock.tsx
Normal file
@@ -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 = <T,>(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<MonthBlockProps> = ({
|
||||||
|
month,
|
||||||
|
selectedDateKey,
|
||||||
|
onSelect,
|
||||||
|
renderTip,
|
||||||
|
}) => {
|
||||||
|
const weeks = useMemo(() => chunkArray(month.cells, 7), [month.cells]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.monthCard}>
|
||||||
|
<View style={styles.monthHeader}>
|
||||||
|
<Text style={styles.monthTitle}>{month.title}</Text>
|
||||||
|
<Text style={styles.monthSubtitle}>{month.subtitle}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.weekRow}>
|
||||||
|
{WEEK_LABELS.map((label) => (
|
||||||
|
<Text key={label} style={styles.weekLabel}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
<View style={styles.monthGrid}>
|
||||||
|
{weeks.map((week, weekIndex) => {
|
||||||
|
const selectedIndex = week.findIndex(
|
||||||
|
(c) => c.type === 'day' && c.date.format('YYYY-MM-DD') === selectedDateKey
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment key={weekIndex}>
|
||||||
|
<View style={styles.daysRow}>
|
||||||
|
{week.map((cell) => {
|
||||||
|
if (cell.type === 'placeholder') {
|
||||||
|
return <View key={cell.key} style={styles.dayCell} />;
|
||||||
|
}
|
||||||
|
const dateKey = cell.date.format('YYYY-MM-DD');
|
||||||
|
return (
|
||||||
|
<DayCell
|
||||||
|
key={cell.key}
|
||||||
|
cell={cell}
|
||||||
|
isSelected={selectedDateKey === dateKey}
|
||||||
|
onPress={() => onSelect(dateKey)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
{selectedIndex !== -1 && (
|
||||||
|
<View style={styles.inlineTipContainer}>
|
||||||
|
{renderTip(selectedIndex)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
12
components/menstrual-cycle/constants.ts
Normal file
12
components/menstrual-cycle/constants.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { MenstrualDayStatus } from '@/utils/menstrualCycle';
|
||||||
|
|
||||||
|
export const STATUS_COLORS: Record<MenstrualDayStatus, { bg: string; text: string }> = {
|
||||||
|
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;
|
||||||
7
components/menstrual-cycle/index.ts
Normal file
7
components/menstrual-cycle/index.ts
Normal file
@@ -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';
|
||||||
|
|
||||||
21
components/menstrual-cycle/types.ts
Normal file
21
components/menstrual-cycle/types.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { MenstrualDayCell, MenstrualDayInfo } from '@/utils/menstrualCycle';
|
||||||
|
import { Dayjs } from 'dayjs';
|
||||||
|
|
||||||
|
export interface DayCellProps {
|
||||||
|
cell: Extract<MenstrualDayCell, { type: 'day' }>;
|
||||||
|
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';
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Image } from 'expo-image';
|
import { Image } from 'expo-image';
|
||||||
import React from 'react';
|
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';
|
import Animated, { FadeIn, FadeOut } from 'react-native-reanimated';
|
||||||
|
|
||||||
interface HealthDataCardProps {
|
interface HealthDataCardProps {
|
||||||
@@ -8,37 +8,36 @@ interface HealthDataCardProps {
|
|||||||
value: string;
|
value: string;
|
||||||
unit: string;
|
unit: string;
|
||||||
style?: object;
|
style?: object;
|
||||||
|
onPress?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const HealthDataCard: React.FC<HealthDataCardProps> = ({
|
const HealthDataCard: React.FC<HealthDataCardProps> = ({
|
||||||
title,
|
title,
|
||||||
value,
|
value,
|
||||||
unit,
|
unit,
|
||||||
style
|
style,
|
||||||
|
onPress
|
||||||
}) => {
|
}) => {
|
||||||
|
const Container = onPress ? Pressable : View;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Animated.View
|
<Animated.View entering={FadeIn.duration(300)} exiting={FadeOut.duration(300)} style={[styles.card, style]}>
|
||||||
entering={FadeIn.duration(300)}
|
<Container
|
||||||
exiting={FadeOut.duration(300)}
|
style={styles.content}
|
||||||
style={[styles.card, style]}
|
onPress={onPress}
|
||||||
|
accessibilityRole={onPress ? 'button' : undefined}
|
||||||
|
accessibilityLabel={title}
|
||||||
|
accessibilityHint={onPress ? `${title} details` : undefined}
|
||||||
>
|
>
|
||||||
<View style={styles.content}>
|
<View style={styles.headerRow}>
|
||||||
<View style={{
|
<Image source={require('@/assets/images/icons/icon-blood-oxygen.png')} style={styles.titleIcon} />
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
marginBottom: 14,
|
|
||||||
}}>
|
|
||||||
<Image
|
|
||||||
source={require('@/assets/images/icons/icon-blood-oxygen.png')}
|
|
||||||
style={styles.titleIcon}
|
|
||||||
/>
|
|
||||||
<Text style={styles.title}>{title}</Text>
|
<Text style={styles.title}>{title}</Text>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.valueContainer}>
|
<View style={styles.valueContainer}>
|
||||||
<Text style={styles.value}>{value}</Text>
|
<Text style={styles.value}>{value}</Text>
|
||||||
<Text style={styles.unit}>{unit}</Text>
|
<Text style={styles.unit}>{unit}</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</Container>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -62,6 +61,11 @@ const styles = StyleSheet.create({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
|
headerRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 14,
|
||||||
|
},
|
||||||
titleIcon: {
|
titleIcon: {
|
||||||
width: 16,
|
width: 16,
|
||||||
height: 16,
|
height: 16,
|
||||||
|
|||||||
568
components/statistic/WristTemperatureCard.tsx
Normal file
568
components/statistic/WristTemperatureCard.tsx
Normal file
@@ -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<WristTemperatureCardProps> = ({
|
||||||
|
style,
|
||||||
|
selectedDate
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const isFocused = useIsFocused();
|
||||||
|
const [temperature, setTemperature] = useState<number | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const loadingRef = useRef(false);
|
||||||
|
const [historyVisible, setHistoryVisible] = useState(false);
|
||||||
|
const [history, setHistory] = useState<WristTemperatureHistoryPoint[]>([]);
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<HealthDataCard
|
||||||
|
title={t('statistics.components.wristTemperature.title')}
|
||||||
|
value={loading ? '--' : (temperature !== null && temperature !== undefined ? temperature.toFixed(1) : '--')}
|
||||||
|
unit="°C"
|
||||||
|
style={style}
|
||||||
|
onPress={openHistory}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
visible={historyVisible}
|
||||||
|
animationType="slide"
|
||||||
|
presentationStyle={Platform.OS === 'ios' ? 'pageSheet' : 'fullScreen'}
|
||||||
|
onRequestClose={closeHistory}
|
||||||
|
>
|
||||||
|
<View style={styles.modalSafeArea}>
|
||||||
|
<LinearGradient
|
||||||
|
colors={['#F7F6FF', '#FFFFFF']}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 0, y: 1 }}
|
||||||
|
style={StyleSheet.absoluteFill}
|
||||||
|
/>
|
||||||
|
<View style={styles.modalContainer}>
|
||||||
|
<View style={styles.modalHeader}>
|
||||||
|
<View>
|
||||||
|
<Text style={styles.modalTitle}>{t('statistics.components.wristTemperature.title')}</Text>
|
||||||
|
<Text style={styles.modalSubtitle}>{t('statistics.components.wristTemperature.last30Days')}</Text>
|
||||||
|
</View>
|
||||||
|
<Pressable style={styles.closeButton} onPress={closeHistory} hitSlop={10}>
|
||||||
|
<BlurView intensity={24} tint="light" style={StyleSheet.absoluteFill} />
|
||||||
|
<View style={styles.closeButtonInner}>
|
||||||
|
<Ionicons name="close" size={18} color="#111827" />
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{historyLoading ? (
|
||||||
|
<Text style={styles.hintText}>{t('statistics.components.wristTemperature.syncing')}</Text>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{history.length === 0 ? (
|
||||||
|
<View style={styles.emptyState}>
|
||||||
|
<Text style={styles.emptyText}>{t('statistics.components.wristTemperature.noData')}</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View
|
||||||
|
style={styles.chartCard}
|
||||||
|
onLayout={(event) => {
|
||||||
|
const nextWidth = event.nativeEvent.layout.width;
|
||||||
|
if (nextWidth > 120 && Math.abs(nextWidth - chartWidth) > 2) {
|
||||||
|
setChartWidth(nextWidth);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Svg width={chartWidth} height={CHART_HEIGHT + 36}>
|
||||||
|
<Defs>
|
||||||
|
<SvgLinearGradient id="lineFade" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<Stop offset="0%" stopColor="#1F2A44" stopOpacity="1" />
|
||||||
|
<Stop offset="100%" stopColor="#1F2A44" stopOpacity="0.78" />
|
||||||
|
</SvgLinearGradient>
|
||||||
|
</Defs>
|
||||||
|
|
||||||
|
<Line
|
||||||
|
x1={CHART_HORIZONTAL_PADDING}
|
||||||
|
y1={valueToY(baseline ?? 0)}
|
||||||
|
x2={chartWidth - CHART_HORIZONTAL_PADDING}
|
||||||
|
y2={valueToY(baseline ?? 0)}
|
||||||
|
stroke="#CBD5E1"
|
||||||
|
strokeDasharray="6 6"
|
||||||
|
strokeWidth={1.2}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Path d={linePath} stroke="url(#lineFade)" strokeWidth={2.6} fill="none" strokeLinecap="round" />
|
||||||
|
|
||||||
|
{history.map((point, index) => {
|
||||||
|
const x = CHART_HORIZONTAL_PADDING + xStep * index;
|
||||||
|
const y = valueToY(point.value);
|
||||||
|
return (
|
||||||
|
<Circle
|
||||||
|
key={point.date}
|
||||||
|
cx={x}
|
||||||
|
cy={y}
|
||||||
|
r={5}
|
||||||
|
stroke="#1F2A44"
|
||||||
|
strokeWidth={1.6}
|
||||||
|
fill="#FFFFFF"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Svg>
|
||||||
|
<View style={styles.labelRow}>
|
||||||
|
{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 (
|
||||||
|
<Text key={item.date} style={[styles.axisLabel, { left: clampedLeft, width: LABEL_ESTIMATED_WIDTH }]}>
|
||||||
|
{item.label}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<View style={styles.baselineLabelWrapper}>
|
||||||
|
<View style={styles.baselinePill}>
|
||||||
|
<View style={styles.baselineDot} />
|
||||||
|
<Text style={styles.axisHint}>{t('statistics.components.wristTemperature.baseline')}</Text>
|
||||||
|
{baseline !== null && (
|
||||||
|
<Text style={styles.axisHintValue}>
|
||||||
|
{baseline.toFixed(1)}
|
||||||
|
°C
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
{latestChange !== null && (
|
||||||
|
<View style={styles.deviationBadge}>
|
||||||
|
<Text style={styles.deviationBadgeText}>
|
||||||
|
{latestChange >= 0 ? '+' : ''}
|
||||||
|
{latestChange.toFixed(1)}°C
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View style={styles.metricsRow}>
|
||||||
|
<View style={styles.metric}>
|
||||||
|
<Text style={styles.metricLabel}>{t('statistics.components.wristTemperature.average')}</Text>
|
||||||
|
<Text style={styles.metricValue}>
|
||||||
|
{baseline !== null ? baseline.toFixed(1) : '--'}
|
||||||
|
<Text style={styles.metricUnit}>°C</Text>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.metric}>
|
||||||
|
<Text style={styles.metricLabel}>{t('statistics.components.wristTemperature.latest')}</Text>
|
||||||
|
<Text style={styles.metricValue}>
|
||||||
|
{latestValue !== null ? latestValue.toFixed(1) : '--'}
|
||||||
|
<Text style={styles.metricUnit}>°C</Text>
|
||||||
|
</Text>
|
||||||
|
{latestChange !== null && (
|
||||||
|
<Text style={styles.metricHint}>
|
||||||
|
{latestChange >= 0 ? '+' : ''}
|
||||||
|
{latestChange.toFixed(1)}°C {t('statistics.components.wristTemperature.vsBaseline')}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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'
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -142,6 +142,16 @@ export const statistics = {
|
|||||||
oxygen: {
|
oxygen: {
|
||||||
title: 'Blood 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: {
|
circumference: {
|
||||||
title: 'Circumference (cm)',
|
title: 'Circumference (cm)',
|
||||||
setTitle: 'Set {{label}}',
|
setTitle: 'Set {{label}}',
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import * as Challenge from './challenge';
|
|||||||
import * as Common from './common';
|
import * as Common from './common';
|
||||||
import * as Diet from './diet';
|
import * as Diet from './diet';
|
||||||
import * as Health from './health';
|
import * as Health from './health';
|
||||||
|
import * as Menstrual from './menstrual';
|
||||||
import * as Medication from './medication';
|
import * as Medication from './medication';
|
||||||
import * as Mood from './mood';
|
import * as Mood from './mood';
|
||||||
import * as Personal from './personal';
|
import * as Personal from './personal';
|
||||||
@@ -15,6 +16,7 @@ export default {
|
|||||||
...Weight,
|
...Weight,
|
||||||
...Challenge,
|
...Challenge,
|
||||||
...Mood,
|
...Mood,
|
||||||
|
...Menstrual,
|
||||||
...Common,
|
...Common,
|
||||||
...Common.common, // 确保通用翻译被正确导出
|
...Common.common, // 确保通用翻译被正确导出
|
||||||
};
|
};
|
||||||
|
|||||||
37
i18n/en/menstrual.ts
Normal file
37
i18n/en/menstrual.ts
Normal file
@@ -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.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -127,6 +127,7 @@ export const statisticsCustomization = {
|
|||||||
water: 'Water Intake',
|
water: 'Water Intake',
|
||||||
basalMetabolism: 'Basal Metabolism',
|
basalMetabolism: 'Basal Metabolism',
|
||||||
oxygenSaturation: 'Oxygen Saturation',
|
oxygenSaturation: 'Oxygen Saturation',
|
||||||
|
wristTemperature: 'Wrist Temperature',
|
||||||
menstrualCycle: 'Menstrual Cycle',
|
menstrualCycle: 'Menstrual Cycle',
|
||||||
weight: 'Weight',
|
weight: 'Weight',
|
||||||
circumference: 'Circumference',
|
circumference: 'Circumference',
|
||||||
|
|||||||
@@ -143,6 +143,16 @@ export const statistics = {
|
|||||||
oxygen: {
|
oxygen: {
|
||||||
title: '血氧饱和度',
|
title: '血氧饱和度',
|
||||||
},
|
},
|
||||||
|
wristTemperature: {
|
||||||
|
title: '手腕温度',
|
||||||
|
last30Days: '最近30天',
|
||||||
|
syncing: '正在同步健康数据...',
|
||||||
|
noData: '暂无手腕温度数据',
|
||||||
|
baseline: '基线',
|
||||||
|
average: '30天均值',
|
||||||
|
latest: '最新值',
|
||||||
|
vsBaseline: '相对基线'
|
||||||
|
},
|
||||||
circumference: {
|
circumference: {
|
||||||
title: '围度 (cm)',
|
title: '围度 (cm)',
|
||||||
setTitle: '设置{{label}}',
|
setTitle: '设置{{label}}',
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import * as Challenge from './challenge';
|
|||||||
import * as Common from './common';
|
import * as Common from './common';
|
||||||
import * as Diet from './diet';
|
import * as Diet from './diet';
|
||||||
import * as Health from './health';
|
import * as Health from './health';
|
||||||
|
import * as Menstrual from './menstrual';
|
||||||
import * as Medication from './medication';
|
import * as Medication from './medication';
|
||||||
import * as Mood from './mood';
|
import * as Mood from './mood';
|
||||||
import * as Personal from './personal';
|
import * as Personal from './personal';
|
||||||
@@ -15,6 +16,7 @@ export default {
|
|||||||
...Weight,
|
...Weight,
|
||||||
...Challenge,
|
...Challenge,
|
||||||
...Mood,
|
...Mood,
|
||||||
|
...Menstrual,
|
||||||
...Common,
|
...Common,
|
||||||
...Common.common, // 确保通用翻译被正确导出
|
...Common.common, // 确保通用翻译被正确导出
|
||||||
};
|
};
|
||||||
|
|||||||
36
i18n/zh/menstrual.ts
Normal file
36
i18n/zh/menstrual.ts
Normal file
@@ -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 个周期的记录,计算平均经期和周期长度,后续会展示趋势和预测准确度。',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -127,6 +127,7 @@ export const statisticsCustomization = {
|
|||||||
water: '饮水',
|
water: '饮水',
|
||||||
basalMetabolism: '基础代谢',
|
basalMetabolism: '基础代谢',
|
||||||
oxygenSaturation: '血氧',
|
oxygenSaturation: '血氧',
|
||||||
|
wristTemperature: '手腕温度',
|
||||||
menstrualCycle: '经期',
|
menstrualCycle: '经期',
|
||||||
weight: '体重',
|
weight: '体重',
|
||||||
circumference: '围度',
|
circumference: '围度',
|
||||||
|
|||||||
@@ -43,6 +43,10 @@ RCT_EXTERN_METHOD(getOxygenSaturationSamples:(NSDictionary *)options
|
|||||||
resolver:(RCTPromiseResolveBlock)resolver
|
resolver:(RCTPromiseResolveBlock)resolver
|
||||||
rejecter:(RCTPromiseRejectBlock)rejecter)
|
rejecter:(RCTPromiseRejectBlock)rejecter)
|
||||||
|
|
||||||
|
RCT_EXTERN_METHOD(getWristTemperatureSamples:(NSDictionary *)options
|
||||||
|
resolver:(RCTPromiseResolveBlock)resolver
|
||||||
|
rejecter:(RCTPromiseRejectBlock)rejecter)
|
||||||
|
|
||||||
RCT_EXTERN_METHOD(getHeartRateSamples:(NSDictionary *)options
|
RCT_EXTERN_METHOD(getHeartRateSamples:(NSDictionary *)options
|
||||||
resolver:(RCTPromiseResolveBlock)resolver
|
resolver:(RCTPromiseResolveBlock)resolver
|
||||||
rejecter:(RCTPromiseRejectBlock)rejecter)
|
rejecter:(RCTPromiseRejectBlock)rejecter)
|
||||||
@@ -135,4 +139,17 @@ RCT_EXTERN_METHOD(saveWeight:(NSDictionary *)options
|
|||||||
resolver:(RCTPromiseResolveBlock)resolver
|
resolver:(RCTPromiseResolveBlock)resolver
|
||||||
rejecter:(RCTPromiseRejectBlock)rejecter)
|
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
|
@end
|
||||||
|
|||||||
@@ -68,6 +68,16 @@ class HealthKitManager: RCTEventEmitter {
|
|||||||
static var dateOfBirth: HKCharacteristicType {
|
static var dateOfBirth: HKCharacteristicType {
|
||||||
return HKObjectType.characteristicType(forIdentifier: .dateOfBirth)!
|
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<HKObjectType> {
|
static var all: Set<HKObjectType> {
|
||||||
var types: Set<HKObjectType> = [activitySummary, workout, dateOfBirth]
|
var types: Set<HKObjectType> = [activitySummary, workout, dateOfBirth]
|
||||||
@@ -83,6 +93,8 @@ class HealthKitManager: RCTEventEmitter {
|
|||||||
if let dietaryWater = dietaryWater { types.insert(dietaryWater) }
|
if let dietaryWater = dietaryWater { types.insert(dietaryWater) }
|
||||||
if let height = height { types.insert(height) }
|
if let height = height { types.insert(height) }
|
||||||
if let bodyMass = bodyMass { types.insert(bodyMass) }
|
if let bodyMass = bodyMass { types.insert(bodyMass) }
|
||||||
|
if let menstrualFlow = menstrualFlow { types.insert(menstrualFlow) }
|
||||||
|
if let appleSleepingWristTemperature = appleSleepingWristTemperature { types.insert(appleSleepingWristTemperature) }
|
||||||
return types
|
return types
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,6 +123,9 @@ class HealthKitManager: RCTEventEmitter {
|
|||||||
static var dietaryCarbohydrates: HKQuantityType? {
|
static var dietaryCarbohydrates: HKQuantityType? {
|
||||||
return HKObjectType.quantityType(forIdentifier: .dietaryCarbohydrates)
|
return HKObjectType.quantityType(forIdentifier: .dietaryCarbohydrates)
|
||||||
}
|
}
|
||||||
|
static var menstrualFlow: HKCategoryType? {
|
||||||
|
return HKObjectType.categoryType(forIdentifier: .menstrualFlow)
|
||||||
|
}
|
||||||
|
|
||||||
static var all: Set<HKSampleType> {
|
static var all: Set<HKSampleType> {
|
||||||
var types: Set<HKSampleType> = []
|
var types: Set<HKSampleType> = []
|
||||||
@@ -120,6 +135,7 @@ class HealthKitManager: RCTEventEmitter {
|
|||||||
if let dietaryProtein = dietaryProtein { types.insert(dietaryProtein) }
|
if let dietaryProtein = dietaryProtein { types.insert(dietaryProtein) }
|
||||||
if let dietaryFatTotal = dietaryFatTotal { types.insert(dietaryFatTotal) }
|
if let dietaryFatTotal = dietaryFatTotal { types.insert(dietaryFatTotal) }
|
||||||
if let dietaryCarbohydrates = dietaryCarbohydrates { types.insert(dietaryCarbohydrates) }
|
if let dietaryCarbohydrates = dietaryCarbohydrates { types.insert(dietaryCarbohydrates) }
|
||||||
|
if let menstrualFlow = menstrualFlow { types.insert(menstrualFlow) }
|
||||||
return types
|
return types
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -852,6 +868,86 @@ class HealthKitManager: RCTEventEmitter {
|
|||||||
healthStore.execute(query)
|
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
|
@objc
|
||||||
func getHeartRateSamples(
|
func getHeartRateSamples(
|
||||||
_ options: NSDictionary,
|
_ 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
|
// MARK: - RCTEventEmitter Overrides
|
||||||
|
|
||||||
override func supportedEvents() -> [String]! {
|
override func supportedEvents() -> [String]! {
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export const DEFAULT_TAB_CONFIGS: TabConfig[] = [
|
|||||||
{
|
{
|
||||||
id: 'statistics',
|
id: 'statistics',
|
||||||
icon: 'chart.pie.fill',
|
icon: 'chart.pie.fill',
|
||||||
titleKey: 'statistics.tabs.health',
|
titleKey: 'health.tabs.health',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
canBeDisabled: false,
|
canBeDisabled: false,
|
||||||
order: 1,
|
order: 1,
|
||||||
@@ -39,7 +39,7 @@ export const DEFAULT_TAB_CONFIGS: TabConfig[] = [
|
|||||||
{
|
{
|
||||||
id: 'medications',
|
id: 'medications',
|
||||||
icon: 'pills.fill',
|
icon: 'pills.fill',
|
||||||
titleKey: 'statistics.tabs.medications',
|
titleKey: 'health.tabs.medications',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
canBeDisabled: true, // 用药管理可以被关闭
|
canBeDisabled: true, // 用药管理可以被关闭
|
||||||
order: 2,
|
order: 2,
|
||||||
@@ -47,7 +47,7 @@ export const DEFAULT_TAB_CONFIGS: TabConfig[] = [
|
|||||||
{
|
{
|
||||||
id: 'fasting',
|
id: 'fasting',
|
||||||
icon: 'timer',
|
icon: 'timer',
|
||||||
titleKey: 'statistics.tabs.fasting',
|
titleKey: 'health.tabs.fasting',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
canBeDisabled: true, // 断食可以被关闭
|
canBeDisabled: true, // 断食可以被关闭
|
||||||
order: 3,
|
order: 3,
|
||||||
@@ -55,7 +55,7 @@ export const DEFAULT_TAB_CONFIGS: TabConfig[] = [
|
|||||||
{
|
{
|
||||||
id: 'challenges',
|
id: 'challenges',
|
||||||
icon: 'trophy.fill',
|
icon: 'trophy.fill',
|
||||||
titleKey: 'statistics.tabs.challenges',
|
titleKey: 'health.tabs.challenges',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
canBeDisabled: true, // 挑战可以被关闭
|
canBeDisabled: true, // 挑战可以被关闭
|
||||||
order: 4,
|
order: 4,
|
||||||
@@ -63,7 +63,7 @@ export const DEFAULT_TAB_CONFIGS: TabConfig[] = [
|
|||||||
{
|
{
|
||||||
id: 'personal',
|
id: 'personal',
|
||||||
icon: 'person.fill',
|
icon: 'person.fill',
|
||||||
titleKey: 'statistics.tabs.personal',
|
titleKey: 'health.tabs.personal',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
canBeDisabled: false,
|
canBeDisabled: false,
|
||||||
order: 5,
|
order: 5,
|
||||||
|
|||||||
178
utils/health.ts
178
utils/health.ts
@@ -2,6 +2,7 @@ import { CompleteSleepData, fetchCompleteSleepData } from '@/utils/sleepHealthKi
|
|||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { AppState, AppStateStatus, NativeModules } from 'react-native';
|
import { AppState, AppStateStatus, NativeModules } from 'react-native';
|
||||||
import i18n from '../i18n';
|
import i18n from '../i18n';
|
||||||
|
import { logger } from './logger';
|
||||||
import { SimpleEventEmitter } from './SimpleEventEmitter';
|
import { SimpleEventEmitter } from './SimpleEventEmitter';
|
||||||
|
|
||||||
type HealthDataOptions = {
|
type HealthDataOptions = {
|
||||||
@@ -343,6 +344,19 @@ export type TodayHealthData = {
|
|||||||
heartRate: number | null;
|
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<string, any>;
|
||||||
|
};
|
||||||
|
|
||||||
// 更新:使用新的权限管理系统
|
// 更新:使用新的权限管理系统
|
||||||
export async function ensureHealthPermissions(): Promise<boolean> {
|
export async function ensureHealthPermissions(): Promise<boolean> {
|
||||||
return await healthPermissionManager.requestPermission();
|
return await healthPermissionManager.requestPermission();
|
||||||
@@ -370,15 +384,15 @@ function createDateRange(date: Date): HealthDataOptions {
|
|||||||
|
|
||||||
// 通用错误处理
|
// 通用错误处理
|
||||||
function logError(operation: string, error: any): void {
|
function logError(operation: string, error: any): void {
|
||||||
console.error(`获取${operation}失败:`, error);
|
logger.error(`获取${operation}失败`, error);
|
||||||
}
|
}
|
||||||
|
|
||||||
function logWarning(operation: string, message: string): void {
|
function logWarning(operation: string, message: string): void {
|
||||||
console.warn(`${operation}数据${message}`);
|
logger.warn(`${operation}数据${message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function logSuccess(operation: string, data: any): void {
|
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<number | null> {
|
||||||
|
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<WristTemperatureHistoryPoint[]> {
|
||||||
|
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<string, WristTemperatureHistoryPoint> = {};
|
||||||
|
|
||||||
|
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(
|
export async function fetchHeartRateSamplesForRange(
|
||||||
startDate: Date,
|
startDate: Date,
|
||||||
endDate: Date,
|
endDate: Date,
|
||||||
@@ -1493,6 +1588,83 @@ export async function fetchHourlyStandHoursForDate(date: Date): Promise<HourlySt
|
|||||||
export { fetchCompleteSleepData };
|
export { fetchCompleteSleepData };
|
||||||
export type { CompleteSleepData };
|
export type { CompleteSleepData };
|
||||||
|
|
||||||
|
// === 经期数据相关方法 ===
|
||||||
|
|
||||||
|
export async function fetchMenstrualFlowSamples(
|
||||||
|
startDate: Date,
|
||||||
|
endDate: Date,
|
||||||
|
limit: number = 100
|
||||||
|
): Promise<MenstrualFlowSample[]> {
|
||||||
|
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<boolean> {
|
||||||
|
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<boolean> {
|
||||||
|
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<ActivityRingsData | null> {
|
export async function fetchActivityRingsForDate(date: Date): Promise<ActivityRingsData | null> {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import dayjs, { Dayjs } from 'dayjs';
|
import dayjs, { Dayjs } from 'dayjs';
|
||||||
|
import { MenstrualFlowSample } from './health';
|
||||||
|
|
||||||
export type MenstrualDayStatus = 'period' | 'predicted-period' | 'fertile' | 'ovulation-day';
|
export type MenstrualDayStatus = 'period' | 'predicted-period' | 'fertile' | 'ovulation-day';
|
||||||
|
|
||||||
@@ -54,18 +55,42 @@ const STATUS_PRIORITY: Record<MenstrualDayStatus, number> = {
|
|||||||
|
|
||||||
export const DEFAULT_CYCLE_LENGTH = 28;
|
export const DEFAULT_CYCLE_LENGTH = 28;
|
||||||
export const DEFAULT_PERIOD_LENGTH = 5;
|
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 clampCycleLength = (value: number, fallback: number) => {
|
||||||
const today = dayjs();
|
if (!Number.isFinite(value) || value <= 0) return fallback;
|
||||||
const latestStart = today.subtract(4, 'day'); // 默认让今天处于经期第5天
|
return Math.max(MIN_CYCLE_LENGTH, Math.min(MAX_CYCLE_LENGTH, Math.round(value)));
|
||||||
const previousStart = latestStart.subtract(DEFAULT_CYCLE_LENGTH, 'day');
|
};
|
||||||
const olderStart = previousStart.subtract(DEFAULT_CYCLE_LENGTH, 'day');
|
|
||||||
|
|
||||||
return [
|
const calcMedian = (values: number[]) => {
|
||||||
{ startDate: olderStart.format('YYYY-MM-DD'), periodLength: DEFAULT_PERIOD_LENGTH },
|
if (!values.length) return undefined;
|
||||||
{ startDate: previousStart.format('YYYY-MM-DD'), periodLength: DEFAULT_PERIOD_LENGTH },
|
const sorted = [...values].sort((a, b) => a - b);
|
||||||
{ startDate: latestStart.format('YYYY-MM-DD'), periodLength: DEFAULT_PERIOD_LENGTH },
|
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) => {
|
const calcAverageCycleLength = (records: CycleRecord[], fallback = DEFAULT_CYCLE_LENGTH) => {
|
||||||
@@ -81,8 +106,11 @@ const calcAverageCycleLength = (records: CycleRecord[], fallback = DEFAULT_CYCLE
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!intervals.length) return fallback;
|
if (!intervals.length) return fallback;
|
||||||
const avg = intervals.reduce((sum, cur) => sum + cur, 0) / intervals.length;
|
const recent = intervals.slice(-LOOKBACK_WINDOW);
|
||||||
return Math.round(avg);
|
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) => {
|
const calcAveragePeriodLength = (records: CycleRecord[], fallback = DEFAULT_PERIOD_LENGTH) => {
|
||||||
@@ -90,8 +118,11 @@ const calcAveragePeriodLength = (records: CycleRecord[], fallback = DEFAULT_PERI
|
|||||||
.map((r) => r.periodLength)
|
.map((r) => r.periodLength)
|
||||||
.filter((l): l is number => typeof l === 'number' && l > 0);
|
.filter((l): l is number => typeof l === 'number' && l > 0);
|
||||||
if (!lengths.length) return fallback;
|
if (!lengths.length) return fallback;
|
||||||
const avg = lengths.reduce((sum, cur) => sum + cur, 0) / lengths.length;
|
const recent = lengths.slice(-LOOKBACK_WINDOW);
|
||||||
return Math.round(avg);
|
const median = calcMedian(recent);
|
||||||
|
const trimmed = calcTrimmedMean(recent);
|
||||||
|
const blended = median ?? trimmed ?? calcTrimmedMean(lengths) ?? fallback;
|
||||||
|
return Math.max(3, Math.round(blended)); // 极端短周期时仍保持生理期合理下限
|
||||||
};
|
};
|
||||||
|
|
||||||
const addDayInfo = (
|
const addDayInfo = (
|
||||||
@@ -110,9 +141,10 @@ const addDayInfo = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getOvulationDay = (cycleStart: Dayjs, cycleLength: number) => {
|
const getOvulationDay = (cycleStart: Dayjs, cycleLength: number) => {
|
||||||
// 默认排卵日位于周期的中间偏后,兼容短/长周期
|
// 排卵日约在下次月经前 12-14 天,较稳定;对极端周期做边界约束
|
||||||
const daysFromStart = Math.max(12, Math.round(cycleLength / 2));
|
const daysFromStart = cycleLength - LUTEAL_PHASE_MEAN;
|
||||||
return cycleStart.add(daysFromStart, 'day');
|
const offset = Math.min(Math.max(daysFromStart, 11), 16);
|
||||||
|
return cycleStart.add(offset, 'day');
|
||||||
};
|
};
|
||||||
|
|
||||||
export const buildMenstrualTimeline = (options?: {
|
export const buildMenstrualTimeline = (options?: {
|
||||||
@@ -136,6 +168,23 @@ export const buildMenstrualTimeline = (options?: {
|
|||||||
options?.defaultCycleLength ?? calcAverageCycleLength(records, DEFAULT_CYCLE_LENGTH);
|
options?.defaultCycleLength ?? calcAverageCycleLength(records, DEFAULT_CYCLE_LENGTH);
|
||||||
const avgPeriodLength =
|
const avgPeriodLength =
|
||||||
options?.defaultPeriodLength ?? calcAveragePeriodLength(records, DEFAULT_PERIOD_LENGTH);
|
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) => ({
|
const cycles = records.map((record) => ({
|
||||||
start: dayjs(record.startDate),
|
start: dayjs(record.startDate),
|
||||||
@@ -144,19 +193,36 @@ export const buildMenstrualTimeline = (options?: {
|
|||||||
cycleLength: record.cycleLength ?? avgCycleLength,
|
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) {
|
if (cycles.length > 0) {
|
||||||
const lastConfirmed = cycles[cycles.length - 1];
|
const lastConfirmed = cycles[cycles.length - 1];
|
||||||
let cursorStart = lastConfirmed.start;
|
let cursorStart = lastConfirmed.start;
|
||||||
|
let nextCycleLength = predictedCycleLength;
|
||||||
|
|
||||||
while (cursorStart.isBefore(endMonth)) {
|
while (cursorStart.isBefore(endMonth)) {
|
||||||
cursorStart = cursorStart.add(avgCycleLength, 'day');
|
cursorStart = cursorStart.add(nextCycleLength, 'day');
|
||||||
cycles.push({
|
cycles.push({
|
||||||
start: cursorStart,
|
start: cursorStart,
|
||||||
confirmed: false,
|
confirmed: false,
|
||||||
periodLength: avgPeriodLength,
|
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) => {
|
cycles.forEach((cycle) => {
|
||||||
const ovulationDay = getOvulationDay(cycle.start, cycle.cycleLength);
|
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) {
|
for (let i = 0; i < cycle.periodLength; i += 1) {
|
||||||
const date = cycle.start.add(i, 'day');
|
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');
|
const date = fertileStart.add(i, 'day');
|
||||||
if (date.isBefore(startMonth) || date.isAfter(endMonth)) continue;
|
if (date.isBefore(startMonth) || date.isAfter(endMonth)) continue;
|
||||||
addDayInfo(dayMap, date, {
|
addDayInfo(dayMap, date, {
|
||||||
@@ -245,7 +313,7 @@ export const buildMenstrualTimeline = (options?: {
|
|||||||
return {
|
return {
|
||||||
months,
|
months,
|
||||||
dayMap,
|
dayMap,
|
||||||
cycleLength: avgCycleLength,
|
cycleLength: predictedCycleLength,
|
||||||
periodLength: avgPeriodLength,
|
periodLength: avgPeriodLength,
|
||||||
todayInfo: dayMap[todayKey],
|
todayInfo: dayMap[todayKey],
|
||||||
};
|
};
|
||||||
@@ -258,3 +326,62 @@ export const getMenstrualSummaryForDate = (
|
|||||||
const key = date.format('YYYY-MM-DD');
|
const key = date.format('YYYY-MM-DD');
|
||||||
return dayMap[key];
|
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;
|
||||||
|
};
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ const PREFERENCES_KEYS = {
|
|||||||
SHOW_MENSTRUAL_CYCLE_CARD: 'user_preference_show_menstrual_cycle_card',
|
SHOW_MENSTRUAL_CYCLE_CARD: 'user_preference_show_menstrual_cycle_card',
|
||||||
SHOW_WEIGHT_CARD: 'user_preference_show_weight_card',
|
SHOW_WEIGHT_CARD: 'user_preference_show_weight_card',
|
||||||
SHOW_CIRCUMFERENCE_CARD: 'user_preference_show_circumference_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',
|
STATISTICS_CARD_ORDER: 'user_preference_statistics_card_order',
|
||||||
@@ -46,6 +47,7 @@ export interface StatisticsCardsVisibility {
|
|||||||
showMenstrualCycle: boolean;
|
showMenstrualCycle: boolean;
|
||||||
showWeight: boolean;
|
showWeight: boolean;
|
||||||
showCircumference: boolean;
|
showCircumference: boolean;
|
||||||
|
showWristTemperature: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 默认卡片顺序
|
// 默认卡片顺序
|
||||||
@@ -58,6 +60,7 @@ export const DEFAULT_CARD_ORDER: string[] = [
|
|||||||
'water',
|
'water',
|
||||||
'metabolism',
|
'metabolism',
|
||||||
'oxygen',
|
'oxygen',
|
||||||
|
'temperature',
|
||||||
'menstrual',
|
'menstrual',
|
||||||
'weight',
|
'weight',
|
||||||
'circumference',
|
'circumference',
|
||||||
@@ -109,6 +112,7 @@ const DEFAULT_PREFERENCES: UserPreferences = {
|
|||||||
showMenstrualCycle: true,
|
showMenstrualCycle: true,
|
||||||
showWeight: true,
|
showWeight: true,
|
||||||
showCircumference: true,
|
showCircumference: true,
|
||||||
|
showWristTemperature: true,
|
||||||
|
|
||||||
// 默认卡片顺序
|
// 默认卡片顺序
|
||||||
cardOrder: DEFAULT_CARD_ORDER,
|
cardOrder: DEFAULT_CARD_ORDER,
|
||||||
@@ -145,6 +149,7 @@ export const getUserPreferences = async (): Promise<UserPreferences> => {
|
|||||||
const showMenstrualCycle = await AsyncStorage.getItem(PREFERENCES_KEYS.SHOW_MENSTRUAL_CYCLE_CARD);
|
const showMenstrualCycle = await AsyncStorage.getItem(PREFERENCES_KEYS.SHOW_MENSTRUAL_CYCLE_CARD);
|
||||||
const showWeight = await AsyncStorage.getItem(PREFERENCES_KEYS.SHOW_WEIGHT_CARD);
|
const showWeight = await AsyncStorage.getItem(PREFERENCES_KEYS.SHOW_WEIGHT_CARD);
|
||||||
const showCircumference = await AsyncStorage.getItem(PREFERENCES_KEYS.SHOW_CIRCUMFERENCE_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 cardOrderStr = await AsyncStorage.getItem(PREFERENCES_KEYS.STATISTICS_CARD_ORDER);
|
||||||
const cardOrder = cardOrderStr ? JSON.parse(cardOrderStr) : DEFAULT_PREFERENCES.cardOrder;
|
const cardOrder = cardOrderStr ? JSON.parse(cardOrderStr) : DEFAULT_PREFERENCES.cardOrder;
|
||||||
|
|
||||||
@@ -174,6 +179,7 @@ export const getUserPreferences = async (): Promise<UserPreferences> => {
|
|||||||
showMenstrualCycle: showMenstrualCycle !== null ? showMenstrualCycle === 'true' : DEFAULT_PREFERENCES.showMenstrualCycle,
|
showMenstrualCycle: showMenstrualCycle !== null ? showMenstrualCycle === 'true' : DEFAULT_PREFERENCES.showMenstrualCycle,
|
||||||
showWeight: showWeight !== null ? showWeight === 'true' : DEFAULT_PREFERENCES.showWeight,
|
showWeight: showWeight !== null ? showWeight === 'true' : DEFAULT_PREFERENCES.showWeight,
|
||||||
showCircumference: showCircumference !== null ? showCircumference === 'true' : DEFAULT_PREFERENCES.showCircumference,
|
showCircumference: showCircumference !== null ? showCircumference === 'true' : DEFAULT_PREFERENCES.showCircumference,
|
||||||
|
showWristTemperature: showWristTemperature !== null ? showWristTemperature === 'true' : DEFAULT_PREFERENCES.showWristTemperature,
|
||||||
cardOrder,
|
cardOrder,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -611,6 +617,7 @@ export const getStatisticsCardsVisibility = async (): Promise<StatisticsCardsVis
|
|||||||
showMenstrualCycle: userPreferences.showMenstrualCycle,
|
showMenstrualCycle: userPreferences.showMenstrualCycle,
|
||||||
showWeight: userPreferences.showWeight,
|
showWeight: userPreferences.showWeight,
|
||||||
showCircumference: userPreferences.showCircumference,
|
showCircumference: userPreferences.showCircumference,
|
||||||
|
showWristTemperature: userPreferences.showWristTemperature,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('获取首页卡片显示设置失败:', error);
|
console.error('获取首页卡片显示设置失败:', error);
|
||||||
@@ -626,6 +633,7 @@ export const getStatisticsCardsVisibility = async (): Promise<StatisticsCardsVis
|
|||||||
showMenstrualCycle: DEFAULT_PREFERENCES.showMenstrualCycle,
|
showMenstrualCycle: DEFAULT_PREFERENCES.showMenstrualCycle,
|
||||||
showWeight: DEFAULT_PREFERENCES.showWeight,
|
showWeight: DEFAULT_PREFERENCES.showWeight,
|
||||||
showCircumference: DEFAULT_PREFERENCES.showCircumference,
|
showCircumference: DEFAULT_PREFERENCES.showCircumference,
|
||||||
|
showWristTemperature: DEFAULT_PREFERENCES.showWristTemperature,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -673,6 +681,7 @@ export const setStatisticsCardVisibility = async (key: keyof StatisticsCardsVisi
|
|||||||
case 'showMenstrualCycle': storageKey = PREFERENCES_KEYS.SHOW_MENSTRUAL_CYCLE_CARD; break;
|
case 'showMenstrualCycle': storageKey = PREFERENCES_KEYS.SHOW_MENSTRUAL_CYCLE_CARD; break;
|
||||||
case 'showWeight': storageKey = PREFERENCES_KEYS.SHOW_WEIGHT_CARD; break;
|
case 'showWeight': storageKey = PREFERENCES_KEYS.SHOW_WEIGHT_CARD; break;
|
||||||
case 'showCircumference': storageKey = PREFERENCES_KEYS.SHOW_CIRCUMFERENCE_CARD; break;
|
case 'showCircumference': storageKey = PREFERENCES_KEYS.SHOW_CIRCUMFERENCE_CARD; break;
|
||||||
|
case 'showWristTemperature': storageKey = PREFERENCES_KEYS.SHOW_WRIST_TEMPERATURE_CARD; break;
|
||||||
default: return;
|
default: return;
|
||||||
}
|
}
|
||||||
await AsyncStorage.setItem(storageKey, value.toString());
|
await AsyncStorage.setItem(storageKey, value.toString());
|
||||||
|
|||||||
Reference in New Issue
Block a user