4 Commits

Author SHA1 Message Date
richarjiang
feb5052fcd feat(i18n): 增强生理周期模块的国际化支持,添加多语言格式和翻译 2025-12-18 09:36:08 +08:00
richarjiang
4836058d56 feat(health): 新增手腕温度监测和经期双向同步功能
新增手腕温度健康数据追踪,支持Apple Watch睡眠手腕温度数据展示和30天历史趋势分析
实现经期数据与HealthKit的完整双向同步,支持读取、写入和删除经期记录
优化经期预测算法,基于历史数据计算更准确的周期和排卵日预测
重构经期UI组件为模块化结构,提升代码可维护性
添加完整的中英文国际化支持,覆盖所有新增功能界面
2025-12-18 08:40:08 +08:00
richarjiang
9b4a300380 feat(app): 新增生理周期记录功能与首页卡片自定义支持
- 新增生理周期追踪页面及相关算法逻辑,支持经期记录与预测
- 新增首页统计卡片自定义页面,支持VIP用户调整卡片显示状态与顺序
- 重构首页统计页面布局逻辑,支持动态渲染与混合布局
- 引入 react-native-draggable-flatlist 用于实现拖拽排序功能
- 添加相关多语言配置及用户偏好设置存储接口
2025-12-16 17:25:21 +08:00
richarjiang
5e11da34ee feat(app): 新增HRV压力提醒设置与锻炼记录分享功能
- 通知设置页面新增 HRV 压力提醒开关,支持自定义开启或关闭压力监测推送
- 锻炼详情页集成分享功能,支持将运动数据生成精美长图并分享
- 优化 HRV 监测服务逻辑,在发送通知前检查用户偏好设置
- 更新多语言配置文件,添加相关文案翻译
- 将应用版本号更新至 1.1.5
2025-12-16 11:27:11 +08:00
39 changed files with 4500 additions and 241 deletions

View File

@@ -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 教练对话能力和分析精度
### 待解决问题 ### 待解决问题

View File

@@ -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. **用户体验**:优秀的界面设计和交互体验

View File

@@ -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 文档**: 接口文档

View File

@@ -2,7 +2,7 @@
"expo": { "expo": {
"name": "Out Live", "name": "Out Live",
"slug": "digital-pilates", "slug": "digital-pilates",
"version": "1.1.4", "version": "1.1.5",
"orientation": "portrait", "orientation": "portrait",
"scheme": "digitalpilates", "scheme": "digitalpilates",
"userInterfaceStyle": "light", "userInterfaceStyle": "light",

View File

@@ -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';

View File

@@ -1,11 +1,13 @@
import { BasalMetabolismCard } from '@/components/BasalMetabolismCard'; import { BasalMetabolismCard } from '@/components/BasalMetabolismCard';
import { DateSelector } from '@/components/DateSelector'; import { DateSelector } from '@/components/DateSelector';
import { FitnessRingsCard } from '@/components/FitnessRingsCard'; import { FitnessRingsCard } from '@/components/FitnessRingsCard';
import { MenstrualCycleCard } from '@/components/MenstrualCycleCard';
import { MoodCard } from '@/components/MoodCard'; import { MoodCard } from '@/components/MoodCard';
import { NutritionRadarCard } from '@/components/NutritionRadarCard'; 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';
@@ -22,13 +24,14 @@ import { fetchTodayWaterStats } from '@/store/waterSlice';
import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date'; import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date';
import { fetchHealthDataForDate } from '@/utils/health'; import { fetchHealthDataForDate } from '@/utils/health';
import { logger } from '@/utils/logger'; import { logger } from '@/utils/logger';
import { DEFAULT_CARD_ORDER, getStatisticsCardOrder, getStatisticsCardsVisibility, StatisticsCardsVisibility } from '@/utils/userPreferences';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect'; import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import { useRouter } from 'expo-router'; import { useFocusEffect, useRouter } from 'expo-router';
import { debounce } from 'lodash'; import { debounce } from 'lodash';
import React, { useEffect, useMemo, useRef, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
AppState, AppState,
@@ -90,9 +93,50 @@ export default function ExploreScreen() {
router.push('/gallery'); router.push('/gallery');
}, [ensureLoggedIn, router]); }, [ensureLoggedIn, router]);
const handleOpenCustomization = React.useCallback(() => {
router.push('/statistics-customization');
}, [router]);
// 用于触发动画重置的 token当日期或数据变化时更新 // 用于触发动画重置的 token当日期或数据变化时更新
const [animToken, setAnimToken] = useState(0); const [animToken, setAnimToken] = useState(0);
// 首页卡片显示设置
const [cardVisibility, setCardVisibility] = useState<StatisticsCardsVisibility>({
showMood: true,
showSteps: true,
showStress: true,
showSleep: true,
showFitnessRings: true,
showWater: true,
showBasalMetabolism: true,
showOxygenSaturation: true,
showWristTemperature: true,
showMenstrualCycle: true,
showWeight: true,
showCircumference: true,
});
const [cardOrder, setCardOrder] = useState<string[]>(DEFAULT_CARD_ORDER);
// 加载卡片设置
const loadSettings = useCallback(async () => {
try {
const [visibility, order] = await Promise.all([
getStatisticsCardsVisibility(),
getStatisticsCardOrder(),
]);
setCardVisibility(visibility);
setCardOrder(order);
} catch (error) {
console.error('Failed to load card settings:', error);
}
}, []);
// 页面聚焦时加载设置
useFocusEffect(
useCallback(() => {
loadSettings();
}, [loadSettings])
);
// 心情相关状态 // 心情相关状态
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
@@ -401,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}
@@ -423,6 +467,26 @@ export default function ExploreScreen() {
</View> </View>
<View style={styles.headerActions}> <View style={styles.headerActions}>
<TouchableOpacity
activeOpacity={0.85}
onPress={handleOpenCustomization}
>
{isLiquidGlassAvailable() ? (
<GlassView
style={styles.liquidGlassButton}
glassEffectStyle="regular"
tintColor="rgba(255, 255, 255, 0.3)"
isInteractive={true}
>
<Ionicons name="options-outline" size={20} color="#0F172A" />
</GlassView>
) : (
<View style={[styles.liquidGlassButton, styles.liquidGlassFallback]}>
<Ionicons name="options-outline" size={20} color="#0F172A" />
</View>
)}
</TouchableOpacity>
<TouchableOpacity <TouchableOpacity
activeOpacity={0.85} activeOpacity={0.85}
onPress={handleOpenGallery} onPress={handleOpenGallery}
@@ -476,90 +540,183 @@ export default function ExploreScreen() {
<Text style={styles.sectionTitle}>{t('statistics.sections.bodyMetrics')}</Text> <Text style={styles.sectionTitle}>{t('statistics.sections.bodyMetrics')}</Text>
</View> </View>
{/* 真正瀑布流布局 */} {/* 动态布局:支持混合瀑布流和全宽卡片 */}
<View style={styles.masonryContainer}> <View style={styles.layoutContainer}>
{/* 左列 */} {(() => {
<View style={styles.masonryColumn}> // 定义所有卡片及其显示状态
{/* 心情卡片 */} const allCardsMap: Record<string, any> = {
<FloatingCard style={styles.masonryCard}> mood: {
<MoodCard visible: cardVisibility.showMood,
moodCheckin={currentMoodCheckin} component: (
onPress={() => pushIfAuthedElseLogin('/mood/calendar')} <MoodCard
isLoading={isMoodLoading} moodCheckin={currentMoodCheckin}
/> onPress={() => pushIfAuthedElseLogin('/mood/calendar')}
</FloatingCard> isLoading={isMoodLoading}
/>
)
},
steps: {
visible: cardVisibility.showSteps,
component: (
<StepsCard
curDate={currentSelectedDate}
stepGoal={stepGoal}
style={styles.stepsCardOverride}
/>
)
},
stress: {
visible: cardVisibility.showStress,
component: (
<StressMeter
curDate={currentSelectedDate}
/>
)
},
sleep: {
visible: cardVisibility.showSleep,
component: (
<SleepCard
selectedDate={currentSelectedDate}
/>
)
},
fitness: {
visible: cardVisibility.showFitnessRings,
component: (
<FitnessRingsCard
selectedDate={currentSelectedDate}
resetToken={animToken}
/>
)
},
water: {
visible: cardVisibility.showWater,
component: (
<WaterIntakeCard
selectedDate={currentSelectedDateString}
style={styles.waterCardOverride}
/>
)
},
metabolism: {
visible: cardVisibility.showBasalMetabolism,
component: (
<BasalMetabolismCard
selectedDate={currentSelectedDate}
style={styles.basalMetabolismCardOverride}
/>
)
},
oxygen: {
visible: cardVisibility.showOxygenSaturation,
component: (
<OxygenSaturationCard
selectedDate={currentSelectedDate}
style={styles.basalMetabolismCardOverride}
/>
)
},
temperature: {
visible: cardVisibility.showWristTemperature,
component: (
<WristTemperatureCard
selectedDate={currentSelectedDate}
style={styles.basalMetabolismCardOverride}
/>
)
},
menstrual: {
visible: cardVisibility.showMenstrualCycle,
component: (
<MenstrualCycleCard
onPress={() => pushIfAuthedElseLogin('/menstrual-cycle')}
/>
)
},
weight: {
visible: cardVisibility.showWeight,
isFullWidth: true,
component: (
<WeightHistoryCard />
)
},
circumference: {
visible: cardVisibility.showCircumference,
isFullWidth: true,
component: (
<CircumferenceCard style={{ marginBottom: 0, marginTop: 16 }} />
)
}
};
<FloatingCard style={styles.masonryCard}> const allKeys = Object.keys(allCardsMap);
<StepsCard const sortedKeys = Array.from(new Set([...cardOrder, ...allKeys]))
curDate={currentSelectedDate} .filter(key => allCardsMap[key]);
stepGoal={stepGoal}
style={styles.stepsCardOverride}
/>
</FloatingCard>
const visibleCards = sortedKeys
.map(key => ({ id: key, ...allCardsMap[key] }))
.filter(card => card.visible);
<FloatingCard style={styles.masonryCard}> // 分组逻辑:将连续的瀑布流卡片聚合,全宽卡片单独作为一组
<StressMeter const blocks: any[] = [];
curDate={currentSelectedDate} let currentMasonryBlock: any[] = [];
/>
</FloatingCard>
{/* 心率卡片 */} visibleCards.forEach(card => {
{/* <FloatingCard style={styles.masonryCard} delay={2000}> if (card.isFullWidth) {
<HeartRateCard // 如果有未处理的瀑布流卡片,先结算
resetToken={animToken} if (currentMasonryBlock.length > 0) {
style={styles.basalMetabolismCardOverride} blocks.push({ type: 'masonry', items: [...currentMasonryBlock] });
heartRate={heartRate} currentMasonryBlock = [];
/> }
</FloatingCard> */} // 添加全宽卡片
blocks.push({ type: 'full', item: card });
} else {
// 添加到当前瀑布流组
currentMasonryBlock.push(card);
}
});
<FloatingCard style={styles.masonryCard}> // 结算剩余的瀑布流卡片
<SleepCard if (currentMasonryBlock.length > 0) {
selectedDate={currentSelectedDate} blocks.push({ type: 'masonry', items: [...currentMasonryBlock] });
/> }
</FloatingCard>
</View>
{/* 右列 */} return blocks.map((block, blockIndex) => {
<View style={styles.masonryColumn}> if (block.type === 'full') {
<FloatingCard style={styles.masonryCard}> return (
<FitnessRingsCard <View key={`block-${blockIndex}-${block.item.id}`}>
selectedDate={currentSelectedDate} {block.item.component}
resetToken={animToken} </View>
/> );
</FloatingCard> } else {
{/* 饮水记录卡片 */} // 渲染瀑布流块
<FloatingCard style={styles.masonryCard}> const leftColumnCards = block.items.filter((_: any, index: number) => index % 2 === 0);
<WaterIntakeCard const rightColumnCards = block.items.filter((_: any, index: number) => index % 2 !== 0);
selectedDate={currentSelectedDateString}
style={styles.waterCardOverride}
/>
</FloatingCard>
return (
{/* 基础代谢卡片 */} <View key={`block-${blockIndex}-masonry`} style={styles.masonryContainer}>
<FloatingCard style={styles.masonryCard}> <View style={styles.masonryColumn}>
<BasalMetabolismCard {leftColumnCards.map((card: any) => (
selectedDate={currentSelectedDate} <FloatingCard key={card.id} style={styles.masonryCard}>
style={styles.basalMetabolismCardOverride} {card.component}
/> </FloatingCard>
</FloatingCard> ))}
</View>
{/* 血氧饱和度卡片 */} <View style={styles.masonryColumn}>
<FloatingCard style={styles.masonryCard}> {rightColumnCards.map((card: any) => (
<OxygenSaturationCard <FloatingCard key={card.id} style={styles.masonryCard}>
selectedDate={currentSelectedDate} {card.component}
style={styles.basalMetabolismCardOverride} </FloatingCard>
/> ))}
</FloatingCard> </View>
</View>
);
</View> }
});
})()}
</View> </View>
<WeightHistoryCard />
{/* 围度数据卡片 - 占满底部一行 */}
<CircumferenceCard style={styles.circumferenceCard} />
</ScrollView> </ScrollView>
</View> </View>
@@ -868,6 +1025,9 @@ const styles = StyleSheet.create({
justifyContent: 'space-between', justifyContent: 'space-between',
marginBottom: 16, marginBottom: 16,
}, },
layoutContainer: {
flex: 1,
},
masonryContainer: { masonryContainer: {
flexDirection: 'row', flexDirection: 'row',
gap: 16, gap: 16,

554
app/menstrual-cycle.tsx Normal file
View File

@@ -0,0 +1,554 @@
import dayjs from 'dayjs';
import { LinearGradient } from 'expo-linear-gradient';
import { Stack, useRouter } from 'expo-router';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
FlatList,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native';
import { InlineTip, ITEM_HEIGHT, Legend, MonthBlock } from '@/components/menstrual-cycle';
import { HeaderBar } from '@/components/ui/HeaderBar';
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
import {
deleteMenstrualFlow,
fetchMenstrualFlowSamples,
saveMenstrualFlow
} from '@/utils/health';
import {
buildMenstrualTimeline,
convertHealthKitSamplesToCycleRecords,
CycleRecord,
DEFAULT_PERIOD_LENGTH
} from '@/utils/menstrualCycle';
type TabKey = 'cycle' | 'analysis';
export default function MenstrualCycleScreen() {
const router = useRouter();
const { t, i18n } = useTranslation();
const safeAreaTop = useSafeAreaTop();
const [records, setRecords] = useState<CycleRecord[]>([]);
const [windowConfig, setWindowConfig] = useState({ before: 2, after: 3 });
const locale = i18n.language.startsWith('en') ? 'en' : 'zh';
const monthTitleFormat = t('menstrual.dateFormats.monthTitle', { defaultValue: 'M月' });
const monthSubtitleFormat = t('menstrual.dateFormats.monthSubtitle', { defaultValue: 'YYYY年' });
const weekLabels = useMemo(() => {
const labels = t('menstrual.weekdays', { returnObjects: true }) as string[];
return Array.isArray(labels) && labels.length === 7 ? labels : undefined;
}, [t]);
// 从 HealthKit 拉取当前窗口范围内的经期数据
useEffect(() => {
const loadData = async () => {
// 根据 windowConfig 计算需要拉取的月份区间
const today = dayjs();
const startDate = today.subtract(windowConfig.before, 'month').startOf('month').toDate();
const endDate = today.add(windowConfig.after, 'month').endOf('month').toDate();
const samples = await fetchMenstrualFlowSamples(startDate, endDate);
const convertedRecords = convertHealthKitSamplesToCycleRecords(samples);
setRecords(convertedRecords);
};
loadData();
}, [windowConfig]);
// 根据记录生成时间轴(包含预测周期、易孕期等)
const timeline = useMemo(
() =>
buildMenstrualTimeline({
monthsBefore: windowConfig.before,
monthsAfter: windowConfig.after,
records,
defaultPeriodLength: DEFAULT_PERIOD_LENGTH,
locale,
monthTitleFormat,
monthSubtitleFormat,
}),
[records, windowConfig, locale, monthSubtitleFormat, monthTitleFormat]
);
const [activeTab, setActiveTab] = useState<TabKey>('cycle');
const [selectedDateKey, setSelectedDateKey] = useState(
dayjs().format('YYYY-MM-DD')
);
const listRef = useRef<FlatList>(null);
const offsetRef = useRef(0);
const prependDeltaRef = useRef(0);
const loadingPrevRef = useRef(false);
const hasAutoScrolledRef = useRef(false);
const todayMonthId = useMemo(() => dayjs().format('YYYY-MM'), []);
const selectedInfo = timeline.dayMap[selectedDateKey];
const selectedDate = dayjs(selectedDateKey);
const initialMonthIndex = useMemo(
() => timeline.months.findIndex((month) => month.id === todayMonthId),
[timeline.months, todayMonthId]
);
useEffect(() => {
if (hasAutoScrolledRef.current) return;
if (initialMonthIndex < 0 || !listRef.current) return;
hasAutoScrolledRef.current = true;
offsetRef.current = initialMonthIndex * ITEM_HEIGHT;
requestAnimationFrame(() => {
listRef.current?.scrollToIndex({ index: initialMonthIndex, animated: false });
});
}, [initialMonthIndex]);
// 标记当天为经期开始(包含乐观更新与 HealthKit 同步)
const handleMarkStart = async () => {
if (selectedDate.isAfter(dayjs(), 'day')) return;
// Check if the selected date is already covered
const isCovered = records.some((r) => {
const start = dayjs(r.startDate);
const end = start.add((r.periodLength ?? DEFAULT_PERIOD_LENGTH) - 1, 'day');
return (
(selectedDate.isSame(start, 'day') || selectedDate.isAfter(start, 'day')) &&
(selectedDate.isSame(end, 'day') || selectedDate.isBefore(end, 'day'))
);
});
if (isCovered) return;
// Optimistic Update
const originalRecords = [...records];
setRecords((prev) => {
const updated = [...prev];
// Logic for optimistic UI update (same as original logic)
const prevRecordIndex = updated.findIndex((r) => {
const start = dayjs(r.startDate);
const end = start.add((r.periodLength ?? DEFAULT_PERIOD_LENGTH) - 1, 'day');
return end.add(1, 'day').isSame(selectedDate, 'day');
});
const nextRecordIndex = updated.findIndex((r) => {
return dayjs(r.startDate).subtract(1, 'day').isSame(selectedDate, 'day');
});
if (prevRecordIndex !== -1 && nextRecordIndex !== -1) {
const prevRecord = updated[prevRecordIndex];
const nextRecord = updated[nextRecordIndex];
const newLength =
(prevRecord.periodLength ?? DEFAULT_PERIOD_LENGTH) +
1 +
(nextRecord.periodLength ?? DEFAULT_PERIOD_LENGTH);
updated[prevRecordIndex] = { ...prevRecord, periodLength: newLength };
updated.splice(nextRecordIndex, 1);
} else if (prevRecordIndex !== -1) {
const prevRecord = updated[prevRecordIndex];
updated[prevRecordIndex] = {
...prevRecord,
periodLength: (prevRecord.periodLength ?? DEFAULT_PERIOD_LENGTH) + 1,
};
} else if (nextRecordIndex !== -1) {
const nextRecord = updated[nextRecordIndex];
updated[nextRecordIndex] = {
...nextRecord,
startDate: selectedDate.format('YYYY-MM-DD'),
periodLength: (nextRecord.periodLength ?? DEFAULT_PERIOD_LENGTH) + 1,
};
} else {
const newRecord: CycleRecord = {
startDate: selectedDate.format('YYYY-MM-DD'),
periodLength: 7,
source: 'manual',
};
updated.push(newRecord);
}
return updated.sort((a, b) => dayjs(a.startDate).valueOf() - dayjs(b.startDate).valueOf());
});
try {
// Determine what to save to HealthKit
// If we are merging or extending, we are effectively adding one day of flow
// If we are creating a new record, we default to 7 days
// However, accurate HealthKit logging should be per day.
// The previous UI logic "creates" a 7-day period for a single tap.
// We should replicate this behavior in HealthKit for consistency.
const isNewIsolatedRecord = !records.some((r) => {
const start = dayjs(r.startDate);
const end = start.add((r.periodLength ?? DEFAULT_PERIOD_LENGTH) - 1, 'day');
// Check adjacency
return (
end.add(1, 'day').isSame(selectedDate, 'day') ||
dayjs(r.startDate).subtract(1, 'day').isSame(selectedDate, 'day')
);
});
if (isNewIsolatedRecord) {
// Save 7 days of flow starting from selectedDate
const promises = [];
for (let i = 0; i < 7; i++) {
const date = selectedDate.add(i, 'day');
// Don't save future dates if they exceed today (though logic allows predicting)
// But for flow logging, we usually only log past/present.
// However, UI allows setting a period that might extend slightly?
// Let's stick to the selected date logic.
// Wait, if I tap "Mark Start", it creates a 7 day period.
// Should I write 7 samples? Yes, to match the UI state.
promises.push(saveMenstrualFlow(date.toDate(), 1, i === 0)); // 1=unspecified
}
await Promise.all(promises);
} else {
// Just adding a single day to bridge/extend
await saveMenstrualFlow(selectedDate.toDate(), 1, false);
}
} catch (error) {
console.error('Failed to save to HealthKit', error);
// Revert optimistic update
setRecords(originalRecords);
}
};
// 取消选中日期的经期标记(与 HealthKit 同步)
const handleCancelMark = async () => {
if (!selectedInfo || !selectedInfo.confirmed) return;
if (selectedDate.isAfter(dayjs(), 'day')) return;
const target = selectedDate;
// Optimistic Update
const originalRecords = [...records];
setRecords((prev) => {
const updated: CycleRecord[] = [];
prev.forEach((record) => {
const start = dayjs(record.startDate);
const periodLength = record.periodLength ?? DEFAULT_PERIOD_LENGTH;
const diff = target.diff(start, 'day');
if (diff < 0 || diff >= periodLength) {
updated.push(record);
return;
}
if (diff === 0) return; // Remove entire record (or start of it)
updated.push({ ...record, periodLength: diff }); // Shorten it
});
return updated;
});
try {
// Logic:
// 1. Find the record covering the target date
const record = records.find((r) => {
const start = dayjs(r.startDate);
const end = start.add((r.periodLength ?? DEFAULT_PERIOD_LENGTH) - 1, 'day');
return (
(target.isSame(start, 'day') || target.isAfter(start, 'day')) &&
(target.isSame(end, 'day') || target.isBefore(end, 'day'))
);
});
if (record) {
const start = dayjs(record.startDate);
const diff = target.diff(start, 'day');
if (diff === 0) {
// If cancelling the start date, the UI removes the ENTIRE period record.
// So we should delete all samples for this period range.
const periodLength = record.periodLength ?? DEFAULT_PERIOD_LENGTH;
const endDate = start.add(periodLength - 1, 'day');
await deleteMenstrualFlow(start.toDate(), endDate.toDate());
} else {
// If cancelling a middle/end date, the UI shortens the period to end BEFORE target.
// So we delete from target date onwards to the original end date.
const periodLength = record.periodLength ?? DEFAULT_PERIOD_LENGTH;
const originalEnd = start.add(periodLength - 1, 'day');
// Delete from target to originalEnd
await deleteMenstrualFlow(target.toDate(), originalEnd.toDate());
}
}
} catch (error) {
console.error('Failed to delete from HealthKit', error);
setRecords(originalRecords);
}
};
// 下拉到顶部时加载更早的月份
const handleLoadPrevious = () => {
if (loadingPrevRef.current) return;
loadingPrevRef.current = true;
const delta = 3;
prependDeltaRef.current = delta;
setWindowConfig((prev) => ({ ...prev, before: prev.before + delta }));
};
// 向前追加月份时,保持当前视口位置不跳动
useEffect(() => {
if (prependDeltaRef.current > 0 && listRef.current) {
const offset = offsetRef.current + prependDeltaRef.current * ITEM_HEIGHT;
requestAnimationFrame(() => {
listRef.current?.scrollToOffset({ offset, animated: false });
prependDeltaRef.current = 0;
loadingPrevRef.current = false;
});
}
}, [timeline.months.length]);
const viewabilityConfig = useRef({
viewAreaCoveragePercentThreshold: 10,
}).current;
// 监测可视区域,接近顶部时触发加载更早月份
const onViewableItemsChanged = useRef(({ viewableItems }: any) => {
const minIndex = viewableItems.reduce(
(acc: number, cur: any) => Math.min(acc, cur.index ?? acc),
Number.MAX_SAFE_INTEGER
);
if (minIndex <= 1) {
handleLoadPrevious();
}
}).current;
// FlatList 数据源:按月份拆分
const listData = useMemo(() => {
return timeline.months.map((m) => ({
type: 'month' as const,
id: m.id,
month: m,
}));
}, [timeline.months]);
const renderInlineTip = (columnIndex: number) => (
<InlineTip
selectedDate={selectedDate}
selectedInfo={selectedInfo}
columnIndex={columnIndex}
onMarkStart={handleMarkStart}
onCancelMark={handleCancelMark}
/>
);
const renderCycleTab = () => (
<View style={styles.tabContent}>
<Legend />
<FlatList
ref={listRef}
data={listData}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<MonthBlock
month={item.month}
selectedDateKey={selectedDateKey}
onSelect={(key) => setSelectedDateKey(key)}
renderTip={renderInlineTip}
weekLabels={weekLabels}
/>
)}
showsVerticalScrollIndicator={false}
initialNumToRender={3}
windowSize={5}
maxToRenderPerBatch={4}
removeClippedSubviews
contentContainerStyle={styles.listContent}
// 使用固定高度优化初始滚动定位
getItemLayout={(_, index) => ({
length: ITEM_HEIGHT,
offset: ITEM_HEIGHT * index,
index,
})}
initialScrollIndex={initialMonthIndex >= 0 ? initialMonthIndex : undefined}
onScrollToIndexFailed={({ index }) => {
listRef.current?.scrollToOffset({
offset: ITEM_HEIGHT * index,
animated: false,
});
}}
viewabilityConfig={viewabilityConfig}
onViewableItemsChanged={onViewableItemsChanged}
onScroll={(e) => {
offsetRef.current = e.nativeEvent.contentOffset.y;
}}
scrollEventThrottle={16}
/>
</View>
);
const renderAnalysisTab = () => (
<View style={styles.tabContent}>
<View style={styles.analysisCard}>
<Text style={styles.analysisTitle}>{t('menstrual.screen.analysis.title')}</Text>
<Text style={styles.analysisBody}>
{t('menstrual.screen.analysis.description')}
</Text>
</View>
</View>
);
return (
<View style={styles.container}>
<Stack.Screen options={{ headerShown: false }} />
<LinearGradient
colors={['#fdf1ff', '#f3f4ff', '#f7f8ff']}
style={StyleSheet.absoluteFill}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
/>
<HeaderBar
title={t('menstrual.screen.header')}
onBack={() => router.back()}
// right={
// isLiquidGlassAvailable() ? (
// <TouchableOpacity style={styles.headerIconButton} activeOpacity={0.7}>
// <GlassView
// style={styles.headerIconGlass}
// glassEffectStyle="clear"
// tintColor="rgba(255, 255, 255, 0.35)"
// isInteractive={true}
// >
// <Ionicons name="settings-outline" size={20} color="#0f172a" />
// </GlassView>
// </TouchableOpacity>
// ) : (
// <TouchableOpacity style={styles.headerIcon} activeOpacity={0.7}>
// <Ionicons name="settings-outline" size={20} color="#0f172a" />
// </TouchableOpacity>
// )
// }
/>
<View style={{ height: safeAreaTop }} />
<View style={styles.tabSwitcher}>
{([
{ key: 'cycle', label: t('menstrual.screen.tabs.cycle') },
{ key: 'analysis', label: t('menstrual.screen.tabs.analysis') },
] as { key: TabKey; label: string }[]).map((tab) => {
const active = activeTab === tab.key;
return (
<TouchableOpacity
key={tab.key}
style={[styles.tabPill, active && styles.tabPillActive]}
onPress={() => setActiveTab(tab.key)}
activeOpacity={0.9}
>
<Text style={[styles.tabLabel, active && styles.tabLabelActive]}>
{tab.label}
</Text>
</TouchableOpacity>
);
})}
</View>
{activeTab === 'cycle' ? renderCycleTab() : renderAnalysisTab()}
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
paddingHorizontal: 16,
backgroundColor: 'transparent',
},
headerIcon: {
width: 36,
height: 36,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(255, 255, 255, 0.5)',
},
headerIconButton: {
width: 36,
height: 36,
borderRadius: 18,
overflow: 'hidden',
},
headerIconGlass: {
width: 36,
height: 36,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
},
tabSwitcher: {
flexDirection: 'row',
backgroundColor: 'rgba(255,255,255,0.7)',
borderRadius: 18,
padding: 4,
marginBottom: 16,
},
tabPill: {
flex: 1,
alignItems: 'center',
paddingVertical: 10,
borderRadius: 14,
},
tabPillActive: {
backgroundColor: '#fff',
shadowColor: '#000',
shadowOpacity: 0.08,
shadowOffset: { width: 0, height: 8 },
shadowRadius: 10,
elevation: 3,
},
tabLabel: {
color: '#4b5563',
fontWeight: '600',
fontFamily: 'AliRegular',
},
tabLabelActive: {
color: '#0f172a',
fontFamily: 'AliBold',
},
tabContent: {
flex: 1,
},
selectedCard: {
backgroundColor: '#fff',
borderRadius: 16,
paddingVertical: 10,
paddingHorizontal: 12,
marginBottom: 10,
shadowColor: '#000',
shadowOpacity: 0.08,
shadowRadius: 10,
shadowOffset: { width: 0, height: 8 },
elevation: 3,
},
selectedStatus: {
fontSize: 14,
color: '#111827',
fontWeight: '700',
fontFamily: 'AliBold',
},
listContent: {
paddingBottom: 80,
},
analysisCard: {
backgroundColor: '#fff',
borderRadius: 16,
padding: 16,
marginTop: 8,
shadowColor: '#000',
shadowOpacity: 0.08,
shadowRadius: 10,
shadowOffset: { width: 0, height: 6 },
elevation: 3,
},
analysisTitle: {
fontSize: 17,
fontWeight: '800',
color: '#0f172a',
marginBottom: 8,
fontFamily: 'AliBold',
},
analysisBody: {
fontSize: 14,
color: '#6b7280',
lineHeight: 20,
fontFamily: 'AliRegular',
},
});

