Compare commits
5 Commits
9b4a300380
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17664c679d | ||
|
|
e51aca2fdb | ||
|
|
76c37bfeb0 | ||
|
|
feb5052fcd | ||
|
|
4836058d56 |
@@ -21,10 +21,11 @@
|
|||||||
|
|
||||||
### 健康数据追踪 ✅
|
### 健康数据追踪 ✅
|
||||||
|
|
||||||
- HealthKit 集成完成,支持步数、心率、HRV、睡眠等数据
|
- HealthKit 集成完成,支持步数、心率、HRV、睡眠、手腕温度等数据
|
||||||
- 活动圆环显示(活动卡路里、锻炼分钟、站立小时)
|
- 活动圆环显示(活动卡路里、锻炼分钟、站立小时)
|
||||||
- 实时健康数据监控和历史数据查看
|
- 实时健康数据监控和历史数据查看
|
||||||
- 健康权限管理系统
|
- 健康权限管理系统
|
||||||
|
- 经期跟踪与 HealthKit 同步
|
||||||
|
|
||||||
### 营养管理 ✅
|
### 营养管理 ✅
|
||||||
|
|
||||||
@@ -95,11 +96,12 @@
|
|||||||
|
|
||||||
### 近期更新
|
### 近期更新
|
||||||
|
|
||||||
1. **多语言支持**: 完善挑战页面的多语言翻译支持,建立翻译最佳实践指南
|
1. **健康数据**: 新增手腕温度监测功能(支持 Apple Watch 睡眠手腕温度)
|
||||||
2. **性能优化**: 优化健康数据加载和图表渲染性能
|
2. **健康数据**: 实现经期数据与 HealthKit 的双向同步(读写与删除)
|
||||||
3. **用户体验**: 改进 Liquid Glass 设计效果和交互动画
|
3. **多语言支持**: 完善挑战页面的多语言翻译支持,建立翻译最佳实践指南
|
||||||
4. **数据同步**: 增强离线功能和数据同步稳定性
|
4. **用户体验**: 改进 Liquid Glass 设计效果和交互动画
|
||||||
5. **AI 功能**: 扩展 AI 教练对话能力和分析精度
|
5. **数据同步**: 增强离线功能和数据同步稳定性
|
||||||
|
6. **AI 功能**: 扩展 AI 教练对话能力和分析精度
|
||||||
|
|
||||||
### 待解决问题
|
### 待解决问题
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
# 产品概述
|
# 产品概述
|
||||||
|
|
||||||
## 产品定位
|
## 产品定位
|
||||||
|
|
||||||
Out Live(超越生命)是一款专注于健康、减肥、瘦身和生活习惯养成的 iOS 应用。该应用通过整合健康数据追踪、AI 教练指导、目标管理和社区挑战等功能,为用户提供全方位的健康生活管理解决方案。
|
Out Live(超越生命)是一款专注于健康、减肥、瘦身和生活习惯养成的 iOS 应用。该应用通过整合健康数据追踪、AI 教练指导、目标管理和社区挑战等功能,为用户提供全方位的健康生活管理解决方案。
|
||||||
|
|
||||||
## 目标用户
|
## 目标用户
|
||||||
|
|
||||||
- 关注健康和体重管理的用户
|
- 关注健康和体重管理的用户
|
||||||
- 希望养成良好生活习惯的用户
|
- 希望养成良好生活习惯的用户
|
||||||
- 对普拉提和健身感兴趣的用户
|
- 对普拉提和健身感兴趣的用户
|
||||||
@@ -11,6 +13,7 @@ Out Live(超越生命)是一款专注于健康、减肥、瘦身和生活习
|
|||||||
- 希望通过 AI 获得个性化健康指导的用户
|
- 希望通过 AI 获得个性化健康指导的用户
|
||||||
|
|
||||||
## 核心价值主张
|
## 核心价值主张
|
||||||
|
|
||||||
1. **全方位健康数据管理**:整合 HealthKit 数据,提供步数、心率、睡眠、饮水量等多维度健康指标追踪
|
1. **全方位健康数据管理**:整合 HealthKit 数据,提供步数、心率、睡眠、饮水量等多维度健康指标追踪
|
||||||
2. **AI 智能教练**:基于用户健康数据提供个性化的健康建议和指导
|
2. **AI 智能教练**:基于用户健康数据提供个性化的健康建议和指导
|
||||||
3. **目标管理系统**:帮助用户设定、追踪和完成健康目标
|
3. **目标管理系统**:帮助用户设定、追踪和完成健康目标
|
||||||
@@ -20,50 +23,59 @@ Out Live(超越生命)是一款专注于健康、减肥、瘦身和生活习
|
|||||||
## 主要功能模块
|
## 主要功能模块
|
||||||
|
|
||||||
### 健康数据追踪
|
### 健康数据追踪
|
||||||
|
|
||||||
- **活动圆环**:展示活动卡路里、锻炼分钟和站立小时
|
- **活动圆环**:展示活动卡路里、锻炼分钟和站立小时
|
||||||
- **步数统计**:按小时显示步数数据和趋势
|
- **步数统计**:按小时显示步数数据和趋势
|
||||||
- **心率监测**:实时心率和心率变异性(HRV)分析
|
- **心率监测**:实时心率和心率变异性(HRV)分析
|
||||||
- **睡眠分析**:睡眠质量和时长追踪
|
- **睡眠分析**:睡眠质量和时长追踪
|
||||||
|
- **手腕温度**:追踪睡眠期间的手腕温度变化
|
||||||
- **体重管理**:体重记录和 BMI 计算
|
- **体重管理**:体重记录和 BMI 计算
|
||||||
- **饮水量追踪**:每日饮水目标设定和记录
|
- **饮水量追踪**:每日饮水目标设定和记录
|
||||||
|
|
||||||
### 营养管理
|
### 营养管理
|
||||||
|
|
||||||
- **饮食记录**:支持文字、语音和拍照识别食物
|
- **饮食记录**:支持文字、语音和拍照识别食物
|
||||||
- **营养分析**:卡路里、蛋白质、碳水化合物等营养成分分析
|
- **营养分析**:卡路里、蛋白质、碳水化合物等营养成分分析
|
||||||
- **食物库**:丰富的食物数据库和自定义食物功能
|
- **食物库**:丰富的食物数据库和自定义食物功能
|
||||||
- **营养标签识别**:通过拍照识别食品营养标签
|
- **营养标签识别**:通过拍照识别食品营养标签
|
||||||
|
|
||||||
### 目标与习惯管理
|
### 目标与习惯管理
|
||||||
|
|
||||||
- **目标设定**:支持日、周、月重复模式的目标设定
|
- **目标设定**:支持日、周、月重复模式的目标设定
|
||||||
- **任务管理**:将目标分解为可执行的任务
|
- **任务管理**:将目标分解为可执行的任务
|
||||||
- **进度追踪**:可视化目标完成进度
|
- **进度追踪**:可视化目标完成进度
|
||||||
- **提醒功能**:智能提醒帮助用户坚持目标
|
- **提醒功能**:智能提醒帮助用户坚持目标
|
||||||
|
|
||||||
### 轻断食功能
|
### 轻断食功能
|
||||||
- **断食计划**:多种预设断食方案(16:8、18:6等)
|
|
||||||
|
- **断食计划**:多种预设断食方案(16:8、18:6 等)
|
||||||
- **断食追踪**:实时显示断食进度和状态
|
- **断食追踪**:实时显示断食进度和状态
|
||||||
- **智能提醒**:断食开始和结束提醒
|
- **智能提醒**:断食开始和结束提醒
|
||||||
- **断食历史**:记录和分析断食历史数据
|
- **断食历史**:记录和分析断食历史数据
|
||||||
|
|
||||||
### AI 教练系统
|
### AI 教练系统
|
||||||
|
|
||||||
- **智能对话**:基于用户健康数据提供个性化建议
|
- **智能对话**:基于用户健康数据提供个性化建议
|
||||||
- **体态评估**:通过 AI 分析用户体态照片
|
- **体态评估**:通过 AI 分析用户体态照片
|
||||||
- **健康指导**:提供运动、营养和生活方式建议
|
- **健康指导**:提供运动、营养和生活方式建议
|
||||||
- **情绪分析**:基于 HRV 数据分析压力水平
|
- **情绪分析**:基于 HRV 数据分析压力水平
|
||||||
|
|
||||||
### 社区与挑战
|
### 社区与挑战
|
||||||
|
|
||||||
- **挑战赛**:参与各种健康主题挑战
|
- **挑战赛**:参与各种健康主题挑战
|
||||||
- **排行榜**:与好友或其他用户比较进度
|
- **排行榜**:与好友或其他用户比较进度
|
||||||
- **成就系统**:完成目标获得成就奖励
|
- **成就系统**:完成目标获得成就奖励
|
||||||
- **社交分享**:分享健康成果到社交平台
|
- **社交分享**:分享健康成果到社交平台
|
||||||
|
|
||||||
### 训练计划
|
### 训练计划
|
||||||
|
|
||||||
- **个性化计划**:基于用户目标生成训练计划
|
- **个性化计划**:基于用户目标生成训练计划
|
||||||
- **运动库**:丰富的运动动作库和指导
|
- **运动库**:丰富的运动动作库和指导
|
||||||
- **进度追踪**:记录训练完成情况和效果
|
- **进度追踪**:记录训练完成情况和效果
|
||||||
- **智能推荐**:根据用户表现调整训练计划
|
- **智能推荐**:根据用户表现调整训练计划
|
||||||
|
|
||||||
## 用户体验特色
|
## 用户体验特色
|
||||||
|
|
||||||
1. **Liquid Glass 设计风格**:采用现代化的毛玻璃效果设计
|
1. **Liquid Glass 设计风格**:采用现代化的毛玻璃效果设计
|
||||||
2. **数据可视化**:丰富的图表和动画展示健康数据
|
2. **数据可视化**:丰富的图表和动画展示健康数据
|
||||||
3. **快捷操作**:支持快捷动作和小组件快速记录
|
3. **快捷操作**:支持快捷动作和小组件快速记录
|
||||||
@@ -71,6 +83,7 @@ Out Live(超越生命)是一款专注于健康、减肥、瘦身和生活习
|
|||||||
5. **隐私保护**:严格保护用户健康数据隐私
|
5. **隐私保护**:严格保护用户健康数据隐私
|
||||||
|
|
||||||
## 技术亮点
|
## 技术亮点
|
||||||
|
|
||||||
- **HealthKit 深度集成**:充分利用 iOS 健康生态系统
|
- **HealthKit 深度集成**:充分利用 iOS 健康生态系统
|
||||||
- **实时数据同步**:支持多设备数据实时同步
|
- **实时数据同步**:支持多设备数据实时同步
|
||||||
- **智能通知系统**:基于用户行为的智能提醒
|
- **智能通知系统**:基于用户行为的智能提醒
|
||||||
@@ -78,11 +91,13 @@ Out Live(超越生命)是一款专注于健康、减肥、瘦身和生活习
|
|||||||
- **无障碍支持**:完整的无障碍功能支持
|
- **无障碍支持**:完整的无障碍功能支持
|
||||||
|
|
||||||
## 商业模式
|
## 商业模式
|
||||||
|
|
||||||
- **免费增值模式**:基础功能免费,高级功能付费
|
- **免费增值模式**:基础功能免费,高级功能付费
|
||||||
- **VIP 会员**:提供更多个性化功能和专业指导
|
- **VIP 会员**:提供更多个性化功能和专业指导
|
||||||
- **企业健康**:面向企业提供的员工健康管理解决方案
|
- **企业健康**:面向企业提供的员工健康管理解决方案
|
||||||
|
|
||||||
## 竞争优势
|
## 竞争优势
|
||||||
|
|
||||||
1. **全平台整合**:深度整合 iOS 健康生态系统
|
1. **全平台整合**:深度整合 iOS 健康生态系统
|
||||||
2. **AI 技术应用**:先进的 AI 分析和个性化推荐
|
2. **AI 技术应用**:先进的 AI 分析和个性化推荐
|
||||||
3. **用户体验**:优秀的界面设计和交互体验
|
3. **用户体验**:优秀的界面设计和交互体验
|
||||||
|
|||||||
@@ -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`: 使用示例
|
||||||
|
|||||||
@@ -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 文档**: 接口文档
|
||||||
|
|||||||
2
app.json
2
app.json
@@ -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",
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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: (
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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%',
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
77
components/menstrual-cycle/DayCell.tsx
Normal file
77
components/menstrual-cycle/DayCell.tsx
Normal 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',
|
||||||
|
},
|
||||||
|
});
|
||||||
119
components/menstrual-cycle/InlineTip.tsx
Normal file
119
components/menstrual-cycle/InlineTip.tsx
Normal 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',
|
||||||
|
},
|
||||||
|
});
|
||||||
61
components/menstrual-cycle/Legend.tsx
Normal file
61
components/menstrual-cycle/Legend.tsx
Normal 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',
|
||||||
|
},
|
||||||
|
});
|
||||||
140
components/menstrual-cycle/MonthBlock.tsx
Normal file
140
components/menstrual-cycle/MonthBlock.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
12
components/menstrual-cycle/constants.ts
Normal file
12
components/menstrual-cycle/constants.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { MenstrualDayStatus } from '@/utils/menstrualCycle';
|
||||||
|
|
||||||
|
export const STATUS_COLORS: Record<MenstrualDayStatus, { bg: string; text: string }> = {
|
||||||
|
period: { bg: '#f5679f', text: '#fff' },
|
||||||
|
'predicted-period': { bg: '#f8d9e9', text: '#9b2c6a' },
|
||||||
|
fertile: { bg: '#d9d2ff', text: '#5a52c5' },
|
||||||
|
'ovulation-day': { bg: '#5b4ee4', text: '#fff' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WEEK_LABELS = ['一', '二', '三', '四', '五', '六', '日'];
|
||||||
|
|
||||||
|
export const ITEM_HEIGHT = 380;
|
||||||
7
components/menstrual-cycle/index.ts
Normal file
7
components/menstrual-cycle/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export { ITEM_HEIGHT, STATUS_COLORS, WEEK_LABELS } from './constants';
|
||||||
|
export { DayCell } from './DayCell';
|
||||||
|
export { InlineTip } from './InlineTip';
|
||||||
|
export { Legend } from './Legend';
|
||||||
|
export { MonthBlock } from './MonthBlock';
|
||||||
|
export type { DayCellProps, InlineTipProps, LegendItem } from './types';
|
||||||
|
|
||||||
21
components/menstrual-cycle/types.ts
Normal file
21
components/menstrual-cycle/types.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { MenstrualDayCell, MenstrualDayInfo } from '@/utils/menstrualCycle';
|
||||||
|
import { Dayjs } from 'dayjs';
|
||||||
|
|
||||||
|
export interface DayCellProps {
|
||||||
|
cell: Extract<MenstrualDayCell, { type: 'day' }>;
|
||||||
|
isSelected: boolean;
|
||||||
|
onPress: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InlineTipProps {
|
||||||
|
selectedDate: Dayjs;
|
||||||
|
selectedInfo: MenstrualDayInfo | undefined;
|
||||||
|
columnIndex: number;
|
||||||
|
onMarkStart: () => void;
|
||||||
|
onCancelMark: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LegendItem {
|
||||||
|
label: string;
|
||||||
|
key: 'period' | 'predicted-period' | 'fertile' | 'ovulation-day';
|
||||||
|
}
|
||||||
@@ -1,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';
|
||||||
|
|||||||
@@ -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';
|
|
||||||
|
|
||||||
// 导入统一的食物类型定义
|
// 导入统一的食物类型定义
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
559
components/statistic/SunlightCard.tsx
Normal file
559
components/statistic/SunlightCard.tsx
Normal 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'
|
||||||
|
}
|
||||||
|
});
|
||||||
568
components/statistic/WristTemperatureCard.tsx
Normal file
568
components/statistic/WristTemperatureCard.tsx
Normal file
@@ -0,0 +1,568 @@
|
|||||||
|
import {
|
||||||
|
ensureHealthPermissions,
|
||||||
|
fetchWristTemperature,
|
||||||
|
fetchWristTemperatureHistory,
|
||||||
|
WristTemperatureHistoryPoint
|
||||||
|
} from '@/utils/health';
|
||||||
|
import { HealthKitUtils } from '@/utils/healthKit';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { useIsFocused } from '@react-navigation/native';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
|
import { BlurView } from 'expo-blur';
|
||||||
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import {
|
||||||
|
Dimensions,
|
||||||
|
Modal,
|
||||||
|
Platform,
|
||||||
|
Pressable,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
View
|
||||||
|
} from 'react-native';
|
||||||
|
import Svg, {
|
||||||
|
Circle,
|
||||||
|
Defs,
|
||||||
|
Line,
|
||||||
|
Path,
|
||||||
|
Stop,
|
||||||
|
LinearGradient as SvgLinearGradient
|
||||||
|
} from 'react-native-svg';
|
||||||
|
import HealthDataCard from './HealthDataCard';
|
||||||
|
|
||||||
|
interface WristTemperatureCardProps {
|
||||||
|
style?: object;
|
||||||
|
selectedDate?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
const screenWidth = Dimensions.get('window').width;
|
||||||
|
const INITIAL_CHART_WIDTH = screenWidth - 32;
|
||||||
|
const CHART_HEIGHT = 240;
|
||||||
|
const CHART_HORIZONTAL_PADDING = 20;
|
||||||
|
const LABEL_ESTIMATED_WIDTH = 44;
|
||||||
|
|
||||||
|
const WristTemperatureCard: React.FC<WristTemperatureCardProps> = ({
|
||||||
|
style,
|
||||||
|
selectedDate
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const isFocused = useIsFocused();
|
||||||
|
const [temperature, setTemperature] = useState<number | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const loadingRef = useRef(false);
|
||||||
|
const [historyVisible, setHistoryVisible] = useState(false);
|
||||||
|
const [history, setHistory] = useState<WristTemperatureHistoryPoint[]>([]);
|
||||||
|
const [historyLoading, setHistoryLoading] = useState(false);
|
||||||
|
const historyLoadingRef = useRef(false);
|
||||||
|
const [chartWidth, setChartWidth] = useState(INITIAL_CHART_WIDTH);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadData = async () => {
|
||||||
|
const dateToUse = selectedDate || new Date();
|
||||||
|
|
||||||
|
if (!isFocused) return;
|
||||||
|
if (!HealthKitUtils.isAvailable()) {
|
||||||
|
setTemperature(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 防止重复请求
|
||||||
|
if (loadingRef.current) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
loadingRef.current = true;
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
const hasPermission = await ensureHealthPermissions();
|
||||||
|
if (!hasPermission) {
|
||||||
|
setTemperature(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dayStart = dayjs(dateToUse).startOf('day');
|
||||||
|
// wrist temperature samples often start于前一晚,查询时向前扩展一天以包含跨夜数据
|
||||||
|
const options = {
|
||||||
|
startDate: dayStart.subtract(1, 'day').toDate().toISOString(),
|
||||||
|
endDate: dayStart.endOf('day').toDate().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
const data = await fetchWristTemperature(options, dateToUse);
|
||||||
|
setTemperature(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('WristTemperatureCard: Failed to get wrist temperature data:', error);
|
||||||
|
setTemperature(null);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
loadingRef.current = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadData();
|
||||||
|
}, [isFocused, selectedDate]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!historyVisible || !isFocused) return;
|
||||||
|
|
||||||
|
const loadHistory = async () => {
|
||||||
|
if (historyLoadingRef.current) return;
|
||||||
|
if (!HealthKitUtils.isAvailable()) {
|
||||||
|
setHistory([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
historyLoadingRef.current = true;
|
||||||
|
setHistoryLoading(true);
|
||||||
|
|
||||||
|
const hasPermission = await ensureHealthPermissions();
|
||||||
|
if (!hasPermission) {
|
||||||
|
setHistory([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const end = dayjs(selectedDate || new Date()).endOf('day');
|
||||||
|
const start = end.subtract(30, 'day').startOf('day').subtract(1, 'day');
|
||||||
|
const options = {
|
||||||
|
startDate: start.toDate().toISOString(),
|
||||||
|
endDate: end.toDate().toISOString(),
|
||||||
|
limit: 1200
|
||||||
|
};
|
||||||
|
|
||||||
|
const historyData = await fetchWristTemperatureHistory(options);
|
||||||
|
setHistory(historyData);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('WristTemperatureCard: Failed to get wrist temperature history:', error);
|
||||||
|
setHistory([]);
|
||||||
|
} finally {
|
||||||
|
historyLoadingRef.current = false;
|
||||||
|
setHistoryLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadHistory();
|
||||||
|
}, [historyVisible, selectedDate, isFocused]);
|
||||||
|
|
||||||
|
const baseline = useMemo(() => {
|
||||||
|
if (!history.length) return null;
|
||||||
|
const avg = history.reduce((sum, point) => sum + point.value, 0) / history.length;
|
||||||
|
return Number(avg.toFixed(2));
|
||||||
|
}, [history]);
|
||||||
|
|
||||||
|
const chartRange = useMemo(() => {
|
||||||
|
if (!history.length) return { min: -1, max: 1 };
|
||||||
|
|
||||||
|
const values = history.map((p) => p.value);
|
||||||
|
const minValue = Math.min(...values);
|
||||||
|
const maxValue = Math.max(...values);
|
||||||
|
const center = baseline ?? (minValue + maxValue) / 2;
|
||||||
|
const maxDeviation = Math.max(Math.abs(maxValue - center), Math.abs(minValue - center), 0.2);
|
||||||
|
const padding = Math.max(maxDeviation * 0.25, 0.15);
|
||||||
|
|
||||||
|
return {
|
||||||
|
min: center - maxDeviation - padding,
|
||||||
|
max: center + maxDeviation + padding
|
||||||
|
};
|
||||||
|
}, [baseline, history]);
|
||||||
|
|
||||||
|
const xStep = useMemo(() => {
|
||||||
|
if (history.length <= 1) return 0;
|
||||||
|
return (chartWidth - CHART_HORIZONTAL_PADDING * 2) / (history.length - 1);
|
||||||
|
}, [history.length, chartWidth]);
|
||||||
|
|
||||||
|
const valueToY = useCallback(
|
||||||
|
(value: number) => {
|
||||||
|
const range = chartRange.max - chartRange.min || 1;
|
||||||
|
return ((chartRange.max - value) / range) * CHART_HEIGHT;
|
||||||
|
},
|
||||||
|
[chartRange.max, chartRange.min]
|
||||||
|
);
|
||||||
|
|
||||||
|
const linePath = useMemo(() => {
|
||||||
|
if (!history.length) return '';
|
||||||
|
return history.reduce((path, point, index) => {
|
||||||
|
const x = CHART_HORIZONTAL_PADDING + xStep * index;
|
||||||
|
const y = valueToY(point.value);
|
||||||
|
if (index === 0) return `M ${x} ${y}`;
|
||||||
|
return `${path} L ${x} ${y}`;
|
||||||
|
}, '');
|
||||||
|
}, [history, valueToY, xStep]);
|
||||||
|
|
||||||
|
const latestValue = history.length ? history[history.length - 1].value : null;
|
||||||
|
const latestChange = baseline !== null && latestValue !== null ? latestValue - baseline : null;
|
||||||
|
|
||||||
|
const dateLabels = useMemo(() => {
|
||||||
|
if (!history.length) return [];
|
||||||
|
const first = history[0];
|
||||||
|
const middle = history[Math.floor(history.length / 2)];
|
||||||
|
const last = history[history.length - 1];
|
||||||
|
const uniqueDates = [first, middle, last].filter((item, idx, arr) => {
|
||||||
|
if (!item) return false;
|
||||||
|
return arr.findIndex((it) => it?.date === item.date) === idx;
|
||||||
|
});
|
||||||
|
|
||||||
|
return uniqueDates.map((point) => {
|
||||||
|
const index = history.findIndex((p) => p.date === point.date);
|
||||||
|
const positionIndex = index >= 0 ? index : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
date: point.date,
|
||||||
|
label: dayjs(point.date).format('MM.DD'),
|
||||||
|
x: CHART_HORIZONTAL_PADDING + positionIndex * xStep
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [history, xStep]);
|
||||||
|
|
||||||
|
const openHistory = useCallback(() => {
|
||||||
|
setHistoryVisible(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const closeHistory = useCallback(() => {
|
||||||
|
setHistoryVisible(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<HealthDataCard
|
||||||
|
title={t('statistics.components.wristTemperature.title')}
|
||||||
|
value={loading ? '--' : (temperature !== null && temperature !== undefined ? temperature.toFixed(1) : '--')}
|
||||||
|
unit="°C"
|
||||||
|
style={style}
|
||||||
|
onPress={openHistory}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
visible={historyVisible}
|
||||||
|
animationType="slide"
|
||||||
|
presentationStyle={Platform.OS === 'ios' ? 'pageSheet' : 'fullScreen'}
|
||||||
|
onRequestClose={closeHistory}
|
||||||
|
>
|
||||||
|
<View style={styles.modalSafeArea}>
|
||||||
|
<LinearGradient
|
||||||
|
colors={['#F7F6FF', '#FFFFFF']}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 0, y: 1 }}
|
||||||
|
style={StyleSheet.absoluteFill}
|
||||||
|
/>
|
||||||
|
<View style={styles.modalContainer}>
|
||||||
|
<View style={styles.modalHeader}>
|
||||||
|
<View>
|
||||||
|
<Text style={styles.modalTitle}>{t('statistics.components.wristTemperature.title')}</Text>
|
||||||
|
<Text style={styles.modalSubtitle}>{t('statistics.components.wristTemperature.last30Days')}</Text>
|
||||||
|
</View>
|
||||||
|
<Pressable style={styles.closeButton} onPress={closeHistory} hitSlop={10}>
|
||||||
|
<BlurView intensity={24} tint="light" style={StyleSheet.absoluteFill} />
|
||||||
|
<View style={styles.closeButtonInner}>
|
||||||
|
<Ionicons name="close" size={18} color="#111827" />
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{historyLoading ? (
|
||||||
|
<Text style={styles.hintText}>{t('statistics.components.wristTemperature.syncing')}</Text>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{history.length === 0 ? (
|
||||||
|
<View style={styles.emptyState}>
|
||||||
|
<Text style={styles.emptyText}>{t('statistics.components.wristTemperature.noData')}</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View
|
||||||
|
style={styles.chartCard}
|
||||||
|
onLayout={(event) => {
|
||||||
|
const nextWidth = event.nativeEvent.layout.width;
|
||||||
|
if (nextWidth > 120 && Math.abs(nextWidth - chartWidth) > 2) {
|
||||||
|
setChartWidth(nextWidth);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Svg width={chartWidth} height={CHART_HEIGHT + 36}>
|
||||||
|
<Defs>
|
||||||
|
<SvgLinearGradient id="lineFade" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<Stop offset="0%" stopColor="#1F2A44" stopOpacity="1" />
|
||||||
|
<Stop offset="100%" stopColor="#1F2A44" stopOpacity="0.78" />
|
||||||
|
</SvgLinearGradient>
|
||||||
|
</Defs>
|
||||||
|
|
||||||
|
<Line
|
||||||
|
x1={CHART_HORIZONTAL_PADDING}
|
||||||
|
y1={valueToY(baseline ?? 0)}
|
||||||
|
x2={chartWidth - CHART_HORIZONTAL_PADDING}
|
||||||
|
y2={valueToY(baseline ?? 0)}
|
||||||
|
stroke="#CBD5E1"
|
||||||
|
strokeDasharray="6 6"
|
||||||
|
strokeWidth={1.2}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Path d={linePath} stroke="url(#lineFade)" strokeWidth={2.6} fill="none" strokeLinecap="round" />
|
||||||
|
|
||||||
|
{history.map((point, index) => {
|
||||||
|
const x = CHART_HORIZONTAL_PADDING + xStep * index;
|
||||||
|
const y = valueToY(point.value);
|
||||||
|
return (
|
||||||
|
<Circle
|
||||||
|
key={point.date}
|
||||||
|
cx={x}
|
||||||
|
cy={y}
|
||||||
|
r={5}
|
||||||
|
stroke="#1F2A44"
|
||||||
|
strokeWidth={1.6}
|
||||||
|
fill="#FFFFFF"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Svg>
|
||||||
|
<View style={styles.labelRow}>
|
||||||
|
{dateLabels.map((item) => {
|
||||||
|
const clampedLeft = Math.min(
|
||||||
|
Math.max(item.x - LABEL_ESTIMATED_WIDTH / 2, CHART_HORIZONTAL_PADDING),
|
||||||
|
chartWidth - CHART_HORIZONTAL_PADDING - LABEL_ESTIMATED_WIDTH
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<Text key={item.date} style={[styles.axisLabel, { left: clampedLeft, width: LABEL_ESTIMATED_WIDTH }]}>
|
||||||
|
{item.label}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<View style={styles.baselineLabelWrapper}>
|
||||||
|
<View style={styles.baselinePill}>
|
||||||
|
<View style={styles.baselineDot} />
|
||||||
|
<Text style={styles.axisHint}>{t('statistics.components.wristTemperature.baseline')}</Text>
|
||||||
|
{baseline !== null && (
|
||||||
|
<Text style={styles.axisHintValue}>
|
||||||
|
{baseline.toFixed(1)}
|
||||||
|
°C
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
{latestChange !== null && (
|
||||||
|
<View style={styles.deviationBadge}>
|
||||||
|
<Text style={styles.deviationBadgeText}>
|
||||||
|
{latestChange >= 0 ? '+' : ''}
|
||||||
|
{latestChange.toFixed(1)}°C
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View style={styles.metricsRow}>
|
||||||
|
<View style={styles.metric}>
|
||||||
|
<Text style={styles.metricLabel}>{t('statistics.components.wristTemperature.average')}</Text>
|
||||||
|
<Text style={styles.metricValue}>
|
||||||
|
{baseline !== null ? baseline.toFixed(1) : '--'}
|
||||||
|
<Text style={styles.metricUnit}>°C</Text>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.metric}>
|
||||||
|
<Text style={styles.metricLabel}>{t('statistics.components.wristTemperature.latest')}</Text>
|
||||||
|
<Text style={styles.metricValue}>
|
||||||
|
{latestValue !== null ? latestValue.toFixed(1) : '--'}
|
||||||
|
<Text style={styles.metricUnit}>°C</Text>
|
||||||
|
</Text>
|
||||||
|
{latestChange !== null && (
|
||||||
|
<Text style={styles.metricHint}>
|
||||||
|
{latestChange >= 0 ? '+' : ''}
|
||||||
|
{latestChange.toFixed(1)}°C {t('statistics.components.wristTemperature.vsBaseline')}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default WristTemperatureCard;
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
modalSafeArea: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
paddingTop: Platform.OS === 'ios' ? 10 : 0
|
||||||
|
},
|
||||||
|
modalContainer: {
|
||||||
|
flex: 1,
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingTop: 22
|
||||||
|
},
|
||||||
|
modalHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginBottom: 14
|
||||||
|
},
|
||||||
|
modalTitle: {
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#1C1C28',
|
||||||
|
fontFamily: 'AliBold'
|
||||||
|
},
|
||||||
|
modalSubtitle: {
|
||||||
|
fontSize: 13,
|
||||||
|
color: '#6B7280',
|
||||||
|
marginTop: 4,
|
||||||
|
fontFamily: 'AliRegular'
|
||||||
|
},
|
||||||
|
closeButton: {
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
borderRadius: 18,
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.42)',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
overflow: 'hidden',
|
||||||
|
borderWidth: 0.5,
|
||||||
|
borderColor: 'rgba(255,255,255,0.6)',
|
||||||
|
shadowColor: '#0F172A',
|
||||||
|
shadowOpacity: 0.08,
|
||||||
|
shadowRadius: 8,
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
elevation: 2
|
||||||
|
},
|
||||||
|
closeButtonInner: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center'
|
||||||
|
},
|
||||||
|
chartCard: {
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderRadius: 24,
|
||||||
|
paddingVertical: 12,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOpacity: 0.04,
|
||||||
|
shadowRadius: 12,
|
||||||
|
shadowOffset: { width: 0, height: 10 },
|
||||||
|
elevation: 4,
|
||||||
|
marginTop: 8,
|
||||||
|
marginBottom: 14,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#F1F5F9'
|
||||||
|
},
|
||||||
|
labelRow: {
|
||||||
|
marginTop: -6,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
height: 44,
|
||||||
|
justifyContent: 'center'
|
||||||
|
},
|
||||||
|
axisLabel: {
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 0,
|
||||||
|
fontSize: 11,
|
||||||
|
color: '#94A3B8',
|
||||||
|
fontFamily: 'AliRegular',
|
||||||
|
textAlign: 'center'
|
||||||
|
},
|
||||||
|
baselineLabelWrapper: {
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
top: -4,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center'
|
||||||
|
},
|
||||||
|
baselinePill: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 6,
|
||||||
|
backgroundColor: '#F1F5F9',
|
||||||
|
borderRadius: 14,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#E2E8F0',
|
||||||
|
gap: 6
|
||||||
|
},
|
||||||
|
baselineDot: {
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: 4,
|
||||||
|
backgroundColor: '#94A3B8'
|
||||||
|
},
|
||||||
|
axisHint: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#6B7280',
|
||||||
|
fontFamily: 'AliRegular'
|
||||||
|
},
|
||||||
|
axisHintValue: {
|
||||||
|
fontSize: 13,
|
||||||
|
color: '#111827',
|
||||||
|
fontWeight: '700',
|
||||||
|
fontFamily: 'AliBold'
|
||||||
|
},
|
||||||
|
deviationBadge: {
|
||||||
|
position: 'absolute',
|
||||||
|
right: 12,
|
||||||
|
bottom: 2,
|
||||||
|
backgroundColor: '#ECFEFF',
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 5,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#CFFAFE'
|
||||||
|
},
|
||||||
|
deviationBadgeText: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#0EA5E9',
|
||||||
|
fontWeight: '700',
|
||||||
|
fontFamily: 'AliBold'
|
||||||
|
},
|
||||||
|
metricsRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: 12,
|
||||||
|
paddingVertical: 6
|
||||||
|
},
|
||||||
|
metric: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#F8FAFC',
|
||||||
|
borderRadius: 18,
|
||||||
|
padding: 14,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#E2E8F0'
|
||||||
|
},
|
||||||
|
metricLabel: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#6B7280',
|
||||||
|
marginBottom: 6,
|
||||||
|
fontFamily: 'AliRegular'
|
||||||
|
},
|
||||||
|
metricValue: {
|
||||||
|
fontSize: 20,
|
||||||
|
color: '#111827',
|
||||||
|
fontWeight: '700',
|
||||||
|
fontFamily: 'AliBold'
|
||||||
|
},
|
||||||
|
metricUnit: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#6B7280',
|
||||||
|
marginLeft: 4,
|
||||||
|
fontWeight: '500',
|
||||||
|
fontFamily: 'AliRegular'
|
||||||
|
},
|
||||||
|
metricHint: {
|
||||||
|
marginTop: 6,
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#6B21A8',
|
||||||
|
fontFamily: 'AliRegular'
|
||||||
|
},
|
||||||
|
emptyState: {
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingVertical: 32
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#94A3B8',
|
||||||
|
fontFamily: 'AliRegular'
|
||||||
|
},
|
||||||
|
hintText: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#6B7280',
|
||||||
|
marginBottom: 6,
|
||||||
|
fontFamily: 'AliRegular'
|
||||||
|
}
|
||||||
|
});
|
||||||
44
components/ui/Image.tsx
Normal file
44
components/ui/Image.tsx
Normal 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';
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}}',
|
||||||
|
|||||||
@@ -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
53
i18n/en/menstrual.ts
Normal 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.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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}}',
|
||||||
|
|||||||
@@ -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
52
i18n/zh/menstrual.ts
Normal 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 个周期的记录,计算平均经期和周期长度,后续会展示趋势和预测准确度。',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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: '围度',
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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]! {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
229
utils/health.ts
229
utils/health.ts
@@ -2,6 +2,7 @@ import { CompleteSleepData, fetchCompleteSleepData } from '@/utils/sleepHealthKi
|
|||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { AppState, AppStateStatus, NativeModules } from 'react-native';
|
import { AppState, AppStateStatus, NativeModules } from 'react-native';
|
||||||
import i18n from '../i18n';
|
import i18n from '../i18n';
|
||||||
|
import { logger } from './logger';
|
||||||
import { SimpleEventEmitter } from './SimpleEventEmitter';
|
import { SimpleEventEmitter } from './SimpleEventEmitter';
|
||||||
|
|
||||||
type HealthDataOptions = {
|
type HealthDataOptions = {
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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; // 12–14 天之间较为稳定
|
||||||
|
|
||||||
export const createDefaultRecords = (): CycleRecord[] => {
|
const clampCycleLength = (value: number, fallback: number) => {
|
||||||
const today = dayjs();
|
if (!Number.isFinite(value) || value <= 0) return fallback;
|
||||||
const latestStart = today.subtract(4, 'day'); // 默认让今天处于经期第5天
|
return Math.max(MIN_CYCLE_LENGTH, Math.min(MAX_CYCLE_LENGTH, Math.round(value)));
|
||||||
const previousStart = latestStart.subtract(DEFAULT_CYCLE_LENGTH, 'day');
|
};
|
||||||
const olderStart = previousStart.subtract(DEFAULT_CYCLE_LENGTH, 'day');
|
|
||||||
|
|
||||||
return [
|
const calcMedian = (values: number[]) => {
|
||||||
{ startDate: olderStart.format('YYYY-MM-DD'), periodLength: DEFAULT_PERIOD_LENGTH },
|
if (!values.length) return undefined;
|
||||||
{ startDate: previousStart.format('YYYY-MM-DD'), periodLength: DEFAULT_PERIOD_LENGTH },
|
const sorted = [...values].sort((a, b) => a - b);
|
||||||
{ startDate: latestStart.format('YYYY-MM-DD'), periodLength: DEFAULT_PERIOD_LENGTH },
|
const mid = Math.floor(sorted.length / 2);
|
||||||
];
|
if (sorted.length % 2 === 0) {
|
||||||
|
return (sorted[mid - 1] + sorted[mid]) / 2;
|
||||||
|
}
|
||||||
|
return sorted[mid];
|
||||||
|
};
|
||||||
|
|
||||||
|
const calcTrimmedMean = (values: number[], trim = 1) => {
|
||||||
|
if (!values.length) return undefined;
|
||||||
|
const sorted = [...values].sort((a, b) => a - b);
|
||||||
|
const start = Math.min(trim, sorted.length);
|
||||||
|
const end = Math.max(sorted.length - trim, start);
|
||||||
|
const sliced = sorted.slice(start, end);
|
||||||
|
if (!sliced.length) return undefined;
|
||||||
|
return sliced.reduce((sum, cur) => sum + cur, 0) / sliced.length;
|
||||||
|
};
|
||||||
|
|
||||||
|
const calcStdDev = (values: number[]) => {
|
||||||
|
if (values.length < 2) return 0;
|
||||||
|
const mean = values.reduce((s, v) => s + v, 0) / values.length;
|
||||||
|
const variance =
|
||||||
|
values.reduce((s, v) => s + (v - mean) * (v - mean), 0) / (values.length - 1);
|
||||||
|
return Math.sqrt(variance);
|
||||||
};
|
};
|
||||||
|
|
||||||
const calcAverageCycleLength = (records: CycleRecord[], fallback = DEFAULT_CYCLE_LENGTH) => {
|
const calcAverageCycleLength = (records: CycleRecord[], fallback = DEFAULT_CYCLE_LENGTH) => {
|
||||||
@@ -81,8 +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;
|
||||||
|
};
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
Reference in New Issue
Block a user