5 Commits

Author SHA1 Message Date
richarjiang
17664c679d feat(health): 新增日照时长监测卡片与 HealthKit 集成
- iOS 端集成 HealthKit 日照时间 (TimeInDaylight) 数据获取接口
- 新增 SunlightCard 组件,支持查看今日数据及最近30天历史趋势图表
- 更新统计页和自定义设置页,支持开启/关闭日照卡片
- 优化 HealthDataCard 组件,支持自定义图标组件和副标题显示
- 更新多语言文件及应用版本号至 1.1.6
2025-12-19 17:38:16 +08:00
richarjiang
e51aca2fdb feat(image): 封装 expo-image 组件以支持安全的图片请求头 2025-12-18 16:37:00 +08:00
richarjiang
76c37bfeb0 feat: 抽离 imaghe 组件,为图片增加 header 2025-12-18 16:36:53 +08:00
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
82 changed files with 3410 additions and 631 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

@@ -751,3 +751,44 @@ list: {
2. **保持翻译一致性**:相同含义的文本使用相同的翻译键 2. **保持翻译一致性**:相同含义的文本使用相同的翻译键
3. **定期审查**:定期检查是否有硬编码文本遗漏 3. **定期审查**:定期检查是否有硬编码文本遗漏
4. **测试验证**:在开发完成后测试语言切换功能是否正常 4. **测试验证**:在开发完成后测试语言切换功能是否正常
## Expo Image 封装与使用规范
**最后更新**: 2025-12-18
### 重要原则
**禁止直接使用 `expo-image` 的 `Image` 组件**,必须使用封装好的 `@/components/ui/Image` 组件。
### 问题描述
为了满足后端 API 安全要求,所有图片请求都需要携带特定的 `User-Agent``Referer` 请求头。`expo-image` 默认不会添加这些头信息。
### 解决方案
创建了一个封装组件 `@/components/ui/Image.tsx`,该组件自动拦截 `source` 属性并注入所需的请求头。
### 实现模式
#### 1. 替换导入语句
```typescript
// ❌ 禁止使用
import { Image } from "expo-image";
// ✅ 正确写法
import { Image } from "@/components/ui/Image";
```
#### 2. 组件功能
封装的组件会自动处理以下逻辑:
1. **注入 User-Agent**: 使用 `Out Live/{version} (iOS)` 格式
2. **注入 Referer**: 使用 `API_ORIGIN` 常量 (`https://pilate.richarjiang.com`)
3. **支持多种 Source 类型**: 自动处理 `string` (URL), `object` (带 uri), `number` (本地资源) 以及它们的数组形式
### 参考实现
- `components/ui/Image.tsx`: 核心封装实现
- `components/WorkoutSummaryCard.tsx`: 使用示例

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.5", "version": "1.1.6",
"orientation": "portrait", "orientation": "portrait",
"scheme": "digitalpilates", "scheme": "digitalpilates",
"userInterfaceStyle": "light", "userInterfaceStyle": "light",

View File

@@ -2,6 +2,7 @@ import dayjs from 'dayjs';
import ChallengeProgressCard from '@/components/challenges/ChallengeProgressCard'; import ChallengeProgressCard from '@/components/challenges/ChallengeProgressCard';
import { ConfirmationSheet } from '@/components/ui/ConfirmationSheet'; import { ConfirmationSheet } from '@/components/ui/ConfirmationSheet';
import { Image } from '@/components/ui/Image';
import { Colors } from '@/constants/Colors'; import { Colors } from '@/constants/Colors';
import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useAuthGuard } from '@/hooks/useAuthGuard'; import { useAuthGuard } from '@/hooks/useAuthGuard';
@@ -23,7 +24,6 @@ import {
import { Toast } from '@/utils/toast.utils'; import { Toast } from '@/utils/toast.utils';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect'; import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';

View File

@@ -4,6 +4,7 @@ import { MedicationCard } from '@/components/medication/MedicationCard';
import { TakenMedicationsStack } from '@/components/medication/TakenMedicationsStack'; import { TakenMedicationsStack } from '@/components/medication/TakenMedicationsStack';
import { ThemedText } from '@/components/ThemedText'; import { ThemedText } from '@/components/ThemedText';
import { IconSymbol } from '@/components/ui/IconSymbol'; import { IconSymbol } from '@/components/ui/IconSymbol';
import { Image } from '@/components/ui/Image';
import { MedicalDisclaimerSheet } from '@/components/ui/MedicalDisclaimerSheet'; import { MedicalDisclaimerSheet } from '@/components/ui/MedicalDisclaimerSheet';
import { MedicationAiSummaryInfoSheet } from '@/components/ui/MedicationAiSummaryInfoSheet'; import { MedicationAiSummaryInfoSheet } from '@/components/ui/MedicationAiSummaryInfoSheet';
import { Colors } from '@/constants/Colors'; import { Colors } from '@/constants/Colors';
@@ -20,7 +21,6 @@ import { useFocusEffect } from '@react-navigation/native';
import dayjs, { Dayjs } from 'dayjs'; import dayjs, { Dayjs } from 'dayjs';
import 'dayjs/locale/zh-cn'; import 'dayjs/locale/zh-cn';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect'; import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import { router } from 'expo-router'; import { router } from 'expo-router';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';

View File

@@ -10,7 +10,8 @@ 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 { Image } from '@/components/ui/Image';
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';
@@ -24,7 +25,6 @@ import { Ionicons } from '@expo/vector-icons';
import { useFocusEffect } from '@react-navigation/native'; import { useFocusEffect } from '@react-navigation/native';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect'; import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';

View File

@@ -7,6 +7,8 @@ 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 SunlightCard from '@/components/statistic/SunlightCard';
import WristTemperatureCard from '@/components/statistic/WristTemperatureCard';
import StepsCard from '@/components/StepsCard'; import StepsCard from '@/components/StepsCard';
import { StressMeter } from '@/components/StressMeter'; import { StressMeter } from '@/components/StressMeter';
import WaterIntakeCard from '@/components/WaterIntakeCard'; import WaterIntakeCard from '@/components/WaterIntakeCard';
@@ -109,9 +111,11 @@ export default function ExploreScreen() {
showWater: true, showWater: true,
showBasalMetabolism: true, showBasalMetabolism: true,
showOxygenSaturation: true, showOxygenSaturation: true,
showWristTemperature: true,
showMenstrualCycle: true, showMenstrualCycle: true,
showWeight: true, showWeight: true,
showCircumference: true, showCircumference: true,
showSunlight: true,
}); });
const [cardOrder, setCardOrder] = useState<string[]>(DEFAULT_CARD_ORDER); const [cardOrder, setCardOrder] = useState<string[]>(DEFAULT_CARD_ORDER);
@@ -443,7 +447,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}
@@ -579,6 +583,15 @@ export default function ExploreScreen() {
/> />
) )
}, },
sunlight: {
visible: cardVisibility.showSunlight,
component: (
<SunlightCard
selectedDate={currentSelectedDate}
style={styles.basalMetabolismCardOverride}
/>
)
},
fitness: { fitness: {
visible: cardVisibility.showFitnessRings, visible: cardVisibility.showFitnessRings,
component: ( component: (
@@ -615,6 +628,15 @@ export default function ExploreScreen() {
/> />
) )
}, },
temperature: {
visible: cardVisibility.showWristTemperature,
component: (
<WristTemperatureCard
selectedDate={currentSelectedDate}
style={styles.basalMetabolismCardOverride}
/>
)
},
menstrual: { menstrual: {
visible: cardVisibility.showMenstrualCycle, visible: cardVisibility.showMenstrualCycle,
component: ( component: (

View File

@@ -1,14 +1,14 @@
import { BadgeShowcaseModal } from '@/components/badges/BadgeShowcaseModal';
import { HeaderBar } from '@/components/ui/HeaderBar'; import { HeaderBar } from '@/components/ui/HeaderBar';
import { Image } from '@/components/ui/Image';
import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import type { BadgeDto } from '@/services/badges'; import type { BadgeDto } from '@/services/badges';
import { fetchAvailableBadges, selectBadgesLoading, selectSortedBadges } from '@/store/badgesSlice'; import { fetchAvailableBadges, selectBadgesLoading, selectSortedBadges } from '@/store/badgesSlice';
import { DEFAULT_MEMBER_NAME, selectUserProfile } from '@/store/userSlice'; import { DEFAULT_MEMBER_NAME, selectUserProfile } from '@/store/userSlice';
import { BadgeShowcaseModal } from '@/components/badges/BadgeShowcaseModal';
import { Toast } from '@/utils/toast.utils'; import { Toast } from '@/utils/toast.utils';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { useFocusEffect } from '@react-navigation/native'; import { useFocusEffect } from '@react-navigation/native';
import * as Haptics from 'expo-haptics'; import * as Haptics from 'expo-haptics';
import { Image } from 'expo-image';
import React, { useCallback, useMemo, useState } from 'react'; import React, { useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { FlatList, Pressable, RefreshControl, StyleSheet, Text, View } from 'react-native'; import { FlatList, Pressable, RefreshControl, StyleSheet, Text, View } from 'react-native';

View File

@@ -1,6 +1,7 @@
import ChallengeProgressCard from '@/components/challenges/ChallengeProgressCard'; import ChallengeProgressCard from '@/components/challenges/ChallengeProgressCard';
import { ChallengeRankingItem } from '@/components/challenges/ChallengeRankingItem'; import { ChallengeRankingItem } from '@/components/challenges/ChallengeRankingItem';
import { HeaderBar } from '@/components/ui/HeaderBar'; import { HeaderBar } from '@/components/ui/HeaderBar';
import { Image } from '@/components/ui/Image';
import { Colors } from '@/constants/Colors'; import { Colors } from '@/constants/Colors';
import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useAuthGuard } from '@/hooks/useAuthGuard'; import { useAuthGuard } from '@/hooks/useAuthGuard';
@@ -33,7 +34,6 @@ import { BlurView } from 'expo-blur';
import * as Clipboard from 'expo-clipboard'; import * as Clipboard from 'expo-clipboard';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect'; import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import * as Haptics from 'expo-haptics'; import * as Haptics from 'expo-haptics';
import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import { useLocalSearchParams, useRouter } from 'expo-router'; import { useLocalSearchParams, useRouter } from 'expo-router';
import LottieView from 'lottie-react-native'; import LottieView from 'lottie-react-native';

View File

@@ -1,8 +1,8 @@
import { Image } from '@/components/ui/Image';
import i18n from '@/i18n'; import i18n from '@/i18n';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { BlurView } from 'expo-blur'; import { BlurView } from 'expo-blur';
import * as Clipboard from 'expo-clipboard'; import * as Clipboard from 'expo-clipboard';
import { Image } from 'expo-image';
import * as ImagePicker from 'expo-image-picker'; import * as ImagePicker from 'expo-image-picker';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import { useLocalSearchParams, useRouter } from 'expo-router'; import { useLocalSearchParams, useRouter } from 'expo-router';

View File

@@ -18,6 +18,7 @@ import {
import Animated, { FadeInDown, FadeInUp, Layout } from 'react-native-reanimated'; import Animated, { FadeInDown, FadeInUp, Layout } from 'react-native-reanimated';
import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Image } from '@/components/ui/Image';
import { Colors } from '@/constants/Colors'; import { Colors } from '@/constants/Colors';
import { getTabBarBottomPadding } from '@/constants/TabBar'; import { getTabBarBottomPadding } from '@/constants/TabBar';
import { useAppSelector } from '@/hooks/redux'; import { useAppSelector } from '@/hooks/redux';
@@ -29,7 +30,6 @@ import { loadAiCoachSessionCache, saveAiCoachSessionCache } from '@/services/aiC
import { api, getAuthToken, postTextStream } from '@/services/api'; import { api, getAuthToken, postTextStream } from '@/services/api';
import { selectLatestMoodRecordByDate } from '@/store/moodSlice'; import { selectLatestMoodRecordByDate } from '@/store/moodSlice';
import { generateWelcomeMessage, hasRecordedMoodToday } from '@/utils/welcomeMessage'; import { generateWelcomeMessage, hasRecordedMoodToday } from '@/utils/welcomeMessage';
import { Image } from 'expo-image';
import { HistoryModal } from '../components/model/HistoryModal'; import { HistoryModal } from '../components/model/HistoryModal';
import { ActionSheet } from '../components/ui/ActionSheet'; import { ActionSheet } from '../components/ui/ActionSheet';

View File

@@ -1,6 +1,7 @@
import { CreateCustomFoodModal, type CustomFoodData } from '@/components/model/food/CreateCustomFoodModal'; import { CreateCustomFoodModal, type CustomFoodData } from '@/components/model/food/CreateCustomFoodModal';
import { FoodDetailModal } from '@/components/model/food/FoodDetailModal'; import { FoodDetailModal } from '@/components/model/food/FoodDetailModal';
import { HeaderBar } from '@/components/ui/HeaderBar'; import { HeaderBar } from '@/components/ui/HeaderBar';
import { Image } from '@/components/ui/Image';
import { Colors } from '@/constants/Colors'; import { Colors } from '@/constants/Colors';
import { DEFAULT_IMAGE_FOOD } from '@/constants/Image'; import { DEFAULT_IMAGE_FOOD } from '@/constants/Image';
import { useAppDispatch } from '@/hooks/redux'; import { useAppDispatch } from '@/hooks/redux';
@@ -13,7 +14,6 @@ import { fetchDailyNutritionData } from '@/store/nutritionSlice';
import type { FoodItem, MealType, SelectedFoodItem } from '@/types/food'; import type { FoodItem, MealType, SelectedFoodItem } from '@/types/food';
import { saveNutritionToHealthKit } from '@/utils/health'; import { saveNutritionToHealthKit } from '@/utils/health';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { Image } from 'expo-image';
import { useLocalSearchParams, useRouter } from 'expo-router'; import { useLocalSearchParams, useRouter } from 'expo-router';
import React, { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { import {

View File

@@ -1,5 +1,6 @@
import { CircularRing } from '@/components/CircularRing'; import { CircularRing } from '@/components/CircularRing';
import { HeaderBar } from '@/components/ui/HeaderBar'; import { HeaderBar } from '@/components/ui/HeaderBar';
import { Image } from '@/components/ui/Image';
import { Colors } from '@/constants/Colors'; import { Colors } from '@/constants/Colors';
import { ROUTES } from '@/constants/Routes'; import { ROUTES } from '@/constants/Routes';
import { useAppSelector } from '@/hooks/redux'; import { useAppSelector } from '@/hooks/redux';
@@ -9,7 +10,6 @@ import { addDietRecord, type CreateDietRecordDto, type MealType } from '@/servic
import { selectFoodRecognitionResult } from '@/store/foodRecognitionSlice'; import { selectFoodRecognitionResult } from '@/store/foodRecognitionSlice';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import { useLocalSearchParams, useRouter } from 'expo-router'; import { useLocalSearchParams, useRouter } from 'expo-router';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';

View File

@@ -1,4 +1,5 @@
import { HeaderBar } from '@/components/ui/HeaderBar'; import { HeaderBar } from '@/components/ui/HeaderBar';
import { Image } from '@/components/ui/Image';
import { Colors } from '@/constants/Colors'; import { Colors } from '@/constants/Colors';
import { useAuthGuard } from '@/hooks/useAuthGuard'; import { useAuthGuard } from '@/hooks/useAuthGuard';
import { useColorScheme } from '@/hooks/useColorScheme'; import { useColorScheme } from '@/hooks/useColorScheme';
@@ -6,7 +7,6 @@ import { useI18n } from '@/hooks/useI18n';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { CameraType, CameraView, useCameraPermissions } from 'expo-camera'; import { CameraType, CameraView, useCameraPermissions } from 'expo-camera';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect'; import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { Image } from 'expo-image';
import * as ImagePicker from 'expo-image-picker'; import * as ImagePicker from 'expo-image-picker';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import { useLocalSearchParams, useRouter } from 'expo-router'; import { useLocalSearchParams, useRouter } from 'expo-router';

View File

@@ -1,4 +1,5 @@
import { HeaderBar } from '@/components/ui/HeaderBar'; import { HeaderBar } from '@/components/ui/HeaderBar';
import { Image } from '@/components/ui/Image';
import { Colors } from '@/constants/Colors'; import { Colors } from '@/constants/Colors';
import { useMembershipModal } from '@/contexts/MembershipModalContext'; import { useMembershipModal } from '@/contexts/MembershipModalContext';
import { useAppDispatch } from '@/hooks/redux'; import { useAppDispatch } from '@/hooks/redux';
@@ -11,7 +12,6 @@ import { recognizeFood } from '@/services/foodRecognition';
import { saveRecognitionResult, setError, setLoading } from '@/store/foodRecognitionSlice'; import { saveRecognitionResult, setError, setLoading } from '@/store/foodRecognitionSlice';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect'; import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import { useLocalSearchParams, useRouter } from 'expo-router'; import { useLocalSearchParams, useRouter } from 'expo-router';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';

View File

@@ -1,4 +1,5 @@
import { HeaderBar } from '@/components/ui/HeaderBar'; import { HeaderBar } from '@/components/ui/HeaderBar';
import { Image } from '@/components/ui/Image';
import { Colors } from '@/constants/Colors'; import { Colors } from '@/constants/Colors';
import { useI18n } from '@/hooks/useI18n'; import { useI18n } from '@/hooks/useI18n';
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding'; import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
@@ -13,7 +14,6 @@ import { triggerLightHaptic } from '@/utils/haptics';
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 { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import React, { useCallback, useEffect, useState } from 'react'; import React, { useCallback, useEffect, useState } from 'react';

View File

@@ -1,4 +1,5 @@
import { HeaderBar } from '@/components/ui/HeaderBar'; import { HeaderBar } from '@/components/ui/HeaderBar';
import { Image } from '@/components/ui/Image';
import { Colors } from '@/constants/Colors'; import { Colors } from '@/constants/Colors';
import { useAuthGuard } from '@/hooks/useAuthGuard'; import { useAuthGuard } from '@/hooks/useAuthGuard';
import { useCosUpload } from '@/hooks/useCosUpload'; import { useCosUpload } from '@/hooks/useCosUpload';
@@ -12,7 +13,6 @@ import { triggerLightHaptic } from '@/utils/haptics';
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 { Image } from 'expo-image';
import * as ImagePicker from 'expo-image-picker'; import * as ImagePicker from 'expo-image-picker';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';

View File

@@ -1,4 +1,5 @@
import { HeaderBar } from '@/components/ui/HeaderBar'; import { HeaderBar } from '@/components/ui/HeaderBar';
import { Image as ExpoImage } from '@/components/ui/Image';
import { useMembershipModal } from '@/contexts/MembershipModalContext'; import { useMembershipModal } from '@/contexts/MembershipModalContext';
import { useAuthGuard } from '@/hooks/useAuthGuard'; import { useAuthGuard } from '@/hooks/useAuthGuard';
import { useVipService } from '@/hooks/useVipService'; import { useVipService } from '@/hooks/useVipService';
@@ -9,7 +10,6 @@ import { Ionicons } from '@expo/vector-icons';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import * as FileSystem from 'expo-file-system/legacy'; import * as FileSystem from 'expo-file-system/legacy';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect'; import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { Image as ExpoImage } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import * as MediaLibrary from 'expo-media-library'; import * as MediaLibrary from 'expo-media-library';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';

View File

@@ -5,6 +5,7 @@ import { HealthHistoryTab } from '@/components/health/tabs/HealthHistoryTab';
import { MedicalRecordsTab } from '@/components/health/tabs/MedicalRecordsTab'; import { MedicalRecordsTab } from '@/components/health/tabs/MedicalRecordsTab';
import { ConfirmationSheet } from '@/components/ui/ConfirmationSheet'; import { ConfirmationSheet } from '@/components/ui/ConfirmationSheet';
import { HeaderBar } from '@/components/ui/HeaderBar'; import { HeaderBar } from '@/components/ui/HeaderBar';
import { Image } from '@/components/ui/Image';
import { Colors } from '@/constants/Colors'; import { Colors } from '@/constants/Colors';
import { ROUTES } from '@/constants/Routes'; import { ROUTES } from '@/constants/Routes';
import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useAppDispatch, useAppSelector } from '@/hooks/redux';
@@ -24,7 +25,6 @@ import { DEFAULT_MEMBER_NAME } from '@/store/userSlice';
import { Toast } from '@/utils/toast.utils'; import { Toast } from '@/utils/toast.utils';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect'; import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import { Stack, useRouter } from 'expo-router'; import { Stack, useRouter } from 'expo-router';
import React, { useCallback, useEffect, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';

View File

@@ -2,6 +2,7 @@ import { ExpiryDatePickerModal } from '@/components/medications/ExpiryDatePicker
import { ThemedText } from '@/components/ThemedText'; import { ThemedText } from '@/components/ThemedText';
import { ConfirmationSheet } from '@/components/ui/ConfirmationSheet'; import { ConfirmationSheet } from '@/components/ui/ConfirmationSheet';
import { HeaderBar } from '@/components/ui/HeaderBar'; import { HeaderBar } from '@/components/ui/HeaderBar';
import { Image } from '@/components/ui/Image';
import InfoCard from '@/components/ui/InfoCard'; import InfoCard from '@/components/ui/InfoCard';
import { Colors } from '@/constants/Colors'; import { Colors } from '@/constants/Colors';
import { DOSAGE_UNITS, DOSAGE_VALUES, FORM_OPTIONS } from '@/constants/Medication'; import { DOSAGE_UNITS, DOSAGE_VALUES, FORM_OPTIONS } from '@/constants/Medication';
@@ -37,7 +38,6 @@ import { Picker } from '@react-native-picker/picker';
import Voice from '@react-native-voice/voice'; import Voice from '@react-native-voice/voice';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect'; import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { Image } from 'expo-image';
import * as ImagePicker from 'expo-image-picker'; import * as ImagePicker from 'expo-image-picker';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import { useLocalSearchParams, useRouter } from 'expo-router'; import { useLocalSearchParams, useRouter } from 'expo-router';

View File

@@ -1,6 +1,7 @@
import { ThemedText } from '@/components/ThemedText'; import { ThemedText } from '@/components/ThemedText';
import { HeaderBar } from '@/components/ui/HeaderBar'; import { HeaderBar } from '@/components/ui/HeaderBar';
import { IconSymbol } from '@/components/ui/IconSymbol'; import { IconSymbol } from '@/components/ui/IconSymbol';
import { Image } from '@/components/ui/Image';
import { Colors } from '@/constants/Colors'; import { Colors } from '@/constants/Colors';
import { DOSAGE_UNITS, FORM_OPTIONS } from '@/constants/Medication'; import { DOSAGE_UNITS, FORM_OPTIONS } from '@/constants/Medication';
import { useAppDispatch } from '@/hooks/redux'; import { useAppDispatch } from '@/hooks/redux';
@@ -15,7 +16,6 @@ import { Picker } from '@react-native-picker/picker';
import Voice from '@react-native-voice/voice'; import Voice from '@react-native-voice/voice';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect'; import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { Image } from 'expo-image';
import * as ImagePicker from 'expo-image-picker'; import * as ImagePicker from 'expo-image-picker';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import { router } from 'expo-router'; import { router } from 'expo-router';

View File

@@ -1,5 +1,6 @@
import { MedicationPhotoGuideModal } from '@/components/medications/MedicationPhotoGuideModal'; import { MedicationPhotoGuideModal } from '@/components/medications/MedicationPhotoGuideModal';
import { HeaderBar } from '@/components/ui/HeaderBar'; import { HeaderBar } from '@/components/ui/HeaderBar';
import { Image } from '@/components/ui/Image';
import { Colors } from '@/constants/Colors'; import { Colors } from '@/constants/Colors';
import { useAuthGuard } from '@/hooks/useAuthGuard'; import { useAuthGuard } from '@/hooks/useAuthGuard';
import { useColorScheme } from '@/hooks/useColorScheme'; import { useColorScheme } from '@/hooks/useColorScheme';
@@ -10,7 +11,6 @@ import { getItem, setItem } from '@/utils/kvStore';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { CameraView, useCameraPermissions } from 'expo-camera'; import { CameraView, useCameraPermissions } from 'expo-camera';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect'; import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { Image } from 'expo-image';
import * as ImagePicker from 'expo-image-picker'; import * as ImagePicker from 'expo-image-picker';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import { router } from 'expo-router'; import { router } from 'expo-router';

View File

@@ -1,11 +1,11 @@
import { HeaderBar } from '@/components/ui/HeaderBar'; import { HeaderBar } from '@/components/ui/HeaderBar';
import { Image } from '@/components/ui/Image';
import { Colors, palette } from '@/constants/Colors'; import { Colors, palette } from '@/constants/Colors';
import { useI18n } from '@/hooks/useI18n'; import { useI18n } from '@/hooks/useI18n';
import { getMedicationRecognitionStatus } from '@/services/medications'; import { getMedicationRecognitionStatus } from '@/services/medications';
import { MedicationRecognitionTask } from '@/types/medication'; import { MedicationRecognitionTask } from '@/types/medication';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect'; import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import { router, useLocalSearchParams } from 'expo-router'; import { router, useLocalSearchParams } from 'expo-router';
import React, { useEffect, useMemo, useRef, useState } from 'react'; import React, { useEffect, useMemo, useRef, useState } from 'react';

View File

@@ -2,6 +2,7 @@ import { ThemedText } from '@/components/ThemedText';
import { ConfirmationSheet } from '@/components/ui/ConfirmationSheet'; import { ConfirmationSheet } from '@/components/ui/ConfirmationSheet';
import { HeaderBar } from '@/components/ui/HeaderBar'; import { HeaderBar } from '@/components/ui/HeaderBar';
import { IconSymbol } from '@/components/ui/IconSymbol'; import { IconSymbol } from '@/components/ui/IconSymbol';
import { Image } from '@/components/ui/Image';
import { Colors } from '@/constants/Colors'; import { Colors } from '@/constants/Colors';
import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useColorScheme } from '@/hooks/useColorScheme'; import { useColorScheme } from '@/hooks/useColorScheme';
@@ -18,7 +19,6 @@ import type { Medication, MedicationForm } from '@/types/medication';
import { useFocusEffect } from '@react-navigation/native'; import { useFocusEffect } from '@react-navigation/native';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect'; import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import { router } from 'expo-router'; import { router } from 'expo-router';
import React, { useCallback, useMemo, useState } from 'react'; import React, { useCallback, useMemo, useState } from 'react';

View File

@@ -1,10 +1,9 @@
import { Ionicons } from '@expo/vector-icons';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import { Stack, useRouter } from 'expo-router'; import { Stack, useRouter } from 'expo-router';
import React, { useEffect, useMemo, useRef, useState } from 'react'; import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { import {
DimensionValue,
FlatList, FlatList,
StyleSheet, StyleSheet,
Text, Text,
@@ -12,144 +11,54 @@ import {
View, View,
} from 'react-native'; } from 'react-native';
import { Colors } from '@/constants/Colors'; import { InlineTip, ITEM_HEIGHT, Legend, MonthBlock } from '@/components/menstrual-cycle';
import { HeaderBar } from '@/components/ui/HeaderBar';
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
import { import {
deleteMenstrualFlow,
fetchMenstrualFlowSamples,
saveMenstrualFlow
} from '@/utils/health';
import {
buildMenstrualTimeline,
convertHealthKitSamplesToCycleRecords,
CycleRecord, CycleRecord,
DEFAULT_PERIOD_LENGTH, DEFAULT_PERIOD_LENGTH
MenstrualDayCell,
MenstrualDayStatus,
MenstrualTimeline,
buildMenstrualTimeline
} from '@/utils/menstrualCycle'; } from '@/utils/menstrualCycle';
type TabKey = 'cycle' | 'analysis'; type TabKey = 'cycle' | 'analysis';
const ITEM_HEIGHT = 380;
const STATUS_COLORS: Record<MenstrualDayStatus, { bg: string; text: string }> = {
period: { bg: '#f5679f', text: '#fff' },
'predicted-period': { bg: '#f8d9e9', text: '#9b2c6a' },
fertile: { bg: '#d9d2ff', text: '#5a52c5' },
'ovulation-day': { bg: '#5b4ee4', text: '#fff' },
};
const WEEK_LABELS = ['一', '二', '三', '四', '五', '六', '日'];
const chunkArray = <T,>(array: T[], size: number): T[][] => {
const result: T[][] = [];
for (let i = 0; i < array.length; i += size) {
result.push(array.slice(i, i + size));
}
return result;
};
const DayCell = ({
cell,
isSelected,
onPress,
}: {
cell: Extract<MenstrualDayCell, { type: 'day' }>;
isSelected: boolean;
onPress: () => void;
}) => {
const status = cell.info?.status;
const colors = status ? STATUS_COLORS[status] : undefined;
return (
<TouchableOpacity
activeOpacity={0.8}
style={styles.dayCell}
onPress={onPress}
>
<View
style={[
styles.dayCircle,
colors && { backgroundColor: colors.bg },
isSelected && styles.dayCircleSelected,
cell.isToday && styles.todayOutline,
]}
>
<Text
style={[
styles.dayLabel,
colors && { color: colors.text },
!colors && styles.dayLabelDefault,
]}
>
{cell.label}
</Text>
</View>
{cell.isToday && <Text style={styles.todayText}></Text>}
</TouchableOpacity>
);
};
const MonthBlock = ({
month,
selectedDateKey,
onSelect,
renderTip,
}: {
month: MenstrualTimeline['months'][number];
selectedDateKey: string;
onSelect: (dateKey: string) => void;
renderTip: (colIndex: number) => React.ReactNode;
}) => {
const weeks = useMemo(() => chunkArray(month.cells, 7), [month.cells]);
return (
<View style={styles.monthCard}>
<View style={styles.monthHeader}>
<Text style={styles.monthTitle}>{month.title}</Text>
<Text style={styles.monthSubtitle}>{month.subtitle}</Text>
</View>
<View style={styles.weekRow}>
{WEEK_LABELS.map((label) => (
<Text key={label} style={styles.weekLabel}>
{label}
</Text>
))}
</View>
<View style={styles.monthGrid}>
{weeks.map((week, weekIndex) => {
const selectedIndex = week.findIndex(
(c) => c.type === 'day' && c.date.format('YYYY-MM-DD') === selectedDateKey
);
return (
<React.Fragment key={weekIndex}>
<View style={styles.daysRow}>
{week.map((cell) => {
if (cell.type === 'placeholder') {
return <View key={cell.key} style={styles.dayCell} />;
}
const dateKey = cell.date.format('YYYY-MM-DD');
return (
<DayCell
key={cell.key}
cell={cell}
isSelected={selectedDateKey === dateKey}
onPress={() => onSelect(dateKey)}
/>
);
})}
</View>
{selectedIndex !== -1 && (
<View style={styles.inlineTipContainer}>
{renderTip(selectedIndex)}
</View>
)}
</React.Fragment>
);
})}
</View>
</View>
);
};
export default function MenstrualCycleScreen() { export default function MenstrualCycleScreen() {
const router = useRouter(); const router = useRouter();
const { t, i18n } = useTranslation();
const safeAreaTop = useSafeAreaTop();
const [records, setRecords] = useState<CycleRecord[]>([]); const [records, setRecords] = useState<CycleRecord[]>([]);
const [windowConfig, setWindowConfig] = useState({ before: 2, after: 3 }); const [windowConfig, setWindowConfig] = useState({ before: 2, after: 3 });
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( const timeline = useMemo(
() => () =>
buildMenstrualTimeline({ buildMenstrualTimeline({
@@ -157,8 +66,11 @@ export default function MenstrualCycleScreen() {
monthsAfter: windowConfig.after, monthsAfter: windowConfig.after,
records, records,
defaultPeriodLength: DEFAULT_PERIOD_LENGTH, defaultPeriodLength: DEFAULT_PERIOD_LENGTH,
locale,
monthTitleFormat,
monthSubtitleFormat,
}), }),
[records, windowConfig] [records, windowConfig, locale, monthSubtitleFormat, monthTitleFormat]
); );
const [activeTab, setActiveTab] = useState<TabKey>('cycle'); const [activeTab, setActiveTab] = useState<TabKey>('cycle');
const [selectedDateKey, setSelectedDateKey] = useState( const [selectedDateKey, setSelectedDateKey] = useState(
@@ -168,15 +80,32 @@ export default function MenstrualCycleScreen() {
const offsetRef = useRef(0); const offsetRef = useRef(0);
const prependDeltaRef = useRef(0); const prependDeltaRef = useRef(0);
const loadingPrevRef = useRef(false); const loadingPrevRef = useRef(false);
const hasAutoScrolledRef = useRef(false);
const todayMonthId = useMemo(() => dayjs().format('YYYY-MM'), []);
const selectedInfo = timeline.dayMap[selectedDateKey]; const selectedInfo = timeline.dayMap[selectedDateKey];
const selectedDate = dayjs(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]);
const handleMarkStart = () => { // 标记当天为经期开始(包含乐观更新与 HealthKit 同步)
const handleMarkStart = async () => {
if (selectedDate.isAfter(dayjs(), 'day')) return; if (selectedDate.isAfter(dayjs(), 'day')) return;
// Check if the selected date is already covered by an existing record (including duration) // Check if the selected date is already covered
const isCovered = records.some((r) => { const isCovered = records.some((r) => {
const start = dayjs(r.startDate); const start = dayjs(r.startDate);
const end = start.add((r.periodLength ?? DEFAULT_PERIOD_LENGTH) - 1, 'day'); const end = start.add((r.periodLength ?? DEFAULT_PERIOD_LENGTH) - 1, 'day');
@@ -187,45 +116,36 @@ export default function MenstrualCycleScreen() {
}); });
if (isCovered) return; if (isCovered) return;
// Optimistic Update
const originalRecords = [...records];
setRecords((prev) => { setRecords((prev) => {
const updated = [...prev]; const updated = [...prev];
// Logic for optimistic UI update (same as original logic)
// 1. Check if selectedDate is immediately after an existing period
const prevRecordIndex = updated.findIndex((r) => { const prevRecordIndex = updated.findIndex((r) => {
const start = dayjs(r.startDate); const start = dayjs(r.startDate);
const end = start.add((r.periodLength ?? DEFAULT_PERIOD_LENGTH) - 1, 'day'); const end = start.add((r.periodLength ?? DEFAULT_PERIOD_LENGTH) - 1, 'day');
return end.add(1, 'day').isSame(selectedDate, 'day'); return end.add(1, 'day').isSame(selectedDate, 'day');
}); });
// 2. Check if selectedDate is immediately before an existing period
const nextRecordIndex = updated.findIndex((r) => { const nextRecordIndex = updated.findIndex((r) => {
return dayjs(r.startDate).subtract(1, 'day').isSame(selectedDate, 'day'); return dayjs(r.startDate).subtract(1, 'day').isSame(selectedDate, 'day');
}); });
if (prevRecordIndex !== -1 && nextRecordIndex !== -1) { if (prevRecordIndex !== -1 && nextRecordIndex !== -1) {
// Merge three parts: Prev + Selected + Next
const prevRecord = updated[prevRecordIndex]; const prevRecord = updated[prevRecordIndex];
const nextRecord = updated[nextRecordIndex]; const nextRecord = updated[nextRecordIndex];
const newLength = const newLength =
(prevRecord.periodLength ?? DEFAULT_PERIOD_LENGTH) + (prevRecord.periodLength ?? DEFAULT_PERIOD_LENGTH) +
1 + 1 +
(nextRecord.periodLength ?? DEFAULT_PERIOD_LENGTH); (nextRecord.periodLength ?? DEFAULT_PERIOD_LENGTH);
updated[prevRecordIndex] = { ...prevRecord, periodLength: newLength };
updated[prevRecordIndex] = {
...prevRecord,
periodLength: newLength,
};
// Remove the next record since it's merged
updated.splice(nextRecordIndex, 1); updated.splice(nextRecordIndex, 1);
} else if (prevRecordIndex !== -1) { } else if (prevRecordIndex !== -1) {
// Extend previous record
const prevRecord = updated[prevRecordIndex]; const prevRecord = updated[prevRecordIndex];
updated[prevRecordIndex] = { updated[prevRecordIndex] = {
...prevRecord, ...prevRecord,
periodLength: (prevRecord.periodLength ?? DEFAULT_PERIOD_LENGTH) + 1, periodLength: (prevRecord.periodLength ?? DEFAULT_PERIOD_LENGTH) + 1,
}; };
} else if (nextRecordIndex !== -1) { } else if (nextRecordIndex !== -1) {
// Extend next record (start earlier)
const nextRecord = updated[nextRecordIndex]; const nextRecord = updated[nextRecordIndex];
updated[nextRecordIndex] = { updated[nextRecordIndex] = {
...nextRecord, ...nextRecord,
@@ -233,7 +153,6 @@ export default function MenstrualCycleScreen() {
periodLength: (nextRecord.periodLength ?? DEFAULT_PERIOD_LENGTH) + 1, periodLength: (nextRecord.periodLength ?? DEFAULT_PERIOD_LENGTH) + 1,
}; };
} else { } else {
// Create new isolated record
const newRecord: CycleRecord = { const newRecord: CycleRecord = {
startDate: selectedDate.format('YYYY-MM-DD'), startDate: selectedDate.format('YYYY-MM-DD'),
periodLength: 7, periodLength: 7,
@@ -241,18 +160,60 @@ export default function MenstrualCycleScreen() {
}; };
updated.push(newRecord); updated.push(newRecord);
} }
return updated.sort((a, b) => dayjs(a.startDate).valueOf() - dayjs(b.startDate).valueOf());
return updated.sort(
(a, b) => dayjs(a.startDate).valueOf() - dayjs(b.startDate).valueOf()
);
}); });
try {
// Determine what to save to HealthKit
// If we are merging or extending, we are effectively adding one day of flow
// If we are creating a new record, we default to 7 days
// However, accurate HealthKit logging should be per day.
// The previous UI logic "creates" a 7-day period for a single tap.
// We should replicate this behavior in HealthKit for consistency.
const isNewIsolatedRecord = !records.some((r) => {
const start = dayjs(r.startDate);
const end = start.add((r.periodLength ?? DEFAULT_PERIOD_LENGTH) - 1, 'day');
// Check adjacency
return (
end.add(1, 'day').isSame(selectedDate, 'day') ||
dayjs(r.startDate).subtract(1, 'day').isSame(selectedDate, 'day')
);
});
if (isNewIsolatedRecord) {
// Save 7 days of flow starting from selectedDate
const promises = [];
for (let i = 0; i < 7; i++) {
const date = selectedDate.add(i, 'day');
// Don't save future dates if they exceed today (though logic allows predicting)
// But for flow logging, we usually only log past/present.
// However, UI allows setting a period that might extend slightly?
// Let's stick to the selected date logic.
// Wait, if I tap "Mark Start", it creates a 7 day period.
// Should I write 7 samples? Yes, to match the UI state.
promises.push(saveMenstrualFlow(date.toDate(), 1, i === 0)); // 1=unspecified
}
await Promise.all(promises);
} else {
// Just adding a single day to bridge/extend
await saveMenstrualFlow(selectedDate.toDate(), 1, false);
}
} catch (error) {
console.error('Failed to save to HealthKit', error);
// Revert optimistic update
setRecords(originalRecords);
}
}; };
const handleCancelMark = () => { // 取消选中日期的经期标记(与 HealthKit 同步)
const handleCancelMark = async () => {
if (!selectedInfo || !selectedInfo.confirmed) return; if (!selectedInfo || !selectedInfo.confirmed) return;
if (selectedDate.isAfter(dayjs(), 'day')) return; if (selectedDate.isAfter(dayjs(), 'day')) return;
const target = selectedDate; const target = selectedDate;
// Optimistic Update
const originalRecords = [...records];
setRecords((prev) => { setRecords((prev) => {
const updated: CycleRecord[] = []; const updated: CycleRecord[] = [];
prev.forEach((record) => { prev.forEach((record) => {
@@ -264,23 +225,50 @@ export default function MenstrualCycleScreen() {
updated.push(record); updated.push(record);
return; return;
} }
if (diff === 0) return; // Remove entire record (or start of it)
if (diff === 0) { updated.push({ ...record, periodLength: diff }); // Shorten it
// 取消开始日:移除整段记录
return;
}
// diff > 0 且在区间内:将该日标记为结束日 (选中当日也被取消,所以长度为 diff)
updated.push({
...record,
periodLength: diff,
});
}); });
return updated; return updated;
}); });
try {
// Logic:
// 1. Find the record covering the target date
const record = records.find((r) => {
const start = dayjs(r.startDate);
const end = start.add((r.periodLength ?? DEFAULT_PERIOD_LENGTH) - 1, 'day');
return (
(target.isSame(start, 'day') || target.isAfter(start, 'day')) &&
(target.isSame(end, 'day') || target.isBefore(end, 'day'))
);
});
if (record) {
const start = dayjs(record.startDate);
const diff = target.diff(start, 'day');
if (diff === 0) {
// If cancelling the start date, the UI removes the ENTIRE period record.
// So we should delete all samples for this period range.
const periodLength = record.periodLength ?? DEFAULT_PERIOD_LENGTH;
const endDate = start.add(periodLength - 1, 'day');
await deleteMenstrualFlow(start.toDate(), endDate.toDate());
} else {
// If cancelling a middle/end date, the UI shortens the period to end BEFORE target.
// So we delete from target date onwards to the original end date.
const periodLength = record.periodLength ?? DEFAULT_PERIOD_LENGTH;
const originalEnd = start.add(periodLength - 1, 'day');
// Delete from target to originalEnd
await deleteMenstrualFlow(target.toDate(), originalEnd.toDate());
}
}
} catch (error) {
console.error('Failed to delete from HealthKit', error);
setRecords(originalRecords);
}
}; };
// 下拉到顶部时加载更早的月份
const handleLoadPrevious = () => { const handleLoadPrevious = () => {
if (loadingPrevRef.current) return; if (loadingPrevRef.current) return;
loadingPrevRef.current = true; loadingPrevRef.current = true;
@@ -289,6 +277,7 @@ export default function MenstrualCycleScreen() {
setWindowConfig((prev) => ({ ...prev, before: prev.before + delta })); setWindowConfig((prev) => ({ ...prev, before: prev.before + delta }));
}; };
// 向前追加月份时,保持当前视口位置不跳动
useEffect(() => { useEffect(() => {
if (prependDeltaRef.current > 0 && listRef.current) { if (prependDeltaRef.current > 0 && listRef.current) {
const offset = offsetRef.current + prependDeltaRef.current * ITEM_HEIGHT; const offset = offsetRef.current + prependDeltaRef.current * ITEM_HEIGHT;
@@ -304,6 +293,7 @@ export default function MenstrualCycleScreen() {
viewAreaCoveragePercentThreshold: 10, viewAreaCoveragePercentThreshold: 10,
}).current; }).current;
// 监测可视区域,接近顶部时触发加载更早月份
const onViewableItemsChanged = useRef(({ viewableItems }: any) => { const onViewableItemsChanged = useRef(({ viewableItems }: any) => {
const minIndex = viewableItems.reduce( const minIndex = viewableItems.reduce(
(acc: number, cur: any) => Math.min(acc, cur.index ?? acc), (acc: number, cur: any) => Math.min(acc, cur.index ?? acc),
@@ -314,28 +304,9 @@ export default function MenstrualCycleScreen() {
} }
}).current; }).current;
const renderLegend = () => (
<View style={styles.legendRow}>
{[
{ label: '经期', key: 'period' as const },
{ label: '预测经期', key: 'predicted-period' as const },
{ label: '排卵期', key: 'fertile' as const },
{ label: '排卵日', key: 'ovulation-day' as const },
].map((item) => (
<View key={item.key} style={styles.legendItem}>
<View
style={[
styles.legendDot,
{ backgroundColor: STATUS_COLORS[item.key].bg },
item.key === 'ovulation-day' && styles.legendDotRing,
]}
/>
<Text style={styles.legendLabel}>{item.label}</Text>
</View>
))}
</View>
);
// FlatList 数据源:按月份拆分
const listData = useMemo(() => { const listData = useMemo(() => {
return timeline.months.map((m) => ({ return timeline.months.map((m) => ({
type: 'month' as const, type: 'month' as const,
@@ -344,40 +315,19 @@ export default function MenstrualCycleScreen() {
})); }));
}, [timeline.months]); }, [timeline.months]);
const renderInlineTip = (columnIndex: number) => { const renderInlineTip = (columnIndex: number) => (
// 14.28% per cell. Center is 7.14%. <InlineTip
const pointerLeft = `${columnIndex * 14.2857 + 7.1428}%` as DimensionValue; selectedDate={selectedDate}
const isFuture = selectedDate.isAfter(dayjs(), 'day'); selectedInfo={selectedInfo}
columnIndex={columnIndex}
const base = ( onMarkStart={handleMarkStart}
<View style={styles.inlineTipCard}> onCancelMark={handleCancelMark}
<View style={[styles.inlineTipPointer, { left: pointerLeft }]} /> />
<View style={styles.inlineTipRow}> );
<View style={styles.inlineTipDate}>
<Ionicons name="calendar-outline" size={16} color="#111827" />
<Text style={styles.inlineTipDateText}>{selectedDate.format('M月D日')}</Text>
</View>
{!isFuture && (!selectedInfo || !selectedInfo.confirmed) && (
<TouchableOpacity style={styles.inlinePrimaryBtn} onPress={handleMarkStart}>
<Ionicons name="add" size={14} color="#fff" />
<Text style={styles.inlinePrimaryText}></Text>
</TouchableOpacity>
)}
{!isFuture && selectedInfo?.confirmed && selectedInfo.status === 'period' && (
<TouchableOpacity style={styles.inlineSecondaryBtn} onPress={handleCancelMark}>
<Text style={styles.inlineSecondaryText}></Text>
</TouchableOpacity>
)}
</View>
</View>
);
return base;
};
const renderCycleTab = () => ( const renderCycleTab = () => (
<View style={styles.tabContent}> <View style={styles.tabContent}>
{renderLegend()} <Legend />
<FlatList <FlatList
@@ -390,6 +340,7 @@ export default function MenstrualCycleScreen() {
selectedDateKey={selectedDateKey} selectedDateKey={selectedDateKey}
onSelect={(key) => setSelectedDateKey(key)} onSelect={(key) => setSelectedDateKey(key)}
renderTip={renderInlineTip} renderTip={renderInlineTip}
weekLabels={weekLabels}
/> />
)} )}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
@@ -398,6 +349,19 @@ export default function MenstrualCycleScreen() {
maxToRenderPerBatch={4} maxToRenderPerBatch={4}
removeClippedSubviews removeClippedSubviews
contentContainerStyle={styles.listContent} 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} viewabilityConfig={viewabilityConfig}
onViewableItemsChanged={onViewableItemsChanged} onViewableItemsChanged={onViewableItemsChanged}
onScroll={(e) => { onScroll={(e) => {
@@ -411,9 +375,9 @@ export default function MenstrualCycleScreen() {
const renderAnalysisTab = () => ( const renderAnalysisTab = () => (
<View style={styles.tabContent}> <View style={styles.tabContent}>
<View style={styles.analysisCard}> <View style={styles.analysisCard}>
<Text style={styles.analysisTitle}></Text> <Text style={styles.analysisTitle}>{t('menstrual.screen.analysis.title')}</Text>
<Text style={styles.analysisBody}> <Text style={styles.analysisBody}>
6 {t('menstrual.screen.analysis.description')}
</Text> </Text>
</View> </View>
</View> </View>
@@ -429,20 +393,35 @@ export default function MenstrualCycleScreen() {
end={{ x: 0, y: 1 }} end={{ x: 0, y: 1 }}
/> />
<View style={styles.header}> <HeaderBar
<TouchableOpacity onPress={() => router.back()} style={styles.headerIcon}> title={t('menstrual.screen.header')}
<Ionicons name="chevron-back" size={22} color="#0f172a" /> onBack={() => router.back()}
</TouchableOpacity> // right={
<Text style={styles.headerTitle}></Text> // isLiquidGlassAvailable() ? (
<TouchableOpacity style={styles.headerIcon}> // <TouchableOpacity style={styles.headerIconButton} activeOpacity={0.7}>
<Ionicons name="settings-outline" size={20} color="#0f172a" /> // <GlassView
</TouchableOpacity> // style={styles.headerIconGlass}
</View> // 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}> <View style={styles.tabSwitcher}>
{([ {([
{ key: 'cycle', label: '生理周期' }, { key: 'cycle', label: t('menstrual.screen.tabs.cycle') },
{ key: 'analysis', label: '分析' }, { key: 'analysis', label: t('menstrual.screen.tabs.analysis') },
] as { key: TabKey; label: string }[]).map((tab) => { ] as { key: TabKey; label: string }[]).map((tab) => {
const active = activeTab === tab.key; const active = activeTab === tab.key;
return ( return (
@@ -468,29 +447,29 @@ export default function MenstrualCycleScreen() {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
paddingTop: 52,
paddingHorizontal: 16, paddingHorizontal: 16,
backgroundColor: 'transparent', backgroundColor: 'transparent',
}, },
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 12,
},
headerIcon: { headerIcon: {
width: 36, width: 36,
height: 36, height: 36,
borderRadius: 18, borderRadius: 18,
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
backgroundColor: 'rgba(255,255,255,0.9)', backgroundColor: 'rgba(255, 255, 255, 0.5)',
}, },
headerTitle: { headerIconButton: {
fontSize: 18, width: 36,
fontWeight: '800', height: 36,
color: '#0f172a', borderRadius: 18,
fontFamily: 'AliBold', overflow: 'hidden',
},
headerIconGlass: {
width: 36,
height: 36,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
}, },
tabSwitcher: { tabSwitcher: {
flexDirection: 'row', flexDirection: 'row',
@@ -525,32 +504,7 @@ const styles = StyleSheet.create({
tabContent: { tabContent: {
flex: 1, flex: 1,
}, },
legendRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 12,
marginBottom: 12,
paddingHorizontal: 4,
},
legendItem: {
flexDirection: 'row',
alignItems: 'center',
},
legendDot: {
width: 16,
height: 16,
borderRadius: 8,
marginRight: 6,
},
legendDotRing: {
borderWidth: 2,
borderColor: '#fff',
},
legendLabel: {
fontSize: 13,
color: '#111827',
fontFamily: 'AliRegular',
},
selectedCard: { selectedCard: {
backgroundColor: '#fff', backgroundColor: '#fff',
borderRadius: 16, borderRadius: 16,
@@ -569,206 +523,7 @@ const styles = StyleSheet.create({
fontWeight: '700', fontWeight: '700',
fontFamily: 'AliBold', fontFamily: 'AliBold',
}, },
tipCard: {
backgroundColor: '#f4f3ff',
borderRadius: 14,
padding: 12,
marginTop: 10,
borderWidth: 1,
borderColor: '#ede9fe',
},
tipTitle: {
fontSize: 14,
color: '#111827',
fontWeight: '700',
marginBottom: 4,
fontFamily: 'AliBold',
},
tipDesc: {
fontSize: 12,
color: '#6b7280',
lineHeight: 18,
marginBottom: 8,
fontFamily: 'AliRegular',
},
tipButton: {
backgroundColor: Colors.light.primary,
paddingVertical: 10,
borderRadius: 12,
alignItems: 'center',
},
tipButtonText: {
color: '#fff',
fontSize: 14,
fontWeight: '700',
fontFamily: 'AliBold',
},
tipSecondaryButton: {
backgroundColor: '#fff',
paddingVertical: 10,
borderRadius: 12,
alignItems: 'center',
borderWidth: 1,
borderColor: '#e5e7eb',
},
tipSecondaryButtonText: {
color: '#0f172a',
fontSize: 14,
fontWeight: '700',
fontFamily: 'AliBold',
},
inlineTipCard: {
backgroundColor: '#e8e7ff',
borderRadius: 18,
paddingVertical: 10,
paddingHorizontal: 12,
shadowColor: '#000',
shadowOpacity: 0.04,
shadowRadius: 6,
shadowOffset: { width: 0, height: 2 },
elevation: 1,
},
inlineTipPointer: {
position: 'absolute',
top: -6,
width: 12,
height: 12,
marginLeft: -6,
backgroundColor: '#e8e7ff',
transform: [{ rotate: '45deg' }],
borderRadius: 3,
},
inlineTipRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: 8,
},
inlineTipDate: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
inlineTipDateText: {
fontSize: 14,
color: '#111827',
fontWeight: '800',
fontFamily: 'AliBold',
},
inlinePrimaryBtn: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: Colors.light.primary,
paddingHorizontal: 12,
paddingVertical: 8,
borderRadius: 14,
gap: 6,
},
inlinePrimaryText: {
color: '#fff',
fontSize: 13,
fontWeight: '700',
fontFamily: 'AliBold',
},
inlineSecondaryBtn: {
paddingHorizontal: 12,
paddingVertical: 8,
borderRadius: 14,
backgroundColor: '#fff',
borderWidth: 1,
borderColor: '#d1d5db',
},
inlineSecondaryText: {
color: '#111827',
fontSize: 13,
fontWeight: '700',
fontFamily: 'AliBold',
},
monthCard: {
backgroundColor: '#fff',
borderRadius: 16,
padding: 14,
marginBottom: 12,
shadowColor: '#000',
shadowOpacity: 0.08,
shadowRadius: 8,
shadowOffset: { width: 0, height: 6 },
elevation: 2,
},
monthHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 8,
},
monthTitle: {
fontSize: 17,
fontWeight: '800',
color: '#0f172a',
fontFamily: 'AliBold',
},
monthSubtitle: {
fontSize: 12,
color: '#6b7280',
fontFamily: 'AliRegular',
},
weekRow: {
flexDirection: 'row',
justifyContent: 'space-between',
marginBottom: 6,
paddingHorizontal: 4,
},
weekLabel: {
width: '14.28%',
textAlign: 'center',
fontSize: 12,
color: '#94a3b8',
fontFamily: 'AliRegular',
},
monthGrid: {
flexDirection: 'column',
},
daysRow: {
flexDirection: 'row',
},
dayCell: {
width: '14.28%',
alignItems: 'center',
marginVertical: 6,
},
inlineTipContainer: {
paddingBottom: 6,
marginBottom: 6,
},
dayCircle: {
width: 40,
height: 40,
borderRadius: 20,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#f3f4f6',
},
dayCircleSelected: {
borderWidth: 2,
borderColor: Colors.light.primary,
},
todayOutline: {
borderWidth: 2,
borderColor: '#94a3b8',
},
dayLabel: {
fontSize: 15,
fontFamily: 'AliBold',
},
dayLabelDefault: {
color: '#111827',
},
todayText: {
fontSize: 10,
color: '#9ca3af',
marginTop: 2,
fontFamily: 'AliRegular',
},
listContent: { listContent: {
paddingBottom: 80, paddingBottom: 80,
}, },

View File

@@ -3,6 +3,7 @@ import { DateSelector } from '@/components/DateSelector';
import { FloatingFoodOverlay } from '@/components/FloatingFoodOverlay'; import { FloatingFoodOverlay } from '@/components/FloatingFoodOverlay';
import { NutritionRecordCard } from '@/components/NutritionRecordCard'; import { NutritionRecordCard } from '@/components/NutritionRecordCard';
import { HeaderBar } from '@/components/ui/HeaderBar'; import { HeaderBar } from '@/components/ui/HeaderBar';
import { Image } from '@/components/ui/Image';
import { Colors } from '@/constants/Colors'; import { Colors } from '@/constants/Colors';
import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useAuthGuard } from '@/hooks/useAuthGuard'; import { useAuthGuard } from '@/hooks/useAuthGuard';
@@ -27,7 +28,6 @@ import { Ionicons } from '@expo/vector-icons';
import { useFocusEffect } from '@react-navigation/native'; import { useFocusEffect } from '@react-navigation/native';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect'; import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import { router } from 'expo-router'; import { router } from 'expo-router';
import React, { useCallback, useEffect, useState } from 'react'; import React, { useCallback, useEffect, useState } from 'react';

View File

@@ -1,4 +1,5 @@
import { HeaderBar } from '@/components/ui/HeaderBar'; import { HeaderBar } from '@/components/ui/HeaderBar';
import { Image } from '@/components/ui/Image';
import { Colors } from '@/constants/Colors'; import { Colors } from '@/constants/Colors';
import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useColorScheme } from '@/hooks/useColorScheme'; import { useColorScheme } from '@/hooks/useColorScheme';
@@ -13,7 +14,6 @@ import { Ionicons } from '@expo/vector-icons';
import DateTimePicker from '@react-native-community/datetimepicker'; import DateTimePicker from '@react-native-community/datetimepicker';
import { Picker } from '@react-native-picker/picker'; import { Picker } from '@react-native-picker/picker';
import { useFocusEffect } from '@react-navigation/native'; import { useFocusEffect } from '@react-navigation/native';
import { Image } from 'expo-image';
import * as ImagePicker from 'expo-image-picker'; import * as ImagePicker from 'expo-image-picker';
import { router } from 'expo-router'; import { router } from 'expo-router';
import React, { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';

View File

@@ -2,6 +2,7 @@ import { InfoModal, type SleepDetailData } from '@/components/sleep/InfoModal';
import { SleepStagesInfoModal } from '@/components/sleep/SleepStagesInfoModal'; import { SleepStagesInfoModal } from '@/components/sleep/SleepStagesInfoModal';
import { SleepStageTimeline } from '@/components/sleep/SleepStageTimeline'; import { SleepStageTimeline } from '@/components/sleep/SleepStageTimeline';
import { HeaderBar } from '@/components/ui/HeaderBar'; import { HeaderBar } from '@/components/ui/HeaderBar';
import { Image } from '@/components/ui/Image';
import { useColorScheme } from '@/hooks/useColorScheme'; import { useColorScheme } from '@/hooks/useColorScheme';
import { useI18n } from '@/hooks/useI18n'; import { useI18n } from '@/hooks/useI18n';
import { import {
@@ -14,7 +15,6 @@ import {
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 { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import { router, useLocalSearchParams } from 'expo-router'; import { router, useLocalSearchParams } from 'expo-router';
import React, { useCallback, useEffect, useState } from 'react'; import React, { useCallback, useEffect, useState } from 'react';

View File

@@ -42,10 +42,12 @@ export default function StatisticsCustomizationScreen() {
steps: { icon: 'footsteps-outline', titleKey: 'statisticsCustomization.items.steps', visibilityKey: 'showSteps' }, steps: { icon: 'footsteps-outline', titleKey: 'statisticsCustomization.items.steps', visibilityKey: 'showSteps' },
stress: { icon: 'pulse-outline', titleKey: 'statisticsCustomization.items.stress', visibilityKey: 'showStress' }, stress: { icon: 'pulse-outline', titleKey: 'statisticsCustomization.items.stress', visibilityKey: 'showStress' },
sleep: { icon: 'moon-outline', titleKey: 'statisticsCustomization.items.sleep', visibilityKey: 'showSleep' }, sleep: { icon: 'moon-outline', titleKey: 'statisticsCustomization.items.sleep', visibilityKey: 'showSleep' },
sunlight: { icon: 'sunny-outline', titleKey: 'statisticsCustomization.items.sunlight', visibilityKey: 'showSunlight' },
fitness: { icon: 'fitness-outline', titleKey: 'statisticsCustomization.items.fitnessRings', visibilityKey: 'showFitnessRings' }, fitness: { icon: 'fitness-outline', titleKey: 'statisticsCustomization.items.fitnessRings', visibilityKey: 'showFitnessRings' },
water: { icon: 'water-outline', titleKey: 'statisticsCustomization.items.water', visibilityKey: 'showWater' }, water: { icon: 'water-outline', titleKey: 'statisticsCustomization.items.water', visibilityKey: 'showWater' },
metabolism: { icon: 'flame-outline', titleKey: 'statisticsCustomization.items.basalMetabolism', visibilityKey: 'showBasalMetabolism' }, metabolism: { icon: 'flame-outline', titleKey: 'statisticsCustomization.items.basalMetabolism', visibilityKey: 'showBasalMetabolism' },
oxygen: { icon: 'water-outline', titleKey: 'statisticsCustomization.items.oxygenSaturation', visibilityKey: 'showOxygenSaturation' }, oxygen: { icon: 'water-outline', titleKey: 'statisticsCustomization.items.oxygenSaturation', visibilityKey: 'showOxygenSaturation' },
temperature: { icon: 'thermometer-outline', titleKey: 'statisticsCustomization.items.wristTemperature', visibilityKey: 'showWristTemperature' },
menstrual: { icon: 'rose-outline', titleKey: 'statisticsCustomization.items.menstrualCycle', visibilityKey: 'showMenstrualCycle' }, menstrual: { icon: 'rose-outline', titleKey: 'statisticsCustomization.items.menstrualCycle', visibilityKey: 'showMenstrualCycle' },
weight: { icon: 'scale-outline', titleKey: 'statisticsCustomization.items.weight', visibilityKey: 'showWeight' }, weight: { icon: 'scale-outline', titleKey: 'statisticsCustomization.items.weight', visibilityKey: 'showWeight' },
circumference: { icon: 'body-outline', titleKey: 'statisticsCustomization.items.circumference', visibilityKey: 'showCircumference' }, circumference: { icon: 'body-outline', titleKey: 'statisticsCustomization.items.circumference', visibilityKey: 'showCircumference' },

View File

@@ -1,10 +1,10 @@
import { Image } from '@/components/ui/Image';
import { Colors } from '@/constants/Colors'; import { Colors } from '@/constants/Colors';
import { useColorScheme } from '@/hooks/useColorScheme'; import { useColorScheme } from '@/hooks/useColorScheme';
import { useWaterDataByDate } from '@/hooks/useWaterData'; import { useWaterDataByDate } from '@/hooks/useWaterData';
import { getQuickWaterAmount } from '@/utils/userPreferences'; import { getQuickWaterAmount } from '@/utils/userPreferences';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect'; import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import { router, useLocalSearchParams } from 'expo-router'; import { router, useLocalSearchParams } from 'expo-router';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';

View File

@@ -1,10 +1,10 @@
import { Image } from '@/components/ui/Image';
import { Colors } from '@/constants/Colors'; import { Colors } from '@/constants/Colors';
import { useColorScheme } from '@/hooks/useColorScheme'; import { useColorScheme } from '@/hooks/useColorScheme';
import { useWaterDataByDate } from '@/hooks/useWaterData'; import { useWaterDataByDate } from '@/hooks/useWaterData';
import { getQuickWaterAmount, setQuickWaterAmount } from '@/utils/userPreferences'; import { getQuickWaterAmount, setQuickWaterAmount } from '@/utils/userPreferences';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { Image } from 'expo-image';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { import {
Alert, Alert,

View File

@@ -1,9 +1,9 @@
import { Image } from '@/components/ui/Image';
import { ROUTES } from '@/constants/Routes'; import { ROUTES } from '@/constants/Routes';
import { useAppSelector } from '@/hooks/redux'; import { useAppSelector } from '@/hooks/redux';
import { selectUserAge, selectUserProfile } from '@/store/userSlice'; import { selectUserAge, selectUserProfile } from '@/store/userSlice';
import { fetchBasalEnergyBurned } from '@/utils/health'; import { fetchBasalEnergyBurned } from '@/utils/health';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { Image } from 'expo-image';
import { router } from 'expo-router'; import { router } from 'expo-router';
import React, { useCallback, 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';

View File

@@ -1,14 +1,33 @@
import dayjs, { Dayjs } from 'dayjs';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import React, { useMemo } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { Colors } from '@/constants/Colors'; import { Colors } from '@/constants/Colors';
import { buildMenstrualTimeline } from '@/utils/menstrualCycle'; import { fetchMenstrualFlowSamples, healthDataEvents } from '@/utils/health';
import {
buildMenstrualTimeline,
convertHealthKitSamplesToCycleRecords,
CycleRecord,
DEFAULT_PERIOD_LENGTH,
MenstrualDayInfo,
MenstrualDayStatus,
MenstrualTimeline,
} from '@/utils/menstrualCycle';
type Props = { type Props = {
onPress?: () => void; onPress?: () => void;
}; };
type Summary = {
state: string;
prefix?: string;
suffix?: string;
number?: number;
fallbackText: string;
};
const RingIcon = () => ( const RingIcon = () => (
<View style={styles.iconWrapper}> <View style={styles.iconWrapper}>
<LinearGradient <LinearGradient
@@ -23,45 +42,79 @@ const RingIcon = () => (
); );
export const MenstrualCycleCard: React.FC<Props> = ({ onPress }) => { export const MenstrualCycleCard: React.FC<Props> = ({ onPress }) => {
const { todayInfo, periodLength } = useMemo(() => buildMenstrualTimeline(), []); const { t } = useTranslation();
const [records, setRecords] = useState<CycleRecord[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
let mounted = true;
const loadMenstrualData = async () => {
// 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(() => { const summary = useMemo(() => {
if (!todayInfo) { if (loading && records.length === 0) {
return { return {
state: '待记录', state: t('menstrual.card.syncingState'),
dayText: '点击记录本次经期', fallbackText: t('menstrual.card.syncingDesc'),
number: undefined,
}; };
} }
if (todayInfo.status === 'period' || todayInfo.status === 'predicted-period') { return deriveSummary(timeline, records.length > 0, t);
return { }, [loading, records.length, timeline, t]);
state: todayInfo.status === 'period' ? '经期' : '预测经期',
dayText: '天',
number: todayInfo.dayOfCycle ?? 1,
};
}
if (todayInfo.status === 'ovulation-day') {
return {
state: '排卵日',
dayText: '易孕窗口',
number: undefined,
};
}
return {
state: '排卵期',
dayText: `距离排卵日${Math.max(periodLength - 1, 1)}`,
number: undefined,
};
}, [periodLength, todayInfo]);
return ( return (
<TouchableOpacity activeOpacity={0.92} onPress={onPress} style={styles.wrapper}> <TouchableOpacity activeOpacity={0.92} onPress={onPress} style={styles.wrapper}>
<View style={styles.headerRow}> <View style={styles.headerRow}>
<RingIcon /> <RingIcon />
<Text style={styles.title}></Text> <Text style={styles.title}>{t('menstrual.card.title')}</Text>
<View style={styles.badgeOuter}> <View style={styles.badgeOuter}>
<View style={styles.badgeInner} /> <View style={styles.badgeInner} />
</View> </View>
@@ -71,10 +124,12 @@ export const MenstrualCycleCard: React.FC<Props> = ({ onPress }) => {
<Text style={styles.dayRow}> <Text style={styles.dayRow}>
{summary.number !== undefined ? ( {summary.number !== undefined ? (
<> <>
<Text style={styles.dayNumber}>{summary.number}</Text> {summary.dayText} {summary.prefix}
<Text style={styles.dayNumber}>{summary.number}</Text>
{summary.suffix}
</> </>
) : ( ) : (
summary.dayText summary.fallbackText
)} )}
</Text> </Text>
</View> </View>
@@ -82,6 +137,199 @@ export const MenstrualCycleCard: React.FC<Props> = ({ onPress }) => {
); );
}; };
const periodStatuses = new Set<MenstrualDayStatus>(['period', 'predicted-period']);
const fertileStatuses = new Set<MenstrualDayStatus>(['fertile', 'ovulation-day']);
const ovulationStatuses = new Set<MenstrualDayStatus>(['ovulation-day']);
const deriveSummary = (
timeline: MenstrualTimeline,
hasRecords: boolean,
t: (key: string, options?: Record<string, any>) => string
): Summary => {
const today = dayjs();
const { dayMap, todayInfo } = timeline;
if (!hasRecords || !Object.keys(dayMap).length) {
return {
state: t('menstrual.card.emptyState'),
fallbackText: t('menstrual.card.emptyDesc'),
};
}
const sortedInfos = Object.values(dayMap).sort(
(a, b) => a.date.valueOf() - b.date.valueOf()
);
const findContinuousRange = (
date: Dayjs,
targetStatuses: Set<MenstrualDayStatus>
): { start: Dayjs; end: Dayjs } | null => {
const key = date.format('YYYY-MM-DD');
if (!targetStatuses.has(dayMap[key]?.status)) return null;
let start = date;
let end = date;
while (true) {
const prev = start.subtract(1, 'day');
const prevInfo = dayMap[prev.format('YYYY-MM-DD')];
if (prevInfo && targetStatuses.has(prevInfo.status)) {
start = prev;
} else {
break;
}
}
while (true) {
const next = end.add(1, 'day');
const nextInfo = dayMap[next.format('YYYY-MM-DD')];
if (nextInfo && targetStatuses.has(nextInfo.status)) {
end = next;
} else {
break;
}
}
return { start, end };
};
const findFutureStatus = (
targetStatuses: Set<MenstrualDayStatus>,
inclusive = true
): MenstrualDayInfo | undefined => {
return sortedInfos.find((info) => {
const isInRange = inclusive
? !info.date.isBefore(today, 'day')
: info.date.isAfter(today, 'day');
return isInRange && targetStatuses.has(info.status);
});
};
const findPastStatus = (targetStatuses: Set<MenstrualDayStatus>) => {
for (let i = sortedInfos.length - 1; i >= 0; i -= 1) {
const info = sortedInfos[i];
if (!info.date.isAfter(today, 'day') && targetStatuses.has(info.status)) {
return info;
}
}
return undefined;
};
if (todayInfo && periodStatuses.has(todayInfo.status)) {
const range = findContinuousRange(today, periodStatuses);
const end = range?.end ?? today;
const daysLeft = Math.max(end.diff(today, 'day'), 0);
if (daysLeft === 0) {
return {
state:
todayInfo.status === 'period'
? t('menstrual.card.periodState')
: t('menstrual.card.predictedPeriodState'),
fallbackText: t('menstrual.card.periodEndToday', {
date: end.format(t('menstrual.dateFormatShort')),
}),
};
}
return {
state:
todayInfo.status === 'period'
? t('menstrual.card.periodState')
: t('menstrual.card.predictedPeriodState'),
prefix: t('menstrual.card.periodEndPrefix'),
number: daysLeft,
suffix: t('menstrual.card.periodEndSuffix', {
date: end.format(t('menstrual.dateFormatShort')),
}),
fallbackText: '',
};
}
const nextPeriod = findFutureStatus(periodStatuses, false);
const lastPeriodInfo = findPastStatus(periodStatuses);
const lastPeriodStart = lastPeriodInfo
? findContinuousRange(lastPeriodInfo.date, periodStatuses)?.start
: undefined;
const ovulationThisCycle = sortedInfos.find((info) => {
if (!ovulationStatuses.has(info.status)) return false;
if (lastPeriodStart && info.date.isBefore(lastPeriodStart, 'day')) return false;
if (nextPeriod && !info.date.isBefore(nextPeriod.date, 'day')) return false;
return true;
});
if (todayInfo?.status === 'fertile') {
const targetOvulation = ovulationThisCycle ?? findFutureStatus(ovulationStatuses);
if (targetOvulation) {
const days = Math.max(targetOvulation.date.diff(today, 'day'), 0);
if (days === 0) {
return {
state: t('menstrual.card.fertileState'),
fallbackText: t('menstrual.card.ovulationToday'),
};
}
return {
state: t('menstrual.card.fertileState'),
prefix: t('menstrual.card.ovulationCountdownPrefix'),
number: days,
suffix: t('menstrual.card.ovulationCountdownSuffix'),
fallbackText: '',
};
}
}
const nextFertile = findFutureStatus(fertileStatuses);
if (nextFertile && (!nextPeriod || nextFertile.date.isBefore(nextPeriod.date))) {
const days = Math.max(nextFertile.date.diff(today, 'day'), 0);
if (days === 0) {
return {
state: t('menstrual.card.fertileState'),
fallbackText: t('menstrual.card.fertileToday'),
};
}
return {
state: t('menstrual.card.fertileState'),
prefix: t('menstrual.card.fertileCountdownPrefix'),
number: days,
suffix: t('menstrual.card.fertileCountdownSuffix'),
fallbackText: '',
};
}
if (
ovulationThisCycle &&
nextPeriod &&
today.isAfter(ovulationThisCycle.date, 'day') &&
today.isBefore(nextPeriod.date, 'day')
) {
const days = Math.max(nextPeriod.date.diff(today, 'day'), 0);
return {
state: t('menstrual.card.periodState'),
prefix: t('menstrual.card.nextPeriodPrefix'),
number: days,
suffix: t('menstrual.card.nextPeriodSuffix'),
fallbackText: '',
};
}
if (nextPeriod) {
const days = Math.max(nextPeriod.date.diff(today, 'day'), 0);
return {
state: t('menstrual.card.periodState'),
prefix: t('menstrual.card.nextPeriodPrefix'),
number: days,
suffix: t('menstrual.card.nextPeriodSuffix'),
fallbackText: '',
};
}
return {
state: t('menstrual.card.emptyState'),
fallbackText: t('menstrual.card.emptyDesc'),
};
};
const styles = StyleSheet.create({ const styles = StyleSheet.create({
wrapper: { wrapper: {
width: '100%', width: '100%',

View File

@@ -1,10 +1,10 @@
import { ThemedText } from '@/components/ThemedText'; import { ThemedText } from '@/components/ThemedText';
import { Image } from '@/components/ui/Image';
import { useI18n } from '@/hooks/useI18n'; import { useI18n } from '@/hooks/useI18n';
import { useThemeColor } from '@/hooks/useThemeColor'; import { useThemeColor } from '@/hooks/useThemeColor';
import { DietRecord } from '@/services/dietRecords'; import { DietRecord } from '@/services/dietRecords';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { Image } from 'expo-image';
import React, { useMemo, useRef, useState } from 'react'; import React, { useMemo, useRef, useState } from 'react';
import { Alert, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { Alert, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { RectButton, Swipeable } from 'react-native-gesture-handler'; import { RectButton, Swipeable } from 'react-native-gesture-handler';

View File

@@ -1,21 +1,21 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { import {
Animated, Animated,
InteractionManager, InteractionManager,
StyleSheet, StyleSheet,
Text, Text,
TouchableOpacity, TouchableOpacity,
View, View,
ViewStyle ViewStyle
} from 'react-native'; } from 'react-native';
import { Image } from '@/components/ui/Image';
import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { ChallengeType } from '@/services/challengesApi'; import { ChallengeType } from '@/services/challengesApi';
import { reportChallengeProgress, selectChallengeList } from '@/store/challengesSlice'; import { reportChallengeProgress, selectChallengeList } from '@/store/challengesSlice';
import { fetchHourlyStepSamples, fetchStepCount, HourlyStepData } from '@/utils/health'; import { fetchHourlyStepSamples, fetchStepCount, HourlyStepData } from '@/utils/health';
import { logger } from '@/utils/logger'; import { logger } from '@/utils/logger';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { Image } from 'expo-image';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { AnimatedNumber } from './AnimatedNumber'; import { AnimatedNumber } from './AnimatedNumber';

View File

@@ -1,6 +1,6 @@
import { Image } from '@/components/ui/Image';
import { fetchHRVWithStatus } from '@/utils/health'; import { fetchHRVWithStatus } from '@/utils/health';
import { convertHrvToStressIndex } from '@/utils/stress'; import { convertHrvToStressIndex } from '@/utils/stress';
import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';

View File

@@ -1,10 +1,10 @@
import { Image } from '@/components/ui/Image';
import { useWaterDataByDate } from '@/hooks/useWaterData'; import { useWaterDataByDate } from '@/hooks/useWaterData';
import { appStoreReviewService } from '@/services/appStoreReview'; import { appStoreReviewService } from '@/services/appStoreReview';
import { getQuickWaterAmount } from '@/utils/userPreferences'; import { getQuickWaterAmount } from '@/utils/userPreferences';
import { useFocusEffect } from '@react-navigation/native'; import { useFocusEffect } from '@react-navigation/native';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import * as Haptics from 'expo-haptics'; import * as Haptics from 'expo-haptics';
import { Image } from 'expo-image';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import LottieView from 'lottie-react-native'; import LottieView from 'lottie-react-native';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';

View File

@@ -1,6 +1,6 @@
import { Image } from '@/components/ui/Image';
import { MaterialCommunityIcons } from '@expo/vector-icons'; import { MaterialCommunityIcons } from '@expo/vector-icons';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { Image } from 'expo-image';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import React, { useCallback, 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';

View File

@@ -1,10 +1,10 @@
import { Image } from '@/components/ui/Image';
import { Toast } from '@/utils/toast.utils'; import { Toast } from '@/utils/toast.utils';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import dayjs from 'dayjs';
import { BlurView } from 'expo-blur'; import { BlurView } from 'expo-blur';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect'; import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import dayjs from 'dayjs';
import React, { useCallback, useEffect, useRef, useState } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Animated, Modal, Platform, Pressable, Share, StyleSheet, Text, View } from 'react-native'; import { Animated, Modal, Platform, Pressable, Share, StyleSheet, Text, View } from 'react-native';

View File

@@ -1,7 +1,7 @@
import { Image } from '@/components/ui/Image';
import { useI18n } from '@/hooks/useI18n'; import { useI18n } from '@/hooks/useI18n';
import type { RankingItem } from '@/store/challengesSlice'; import type { RankingItem } from '@/store/challengesSlice';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { Image } from 'expo-image';
import React from 'react'; import React from 'react';
import { StyleSheet, Text, View } from 'react-native'; import { StyleSheet, Text, View } from 'react-native';

View File

@@ -1,8 +1,8 @@
import { Image } from '@/components/ui/Image';
import { Colors } from '@/constants/Colors'; import { Colors } from '@/constants/Colors';
import { useColorScheme } from '@/hooks/useColorScheme'; import { useColorScheme } from '@/hooks/useColorScheme';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { BlurView } from 'expo-blur'; import { BlurView } from 'expo-blur';
import { Image } from 'expo-image';
import React from 'react'; import React from 'react';
import { ScrollView, StyleSheet, Text, TextInput, TouchableOpacity, View } from 'react-native'; import { ScrollView, StyleSheet, Text, TextInput, TouchableOpacity, View } from 'react-native';
import QuickChips from './QuickChips'; import QuickChips from './QuickChips';

View File

@@ -1,7 +1,7 @@
import { Image } from '@/components/ui/Image';
import { Colors } from '@/constants/Colors'; import { Colors } from '@/constants/Colors';
import { useColorScheme } from '@/hooks/useColorScheme'; import { useColorScheme } from '@/hooks/useColorScheme';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { Image } from 'expo-image';
import React from 'react'; import React from 'react';
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import Markdown from 'react-native-markdown-display'; import Markdown from 'react-native-markdown-display';

View File

@@ -1,8 +1,8 @@
import { Colors, palette } from '@/constants/Colors'; import { Image } from '@/components/ui/Image';
import { palette } from '@/constants/Colors';
import { MedicalRecordItem } from '@/services/healthProfile'; import { MedicalRecordItem } from '@/services/healthProfile';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { Image } from 'expo-image';
import React from 'react'; import React from 'react';
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';

View File

@@ -1,4 +1,5 @@
import { MedicalRecordCard } from '@/components/health/MedicalRecordCard'; import { MedicalRecordCard } from '@/components/health/MedicalRecordCard';
import { Image } from '@/components/ui/Image';
import { palette } from '@/constants/Colors'; import { palette } from '@/constants/Colors';
import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useCosUpload } from '@/hooks/useCosUpload'; import { useCosUpload } from '@/hooks/useCosUpload';
@@ -13,7 +14,6 @@ import {
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import * as DocumentPicker from 'expo-document-picker'; import * as DocumentPicker from 'expo-document-picker';
import { Image } from 'expo-image';
import * as ImagePicker from 'expo-image-picker'; import * as ImagePicker from 'expo-image-picker';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';

View File

@@ -1,5 +1,5 @@
import { Image } from '@/components/ui/Image';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import { Animated, Modal, Pressable, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { Animated, Modal, Pressable, StyleSheet, Text, TouchableOpacity, View } from 'react-native';

View File

@@ -1,4 +1,5 @@
import { ThemedText } from '@/components/ThemedText'; import { ThemedText } from '@/components/ThemedText';
import { Image } from '@/components/ui/Image';
import { useAppDispatch } from '@/hooks/redux'; import { useAppDispatch } from '@/hooks/redux';
import { useI18n } from '@/hooks/useI18n'; import { useI18n } from '@/hooks/useI18n';
import { skipMedicationAction, takeMedicationAction } from '@/store/medicationsSlice'; import { skipMedicationAction, takeMedicationAction } from '@/store/medicationsSlice';
@@ -6,7 +7,6 @@ import type { MedicationDisplayItem } from '@/types/medication';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import dayjs, { Dayjs } from 'dayjs'; import dayjs, { Dayjs } from 'dayjs';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect'; import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { Image } from 'expo-image';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Alert, StyleSheet, TouchableOpacity, View } from 'react-native'; import { Alert, StyleSheet, TouchableOpacity, View } from 'react-native';

View File

@@ -1,7 +1,7 @@
import { Image } from '@/components/ui/Image';
import { useI18n } from '@/hooks/useI18n'; import { useI18n } from '@/hooks/useI18n';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect'; import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import React from 'react'; import React from 'react';
import { import {

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,9 +1,9 @@
import { Image } from '@/components/ui/Image';
import { useAppSelector } from '@/hooks/redux'; import { useAppSelector } from '@/hooks/redux';
import { useCosUpload } from '@/hooks/useCosUpload'; import { useCosUpload } from '@/hooks/useCosUpload';
import { useI18n } from '@/hooks/useI18n'; import { useI18n } from '@/hooks/useI18n';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { BlurView } from 'expo-blur'; import { BlurView } from 'expo-blur';
import { Image } from 'expo-image';
import * as ImagePicker from 'expo-image-picker'; import * as ImagePicker from 'expo-image-picker';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import React, { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';

View File

@@ -15,10 +15,10 @@ import {
View, View,
} from 'react-native'; } from 'react-native';
// 导入统一的食物类型定义 // 导入统一的食物类型定义
import { Image } from '@/components/ui/Image';
import { Colors } from '@/constants/Colors'; import { Colors } from '@/constants/Colors';
import { DEFAULT_IMAGE_FOOD } from '@/constants/Image'; import { DEFAULT_IMAGE_FOOD } from '@/constants/Image';
import type { FoodItem } from '@/types/food'; import type { FoodItem } from '@/types/food';
import { Image } from 'expo-image';
// 导入统一的食物类型定义 // 导入统一的食物类型定义

View File

@@ -1,6 +1,6 @@
import { Image } from 'expo-image'; import { Image } from '@/components/ui/Image';
import React from 'react'; import React from 'react';
import { StyleSheet, Text, View } from 'react-native'; import { ImageSourcePropType, 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,53 @@ interface HealthDataCardProps {
value: string; value: string;
unit: string; unit: string;
style?: object; style?: object;
onPress?: () => void;
icon?: React.ReactNode;
iconSource?: ImageSourcePropType;
subtitle?: string;
} }
const defaultIconSource = require('@/assets/images/icons/icon-blood-oxygen.png');
const HealthDataCard: React.FC<HealthDataCardProps> = ({ const HealthDataCard: React.FC<HealthDataCardProps> = ({
title, title,
value, value,
unit, unit,
style style,
onPress,
icon,
iconSource,
subtitle
}) => { }) => {
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, {icon ? (
}}> <View style={styles.iconWrapper}>{icon}</View>
<Image ) : (
source={require('@/assets/images/icons/icon-blood-oxygen.png')} <Image source={iconSource ?? defaultIconSource} style={styles.titleIcon} />
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> {subtitle ? (
<Text style={styles.subtitle} numberOfLines={1}>
{subtitle}
</Text>
) : null}
</Container>
</Animated.View> </Animated.View>
); );
}; };
@@ -62,6 +78,18 @@ const styles = StyleSheet.create({
flex: 1, flex: 1,
justifyContent: 'center', justifyContent: 'center',
}, },
headerRow: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 14,
},
iconWrapper: {
width: 16,
height: 16,
marginRight: 6,
alignItems: 'center',
justifyContent: 'center',
},
titleIcon: { titleIcon: {
width: 16, width: 16,
height: 16, height: 16,
@@ -92,6 +120,12 @@ const styles = StyleSheet.create({
fontWeight: '500', fontWeight: '500',
fontFamily: 'AliRegular', fontFamily: 'AliRegular',
}, },
subtitle: {
marginTop: 6,
fontSize: 12,
color: '#8A8A8A',
fontFamily: 'AliRegular',
},
}); });
export default HealthDataCard; export default HealthDataCard;

View File

@@ -24,6 +24,7 @@ const HeartRateCard: React.FC<HeartRateCardProps> = ({
value={heartRate !== null && heartRate !== undefined ? Math.round(heartRate).toString() : '--'} value={heartRate !== null && heartRate !== undefined ? Math.round(heartRate).toString() : '--'}
unit="bpm" unit="bpm"
style={style} style={style}
icon={heartIcon}
/> />
); );
}; };

View File

@@ -1,10 +1,10 @@
import { Image } from '@/components/ui/Image';
import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { ChallengeType } from '@/services/challengesApi'; import { ChallengeType } from '@/services/challengesApi';
import { reportChallengeProgress, selectChallengeList } from '@/store/challengesSlice'; import { reportChallengeProgress, selectChallengeList } from '@/store/challengesSlice';
import { logger } from '@/utils/logger'; import { logger } from '@/utils/logger';
import { fetchCompleteSleepData, formatSleepTime } from '@/utils/sleepHealthKit'; import { fetchCompleteSleepData, formatSleepTime } from '@/utils/sleepHealthKit';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { Image } from 'expo-image';
import { router } from 'expo-router'; import { router } from 'expo-router';
import React, { useEffect, useMemo, useRef, useState } from 'react'; import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';

View File

@@ -0,0 +1,559 @@
import {
ensureHealthPermissions,
fetchTimeInDaylight,
fetchTimeInDaylightHistory,
SunlightHistoryPoint
} 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, { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Dimensions,
Modal,
Platform,
Pressable,
StyleSheet,
Text,
View
} from 'react-native';
import Svg, {
Defs,
LinearGradient as SvgLinearGradient,
Line,
Rect,
Stop,
Text as SvgText
} from 'react-native-svg';
import HealthDataCard from './HealthDataCard';
interface SunlightCardProps {
style?: object;
selectedDate?: Date;
}
const screenWidth = Dimensions.get('window').width;
const INITIAL_CHART_WIDTH = screenWidth - 32;
const CHART_HEIGHT = 190;
const CHART_RIGHT_PADDING = 12;
const AXIS_COLUMN_WIDTH = 36;
const CHART_INNER_PADDING = 4;
const AXIS_LABEL_WIDTH = 48;
const Y_TICK_COUNT = 4;
const BAR_GAP = 6;
const MIN_BAR_HEIGHT = 4;
const SunlightCard: React.FC<SunlightCardProps> = ({
style,
selectedDate
}) => {
const { t, i18n } = useTranslation();
const locale = i18n.language;
const isFocused = useIsFocused();
const [sunlightMinutes, setSunlightMinutes] = useState<number | null>(null);
const [comparisonText, setComparisonText] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const loadingRef = useRef(false);
const [historyVisible, setHistoryVisible] = useState(false);
const [historyLoading, setHistoryLoading] = useState(false);
const [history, setHistory] = useState<SunlightHistoryPoint[]>([]);
const historyLoadingRef = useRef(false);
const [chartWidth, setChartWidth] = useState(INITIAL_CHART_WIDTH);
const formatCompareDate = (date: Date) => {
if (locale?.startsWith('zh')) {
return dayjs(date).format('M月D日');
}
return dayjs(date).format('MMM D');
};
useEffect(() => {
const loadSunlightData = async () => {
const dateToUse = selectedDate || new Date();
if (!isFocused) return;
if (!HealthKitUtils.isAvailable()) {
setSunlightMinutes(null);
setComparisonText(null);
return;
}
if (loadingRef.current) return;
try {
loadingRef.current = true;
setLoading(true);
setComparisonText(null);
const hasPermission = await ensureHealthPermissions();
if (!hasPermission) {
setSunlightMinutes(null);
setComparisonText(null);
setLoading(false);
return;
}
const options = {
startDate: dayjs(dateToUse).startOf('day').toDate().toISOString(),
endDate: dayjs(dateToUse).endOf('day').toDate().toISOString()
};
const totalMinutes = await fetchTimeInDaylight(options);
setSunlightMinutes(totalMinutes);
setLoading(false);
if (totalMinutes !== null && totalMinutes !== undefined) {
try {
let previousMinutes: number | null = null;
let previousDate: Date | null = null;
for (let i = 1; i <= 30; i += 1) {
const targetDate = dayjs(dateToUse).subtract(i, 'day');
const previousOptions = {
startDate: targetDate.startOf('day').toDate().toISOString(),
endDate: targetDate.endOf('day').toDate().toISOString()
};
const candidateMinutes = await fetchTimeInDaylight(previousOptions);
if (candidateMinutes !== null && candidateMinutes !== undefined && candidateMinutes > 0) {
previousMinutes = candidateMinutes;
previousDate = targetDate.toDate();
break;
}
}
if (previousMinutes !== null && previousDate) {
const diff = Math.round(totalMinutes - previousMinutes);
const dateLabel = formatCompareDate(previousDate);
if (diff > 0) {
setComparisonText(t('statistics.components.sunlight.compareIncrease', { date: dateLabel, diff }));
} else if (diff < 0) {
setComparisonText(t('statistics.components.sunlight.compareDecrease', { date: dateLabel, diff: Math.abs(diff) }));
} else {
setComparisonText(t('statistics.components.sunlight.compareSame', { date: dateLabel }));
}
} else {
setComparisonText(t('statistics.components.sunlight.compareNone'));
}
} catch (error) {
console.error('SunlightCard: Failed to compare time in daylight:', error);
setComparisonText(t('statistics.components.sunlight.compareNone'));
}
} else {
setComparisonText(null);
}
} catch (error) {
console.error('SunlightCard: Failed to get time in daylight:', error);
setSunlightMinutes(null);
setComparisonText(null);
setLoading(false);
} finally {
loadingRef.current = false;
}
};
loadSunlightData();
}, [isFocused, selectedDate, t, locale]);
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(29, 'day').startOf('day');
const options = {
startDate: start.toDate().toISOString(),
endDate: end.toDate().toISOString()
};
const historyData = await fetchTimeInDaylightHistory(options);
const sorted = historyData
.filter((item) => item && item.date)
.sort((a, b) => dayjs(a.date).valueOf() - dayjs(b.date).valueOf());
setHistory(sorted);
} catch (error) {
console.error('SunlightCard: Failed to get time in daylight history:', error);
setHistory([]);
} finally {
historyLoadingRef.current = false;
setHistoryLoading(false);
}
};
loadHistory();
}, [historyVisible, selectedDate, isFocused]);
const displayValue = loading
? '--'
: (sunlightMinutes !== null && sunlightMinutes !== undefined
? Math.max(0, Math.round(sunlightMinutes)).toString()
: '--');
const openHistory = () => setHistoryVisible(true);
const closeHistory = () => setHistoryVisible(false);
const maxValue = history.length ? Math.max(...history.map((item) => item.value), 10) : 10;
const averageValue = history.length
? history.reduce((sum, item) => sum + item.value, 0) / history.length
: null;
const latestValue = history.length ? history[history.length - 1].value : null;
const barCount = history.length || 1;
const chartInnerWidth = Math.max(0, chartWidth - 24);
const chartAreaWidth = Math.max(
0,
chartInnerWidth - AXIS_COLUMN_WIDTH - CHART_RIGHT_PADDING
);
const barWidth = Math.max(
6,
(chartAreaWidth - CHART_INNER_PADDING * 2 - BAR_GAP * (barCount - 1)) / barCount
);
const dateLabels = history.length
? [
history[0],
history[Math.floor(history.length / 2)],
history[history.length - 1]
].filter(Boolean)
: [];
return (
<>
<HealthDataCard
title={t('statistics.components.sunlight.title')}
value={displayValue}
unit={t('statistics.components.sunlight.unit')}
style={style}
icon={<Ionicons name="sunny-outline" size={16} color="#F59E0B" />}
subtitle={loading ? undefined : comparisonText ?? undefined}
onPress={openHistory}
/>
<Modal
visible={historyVisible}
animationType="slide"
presentationStyle={Platform.OS === 'ios' ? 'pageSheet' : 'fullScreen'}
onRequestClose={closeHistory}
>
<View style={styles.modalSafeArea}>
<LinearGradient
colors={['#FFF7E8', '#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.sunlight.title')}</Text>
<Text style={styles.modalSubtitle}>{t('statistics.components.sunlight.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.sunlight.syncing')}</Text>
) : null}
{history.length === 0 ? (
<View style={styles.emptyState}>
<Text style={styles.emptyText}>{t('statistics.components.sunlight.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);
}
}}
>
<View style={styles.chartHeaderRow}>
<Text style={styles.axisUnit}>{t('statistics.components.sunlight.unit')}</Text>
</View>
<View style={styles.chartContentRow}>
<View style={styles.axisColumn}>
{Array.from({ length: Y_TICK_COUNT + 1 }).map((_, index) => {
const value = (maxValue / Y_TICK_COUNT) * (Y_TICK_COUNT - index);
const y = (CHART_HEIGHT / Y_TICK_COUNT) * index;
return (
<Text key={`tick-${index}`} style={[styles.axisTick, { top: Math.max(0, y - 6) }]}>
{Math.round(value)}
</Text>
);
})}
</View>
<Svg width={chartAreaWidth} height={CHART_HEIGHT + 10}>
<Defs>
<SvgLinearGradient id="sunBar" x1="0%" y1="0%" x2="0%" y2="100%">
<Stop offset="0%" stopColor="#F59E0B" stopOpacity="0.95" />
<Stop offset="100%" stopColor="#FDE68A" stopOpacity="0.8" />
</SvgLinearGradient>
</Defs>
{Array.from({ length: Y_TICK_COUNT + 1 }).map((_, index) => {
const value = (maxValue / Y_TICK_COUNT) * index;
const y = CHART_HEIGHT - (value / maxValue) * CHART_HEIGHT;
return (
<React.Fragment key={`tick-${index}`}>
<Line
x1={0}
y1={y}
x2={chartAreaWidth}
y2={y}
stroke="#FEF3C7"
strokeWidth={1}
/>
</React.Fragment>
);
})}
{history.map((item, index) => {
const value = item.value;
const barHeight = Math.max((value / maxValue) * CHART_HEIGHT, MIN_BAR_HEIGHT);
const x = CHART_INNER_PADDING + index * (barWidth + BAR_GAP);
const y = CHART_HEIGHT - barHeight;
return (
<Rect
key={item.date}
x={x}
y={y}
width={barWidth}
height={barHeight}
rx={barWidth > 8 ? 6 : 4}
fill="url(#sunBar)"
/>
);
})}
</Svg>
</View>
<View style={[styles.labelRow, { width: chartAreaWidth }]}>
{dateLabels.map((item) => {
const index = history.findIndex((point) => point.date === item.date);
const x = CHART_INNER_PADDING + index * (barWidth + BAR_GAP) + barWidth / 2;
const label = dayjs(item.date).format(locale?.startsWith('zh') ? 'M.D' : 'MMM D');
const maxLeft = Math.max(0, chartAreaWidth - AXIS_LABEL_WIDTH);
const clampedLeft = Math.min(
Math.max(x - AXIS_LABEL_WIDTH / 2, 0),
maxLeft
);
return (
<Text key={item.date} style={[styles.axisLabel, { left: clampedLeft, width: AXIS_LABEL_WIDTH }]}>
{label}
</Text>
);
})}
</View>
</View>
)}
<View style={styles.metricsRow}>
<View style={styles.metric}>
<Text style={styles.metricLabel}>{t('statistics.components.sunlight.average')}</Text>
<Text style={styles.metricValue}>
{averageValue !== null ? Math.round(averageValue) : '--'}
<Text style={styles.metricUnit}> {t('statistics.components.sunlight.unit')}</Text>
</Text>
</View>
<View style={styles.metric}>
<Text style={styles.metricLabel}>{t('statistics.components.sunlight.latest')}</Text>
<Text style={styles.metricValue}>
{latestValue !== null ? Math.round(latestValue) : '--'}
<Text style={styles.metricUnit}> {t('statistics.components.sunlight.unit')}</Text>
</Text>
</View>
</View>
</View>
</View>
</Modal>
</>
);
};
export default SunlightCard;
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.05,
shadowRadius: 14,
shadowOffset: { width: 0, height: 12 },
elevation: 4,
marginTop: 8,
marginBottom: 14,
borderWidth: 1,
borderColor: '#FEF3C7'
},
chartHeaderRow: {
paddingLeft: AXIS_COLUMN_WIDTH,
paddingBottom: 6
},
axisUnit: {
fontSize: 10,
color: '#B45309',
fontFamily: 'AliRegular'
},
chartContentRow: {
flexDirection: 'row',
alignItems: 'flex-start'
},
axisColumn: {
width: AXIS_COLUMN_WIDTH,
height: CHART_HEIGHT,
position: 'relative',
justifyContent: 'space-between',
paddingRight: 6
},
axisTick: {
position: 'absolute',
right: 6,
fontSize: 10,
color: '#B45309',
fontFamily: 'AliRegular'
},
labelRow: {
marginTop: 4,
marginLeft: AXIS_COLUMN_WIDTH,
height: 24,
justifyContent: 'center'
},
axisLabel: {
position: 'absolute',
bottom: 0,
fontSize: 11,
color: '#9A6B2F',
fontFamily: 'AliRegular',
textAlign: 'center',
width: 48
},
metricsRow: {
flexDirection: 'row',
justifyContent: 'space-between',
gap: 12,
paddingVertical: 6
},
metric: {
flex: 1,
padding: 14,
backgroundColor: 'rgba(255, 247, 237, 0.8)',
borderRadius: 18,
borderWidth: 1,
borderColor: '#FED7AA'
},
metricLabel: {
fontSize: 12,
color: '#92400E',
marginBottom: 8,
fontFamily: 'AliRegular'
},
metricValue: {
fontSize: 20,
fontWeight: '700',
color: '#7C2D12',
fontFamily: 'AliBold'
},
metricUnit: {
fontSize: 12,
color: '#9A6B2F',
fontWeight: '500',
fontFamily: 'AliRegular'
},
emptyState: {
marginTop: 32,
padding: 20,
borderRadius: 20,
backgroundColor: 'rgba(255, 247, 237, 0.9)',
borderWidth: 1,
borderColor: '#FED7AA',
alignItems: 'center'
},
emptyText: {
fontSize: 14,
color: '#9A3412',
fontFamily: 'AliRegular'
},
hintText: {
fontSize: 13,
color: '#9CA3AF',
fontFamily: 'AliRegular'
}
});

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

44
components/ui/Image.tsx Normal file
View File

@@ -0,0 +1,44 @@
import { API_ORIGIN } from '@/constants/Api';
import Constants from 'expo-constants';
import { Image as ExpoImage, ImageProps as ExpoImageProps } from 'expo-image';
import React, { forwardRef, useMemo } from 'react';
// Construct User-Agent
const APP_NAME = Constants.expoConfig?.name || 'Out Live';
const APP_VERSION = Constants.expoConfig?.version || '1.1.5';
const USER_AGENT = `${APP_NAME}/${APP_VERSION} (iOS)`;
export type ImageProps = ExpoImageProps;
export const Image = forwardRef<ExpoImage, ImageProps>(({ source, ...props }, ref) => {
const finalSource = useMemo(() => {
if (!source) return source;
const headers = {
'User-Agent': USER_AGENT,
'Referer': API_ORIGIN,
};
const addHeaders = (src: any) => {
if (typeof src === 'number' || src === null || src === undefined) return src;
if (typeof src === 'string') return { uri: src, headers };
if (typeof src === 'object' && 'uri' in src) {
return {
...src,
headers: { ...headers, ...(src.headers || {}) }
};
}
return src;
};
if (Array.isArray(source)) {
return source.map(addHeaders);
}
return addHeaders(source);
}, [source]);
return <ExpoImage {...props} source={finalSource} ref={ref} />;
});
Image.displayName = 'Image';

View File

@@ -1,7 +1,7 @@
import { Image } from '@/components/ui/Image';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect'; import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import * as Haptics from 'expo-haptics'; import * as Haptics from 'expo-haptics';
import { Image } from 'expo-image';
import React, { useCallback, useEffect, useRef, useState } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react';
import { import {
ActivityIndicator, ActivityIndicator,

View File

@@ -1,3 +1,4 @@
import { Image } from '@/components/ui/Image';
import { Colors } from '@/constants/Colors'; import { Colors } from '@/constants/Colors';
import { ROUTES } from '@/constants/Routes'; import { ROUTES } from '@/constants/Routes';
import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useAppDispatch, useAppSelector } from '@/hooks/redux';
@@ -7,7 +8,6 @@ import { fetchWeightHistory } from '@/store/userSlice';
import { BMI_CATEGORIES } from '@/utils/bmi'; import { BMI_CATEGORIES } from '@/utils/bmi';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect'; import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';

View File

@@ -9,7 +9,6 @@ import {
ActivityIndicator, ActivityIndicator,
Dimensions, Dimensions,
Modal, Modal,
Platform,
Pressable, Pressable,
ScrollView, ScrollView,
Share, Share,
@@ -19,7 +18,7 @@ import {
TouchableWithoutFeedback, TouchableWithoutFeedback,
View, View,
} from 'react-native'; } from 'react-native';
import ViewShot from 'react-native-view-shot'; import ViewShot, { captureRef } from 'react-native-view-shot';
import { useI18n } from '@/hooks/useI18n'; import { useI18n } from '@/hooks/useI18n';
import { import {
@@ -156,7 +155,7 @@ export function WorkoutDetailModal({
type: 'info', type: 'info',
text1: t('workoutDetail.share.generating', '正在生成分享卡片…'), text1: t('workoutDetail.share.generating', '正在生成分享卡片…'),
}); });
const uri = await shareContentRef.current.capture?.({ const uri = await captureRef(shareContentRef, {
format: 'png', format: 'png',
quality: 0.95, quality: 0.95,
snapshotContentContainer: true, snapshotContentContainer: true,
@@ -164,6 +163,7 @@ export function WorkoutDetailModal({
if (!uri) { if (!uri) {
throw new Error('share-capture-failed'); 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 shareTitle = t('workoutDetail.share.title', { defaultValue: activityName || t('workoutDetail.title', '锻炼详情') });
const caloriesLabel = metrics?.calories != null const caloriesLabel = metrics?.calories != null
? `${metrics.calories} ${t('workoutDetail.metrics.caloriesUnit')}` ? `${metrics.calories} ${t('workoutDetail.metrics.caloriesUnit')}`
@@ -179,7 +179,7 @@ export function WorkoutDetailModal({
await Share.share({ await Share.share({
title: shareTitle, title: shareTitle,
message: shareMessage, message: shareMessage,
url: Platform.OS === 'ios' ? uri : `file://${uri}`, url: shareUri,
}); });
} catch (error) { } catch (error) {
console.warn('workout-detail-share-failed', error); console.warn('workout-detail-share-failed', error);
@@ -487,7 +487,6 @@ export function WorkoutDetailModal({
<ViewShot <ViewShot
ref={shareContentRef} ref={shareContentRef}
style={[styles.sheetContainer, styles.shareCaptureContainer]} style={[styles.sheetContainer, styles.shareCaptureContainer]}
collapsable={false}
options={{ format: 'png', quality: 0.95, snapshotContentContainer: true }} options={{ format: 'png', quality: 0.95, snapshotContentContainer: true }}
> >
<LinearGradient <LinearGradient

View File

@@ -139,9 +139,32 @@ export const statistics = {
title: 'Sleep', title: 'Sleep',
loading: 'Loading...', loading: 'Loading...',
}, },
sunlight: {
title: 'Sun',
unit: 'min',
compareIncrease: 'Up {{diff}} min vs {{date}}',
compareDecrease: 'Down {{diff}} min vs {{date}}',
compareSame: 'Same as {{date}}',
compareNone: 'No prior data',
last30Days: 'Last 30 days',
syncing: 'Syncing Health data...',
noData: 'No sunlight data yet',
average: '30-day avg',
latest: 'Latest',
},
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

@@ -123,10 +123,12 @@ export const statisticsCustomization = {
steps: 'Steps', steps: 'Steps',
stress: 'Stress', stress: 'Stress',
sleep: 'Sleep', sleep: 'Sleep',
sunlight: 'Sun',
fitnessRings: 'Fitness Rings', fitnessRings: 'Fitness Rings',
water: 'Water Intake', water: 'Water Intake',
basalMetabolism: 'Basal Metabolism', basalMetabolism: 'Basal Metabolism',
oxygenSaturation: 'Oxygen Saturation', oxygenSaturation: 'Oxygen Saturation',
wristTemperature: 'Wrist Temperature',
menstrualCycle: 'Menstrual Cycle', menstrualCycle: 'Menstrual Cycle',
weight: 'Weight', weight: 'Weight',
circumference: 'Circumference', circumference: 'Circumference',

View File

@@ -140,9 +140,32 @@ export const statistics = {
title: '睡眠', title: '睡眠',
loading: '加载中...', loading: '加载中...',
}, },
sunlight: {
title: '晒太阳',
unit: '分钟',
compareIncrease: '与 {{date}} 相比增加 {{diff}} 分钟',
compareDecrease: '与 {{date}} 相比减少 {{diff}} 分钟',
compareSame: '与 {{date}} 相比无变化',
compareNone: '暂无对比',
last30Days: '最近30天',
syncing: '正在同步健康数据...',
noData: '暂无日照时间数据',
average: '30天均值',
latest: '最新值',
},
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

@@ -123,10 +123,12 @@ export const statisticsCustomization = {
steps: '步数', steps: '步数',
stress: '压力', stress: '压力',
sleep: '睡眠', sleep: '睡眠',
sunlight: '晒太阳',
fitnessRings: '健身圆环', fitnessRings: '健身圆环',
water: '饮水', water: '饮水',
basalMetabolism: '基础代谢', basalMetabolism: '基础代谢',
oxygenSaturation: '血氧', oxygenSaturation: '血氧',
wristTemperature: '手腕温度',
menstrualCycle: '经期', menstrualCycle: '经期',
weight: '体重', weight: '体重',
circumference: '围度', circumference: '围度',

View File

@@ -30,6 +30,14 @@ RCT_EXTERN_METHOD(getAppleStandTime:(NSDictionary *)options
resolver:(RCTPromiseResolveBlock)resolver resolver:(RCTPromiseResolveBlock)resolver
rejecter:(RCTPromiseRejectBlock)rejecter) rejecter:(RCTPromiseRejectBlock)rejecter)
RCT_EXTERN_METHOD(getTimeInDaylight:(NSDictionary *)options
resolver:(RCTPromiseResolveBlock)resolver
rejecter:(RCTPromiseRejectBlock)rejecter)
RCT_EXTERN_METHOD(getTimeInDaylightSamples:(NSDictionary *)options
resolver:(RCTPromiseResolveBlock)resolver
rejecter:(RCTPromiseRejectBlock)rejecter)
RCT_EXTERN_METHOD(getActivitySummary:(NSDictionary *)options RCT_EXTERN_METHOD(getActivitySummary:(NSDictionary *)options
resolver:(RCTPromiseResolveBlock)resolver resolver:(RCTPromiseResolveBlock)resolver
rejecter:(RCTPromiseRejectBlock)rejecter) rejecter:(RCTPromiseRejectBlock)rejecter)
@@ -43,6 +51,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 +147,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,23 @@ 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 timeInDaylight: HKQuantityType? {
if #available(iOS 17.0, *) {
return HKObjectType.quantityType(forIdentifier: .timeInDaylight)
} 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 +100,9 @@ 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) }
if let timeInDaylight = timeInDaylight { types.insert(timeInDaylight) }
return types return types
} }
@@ -111,6 +131,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 +143,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
} }
} }
@@ -607,6 +631,151 @@ class HealthKitManager: RCTEventEmitter {
healthStore.execute(query) healthStore.execute(query)
} }
@objc
func getTimeInDaylight(
_ 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 daylightType = ReadTypes.timeInDaylight else {
rejecter("TYPE_NOT_AVAILABLE", "Time in daylight 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 query = HKStatisticsQuery(quantityType: daylightType,
quantitySamplePredicate: predicate,
options: .cumulativeSum) { [weak self] (query, statistics, error) in
DispatchQueue.main.async {
if let error = error {
rejecter("QUERY_ERROR", "Failed to query time in daylight: \(error.localizedDescription)", error)
return
}
guard let statistics = statistics else {
resolver([
"totalValue": 0,
"startDate": self?.dateToISOString(startDate) ?? "",
"endDate": self?.dateToISOString(endDate) ?? ""
])
return
}
let totalValue = statistics.sumQuantity()?.doubleValue(for: HKUnit.minute()) ?? 0
let result: [String: Any] = [
"totalValue": totalValue,
"startDate": self?.dateToISOString(startDate) ?? "",
"endDate": self?.dateToISOString(endDate) ?? ""
]
resolver(result)
}
}
healthStore.execute(query)
}
@objc
func getTimeInDaylightSamples(
_ 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 daylightType = ReadTypes.timeInDaylight else {
rejecter("TYPE_NOT_AVAILABLE", "Time in daylight 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)
var interval = DateComponents()
interval.day = 1
let anchorDate = Calendar.current.startOfDay(for: startDate)
let query = HKStatisticsCollectionQuery(quantityType: daylightType,
quantitySamplePredicate: predicate,
options: .cumulativeSum,
anchorDate: anchorDate,
intervalComponents: interval)
query.initialResultsHandler = { [weak self] (_, results, error) in
DispatchQueue.main.async {
if let error = error {
rejecter("QUERY_ERROR", "Failed to query time in daylight samples: \(error.localizedDescription)", error)
return
}
guard let results = results else {
resolver([
"data": [],
"count": 0,
"startDate": self?.dateToISOString(startDate) ?? "",
"endDate": self?.dateToISOString(endDate) ?? ""
])
return
}
var data: [[String: Any]] = []
results.enumerateStatistics(from: startDate, to: endDate) { statistics, _ in
let value = statistics.sumQuantity()?.doubleValue(for: HKUnit.minute()) ?? 0
data.append([
"date": self?.dateToISOString(statistics.startDate) ?? "",
"value": value
])
}
let result: [String: Any] = [
"data": data,
"count": data.count,
"startDate": self?.dateToISOString(startDate) ?? "",
"endDate": self?.dateToISOString(endDate) ?? ""
]
resolver(result)
}
}
healthStore.execute(query)
}
@objc @objc
func getActivitySummary( func getActivitySummary(
_ options: NSDictionary, _ options: NSDictionary,
@@ -852,6 +1021,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 +2797,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.5</string> <string>1.1.6</string>
<key>CFBundleSignature</key> <key>CFBundleSignature</key>
<string>????</string> <string>????</string>
<key>CFBundleURLTypes</key> <key>CFBundleURLTypes</key>

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,131 @@ export async function fetchOxygenSaturation(options: HealthDataOptions): Promise
} }
} }
export async function fetchTimeInDaylight(options: HealthDataOptions): Promise<number | null> {
try {
const result = await HealthKitManager.getTimeInDaylight(options);
if (result && result.totalValue !== undefined) {
logSuccess('晒太阳时长', result);
return result.totalValue;
} else {
logWarning('晒太阳时长', '为空或格式错误');
return null;
}
} catch (error) {
logError('晒太阳时长', error);
return null;
}
}
export interface SunlightHistoryPoint {
date: string;
value: number;
}
export async function fetchTimeInDaylightHistory(options: HealthDataOptions): Promise<SunlightHistoryPoint[]> {
try {
const result = await HealthKitManager.getTimeInDaylightSamples(options);
if (result && result.data && Array.isArray(result.data)) {
logSuccess('晒太阳历史', result);
return result.data
.filter((item: any) => item && typeof item.value === 'number' && item.date)
.map((item: any) => ({
date: item.date,
value: Number(item.value)
}));
} else {
logWarning('晒太阳历史', '为空或格式错误');
return [];
}
} catch (error) {
logError('晒太阳历史', error);
return [];
}
}
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 +1635,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 {

View File

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

View File

@@ -28,6 +28,8 @@ const PREFERENCES_KEYS = {
SHOW_MENSTRUAL_CYCLE_CARD: 'user_preference_show_menstrual_cycle_card', SHOW_MENSTRUAL_CYCLE_CARD: 'user_preference_show_menstrual_cycle_card',
SHOW_WEIGHT_CARD: 'user_preference_show_weight_card', SHOW_WEIGHT_CARD: 'user_preference_show_weight_card',
SHOW_CIRCUMFERENCE_CARD: 'user_preference_show_circumference_card', SHOW_CIRCUMFERENCE_CARD: 'user_preference_show_circumference_card',
SHOW_WRIST_TEMPERATURE_CARD: 'user_preference_show_wrist_temperature_card',
SHOW_SUNLIGHT_CARD: 'user_preference_show_sunlight_card',
// 首页身体指标卡片排序设置 // 首页身体指标卡片排序设置
STATISTICS_CARD_ORDER: 'user_preference_statistics_card_order', STATISTICS_CARD_ORDER: 'user_preference_statistics_card_order',
@@ -46,6 +48,8 @@ export interface StatisticsCardsVisibility {
showMenstrualCycle: boolean; showMenstrualCycle: boolean;
showWeight: boolean; showWeight: boolean;
showCircumference: boolean; showCircumference: boolean;
showWristTemperature: boolean;
showSunlight: boolean;
} }
// 默认卡片顺序 // 默认卡片顺序
@@ -54,10 +58,12 @@ export const DEFAULT_CARD_ORDER: string[] = [
'steps', 'steps',
'stress', 'stress',
'sleep', 'sleep',
'sunlight',
'fitness', 'fitness',
'water', 'water',
'metabolism', 'metabolism',
'oxygen', 'oxygen',
'temperature',
'menstrual', 'menstrual',
'weight', 'weight',
'circumference', 'circumference',
@@ -109,6 +115,8 @@ const DEFAULT_PREFERENCES: UserPreferences = {
showMenstrualCycle: true, showMenstrualCycle: true,
showWeight: true, showWeight: true,
showCircumference: true, showCircumference: true,
showWristTemperature: true,
showSunlight: true,
// 默认卡片顺序 // 默认卡片顺序
cardOrder: DEFAULT_CARD_ORDER, cardOrder: DEFAULT_CARD_ORDER,
@@ -145,6 +153,8 @@ export const getUserPreferences = async (): Promise<UserPreferences> => {
const showMenstrualCycle = await AsyncStorage.getItem(PREFERENCES_KEYS.SHOW_MENSTRUAL_CYCLE_CARD); const showMenstrualCycle = await AsyncStorage.getItem(PREFERENCES_KEYS.SHOW_MENSTRUAL_CYCLE_CARD);
const showWeight = await AsyncStorage.getItem(PREFERENCES_KEYS.SHOW_WEIGHT_CARD); const showWeight = await AsyncStorage.getItem(PREFERENCES_KEYS.SHOW_WEIGHT_CARD);
const showCircumference = await AsyncStorage.getItem(PREFERENCES_KEYS.SHOW_CIRCUMFERENCE_CARD); const showCircumference = await AsyncStorage.getItem(PREFERENCES_KEYS.SHOW_CIRCUMFERENCE_CARD);
const showWristTemperature = await AsyncStorage.getItem(PREFERENCES_KEYS.SHOW_WRIST_TEMPERATURE_CARD);
const showSunlight = await AsyncStorage.getItem(PREFERENCES_KEYS.SHOW_SUNLIGHT_CARD);
const cardOrderStr = await AsyncStorage.getItem(PREFERENCES_KEYS.STATISTICS_CARD_ORDER); const cardOrderStr = await AsyncStorage.getItem(PREFERENCES_KEYS.STATISTICS_CARD_ORDER);
const cardOrder = cardOrderStr ? JSON.parse(cardOrderStr) : DEFAULT_PREFERENCES.cardOrder; const cardOrder = cardOrderStr ? JSON.parse(cardOrderStr) : DEFAULT_PREFERENCES.cardOrder;
@@ -174,6 +184,8 @@ export const getUserPreferences = async (): Promise<UserPreferences> => {
showMenstrualCycle: showMenstrualCycle !== null ? showMenstrualCycle === 'true' : DEFAULT_PREFERENCES.showMenstrualCycle, showMenstrualCycle: showMenstrualCycle !== null ? showMenstrualCycle === 'true' : DEFAULT_PREFERENCES.showMenstrualCycle,
showWeight: showWeight !== null ? showWeight === 'true' : DEFAULT_PREFERENCES.showWeight, showWeight: showWeight !== null ? showWeight === 'true' : DEFAULT_PREFERENCES.showWeight,
showCircumference: showCircumference !== null ? showCircumference === 'true' : DEFAULT_PREFERENCES.showCircumference, showCircumference: showCircumference !== null ? showCircumference === 'true' : DEFAULT_PREFERENCES.showCircumference,
showWristTemperature: showWristTemperature !== null ? showWristTemperature === 'true' : DEFAULT_PREFERENCES.showWristTemperature,
showSunlight: showSunlight !== null ? showSunlight === 'true' : DEFAULT_PREFERENCES.showSunlight,
cardOrder, cardOrder,
}; };
} catch (error) { } catch (error) {
@@ -611,6 +623,8 @@ export const getStatisticsCardsVisibility = async (): Promise<StatisticsCardsVis
showMenstrualCycle: userPreferences.showMenstrualCycle, showMenstrualCycle: userPreferences.showMenstrualCycle,
showWeight: userPreferences.showWeight, showWeight: userPreferences.showWeight,
showCircumference: userPreferences.showCircumference, showCircumference: userPreferences.showCircumference,
showWristTemperature: userPreferences.showWristTemperature,
showSunlight: userPreferences.showSunlight,
}; };
} catch (error) { } catch (error) {
console.error('获取首页卡片显示设置失败:', error); console.error('获取首页卡片显示设置失败:', error);
@@ -626,6 +640,8 @@ export const getStatisticsCardsVisibility = async (): Promise<StatisticsCardsVis
showMenstrualCycle: DEFAULT_PREFERENCES.showMenstrualCycle, showMenstrualCycle: DEFAULT_PREFERENCES.showMenstrualCycle,
showWeight: DEFAULT_PREFERENCES.showWeight, showWeight: DEFAULT_PREFERENCES.showWeight,
showCircumference: DEFAULT_PREFERENCES.showCircumference, showCircumference: DEFAULT_PREFERENCES.showCircumference,
showWristTemperature: DEFAULT_PREFERENCES.showWristTemperature,
showSunlight: DEFAULT_PREFERENCES.showSunlight,
}; };
} }
}; };
@@ -673,6 +689,8 @@ export const setStatisticsCardVisibility = async (key: keyof StatisticsCardsVisi
case 'showMenstrualCycle': storageKey = PREFERENCES_KEYS.SHOW_MENSTRUAL_CYCLE_CARD; break; case 'showMenstrualCycle': storageKey = PREFERENCES_KEYS.SHOW_MENSTRUAL_CYCLE_CARD; break;
case 'showWeight': storageKey = PREFERENCES_KEYS.SHOW_WEIGHT_CARD; break; case 'showWeight': storageKey = PREFERENCES_KEYS.SHOW_WEIGHT_CARD; break;
case 'showCircumference': storageKey = PREFERENCES_KEYS.SHOW_CIRCUMFERENCE_CARD; break; case 'showCircumference': storageKey = PREFERENCES_KEYS.SHOW_CIRCUMFERENCE_CARD; break;
case 'showWristTemperature': storageKey = PREFERENCES_KEYS.SHOW_WRIST_TEMPERATURE_CARD; break;
case 'showSunlight': storageKey = PREFERENCES_KEYS.SHOW_SUNLIGHT_CARD; break;
default: return; default: return;
} }
await AsyncStorage.setItem(storageKey, value.toString()); await AsyncStorage.setItem(storageKey, value.toString());