View File

@@ -8,10 +8,12 @@ import {
getMoodReminderEnabled, getMoodReminderEnabled,
getNotificationEnabled, getNotificationEnabled,
getNutritionReminderEnabled, getNutritionReminderEnabled,
getHRVReminderEnabled,
setMedicationReminderEnabled, setMedicationReminderEnabled,
setMoodReminderEnabled, setMoodReminderEnabled,
setNotificationEnabled, setNotificationEnabled,
setNutritionReminderEnabled setNutritionReminderEnabled,
setHRVReminderEnabled
} from '@/utils/userPreferences'; } from '@/utils/userPreferences';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
@@ -29,21 +31,24 @@ export default function NotificationSettingsScreen() {
const [medicationReminderEnabled, setMedicationReminderEnabledState] = useState(false); const [medicationReminderEnabled, setMedicationReminderEnabledState] = useState(false);
const [nutritionReminderEnabled, setNutritionReminderEnabledState] = useState(false); const [nutritionReminderEnabled, setNutritionReminderEnabledState] = useState(false);
const [moodReminderEnabled, setMoodReminderEnabledState] = useState(false); const [moodReminderEnabled, setMoodReminderEnabledState] = useState(false);
const [hrvReminderEnabled, setHrvReminderEnabledState] = useState(false);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
// 加载通知设置 // 加载通知设置
const loadNotificationSettings = useCallback(async () => { const loadNotificationSettings = useCallback(async () => {
try { try {
const [notification, medicationReminder, nutritionReminder, moodReminder] = await Promise.all([ const [notification, medicationReminder, nutritionReminder, moodReminder, hrvReminder] = await Promise.all([
getNotificationEnabled(), getNotificationEnabled(),
getMedicationReminderEnabled(), getMedicationReminderEnabled(),
getNutritionReminderEnabled(), getNutritionReminderEnabled(),
getMoodReminderEnabled(), getMoodReminderEnabled(),
getHRVReminderEnabled(),
]); ]);
setNotificationEnabledState(notification); setNotificationEnabledState(notification);
setMedicationReminderEnabledState(medicationReminder); setMedicationReminderEnabledState(medicationReminder);
setNutritionReminderEnabledState(nutritionReminder); setNutritionReminderEnabledState(nutritionReminder);
setMoodReminderEnabledState(moodReminder); setMoodReminderEnabledState(moodReminder);
setHrvReminderEnabledState(hrvReminder);
} catch (error) { } catch (error) {
console.error('Failed to load notification settings:', error); console.error('Failed to load notification settings:', error);
} finally { } finally {
@@ -103,6 +108,8 @@ export default function NotificationSettingsScreen() {
setNutritionReminderEnabledState(false); setNutritionReminderEnabledState(false);
await setMoodReminderEnabled(false); await setMoodReminderEnabled(false);
setMoodReminderEnabledState(false); setMoodReminderEnabledState(false);
await setHRVReminderEnabled(false);
setHrvReminderEnabledState(false);
} catch (error) { } catch (error) {
console.error('Failed to disable push notifications:', error); console.error('Failed to disable push notifications:', error);
Alert.alert(t('notificationSettings.alerts.error.title'), t('notificationSettings.alerts.error.saveFailed')); Alert.alert(t('notificationSettings.alerts.error.title'), t('notificationSettings.alerts.error.saveFailed'));
@@ -173,6 +180,26 @@ export default function NotificationSettingsScreen() {
} }
}; };
// 处理 HRV 通知提醒开关变化
const handleHrvReminderToggle = async (value: boolean) => {
try {
await setHRVReminderEnabled(value);
setHrvReminderEnabledState(value);
if (value) {
await sendNotification({
title: t('notificationSettings.alerts.hrvReminderEnabled.title'),
body: t('notificationSettings.alerts.hrvReminderEnabled.body'),
sound: true,
priority: 'high',
});
}
} catch (error) {
console.error('Failed to set HRV reminder:', error);
Alert.alert(t('notificationSettings.alerts.error.title'), t('notificationSettings.alerts.error.hrvReminderFailed'));
}
};
// 渲染设置项 // 渲染设置项
const renderSettingItem = ( const renderSettingItem = (
icon: keyof typeof Ionicons.glyphMap, icon: keyof typeof Ionicons.glyphMap,
@@ -297,6 +324,16 @@ export default function NotificationSettingsScreen() {
true true
)} )}
{renderSettingItem(
'pulse-outline',
t('notificationSettings.items.hrvReminder.title'),
t('notificationSettings.items.hrvReminder.description'),
hrvReminderEnabled,
handleHrvReminderToggle,
!notificationEnabled,
true
)}
{renderSettingItem( {renderSettingItem(
'happy-outline', 'happy-outline',
t('notificationSettings.items.moodReminder.title'), t('notificationSettings.items.moodReminder.title'),

View File

@@ -0,0 +1,358 @@
import { HeaderBar } from '@/components/ui/HeaderBar';
import { palette } from '@/constants/Colors';
import { useMembershipModal } from '@/contexts/MembershipModalContext';
import { useToast } from '@/contexts/ToastContext';
import { useI18n } from '@/hooks/useI18n';
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
import { useVipService } from '@/hooks/useVipService';
import {
getStatisticsCardOrder,
getStatisticsCardsVisibility,
setStatisticsCardOrder,
setStatisticsCardVisibility,
StatisticsCardsVisibility
} from '@/utils/userPreferences';
import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import { router, useFocusEffect } from 'expo-router';
import React, { useCallback, useState } from 'react';
import { StatusBar, StyleSheet, Switch, Text, TouchableOpacity, View } from 'react-native';
import DraggableFlatList, { RenderItemParams, ScaleDecorator } from 'react-native-draggable-flatlist';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
type CardItem = {
key: string;
title: string;
icon: keyof typeof Ionicons.glyphMap;
visible: boolean;
visibilityKey: keyof StatisticsCardsVisibility;
};
export default function StatisticsCustomizationScreen() {
const safeAreaTop = useSafeAreaTop(60);
const { t } = useI18n();
const { isVip } = useVipService();
const { openMembershipModal } = useMembershipModal();
const { showToast } = useToast();
const [isLoading, setIsLoading] = useState(true);
const [data, setData] = useState<CardItem[]>([]);
const CARD_CONFIG: Record<string, { icon: keyof typeof Ionicons.glyphMap; titleKey: string; visibilityKey: keyof StatisticsCardsVisibility }> = {
mood: { icon: 'happy-outline', titleKey: 'statisticsCustomization.items.mood', visibilityKey: 'showMood' },
steps: { icon: 'footsteps-outline', titleKey: 'statisticsCustomization.items.steps', visibilityKey: 'showSteps' },
stress: { icon: 'pulse-outline', titleKey: 'statisticsCustomization.items.stress', visibilityKey: 'showStress' },
sleep: { icon: 'moon-outline', titleKey: 'statisticsCustomization.items.sleep', visibilityKey: 'showSleep' },
fitness: { icon: 'fitness-outline', titleKey: 'statisticsCustomization.items.fitnessRings', visibilityKey: 'showFitnessRings' },
water: { icon: 'water-outline', titleKey: 'statisticsCustomization.items.water', visibilityKey: 'showWater' },
metabolism: { icon: 'flame-outline', titleKey: 'statisticsCustomization.items.basalMetabolism', visibilityKey: 'showBasalMetabolism' },
oxygen: { icon: 'water-outline', titleKey: 'statisticsCustomization.items.oxygenSaturation', visibilityKey: 'showOxygenSaturation' },
temperature: { icon: 'thermometer-outline', titleKey: 'statisticsCustomization.items.wristTemperature', visibilityKey: 'showWristTemperature' },
menstrual: { icon: 'rose-outline', titleKey: 'statisticsCustomization.items.menstrualCycle', visibilityKey: 'showMenstrualCycle' },
weight: { icon: 'scale-outline', titleKey: 'statisticsCustomization.items.weight', visibilityKey: 'showWeight' },
circumference: { icon: 'body-outline', titleKey: 'statisticsCustomization.items.circumference', visibilityKey: 'showCircumference' },
};
// 加载设置
const loadSettings = useCallback(async () => {
try {
const [visibility, order] = await Promise.all([
getStatisticsCardsVisibility(),
getStatisticsCardOrder(),
]);
// 确保 order 包含所有配置的 key (处理新增 key 的情况)
const allKeys = Object.keys(CARD_CONFIG);
const uniqueOrder = Array.from(new Set([...order, ...allKeys]));
const listData: CardItem[] = uniqueOrder
.filter(key => CARD_CONFIG[key]) // 过滤掉无效 key
.map(key => {
const config = CARD_CONFIG[key];
return {
key,
title: t(config.titleKey),
icon: config.icon,
visible: visibility[config.visibilityKey],
visibilityKey: config.visibilityKey,
};
});
setData(listData);
} catch (error) {
console.error('Failed to load statistics customization settings:', error);
} finally {
setIsLoading(false);
}
}, [t]);
// 页面聚焦时加载设置
useFocusEffect(
useCallback(() => {
loadSettings();
}, [loadSettings])
);
// 处理开关切换
const handleToggle = async (item: CardItem, value: boolean) => {
if (!isVip) {
showToast({
type: 'info',
message: t('statisticsCustomization.vipRequired'),
});
openMembershipModal();
return;
}
try {
// 乐观更新 UI
setData(prev => prev.map(d => d.key === item.key ? { ...d, visible: value } : d));
await setStatisticsCardVisibility(item.visibilityKey, value);
} catch (error) {
console.error(`Failed to set ${item.key}:`, error);
// 回滚
setData(prev => prev.map(d => d.key === item.key ? { ...d, visible: !value } : d));
}
};
// 处理排序结束
const handleDragEnd = async ({ data: newData }: { data: CardItem[] }) => {
setData(newData);
const newOrder = newData.map(item => item.key);
try {
await setStatisticsCardOrder(newOrder);
} catch (error) {
console.error('Failed to save card order:', error);
}
};
const renderItem = useCallback(({ item, drag, isActive }: RenderItemParams<CardItem>) => {
const handleDrag = () => {
if (!isVip) {
showToast({
type: 'info',
message: t('statisticsCustomization.vipRequired'),
});
openMembershipModal();
return;
}
drag();
};
return (
<ScaleDecorator>
<TouchableOpacity
onLongPress={handleDrag}
disabled={isActive}
activeOpacity={1}
style={[
styles.rowItem,
isActive && styles.activeItem,
]}
>
<View style={styles.itemContent}>
<View style={styles.leftContent}>
<View style={styles.dragHandle}>
<Ionicons name="reorder-three-outline" size={24} color="#C7C7CC" />
</View>
<View style={styles.iconContainer}>
<Ionicons name={item.icon} size={24} color={'#9370DB'} />
</View>
<Text style={styles.itemTitle}>{item.title}</Text>
</View>
<Switch
value={item.visible}
onValueChange={(v) => handleToggle(item, v)}
trackColor={{ false: '#E5E5E5', true: '#9370DB' }}
thumbColor="#FFFFFF"
style={styles.switch}
/>
</View>
</TouchableOpacity>
</ScaleDecorator>
);
}, [handleToggle, isVip, t, showToast, openMembershipModal]);
if (isLoading) {
return (
<View style={styles.container}>
<StatusBar barStyle="dark-content" backgroundColor="transparent" translucent />
<LinearGradient
colors={[palette.purple[100], '#F5F5F5']}
start={{ x: 1, y: 0 }}
end={{ x: 0.3, y: 0.4 }}
style={styles.gradientBackground}
/>
<View style={styles.loadingContainer}>
<Text style={styles.loadingText}>{t('notificationSettings.loading')}</Text>
</View>
</View>
);
}
return (
<GestureHandlerRootView style={styles.container}>
<StatusBar barStyle="dark-content" backgroundColor="transparent" translucent />
<LinearGradient
colors={[palette.purple[100], '#F5F5F5']}
start={{ x: 1, y: 0 }}
end={{ x: 0.3, y: 0.4 }}
style={styles.gradientBackground}
/>
<HeaderBar
title={t('statisticsCustomization.title')}
onBack={() => router.back()}
/>
<DraggableFlatList
data={data}
onDragEnd={handleDragEnd}
keyExtractor={(item) => item.key}
renderItem={renderItem}
contentContainerStyle={[
styles.scrollContent,
{ paddingTop: safeAreaTop }
]}
showsVerticalScrollIndicator={false}
ListHeaderComponent={() => (
<>
<View style={styles.headerSection}>
<Text style={styles.subtitle}>{t('notificationSettings.sections.description')}</Text>
<View style={styles.descriptionCard}>
<View style={styles.hintRow}>
<Ionicons name="information-circle-outline" size={20} color="#9370DB" />
<Text style={styles.descriptionText}>
{t('statisticsCustomization.description.text')}
</Text>
</View>
</View>
</View>
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>{t('statisticsCustomization.sectionTitle')}</Text>
</View>
</>
)}
/>
</GestureHandlerRootView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#F5F5F5',
},
gradientBackground: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
height: '60%',
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
loadingText: {
fontSize: 16,
color: '#666',
},
scrollContent: {
paddingHorizontal: 16,
paddingBottom: 40,
},
headerSection: {
marginBottom: 20,
},
subtitle: {
fontSize: 14,
color: '#6C757D',
marginBottom: 12,
marginLeft: 4,
},
descriptionCard: {
backgroundColor: 'rgba(255, 255, 255, 0.6)',
borderRadius: 12,
padding: 12,
gap: 8,
borderWidth: 1,
borderColor: 'rgba(147, 112, 219, 0.1)',
},
hintRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
descriptionText: {
flex: 1,
fontSize: 13,
color: '#2C3E50',
lineHeight: 18,
},
sectionHeader: {
marginBottom: 12,
marginLeft: 4,
},
sectionTitle: {
fontSize: 16,
fontWeight: '600',
color: '#2C3E50',
},
rowItem: {
backgroundColor: '#FFFFFF',
borderRadius: 16,
marginBottom: 12,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.03,
shadowRadius: 4,
elevation: 2,
},
activeItem: {
backgroundColor: '#FAFAFA',
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 4,
zIndex: 100,
transform: [{ scale: 1.02 }],
},
itemContent: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
padding: 16,
height: 72,
},
leftContent: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
},
dragHandle: {
paddingRight: 12,
},
iconContainer: {
width: 40,
height: 40,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(147, 112, 219, 0.05)',
borderRadius: 12,
marginRight: 12,
},
itemTitle: {
fontSize: 16,
fontWeight: '500',
color: '#2C3E50',
flex: 1,
},
switch: {
transform: [{ scaleX: 0.9 }, { scaleY: 0.9 }],
},
});

View File

@@ -0,0 +1,405 @@
import dayjs, { Dayjs } from 'dayjs';
import { LinearGradient } from 'expo-linear-gradient';
import React, { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { Colors } from '@/constants/Colors';
import { fetchMenstrualFlowSamples, healthDataEvents } from '@/utils/health';
import {
buildMenstrualTimeline,
convertHealthKitSamplesToCycleRecords,
CycleRecord,
DEFAULT_PERIOD_LENGTH,
MenstrualDayInfo,
MenstrualDayStatus,
MenstrualTimeline,
} from '@/utils/menstrualCycle';
type Props = {
onPress?: () => void;
};
type Summary = {
state: string;
prefix?: string;
suffix?: string;
number?: number;
fallbackText: string;
};
const RingIcon = () => (
<View style={styles.iconWrapper}>
<LinearGradient
colors={['#f572a7', '#f0a4ff', '#6f6ced']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.iconGradient}
>
<View style={styles.iconInner} />
</LinearGradient>
</View>
);
export const MenstrualCycleCard: React.FC<Props> = ({ onPress }) => {
const { t } = useTranslation();
const [records, setRecords] = useState<CycleRecord[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
let mounted = true;
const loadMenstrualData = async () => {
// Avoid setting loading to true for background updates to prevent UI flicker
if (records.length === 0) {
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();
// Listen for data changes
const handleDataChange = () => {
loadMenstrualData();
};
healthDataEvents.on('menstrualDataChanged', handleDataChange);
return () => {
mounted = false;
healthDataEvents.off('menstrualDataChanged', handleDataChange);
};
}, []);
const timeline = useMemo(
() =>
buildMenstrualTimeline({
records,
monthsBefore: 2,
monthsAfter: 4,
defaultPeriodLength: DEFAULT_PERIOD_LENGTH,
}),
[records]
);
const summary = useMemo(() => {
if (loading && records.length === 0) {
return {
state: t('menstrual.card.syncingState'),
fallbackText: t('menstrual.card.syncingDesc'),
};
}
return deriveSummary(timeline, records.length > 0, t);
}, [loading, records.length, timeline, t]);
return (
<TouchableOpacity activeOpacity={0.92} onPress={onPress} style={styles.wrapper}>
<View style={styles.headerRow}>
<RingIcon />
<Text style={styles.title}>{t('menstrual.card.title')}</Text>
<View style={styles.badgeOuter}>
<View style={styles.badgeInner} />
</View>
</View>
<View style={styles.content}>
<Text style={styles.stateText}>{summary.state}</Text>
<Text style={styles.dayRow}>
{summary.number !== undefined ? (
<>
{summary.prefix}
<Text style={styles.dayNumber}>{summary.number}</Text>
{summary.suffix}
</>
) : (
summary.fallbackText
)}
</Text>
</View>
</TouchableOpacity>
);
};
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({
wrapper: {
width: '100%',
},
headerRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
},
iconWrapper: {
width: 24,
height: 24,
borderRadius: 12,
alignItems: 'center',
justifyContent: 'center',
},
iconGradient: {
width: 22,
height: 22,
borderRadius: 11,
alignItems: 'center',
justifyContent: 'center',
},
iconInner: {
width: 10,
height: 10,
borderRadius: 5,
backgroundColor: '#fff',
},
title: {
fontSize: 14,
color: '#192126',
fontWeight: '600',
flex: 1,
fontFamily: 'AliBold',
},
badgeOuter: {
width: 18,
height: 18,
borderRadius: 9,
borderWidth: 2,
borderColor: '#fbcfe8',
alignItems: 'center',
justifyContent: 'center',
},
badgeInner: {
width: 6,
height: 6,
borderRadius: 3,
backgroundColor: Colors.light.primary,
opacity: 0.35,
},
content: {
marginTop: 12,
},
stateText: {
fontSize: 12,
color: '#515558',
marginBottom: 4,
fontFamily: 'AliRegular',
},
dayRow: {
fontSize: 14,
color: '#192126',
fontFamily: 'AliRegular',
},
dayNumber: {
fontSize: 18,
fontWeight: '700',
color: '#192126',
fontFamily: 'AliBold',
},
});

View File

@@ -0,0 +1,77 @@
import { Colors } from '@/constants/Colors';
import React from 'react';
import { useTranslation } from 'react-i18next';
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 { t } = useTranslation();
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}>{t('menstrual.today')}</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',
},
});

View File

@@ -0,0 +1,119 @@
import { Colors } from '@/constants/Colors';
import { Ionicons } from '@expo/vector-icons';
import dayjs from 'dayjs';
import 'dayjs/locale/en';
import 'dayjs/locale/zh-cn';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { DimensionValue, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { InlineTipProps } from './types';
export const InlineTip: React.FC<InlineTipProps> = ({
selectedDate,
selectedInfo,
columnIndex,
onMarkStart,
onCancelMark,
}) => {
const { t, i18n } = useTranslation();
// 14.28% per cell. Center is 7.14%.
const pointerLeft = `${columnIndex * 14.2857 + 7.1428}%` as DimensionValue;
const isFuture = selectedDate.isAfter(dayjs(), 'day');
const localeKey = i18n.language.startsWith('en') ? 'en' : 'zh-cn';
const dateFormat = t('menstrual.dateFormatShort', { defaultValue: 'M月D日' });
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.locale(localeKey).format(dateFormat)}
</Text>
</View>
{!isFuture && (!selectedInfo || !selectedInfo.confirmed) && (
<TouchableOpacity style={styles.inlinePrimaryBtn} onPress={onMarkStart}>
<Ionicons name="add" size={14} color="#fff" />
<Text style={styles.inlinePrimaryText}>{t('menstrual.actions.markPeriod')}</Text>
</TouchableOpacity>
)}
{!isFuture && selectedInfo?.confirmed && selectedInfo.status === 'period' && (
<TouchableOpacity style={styles.inlineSecondaryBtn} onPress={onCancelMark}>
<Text style={styles.inlineSecondaryText}>{t('menstrual.actions.cancelMark')}</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',
},
});

View File

@@ -0,0 +1,61 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { StyleSheet, Text, View } from 'react-native';
import { STATUS_COLORS } from './constants';
import { LegendItem } from './types';
export const Legend: React.FC = () => {
const { t } = useTranslation();
const legendItems: LegendItem[] = [
{ label: t('menstrual.legend.period'), key: 'period' },
{ label: t('menstrual.legend.predictedPeriod'), key: 'predicted-period' },
{ label: t('menstrual.legend.fertile'), key: 'fertile' },
{ label: t('menstrual.legend.ovulation'), key: 'ovulation-day' },
];
return (
<View style={styles.legendRow}>
{legendItems.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',
},
});

View File

@@ -0,0 +1,140 @@
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;
weekLabels?: string[];
}
export const MonthBlock: React.FC<MonthBlockProps> = ({
month,
selectedDateKey,
onSelect,
renderTip,
weekLabels,
}) => {
const weeks = useMemo(() => chunkArray(month.cells, 7), [month.cells]);
const labels = weekLabels?.length === 7 ? weekLabels : WEEK_LABELS;
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}>
{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,
},
});

View 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;

View 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';

View 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';
}

View File

@@ -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}
<View style={styles.content}> accessibilityLabel={title}
<View style={{ accessibilityHint={onPress ? `${title} details` : undefined}
flexDirection: 'row', >
alignItems: 'center', <View style={styles.headerRow}>
marginBottom: 14, <Image source={require('@/assets/images/icons/icon-blood-oxygen.png')} style={styles.titleIcon} />
}}>
<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,

View 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'
}
});

View File

@@ -1,20 +1,24 @@
import { MaterialCommunityIcons } from '@expo/vector-icons'; import { Ionicons, MaterialCommunityIcons } from '@expo/vector-icons';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import 'dayjs/locale/en'; import 'dayjs/locale/en';
import 'dayjs/locale/zh-cn'; import 'dayjs/locale/zh-cn';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import React, { useEffect, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { import {
ActivityIndicator, ActivityIndicator,
Dimensions, Dimensions,
Modal, Modal,
Pressable,
ScrollView, ScrollView,
Share,
StyleSheet, StyleSheet,
Text, Text,
TouchableOpacity, TouchableOpacity,
TouchableWithoutFeedback, TouchableWithoutFeedback,
View, View,
} from 'react-native'; } from 'react-native';
import ViewShot, { captureRef } from 'react-native-view-shot';
import { useI18n } from '@/hooks/useI18n'; import { useI18n } from '@/hooks/useI18n';
import { import {
@@ -26,6 +30,7 @@ import {
WorkoutActivityType, WorkoutActivityType,
WorkoutData, WorkoutData,
} from '@/utils/health'; } from '@/utils/health';
import { Toast } from '@/utils/toast.utils';
export interface IntensityBadge { export interface IntensityBadge {
label: string; label: string;
@@ -65,6 +70,8 @@ export function WorkoutDetailModal({
const [isMounted, setIsMounted] = useState(visible); const [isMounted, setIsMounted] = useState(visible);
const [shouldRenderChart, setShouldRenderChart] = useState(visible); const [shouldRenderChart, setShouldRenderChart] = useState(visible);
const [showIntensityInfo, setShowIntensityInfo] = useState(false); const [showIntensityInfo, setShowIntensityInfo] = useState(false);
const [sharing, setSharing] = useState(false);
const shareContentRef = useRef<ViewShot | null>(null);
const locale = useMemo(() => (i18n.language?.startsWith('en') ? 'en' : 'zh-cn'), [i18n.language]); const locale = useMemo(() => (i18n.language?.startsWith('en') ? 'en' : 'zh-cn'), [i18n.language]);
@@ -138,6 +145,50 @@ export function WorkoutDetailModal({
} }
}; };
const handleShare = useCallback(async () => {
if (!shareContentRef.current || !workout || sharing) {
return;
}
setSharing(true);
try {
Toast.show({
type: 'info',
text1: t('workoutDetail.share.generating', '正在生成分享卡片…'),
});
const uri = await captureRef(shareContentRef, {
format: 'png',
quality: 0.95,
snapshotContentContainer: true,
});
if (!uri) {
throw new Error('share-capture-failed');
}
const shareUri = uri.startsWith('file://') ? uri : `file://${uri}`;
const shareTitle = t('workoutDetail.share.title', { defaultValue: activityName || t('workoutDetail.title', '锻炼详情') });
const caloriesLabel = metrics?.calories != null
? `${metrics.calories} ${t('workoutDetail.metrics.caloriesUnit')}`
: '--';
const shareMessage = t('workoutDetail.share.message', {
activity: activityName || t('workoutDetail.share.activityFallback', '锻炼'),
duration: metrics?.durationLabel ?? '--',
calories: caloriesLabel,
date: dateInfo.subtitle,
defaultValue: `我的${activityName || '锻炼'}${dateInfo.subtitle},持续${metrics?.durationLabel ?? '--'},消耗${caloriesLabel}`,
});
await Share.share({
title: shareTitle,
message: shareMessage,
url: shareUri,
});
} catch (error) {
console.warn('workout-detail-share-failed', error);
Toast.error(t('workoutDetail.share.failed', '分享失败,请稍后再试'));
} finally {
setSharing(false);
}
}, [activityName, dateInfo.subtitle, metrics?.calories, metrics?.durationLabel, sharing, t, workout]);
if (!isMounted) { if (!isMounted) {
return null; return null;
} }
@@ -176,7 +227,48 @@ export function WorkoutDetailModal({
<Text style={styles.headerSubtitle}>{dateInfo.subtitle}</Text> <Text style={styles.headerSubtitle}>{dateInfo.subtitle}</Text>
</View> </View>
<View style={styles.headerSpacer} /> {isLiquidGlassAvailable() ? (
<Pressable
onPress={handleShare}
disabled={loading || sharing || !workout}
style={({ pressed }) => [
styles.headerIconButton,
styles.glassButtonWrapper,
pressed && styles.headerIconPressed,
(loading || sharing || !workout) && styles.headerIconDisabled,
]}
accessibilityRole="button"
accessibilityLabel={t('workoutDetail.share.accessibilityLabel', '分享锻炼记录')}
>
<GlassView glassEffectStyle="regular" tintColor="rgba(255,255,255,0.9)" isInteractive style={styles.glassButton}>
<View style={styles.glassButtonInner}>
<Ionicons name="share-outline" size={20} color="#1E2148" />
</View>
</GlassView>
</Pressable>
) : (
<Pressable
onPress={handleShare}
disabled={loading || sharing || !workout}
style={({ pressed }) => [
styles.headerIconButton,
styles.headerIconFallback,
pressed && styles.headerIconPressed,
(loading || sharing || !workout) && styles.headerIconDisabled,
]}
accessibilityRole="button"
accessibilityLabel={t('workoutDetail.share.accessibilityLabel', '分享锻炼记录')}
>
<LinearGradient
colors={['#EEF2FF', '#E0E7FF']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.glassButtonInner}
>
<Ionicons name="share-outline" size={20} color="#1E2148" />
</LinearGradient>
</Pressable>
)}
</View> </View>
<View style={styles.heroIconWrapper}> <View style={styles.heroIconWrapper}>
@@ -390,6 +482,237 @@ export function WorkoutDetailModal({
<View style={styles.homeIndicatorSpacer} /> <View style={styles.homeIndicatorSpacer} />
</ScrollView> </ScrollView>
</View> </View>
{/* Hidden share capture renders full content height for complete screenshots */}
<ViewShot
ref={shareContentRef}
style={[styles.sheetContainer, styles.shareCaptureContainer]}
options={{ format: 'png', quality: 0.95, snapshotContentContainer: true }}
>
<LinearGradient
colors={['#FFFFFF', '#F3F5FF']}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
style={styles.gradientBackground}
/>
<View style={styles.handleWrapper}>
<View style={styles.handle} />
</View>
<View style={styles.headerRow}>
<View style={styles.headerIconButton} />
<View style={styles.headerTitleWrapper}>
<Text style={styles.headerTitle}>{dateInfo.title}</Text>
<Text style={styles.headerSubtitle}>{dateInfo.subtitle}</Text>
</View>
<View style={styles.headerIconButton} />
</View>
<View style={styles.heroIconWrapper}>
<MaterialCommunityIcons name="run" size={160} color="#E8EAFE" />
</View>
<ScrollView
bounces={false}
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.contentContainer}
>
<View style={[styles.summaryCard, loading ? styles.summaryCardLoading : null]}>
<View style={styles.summaryHeader}>
<Text style={styles.activityName}>{activityName}</Text>
{intensityBadge ? (
<View
style={[
styles.intensityPill,
{ backgroundColor: intensityBadge.background },
]}
>
<Text style={[styles.intensityPillText, { color: intensityBadge.color }]}>
{intensityBadge.label}
</Text>
</View>
) : null}
</View>
<Text style={styles.summarySubtitle}>
{dayjs(workout?.startDate || workout?.endDate)
.locale(locale)
.format(locale === 'en' ? 'dddd, MMM D, YYYY HH:mm' : 'YYYY年M月D日 dddd HH:mm')}
</Text>
{loading ? (
<View style={styles.loadingBlock}>
<ActivityIndicator color="#5C55FF" />
<Text style={styles.loadingLabel}>{t('workoutDetail.loading')}</Text>
</View>
) : metrics ? (
<>
<View style={styles.metricsRow}>
<View style={styles.metricItem}>
<Text style={styles.metricTitle}>{t('workoutDetail.metrics.duration')}</Text>
<Text style={styles.metricValue}>{metrics.durationLabel}</Text>
</View>
<View style={styles.metricItem}>
<Text style={styles.metricTitle}>{t('workoutDetail.metrics.calories')}</Text>
<Text style={styles.metricValue}>
{metrics.calories != null ? `${metrics.calories} ${t('workoutDetail.metrics.caloriesUnit')}` : '--'}
</Text>
</View>
</View>
<View style={styles.metricsRow}>
<View style={styles.metricItem}>
<View style={styles.metricTitleRow}>
<Text style={styles.metricTitle}>{t('workoutDetail.metrics.intensity')}</Text>
<View style={styles.metricInfoButton}>
<MaterialCommunityIcons name="information-outline" size={16} color="#7780AA" />
</View>
</View>
<Text style={styles.metricValue}>
{formatMetsValue(metrics.mets)}
</Text>
</View>
<View style={styles.metricItem}>
<Text style={styles.metricTitle}>{t('workoutDetail.metrics.averageHeartRate')}</Text>
<Text style={styles.metricValue}>
{metrics.averageHeartRate != null ? `${metrics.averageHeartRate} ${t('workoutDetail.metrics.heartRateUnit')}` : '--'}
</Text>
</View>
</View>
{monthOccurrenceText ? (
<Text style={styles.monthOccurrenceText}>{monthOccurrenceText}</Text>
) : null}
</>
) : (
<View style={styles.errorBlock}>
<Text style={styles.errorText}>
{errorMessage || t('workoutDetail.errors.loadFailed')}
</Text>
{onRetry ? (
<View style={[styles.retryButton, styles.retryButtonDisabled]}>
<Text style={styles.retryButtonText}>{t('workoutDetail.retry')}</Text>
</View>
) : null}
</View>
)}
</View>
<View style={[styles.section, loading ? styles.sectionHeartRateLoading : null]}>
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>{t('workoutDetail.sections.heartRateRange')}</Text>
</View>
{loading ? (
<View style={styles.sectionLoading}>
<ActivityIndicator color="#5C55FF" />
</View>
) : metrics ? (
<>
<View style={styles.heartRateSummaryRow}>
<View style={styles.heartRateStat}>
<Text style={styles.statLabel}>{t('workoutDetail.sections.averageHeartRate')}</Text>
<Text style={styles.statValue}>
{metrics.averageHeartRate != null ? `${metrics.averageHeartRate} ${t('workoutDetail.sections.heartRateUnit')}` : '--'}
</Text>
</View>
<View style={styles.heartRateStat}>
<Text style={styles.statLabel}>{t('workoutDetail.sections.maximumHeartRate')}</Text>
<Text style={styles.statValue}>
{metrics.maximumHeartRate != null ? `${metrics.maximumHeartRate} ${t('workoutDetail.sections.heartRateUnit')}` : '--'}
</Text>
</View>
<View style={styles.heartRateStat}>
<Text style={styles.statLabel}>{t('workoutDetail.sections.minimumHeartRate')}</Text>
<Text style={styles.statValue}>
{metrics.minimumHeartRate != null ? `${metrics.minimumHeartRate} ${t('workoutDetail.sections.heartRateUnit')}` : '--'}
</Text>
</View>
</View>
{heartRateChart ? (
LineChart ? (
<View style={styles.chartWrapper}>
{shouldRenderChart ? (
/* @ts-ignore - react-native-chart-kit types are outdated */
<LineChart
data={{
labels: heartRateChart.labels,
datasets: [
{
data: heartRateChart.data,
color: () => '#5C55FF',
strokeWidth: 2,
},
],
}}
width={chartWidth}
height={220}
fromZero={false}
yAxisSuffix={t('workoutDetail.sections.heartRateUnit')}
withInnerLines={false}
bezier
paddingRight={48}
chartConfig={{
backgroundColor: '#FFFFFF',
backgroundGradientFrom: '#FFFFFF',
backgroundGradientTo: '#FFFFFF',
decimalPlaces: 0,
color: (opacity = 1) => `rgba(92, 85, 255, ${opacity})`,
labelColor: (opacity = 1) => `rgba(98, 105, 138, ${opacity})`,
propsForDots: {
r: '3',
strokeWidth: '2',
stroke: '#FFFFFF',
},
fillShadowGradientFromOpacity: 0.1,
fillShadowGradientToOpacity: 0.02,
}}
style={styles.chartStyle}
/>
) : (
<View style={[styles.chartLoading, { width: chartWidth }]}>
<ActivityIndicator color="#5C55FF" />
<Text style={styles.chartLoadingText}>{t('workoutDetail.loading')}</Text>
</View>
)}
</View>
) : (
<View style={styles.chartEmpty}>
<MaterialCommunityIcons name="chart-line-variant" size={32} color="#C5CBE2" />
<Text style={styles.chartEmptyText}>{t('workoutDetail.chart.unavailable')}</Text>
</View>
)
) : (
<View style={styles.chartEmpty}>
<MaterialCommunityIcons name="heart-off-outline" size={32} color="#C5CBE2" />
<Text style={styles.chartEmptyText}>{t('workoutDetail.chart.noData')}</Text>
</View>
)}
</>
) : (
<View style={styles.sectionError}>
<Text style={styles.errorTextSmall}>
{errorMessage || t('workoutDetail.errors.noHeartRateData')}
</Text>
</View>
)}
</View>
<View style={[styles.section, loading ? styles.sectionZonesLoading : null]}>
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>{t('workoutDetail.sections.heartRateZones')}</Text>
</View>
{loading ? (
<View style={styles.sectionLoading}>
<ActivityIndicator color="#5C55FF" />
</View>
) : metrics ? (
metrics.heartRateZones.map((zone) => renderHeartRateZone(zone, t))
) : (
<Text style={styles.errorTextSmall}>{t('workoutDetail.errors.noZoneStats')}</Text>
)}
</View>
<View style={styles.homeIndicatorSpacer} />
</ScrollView>
</ViewShot>
{showIntensityInfo ? ( {showIntensityInfo ? (
<Modal <Modal
transparent transparent
@@ -634,6 +957,39 @@ const styles = StyleSheet.create({
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
}, },
headerIconFallback: {
overflow: 'hidden',
},
headerIconPressed: {
opacity: 0.75,
},
headerIconDisabled: {
opacity: 0.4,
},
glassButtonWrapper: {
overflow: 'hidden',
},
glassButton: {
borderRadius: 20,
width: '100%',
height: '100%',
},
glassButtonInner: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
width: '100%',
height: '100%',
borderRadius: 20,
},
shareCaptureContainer: {
position: 'absolute',
top: -9999,
left: 0,
right: 0,
opacity: 0,
zIndex: -1,
},
headerTitleWrapper: { headerTitleWrapper: {
flex: 1, flex: 1,
alignItems: 'center', alignItems: 'center',
@@ -761,6 +1117,9 @@ const styles = StyleSheet.create({
backgroundColor: '#5C55FF', backgroundColor: '#5C55FF',
borderRadius: 16, borderRadius: 16,
}, },
retryButtonDisabled: {
opacity: 0.4,
},
retryButtonText: { retryButtonText: {
color: '#FFFFFF', color: '#FFFFFF',
fontWeight: '600', fontWeight: '600',
@@ -970,8 +1329,4 @@ const styles = StyleSheet.create({
intensityHigh: { intensityHigh: {
color: '#FF6767', color: '#FF6767',
}, },
headerSpacer: {
width: 40,
height: 40,
},
}); });

View File

@@ -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}}',

View File

@@ -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, // 确保通用翻译被正确导出
}; };

53
i18n/en/menstrual.ts Normal file
View File

@@ -0,0 +1,53 @@
export const menstrual = {
dateFormatShort: 'MMM D',
dateFormats: {
monthTitle: 'MMM',
monthSubtitle: 'YYYY',
},
weekdays: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'],
today: 'Today',
legend: {
period: 'Period',
predictedPeriod: 'Predicted period',
fertile: 'Fertile window',
ovulation: 'Ovulation',
},
actions: {
markPeriod: 'Mark period',
cancelMark: 'Cancel',
},
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.',
},
},
};

View File

@@ -112,6 +112,29 @@ export const personal = {
}, },
}; };
export const statisticsCustomization = {
title: 'Home Content Settings',
sectionTitle: 'Body Metrics Cards',
description: {
text: '• Customize the body metrics modules displayed on the home page\n• Hidden modules will not be shown on the home page, but data will be retained',
},
items: {
mood: 'Mood',
steps: 'Steps',
stress: 'Stress',
sleep: 'Sleep',
fitnessRings: 'Fitness Rings',
water: 'Water Intake',
basalMetabolism: 'Basal Metabolism',
oxygenSaturation: 'Oxygen Saturation',
wristTemperature: 'Wrist Temperature',
menstrualCycle: 'Menstrual Cycle',
weight: 'Weight',
circumference: 'Circumference',
},
vipRequired: 'VIP membership required to customize home layout',
};
export const editProfile = { export const editProfile = {
title: 'Edit Profile', title: 'Edit Profile',
fields: { fields: {
@@ -394,6 +417,10 @@ export const notificationSettings = {
title: 'Nutrition Record Reminder', title: 'Nutrition Record Reminder',
description: 'Receive nutrition record reminders at meal times', description: 'Receive nutrition record reminders at meal times',
}, },
hrvReminder: {
title: 'HRV Stress Alert',
description: 'Get guidance when elevated stress is detected from HRV',
},
moodReminder: { moodReminder: {
title: 'Mood Record Reminder', title: 'Mood Record Reminder',
description: 'Receive mood record reminders in the evening', description: 'Receive mood record reminders in the evening',
@@ -415,6 +442,7 @@ export const notificationSettings = {
saveFailed: 'Failed to save settings', saveFailed: 'Failed to save settings',
medicationReminderFailed: 'Failed to set medication reminder', medicationReminderFailed: 'Failed to set medication reminder',
nutritionReminderFailed: 'Failed to set nutrition reminder', nutritionReminderFailed: 'Failed to set nutrition reminder',
hrvReminderFailed: 'Failed to set HRV reminder',
moodReminderFailed: 'Failed to set mood reminder', moodReminderFailed: 'Failed to set mood reminder',
}, },
notificationsEnabled: { notificationsEnabled: {
@@ -429,6 +457,10 @@ export const notificationSettings = {
title: 'Nutrition Reminder Enabled', title: 'Nutrition Reminder Enabled',
body: 'You will receive nutrition record reminders at meal times', body: 'You will receive nutrition record reminders at meal times',
}, },
hrvReminderEnabled: {
title: 'HRV Reminder Enabled',
body: 'You will get tips when elevated stress is detected from HRV',
},
moodReminderEnabled: { moodReminderEnabled: {
title: 'Mood Reminder Enabled', title: 'Mood Reminder Enabled',
body: 'You will receive mood record reminders in the evening', body: 'You will receive mood record reminders in the evening',

View File

@@ -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}}',

View File

@@ -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, // 确保通用翻译被正确导出
}; };

52
i18n/zh/menstrual.ts Normal file
View File

@@ -0,0 +1,52 @@
export const menstrual = {
dateFormatShort: 'M月D日',
dateFormats: {
monthTitle: 'M月',
monthSubtitle: 'YYYY年',
},
weekdays: ['一', '二', '三', '四', '五', '六', '日'],
today: '今天',
legend: {
period: '经期',
predictedPeriod: '预测经期',
fertile: '排卵期',
ovulation: '排卵日',
},
actions: {
markPeriod: '标记经期',
cancelMark: '取消标记',
},
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 个周期的记录,计算平均经期和周期长度,后续会展示趋势和预测准确度。',
},
},
};

View File

@@ -112,6 +112,29 @@ export const personal = {
}, },
}; };
export const statisticsCustomization = {
title: '首页内容设置',
sectionTitle: '身体指标卡片',
description: {
text: '• 自定义首页展示的身体指标模块\n• 关闭的模块将不会在首页显示,但数据仍会保留',
},
items: {
mood: '心情',
steps: '步数',
stress: '压力',
sleep: '睡眠',
fitnessRings: '健身圆环',
water: '饮水',
basalMetabolism: '基础代谢',
oxygenSaturation: '血氧',
wristTemperature: '手腕温度',
menstrualCycle: '经期',
weight: '体重',
circumference: '围度',
},
vipRequired: '需要开通 VIP 会员才能自定义首页布局',
};
export const editProfile = { export const editProfile = {
title: '编辑资料', title: '编辑资料',
fields: { fields: {
@@ -398,6 +421,10 @@ export const notificationSettings = {
title: '营养记录提醒', title: '营养记录提醒',
description: '在用餐时间接收营养记录提醒', description: '在用餐时间接收营养记录提醒',
}, },
hrvReminder: {
title: 'HRV 压力提醒',
description: '监测到压力偏高时发送健康建议',
},
moodReminder: { moodReminder: {
title: '心情记录提醒', title: '心情记录提醒',
description: '在晚间接收心情记录提醒', description: '在晚间接收心情记录提醒',
@@ -419,6 +446,7 @@ export const notificationSettings = {
saveFailed: '保存设置失败', saveFailed: '保存设置失败',
medicationReminderFailed: '设置药品提醒失败', medicationReminderFailed: '设置药品提醒失败',
nutritionReminderFailed: '设置营养提醒失败', nutritionReminderFailed: '设置营养提醒失败',
hrvReminderFailed: '设置 HRV 提醒失败',
moodReminderFailed: '设置心情提醒失败', moodReminderFailed: '设置心情提醒失败',
}, },
notificationsEnabled: { notificationsEnabled: {
@@ -433,6 +461,10 @@ export const notificationSettings = {
title: '营养提醒已开启', title: '营养提醒已开启',
body: '您将在用餐时间收到营养记录提醒', body: '您将在用餐时间收到营养记录提醒',
}, },
hrvReminderEnabled: {
title: 'HRV 提醒已开启',
body: '检测到压力升高时将收到健康建议推送',
},
moodReminderEnabled: { moodReminderEnabled: {
title: '心情提醒已开启', title: '心情提醒已开启',
body: '您将在晚间收到心情记录提醒', body: '您将在晚间收到心情记录提醒',

View File

@@ -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

View File

@@ -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]! {

View File

@@ -27,7 +27,7 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string> <string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>1.1.4</string> <string>1.1.5</string>
<key>CFBundleSignature</key> <key>CFBundleSignature</key>
<string>????</string> <string>????</string>
<key>CFBundleURLTypes</key> <key>CFBundleURLTypes</key>

View File

@@ -2745,52 +2745,52 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native/ReactCommon/yoga" :path: "../node_modules/react-native/ReactCommon/yoga"
SPEC CHECKSUMS: SPEC CHECKSUMS:
EASClient: de38c20c1dd9af7ff435afdc8b2c9b7c20d46767 EASClient: 68127f1248d2b25fdc82dbbfb17be95d1c4700be
EXApplication: a9d1c46d473d36f61302a9a81db2379441f3f094 EXApplication: 296622817d459f46b6c5fe8691f4aac44d2b79e7
EXConstants: e6e50cdfcb4524f40121d1fdcff24e97b7dcd2fd EXConstants: fd688cef4e401dcf798a021cfb5d87c890c30ba3
EXImageLoader: e501c001bc40b8326605e82e6e80363c80fe06b5 EXImageLoader: 189e3476581efe3ad4d1d3fb4735b7179eb26f05
EXJSONUtils: 1d3e4590438c3ee593684186007028a14b3686cd EXJSONUtils: 1d3e4590438c3ee593684186007028a14b3686cd
EXManifests: eaf327418721c4a7482576c579d215516d36319c EXManifests: 26e15640538c3d5ef028077ebcaf004b744d4932
EXNotifications: 5edf06b6b41a52d8b5b3eccf0e951dc8a880a50b EXNotifications: a62e1f8e3edd258dc3b155d3caa49f32920f1c6c
Expo: a429ae95cbae859785ece75d42ff768d6176099f Expo: 7af24402df45b9384900104e88a11896ffc48161
ExpoAppleAuthentication: 414e4316f8e25a2afbc3943cf725579c910f24b8 ExpoAppleAuthentication: bc9de6e9ff3340604213ab9031d4c4f7f802623e
ExpoAsset: ee515c16290e521de1870dcdee66d78173fbc533 ExpoAsset: d839c8eae8124470332408427327e8f88beb2dfd
ExpoBackgroundTask: e0efb5999d30593cbc0a2c443db70cc9310ec51c ExpoBackgroundTask: c498ce99a10f125d8370a5b2f4405e2583a3c896
ExpoBlur: b5b7a26572b3c33a11f0b2aa2f95c17c4c393b76 ExpoBlur: 2dd8f64aa31f5d405652c21d3deb2d2588b1852f
ExpoCamera: d1894aad960492f4881c1f83967167963365d937 ExpoCamera: 2a87c210f8955350ea5c70f1d539520b2fc5d940
ExpoClipboard: 99109306a2d9ed2fbd16f6b856e6267b2afa8472 ExpoClipboard: af650d14765f19c60ce2a1eaf9dfe6445eff7365
ExpoDocumentPicker: dbe4eeeb771891209fee044a6b0bafbd1fdcc4fa ExpoDocumentPicker: 2200eefc2817f19315fa18f0147e0b80ece86926
ExpoFileSystem: 73a9f3f2e0affc61eba5b9326153f64870438af1 ExpoFileSystem: 77157a101e03150a4ea4f854b4dd44883c93ae0a
ExpoFont: b881d43057dceb7b31ff767b24f612609e80f60f ExpoFont: cf9d90ec1d3b97c4f513211905724c8171f82961
ExpoGlassEffect: 3d147d753d3bfe1a5d6b7920560e206e3e98c19e ExpoGlassEffect: 265fa3d75b46bc58262e4dfa513135fa9dfe4aac
ExpoHaptics: b48d913e7e5f23816c6f130e525c9a6501b160b5 ExpoHaptics: 807476b0c39e9d82b7270349d6487928ce32df84
ExpoHead: 4ad178d6b19c2bf786b0d1889a870ba3187c18ef ExpoHead: fc0185d5c2a51ea599aff223aba5d61782301044
ExpoImage: 6eb842cd07817402640545c41884dd7f5fbfbca5 ExpoImage: 9c3428921c536ab29e5c6721d001ad5c1f469566
ExpoImagePicker: bd0a5c81d7734548f6908a480609257e85d19ea8 ExpoImagePicker: d251aab45a1b1857e4156fed88511b278b4eee1c
ExpoKeepAwake: 3f5e3ac53627849174f3603271df8e08f174ed4a ExpoKeepAwake: 1a2e820692e933c94a565ec3fbbe38ac31658ffe
ExpoLinearGradient: f9e7182e5253d53b2de4134b69d70bbfc2d50588 ExpoLinearGradient: a464898cb95153125e3b81894fd479bcb1c7dd27
ExpoLinking: 50a65cd7beb6051ffc82f84ffb33961dd5e55a5b ExpoLinking: 77455aa013e9b6a3601de03ecfab09858ee1b031
ExpoLocalization: 6c6f0f89ad2822001ab0bc2eb6d4d980c77f080c ExpoLocalization: b852a5d8ec14c5349c1593eca87896b5b3ebfcca
ExpoMediaLibrary: 648cee3f5dcba13410ec9cc8ac9a426e89a61a31 ExpoMediaLibrary: 641a6952299b395159ccd459bd8f5f6764bf55fe
ExpoModulesCore: bf3cd6f1f564c47031bcb1ee61b6aed1e0909eed ExpoModulesCore: bdc95c6daa1639e235a16350134152a0b28e5c72
ExpoQuickActions: 62b9db8a20618be1cc19efa3b562ac963c803d58 ExpoQuickActions: 31a70aa6a606128de4416a4830e09cfabfe6667f
ExpoSplashScreen: f46795cd52cdad65d30e54043f04c86401c4f85d ExpoSplashScreen: 268b2f128dc04284c21010540a6c4dd9f95003e3
ExpoSQLite: 36b8723223f322148606473507da446b1912500c ExpoSQLite: b312b02c8b77ab55951396e6cd13992f8db9215f
ExpoSymbols: ef7b8ac77ac2d496b1bc3f0f7daf5e19c3a9933a ExpoSymbols: 1ae04ce686de719b9720453b988d8bc5bf776c68
ExpoSystemUI: 9441d46a8efbf9224d1b2e6b18042452ffd0ed79 ExpoSystemUI: 2761aa6875849af83286364811d46e8ed8ea64c7
ExpoUI: 821b058da921ea4aa6172b36d080991ea6fb2fae ExpoUI: b99a1d1ef5352a60bebf4f4fd3a50d2f896ae804
ExpoWebBrowser: 35223474c19e261c0661ecc7caa513d054244ab5 ExpoWebBrowser: b973e1351fdcf5fec0c400997b1851f5a8219ec3
EXStructuredHeaders: c951e77f2d936f88637421e9588c976da5827368 EXStructuredHeaders: c951e77f2d936f88637421e9588c976da5827368
EXTaskManager: 53f87ed11659341c3f3f02c0041498ef293f5684 EXTaskManager: cbbb80cbccea6487ccca0631809fbba2ed3e5271
EXUpdates: 04853b77f4a405c5b85039b9a2ccf1c71438028c EXUpdates: 9042dc213f17593a02d59ef7dd9d297edf621936
EXUpdatesInterface: 1436757deb0d574b84bba063bd024c315e0ec08b EXUpdatesInterface: 5adf50cb41e079c861da6d9b4b954c3db9a50734
FBLazyVector: e95a291ad2dadb88e42b06e0c5fb8262de53ec12 FBLazyVector: e95a291ad2dadb88e42b06e0c5fb8262de53ec12
hermes-engine: 9f4dfe93326146a1c99eb535b1cb0b857a3cd172 hermes-engine: 9f4dfe93326146a1c99eb535b1cb0b857a3cd172
libavif: 84bbb62fb232c3018d6f1bab79beea87e35de7b7 libavif: 84bbb62fb232c3018d6f1bab79beea87e35de7b7
libdav1d: 23581a4d8ec811ff171ed5e2e05cd27bad64c39f libdav1d: 23581a4d8ec811ff171ed5e2e05cd27bad64c39f
libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8 libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8
lottie-ios: a881093fab623c467d3bce374367755c272bdd59 lottie-ios: a881093fab623c467d3bce374367755c272bdd59
lottie-react-native: 97a11537edc72d0763edab0c83e8cc8a0b9d8484 lottie-react-native: cbe3d931a7c24f7891a8e8032c2bb9b2373c4b9c
PurchasesHybridCommon: 71c94158ff8985657d37d5f3be05602881227619 PurchasesHybridCommon: 71c94158ff8985657d37d5f3be05602881227619
RCTDeprecation: 943572d4be82d480a48f4884f670135ae30bf990 RCTDeprecation: 943572d4be82d480a48f4884f670135ae30bf990
RCTRequired: 8f3cfc90cc25cf6e420ddb3e7caaaabc57df6043 RCTRequired: 8f3cfc90cc25cf6e420ddb3e7caaaabc57df6043
@@ -2798,83 +2798,83 @@ SPEC CHECKSUMS:
ReachabilitySwift: 32793e867593cfc1177f5d16491e3a197d2fccda ReachabilitySwift: 32793e867593cfc1177f5d16491e3a197d2fccda
React: 914f8695f9bf38e6418228c2ffb70021e559f92f React: 914f8695f9bf38e6418228c2ffb70021e559f92f
React-callinvoker: 1c0808402aee0c6d4a0d8e7220ce6547af9fba71 React-callinvoker: 1c0808402aee0c6d4a0d8e7220ce6547af9fba71
React-Core: 4ae98f9e8135b8ddbd7c98730afb6fdae883db90 React-Core: c61410ef0ca6055e204a963992e363227e0fd1c5
React-Core-prebuilt: 8f4cca589c14e8cf8fc6db4587ef1c2056b5c151 React-Core-prebuilt: 02f0ad625ddd47463c009c2d0c5dd35c0d982599
React-CoreModules: e878a90bb19b8f3851818af997dbae3b3b0a27ac React-CoreModules: 1f6d1744b5f9f2ec684a4bb5ced25370f87e5382
React-cxxreact: 28af9844f6dc87be1385ab521fbfb3746f19563c React-cxxreact: 3af79478e8187b63ffc22b794cd42d3fc1f1f2da
React-debug: 6328c2228e268846161f10082e80dc69eac2e90a React-debug: 6328c2228e268846161f10082e80dc69eac2e90a
React-defaultsnativemodule: afc9d809ec75780f39464a6949c07987fbea488c React-defaultsnativemodule: d635ef36d755321e5d6fc065bd166b2c5a0e9833
React-domnativemodule: 91a233260411d41f27f67aa1358b7f9f0bfd101d React-domnativemodule: dd28f6d96cd21236e020be2eff6fe0b7d4ec3b66
React-Fabric: 21f349b5e93f305a3c38c885902683a9c79cf983 React-Fabric: 2e32c3fdbb1fbcf5fde54607e3abe453c6652ce2
React-FabricComponents: 47ac634cc9ecc64b30a9997192f510eebe4177e4 React-FabricComponents: 5ed0cdb81f6b91656cb4d3be432feaa28a58071a
React-FabricImage: 21873acd6d4a51a0b97c133141051c7acb11cc86 React-FabricImage: 2bc714f818cb24e454f5d3961864373271b2faf8
React-featureflags: 653f469f0c3c9dc271d610373e3b6e66a9fd847d React-featureflags: 847642f41fa71ad4eec5e0351badebcad4fe6171
React-featureflagsnativemodule: c91a8a3880e0f4838286402241ead47db43aed28 React-featureflagsnativemodule: c868a544b2c626fa337bcbd364b1befe749f0d3f
React-graphics: b4bdb0f635b8048c652a5d2b73eb8b1ddd950f24 React-graphics: 192ec701def5b3f2a07db2814dfba5a44986cff6
React-hermes: fcfad3b917400f49026f3232561e039c9d1c34bf React-hermes: e875778b496c86d07ab2ccaa36a9505d248a254b
React-idlecallbacksnativemodule: 8cb83207e39f8179ac1d344b6177c6ab3ccebcdc React-idlecallbacksnativemodule: 4d57965cdf82c14ee3b337189836cd8491632b76
React-ImageManager: 396128004783fc510e629124dce682d38d1088e7 React-ImageManager: bd0b99e370b13de82c9cd15f0f08144ff3de079e
React-jserrorhandler: b58b788d788cdbf8bda7db74a88ebfcffc8a0795 React-jserrorhandler: a2fdef4cbcfdcdf3fa9f5d1f7190f7fd4535248d
React-jsi: d2c3f8555175371c02da6dfe7ed1b64b55a9d6c0 React-jsi: 89d43d1e7d4d0663f8ba67e0b39eb4e4672c27de
React-jsiexecutor: ba537434eb45ee018b590ed7d29ee233fddb8669 React-jsiexecutor: abe4874aaab90dfee5dec480680220b2f8af07e3
React-jsinspector: f21b6654baf96cb9f71748844a32468a5f73ad51 React-jsinspector: a0b3e051aef842b0b2be2353790ae2b2a5a65a8f
React-jsinspectorcdp: 3f8be4830694c3c1c39442e50f8db877966d43f0 React-jsinspectorcdp: 6346013b2247c6263fbf5199adf4a8751e53bd89
React-jsinspectornetwork: 70e41469565712ad60e11d9c8b8f999b9f7f61eb React-jsinspectornetwork: 26281aa50d49fc1ec93abf981d934698fa95714f
React-jsinspectortracing: eccf9bfa4ec7f130d514f215cfb2222dc3c0e270 React-jsinspectortracing: 55eedf6d57540507570259a778663b90060bbd6e
React-jsitooling: b376a695f5a507627f7934748533b24eed1751ca React-jsitooling: 0e001113fa56d8498aa8ac28437ac0d36348e51a
React-jsitracing: 5c8c3273dda2d95191cc0612fb5e71c4d9018d2a React-jsitracing: b713793eb8a5bbc4d86a84e9d9e5023c0f58cbaf
React-logger: c3e2f8a2e284341205f61eef3d4677ab5a309dfd React-logger: 50fdb9a8236da90c0b1072da5c32ee03aeb5bf28
React-Mapbuffer: 603c18db65844bb81dbe62fee8fcc976eaeb7108 React-Mapbuffer: 9050ee10c19f4f7fca8963d0211b2854d624973e
React-microtasksnativemodule: d77e0c426fce34c23227394c96ca1033b30c813c React-microtasksnativemodule: f775db9e991c6f3b8ccbc02bfcde22770f96e23b
react-native-render-html: 984dfe2294163d04bf5fe25d7c9f122e60e05ebe react-native-render-html: 5afc4751f1a98621b3009432ef84c47019dcb2bd
react-native-safe-area-context: 53f796cb6c814661bbe99fbdfd0585d07b996cdd react-native-safe-area-context: 37e680fc4cace3c0030ee46e8987d24f5d3bdab2
react-native-view-shot: 26174e54ec6b4b7c5d70b86964b747919759adc1 react-native-view-shot: fb3c0774edb448f42705491802a455beac1502a2
react-native-voice: f5e8eec2278451d0017eb6a30a6ccc726aca34e0 react-native-voice: 908a0eba96c8c3d643e4f98b7232c6557d0a6f9c
react-native-webview: a4f0775a31b73cf13cfc3d2d2b119aa94ec76e49 react-native-webview: b29007f4723bca10872028067b07abacfa1cb35a
React-NativeModulesApple: 1664340b8750d64e0ef3907c5e53d9481f74bcbd React-NativeModulesApple: 8969913947d5b576de4ed371a939455a8daf28aa
React-oscompat: ce47230ed20185e91de62d8c6d139ae61763d09c React-oscompat: ce47230ed20185e91de62d8c6d139ae61763d09c
React-perflogger: b1af3cfb3f095f819b2814910000392a8e17ba9f React-perflogger: 02b010e665772c7dcb859d85d44c1bfc5ac7c0e4
React-performancetimeline: f9ec65b77bcadbc7bd8b47a6f4b4b697da7b1490 React-performancetimeline: 130db956b5a83aa4fb41ddf5ae68da89f3fb1526
React-RCTActionSheet: 0b14875b3963e9124a5a29a45bd1b22df8803916 React-RCTActionSheet: 0b14875b3963e9124a5a29a45bd1b22df8803916
React-RCTAnimation: 60f6eca214a62b9673f64db6df3830cee902b5af React-RCTAnimation: a7b90fd2af7bb9c084428867445a1481a8cb112e
React-RCTAppDelegate: 37734b39bac108af30a0fd9d3e1149ec68b82c28 React-RCTAppDelegate: 3262bedd01263f140ec62b7989f4355f57cec016
React-RCTBlob: 83fbcbd57755caf021787324aac2fe9b028cc264 React-RCTBlob: c17531368702f1ebed5d0ada75a7cf5915072a53
React-RCTFabric: a05cb1df484008db3753c8b4a71e4c6d9f1e43a6 React-RCTFabric: 6409edd8cfdc3133b6cc75636d3b858fdb1d11ea
React-RCTFBReactNativeSpec: d58d7ae9447020bbbac651e3b0674422aba18266 React-RCTFBReactNativeSpec: c004b27b4fa3bd85878ad2cf53de3bbec85da797
React-RCTImage: 47aba3be7c6c64f956b7918ab933769602406aac React-RCTImage: c68078a120d0123f4f07a5ac77bea3bb10242f32
React-RCTLinking: 2dbaa4df2e4523f68baa07936bd8efdfa34d5f31 React-RCTLinking: cf8f9391fe7fe471f96da3a5f0435235eca18c5b
React-RCTNetwork: 1fca7455f9dedf7de2b95bec438da06680f3b000 React-RCTNetwork: ca31f7c879355760c2d9832a06ee35f517938a20
React-RCTRuntime: 17819dd1dfc8613efaf4cbb9d8686baae4a83e5b React-RCTRuntime: a6cf4a1e42754fc87f493e538f2ac6b820e45418
React-RCTSettings: 01bf91c856862354d3d2f642ccb82f3697a4284a React-RCTSettings: e0e140b2ff4bf86d34e9637f6316848fc00be035
React-RCTText: cb576a3797dcb64933613c522296a07eaafc0461 React-RCTText: 75915bace6f7877c03a840cc7b6c622fb62bfa6b
React-RCTVibration: 560af8c086741f3525b8456a482cdbe27f9d098e React-RCTVibration: 25f26b85e5e432bb3c256f8b384f9269e9529f25
React-rendererconsistency: 2dac03f448ff337235fd5820b10f81633328870d React-rendererconsistency: 2dac03f448ff337235fd5820b10f81633328870d
React-renderercss: c5c6b7a15948dd28facca39a18ac269073718490 React-renderercss: 477da167bb96b5ac86d30c5d295412fb853f5453
React-rendererdebug: 3c9d5e1634273f5a24d84cc5669f290ce0bdc812 React-rendererdebug: 2a1798c6f3ef5f22d466df24c33653edbabb5b89
React-RuntimeApple: 887637d1e12ea8262df7d32bc100467df2302613 React-RuntimeApple: 28cf4d8eb18432f6a21abbed7d801ab7f6b6f0b4
React-RuntimeCore: 91f779835dc4f8f84777fe5dd24f1a22f96454e4 React-RuntimeCore: 41bf0fd56a00de5660f222415af49879fa49c4f0
React-runtimeexecutor: 8bb6b738f37b0ada4a6269e6f8ab1133dea0285c React-runtimeexecutor: 1afb774dde3011348e8334be69d2f57a359ea43e
React-RuntimeHermes: 4cb93de9fa8b1cc753d200dbe61a01b9ec5f5562 React-RuntimeHermes: f3b158ea40e8212b1a723a68b4315e7a495c5fc6
React-runtimescheduler: 83dc28f530bfbd2fce84ed13aa7feebdc24e5af7 React-runtimescheduler: 3e1e2bec7300bae512533107d8e54c6e5c63fe0f
React-timing: 03c7217455d2bff459b27a3811be25796b600f47 React-timing: 6fa9883de2e41791e5dc4ec404e5e37f3f50e801
React-utils: 6d46795ae0444ec8a5d9a5f201157b286bf5250a React-utils: 6e2035b53d087927768649a11a26c4e092448e34
ReactAppDependencyProvider: c277c5b231881ad4f00cd59e3aa0671b99d7ebee ReactAppDependencyProvider: 1bcd3527ac0390a1c898c114f81ff954be35ed79
ReactCodegen: 4c44b74b77fc41ae25b9e2c7e9bd6e2bc772c23f ReactCodegen: 7d4593f7591f002d137fe40cef3f6c11f13c88cc
ReactCommon: e6e232202a447d353e5531f2be82f50f47cbaa9a ReactCommon: 08810150b1206cc44aecf5f6ae19af32f29151a8
ReactNativeDependencies: 71ce9c28beb282aa720ea7b46980fff9669f428a ReactNativeDependencies: 71ce9c28beb282aa720ea7b46980fff9669f428a
RevenueCat: d185cbff8be9425b5835042afd6889389bb756c8 RevenueCat: d185cbff8be9425b5835042afd6889389bb756c8
RNCAsyncStorage: e85a99325df9eb0191a6ee2b2a842644c7eb29f4 RNCAsyncStorage: 3a4f5e2777dae1688b781a487923a08569e27fe4
RNCMaskedView: 3c9d7586e2b9bbab573591dcb823918bc4668005 RNCMaskedView: d2578d41c59b936db122b2798ba37e4722d21035
RNCPicker: e0149590451d5eae242cf686014a6f6d808f93c7 RNCPicker: c8a3584b74133464ee926224463fcc54dfdaebca
RNDateTimePicker: 5e0a759109b63ebc661a4714712361d2d07142fe RNDateTimePicker: 19ffa303c4524ec0a2dfdee2658198451c16b7f1
RNDeviceInfo: 8b6fa8379062949dd79a009cf3d6b02a9c03ca59 RNDeviceInfo: bcce8752b5043a623fe3c26789679b473f705d3c
RNGestureHandler: 6a488ce85c88e82d8610db1108daf04e9b2d5162 RNGestureHandler: 2914750df066d89bf9d8f48a10ad5f0051108ac3
RNPurchases: 8a21795ad71f932dca9f48e9560924a3c64878c6 RNPurchases: 34da99c0e14ee484ed57e77dc06dcfe8e7cb1cee
RNReanimated: 43f611f1c85c90e0273df7399bf1536f8e2bd125 RNReanimated: e5c702a3e24cc1c68b2de67671713f35461678f4
RNScreens: dd61bc3a3e6f6901ad833efa411917d44827cf51 RNScreens: d8d6f1792f6e7ac12b0190d33d8d390efc0c1845
RNSentry: 54f8041cd06d7ccf484171edde72f1b07323fb2e RNSentry: 1d7b9fdae7a01ad8f9053335b5d44e75c39a955e
RNSVG: 2825ee146e0f6a16221e852299943e4cceef4528 RNSVG: 31d6639663c249b7d5abc9728dde2041eb2a3c34
RNWorklets: e2b9423934700160135e216d8a2e6cb7b194846c RNWorklets: 9eb6d567fa43984e96b6924a6df504b8a15980cd
SDWebImage: e9c98383c7572d713c1a0d7dd2783b10599b9838 SDWebImage: e9c98383c7572d713c1a0d7dd2783b10599b9838
SDWebImageAVIFCoder: afe194a084e851f70228e4be35ef651df0fc5c57 SDWebImageAVIFCoder: afe194a084e851f70228e4be35ef651df0fc5c57
SDWebImageSVGCoder: 15a300a97ec1c8ac958f009c02220ac0402e936c SDWebImageSVGCoder: 15a300a97ec1c8ac958f009c02220ac0402e936c

15
package-lock.json generated
View File

@@ -60,6 +60,7 @@
"react-native": "0.81.5", "react-native": "0.81.5",
"react-native-chart-kit": "^6.12.0", "react-native-chart-kit": "^6.12.0",
"react-native-device-info": "^14.0.4", "react-native-device-info": "^14.0.4",
"react-native-draggable-flatlist": "^4.0.3",
"react-native-gesture-handler": "~2.28.0", "react-native-gesture-handler": "~2.28.0",
"react-native-image-viewing": "^0.2.2", "react-native-image-viewing": "^0.2.2",
"react-native-markdown-display": "^7.0.2", "react-native-markdown-display": "^7.0.2",
@@ -12210,6 +12211,20 @@
"react-native": "*" "react-native": "*"
} }
}, },
"node_modules/react-native-draggable-flatlist": {
"version": "4.0.3",
"resolved": "https://mirrors.tencent.com/npm/react-native-draggable-flatlist/-/react-native-draggable-flatlist-4.0.3.tgz",
"integrity": "sha512-2F4x5BFieWdGq9SetD2nSAR7s7oQCSgNllYgERRXXtNfSOuAGAVbDb/3H3lP0y5f7rEyNwabKorZAD/SyyNbDw==",
"license": "MIT",
"dependencies": {
"@babel/preset-typescript": "^7.17.12"
},
"peerDependencies": {
"react-native": ">=0.64.0",
"react-native-gesture-handler": ">=2.0.0",
"react-native-reanimated": ">=2.8.0"
}
},
"node_modules/react-native-fit-image": { "node_modules/react-native-fit-image": {
"version": "1.5.5", "version": "1.5.5",
"resolved": "https://mirrors.tencent.com/npm/react-native-fit-image/-/react-native-fit-image-1.5.5.tgz", "resolved": "https://mirrors.tencent.com/npm/react-native-fit-image/-/react-native-fit-image-1.5.5.tgz",

View File

@@ -61,6 +61,7 @@
"react-native": "0.81.5", "react-native": "0.81.5",
"react-native-chart-kit": "^6.12.0", "react-native-chart-kit": "^6.12.0",
"react-native-device-info": "^14.0.4", "react-native-device-info": "^14.0.4",
"react-native-draggable-flatlist": "^4.0.3",
"react-native-gesture-handler": "~2.28.0", "react-native-gesture-handler": "~2.28.0",
"react-native-image-viewing": "^0.2.2", "react-native-image-viewing": "^0.2.2",
"react-native-markdown-display": "^7.0.2", "react-native-markdown-display": "^7.0.2",

View File

@@ -2,6 +2,7 @@ import { analyzeHRVData, fetchHRVWithStatus } from '@/utils/health';
import AsyncStorage from '@/utils/kvStore'; import AsyncStorage from '@/utils/kvStore';
import { logger } from '@/utils/logger'; import { logger } from '@/utils/logger';
import { convertHrvToStressIndex, getStressLevelInfo, StressLevel } from '@/utils/stress'; import { convertHrvToStressIndex, getStressLevelInfo, StressLevel } from '@/utils/stress';
import { getHRVReminderEnabled, getNotificationEnabled } from '@/utils/userPreferences';
import { NativeEventEmitter, NativeModules } from 'react-native'; import { NativeEventEmitter, NativeModules } from 'react-native';
import { sendHRVStressNotification } from './hrvNotificationService'; import { sendHRVStressNotification } from './hrvNotificationService';
@@ -88,6 +89,16 @@ class HRVMonitorService {
this.lastProcessedTime = now; this.lastProcessedTime = now;
try { try {
const [notificationsEnabled, hrvReminderEnabled] = await Promise.all([
getNotificationEnabled(),
getHRVReminderEnabled(),
]);
if (!notificationsEnabled || !hrvReminderEnabled) {
logger.info('[HRVMonitor] Notification preference disabled, skip HRV push');
return;
}
const canNotify = await this.canSendNotification(now); const canNotify = await this.canSendNotification(now);
if (!canNotify) { if (!canNotify) {
logger.info('[HRVMonitor] Notification recently sent, skipping'); logger.info('[HRVMonitor] Notification recently sent, skipping');

View File

@@ -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,

View File

@@ -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 = {
@@ -270,6 +271,9 @@ class HealthPermissionManager extends SimpleEventEmitter {
// 全局权限管理实例 // 全局权限管理实例
export const healthPermissionManager = new HealthPermissionManager(); export const healthPermissionManager = new HealthPermissionManager();
// 全局健康数据事件发射器
export const healthDataEvents = new SimpleEventEmitter();
// Interface for activity summary data from HealthKit // Interface for activity summary data from HealthKit
export interface HealthActivitySummary { export interface HealthActivitySummary {
activeEnergyBurned: number; activeEnergyBurned: number;
@@ -343,6 +347,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 +387,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 +801,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 +1591,87 @@ 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('经期数据保存成功');
// 触发数据变更事件
healthDataEvents.emit('menstrualDataChanged');
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}`);
// 触发数据变更事件
healthDataEvents.emit('menstrualDataChanged');
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 {

399
utils/menstrualCycle.ts Normal file
View File

@@ -0,0 +1,399 @@
import dayjs, { Dayjs } from 'dayjs';
import 'dayjs/locale/en';
import 'dayjs/locale/zh-cn';
import { MenstrualFlowSample } from './health';
export type MenstrualDayStatus = 'period' | 'predicted-period' | 'fertile' | 'ovulation-day';
export type CycleRecord = {
startDate: string;
periodLength?: number;
cycleLength?: number;
source?: 'healthkit' | 'manual';
};
export type MenstrualDayInfo = {
date: Dayjs;
status: MenstrualDayStatus;
confirmed: boolean;
dayOfCycle?: number;
};
export type MenstrualDayCell =
| {
type: 'placeholder';
key: string;
}
| {
type: 'day';
key: string;
label: number;
date: Dayjs;
info?: MenstrualDayInfo;
isToday: boolean;
};
export type MenstrualMonth = {
id: string;
title: string;
subtitle: string;
cells: MenstrualDayCell[];
};
export type MenstrualTimeline = {
months: MenstrualMonth[];
dayMap: Record<string, MenstrualDayInfo>;
cycleLength: number;
periodLength: number;
todayInfo?: MenstrualDayInfo;
};
const STATUS_PRIORITY: Record<MenstrualDayStatus, number> = {
'ovulation-day': 4,
period: 3,
'predicted-period': 2,
fertile: 1,
};
export const DEFAULT_CYCLE_LENGTH = 28;
export const DEFAULT_PERIOD_LENGTH = 5;
const MIN_CYCLE_LENGTH = 21;
const MAX_CYCLE_LENGTH = 45;
const LOOKBACK_WINDOW = 6;
const LUTEAL_PHASE_MEAN = 13; // 1214 天之间较为稳定
const clampCycleLength = (value: number, fallback: number) => {
if (!Number.isFinite(value) || value <= 0) return fallback;
return Math.max(MIN_CYCLE_LENGTH, Math.min(MAX_CYCLE_LENGTH, Math.round(value)));
};
const calcMedian = (values: number[]) => {
if (!values.length) return undefined;
const sorted = [...values].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
if (sorted.length % 2 === 0) {
return (sorted[mid - 1] + sorted[mid]) / 2;
}
return sorted[mid];
};
const calcTrimmedMean = (values: number[], trim = 1) => {
if (!values.length) return undefined;
const sorted = [...values].sort((a, b) => a - b);
const start = Math.min(trim, sorted.length);
const end = Math.max(sorted.length - trim, start);
const sliced = sorted.slice(start, end);
if (!sliced.length) return undefined;
return sliced.reduce((sum, cur) => sum + cur, 0) / sliced.length;
};
const calcStdDev = (values: number[]) => {
if (values.length < 2) return 0;
const mean = values.reduce((s, v) => s + v, 0) / values.length;
const variance =
values.reduce((s, v) => s + (v - mean) * (v - mean), 0) / (values.length - 1);
return Math.sqrt(variance);
};
const calcAverageCycleLength = (records: CycleRecord[], fallback = DEFAULT_CYCLE_LENGTH) => {
if (records.length < 2) return fallback;
const sorted = [...records].sort(
(a, b) => dayjs(a.startDate).valueOf() - dayjs(b.startDate).valueOf()
);
const intervals: number[] = [];
for (let i = 1; i < sorted.length; i += 1) {
const diff = dayjs(sorted[i].startDate).diff(dayjs(sorted[i - 1].startDate), 'day');
if (diff > 0) {
intervals.push(diff);
}
}
if (!intervals.length) return fallback;
const recent = intervals.slice(-LOOKBACK_WINDOW);
const median = calcMedian(recent);
const trimmed = calcTrimmedMean(recent);
const blended = median ?? trimmed ?? calcTrimmedMean(intervals) ?? fallback;
return clampCycleLength(blended, fallback);
};
const calcAveragePeriodLength = (records: CycleRecord[], fallback = DEFAULT_PERIOD_LENGTH) => {
const lengths = records
.map((r) => r.periodLength)
.filter((l): l is number => typeof l === 'number' && l > 0);
if (!lengths.length) return fallback;
const recent = lengths.slice(-LOOKBACK_WINDOW);
const median = calcMedian(recent);
const trimmed = calcTrimmedMean(recent);
const blended = median ?? trimmed ?? calcTrimmedMean(lengths) ?? fallback;
return Math.max(3, Math.round(blended)); // 极端短周期时仍保持生理期合理下限
};
const addDayInfo = (
dayMap: Record<string, MenstrualDayInfo>,
date: Dayjs,
info: MenstrualDayInfo
) => {
const key = date.format('YYYY-MM-DD');
const existing = dayMap[key];
if (existing && STATUS_PRIORITY[existing.status] >= STATUS_PRIORITY[info.status]) {
return;
}
dayMap[key] = info;
};
const getOvulationDay = (cycleStart: Dayjs, cycleLength: number) => {
// 排卵日约在下次月经前 12-14 天,较稳定;对极端周期做边界约束
const daysFromStart = cycleLength - LUTEAL_PHASE_MEAN;
const offset = Math.min(Math.max(daysFromStart, 11), 16);
return cycleStart.add(offset, 'day');
};
export const buildMenstrualTimeline = (options?: {
records?: CycleRecord[];
monthsBefore?: number;
monthsAfter?: number;
defaultCycleLength?: number;
defaultPeriodLength?: number;
locale?: 'zh' | 'en';
monthTitleFormat?: string;
monthSubtitleFormat?: string;
}): MenstrualTimeline => {
const today = dayjs();
const monthsBefore = options?.monthsBefore ?? 2;
const monthsAfter = options?.monthsAfter ?? 3;
const startMonth = today.subtract(monthsBefore, 'month').startOf('month');
const endMonth = today.add(monthsAfter, 'month').endOf('month');
const records = (options?.records ?? []).sort(
(a, b) => dayjs(a.startDate).valueOf() - dayjs(b.startDate).valueOf()
);
const avgCycleLength =
options?.defaultCycleLength ?? calcAverageCycleLength(records, DEFAULT_CYCLE_LENGTH);
const avgPeriodLength =
options?.defaultPeriodLength ?? calcAveragePeriodLength(records, DEFAULT_PERIOD_LENGTH);
const sortedStarts = [...records]
.sort((a, b) => dayjs(a.startDate).valueOf() - dayjs(b.startDate).valueOf())
.map((r) => dayjs(r.startDate));
const cycleIntervals: number[] = [];
for (let i = 1; i < sortedStarts.length; i += 1) {
const diff = sortedStarts[i].diff(sortedStarts[i - 1], 'day');
if (diff > 0) cycleIntervals.push(diff);
}
const recentIntervals = cycleIntervals.slice(-LOOKBACK_WINDOW);
const cycleVariability = calcStdDev(recentIntervals);
const lastInterval =
recentIntervals.length > 0 ? recentIntervals[recentIntervals.length - 1] : avgCycleLength;
// 对未来预测使用稳健均值 + 最新趋势的折中,以避免单次异常牵动过大
const predictedCycleLength = clampCycleLength(
0.55 * avgCycleLength + 0.45 * lastInterval,
avgCycleLength
);
const cycles = records.map((record) => ({
start: dayjs(record.startDate),
confirmed: true,
periodLength: record.periodLength ?? avgPeriodLength,
cycleLength: record.cycleLength ?? avgCycleLength,
}));
// 基于真实相邻开始日期矫正已记录周期长度,便于后续计算排卵日/易孕期
for (let i = 0; i < cycles.length; i += 1) {
const next = cycles[i + 1];
if (next) {
const interval = next.start.diff(cycles[i].start, 'day');
if (interval > 0) {
cycles[i].cycleLength = clampCycleLength(interval, cycles[i].cycleLength);
}
}
}
// 只有当存在历史记录时,才进行后续预测
if (cycles.length > 0) {
const lastConfirmed = cycles[cycles.length - 1];
let cursorStart = lastConfirmed.start;
let nextCycleLength = predictedCycleLength;
while (cursorStart.isBefore(endMonth)) {
cursorStart = cursorStart.add(nextCycleLength, 'day');
cycles.push({
start: cursorStart,
confirmed: false,
periodLength: avgPeriodLength,
cycleLength: nextCycleLength,
});
// 趋势逐步回归稳健均值,避免长期漂移
nextCycleLength = clampCycleLength(
Math.round(nextCycleLength * 0.65 + avgCycleLength * 0.35),
avgCycleLength
);
}
}
const dayMap: Record<string, MenstrualDayInfo> = {};
cycles.forEach((cycle) => {
const ovulationDay = getOvulationDay(cycle.start, cycle.cycleLength);
// 经前 luteal 稳定,易孕窗口 5-7 天:排卵日前 5 天 + 排卵日当日;高波动人群额外向前扩 1 天
const fertileWindow = Math.min(7, 6 + (cycle.confirmed ? 0 : Math.round(cycleVariability)));
const fertileStart = ovulationDay.subtract(fertileWindow, 'day');
for (let i = 0; i < cycle.periodLength; i += 1) {
const date = cycle.start.add(i, 'day');
if (date.isBefore(startMonth) || date.isAfter(endMonth)) continue;
addDayInfo(dayMap, date, {
date,
status: cycle.confirmed ? 'period' : 'predicted-period',
confirmed: cycle.confirmed,
dayOfCycle: i + 1,
});
}
for (let i = 0; i < fertileWindow; i += 1) {
const date = fertileStart.add(i, 'day');
if (date.isBefore(startMonth) || date.isAfter(endMonth)) continue;
addDayInfo(dayMap, date, {
date,
status: 'fertile',
confirmed: cycle.confirmed,
});
}
if (!ovulationDay.isBefore(startMonth) && !ovulationDay.isAfter(endMonth)) {
addDayInfo(dayMap, ovulationDay, {
date: ovulationDay,
status: 'ovulation-day',
confirmed: cycle.confirmed,
});
}
});
const months: MenstrualMonth[] = [];
let monthCursor = startMonth.startOf('month');
const locale = options?.locale ?? 'zh';
const localeKey = locale === 'en' ? 'en' : 'zh-cn';
const monthTitleFormat = options?.monthTitleFormat ?? (locale === 'en' ? 'MMM' : 'M月');
const monthSubtitleFormat = options?.monthSubtitleFormat ?? (locale === 'en' ? 'YYYY' : 'YYYY年');
while (monthCursor.isBefore(endMonth) || monthCursor.isSame(endMonth, 'month')) {
const firstDay = monthCursor.startOf('month');
const daysInMonth = firstDay.daysInMonth();
// 以周一为周首,符合设计稿呈现
const firstWeekday = (firstDay.day() + 6) % 7; // 0(一) - 6(日)
const cells: MenstrualDayCell[] = [];
for (let i = 0; i < firstWeekday; i += 1) {
cells.push({ type: 'placeholder', key: `${firstDay.format('YYYY-MM')}-p-${i}` });
}
for (let day = 1; day <= daysInMonth; day += 1) {
const date = firstDay.date(day);
const key = date.format('YYYY-MM-DD');
cells.push({
type: 'day',
key,
label: day,
date,
info: dayMap[key],
isToday: date.isSame(today, 'day'),
});
}
while (cells.length % 7 !== 0) {
cells.push({
type: 'placeholder',
key: `${firstDay.format('YYYY-MM')}-t-${cells.length}`,
});
}
const formattedMonth = firstDay.locale(localeKey);
months.push({
id: firstDay.format('YYYY-MM'),
title: formattedMonth.format(monthTitleFormat),
subtitle: formattedMonth.format(monthSubtitleFormat),
cells,
});
monthCursor = monthCursor.add(1, 'month');
}
const todayKey = today.format('YYYY-MM-DD');
return {
months,
dayMap,
cycleLength: predictedCycleLength,
periodLength: avgPeriodLength,
todayInfo: dayMap[todayKey],
};
};
export const getMenstrualSummaryForDate = (
date: Dayjs,
dayMap: Record<string, MenstrualDayInfo>
) => {
const key = date.format('YYYY-MM-DD');
return dayMap[key];
};
export const convertHealthKitSamplesToCycleRecords = (
samples: MenstrualFlowSample[]
): CycleRecord[] => {
if (!samples.length) return [];
// 1. Sort samples by date
const sortedSamples = [...samples].sort(
(a, b) => dayjs(a.startDate).valueOf() - dayjs(b.startDate).valueOf()
);
const records: CycleRecord[] = [];
let currentStart: Dayjs | null = null;
let currentEnd: Dayjs | null = null;
// 2. Iterate and merge consecutive days
for (const sample of sortedSamples) {
const sampleDate = dayjs(sample.startDate);
// If we have no current period being tracked, start a new one
if (!currentStart) {
currentStart = sampleDate;
currentEnd = sampleDate;
continue;
}
// Check if this sample is contiguous with the current period
// Allow 1 day gap? Standard logic usually assumes contiguous days for a single period.
// However, spotting might cause gaps. For now, we'll merge if gap <= 1 day.
const diff = sampleDate.diff(currentEnd, 'day');
if (diff <= 1) {
// Extend current period
currentEnd = sampleDate;
} else {
// Gap is too large, finalize current period and start new one
if (currentStart && currentEnd) {
records.push({
startDate: currentStart.format('YYYY-MM-DD'),
periodLength: currentEnd.diff(currentStart, 'day') + 1,
source: 'healthkit',
});
}
currentStart = sampleDate;
currentEnd = sampleDate;
}
}
// Push the last record
if (currentStart && currentEnd) {
records.push({
startDate: currentStart.format('YYYY-MM-DD'),
periodLength: currentEnd.diff(currentStart, 'day') + 1,
source: 'healthkit',
});
}
return records;
};

View File

@@ -14,10 +14,61 @@ const PREFERENCES_KEYS = {
MEDICATION_REMINDER_ENABLED: 'user_preference_medication_reminder_enabled', MEDICATION_REMINDER_ENABLED: 'user_preference_medication_reminder_enabled',
NUTRITION_REMINDER_ENABLED: 'user_preference_nutrition_reminder_enabled', NUTRITION_REMINDER_ENABLED: 'user_preference_nutrition_reminder_enabled',
MOOD_REMINDER_ENABLED: 'user_preference_mood_reminder_enabled', MOOD_REMINDER_ENABLED: 'user_preference_mood_reminder_enabled',
HRV_REMINDER_ENABLED: 'user_preference_hrv_reminder_enabled',
// 首页身体指标卡片显示设置
SHOW_MOOD_CARD: 'user_preference_show_mood_card',
SHOW_STEPS_CARD: 'user_preference_show_steps_card',
SHOW_STRESS_CARD: 'user_preference_show_stress_card',
SHOW_SLEEP_CARD: 'user_preference_show_sleep_card',
SHOW_FITNESS_RINGS_CARD: 'user_preference_show_fitness_rings_card',
SHOW_WATER_CARD: 'user_preference_show_water_card',
SHOW_BASAL_METABOLISM_CARD: 'user_preference_show_basal_metabolism_card',
SHOW_OXYGEN_SATURATION_CARD: 'user_preference_show_oxygen_saturation_card',
SHOW_MENSTRUAL_CYCLE_CARD: 'user_preference_show_menstrual_cycle_card',
SHOW_WEIGHT_CARD: 'user_preference_show_weight_card',
SHOW_CIRCUMFERENCE_CARD: 'user_preference_show_circumference_card',
SHOW_WRIST_TEMPERATURE_CARD: 'user_preference_show_wrist_temperature_card',
// 首页身体指标卡片排序设置
STATISTICS_CARD_ORDER: 'user_preference_statistics_card_order',
} as const; } as const;
// 首页身体指标卡片显示设置接口
export interface StatisticsCardsVisibility {
showMood: boolean;
showSteps: boolean;
showStress: boolean;
showSleep: boolean;
showFitnessRings: boolean;
showWater: boolean;
showBasalMetabolism: boolean;
showOxygenSaturation: boolean;
showMenstrualCycle: boolean;
showWeight: boolean;
showCircumference: boolean;
showWristTemperature: boolean;
}
// 默认卡片顺序
export const DEFAULT_CARD_ORDER: string[] = [
'mood',
'steps',
'stress',
'sleep',
'fitness',
'water',
'metabolism',
'oxygen',
'temperature',
'menstrual',
'weight',
'circumference',
];
// 用户偏好设置接口 // 用户偏好设置接口
export interface UserPreferences { export interface UserPreferences extends StatisticsCardsVisibility {
cardOrder: string[];
quickWaterAmount: number; quickWaterAmount: number;
waterGoal: number; waterGoal: number;
notificationEnabled: boolean; notificationEnabled: boolean;
@@ -30,6 +81,7 @@ export interface UserPreferences {
medicationReminderEnabled: boolean; medicationReminderEnabled: boolean;
nutritionReminderEnabled: boolean; nutritionReminderEnabled: boolean;
moodReminderEnabled: boolean; moodReminderEnabled: boolean;
hrvReminderEnabled: boolean;
} }
// 默认的用户偏好设置 // 默认的用户偏好设置
@@ -46,6 +98,24 @@ const DEFAULT_PREFERENCES: UserPreferences = {
medicationReminderEnabled: true, // 默认开启药品提醒 medicationReminderEnabled: true, // 默认开启药品提醒
nutritionReminderEnabled: true, // 默认开启营养提醒 nutritionReminderEnabled: true, // 默认开启营养提醒
moodReminderEnabled: true, // 默认开启心情提醒 moodReminderEnabled: true, // 默认开启心情提醒
hrvReminderEnabled: true, // 默认开启 HRV 压力提醒
// 默认显示所有卡片
showMood: true,
showSteps: true,
showStress: true,
showSleep: true,
showFitnessRings: true,
showWater: true,
showBasalMetabolism: true,
showOxygenSaturation: true,
showMenstrualCycle: true,
showWeight: true,
showCircumference: true,
showWristTemperature: true,
// 默认卡片顺序
cardOrder: DEFAULT_CARD_ORDER,
}; };
/** /**
@@ -65,6 +135,23 @@ export const getUserPreferences = async (): Promise<UserPreferences> => {
const medicationReminderEnabled = await AsyncStorage.getItem(PREFERENCES_KEYS.MEDICATION_REMINDER_ENABLED); const medicationReminderEnabled = await AsyncStorage.getItem(PREFERENCES_KEYS.MEDICATION_REMINDER_ENABLED);
const nutritionReminderEnabled = await AsyncStorage.getItem(PREFERENCES_KEYS.NUTRITION_REMINDER_ENABLED); const nutritionReminderEnabled = await AsyncStorage.getItem(PREFERENCES_KEYS.NUTRITION_REMINDER_ENABLED);
const moodReminderEnabled = await AsyncStorage.getItem(PREFERENCES_KEYS.MOOD_REMINDER_ENABLED); const moodReminderEnabled = await AsyncStorage.getItem(PREFERENCES_KEYS.MOOD_REMINDER_ENABLED);
const hrvReminderEnabled = await AsyncStorage.getItem(PREFERENCES_KEYS.HRV_REMINDER_ENABLED);
// 获取首页卡片显示设置
const showMood = await AsyncStorage.getItem(PREFERENCES_KEYS.SHOW_MOOD_CARD);
const showSteps = await AsyncStorage.getItem(PREFERENCES_KEYS.SHOW_STEPS_CARD);
const showStress = await AsyncStorage.getItem(PREFERENCES_KEYS.SHOW_STRESS_CARD);
const showSleep = await AsyncStorage.getItem(PREFERENCES_KEYS.SHOW_SLEEP_CARD);
const showFitnessRings = await AsyncStorage.getItem(PREFERENCES_KEYS.SHOW_FITNESS_RINGS_CARD);
const showWater = await AsyncStorage.getItem(PREFERENCES_KEYS.SHOW_WATER_CARD);
const showBasalMetabolism = await AsyncStorage.getItem(PREFERENCES_KEYS.SHOW_BASAL_METABOLISM_CARD);
const showOxygenSaturation = await AsyncStorage.getItem(PREFERENCES_KEYS.SHOW_OXYGEN_SATURATION_CARD);
const showMenstrualCycle = await AsyncStorage.getItem(PREFERENCES_KEYS.SHOW_MENSTRUAL_CYCLE_CARD);
const showWeight = await AsyncStorage.getItem(PREFERENCES_KEYS.SHOW_WEIGHT_CARD);
const showCircumference = await AsyncStorage.getItem(PREFERENCES_KEYS.SHOW_CIRCUMFERENCE_CARD);
const showWristTemperature = await AsyncStorage.getItem(PREFERENCES_KEYS.SHOW_WRIST_TEMPERATURE_CARD);
const cardOrderStr = await AsyncStorage.getItem(PREFERENCES_KEYS.STATISTICS_CARD_ORDER);
const cardOrder = cardOrderStr ? JSON.parse(cardOrderStr) : DEFAULT_PREFERENCES.cardOrder;
return { return {
quickWaterAmount: quickWaterAmount ? parseInt(quickWaterAmount, 10) : DEFAULT_PREFERENCES.quickWaterAmount, quickWaterAmount: quickWaterAmount ? parseInt(quickWaterAmount, 10) : DEFAULT_PREFERENCES.quickWaterAmount,
@@ -79,6 +166,21 @@ export const getUserPreferences = async (): Promise<UserPreferences> => {
medicationReminderEnabled: medicationReminderEnabled !== null ? medicationReminderEnabled === 'true' : DEFAULT_PREFERENCES.medicationReminderEnabled, medicationReminderEnabled: medicationReminderEnabled !== null ? medicationReminderEnabled === 'true' : DEFAULT_PREFERENCES.medicationReminderEnabled,
nutritionReminderEnabled: nutritionReminderEnabled !== null ? nutritionReminderEnabled === 'true' : DEFAULT_PREFERENCES.nutritionReminderEnabled, nutritionReminderEnabled: nutritionReminderEnabled !== null ? nutritionReminderEnabled === 'true' : DEFAULT_PREFERENCES.nutritionReminderEnabled,
moodReminderEnabled: moodReminderEnabled !== null ? moodReminderEnabled === 'true' : DEFAULT_PREFERENCES.moodReminderEnabled, moodReminderEnabled: moodReminderEnabled !== null ? moodReminderEnabled === 'true' : DEFAULT_PREFERENCES.moodReminderEnabled,
hrvReminderEnabled: hrvReminderEnabled !== null ? hrvReminderEnabled === 'true' : DEFAULT_PREFERENCES.hrvReminderEnabled,
showMood: showMood !== null ? showMood === 'true' : DEFAULT_PREFERENCES.showMood,
showSteps: showSteps !== null ? showSteps === 'true' : DEFAULT_PREFERENCES.showSteps,
showStress: showStress !== null ? showStress === 'true' : DEFAULT_PREFERENCES.showStress,
showSleep: showSleep !== null ? showSleep === 'true' : DEFAULT_PREFERENCES.showSleep,
showFitnessRings: showFitnessRings !== null ? showFitnessRings === 'true' : DEFAULT_PREFERENCES.showFitnessRings,
showWater: showWater !== null ? showWater === 'true' : DEFAULT_PREFERENCES.showWater,
showBasalMetabolism: showBasalMetabolism !== null ? showBasalMetabolism === 'true' : DEFAULT_PREFERENCES.showBasalMetabolism,
showOxygenSaturation: showOxygenSaturation !== null ? showOxygenSaturation === 'true' : DEFAULT_PREFERENCES.showOxygenSaturation,
showMenstrualCycle: showMenstrualCycle !== null ? showMenstrualCycle === 'true' : DEFAULT_PREFERENCES.showMenstrualCycle,
showWeight: showWeight !== null ? showWeight === 'true' : DEFAULT_PREFERENCES.showWeight,
showCircumference: showCircumference !== null ? showCircumference === 'true' : DEFAULT_PREFERENCES.showCircumference,
showWristTemperature: showWristTemperature !== null ? showWristTemperature === 'true' : DEFAULT_PREFERENCES.showWristTemperature,
cardOrder,
}; };
} catch (error) { } catch (error) {
console.error('获取用户偏好设置失败:', error); console.error('获取用户偏好设置失败:', error);
@@ -393,6 +495,7 @@ export const resetUserPreferences = async (): Promise<void> => {
await AsyncStorage.removeItem(PREFERENCES_KEYS.MEDICATION_REMINDER_ENABLED); await AsyncStorage.removeItem(PREFERENCES_KEYS.MEDICATION_REMINDER_ENABLED);
await AsyncStorage.removeItem(PREFERENCES_KEYS.NUTRITION_REMINDER_ENABLED); await AsyncStorage.removeItem(PREFERENCES_KEYS.NUTRITION_REMINDER_ENABLED);
await AsyncStorage.removeItem(PREFERENCES_KEYS.MOOD_REMINDER_ENABLED); await AsyncStorage.removeItem(PREFERENCES_KEYS.MOOD_REMINDER_ENABLED);
await AsyncStorage.removeItem(PREFERENCES_KEYS.HRV_REMINDER_ENABLED);
} catch (error) { } catch (error) {
console.error('重置用户偏好设置失败:', error); console.error('重置用户偏好设置失败:', error);
throw error; throw error;
@@ -476,3 +579,114 @@ export const getMoodReminderEnabled = async (): Promise<boolean> => {
return DEFAULT_PREFERENCES.moodReminderEnabled; return DEFAULT_PREFERENCES.moodReminderEnabled;
} }
}; };
export const setHRVReminderEnabled = async (enabled: boolean): Promise<void> => {
try {
await AsyncStorage.setItem(PREFERENCES_KEYS.HRV_REMINDER_ENABLED, enabled.toString());
} catch (error) {
console.error('设置 HRV 提醒开关失败:', error);
throw error;
}
};
export const getHRVReminderEnabled = async (): Promise<boolean> => {
try {
const enabled = await AsyncStorage.getItem(PREFERENCES_KEYS.HRV_REMINDER_ENABLED);
return enabled !== null ? enabled === 'true' : DEFAULT_PREFERENCES.hrvReminderEnabled;
} catch (error) {
console.error('获取 HRV 提醒开关状态失败:', error);
return DEFAULT_PREFERENCES.hrvReminderEnabled;
}
};
/**
* 获取首页卡片显示设置
*/
export const getStatisticsCardsVisibility = async (): Promise<StatisticsCardsVisibility> => {
try {
const userPreferences = await getUserPreferences();
return {
showMood: userPreferences.showMood,
showSteps: userPreferences.showSteps,
showStress: userPreferences.showStress,
showSleep: userPreferences.showSleep,
showFitnessRings: userPreferences.showFitnessRings,
showWater: userPreferences.showWater,
showBasalMetabolism: userPreferences.showBasalMetabolism,
showOxygenSaturation: userPreferences.showOxygenSaturation,
showMenstrualCycle: userPreferences.showMenstrualCycle,
showWeight: userPreferences.showWeight,
showCircumference: userPreferences.showCircumference,
showWristTemperature: userPreferences.showWristTemperature,
};
} catch (error) {
console.error('获取首页卡片显示设置失败:', error);
return {
showMood: DEFAULT_PREFERENCES.showMood,
showSteps: DEFAULT_PREFERENCES.showSteps,
showStress: DEFAULT_PREFERENCES.showStress,
showSleep: DEFAULT_PREFERENCES.showSleep,
showFitnessRings: DEFAULT_PREFERENCES.showFitnessRings,
showWater: DEFAULT_PREFERENCES.showWater,
showBasalMetabolism: DEFAULT_PREFERENCES.showBasalMetabolism,
showOxygenSaturation: DEFAULT_PREFERENCES.showOxygenSaturation,
showMenstrualCycle: DEFAULT_PREFERENCES.showMenstrualCycle,
showWeight: DEFAULT_PREFERENCES.showWeight,
showCircumference: DEFAULT_PREFERENCES.showCircumference,
showWristTemperature: DEFAULT_PREFERENCES.showWristTemperature,
};
}
};
/**
* 获取首页卡片顺序
*/
export const getStatisticsCardOrder = async (): Promise<string[]> => {
try {
const orderStr = await AsyncStorage.getItem(PREFERENCES_KEYS.STATISTICS_CARD_ORDER);
return orderStr ? JSON.parse(orderStr) : DEFAULT_CARD_ORDER;
} catch (error) {
console.error('获取首页卡片顺序失败:', error);
return DEFAULT_CARD_ORDER;
}
};
/**
* 设置首页卡片顺序
*/
export const setStatisticsCardOrder = async (order: string[]): Promise<void> => {
try {
await AsyncStorage.setItem(PREFERENCES_KEYS.STATISTICS_CARD_ORDER, JSON.stringify(order));
} catch (error) {
console.error('设置首页卡片顺序失败:', error);
throw error;
}
};
/**
* 设置首页卡片显示设置
*/
export const setStatisticsCardVisibility = async (key: keyof StatisticsCardsVisibility, value: boolean): Promise<void> => {
try {
let storageKey: string;
switch (key) {
case 'showMood': storageKey = PREFERENCES_KEYS.SHOW_MOOD_CARD; break;
case 'showSteps': storageKey = PREFERENCES_KEYS.SHOW_STEPS_CARD; break;
case 'showStress': storageKey = PREFERENCES_KEYS.SHOW_STRESS_CARD; break;
case 'showSleep': storageKey = PREFERENCES_KEYS.SHOW_SLEEP_CARD; break;
case 'showFitnessRings': storageKey = PREFERENCES_KEYS.SHOW_FITNESS_RINGS_CARD; break;
case 'showWater': storageKey = PREFERENCES_KEYS.SHOW_WATER_CARD; break;
case 'showBasalMetabolism': storageKey = PREFERENCES_KEYS.SHOW_BASAL_METABOLISM_CARD; break;
case 'showOxygenSaturation': storageKey = PREFERENCES_KEYS.SHOW_OXYGEN_SATURATION_CARD; break;
case 'showMenstrualCycle': storageKey = PREFERENCES_KEYS.SHOW_MENSTRUAL_CYCLE_CARD; break;
case 'showWeight': storageKey = PREFERENCES_KEYS.SHOW_WEIGHT_CARD; break;
case 'showCircumference': storageKey = PREFERENCES_KEYS.SHOW_CIRCUMFERENCE_CARD; break;
case 'showWristTemperature': storageKey = PREFERENCES_KEYS.SHOW_WRIST_TEMPERATURE_CARD; break;
default: return;
}
await AsyncStorage.setItem(storageKey, value.toString());
} catch (error) {
console.error(`设置首页卡片显示失败 (${key}):`, error);
throw error;
}
};