Compare commits
17 Commits
5b46104564
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17664c679d | ||
|
|
e51aca2fdb | ||
|
|
76c37bfeb0 | ||
|
|
feb5052fcd | ||
|
|
4836058d56 | ||
|
|
9b4a300380 | ||
|
|
5e11da34ee | ||
| 409f125db1 | |||
|
|
eef0134ddc | ||
|
|
0013dc3266 | ||
|
|
37a0687456 | ||
|
|
74b49efe23 | ||
|
|
3d08721474 | ||
|
|
f3d4264b53 | ||
|
|
a254af92c7 | ||
|
|
e713ffbace | ||
|
|
02b2de3ea3 |
@@ -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,13 +91,15 @@ Out Live(超越生命)是一款专注于健康、减肥、瘦身和生活习
|
|||||||
- **无障碍支持**:完整的无障碍功能支持
|
- **无障碍支持**:完整的无障碍功能支持
|
||||||
|
|
||||||
## 商业模式
|
## 商业模式
|
||||||
|
|
||||||
- **免费增值模式**:基础功能免费,高级功能付费
|
- **免费增值模式**:基础功能免费,高级功能付费
|
||||||
- **VIP 会员**:提供更多个性化功能和专业指导
|
- **VIP 会员**:提供更多个性化功能和专业指导
|
||||||
- **企业健康**:面向企业提供的员工健康管理解决方案
|
- **企业健康**:面向企业提供的员工健康管理解决方案
|
||||||
|
|
||||||
## 竞争优势
|
## 竞争优势
|
||||||
|
|
||||||
1. **全平台整合**:深度整合 iOS 健康生态系统
|
1. **全平台整合**:深度整合 iOS 健康生态系统
|
||||||
2. **AI 技术应用**:先进的 AI 分析和个性化推荐
|
2. **AI 技术应用**:先进的 AI 分析和个性化推荐
|
||||||
3. **用户体验**:优秀的界面设计和交互体验
|
3. **用户体验**:优秀的界面设计和交互体验
|
||||||
4. **数据安全**:严格的数据隐私保护措施
|
4. **数据安全**:严格的数据隐私保护措施
|
||||||
5. **专业内容**:基于科学研究的健康指导内容
|
5. **专业内容**:基于科学研究的健康指导内容
|
||||||
|
|||||||
@@ -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,19 +241,22 @@
|
|||||||
## 开发规范
|
## 开发规范
|
||||||
|
|
||||||
### 代码规范
|
### 代码规范
|
||||||
|
|
||||||
- **ESLint**: 代码检查
|
- **ESLint**: 代码检查
|
||||||
- **Prettier**: 代码格式化
|
- **Prettier**: 代码格式化
|
||||||
- **TypeScript**: 类型安全
|
- **TypeScript**: 类型安全
|
||||||
- **命名规范**: 统一命名
|
- **命名规范**: 统一命名
|
||||||
|
|
||||||
### Git 工作流
|
### Git 工作流
|
||||||
|
|
||||||
- **Conventional Commits**: 提交规范
|
- **Conventional Commits**: 提交规范
|
||||||
- **分支策略**: Git Flow
|
- **分支策略**: Git Flow
|
||||||
- **代码审查**: PR 流程
|
- **代码审查**: PR 流程
|
||||||
- **版本标签**: 标签管理
|
- **版本标签**: 标签管理
|
||||||
|
|
||||||
### 文档规范
|
### 文档规范
|
||||||
|
|
||||||
- **JSDoc**: 代码注释
|
- **JSDoc**: 代码注释
|
||||||
- **README**: 项目文档
|
- **README**: 项目文档
|
||||||
- **API 文档**: 接口文档
|
- **API 文档**: 接口文档
|
||||||
- **组件文档**: 组件说明
|
- **组件文档**: 组件说明
|
||||||
|
|||||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -4,5 +4,6 @@
|
|||||||
"source.organizeImports": "explicit",
|
"source.organizeImports": "explicit",
|
||||||
"source.sortMembers": "explicit"
|
"source.sortMembers": "explicit"
|
||||||
},
|
},
|
||||||
"kiroAgent.configureMCP": "Enabled"
|
"kiroAgent.configureMCP": "Enabled",
|
||||||
|
"codingcopilot.enableCompletionLanguage": {}
|
||||||
}
|
}
|
||||||
|
|||||||
11
app.json
11
app.json
@@ -2,7 +2,7 @@
|
|||||||
"expo": {
|
"expo": {
|
||||||
"name": "Out Live",
|
"name": "Out Live",
|
||||||
"slug": "digital-pilates",
|
"slug": "digital-pilates",
|
||||||
"version": "1.1.4",
|
"version": "1.1.6",
|
||||||
"orientation": "portrait",
|
"orientation": "portrait",
|
||||||
"scheme": "digitalpilates",
|
"scheme": "digitalpilates",
|
||||||
"userInterfaceStyle": "light",
|
"userInterfaceStyle": "light",
|
||||||
@@ -36,6 +36,7 @@
|
|||||||
},
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
"expo-router",
|
"expo-router",
|
||||||
|
"expo-updates",
|
||||||
[
|
[
|
||||||
"expo-splash-screen",
|
"expo-splash-screen",
|
||||||
{
|
{
|
||||||
@@ -70,8 +71,16 @@
|
|||||||
"experiments": {
|
"experiments": {
|
||||||
"typedRoutes": true
|
"typedRoutes": true
|
||||||
},
|
},
|
||||||
|
"runtimeVersion": {
|
||||||
|
"policy": "appVersion"
|
||||||
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"package": "com.anonymous.digitalpilates"
|
"package": "com.anonymous.digitalpilates"
|
||||||
|
},
|
||||||
|
"updates": {
|
||||||
|
"enabled": true,
|
||||||
|
"checkAutomatically": "ON_LOAD",
|
||||||
|
"url": "https://pilate.richarjiang.com/api/expo-updates/manifest"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -23,11 +23,11 @@ type TabConfig = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const TAB_CONFIGS: Record<string, TabConfig> = {
|
const TAB_CONFIGS: Record<string, TabConfig> = {
|
||||||
statistics: { icon: 'chart.pie.fill', titleKey: 'statistics.tabs.health' },
|
statistics: { icon: 'chart.pie.fill', titleKey: 'health.tabs.health' },
|
||||||
medications: { icon: 'pills.fill', titleKey: 'statistics.tabs.medications' },
|
medications: { icon: 'pills.fill', titleKey: 'health.tabs.medications' },
|
||||||
fasting: { icon: 'timer', titleKey: 'statistics.tabs.fasting' },
|
fasting: { icon: 'timer', titleKey: 'health.tabs.fasting' },
|
||||||
challenges: { icon: 'trophy.fill', titleKey: 'statistics.tabs.challenges' },
|
challenges: { icon: 'trophy.fill', titleKey: 'health.tabs.challenges' },
|
||||||
personal: { icon: 'person.fill', titleKey: 'statistics.tabs.personal' },
|
personal: { icon: 'person.fill', titleKey: 'health.tabs.personal' },
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function TabLayout() {
|
export default function TabLayout() {
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
import ActivityHeatMap from '@/components/ActivityHeatMap';
|
import ActivityHeatMap from '@/components/ActivityHeatMap';
|
||||||
import { BadgeShowcaseModal } from '@/components/badges/BadgeShowcaseModal';
|
import { BadgeShowcaseModal } from '@/components/badges/BadgeShowcaseModal';
|
||||||
|
import { MembershipBanner } from '@/components/MembershipBanner';
|
||||||
import { PRIVACY_POLICY_URL, USER_AGREEMENT_URL } from '@/constants/Agree';
|
import { PRIVACY_POLICY_URL, USER_AGREEMENT_URL } from '@/constants/Agree';
|
||||||
import { palette } from '@/constants/Colors';
|
import { Colors } from '@/constants/Colors';
|
||||||
import { ROUTES } from '@/constants/Routes';
|
import { ROUTES } from '@/constants/Routes';
|
||||||
import { getTabBarBottomPadding } from '@/constants/TabBar';
|
import { getTabBarBottomPadding } from '@/constants/TabBar';
|
||||||
import { useMembershipModal } from '@/contexts/MembershipModalContext';
|
import { useMembershipModal } from '@/contexts/MembershipModalContext';
|
||||||
import { useVersionCheck } from '@/contexts/VersionCheckContext';
|
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 { 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';
|
||||||
@@ -21,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';
|
||||||
@@ -56,6 +59,8 @@ type LanguageOption = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function PersonalScreen() {
|
export default function PersonalScreen() {
|
||||||
|
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||||
|
const colorTokens = Colors[theme];
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const { confirmLogout, confirmDeleteAccount, isLoggedIn, pushIfAuthedElseLogin, ensureLoggedIn } = useAuthGuard();
|
const { confirmLogout, confirmDeleteAccount, isLoggedIn, pushIfAuthedElseLogin, ensureLoggedIn } = useAuthGuard();
|
||||||
const { openMembershipModal } = useMembershipModal();
|
const { openMembershipModal } = useMembershipModal();
|
||||||
@@ -70,6 +75,11 @@ export default function PersonalScreen() {
|
|||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
const { checkForUpdate, isChecking: isCheckingVersion, updateInfo } = useVersionCheck();
|
const { checkForUpdate, isChecking: isCheckingVersion, updateInfo } = useVersionCheck();
|
||||||
|
|
||||||
|
const gradientColors: [string, string] =
|
||||||
|
theme === 'dark'
|
||||||
|
? ['#1f2230', '#10131e']
|
||||||
|
: [colorTokens.backgroundGradientStart, colorTokens.backgroundGradientEnd];
|
||||||
|
|
||||||
const languageOptions = useMemo<LanguageOption[]>(() => ([
|
const languageOptions = useMemo<LanguageOption[]>(() => ([
|
||||||
{
|
{
|
||||||
code: 'zh' as AppLanguage,
|
code: 'zh' as AppLanguage,
|
||||||
@@ -260,25 +270,6 @@ export default function PersonalScreen() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 数据格式化函数
|
|
||||||
const formatHeight = () => {
|
|
||||||
if (userProfile.height == null) return '--';
|
|
||||||
return `${parseFloat(userProfile.height).toFixed(1)}cm`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatWeight = () => {
|
|
||||||
if (userProfile.weight == null) return '--';
|
|
||||||
return `${parseFloat(userProfile.weight).toFixed(1)}kg`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatAge = () => {
|
|
||||||
if (!userProfile.birthDate) return '--';
|
|
||||||
const birthDate = new Date(userProfile.birthDate);
|
|
||||||
const today = new Date();
|
|
||||||
const age = today.getFullYear() - birthDate.getFullYear();
|
|
||||||
return `${age}${t('personal.stats.ageSuffix')}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 显示名称
|
// 显示名称
|
||||||
const displayName = (userProfile.name?.trim()) ? userProfile.name : DEFAULT_MEMBER_NAME;
|
const displayName = (userProfile.name?.trim()) ? userProfile.name : DEFAULT_MEMBER_NAME;
|
||||||
const profileActionLabel = isLoggedIn ? t('personal.edit') : t('personal.login');
|
const profileActionLabel = isLoggedIn ? t('personal.edit') : t('personal.login');
|
||||||
@@ -369,25 +360,6 @@ export default function PersonalScreen() {
|
|||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
const MembershipBanner = () => (
|
|
||||||
<View style={styles.sectionContainer}>
|
|
||||||
<TouchableOpacity
|
|
||||||
activeOpacity={0.9}
|
|
||||||
onPress={() => {
|
|
||||||
void handleMembershipPress();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
source={{ uri: 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/banner/vip2.png' }}
|
|
||||||
style={styles.membershipBannerImage}
|
|
||||||
contentFit="cover"
|
|
||||||
transition={200}
|
|
||||||
cachePolicy="memory-disk"
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
const VipMembershipCard = () => {
|
const VipMembershipCard = () => {
|
||||||
const fallbackProfile = userProfile as Record<string, unknown>;
|
const fallbackProfile = userProfile as Record<string, unknown>;
|
||||||
const fallbackExpire = ['membershipExpiration', 'vipExpiredAt', 'vipExpiresAt', 'vipExpireDate']
|
const fallbackExpire = ['membershipExpiration', 'vipExpiredAt', 'vipExpiresAt', 'vipExpireDate']
|
||||||
@@ -454,27 +426,33 @@ export default function PersonalScreen() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 数据统计部分
|
// 健康档案入口组件
|
||||||
const StatsSection = () => (
|
const HealthProfileEntry = () => (
|
||||||
<View style={styles.sectionContainer}>
|
<View style={styles.sectionContainer}>
|
||||||
<View style={[styles.cardContainer, {
|
<TouchableOpacity
|
||||||
backgroundColor: 'transparent'
|
style={styles.healthProfileCard}
|
||||||
}]}>
|
activeOpacity={0.9}
|
||||||
<View style={styles.statsContainer}>
|
onPress={() => router.push(ROUTES.HEALTH_PROFILE)}
|
||||||
<View style={styles.statItem}>
|
>
|
||||||
<Text style={styles.statValue}>{formatHeight()}</Text>
|
<LinearGradient
|
||||||
<Text style={styles.statLabel}>{t('personal.stats.height')}</Text>
|
colors={['#FFFFFF', '#F0F4FF']}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 1 }}
|
||||||
|
style={styles.healthProfileGradient}
|
||||||
|
>
|
||||||
|
<View style={styles.healthProfileContent}>
|
||||||
|
<View style={styles.healthProfileLeft}>
|
||||||
|
<View style={styles.healthProfileTitleRow}>
|
||||||
|
<Text style={styles.healthProfileTitle}>{t('personal.healthProfile.title') || '健康档案'}</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.healthProfileSubtitle}>{t('personal.healthProfile.subtitle') || '管理您的个人健康数据与家庭档案'}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.healthProfileRight}>
|
||||||
|
<Ionicons name="chevron-forward" size={20} color="#9CA3AF" />
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.statItem}>
|
</LinearGradient>
|
||||||
<Text style={styles.statValue}>{formatWeight()}</Text>
|
</TouchableOpacity>
|
||||||
<Text style={styles.statLabel}>{t('personal.stats.weight')}</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.statItem}>
|
|
||||||
<Text style={styles.statValue}>{formatAge()}</Text>
|
|
||||||
<Text style={styles.statLabel}>{t('personal.stats.age')}</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -793,15 +771,13 @@ export default function PersonalScreen() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={[styles.container, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}>
|
||||||
<StatusBar barStyle={'dark-content'} backgroundColor="transparent" translucent />
|
<StatusBar barStyle={theme === 'dark' ? 'light-content' : 'dark-content'} backgroundColor="transparent" translucent />
|
||||||
|
|
||||||
{/* 背景渐变 */}
|
{/* 背景渐变 */}
|
||||||
<LinearGradient
|
<LinearGradient
|
||||||
colors={[palette.purple[100], '#F5F5F5']}
|
colors={gradientColors}
|
||||||
start={{ x: 1, y: 0 }}
|
style={StyleSheet.absoluteFillObject}
|
||||||
end={{ x: 0.3, y: 0.4 }}
|
|
||||||
style={styles.gradientBackground}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ScrollView
|
<ScrollView
|
||||||
@@ -823,8 +799,8 @@ export default function PersonalScreen() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<UserHeader />
|
<UserHeader />
|
||||||
{userProfile.isVip ? <VipMembershipCard /> : <MembershipBanner />}
|
{userProfile.isVip ? <VipMembershipCard /> : <MembershipBanner onPress={() => void handleMembershipPress()} />}
|
||||||
<StatsSection />
|
<HealthProfileEntry />
|
||||||
<BadgesPreviewSection />
|
<BadgesPreviewSection />
|
||||||
<View style={styles.fishRecordContainer}>
|
<View style={styles.fishRecordContainer}>
|
||||||
{/* <Image
|
{/* <Image
|
||||||
@@ -855,14 +831,6 @@ export default function PersonalScreen() {
|
|||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: '#F5F5F5',
|
|
||||||
},
|
|
||||||
gradientBackground: {
|
|
||||||
position: 'absolute',
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
top: 0,
|
|
||||||
height: '60%',
|
|
||||||
},
|
},
|
||||||
scrollView: {
|
scrollView: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
@@ -889,11 +857,6 @@ const styles = StyleSheet.create({
|
|||||||
elevation: 2,
|
elevation: 2,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
},
|
},
|
||||||
membershipBannerImage: {
|
|
||||||
width: '100%',
|
|
||||||
height: 180,
|
|
||||||
borderRadius: 16,
|
|
||||||
},
|
|
||||||
vipCard: {
|
vipCard: {
|
||||||
borderRadius: 20,
|
borderRadius: 20,
|
||||||
padding: 20,
|
padding: 20,
|
||||||
@@ -1315,4 +1278,60 @@ const styles = StyleSheet.create({
|
|||||||
color: '#9370DB',
|
color: '#9370DB',
|
||||||
fontFamily: 'AliBold',
|
fontFamily: 'AliBold',
|
||||||
},
|
},
|
||||||
|
// 健康档案入口样式
|
||||||
|
healthProfileCard: {
|
||||||
|
borderRadius: 16,
|
||||||
|
overflow: 'hidden',
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.05,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 2,
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
},
|
||||||
|
healthProfileGradient: {
|
||||||
|
padding: 16,
|
||||||
|
},
|
||||||
|
healthProfileContent: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
},
|
||||||
|
healthProfileLeft: {
|
||||||
|
flex: 1,
|
||||||
|
marginRight: 16,
|
||||||
|
},
|
||||||
|
healthProfileTitleRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 6,
|
||||||
|
},
|
||||||
|
healthProfileTitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#1F2937',
|
||||||
|
marginRight: 8,
|
||||||
|
fontFamily: 'AliBold',
|
||||||
|
},
|
||||||
|
healthStatusBadge: {
|
||||||
|
backgroundColor: '#ECFDF5',
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 2,
|
||||||
|
borderRadius: 10,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#A7F3D0',
|
||||||
|
},
|
||||||
|
healthStatusText: {
|
||||||
|
fontSize: 10,
|
||||||
|
color: '#059669',
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
healthProfileSubtitle: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#6B7280',
|
||||||
|
fontFamily: 'AliRegular',
|
||||||
|
},
|
||||||
|
healthProfileRight: {
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { BasalMetabolismCard } from '@/components/BasalMetabolismCard';
|
import { BasalMetabolismCard } from '@/components/BasalMetabolismCard';
|
||||||
import { DateSelector } from '@/components/DateSelector';
|
import { DateSelector } from '@/components/DateSelector';
|
||||||
import { FitnessRingsCard } from '@/components/FitnessRingsCard';
|
import { FitnessRingsCard } from '@/components/FitnessRingsCard';
|
||||||
|
import { MenstrualCycleCard } from '@/components/MenstrualCycleCard';
|
||||||
import { MoodCard } from '@/components/MoodCard';
|
import { MoodCard } from '@/components/MoodCard';
|
||||||
import { NutritionRadarCard } from '@/components/NutritionRadarCard';
|
import { NutritionRadarCard } from '@/components/NutritionRadarCard';
|
||||||
import CircumferenceCard from '@/components/statistic/CircumferenceCard';
|
import CircumferenceCard from '@/components/statistic/CircumferenceCard';
|
||||||
import OxygenSaturationCard from '@/components/statistic/OxygenSaturationCard';
|
import OxygenSaturationCard from '@/components/statistic/OxygenSaturationCard';
|
||||||
import SleepCard from '@/components/statistic/SleepCard';
|
import SleepCard from '@/components/statistic/SleepCard';
|
||||||
|
import 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';
|
||||||
@@ -14,7 +17,7 @@ import { WorkoutSummaryCard } from '@/components/WorkoutSummaryCard';
|
|||||||
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';
|
||||||
import { syncHealthKitToServer } from '@/services/healthKitSync';
|
import { syncDailyHealthReport, syncHealthKitToServer } from '@/services/healthKitSync';
|
||||||
import { setHealthData } from '@/store/healthSlice';
|
import { setHealthData } from '@/store/healthSlice';
|
||||||
import { fetchDailyMoodCheckins, selectLatestMoodRecordByDate } from '@/store/moodSlice';
|
import { fetchDailyMoodCheckins, selectLatestMoodRecordByDate } from '@/store/moodSlice';
|
||||||
import { updateUserProfile } from '@/store/userSlice';
|
import { updateUserProfile } from '@/store/userSlice';
|
||||||
@@ -22,13 +25,14 @@ import { fetchTodayWaterStats } from '@/store/waterSlice';
|
|||||||
import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date';
|
import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date';
|
||||||
import { fetchHealthDataForDate } from '@/utils/health';
|
import { fetchHealthDataForDate } from '@/utils/health';
|
||||||
import { logger } from '@/utils/logger';
|
import { logger } from '@/utils/logger';
|
||||||
|
import { DEFAULT_CARD_ORDER, getStatisticsCardOrder, getStatisticsCardsVisibility, StatisticsCardsVisibility } from '@/utils/userPreferences';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
import { useRouter } from 'expo-router';
|
import { useFocusEffect, useRouter } from 'expo-router';
|
||||||
import { debounce } from 'lodash';
|
import { debounce } from 'lodash';
|
||||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
AppState,
|
AppState,
|
||||||
@@ -64,7 +68,9 @@ export default function ExploreScreen() {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const stepGoal = useAppSelector((s) => s.user.profile?.dailyStepsGoal) ?? 2000;
|
const stepGoal = useAppSelector((s) => s.user.profile?.dailyStepsGoal) ?? 2000;
|
||||||
const userProfile = useAppSelector((s) => s.user.profile);
|
const userProfile = useAppSelector((s) => s.user.profile);
|
||||||
|
const todayWaterStats = useAppSelector((s) => s.water.todayStats);
|
||||||
|
|
||||||
|
|
||||||
const { pushIfAuthedElseLogin, isLoggedIn, ensureLoggedIn } = useAuthGuard();
|
const { pushIfAuthedElseLogin, isLoggedIn, ensureLoggedIn } = useAuthGuard();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
@@ -88,9 +94,51 @@ export default function ExploreScreen() {
|
|||||||
router.push('/gallery');
|
router.push('/gallery');
|
||||||
}, [ensureLoggedIn, router]);
|
}, [ensureLoggedIn, router]);
|
||||||
|
|
||||||
|
const handleOpenCustomization = React.useCallback(() => {
|
||||||
|
router.push('/statistics-customization');
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
// 用于触发动画重置的 token(当日期或数据变化时更新)
|
// 用于触发动画重置的 token(当日期或数据变化时更新)
|
||||||
const [animToken, setAnimToken] = useState(0);
|
const [animToken, setAnimToken] = useState(0);
|
||||||
|
|
||||||
|
// 首页卡片显示设置
|
||||||
|
const [cardVisibility, setCardVisibility] = useState<StatisticsCardsVisibility>({
|
||||||
|
showMood: true,
|
||||||
|
showSteps: true,
|
||||||
|
showStress: true,
|
||||||
|
showSleep: true,
|
||||||
|
showFitnessRings: true,
|
||||||
|
showWater: true,
|
||||||
|
showBasalMetabolism: true,
|
||||||
|
showOxygenSaturation: true,
|
||||||
|
showWristTemperature: true,
|
||||||
|
showMenstrualCycle: true,
|
||||||
|
showWeight: true,
|
||||||
|
showCircumference: true,
|
||||||
|
showSunlight: true,
|
||||||
|
});
|
||||||
|
const [cardOrder, setCardOrder] = useState<string[]>(DEFAULT_CARD_ORDER);
|
||||||
|
|
||||||
|
// 加载卡片设置
|
||||||
|
const loadSettings = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const [visibility, order] = await Promise.all([
|
||||||
|
getStatisticsCardsVisibility(),
|
||||||
|
getStatisticsCardOrder(),
|
||||||
|
]);
|
||||||
|
setCardVisibility(visibility);
|
||||||
|
setCardOrder(order);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load card settings:', error);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 页面聚焦时加载设置
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
loadSettings();
|
||||||
|
}, [loadSettings])
|
||||||
|
);
|
||||||
|
|
||||||
// 心情相关状态
|
// 心情相关状态
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
@@ -293,6 +341,7 @@ export default function ExploreScreen() {
|
|||||||
try {
|
try {
|
||||||
logger.info('开始同步 HealthKit 个人健康数据到服务端...');
|
logger.info('开始同步 HealthKit 个人健康数据到服务端...');
|
||||||
|
|
||||||
|
// 1. 同步个人资料 (身高、体重、出生日期)
|
||||||
// 传入当前用户资料,用于 diff 比较
|
// 传入当前用户资料,用于 diff 比较
|
||||||
const success = await syncHealthKitToServer(
|
const success = await syncHealthKitToServer(
|
||||||
async (data) => {
|
async (data) => {
|
||||||
@@ -302,20 +351,36 @@ export default function ExploreScreen() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
logger.info('HealthKit 数据同步到服务端成功');
|
logger.info('HealthKit 个人资料同步到服务端成功');
|
||||||
} else {
|
} else {
|
||||||
logger.info('HealthKit 数据同步到服务端跳过(无变化)或失败');
|
logger.info('HealthKit 个人资料同步到服务端跳过(无变化)或失败');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2. 同步每日健康数据报表 (活动、睡眠、心率等)
|
||||||
|
// 传入今日饮水量
|
||||||
|
const waterIntake = todayWaterStats?.totalAmount;
|
||||||
|
logger.info('开始同步每日健康数据报表...', { waterIntake });
|
||||||
|
|
||||||
|
const reportSuccess = await syncDailyHealthReport(waterIntake);
|
||||||
|
|
||||||
|
if (reportSuccess) {
|
||||||
|
logger.info('每日健康数据报表同步成功');
|
||||||
|
} else {
|
||||||
|
logger.info('每日健康数据报表同步跳过(无变化)或失败');
|
||||||
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('同步 HealthKit 数据到服务端失败:', error);
|
logger.error('同步 HealthKit 数据到服务端失败:', error);
|
||||||
}
|
}
|
||||||
}, [isLoggedIn, dispatch, userProfile]);
|
}, [isLoggedIn, dispatch, userProfile, todayWaterStats]);
|
||||||
|
|
||||||
// 初始加载时执行数据加载和同步
|
// 初始加载时执行数据加载和同步
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadAllData(currentSelectedDate);
|
loadAllData(currentSelectedDate);
|
||||||
|
|
||||||
// 延迟1秒后执行同步,避免影响初始加载性能
|
// 延迟1秒后执行同步,避免影响初始加载性能
|
||||||
|
// 如果 todayWaterStats 还未加载完成,可能会导致第一次同步时 waterIntake 为 undefined
|
||||||
|
// 但 waterSlice.fetchTodayWaterStats 会在 loadAllData 中被调用
|
||||||
const syncTimer = setTimeout(() => {
|
const syncTimer = setTimeout(() => {
|
||||||
syncHealthDataToServer();
|
syncHealthDataToServer();
|
||||||
}, 1000);
|
}, 1000);
|
||||||
@@ -382,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}
|
||||||
@@ -404,6 +469,26 @@ export default function ExploreScreen() {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.headerActions}>
|
<View style={styles.headerActions}>
|
||||||
|
<TouchableOpacity
|
||||||
|
activeOpacity={0.85}
|
||||||
|
onPress={handleOpenCustomization}
|
||||||
|
>
|
||||||
|
{isLiquidGlassAvailable() ? (
|
||||||
|
<GlassView
|
||||||
|
style={styles.liquidGlassButton}
|
||||||
|
glassEffectStyle="regular"
|
||||||
|
tintColor="rgba(255, 255, 255, 0.3)"
|
||||||
|
isInteractive={true}
|
||||||
|
>
|
||||||
|
<Ionicons name="options-outline" size={20} color="#0F172A" />
|
||||||
|
</GlassView>
|
||||||
|
) : (
|
||||||
|
<View style={[styles.liquidGlassButton, styles.liquidGlassFallback]}>
|
||||||
|
<Ionicons name="options-outline" size={20} color="#0F172A" />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
activeOpacity={0.85}
|
activeOpacity={0.85}
|
||||||
onPress={handleOpenGallery}
|
onPress={handleOpenGallery}
|
||||||
@@ -457,90 +542,192 @@ export default function ExploreScreen() {
|
|||||||
<Text style={styles.sectionTitle}>{t('statistics.sections.bodyMetrics')}</Text>
|
<Text style={styles.sectionTitle}>{t('statistics.sections.bodyMetrics')}</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* 真正瀑布流布局 */}
|
{/* 动态布局:支持混合瀑布流和全宽卡片 */}
|
||||||
<View style={styles.masonryContainer}>
|
<View style={styles.layoutContainer}>
|
||||||
{/* 左列 */}
|
{(() => {
|
||||||
<View style={styles.masonryColumn}>
|
// 定义所有卡片及其显示状态
|
||||||
{/* 心情卡片 */}
|
const allCardsMap: Record<string, any> = {
|
||||||
<FloatingCard style={styles.masonryCard}>
|
mood: {
|
||||||
<MoodCard
|
visible: cardVisibility.showMood,
|
||||||
moodCheckin={currentMoodCheckin}
|
component: (
|
||||||
onPress={() => pushIfAuthedElseLogin('/mood/calendar')}
|
<MoodCard
|
||||||
isLoading={isMoodLoading}
|
moodCheckin={currentMoodCheckin}
|
||||||
/>
|
onPress={() => pushIfAuthedElseLogin('/mood/calendar')}
|
||||||
</FloatingCard>
|
isLoading={isMoodLoading}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
steps: {
|
||||||
|
visible: cardVisibility.showSteps,
|
||||||
|
component: (
|
||||||
|
<StepsCard
|
||||||
|
curDate={currentSelectedDate}
|
||||||
|
stepGoal={stepGoal}
|
||||||
|
style={styles.stepsCardOverride}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
stress: {
|
||||||
|
visible: cardVisibility.showStress,
|
||||||
|
component: (
|
||||||
|
<StressMeter
|
||||||
|
curDate={currentSelectedDate}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
sleep: {
|
||||||
|
visible: cardVisibility.showSleep,
|
||||||
|
component: (
|
||||||
|
<SleepCard
|
||||||
|
selectedDate={currentSelectedDate}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
sunlight: {
|
||||||
|
visible: cardVisibility.showSunlight,
|
||||||
|
component: (
|
||||||
|
<SunlightCard
|
||||||
|
selectedDate={currentSelectedDate}
|
||||||
|
style={styles.basalMetabolismCardOverride}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
fitness: {
|
||||||
|
visible: cardVisibility.showFitnessRings,
|
||||||
|
component: (
|
||||||
|
<FitnessRingsCard
|
||||||
|
selectedDate={currentSelectedDate}
|
||||||
|
resetToken={animToken}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
water: {
|
||||||
|
visible: cardVisibility.showWater,
|
||||||
|
component: (
|
||||||
|
<WaterIntakeCard
|
||||||
|
selectedDate={currentSelectedDateString}
|
||||||
|
style={styles.waterCardOverride}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
metabolism: {
|
||||||
|
visible: cardVisibility.showBasalMetabolism,
|
||||||
|
component: (
|
||||||
|
<BasalMetabolismCard
|
||||||
|
selectedDate={currentSelectedDate}
|
||||||
|
style={styles.basalMetabolismCardOverride}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
oxygen: {
|
||||||
|
visible: cardVisibility.showOxygenSaturation,
|
||||||
|
component: (
|
||||||
|
<OxygenSaturationCard
|
||||||
|
selectedDate={currentSelectedDate}
|
||||||
|
style={styles.basalMetabolismCardOverride}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
temperature: {
|
||||||
|
visible: cardVisibility.showWristTemperature,
|
||||||
|
component: (
|
||||||
|
<WristTemperatureCard
|
||||||
|
selectedDate={currentSelectedDate}
|
||||||
|
style={styles.basalMetabolismCardOverride}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
menstrual: {
|
||||||
|
visible: cardVisibility.showMenstrualCycle,
|
||||||
|
component: (
|
||||||
|
<MenstrualCycleCard
|
||||||
|
onPress={() => pushIfAuthedElseLogin('/menstrual-cycle')}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
weight: {
|
||||||
|
visible: cardVisibility.showWeight,
|
||||||
|
isFullWidth: true,
|
||||||
|
component: (
|
||||||
|
<WeightHistoryCard />
|
||||||
|
)
|
||||||
|
},
|
||||||
|
circumference: {
|
||||||
|
visible: cardVisibility.showCircumference,
|
||||||
|
isFullWidth: true,
|
||||||
|
component: (
|
||||||
|
<CircumferenceCard style={{ marginBottom: 0, marginTop: 16 }} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
<FloatingCard style={styles.masonryCard}>
|
const allKeys = Object.keys(allCardsMap);
|
||||||
<StepsCard
|
const sortedKeys = Array.from(new Set([...cardOrder, ...allKeys]))
|
||||||
curDate={currentSelectedDate}
|
.filter(key => allCardsMap[key]);
|
||||||
stepGoal={stepGoal}
|
|
||||||
style={styles.stepsCardOverride}
|
|
||||||
/>
|
|
||||||
</FloatingCard>
|
|
||||||
|
|
||||||
|
const visibleCards = sortedKeys
|
||||||
|
.map(key => ({ id: key, ...allCardsMap[key] }))
|
||||||
|
.filter(card => card.visible);
|
||||||
|
|
||||||
<FloatingCard style={styles.masonryCard}>
|
// 分组逻辑:将连续的瀑布流卡片聚合,全宽卡片单独作为一组
|
||||||
<StressMeter
|
const blocks: any[] = [];
|
||||||
curDate={currentSelectedDate}
|
let currentMasonryBlock: any[] = [];
|
||||||
/>
|
|
||||||
</FloatingCard>
|
|
||||||
|
|
||||||
{/* 心率卡片 */}
|
visibleCards.forEach(card => {
|
||||||
{/* <FloatingCard style={styles.masonryCard} delay={2000}>
|
if (card.isFullWidth) {
|
||||||
<HeartRateCard
|
// 如果有未处理的瀑布流卡片,先结算
|
||||||
resetToken={animToken}
|
if (currentMasonryBlock.length > 0) {
|
||||||
style={styles.basalMetabolismCardOverride}
|
blocks.push({ type: 'masonry', items: [...currentMasonryBlock] });
|
||||||
heartRate={heartRate}
|
currentMasonryBlock = [];
|
||||||
/>
|
}
|
||||||
</FloatingCard> */}
|
// 添加全宽卡片
|
||||||
|
blocks.push({ type: 'full', item: card });
|
||||||
|
} else {
|
||||||
|
// 添加到当前瀑布流组
|
||||||
|
currentMasonryBlock.push(card);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
<FloatingCard style={styles.masonryCard}>
|
// 结算剩余的瀑布流卡片
|
||||||
<SleepCard
|
if (currentMasonryBlock.length > 0) {
|
||||||
selectedDate={currentSelectedDate}
|
blocks.push({ type: 'masonry', items: [...currentMasonryBlock] });
|
||||||
/>
|
}
|
||||||
</FloatingCard>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 右列 */}
|
return blocks.map((block, blockIndex) => {
|
||||||
<View style={styles.masonryColumn}>
|
if (block.type === 'full') {
|
||||||
<FloatingCard style={styles.masonryCard}>
|
return (
|
||||||
<FitnessRingsCard
|
<View key={`block-${blockIndex}-${block.item.id}`}>
|
||||||
selectedDate={currentSelectedDate}
|
{block.item.component}
|
||||||
resetToken={animToken}
|
</View>
|
||||||
/>
|
);
|
||||||
</FloatingCard>
|
} else {
|
||||||
{/* 饮水记录卡片 */}
|
// 渲染瀑布流块
|
||||||
<FloatingCard style={styles.masonryCard}>
|
const leftColumnCards = block.items.filter((_: any, index: number) => index % 2 === 0);
|
||||||
<WaterIntakeCard
|
const rightColumnCards = block.items.filter((_: any, index: number) => index % 2 !== 0);
|
||||||
selectedDate={currentSelectedDateString}
|
|
||||||
style={styles.waterCardOverride}
|
|
||||||
/>
|
|
||||||
</FloatingCard>
|
|
||||||
|
|
||||||
|
return (
|
||||||
{/* 基础代谢卡片 */}
|
<View key={`block-${blockIndex}-masonry`} style={styles.masonryContainer}>
|
||||||
<FloatingCard style={styles.masonryCard}>
|
<View style={styles.masonryColumn}>
|
||||||
<BasalMetabolismCard
|
{leftColumnCards.map((card: any) => (
|
||||||
selectedDate={currentSelectedDate}
|
<FloatingCard key={card.id} style={styles.masonryCard}>
|
||||||
style={styles.basalMetabolismCardOverride}
|
{card.component}
|
||||||
/>
|
</FloatingCard>
|
||||||
</FloatingCard>
|
))}
|
||||||
|
</View>
|
||||||
{/* 血氧饱和度卡片 */}
|
<View style={styles.masonryColumn}>
|
||||||
<FloatingCard style={styles.masonryCard}>
|
{rightColumnCards.map((card: any) => (
|
||||||
<OxygenSaturationCard
|
<FloatingCard key={card.id} style={styles.masonryCard}>
|
||||||
selectedDate={currentSelectedDate}
|
{card.component}
|
||||||
style={styles.basalMetabolismCardOverride}
|
</FloatingCard>
|
||||||
/>
|
))}
|
||||||
</FloatingCard>
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
</View>
|
}
|
||||||
|
});
|
||||||
|
})()}
|
||||||
</View>
|
</View>
|
||||||
<WeightHistoryCard />
|
|
||||||
|
|
||||||
{/* 围度数据卡片 - 占满底部一行 */}
|
|
||||||
<CircumferenceCard style={styles.circumferenceCard} />
|
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
</View>
|
</View>
|
||||||
@@ -634,9 +821,6 @@ const styles = StyleSheet.create({
|
|||||||
shadowRadius: 4,
|
shadowRadius: 4,
|
||||||
elevation: 3,
|
elevation: 3,
|
||||||
},
|
},
|
||||||
hrvTestButton: {
|
|
||||||
backgroundColor: '#8B5CF6',
|
|
||||||
},
|
|
||||||
debugButtonText: {
|
debugButtonText: {
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontFamily: 'AliRegular',
|
fontFamily: 'AliRegular',
|
||||||
@@ -852,6 +1036,9 @@ const styles = StyleSheet.create({
|
|||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
marginBottom: 16,
|
marginBottom: 16,
|
||||||
},
|
},
|
||||||
|
layoutContainer: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
masonryContainer: {
|
masonryContainer: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
gap: 16,
|
gap: 16,
|
||||||
|
|||||||
@@ -1,14 +1,7 @@
|
|||||||
import '@/i18n';
|
|
||||||
import { DefaultTheme, ThemeProvider } from '@react-navigation/native';
|
|
||||||
import { useFonts } from 'expo-font';
|
|
||||||
import { Stack, useRouter } from 'expo-router';
|
|
||||||
import { StatusBar } from 'expo-status-bar';
|
|
||||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
|
||||||
import 'react-native-reanimated';
|
|
||||||
|
|
||||||
import PrivacyConsentModal from '@/components/PrivacyConsentModal';
|
import PrivacyConsentModal from '@/components/PrivacyConsentModal';
|
||||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||||
import { useQuickActions } from '@/hooks/useQuickActions';
|
import { useQuickActions } from '@/hooks/useQuickActions';
|
||||||
|
import '@/i18n';
|
||||||
import { hrvMonitorService } from '@/services/hrvMonitor';
|
import { hrvMonitorService } from '@/services/hrvMonitor';
|
||||||
import { cleanupLegacyMedicationNotifications } from '@/services/medicationNotificationCleanup';
|
import { cleanupLegacyMedicationNotifications } from '@/services/medicationNotificationCleanup';
|
||||||
import { clearBadgeCount, notificationService } from '@/services/notifications';
|
import { clearBadgeCount, notificationService } from '@/services/notifications';
|
||||||
@@ -26,8 +19,14 @@ import { initializeHealthPermissions } from '@/utils/health';
|
|||||||
import { MoodNotificationHelpers, NutritionNotificationHelpers, WaterNotificationHelpers } from '@/utils/notificationHelpers';
|
import { MoodNotificationHelpers, NutritionNotificationHelpers, WaterNotificationHelpers } from '@/utils/notificationHelpers';
|
||||||
import { getMoodReminderEnabled, getNutritionReminderEnabled, getWaterReminderSettings } from '@/utils/userPreferences';
|
import { getMoodReminderEnabled, getNutritionReminderEnabled, getWaterReminderSettings } from '@/utils/userPreferences';
|
||||||
import { clearPendingWaterRecords, syncPendingWidgetChanges } from '@/utils/widgetDataSync';
|
import { clearPendingWaterRecords, syncPendingWidgetChanges } from '@/utils/widgetDataSync';
|
||||||
|
import { DefaultTheme, ThemeProvider } from '@react-navigation/native';
|
||||||
|
import { useFonts } from 'expo-font';
|
||||||
|
import { Stack, useRouter } from 'expo-router';
|
||||||
|
import { StatusBar } from 'expo-status-bar';
|
||||||
import React, { useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import { AppState, AppStateStatus } from 'react-native';
|
import { AppState, AppStateStatus } from 'react-native';
|
||||||
|
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||||
|
import 'react-native-reanimated';
|
||||||
|
|
||||||
import { DialogProvider } from '@/components/ui/DialogProvider';
|
import { DialogProvider } from '@/components/ui/DialogProvider';
|
||||||
import { MembershipModalProvider } from '@/contexts/MembershipModalContext';
|
import { MembershipModalProvider } from '@/contexts/MembershipModalContext';
|
||||||
@@ -485,6 +484,51 @@ function Bootstrapper({ children }: { children: React.ReactNode }) {
|
|||||||
getPrivacyAgreed();
|
getPrivacyAgreed();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// 使用 ref 确保更新检查只执行一次
|
||||||
|
// const updateCheckRequestedRef = React.useRef(false);
|
||||||
|
|
||||||
|
// useEffect(() => {
|
||||||
|
// // 如果已经执行过更新检查,直接返回
|
||||||
|
// if (updateCheckRequestedRef.current) {
|
||||||
|
// return;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// updateCheckRequestedRef.current = true;
|
||||||
|
|
||||||
|
// async function checkUpdate() {
|
||||||
|
// try {
|
||||||
|
// logger.info(`Checking for updates..., env: ${__DEV__}, Updates.isEnabled: ${Updates.isEnabled}`);
|
||||||
|
|
||||||
|
// // 只有在 expo-updates 启用时才检查更新
|
||||||
|
// // 开发环境或 Updates 未启用时跳过
|
||||||
|
// if (__DEV__ || !Updates.isEnabled) {
|
||||||
|
// logger.info('Skipping update check: dev mode or updates not enabled');
|
||||||
|
// return;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// const update = await Updates.checkForUpdateAsync();
|
||||||
|
// logger.info("Update check:", update);
|
||||||
|
|
||||||
|
// if (update.isAvailable) {
|
||||||
|
// logger.info("Update available, fetching...");
|
||||||
|
// const result = await Updates.fetchUpdateAsync();
|
||||||
|
// logger.info("Fetch result:", result);
|
||||||
|
|
||||||
|
// if (result.isNew) {
|
||||||
|
// logger.info("Reloading app to apply update...");
|
||||||
|
// await Updates.reloadAsync();
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// } catch (e) {
|
||||||
|
// logger.error("Update error:", e);
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// setTimeout(() => {
|
||||||
|
// checkUpdate();
|
||||||
|
// }, 5000);
|
||||||
|
// }, []);
|
||||||
|
|
||||||
const handlePrivacyAgree = () => {
|
const handlePrivacyAgree = () => {
|
||||||
dispatch(setPrivacyAgreed());
|
dispatch(setPrivacyAgreed());
|
||||||
setShowPrivacyModal(false);
|
setShowPrivacyModal(false);
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
620
app/health/family-invite.tsx
Normal file
620
app/health/family-invite.tsx
Normal file
@@ -0,0 +1,620 @@
|
|||||||
|
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||||
|
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||||
|
import {
|
||||||
|
fetchFamilyGroup,
|
||||||
|
generateInviteCode,
|
||||||
|
selectFamilyGroup,
|
||||||
|
selectFamilyHealthLoading,
|
||||||
|
selectInviteCode,
|
||||||
|
selectInviteLoading,
|
||||||
|
} from '@/store/familyHealthSlice';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
|
import { Stack } from 'expo-router';
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Alert,
|
||||||
|
Modal,
|
||||||
|
ScrollView,
|
||||||
|
Share,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TouchableOpacity,
|
||||||
|
View
|
||||||
|
} from 'react-native';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
|
export default function FamilyInviteScreen() {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const [agreed, setAgreed] = useState(true);
|
||||||
|
const [showQRModal, setShowQRModal] = useState(false);
|
||||||
|
|
||||||
|
// Redux state
|
||||||
|
const familyGroup = useAppSelector(selectFamilyGroup);
|
||||||
|
const inviteCode = useAppSelector(selectInviteCode);
|
||||||
|
const isLoading = useAppSelector(selectFamilyHealthLoading);
|
||||||
|
const isInviteLoading = useAppSelector(selectInviteLoading);
|
||||||
|
|
||||||
|
// 初始化时获取家庭组信息
|
||||||
|
useEffect(() => {
|
||||||
|
dispatch(fetchFamilyGroup());
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
// 处理邀请按钮点击
|
||||||
|
const handleInvite = async () => {
|
||||||
|
try {
|
||||||
|
// 生成邀请码
|
||||||
|
await dispatch(generateInviteCode(24)).unwrap();
|
||||||
|
|
||||||
|
// 显示二维码弹窗
|
||||||
|
setShowQRModal(true);
|
||||||
|
} catch (error: any) {
|
||||||
|
Alert.alert('邀请失败', error?.message || '请稍后重试');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 分享邀请码
|
||||||
|
const handleShare = async () => {
|
||||||
|
if (!inviteCode) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Share.share({
|
||||||
|
message: `邀请您加入我的家庭健康管理组!\n邀请码:${inviteCode.inviteCode}\n有效期至:${new Date(inviteCode.expiresAt).toLocaleString()}`,
|
||||||
|
title: '家庭健康管理邀请',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('分享失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<Stack.Screen options={{ headerShown: false }} />
|
||||||
|
<HeaderBar title="" transparent />
|
||||||
|
|
||||||
|
<LinearGradient
|
||||||
|
colors={['#Eef2FF', '#F5F3FF', '#FFFFFF']}
|
||||||
|
style={styles.background}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={[styles.scrollContent, { paddingTop: insets.top + 40 }]}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
{/* Header Title Area */}
|
||||||
|
<View style={styles.headerSection}>
|
||||||
|
<Text style={styles.mainTitle}>家庭健康管理</Text>
|
||||||
|
<Text style={styles.mainTitle}>保障全家健康</Text>
|
||||||
|
|
||||||
|
<View style={styles.subtitleBadge}>
|
||||||
|
<Ionicons name="home" size={12} color="#5B4CFF" />
|
||||||
|
<Text style={styles.subtitleText}>全家互相督促,让关爱不遗漏</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Hero Image / House Icon Area */}
|
||||||
|
<View style={styles.heroContainer}>
|
||||||
|
{/* Floating Labels */}
|
||||||
|
<View style={[styles.floatingLabel, styles.labelLeft]}>
|
||||||
|
<Text style={styles.floatingLabelText}>实时管理</Text>
|
||||||
|
<View style={styles.dot} />
|
||||||
|
</View>
|
||||||
|
<View style={[styles.floatingLabel, styles.labelRight]}>
|
||||||
|
<View style={styles.dot} />
|
||||||
|
<Text style={styles.floatingLabelText}>守护家庭健康</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Main 3D House Icon Placeholder */}
|
||||||
|
<View style={styles.houseIconPlaceholder}>
|
||||||
|
<LinearGradient
|
||||||
|
colors={['#A78BFA', '#5B4CFF']}
|
||||||
|
style={styles.houseIconGradient}
|
||||||
|
>
|
||||||
|
<Ionicons name="heart" size={60} color="#FFFFFF" />
|
||||||
|
</LinearGradient>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Features Grid */}
|
||||||
|
<View style={styles.featuresCard}>
|
||||||
|
<View style={styles.featureItem}>
|
||||||
|
<View style={[styles.featureIcon, { backgroundColor: '#EEF2FF' }]}>
|
||||||
|
<Ionicons name="share-social" size={24} color="#5B4CFF" />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.featureTitle}>数据共享</Text>
|
||||||
|
<Text style={styles.featureDesc}>家人档案共同维护</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.featureItem}>
|
||||||
|
<View style={[styles.featureIcon, { backgroundColor: '#FEF2F2' }]}>
|
||||||
|
<Ionicons name="alert-circle" size={24} color="#EF4444" />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.featureTitle}>异常提醒</Text>
|
||||||
|
<Text style={styles.featureDesc}>数据异常实时提醒</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.featureItem}>
|
||||||
|
<View style={[styles.featureIcon, { backgroundColor: '#FFF7ED' }]}>
|
||||||
|
<Ionicons name="medkit" size={24} color="#F97316" />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.featureTitle}>用药监督</Text>
|
||||||
|
<Text style={styles.featureDesc}>用药情况远程监督</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Steps Section */}
|
||||||
|
<View style={styles.stepsContainer}>
|
||||||
|
<Text style={styles.stepsTitle}>简单3步,帮家人管理档案</Text>
|
||||||
|
<View style={styles.stepsSubtitleContainer}>
|
||||||
|
<Text style={styles.stepsSubtitle}>最多邀请6人,分享二维码有效期24小时</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.stepsRow}>
|
||||||
|
<View style={styles.stepItem}>
|
||||||
|
<Text style={styles.stepNumber}>1</Text>
|
||||||
|
<Text style={styles.stepDesc}>分享二维码邀请</Text>
|
||||||
|
<View style={styles.stepPhoneMockup}>
|
||||||
|
<View style={styles.mockupScreen} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Ionicons name="chevron-forward" size={20} color="#D1D5DB" style={{ marginTop: 40 }} />
|
||||||
|
|
||||||
|
<View style={styles.stepItem}>
|
||||||
|
<Text style={styles.stepNumber}>2</Text>
|
||||||
|
<Text style={styles.stepDesc}>家人下载登录App</Text>
|
||||||
|
<View style={styles.stepPhoneMockup}>
|
||||||
|
<View style={styles.mockupScreen} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Ionicons name="chevron-forward" size={20} color="#D1D5DB" style={{ marginTop: 40 }} />
|
||||||
|
|
||||||
|
<View style={styles.stepItem}>
|
||||||
|
<Text style={styles.stepNumber}>3</Text>
|
||||||
|
<Text style={styles.stepDesc}>扫二维码加入</Text>
|
||||||
|
<View style={styles.stepPhoneMockup}>
|
||||||
|
<View style={styles.mockupScreen} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Bottom Spacing */}
|
||||||
|
<View style={{ height: 120 }} />
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
{/* Bottom Action Area */}
|
||||||
|
<View style={[styles.bottomArea, { paddingBottom: insets.bottom + 16 }]}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.checkboxRow}
|
||||||
|
onPress={() => setAgreed(!agreed)}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name={agreed ? "checkmark-circle" : "ellipse-outline"}
|
||||||
|
size={20}
|
||||||
|
color={agreed ? "#5B4CFF" : "#9CA3AF"}
|
||||||
|
/>
|
||||||
|
<Text style={styles.checkboxText}>
|
||||||
|
申请对方同意我查看并管理其健康档案,有数据异常预警我
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.inviteButton, (!agreed || isLoading) && styles.inviteButtonDisabled]}
|
||||||
|
disabled={!agreed || isLoading}
|
||||||
|
onPress={handleInvite}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<ActivityIndicator size="small" color="#FFFFFF" />
|
||||||
|
) : (
|
||||||
|
<Text style={styles.inviteButtonText}>立即邀请</Text>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* QR Code Modal */}
|
||||||
|
<Modal
|
||||||
|
visible={showQRModal}
|
||||||
|
transparent
|
||||||
|
animationType="fade"
|
||||||
|
onRequestClose={() => setShowQRModal(false)}
|
||||||
|
>
|
||||||
|
<View style={styles.modalOverlay}>
|
||||||
|
<View style={styles.modalContent}>
|
||||||
|
<View style={styles.modalHeader}>
|
||||||
|
<Text style={styles.modalTitle}>邀请家人加入</Text>
|
||||||
|
<TouchableOpacity onPress={() => setShowQRModal(false)}>
|
||||||
|
<Ionicons name="close" size={24} color="#6B7280" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{isInviteLoading ? (
|
||||||
|
<View style={styles.qrContainer}>
|
||||||
|
<ActivityIndicator size="large" color="#5B4CFF" />
|
||||||
|
</View>
|
||||||
|
) : inviteCode ? (
|
||||||
|
<>
|
||||||
|
<View style={styles.qrContainer}>
|
||||||
|
{/* 邀请码大字展示(替代二维码,后续可安装 react-native-qrcode-svg 实现) */}
|
||||||
|
<View style={styles.inviteCodeDisplay}>
|
||||||
|
<Ionicons name="qr-code-outline" size={48} color="#5B4CFF" />
|
||||||
|
<Text style={styles.inviteCodeBig}>{inviteCode.inviteCode}</Text>
|
||||||
|
<Text style={styles.inviteCodeHint}>请让家人在 App 中输入此邀请码</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.inviteCodeContainer}>
|
||||||
|
<Text style={styles.inviteCodeLabel}>邀请码</Text>
|
||||||
|
<Text style={styles.inviteCodeText}>{inviteCode.inviteCode}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={styles.expireText}>
|
||||||
|
有效期至:{new Date(inviteCode.expiresAt).toLocaleString()}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<TouchableOpacity style={styles.shareButton} onPress={handleShare}>
|
||||||
|
<Ionicons name="share-outline" size={20} color="#FFFFFF" />
|
||||||
|
<Text style={styles.shareButtonText}>分享邀请</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#F9FAFB',
|
||||||
|
},
|
||||||
|
background: {
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
},
|
||||||
|
scrollContent: {
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
},
|
||||||
|
headerSection: {
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 30,
|
||||||
|
},
|
||||||
|
mainTitle: {
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#1F2937',
|
||||||
|
lineHeight: 36,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
subtitleBadge: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.6)',
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 6,
|
||||||
|
borderRadius: 16,
|
||||||
|
marginTop: 16,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#FFFFFF',
|
||||||
|
},
|
||||||
|
subtitleText: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#5B4CFF',
|
||||||
|
marginLeft: 6,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
heroContainer: {
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
height: 180,
|
||||||
|
marginBottom: 20,
|
||||||
|
position: 'relative',
|
||||||
|
},
|
||||||
|
houseIconPlaceholder: {
|
||||||
|
width: 140,
|
||||||
|
height: 140,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
houseIconGradient: {
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
borderRadius: 30,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
transform: [{ rotate: '45deg' }],
|
||||||
|
shadowColor: '#5B4CFF',
|
||||||
|
shadowOffset: { width: 0, height: 10 },
|
||||||
|
shadowOpacity: 0.3,
|
||||||
|
shadowRadius: 20,
|
||||||
|
elevation: 10,
|
||||||
|
},
|
||||||
|
floatingLabel: {
|
||||||
|
position: 'absolute',
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.8)',
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 6,
|
||||||
|
borderRadius: 12,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.05,
|
||||||
|
shadowRadius: 4,
|
||||||
|
elevation: 2,
|
||||||
|
},
|
||||||
|
labelLeft: {
|
||||||
|
left: 0,
|
||||||
|
top: 40,
|
||||||
|
},
|
||||||
|
labelRight: {
|
||||||
|
right: 0,
|
||||||
|
top: 20,
|
||||||
|
},
|
||||||
|
floatingLabelText: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#6B7280',
|
||||||
|
fontWeight: '600',
|
||||||
|
marginHorizontal: 4,
|
||||||
|
},
|
||||||
|
dot: {
|
||||||
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
borderRadius: 3,
|
||||||
|
backgroundColor: '#5B4CFF',
|
||||||
|
},
|
||||||
|
featuresCard: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderRadius: 20,
|
||||||
|
padding: 20,
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginBottom: 24,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowOpacity: 0.03,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 2,
|
||||||
|
},
|
||||||
|
featureItem: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
featureIcon: {
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
borderRadius: 24,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
featureTitle: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#1F2937',
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
featureDesc: {
|
||||||
|
fontSize: 10,
|
||||||
|
color: '#9CA3AF',
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
stepsContainer: {
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.6)',
|
||||||
|
borderRadius: 24,
|
||||||
|
padding: 20,
|
||||||
|
paddingBottom: 30,
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
stepsTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#1F2937',
|
||||||
|
textAlign: 'center',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
stepsSubtitleContainer: {
|
||||||
|
backgroundColor: '#F3F4F6',
|
||||||
|
alignSelf: 'center',
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 4,
|
||||||
|
borderRadius: 10,
|
||||||
|
marginBottom: 24,
|
||||||
|
},
|
||||||
|
stepsSubtitle: {
|
||||||
|
fontSize: 11,
|
||||||
|
color: '#6B7280',
|
||||||
|
},
|
||||||
|
stepsRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
},
|
||||||
|
stepItem: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
stepNumber: {
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#5B4CFF',
|
||||||
|
marginBottom: 8,
|
||||||
|
fontStyle: 'italic',
|
||||||
|
},
|
||||||
|
stepDesc: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#4B5563',
|
||||||
|
textAlign: 'center',
|
||||||
|
marginBottom: 12,
|
||||||
|
height: 32,
|
||||||
|
},
|
||||||
|
stepPhoneMockup: {
|
||||||
|
width: 60,
|
||||||
|
height: 100,
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderRadius: 10,
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: '#E5E7EB',
|
||||||
|
padding: 4,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowOpacity: 0.05,
|
||||||
|
shadowRadius: 4,
|
||||||
|
elevation: 2,
|
||||||
|
},
|
||||||
|
mockupScreen: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#F3F4F6',
|
||||||
|
borderRadius: 6,
|
||||||
|
},
|
||||||
|
bottomArea: {
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
paddingTop: 16,
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
borderTopLeftRadius: 24,
|
||||||
|
borderTopRightRadius: 24,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: -4 },
|
||||||
|
shadowOpacity: 0.05,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 10,
|
||||||
|
},
|
||||||
|
checkboxRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
marginBottom: 16,
|
||||||
|
paddingHorizontal: 4,
|
||||||
|
},
|
||||||
|
checkboxText: {
|
||||||
|
flex: 1,
|
||||||
|
marginLeft: 8,
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#6B7280',
|
||||||
|
lineHeight: 18,
|
||||||
|
},
|
||||||
|
inviteButton: {
|
||||||
|
backgroundColor: '#5B4CFF',
|
||||||
|
borderRadius: 28,
|
||||||
|
height: 56,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
shadowColor: '#5B4CFF',
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowOpacity: 0.3,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 4,
|
||||||
|
},
|
||||||
|
inviteButtonDisabled: {
|
||||||
|
backgroundColor: '#C4B5FD',
|
||||||
|
shadowOpacity: 0,
|
||||||
|
},
|
||||||
|
inviteButtonText: {
|
||||||
|
color: '#FFFFFF',
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
},
|
||||||
|
// Modal styles
|
||||||
|
modalOverlay: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: 20,
|
||||||
|
},
|
||||||
|
modalContent: {
|
||||||
|
width: '100%',
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderRadius: 24,
|
||||||
|
padding: 24,
|
||||||
|
},
|
||||||
|
modalHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 24,
|
||||||
|
},
|
||||||
|
modalTitle: {
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#1F2937',
|
||||||
|
},
|
||||||
|
qrContainer: {
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: 20,
|
||||||
|
backgroundColor: '#F9FAFB',
|
||||||
|
borderRadius: 16,
|
||||||
|
marginBottom: 20,
|
||||||
|
minHeight: 180,
|
||||||
|
},
|
||||||
|
inviteCodeDisplay: {
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
inviteCodeBig: {
|
||||||
|
fontSize: 36,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#5B4CFF',
|
||||||
|
letterSpacing: 4,
|
||||||
|
marginTop: 16,
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
inviteCodeHint: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#9CA3AF',
|
||||||
|
},
|
||||||
|
inviteCodeContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: '#F3F4F6',
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 12,
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
inviteCodeLabel: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#6B7280',
|
||||||
|
marginRight: 8,
|
||||||
|
},
|
||||||
|
inviteCodeText: {
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#5B4CFF',
|
||||||
|
letterSpacing: 2,
|
||||||
|
},
|
||||||
|
expireText: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#9CA3AF',
|
||||||
|
textAlign: 'center',
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
shareButton: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: '#5B4CFF',
|
||||||
|
borderRadius: 16,
|
||||||
|
paddingVertical: 14,
|
||||||
|
},
|
||||||
|
shareButtonText: {
|
||||||
|
color: '#FFFFFF',
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
marginLeft: 8,
|
||||||
|
},
|
||||||
|
});
|
||||||
617
app/health/profile.tsx
Normal file
617
app/health/profile.tsx
Normal file
@@ -0,0 +1,617 @@
|
|||||||
|
import { HealthProgressRing } from '@/components/health/HealthProgressRing';
|
||||||
|
import { BasicInfoTab } from '@/components/health/tabs/BasicInfoTab';
|
||||||
|
import { CheckupRecordsTab } from '@/components/health/tabs/CheckupRecordsTab';
|
||||||
|
import { HealthHistoryTab } from '@/components/health/tabs/HealthHistoryTab';
|
||||||
|
import { MedicalRecordsTab } from '@/components/health/tabs/MedicalRecordsTab';
|
||||||
|
import { ConfirmationSheet } from '@/components/ui/ConfirmationSheet';
|
||||||
|
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||||
|
import { Image } from '@/components/ui/Image';
|
||||||
|
import { Colors } from '@/constants/Colors';
|
||||||
|
import { ROUTES } from '@/constants/Routes';
|
||||||
|
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||||
|
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||||
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
|
import { useI18n } from '@/hooks/useI18n';
|
||||||
|
import {
|
||||||
|
fetchFamilyGroup,
|
||||||
|
joinFamilyGroup,
|
||||||
|
selectFamilyGroup,
|
||||||
|
} from '@/store/familyHealthSlice';
|
||||||
|
import {
|
||||||
|
fetchHealthHistory,
|
||||||
|
selectHealthHistoryProgress
|
||||||
|
} from '@/store/healthSlice';
|
||||||
|
import { DEFAULT_MEMBER_NAME } from '@/store/userSlice';
|
||||||
|
import { Toast } from '@/utils/toast.utils';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||||
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
|
import { Stack, useRouter } from 'expo-router';
|
||||||
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Pressable, ScrollView, StyleSheet, Text, TextInput, TouchableOpacity, View } from 'react-native';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
|
export default function HealthProfileScreen() {
|
||||||
|
const router = useRouter();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||||
|
const colorTokens = Colors[theme];
|
||||||
|
const { t } = useI18n();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const { ensureLoggedIn } = useAuthGuard();
|
||||||
|
const glassAvailable = isLiquidGlassAvailable();
|
||||||
|
|
||||||
|
const [activeTab, setActiveTab] = useState(0);
|
||||||
|
const [joinModalVisible, setJoinModalVisible] = useState(false);
|
||||||
|
const [inviteCodeInput, setInviteCodeInput] = useState('');
|
||||||
|
const [selectedRelationship, setSelectedRelationship] = useState('');
|
||||||
|
const [isJoining, setIsJoining] = useState(false);
|
||||||
|
const [joinError, setJoinError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Redux state
|
||||||
|
const familyGroup = useAppSelector(selectFamilyGroup);
|
||||||
|
const medicalRecords = useAppSelector((state) => state.health.medicalRecords);
|
||||||
|
const records = medicalRecords?.records || [];
|
||||||
|
const prescriptions = medicalRecords?.prescriptions || [];
|
||||||
|
|
||||||
|
// Calculate Medical Records Count
|
||||||
|
const medicalRecordsCount = useMemo(() => records.length + prescriptions.length, [records, prescriptions]);
|
||||||
|
|
||||||
|
// 亲属关系选项
|
||||||
|
const relationshipOptions = useMemo(() => [
|
||||||
|
{ key: 'spouse', label: t('familyGroup.relationships.spouse') },
|
||||||
|
{ key: 'father', label: t('familyGroup.relationships.father') },
|
||||||
|
{ key: 'mother', label: t('familyGroup.relationships.mother') },
|
||||||
|
{ key: 'son', label: t('familyGroup.relationships.son') },
|
||||||
|
{ key: 'daughter', label: t('familyGroup.relationships.daughter') },
|
||||||
|
{ key: 'grandfather', label: t('familyGroup.relationships.grandfather') },
|
||||||
|
{ key: 'grandmother', label: t('familyGroup.relationships.grandmother') },
|
||||||
|
{ key: 'grandson', label: t('familyGroup.relationships.grandson') },
|
||||||
|
{ key: 'granddaughter', label: t('familyGroup.relationships.granddaughter') },
|
||||||
|
{ key: 'brother', label: t('familyGroup.relationships.brother') },
|
||||||
|
{ key: 'sister', label: t('familyGroup.relationships.sister') },
|
||||||
|
{ key: 'uncle', label: t('familyGroup.relationships.uncle') },
|
||||||
|
{ key: 'aunt', label: t('familyGroup.relationships.aunt') },
|
||||||
|
{ key: 'nephew', label: t('familyGroup.relationships.nephew') },
|
||||||
|
{ key: 'niece', label: t('familyGroup.relationships.niece') },
|
||||||
|
{ key: 'cousin', label: t('familyGroup.relationships.cousin') },
|
||||||
|
{ key: 'other', label: t('familyGroup.relationships.other') },
|
||||||
|
], [t]);
|
||||||
|
|
||||||
|
// Mock user data - in a real app this would come from Redux/Context
|
||||||
|
const userProfile = useAppSelector((state) => state.user.profile);
|
||||||
|
const displayName = userProfile.name?.trim() ? userProfile.name : DEFAULT_MEMBER_NAME;
|
||||||
|
const avatarUrl = userProfile.avatar || 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/seal-avatar/2.jpeg';
|
||||||
|
|
||||||
|
// 从 Redux 获取健康史进度
|
||||||
|
const healthHistoryProgress = useAppSelector(selectHealthHistoryProgress);
|
||||||
|
|
||||||
|
// Mock health data
|
||||||
|
const healthData = {
|
||||||
|
bmi: userProfile.weight && userProfile.height ? (parseFloat(userProfile.weight) / Math.pow(parseFloat(userProfile.height) / 100, 2)).toFixed(1) : '--',
|
||||||
|
height: userProfile.height ? `${parseFloat(userProfile.height).toFixed(1)}` : '--',
|
||||||
|
weight: userProfile.weight ? `${parseFloat(userProfile.weight).toFixed(1)}` : '--',
|
||||||
|
waist: userProfile.waistCircumference ? `${parseFloat(userProfile.waistCircumference.toString()).toFixed(1)}` : '--',
|
||||||
|
status: '健康状况良好',
|
||||||
|
statusDesc: '请继续保持良好的生活习惯',
|
||||||
|
statusMessage: '您的健康状况不错哦~'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate Basic Info completion percentage
|
||||||
|
const basicInfoProgress = useMemo(() => {
|
||||||
|
let filledCount = 0;
|
||||||
|
const totalFields = 3; // height, weight, waist
|
||||||
|
|
||||||
|
if (userProfile.height && parseFloat(userProfile.height) > 0) filledCount++;
|
||||||
|
if (userProfile.weight && parseFloat(userProfile.weight) > 0) filledCount++;
|
||||||
|
if (userProfile.waistCircumference && parseFloat(userProfile.waistCircumference.toString()) > 0) filledCount++;
|
||||||
|
|
||||||
|
return Math.round((filledCount / totalFields) * 100);
|
||||||
|
}, [userProfile.height, userProfile.weight, userProfile.waistCircumference]);
|
||||||
|
|
||||||
|
// 初始化获取家庭组信息和健康史数据
|
||||||
|
useEffect(() => {
|
||||||
|
dispatch(fetchFamilyGroup());
|
||||||
|
dispatch(fetchHealthHistory());
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
// 重置弹窗状态
|
||||||
|
useEffect(() => {
|
||||||
|
if (!joinModalVisible) {
|
||||||
|
setInviteCodeInput('');
|
||||||
|
setSelectedRelationship('');
|
||||||
|
setJoinError(null);
|
||||||
|
}
|
||||||
|
}, [joinModalVisible]);
|
||||||
|
|
||||||
|
// 打开加入弹窗
|
||||||
|
const handleOpenJoin = useCallback(async () => {
|
||||||
|
const ok = await ensureLoggedIn();
|
||||||
|
if (!ok) return;
|
||||||
|
setJoinModalVisible(true);
|
||||||
|
}, [ensureLoggedIn]);
|
||||||
|
|
||||||
|
// 提交加入家庭组
|
||||||
|
const handleSubmitJoin = useCallback(async () => {
|
||||||
|
if (isJoining) return;
|
||||||
|
const ok = await ensureLoggedIn();
|
||||||
|
if (!ok) return;
|
||||||
|
|
||||||
|
const code = inviteCodeInput.trim().toUpperCase();
|
||||||
|
|
||||||
|
if (!code) {
|
||||||
|
setJoinError(t('familyGroup.errors.emptyCode'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!selectedRelationship) {
|
||||||
|
setJoinError(t('familyGroup.errors.emptyRelationship'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取选中关系的显示文本
|
||||||
|
const relationshipLabel = relationshipOptions.find(r => r.key === selectedRelationship)?.label || selectedRelationship;
|
||||||
|
|
||||||
|
setIsJoining(true);
|
||||||
|
setJoinError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await dispatch(joinFamilyGroup({ inviteCode: code, relationship: relationshipLabel })).unwrap();
|
||||||
|
await dispatch(fetchFamilyGroup());
|
||||||
|
setJoinModalVisible(false);
|
||||||
|
Toast.success(t('familyGroup.success'));
|
||||||
|
} catch (error) {
|
||||||
|
const message = typeof error === 'string' ? error : '加入失败,请检查邀请码是否正确';
|
||||||
|
setJoinError(message);
|
||||||
|
} finally {
|
||||||
|
setIsJoining(false);
|
||||||
|
}
|
||||||
|
}, [dispatch, ensureLoggedIn, inviteCodeInput, isJoining, selectedRelationship, relationshipOptions, t]);
|
||||||
|
|
||||||
|
const gradientColors: [string, string] =
|
||||||
|
theme === 'dark'
|
||||||
|
? ['#1f2230', '#10131e']
|
||||||
|
: [colorTokens.backgroundGradientStart, colorTokens.backgroundGradientEnd];
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
t('health.tabs.healthProfile.basicInfo'),
|
||||||
|
t('health.tabs.healthProfile.healthHistory'),
|
||||||
|
// t('health.tabs.healthProfile.medicalRecords'),
|
||||||
|
t('health.tabs.healthProfile.checkupRecords'),
|
||||||
|
t('health.tabs.healthProfile.medicineBox')
|
||||||
|
];
|
||||||
|
const tabIcons = ["person", "time", "folder", "clipboard", "medkit"];
|
||||||
|
|
||||||
|
const handleTabPress = (index: number) => {
|
||||||
|
if (index === 3) {
|
||||||
|
// Handle Medicine Box tab specially
|
||||||
|
router.push('/medications/manage-medications');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setActiveTab(index);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderActiveTab = () => {
|
||||||
|
switch (activeTab) {
|
||||||
|
case 0:
|
||||||
|
return <BasicInfoTab healthData={healthData} />;
|
||||||
|
case 1:
|
||||||
|
return <HealthHistoryTab />;
|
||||||
|
case 2:
|
||||||
|
return <MedicalRecordsTab />;
|
||||||
|
case 3:
|
||||||
|
return <CheckupRecordsTab />;
|
||||||
|
default:
|
||||||
|
return <BasicInfoTab healthData={healthData} />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.container, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}>
|
||||||
|
<Stack.Screen options={{ headerShown: false }} />
|
||||||
|
<LinearGradient colors={gradientColors} style={StyleSheet.absoluteFillObject} />
|
||||||
|
|
||||||
|
<HeaderBar
|
||||||
|
title={t('health.tabs.healthProfile.title')}
|
||||||
|
transparent
|
||||||
|
right={
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
||||||
|
{/* 加入家庭组按钮 - 仅在未加入家庭组时显示 */}
|
||||||
|
{!familyGroup && (
|
||||||
|
<TouchableOpacity activeOpacity={0.85} onPress={handleOpenJoin} style={{ marginRight: 10 }}>
|
||||||
|
{glassAvailable ? (
|
||||||
|
<GlassView
|
||||||
|
style={styles.joinButtonGlass}
|
||||||
|
glassEffectStyle="regular"
|
||||||
|
tintColor="rgba(255,255,255,0.18)"
|
||||||
|
isInteractive
|
||||||
|
>
|
||||||
|
<Text style={styles.joinButtonLabel}>加入</Text>
|
||||||
|
</GlassView>
|
||||||
|
) : (
|
||||||
|
<View style={[styles.joinButtonGlass, styles.joinButtonFallback]}>
|
||||||
|
<Text style={[styles.joinButtonLabel, { color: colorTokens.text }]}>加入</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
<TouchableOpacity style={{ marginRight: 12 }}>
|
||||||
|
<Ionicons name="settings-outline" size={22} color="#1F2937" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={[styles.scrollContent, { paddingTop: insets.top + 60 }]}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
{/* Top Section with Avatar and Status */}
|
||||||
|
<View style={styles.topSection}>
|
||||||
|
<View style={styles.avatarRow}>
|
||||||
|
<View style={styles.miniAvatarContainer}>
|
||||||
|
<Image source={{ uri: avatarUrl }} style={styles.miniAvatar} />
|
||||||
|
<Text style={styles.miniAvatarName}>{displayName}</Text>
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.addButton}
|
||||||
|
onPress={() => router.push(ROUTES.HEALTH_FAMILY_INVITE)}
|
||||||
|
>
|
||||||
|
<Ionicons name="add" size={16} color="#6B7280" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Action Buttons - Replaced with HealthProgressRing */}
|
||||||
|
<View style={styles.actionButtonsRow}>
|
||||||
|
<HealthProgressRing
|
||||||
|
title={t('health.tabs.healthProfile.basicInfo')}
|
||||||
|
progress={basicInfoProgress}
|
||||||
|
gradientColors={['#9B8AFB', '#5B4CFF']}
|
||||||
|
/>
|
||||||
|
<HealthProgressRing
|
||||||
|
title={t('health.tabs.healthProfile.healthHistory')}
|
||||||
|
progress={healthHistoryProgress}
|
||||||
|
gradientColors={['#E0E7FF', '#C7D2FE']}
|
||||||
|
label={healthHistoryProgress.toString()}
|
||||||
|
suffix="%"
|
||||||
|
/>
|
||||||
|
<HealthProgressRing
|
||||||
|
title={t('health.tabs.healthProfile.medicalRecords')}
|
||||||
|
progress={0}
|
||||||
|
gradientColors={['#E0E7FF', '#C7D2FE']}
|
||||||
|
label={medicalRecordsCount.toString()}
|
||||||
|
suffix="份"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Family Invite Banner */}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.inviteBanner}
|
||||||
|
activeOpacity={0.9}
|
||||||
|
onPress={() => router.push(ROUTES.HEALTH_FAMILY_INVITE)}
|
||||||
|
>
|
||||||
|
<View style={styles.inviteContent}>
|
||||||
|
<View style={styles.inviteIconContainer}>
|
||||||
|
<Ionicons name="home" size={18} color="#5B4CFF" />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.inviteText}>{t('health.tabs.healthProfile.subtitle')}</Text>
|
||||||
|
<Ionicons name="chevron-forward" size={18} color="#6B7280" />
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{/* Tab/Segment Control */}
|
||||||
|
<View style={styles.segmentControl}>
|
||||||
|
{tabs.map((tab, index) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={index}
|
||||||
|
style={styles.segmentItem}
|
||||||
|
onPress={() => handleTabPress(index)}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<View style={[styles.segmentIconPlaceholder, index === activeTab && styles.segmentIconActive]}>
|
||||||
|
<Ionicons
|
||||||
|
name={tabIcons[index] as any}
|
||||||
|
size={20}
|
||||||
|
color={index === activeTab ? "#5B4CFF" : "#6B7280"}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Text style={[styles.segmentText, index === activeTab && styles.segmentTextActive]}>{tab}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Active Tab Content */}
|
||||||
|
{renderActiveTab()}
|
||||||
|
|
||||||
|
{/* Privacy Notice Footer */}
|
||||||
|
<View style={styles.privacyNoticeContainer}>
|
||||||
|
<View style={styles.privacyIconWrapper}>
|
||||||
|
<Ionicons name="shield-checkmark" size={16} color="#9CA3AF" />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.privacyNoticeText}>
|
||||||
|
{t('health.tabs.healthProfile.privacyNotice')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
{/* 加入家庭组弹窗 */}
|
||||||
|
<ConfirmationSheet
|
||||||
|
visible={joinModalVisible}
|
||||||
|
onClose={() => setJoinModalVisible(false)}
|
||||||
|
onConfirm={handleSubmitJoin}
|
||||||
|
title={t('familyGroup.joinTitle')}
|
||||||
|
description={t('familyGroup.joinDescription')}
|
||||||
|
confirmText={isJoining ? t('familyGroup.joining') : t('familyGroup.joinButton')}
|
||||||
|
cancelText={t('familyGroup.cancel')}
|
||||||
|
loading={isJoining}
|
||||||
|
content={
|
||||||
|
<View style={styles.joinModalContent}>
|
||||||
|
{/* 邀请码输入 */}
|
||||||
|
<TextInput
|
||||||
|
style={styles.inviteCodeInput}
|
||||||
|
placeholder={t('familyGroup.inviteCodePlaceholder')}
|
||||||
|
placeholderTextColor="#9ca3af"
|
||||||
|
value={inviteCodeInput}
|
||||||
|
onChangeText={(text) => setInviteCodeInput(text.toUpperCase())}
|
||||||
|
autoCapitalize="characters"
|
||||||
|
autoCorrect={false}
|
||||||
|
keyboardType="default"
|
||||||
|
maxLength={12}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 关系选择标签 */}
|
||||||
|
<Text style={styles.relationshipLabel}>{t('familyGroup.relationshipLabel')}</Text>
|
||||||
|
|
||||||
|
{/* 关系选项网格 - 固定高度可滚动 */}
|
||||||
|
<ScrollView
|
||||||
|
style={styles.relationshipScrollView}
|
||||||
|
contentContainerStyle={styles.relationshipGrid}
|
||||||
|
showsVerticalScrollIndicator={true}
|
||||||
|
nestedScrollEnabled
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
>
|
||||||
|
{relationshipOptions.map((option) => {
|
||||||
|
const isSelected = selectedRelationship === option.key;
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={option.key}
|
||||||
|
style={[
|
||||||
|
styles.relationshipChip,
|
||||||
|
isSelected && styles.relationshipChipSelected,
|
||||||
|
]}
|
||||||
|
onPress={() => setSelectedRelationship(option.key)}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.relationshipChipText,
|
||||||
|
isSelected && styles.relationshipChipTextSelected,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
{/* 错误提示 */}
|
||||||
|
{joinError && joinModalVisible ? (
|
||||||
|
<Text style={styles.modalError}>{joinError}</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
scrollContent: {
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingBottom: 100,
|
||||||
|
},
|
||||||
|
topSection: {
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
avatarRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 10,
|
||||||
|
},
|
||||||
|
miniAvatarContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: '#5B4CFF',
|
||||||
|
paddingVertical: 4,
|
||||||
|
paddingHorizontal: 4,
|
||||||
|
paddingRight: 12,
|
||||||
|
borderRadius: 20,
|
||||||
|
},
|
||||||
|
miniAvatar: {
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
borderRadius: 12,
|
||||||
|
marginRight: 6,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#FFF',
|
||||||
|
},
|
||||||
|
miniAvatarName: {
|
||||||
|
color: '#FFF',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
},
|
||||||
|
addButton: {
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
borderRadius: 14,
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginLeft: 8,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 1 },
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 2,
|
||||||
|
elevation: 2,
|
||||||
|
},
|
||||||
|
actionButtonsRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-around',
|
||||||
|
marginTop: 24,
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
inviteBanner: {
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderRadius: 20,
|
||||||
|
padding: 16,
|
||||||
|
marginBottom: 20,
|
||||||
|
shadowColor: '#5B4CFF',
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowOpacity: 0.05,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 2,
|
||||||
|
},
|
||||||
|
inviteContent: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
inviteIconContainer: {
|
||||||
|
marginRight: 8,
|
||||||
|
},
|
||||||
|
inviteText: {
|
||||||
|
flex: 1,
|
||||||
|
fontSize: 13,
|
||||||
|
color: '#1F2138',
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
segmentControl: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginBottom: 16,
|
||||||
|
paddingHorizontal: 18,
|
||||||
|
},
|
||||||
|
segmentItem: {
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
segmentIconPlaceholder: {
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
borderRadius: 12,
|
||||||
|
backgroundColor: '#F3F4F6',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
segmentIconActive: {
|
||||||
|
backgroundColor: '#E0E7FF',
|
||||||
|
},
|
||||||
|
segmentText: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#6B7280',
|
||||||
|
},
|
||||||
|
segmentTextActive: {
|
||||||
|
color: '#5B4CFF',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
},
|
||||||
|
privacyNoticeContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingVertical: 20,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
marginTop: 32,
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
privacyIconWrapper: {
|
||||||
|
marginRight: 6,
|
||||||
|
},
|
||||||
|
privacyNoticeText: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#9CA3AF',
|
||||||
|
textAlign: 'center',
|
||||||
|
lineHeight: 18,
|
||||||
|
},
|
||||||
|
joinButtonGlass: {
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 8,
|
||||||
|
borderRadius: 16,
|
||||||
|
minWidth: 60,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
borderWidth: StyleSheet.hairlineWidth,
|
||||||
|
borderColor: 'rgba(255,255,255,0.45)',
|
||||||
|
},
|
||||||
|
joinButtonLabel: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#0f1528',
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
fontFamily: 'AliBold',
|
||||||
|
},
|
||||||
|
joinButtonFallback: {
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.7)',
|
||||||
|
},
|
||||||
|
// 加入家庭组弹窗样式
|
||||||
|
joinModalContent: {
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
inviteCodeInput: {
|
||||||
|
backgroundColor: '#f8fafc',
|
||||||
|
borderRadius: 14,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 14,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '700',
|
||||||
|
letterSpacing: 2,
|
||||||
|
color: '#0f1528',
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
relationshipLabel: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#374151',
|
||||||
|
marginTop: 4,
|
||||||
|
marginBottom: 2,
|
||||||
|
},
|
||||||
|
relationshipScrollView: {
|
||||||
|
maxHeight: 160,
|
||||||
|
borderRadius: 12,
|
||||||
|
backgroundColor: '#fafafa',
|
||||||
|
},
|
||||||
|
relationshipGrid: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: 8,
|
||||||
|
padding: 8,
|
||||||
|
},
|
||||||
|
relationshipChip: {
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 8,
|
||||||
|
borderRadius: 20,
|
||||||
|
backgroundColor: '#f3f4f6',
|
||||||
|
borderWidth: 1.5,
|
||||||
|
borderColor: 'transparent',
|
||||||
|
},
|
||||||
|
relationshipChipSelected: {
|
||||||
|
backgroundColor: '#ede9fe',
|
||||||
|
borderColor: '#8b5cf6',
|
||||||
|
},
|
||||||
|
relationshipChipText: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#6b7280',
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
relationshipChipTextSelected: {
|
||||||
|
color: '#7c3aed',
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
modalError: {
|
||||||
|
marginTop: 6,
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#ef4444',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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';
|
||||||
|
|||||||
554
app/menstrual-cycle.tsx
Normal file
554
app/menstrual-cycle.tsx
Normal file
@@ -0,0 +1,554 @@
|
|||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
|
import { Stack, useRouter } from 'expo-router';
|
||||||
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import {
|
||||||
|
FlatList,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
|
||||||
|
import { InlineTip, ITEM_HEIGHT, Legend, MonthBlock } from '@/components/menstrual-cycle';
|
||||||
|
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||||
|
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||||
|
import {
|
||||||
|
deleteMenstrualFlow,
|
||||||
|
fetchMenstrualFlowSamples,
|
||||||
|
saveMenstrualFlow
|
||||||
|
} from '@/utils/health';
|
||||||
|
import {
|
||||||
|
buildMenstrualTimeline,
|
||||||
|
convertHealthKitSamplesToCycleRecords,
|
||||||
|
CycleRecord,
|
||||||
|
DEFAULT_PERIOD_LENGTH
|
||||||
|
} from '@/utils/menstrualCycle';
|
||||||
|
|
||||||
|
type TabKey = 'cycle' | 'analysis';
|
||||||
|
|
||||||
|
export default function MenstrualCycleScreen() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
const safeAreaTop = useSafeAreaTop();
|
||||||
|
const [records, setRecords] = useState<CycleRecord[]>([]);
|
||||||
|
const [windowConfig, setWindowConfig] = useState({ before: 2, after: 3 });
|
||||||
|
const locale = i18n.language.startsWith('en') ? 'en' : 'zh';
|
||||||
|
const monthTitleFormat = t('menstrual.dateFormats.monthTitle', { defaultValue: 'M月' });
|
||||||
|
const monthSubtitleFormat = t('menstrual.dateFormats.monthSubtitle', { defaultValue: 'YYYY年' });
|
||||||
|
const weekLabels = useMemo(() => {
|
||||||
|
const labels = t('menstrual.weekdays', { returnObjects: true }) as string[];
|
||||||
|
return Array.isArray(labels) && labels.length === 7 ? labels : undefined;
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
|
// 从 HealthKit 拉取当前窗口范围内的经期数据
|
||||||
|
useEffect(() => {
|
||||||
|
const loadData = async () => {
|
||||||
|
// 根据 windowConfig 计算需要拉取的月份区间
|
||||||
|
const today = dayjs();
|
||||||
|
const startDate = today.subtract(windowConfig.before, 'month').startOf('month').toDate();
|
||||||
|
const endDate = today.add(windowConfig.after, 'month').endOf('month').toDate();
|
||||||
|
|
||||||
|
const samples = await fetchMenstrualFlowSamples(startDate, endDate);
|
||||||
|
const convertedRecords = convertHealthKitSamplesToCycleRecords(samples);
|
||||||
|
setRecords(convertedRecords);
|
||||||
|
};
|
||||||
|
|
||||||
|
loadData();
|
||||||
|
}, [windowConfig]);
|
||||||
|
|
||||||
|
// 根据记录生成时间轴(包含预测周期、易孕期等)
|
||||||
|
const timeline = useMemo(
|
||||||
|
() =>
|
||||||
|
buildMenstrualTimeline({
|
||||||
|
monthsBefore: windowConfig.before,
|
||||||
|
monthsAfter: windowConfig.after,
|
||||||
|
records,
|
||||||
|
defaultPeriodLength: DEFAULT_PERIOD_LENGTH,
|
||||||
|
locale,
|
||||||
|
monthTitleFormat,
|
||||||
|
monthSubtitleFormat,
|
||||||
|
}),
|
||||||
|
[records, windowConfig, locale, monthSubtitleFormat, monthTitleFormat]
|
||||||
|
);
|
||||||
|
const [activeTab, setActiveTab] = useState<TabKey>('cycle');
|
||||||
|
const [selectedDateKey, setSelectedDateKey] = useState(
|
||||||
|
dayjs().format('YYYY-MM-DD')
|
||||||
|
);
|
||||||
|
const listRef = useRef<FlatList>(null);
|
||||||
|
const offsetRef = useRef(0);
|
||||||
|
const prependDeltaRef = useRef(0);
|
||||||
|
const loadingPrevRef = useRef(false);
|
||||||
|
const hasAutoScrolledRef = useRef(false);
|
||||||
|
const todayMonthId = useMemo(() => dayjs().format('YYYY-MM'), []);
|
||||||
|
|
||||||
|
const selectedInfo = timeline.dayMap[selectedDateKey];
|
||||||
|
const selectedDate = dayjs(selectedDateKey);
|
||||||
|
const initialMonthIndex = useMemo(
|
||||||
|
() => timeline.months.findIndex((month) => month.id === todayMonthId),
|
||||||
|
[timeline.months, todayMonthId]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasAutoScrolledRef.current) return;
|
||||||
|
if (initialMonthIndex < 0 || !listRef.current) return;
|
||||||
|
hasAutoScrolledRef.current = true;
|
||||||
|
offsetRef.current = initialMonthIndex * ITEM_HEIGHT;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
listRef.current?.scrollToIndex({ index: initialMonthIndex, animated: false });
|
||||||
|
});
|
||||||
|
}, [initialMonthIndex]);
|
||||||
|
|
||||||
|
|
||||||
|
// 标记当天为经期开始(包含乐观更新与 HealthKit 同步)
|
||||||
|
const handleMarkStart = async () => {
|
||||||
|
if (selectedDate.isAfter(dayjs(), 'day')) return;
|
||||||
|
|
||||||
|
// Check if the selected date is already covered
|
||||||
|
const isCovered = records.some((r) => {
|
||||||
|
const start = dayjs(r.startDate);
|
||||||
|
const end = start.add((r.periodLength ?? DEFAULT_PERIOD_LENGTH) - 1, 'day');
|
||||||
|
return (
|
||||||
|
(selectedDate.isSame(start, 'day') || selectedDate.isAfter(start, 'day')) &&
|
||||||
|
(selectedDate.isSame(end, 'day') || selectedDate.isBefore(end, 'day'))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
if (isCovered) return;
|
||||||
|
|
||||||
|
// Optimistic Update
|
||||||
|
const originalRecords = [...records];
|
||||||
|
setRecords((prev) => {
|
||||||
|
const updated = [...prev];
|
||||||
|
// Logic for optimistic UI update (same as original logic)
|
||||||
|
const prevRecordIndex = updated.findIndex((r) => {
|
||||||
|
const start = dayjs(r.startDate);
|
||||||
|
const end = start.add((r.periodLength ?? DEFAULT_PERIOD_LENGTH) - 1, 'day');
|
||||||
|
return end.add(1, 'day').isSame(selectedDate, 'day');
|
||||||
|
});
|
||||||
|
const nextRecordIndex = updated.findIndex((r) => {
|
||||||
|
return dayjs(r.startDate).subtract(1, 'day').isSame(selectedDate, 'day');
|
||||||
|
});
|
||||||
|
|
||||||
|
if (prevRecordIndex !== -1 && nextRecordIndex !== -1) {
|
||||||
|
const prevRecord = updated[prevRecordIndex];
|
||||||
|
const nextRecord = updated[nextRecordIndex];
|
||||||
|
const newLength =
|
||||||
|
(prevRecord.periodLength ?? DEFAULT_PERIOD_LENGTH) +
|
||||||
|
1 +
|
||||||
|
(nextRecord.periodLength ?? DEFAULT_PERIOD_LENGTH);
|
||||||
|
updated[prevRecordIndex] = { ...prevRecord, periodLength: newLength };
|
||||||
|
updated.splice(nextRecordIndex, 1);
|
||||||
|
} else if (prevRecordIndex !== -1) {
|
||||||
|
const prevRecord = updated[prevRecordIndex];
|
||||||
|
updated[prevRecordIndex] = {
|
||||||
|
...prevRecord,
|
||||||
|
periodLength: (prevRecord.periodLength ?? DEFAULT_PERIOD_LENGTH) + 1,
|
||||||
|
};
|
||||||
|
} else if (nextRecordIndex !== -1) {
|
||||||
|
const nextRecord = updated[nextRecordIndex];
|
||||||
|
updated[nextRecordIndex] = {
|
||||||
|
...nextRecord,
|
||||||
|
startDate: selectedDate.format('YYYY-MM-DD'),
|
||||||
|
periodLength: (nextRecord.periodLength ?? DEFAULT_PERIOD_LENGTH) + 1,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
const newRecord: CycleRecord = {
|
||||||
|
startDate: selectedDate.format('YYYY-MM-DD'),
|
||||||
|
periodLength: 7,
|
||||||
|
source: 'manual',
|
||||||
|
};
|
||||||
|
updated.push(newRecord);
|
||||||
|
}
|
||||||
|
return updated.sort((a, b) => dayjs(a.startDate).valueOf() - dayjs(b.startDate).valueOf());
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Determine what to save to HealthKit
|
||||||
|
// If we are merging or extending, we are effectively adding one day of flow
|
||||||
|
// If we are creating a new record, we default to 7 days
|
||||||
|
// However, accurate HealthKit logging should be per day.
|
||||||
|
// The previous UI logic "creates" a 7-day period for a single tap.
|
||||||
|
// We should replicate this behavior in HealthKit for consistency.
|
||||||
|
|
||||||
|
const isNewIsolatedRecord = !records.some((r) => {
|
||||||
|
const start = dayjs(r.startDate);
|
||||||
|
const end = start.add((r.periodLength ?? DEFAULT_PERIOD_LENGTH) - 1, 'day');
|
||||||
|
// Check adjacency
|
||||||
|
return (
|
||||||
|
end.add(1, 'day').isSame(selectedDate, 'day') ||
|
||||||
|
dayjs(r.startDate).subtract(1, 'day').isSame(selectedDate, 'day')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isNewIsolatedRecord) {
|
||||||
|
// Save 7 days of flow starting from selectedDate
|
||||||
|
const promises = [];
|
||||||
|
for (let i = 0; i < 7; i++) {
|
||||||
|
const date = selectedDate.add(i, 'day');
|
||||||
|
// Don't save future dates if they exceed today (though logic allows predicting)
|
||||||
|
// But for flow logging, we usually only log past/present.
|
||||||
|
// However, UI allows setting a period that might extend slightly?
|
||||||
|
// Let's stick to the selected date logic.
|
||||||
|
// Wait, if I tap "Mark Start", it creates a 7 day period.
|
||||||
|
// Should I write 7 samples? Yes, to match the UI state.
|
||||||
|
promises.push(saveMenstrualFlow(date.toDate(), 1, i === 0)); // 1=unspecified
|
||||||
|
}
|
||||||
|
await Promise.all(promises);
|
||||||
|
} else {
|
||||||
|
// Just adding a single day to bridge/extend
|
||||||
|
await saveMenstrualFlow(selectedDate.toDate(), 1, false);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save to HealthKit', error);
|
||||||
|
// Revert optimistic update
|
||||||
|
setRecords(originalRecords);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 取消选中日期的经期标记(与 HealthKit 同步)
|
||||||
|
const handleCancelMark = async () => {
|
||||||
|
if (!selectedInfo || !selectedInfo.confirmed) return;
|
||||||
|
if (selectedDate.isAfter(dayjs(), 'day')) return;
|
||||||
|
const target = selectedDate;
|
||||||
|
|
||||||
|
// Optimistic Update
|
||||||
|
const originalRecords = [...records];
|
||||||
|
setRecords((prev) => {
|
||||||
|
const updated: CycleRecord[] = [];
|
||||||
|
prev.forEach((record) => {
|
||||||
|
const start = dayjs(record.startDate);
|
||||||
|
const periodLength = record.periodLength ?? DEFAULT_PERIOD_LENGTH;
|
||||||
|
const diff = target.diff(start, 'day');
|
||||||
|
|
||||||
|
if (diff < 0 || diff >= periodLength) {
|
||||||
|
updated.push(record);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (diff === 0) return; // Remove entire record (or start of it)
|
||||||
|
updated.push({ ...record, periodLength: diff }); // Shorten it
|
||||||
|
});
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Logic:
|
||||||
|
// 1. Find the record covering the target date
|
||||||
|
const record = records.find((r) => {
|
||||||
|
const start = dayjs(r.startDate);
|
||||||
|
const end = start.add((r.periodLength ?? DEFAULT_PERIOD_LENGTH) - 1, 'day');
|
||||||
|
return (
|
||||||
|
(target.isSame(start, 'day') || target.isAfter(start, 'day')) &&
|
||||||
|
(target.isSame(end, 'day') || target.isBefore(end, 'day'))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (record) {
|
||||||
|
const start = dayjs(record.startDate);
|
||||||
|
const diff = target.diff(start, 'day');
|
||||||
|
|
||||||
|
if (diff === 0) {
|
||||||
|
// If cancelling the start date, the UI removes the ENTIRE period record.
|
||||||
|
// So we should delete all samples for this period range.
|
||||||
|
const periodLength = record.periodLength ?? DEFAULT_PERIOD_LENGTH;
|
||||||
|
const endDate = start.add(periodLength - 1, 'day');
|
||||||
|
await deleteMenstrualFlow(start.toDate(), endDate.toDate());
|
||||||
|
} else {
|
||||||
|
// If cancelling a middle/end date, the UI shortens the period to end BEFORE target.
|
||||||
|
// So we delete from target date onwards to the original end date.
|
||||||
|
const periodLength = record.periodLength ?? DEFAULT_PERIOD_LENGTH;
|
||||||
|
const originalEnd = start.add(periodLength - 1, 'day');
|
||||||
|
// Delete from target to originalEnd
|
||||||
|
await deleteMenstrualFlow(target.toDate(), originalEnd.toDate());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete from HealthKit', error);
|
||||||
|
setRecords(originalRecords);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 下拉到顶部时加载更早的月份
|
||||||
|
const handleLoadPrevious = () => {
|
||||||
|
if (loadingPrevRef.current) return;
|
||||||
|
loadingPrevRef.current = true;
|
||||||
|
const delta = 3;
|
||||||
|
prependDeltaRef.current = delta;
|
||||||
|
setWindowConfig((prev) => ({ ...prev, before: prev.before + delta }));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 向前追加月份时,保持当前视口位置不跳动
|
||||||
|
useEffect(() => {
|
||||||
|
if (prependDeltaRef.current > 0 && listRef.current) {
|
||||||
|
const offset = offsetRef.current + prependDeltaRef.current * ITEM_HEIGHT;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
listRef.current?.scrollToOffset({ offset, animated: false });
|
||||||
|
prependDeltaRef.current = 0;
|
||||||
|
loadingPrevRef.current = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [timeline.months.length]);
|
||||||
|
|
||||||
|
const viewabilityConfig = useRef({
|
||||||
|
viewAreaCoveragePercentThreshold: 10,
|
||||||
|
}).current;
|
||||||
|
|
||||||
|
// 监测可视区域,接近顶部时触发加载更早月份
|
||||||
|
const onViewableItemsChanged = useRef(({ viewableItems }: any) => {
|
||||||
|
const minIndex = viewableItems.reduce(
|
||||||
|
(acc: number, cur: any) => Math.min(acc, cur.index ?? acc),
|
||||||
|
Number.MAX_SAFE_INTEGER
|
||||||
|
);
|
||||||
|
if (minIndex <= 1) {
|
||||||
|
handleLoadPrevious();
|
||||||
|
}
|
||||||
|
}).current;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// FlatList 数据源:按月份拆分
|
||||||
|
const listData = useMemo(() => {
|
||||||
|
return timeline.months.map((m) => ({
|
||||||
|
type: 'month' as const,
|
||||||
|
id: m.id,
|
||||||
|
month: m,
|
||||||
|
}));
|
||||||
|
}, [timeline.months]);
|
||||||
|
|
||||||
|
const renderInlineTip = (columnIndex: number) => (
|
||||||
|
<InlineTip
|
||||||
|
selectedDate={selectedDate}
|
||||||
|
selectedInfo={selectedInfo}
|
||||||
|
columnIndex={columnIndex}
|
||||||
|
onMarkStart={handleMarkStart}
|
||||||
|
onCancelMark={handleCancelMark}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderCycleTab = () => (
|
||||||
|
<View style={styles.tabContent}>
|
||||||
|
<Legend />
|
||||||
|
|
||||||
|
|
||||||
|
<FlatList
|
||||||
|
ref={listRef}
|
||||||
|
data={listData}
|
||||||
|
keyExtractor={(item) => item.id}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<MonthBlock
|
||||||
|
month={item.month}
|
||||||
|
selectedDateKey={selectedDateKey}
|
||||||
|
onSelect={(key) => setSelectedDateKey(key)}
|
||||||
|
renderTip={renderInlineTip}
|
||||||
|
weekLabels={weekLabels}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
initialNumToRender={3}
|
||||||
|
windowSize={5}
|
||||||
|
maxToRenderPerBatch={4}
|
||||||
|
removeClippedSubviews
|
||||||
|
contentContainerStyle={styles.listContent}
|
||||||
|
// 使用固定高度优化初始滚动定位
|
||||||
|
getItemLayout={(_, index) => ({
|
||||||
|
length: ITEM_HEIGHT,
|
||||||
|
offset: ITEM_HEIGHT * index,
|
||||||
|
index,
|
||||||
|
})}
|
||||||
|
initialScrollIndex={initialMonthIndex >= 0 ? initialMonthIndex : undefined}
|
||||||
|
onScrollToIndexFailed={({ index }) => {
|
||||||
|
listRef.current?.scrollToOffset({
|
||||||
|
offset: ITEM_HEIGHT * index,
|
||||||
|
animated: false,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
viewabilityConfig={viewabilityConfig}
|
||||||
|
onViewableItemsChanged={onViewableItemsChanged}
|
||||||
|
onScroll={(e) => {
|
||||||
|
offsetRef.current = e.nativeEvent.contentOffset.y;
|
||||||
|
}}
|
||||||
|
scrollEventThrottle={16}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderAnalysisTab = () => (
|
||||||
|
<View style={styles.tabContent}>
|
||||||
|
<View style={styles.analysisCard}>
|
||||||
|
<Text style={styles.analysisTitle}>{t('menstrual.screen.analysis.title')}</Text>
|
||||||
|
<Text style={styles.analysisBody}>
|
||||||
|
{t('menstrual.screen.analysis.description')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<Stack.Screen options={{ headerShown: false }} />
|
||||||
|
<LinearGradient
|
||||||
|
colors={['#fdf1ff', '#f3f4ff', '#f7f8ff']}
|
||||||
|
style={StyleSheet.absoluteFill}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 0, y: 1 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<HeaderBar
|
||||||
|
title={t('menstrual.screen.header')}
|
||||||
|
onBack={() => router.back()}
|
||||||
|
// right={
|
||||||
|
// isLiquidGlassAvailable() ? (
|
||||||
|
// <TouchableOpacity style={styles.headerIconButton} activeOpacity={0.7}>
|
||||||
|
// <GlassView
|
||||||
|
// style={styles.headerIconGlass}
|
||||||
|
// glassEffectStyle="clear"
|
||||||
|
// tintColor="rgba(255, 255, 255, 0.35)"
|
||||||
|
// isInteractive={true}
|
||||||
|
// >
|
||||||
|
// <Ionicons name="settings-outline" size={20} color="#0f172a" />
|
||||||
|
// </GlassView>
|
||||||
|
// </TouchableOpacity>
|
||||||
|
// ) : (
|
||||||
|
// <TouchableOpacity style={styles.headerIcon} activeOpacity={0.7}>
|
||||||
|
// <Ionicons name="settings-outline" size={20} color="#0f172a" />
|
||||||
|
// </TouchableOpacity>
|
||||||
|
// )
|
||||||
|
// }
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View style={{ height: safeAreaTop }} />
|
||||||
|
|
||||||
|
<View style={styles.tabSwitcher}>
|
||||||
|
{([
|
||||||
|
{ key: 'cycle', label: t('menstrual.screen.tabs.cycle') },
|
||||||
|
{ key: 'analysis', label: t('menstrual.screen.tabs.analysis') },
|
||||||
|
] as { key: TabKey; label: string }[]).map((tab) => {
|
||||||
|
const active = activeTab === tab.key;
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={tab.key}
|
||||||
|
style={[styles.tabPill, active && styles.tabPillActive]}
|
||||||
|
onPress={() => setActiveTab(tab.key)}
|
||||||
|
activeOpacity={0.9}
|
||||||
|
>
|
||||||
|
<Text style={[styles.tabLabel, active && styles.tabLabelActive]}>
|
||||||
|
{tab.label}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{activeTab === 'cycle' ? renderCycleTab() : renderAnalysisTab()}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
},
|
||||||
|
headerIcon: {
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
borderRadius: 18,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: 'rgba(255, 255, 255, 0.5)',
|
||||||
|
},
|
||||||
|
headerIconButton: {
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
borderRadius: 18,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
headerIconGlass: {
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
borderRadius: 18,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
tabSwitcher: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.7)',
|
||||||
|
borderRadius: 18,
|
||||||
|
padding: 4,
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
tabPill: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: 10,
|
||||||
|
borderRadius: 14,
|
||||||
|
},
|
||||||
|
tabPillActive: {
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOpacity: 0.08,
|
||||||
|
shadowOffset: { width: 0, height: 8 },
|
||||||
|
shadowRadius: 10,
|
||||||
|
elevation: 3,
|
||||||
|
},
|
||||||
|
tabLabel: {
|
||||||
|
color: '#4b5563',
|
||||||
|
fontWeight: '600',
|
||||||
|
fontFamily: 'AliRegular',
|
||||||
|
},
|
||||||
|
tabLabelActive: {
|
||||||
|
color: '#0f172a',
|
||||||
|
fontFamily: 'AliBold',
|
||||||
|
},
|
||||||
|
tabContent: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
|
||||||
|
selectedCard: {
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
borderRadius: 16,
|
||||||
|
paddingVertical: 10,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
marginBottom: 10,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOpacity: 0.08,
|
||||||
|
shadowRadius: 10,
|
||||||
|
shadowOffset: { width: 0, height: 8 },
|
||||||
|
elevation: 3,
|
||||||
|
},
|
||||||
|
selectedStatus: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#111827',
|
||||||
|
fontWeight: '700',
|
||||||
|
fontFamily: 'AliBold',
|
||||||
|
},
|
||||||
|
|
||||||
|
listContent: {
|
||||||
|
paddingBottom: 80,
|
||||||
|
},
|
||||||
|
analysisCard: {
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 16,
|
||||||
|
marginTop: 8,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOpacity: 0.08,
|
||||||
|
shadowRadius: 10,
|
||||||
|
shadowOffset: { width: 0, height: 6 },
|
||||||
|
elevation: 3,
|
||||||
|
},
|
||||||
|
analysisTitle: {
|
||||||
|
fontSize: 17,
|
||||||
|
fontWeight: '800',
|
||||||
|
color: '#0f172a',
|
||||||
|
marginBottom: 8,
|
||||||
|
fontFamily: 'AliBold',
|
||||||
|
},
|
||||||
|
analysisBody: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#6b7280',
|
||||||
|
lineHeight: 20,
|
||||||
|
fontFamily: 'AliRegular',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -8,10 +8,12 @@ import {
|
|||||||
getMoodReminderEnabled,
|
getMoodReminderEnabled,
|
||||||
getNotificationEnabled,
|
getNotificationEnabled,
|
||||||
getNutritionReminderEnabled,
|
getNutritionReminderEnabled,
|
||||||
|
getHRVReminderEnabled,
|
||||||
setMedicationReminderEnabled,
|
setMedicationReminderEnabled,
|
||||||
setMoodReminderEnabled,
|
setMoodReminderEnabled,
|
||||||
setNotificationEnabled,
|
setNotificationEnabled,
|
||||||
setNutritionReminderEnabled
|
setNutritionReminderEnabled,
|
||||||
|
setHRVReminderEnabled
|
||||||
} from '@/utils/userPreferences';
|
} from '@/utils/userPreferences';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
@@ -29,21 +31,24 @@ export default function NotificationSettingsScreen() {
|
|||||||
const [medicationReminderEnabled, setMedicationReminderEnabledState] = useState(false);
|
const [medicationReminderEnabled, setMedicationReminderEnabledState] = useState(false);
|
||||||
const [nutritionReminderEnabled, setNutritionReminderEnabledState] = useState(false);
|
const [nutritionReminderEnabled, setNutritionReminderEnabledState] = useState(false);
|
||||||
const [moodReminderEnabled, setMoodReminderEnabledState] = useState(false);
|
const [moodReminderEnabled, setMoodReminderEnabledState] = useState(false);
|
||||||
|
const [hrvReminderEnabled, setHrvReminderEnabledState] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
// 加载通知设置
|
// 加载通知设置
|
||||||
const loadNotificationSettings = useCallback(async () => {
|
const loadNotificationSettings = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const [notification, medicationReminder, nutritionReminder, moodReminder] = await Promise.all([
|
const [notification, medicationReminder, nutritionReminder, moodReminder, hrvReminder] = await Promise.all([
|
||||||
getNotificationEnabled(),
|
getNotificationEnabled(),
|
||||||
getMedicationReminderEnabled(),
|
getMedicationReminderEnabled(),
|
||||||
getNutritionReminderEnabled(),
|
getNutritionReminderEnabled(),
|
||||||
getMoodReminderEnabled(),
|
getMoodReminderEnabled(),
|
||||||
|
getHRVReminderEnabled(),
|
||||||
]);
|
]);
|
||||||
setNotificationEnabledState(notification);
|
setNotificationEnabledState(notification);
|
||||||
setMedicationReminderEnabledState(medicationReminder);
|
setMedicationReminderEnabledState(medicationReminder);
|
||||||
setNutritionReminderEnabledState(nutritionReminder);
|
setNutritionReminderEnabledState(nutritionReminder);
|
||||||
setMoodReminderEnabledState(moodReminder);
|
setMoodReminderEnabledState(moodReminder);
|
||||||
|
setHrvReminderEnabledState(hrvReminder);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load notification settings:', error);
|
console.error('Failed to load notification settings:', error);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -103,6 +108,8 @@ export default function NotificationSettingsScreen() {
|
|||||||
setNutritionReminderEnabledState(false);
|
setNutritionReminderEnabledState(false);
|
||||||
await setMoodReminderEnabled(false);
|
await setMoodReminderEnabled(false);
|
||||||
setMoodReminderEnabledState(false);
|
setMoodReminderEnabledState(false);
|
||||||
|
await setHRVReminderEnabled(false);
|
||||||
|
setHrvReminderEnabledState(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to disable push notifications:', error);
|
console.error('Failed to disable push notifications:', error);
|
||||||
Alert.alert(t('notificationSettings.alerts.error.title'), t('notificationSettings.alerts.error.saveFailed'));
|
Alert.alert(t('notificationSettings.alerts.error.title'), t('notificationSettings.alerts.error.saveFailed'));
|
||||||
@@ -173,6 +180,26 @@ export default function NotificationSettingsScreen() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 处理 HRV 通知提醒开关变化
|
||||||
|
const handleHrvReminderToggle = async (value: boolean) => {
|
||||||
|
try {
|
||||||
|
await setHRVReminderEnabled(value);
|
||||||
|
setHrvReminderEnabledState(value);
|
||||||
|
|
||||||
|
if (value) {
|
||||||
|
await sendNotification({
|
||||||
|
title: t('notificationSettings.alerts.hrvReminderEnabled.title'),
|
||||||
|
body: t('notificationSettings.alerts.hrvReminderEnabled.body'),
|
||||||
|
sound: true,
|
||||||
|
priority: 'high',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to set HRV reminder:', error);
|
||||||
|
Alert.alert(t('notificationSettings.alerts.error.title'), t('notificationSettings.alerts.error.hrvReminderFailed'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 渲染设置项
|
// 渲染设置项
|
||||||
const renderSettingItem = (
|
const renderSettingItem = (
|
||||||
icon: keyof typeof Ionicons.glyphMap,
|
icon: keyof typeof Ionicons.glyphMap,
|
||||||
@@ -297,6 +324,16 @@ export default function NotificationSettingsScreen() {
|
|||||||
true
|
true
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{renderSettingItem(
|
||||||
|
'pulse-outline',
|
||||||
|
t('notificationSettings.items.hrvReminder.title'),
|
||||||
|
t('notificationSettings.items.hrvReminder.description'),
|
||||||
|
hrvReminderEnabled,
|
||||||
|
handleHrvReminderToggle,
|
||||||
|
!notificationEnabled,
|
||||||
|
true
|
||||||
|
)}
|
||||||
|
|
||||||
{renderSettingItem(
|
{renderSettingItem(
|
||||||
'happy-outline',
|
'happy-outline',
|
||||||
t('notificationSettings.items.moodReminder.title'),
|
t('notificationSettings.items.moodReminder.title'),
|
||||||
@@ -432,4 +469,4 @@ const styles = StyleSheet.create({
|
|||||||
height: 1,
|
height: 1,
|
||||||
backgroundColor: '#F0F0F0',
|
backgroundColor: '#F0F0F0',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
359
app/statistics-customization.tsx
Normal file
359
app/statistics-customization.tsx
Normal file
@@ -0,0 +1,359 @@
|
|||||||
|
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||||
|
import { palette } from '@/constants/Colors';
|
||||||
|
import { useMembershipModal } from '@/contexts/MembershipModalContext';
|
||||||
|
import { useToast } from '@/contexts/ToastContext';
|
||||||
|
import { useI18n } from '@/hooks/useI18n';
|
||||||
|
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||||
|
import { useVipService } from '@/hooks/useVipService';
|
||||||
|
import {
|
||||||
|
getStatisticsCardOrder,
|
||||||
|
getStatisticsCardsVisibility,
|
||||||
|
setStatisticsCardOrder,
|
||||||
|
setStatisticsCardVisibility,
|
||||||
|
StatisticsCardsVisibility
|
||||||
|
} from '@/utils/userPreferences';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
|
import { router, useFocusEffect } from 'expo-router';
|
||||||
|
import React, { useCallback, useState } from 'react';
|
||||||
|
import { StatusBar, StyleSheet, Switch, Text, TouchableOpacity, View } from 'react-native';
|
||||||
|
import DraggableFlatList, { RenderItemParams, ScaleDecorator } from 'react-native-draggable-flatlist';
|
||||||
|
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||||
|
|
||||||
|
type CardItem = {
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
icon: keyof typeof Ionicons.glyphMap;
|
||||||
|
visible: boolean;
|
||||||
|
visibilityKey: keyof StatisticsCardsVisibility;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function StatisticsCustomizationScreen() {
|
||||||
|
const safeAreaTop = useSafeAreaTop(60);
|
||||||
|
const { t } = useI18n();
|
||||||
|
const { isVip } = useVipService();
|
||||||
|
const { openMembershipModal } = useMembershipModal();
|
||||||
|
const { showToast } = useToast();
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [data, setData] = useState<CardItem[]>([]);
|
||||||
|
|
||||||
|
const CARD_CONFIG: Record<string, { icon: keyof typeof Ionicons.glyphMap; titleKey: string; visibilityKey: keyof StatisticsCardsVisibility }> = {
|
||||||
|
mood: { icon: 'happy-outline', titleKey: 'statisticsCustomization.items.mood', visibilityKey: 'showMood' },
|
||||||
|
steps: { icon: 'footsteps-outline', titleKey: 'statisticsCustomization.items.steps', visibilityKey: 'showSteps' },
|
||||||
|
stress: { icon: 'pulse-outline', titleKey: 'statisticsCustomization.items.stress', visibilityKey: 'showStress' },
|
||||||
|
sleep: { icon: 'moon-outline', titleKey: 'statisticsCustomization.items.sleep', visibilityKey: 'showSleep' },
|
||||||
|
sunlight: { icon: 'sunny-outline', titleKey: 'statisticsCustomization.items.sunlight', visibilityKey: 'showSunlight' },
|
||||||
|
fitness: { icon: 'fitness-outline', titleKey: 'statisticsCustomization.items.fitnessRings', visibilityKey: 'showFitnessRings' },
|
||||||
|
water: { icon: 'water-outline', titleKey: 'statisticsCustomization.items.water', visibilityKey: 'showWater' },
|
||||||
|
metabolism: { icon: 'flame-outline', titleKey: 'statisticsCustomization.items.basalMetabolism', visibilityKey: 'showBasalMetabolism' },
|
||||||
|
oxygen: { icon: 'water-outline', titleKey: 'statisticsCustomization.items.oxygenSaturation', visibilityKey: 'showOxygenSaturation' },
|
||||||
|
temperature: { icon: 'thermometer-outline', titleKey: 'statisticsCustomization.items.wristTemperature', visibilityKey: 'showWristTemperature' },
|
||||||
|
menstrual: { icon: 'rose-outline', titleKey: 'statisticsCustomization.items.menstrualCycle', visibilityKey: 'showMenstrualCycle' },
|
||||||
|
weight: { icon: 'scale-outline', titleKey: 'statisticsCustomization.items.weight', visibilityKey: 'showWeight' },
|
||||||
|
circumference: { icon: 'body-outline', titleKey: 'statisticsCustomization.items.circumference', visibilityKey: 'showCircumference' },
|
||||||
|
};
|
||||||
|
|
||||||
|
// 加载设置
|
||||||
|
const loadSettings = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const [visibility, order] = await Promise.all([
|
||||||
|
getStatisticsCardsVisibility(),
|
||||||
|
getStatisticsCardOrder(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 确保 order 包含所有配置的 key (处理新增 key 的情况)
|
||||||
|
const allKeys = Object.keys(CARD_CONFIG);
|
||||||
|
const uniqueOrder = Array.from(new Set([...order, ...allKeys]));
|
||||||
|
|
||||||
|
const listData: CardItem[] = uniqueOrder
|
||||||
|
.filter(key => CARD_CONFIG[key]) // 过滤掉无效 key
|
||||||
|
.map(key => {
|
||||||
|
const config = CARD_CONFIG[key];
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
title: t(config.titleKey),
|
||||||
|
icon: config.icon,
|
||||||
|
visible: visibility[config.visibilityKey],
|
||||||
|
visibilityKey: config.visibilityKey,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
setData(listData);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load statistics customization settings:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
|
// 页面聚焦时加载设置
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
loadSettings();
|
||||||
|
}, [loadSettings])
|
||||||
|
);
|
||||||
|
|
||||||
|
// 处理开关切换
|
||||||
|
const handleToggle = async (item: CardItem, value: boolean) => {
|
||||||
|
if (!isVip) {
|
||||||
|
showToast({
|
||||||
|
type: 'info',
|
||||||
|
message: t('statisticsCustomization.vipRequired'),
|
||||||
|
});
|
||||||
|
openMembershipModal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 乐观更新 UI
|
||||||
|
setData(prev => prev.map(d => d.key === item.key ? { ...d, visible: value } : d));
|
||||||
|
|
||||||
|
await setStatisticsCardVisibility(item.visibilityKey, value);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to set ${item.key}:`, error);
|
||||||
|
// 回滚
|
||||||
|
setData(prev => prev.map(d => d.key === item.key ? { ...d, visible: !value } : d));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理排序结束
|
||||||
|
const handleDragEnd = async ({ data: newData }: { data: CardItem[] }) => {
|
||||||
|
setData(newData);
|
||||||
|
const newOrder = newData.map(item => item.key);
|
||||||
|
try {
|
||||||
|
await setStatisticsCardOrder(newOrder);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save card order:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderItem = useCallback(({ item, drag, isActive }: RenderItemParams<CardItem>) => {
|
||||||
|
const handleDrag = () => {
|
||||||
|
if (!isVip) {
|
||||||
|
showToast({
|
||||||
|
type: 'info',
|
||||||
|
message: t('statisticsCustomization.vipRequired'),
|
||||||
|
});
|
||||||
|
openMembershipModal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
drag();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScaleDecorator>
|
||||||
|
<TouchableOpacity
|
||||||
|
onLongPress={handleDrag}
|
||||||
|
disabled={isActive}
|
||||||
|
activeOpacity={1}
|
||||||
|
style={[
|
||||||
|
styles.rowItem,
|
||||||
|
isActive && styles.activeItem,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View style={styles.itemContent}>
|
||||||
|
<View style={styles.leftContent}>
|
||||||
|
<View style={styles.dragHandle}>
|
||||||
|
<Ionicons name="reorder-three-outline" size={24} color="#C7C7CC" />
|
||||||
|
</View>
|
||||||
|
<View style={styles.iconContainer}>
|
||||||
|
<Ionicons name={item.icon} size={24} color={'#9370DB'} />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.itemTitle}>{item.title}</Text>
|
||||||
|
</View>
|
||||||
|
<Switch
|
||||||
|
value={item.visible}
|
||||||
|
onValueChange={(v) => handleToggle(item, v)}
|
||||||
|
trackColor={{ false: '#E5E5E5', true: '#9370DB' }}
|
||||||
|
thumbColor="#FFFFFF"
|
||||||
|
style={styles.switch}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</ScaleDecorator>
|
||||||
|
);
|
||||||
|
}, [handleToggle, isVip, t, showToast, openMembershipModal]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<StatusBar barStyle="dark-content" backgroundColor="transparent" translucent />
|
||||||
|
<LinearGradient
|
||||||
|
colors={[palette.purple[100], '#F5F5F5']}
|
||||||
|
start={{ x: 1, y: 0 }}
|
||||||
|
end={{ x: 0.3, y: 0.4 }}
|
||||||
|
style={styles.gradientBackground}
|
||||||
|
/>
|
||||||
|
<View style={styles.loadingContainer}>
|
||||||
|
<Text style={styles.loadingText}>{t('notificationSettings.loading')}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GestureHandlerRootView style={styles.container}>
|
||||||
|
<StatusBar barStyle="dark-content" backgroundColor="transparent" translucent />
|
||||||
|
|
||||||
|
<LinearGradient
|
||||||
|
colors={[palette.purple[100], '#F5F5F5']}
|
||||||
|
start={{ x: 1, y: 0 }}
|
||||||
|
end={{ x: 0.3, y: 0.4 }}
|
||||||
|
style={styles.gradientBackground}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<HeaderBar
|
||||||
|
title={t('statisticsCustomization.title')}
|
||||||
|
onBack={() => router.back()}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DraggableFlatList
|
||||||
|
data={data}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
keyExtractor={(item) => item.key}
|
||||||
|
renderItem={renderItem}
|
||||||
|
contentContainerStyle={[
|
||||||
|
styles.scrollContent,
|
||||||
|
{ paddingTop: safeAreaTop }
|
||||||
|
]}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
ListHeaderComponent={() => (
|
||||||
|
<>
|
||||||
|
<View style={styles.headerSection}>
|
||||||
|
<Text style={styles.subtitle}>{t('notificationSettings.sections.description')}</Text>
|
||||||
|
<View style={styles.descriptionCard}>
|
||||||
|
<View style={styles.hintRow}>
|
||||||
|
<Ionicons name="information-circle-outline" size={20} color="#9370DB" />
|
||||||
|
<Text style={styles.descriptionText}>
|
||||||
|
{t('statisticsCustomization.description.text')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.sectionHeader}>
|
||||||
|
<Text style={styles.sectionTitle}>{t('statisticsCustomization.sectionTitle')}</Text>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</GestureHandlerRootView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#F5F5F5',
|
||||||
|
},
|
||||||
|
gradientBackground: {
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
height: '60%',
|
||||||
|
},
|
||||||
|
loadingContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
loadingText: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: '#666',
|
||||||
|
},
|
||||||
|
scrollContent: {
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingBottom: 40,
|
||||||
|
},
|
||||||
|
headerSection: {
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#6C757D',
|
||||||
|
marginBottom: 12,
|
||||||
|
marginLeft: 4,
|
||||||
|
},
|
||||||
|
descriptionCard: {
|
||||||
|
backgroundColor: 'rgba(255, 255, 255, 0.6)',
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 12,
|
||||||
|
gap: 8,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(147, 112, 219, 0.1)',
|
||||||
|
},
|
||||||
|
hintRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
descriptionText: {
|
||||||
|
flex: 1,
|
||||||
|
fontSize: 13,
|
||||||
|
color: '#2C3E50',
|
||||||
|
lineHeight: 18,
|
||||||
|
},
|
||||||
|
sectionHeader: {
|
||||||
|
marginBottom: 12,
|
||||||
|
marginLeft: 4,
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#2C3E50',
|
||||||
|
},
|
||||||
|
rowItem: {
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderRadius: 16,
|
||||||
|
marginBottom: 12,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.03,
|
||||||
|
shadowRadius: 4,
|
||||||
|
elevation: 2,
|
||||||
|
},
|
||||||
|
activeItem: {
|
||||||
|
backgroundColor: '#FAFAFA',
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 4,
|
||||||
|
zIndex: 100,
|
||||||
|
transform: [{ scale: 1.02 }],
|
||||||
|
},
|
||||||
|
itemContent: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
padding: 16,
|
||||||
|
height: 72,
|
||||||
|
},
|
||||||
|
leftContent: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
dragHandle: {
|
||||||
|
paddingRight: 12,
|
||||||
|
},
|
||||||
|
iconContainer: {
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: 'rgba(147, 112, 219, 0.05)',
|
||||||
|
borderRadius: 12,
|
||||||
|
marginRight: 12,
|
||||||
|
},
|
||||||
|
itemTitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '500',
|
||||||
|
color: '#2C3E50',
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
switch: {
|
||||||
|
transform: [{ scaleX: 0.9 }, { scaleY: 0.9 }],
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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,9 +1,11 @@
|
|||||||
import NumberKeyboard from '@/components/NumberKeyboard';
|
import NumberKeyboard from '@/components/NumberKeyboard';
|
||||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||||
|
import { WeightProgressBar } from '@/components/weight/WeightProgressBar';
|
||||||
import { WeightRecordCard } from '@/components/weight/WeightRecordCard';
|
import { WeightRecordCard } from '@/components/weight/WeightRecordCard';
|
||||||
import { Colors } from '@/constants/Colors';
|
import { Colors } from '@/constants/Colors';
|
||||||
import { getTabBarBottomPadding } from '@/constants/TabBar';
|
import { getTabBarBottomPadding } from '@/constants/TabBar';
|
||||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||||
|
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
import { useI18n } from '@/hooks/useI18n';
|
import { useI18n } from '@/hooks/useI18n';
|
||||||
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||||
@@ -39,14 +41,16 @@ export default function WeightRecordsPage() {
|
|||||||
|
|
||||||
const colorScheme = useColorScheme();
|
const colorScheme = useColorScheme();
|
||||||
const themeColors = Colors[colorScheme ?? 'light'];
|
const themeColors = Colors[colorScheme ?? 'light'];
|
||||||
|
const { isLoggedIn, ensureLoggedIn } = useAuthGuard();
|
||||||
|
|
||||||
const loadWeightHistory = useCallback(async () => {
|
const loadWeightHistory = useCallback(async () => {
|
||||||
|
if (!isLoggedIn) return;
|
||||||
try {
|
try {
|
||||||
await dispatch(fetchWeightHistory() as any);
|
await dispatch(fetchWeightHistory() as any);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(t('weightRecords.loadingHistory'), error);
|
console.error(t('weightRecords.loadingHistory'), error);
|
||||||
}
|
}
|
||||||
}, [dispatch]);
|
}, [dispatch, isLoggedIn]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadWeightHistory();
|
loadWeightHistory();
|
||||||
@@ -56,28 +60,36 @@ export default function WeightRecordsPage() {
|
|||||||
setInputWeight(weight.toString());
|
setInputWeight(weight.toString());
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddWeight = () => {
|
const handleAddWeight = async () => {
|
||||||
|
const ok = await ensureLoggedIn();
|
||||||
|
if (!ok) return;
|
||||||
setPickerType('current');
|
setPickerType('current');
|
||||||
const weight = userProfile?.weight ? parseFloat(userProfile.weight) : 70.0;
|
const weight = userProfile?.weight ? parseFloat(userProfile.weight) : 70.0;
|
||||||
initializeInput(weight);
|
initializeInput(weight);
|
||||||
setShowWeightPicker(true);
|
setShowWeightPicker(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEditInitialWeight = () => {
|
const handleEditInitialWeight = async () => {
|
||||||
|
const ok = await ensureLoggedIn();
|
||||||
|
if (!ok) return;
|
||||||
setPickerType('initial');
|
setPickerType('initial');
|
||||||
const initialWeight = userProfile?.initialWeight || userProfile?.weight || '70.0';
|
const initialWeight = userProfile?.initialWeight || userProfile?.weight || '70.0';
|
||||||
initializeInput(parseFloat(initialWeight));
|
initializeInput(parseFloat(initialWeight));
|
||||||
setShowWeightPicker(true);
|
setShowWeightPicker(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEditTargetWeight = () => {
|
const handleEditTargetWeight = async () => {
|
||||||
|
const ok = await ensureLoggedIn();
|
||||||
|
if (!ok) return;
|
||||||
setPickerType('target');
|
setPickerType('target');
|
||||||
const targetWeight = userProfile?.targetWeight || '60.0';
|
const targetWeight = userProfile?.targetWeight || '60.0';
|
||||||
initializeInput(parseFloat(targetWeight));
|
initializeInput(parseFloat(targetWeight));
|
||||||
setShowWeightPicker(true);
|
setShowWeightPicker(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEditWeightRecord = (record: WeightHistoryItem) => {
|
const handleEditWeightRecord = async (record: WeightHistoryItem) => {
|
||||||
|
const ok = await ensureLoggedIn();
|
||||||
|
if (!ok) return;
|
||||||
setPickerType('edit');
|
setPickerType('edit');
|
||||||
setEditingRecord(record);
|
setEditingRecord(record);
|
||||||
initializeInput(parseFloat(record.weight));
|
initializeInput(parseFloat(record.weight));
|
||||||
@@ -85,6 +97,8 @@ export default function WeightRecordsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteWeightRecord = async (id: string) => {
|
const handleDeleteWeightRecord = async (id: string) => {
|
||||||
|
const ok = await ensureLoggedIn();
|
||||||
|
if (!ok) return;
|
||||||
try {
|
try {
|
||||||
await dispatch(deleteWeightRecord(id) as any);
|
await dispatch(deleteWeightRecord(id) as any);
|
||||||
await loadWeightHistory();
|
await loadWeightHistory();
|
||||||
@@ -180,6 +194,12 @@ export default function WeightRecordsPage() {
|
|||||||
const targetWeight = userProfile?.targetWeight ? parseFloat(userProfile.targetWeight) : 60.0;
|
const targetWeight = userProfile?.targetWeight ? parseFloat(userProfile.targetWeight) : 60.0;
|
||||||
const totalWeightLoss = initialWeight - currentWeight;
|
const totalWeightLoss = initialWeight - currentWeight;
|
||||||
|
|
||||||
|
// 计算减重进度
|
||||||
|
const hasTargetWeight = targetWeight > 0 && initialWeight > targetWeight;
|
||||||
|
const totalToLose = initialWeight - targetWeight;
|
||||||
|
const actualLost = initialWeight - currentWeight;
|
||||||
|
const weightProgress = hasTargetWeight && totalToLose > 0 ? actualLost / totalToLose : 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
{/* 背景 */}
|
{/* 背景 */}
|
||||||
@@ -245,9 +265,9 @@ export default function WeightRecordsPage() {
|
|||||||
<Text style={styles.mainStatUnit}>kg</Text>
|
<Text style={styles.mainStatUnit}>kg</Text>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.totalLossTag}>
|
<View style={styles.totalLossTag}>
|
||||||
<Ionicons name={totalWeightLoss <= 0 ? "trending-down" : "trending-up"} size={16} color="#ffffff" />
|
<Ionicons name={totalWeightLoss > 0 ? "trending-down" : "trending-up"} size={16} color="#ffffff" />
|
||||||
<Text style={styles.totalLossText}>
|
<Text style={styles.totalLossText}>
|
||||||
{totalWeightLoss > 0 ? '+' : ''}{totalWeightLoss.toFixed(1)} kg
|
{totalWeightLoss > 0 ? '-' : totalWeightLoss < 0 ? '+' : ''}{Math.abs(totalWeightLoss).toFixed(1)} kg
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -295,6 +315,19 @@ export default function WeightRecordsPage() {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{/* 减重进度条 - 仅在设置了目标体重时显示 */}
|
||||||
|
{hasTargetWeight && (
|
||||||
|
<View style={styles.progressContainer}>
|
||||||
|
<WeightProgressBar
|
||||||
|
progress={weightProgress}
|
||||||
|
currentWeight={currentWeight}
|
||||||
|
targetWeight={targetWeight}
|
||||||
|
initialWeight={initialWeight}
|
||||||
|
showTopBorder={false}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Monthly Records */}
|
{/* Monthly Records */}
|
||||||
{Object.keys(groupedHistory).length > 0 ? (
|
{Object.keys(groupedHistory).length > 0 ? (
|
||||||
<View style={styles.historySection}>
|
<View style={styles.historySection}>
|
||||||
@@ -628,6 +661,20 @@ const styles = StyleSheet.create({
|
|||||||
marginLeft: 2,
|
marginLeft: 2,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Progress Container
|
||||||
|
progressContainer: {
|
||||||
|
marginHorizontal: 24,
|
||||||
|
marginBottom: 24,
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
borderRadius: 24,
|
||||||
|
padding: 20,
|
||||||
|
shadowColor: 'rgba(30, 41, 59, 0.06)',
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 12,
|
||||||
|
elevation: 3,
|
||||||
|
},
|
||||||
|
|
||||||
// History Section
|
// History Section
|
||||||
historySection: {
|
historySection: {
|
||||||
paddingHorizontal: 24,
|
paddingHorizontal: 24,
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
176
components/MembershipBanner.tsx
Normal file
176
components/MembershipBanner.tsx
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
|
import React from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||||
|
|
||||||
|
interface MembershipBannerProps {
|
||||||
|
onPress: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MembershipBanner: React.FC<MembershipBannerProps> = ({ onPress }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<TouchableOpacity
|
||||||
|
activeOpacity={0.9}
|
||||||
|
onPress={onPress}
|
||||||
|
style={styles.touchable}
|
||||||
|
>
|
||||||
|
<LinearGradient
|
||||||
|
colors={['#4C3AFF', '#8D5BEA']}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 1 }}
|
||||||
|
style={styles.gradient}
|
||||||
|
>
|
||||||
|
{/* Decorative Elements */}
|
||||||
|
<View style={styles.decorationCircleLarge} />
|
||||||
|
<View style={styles.decorationCircleSmall} />
|
||||||
|
|
||||||
|
<View style={styles.contentContainer}>
|
||||||
|
<View style={styles.textContainer}>
|
||||||
|
<View style={styles.badgeContainer}>
|
||||||
|
<Text style={styles.badgeText}>PRO</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.title}>
|
||||||
|
{t('personal.membershipBanner.title', 'Unlock Premium Access')}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.subtitle} numberOfLines={1}>
|
||||||
|
{t('personal.membershipBanner.subtitle', 'Get unlimited access to all features')}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View style={styles.ctaButton}>
|
||||||
|
<Text style={styles.ctaText}>{t('personal.membershipBanner.cta', 'Upgrade')}</Text>
|
||||||
|
<Ionicons name="arrow-forward" size={12} color="#4C3AFF" />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.illustrationContainer}>
|
||||||
|
{/* Use Ionicons as illustration or you can use Image if passed as prop */}
|
||||||
|
<Ionicons name="diamond-outline" size={56} color="rgba(255,255,255,0.15)" />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</LinearGradient>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
marginBottom: 20,
|
||||||
|
borderRadius: 16,
|
||||||
|
// Premium Shadow
|
||||||
|
shadowColor: '#4C3AFF',
|
||||||
|
shadowOffset: { width: 0, height: 6 },
|
||||||
|
shadowOpacity: 0.15,
|
||||||
|
shadowRadius: 12,
|
||||||
|
elevation: 6,
|
||||||
|
marginHorizontal: 4, // Add margin to avoid cutting off shadow
|
||||||
|
},
|
||||||
|
touchable: {
|
||||||
|
borderRadius: 16,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
gradient: {
|
||||||
|
padding: 16,
|
||||||
|
minHeight: 100,
|
||||||
|
position: 'relative',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
decorationCircleLarge: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: -40,
|
||||||
|
right: -40,
|
||||||
|
width: 160,
|
||||||
|
height: 160,
|
||||||
|
borderRadius: 80,
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.08)',
|
||||||
|
},
|
||||||
|
decorationCircleSmall: {
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: -30,
|
||||||
|
left: -30,
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
borderRadius: 50,
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.05)',
|
||||||
|
},
|
||||||
|
contentContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
zIndex: 1,
|
||||||
|
},
|
||||||
|
textContainer: {
|
||||||
|
flex: 1,
|
||||||
|
paddingRight: 12,
|
||||||
|
zIndex: 2,
|
||||||
|
},
|
||||||
|
badgeContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.2)',
|
||||||
|
paddingHorizontal: 6,
|
||||||
|
paddingVertical: 2,
|
||||||
|
borderRadius: 8,
|
||||||
|
alignSelf: 'flex-start',
|
||||||
|
marginBottom: 8,
|
||||||
|
borderWidth: 0.5,
|
||||||
|
borderColor: 'rgba(255,255,255,0.3)',
|
||||||
|
},
|
||||||
|
badgeIcon: {
|
||||||
|
marginRight: 3,
|
||||||
|
},
|
||||||
|
badgeText: {
|
||||||
|
color: '#FFD700',
|
||||||
|
fontSize: 9,
|
||||||
|
fontWeight: '800',
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
fontFamily: 'AliBold',
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#FFFFFF',
|
||||||
|
marginBottom: 4,
|
||||||
|
fontFamily: 'AliBold',
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
fontSize: 11,
|
||||||
|
color: 'rgba(255,255,255,0.9)',
|
||||||
|
marginBottom: 12,
|
||||||
|
lineHeight: 14,
|
||||||
|
fontFamily: 'AliRegular',
|
||||||
|
},
|
||||||
|
ctaButton: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 6,
|
||||||
|
borderRadius: 14,
|
||||||
|
alignSelf: 'flex-start',
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 4,
|
||||||
|
elevation: 2,
|
||||||
|
},
|
||||||
|
ctaText: {
|
||||||
|
color: '#4C3AFF',
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: '700',
|
||||||
|
marginRight: 4,
|
||||||
|
fontFamily: 'AliBold',
|
||||||
|
},
|
||||||
|
illustrationContainer: {
|
||||||
|
position: 'absolute',
|
||||||
|
right: -6,
|
||||||
|
bottom: -6,
|
||||||
|
zIndex: 1,
|
||||||
|
transform: [{ rotate: '-15deg' }]
|
||||||
|
}
|
||||||
|
});
|
||||||
405
components/MenstrualCycleCard.tsx
Normal file
405
components/MenstrualCycleCard.tsx
Normal file
@@ -0,0 +1,405 @@
|
|||||||
|
import dayjs, { Dayjs } from 'dayjs';
|
||||||
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||||
|
|
||||||
|
import { Colors } from '@/constants/Colors';
|
||||||
|
import { fetchMenstrualFlowSamples, healthDataEvents } from '@/utils/health';
|
||||||
|
import {
|
||||||
|
buildMenstrualTimeline,
|
||||||
|
convertHealthKitSamplesToCycleRecords,
|
||||||
|
CycleRecord,
|
||||||
|
DEFAULT_PERIOD_LENGTH,
|
||||||
|
MenstrualDayInfo,
|
||||||
|
MenstrualDayStatus,
|
||||||
|
MenstrualTimeline,
|
||||||
|
} from '@/utils/menstrualCycle';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onPress?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Summary = {
|
||||||
|
state: string;
|
||||||
|
prefix?: string;
|
||||||
|
suffix?: string;
|
||||||
|
number?: number;
|
||||||
|
fallbackText: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const RingIcon = () => (
|
||||||
|
<View style={styles.iconWrapper}>
|
||||||
|
<LinearGradient
|
||||||
|
colors={['#f572a7', '#f0a4ff', '#6f6ced']}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 1 }}
|
||||||
|
style={styles.iconGradient}
|
||||||
|
>
|
||||||
|
<View style={styles.iconInner} />
|
||||||
|
</LinearGradient>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const MenstrualCycleCard: React.FC<Props> = ({ onPress }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [records, setRecords] = useState<CycleRecord[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let mounted = true;
|
||||||
|
const loadMenstrualData = async () => {
|
||||||
|
// Avoid setting loading to true for background updates to prevent UI flicker
|
||||||
|
if (records.length === 0) {
|
||||||
|
setLoading(true);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const today = dayjs();
|
||||||
|
const startDate = today.subtract(3, 'month').startOf('month').toDate();
|
||||||
|
const endDate = today.add(4, 'month').endOf('month').toDate();
|
||||||
|
|
||||||
|
const samples = await fetchMenstrualFlowSamples(startDate, endDate);
|
||||||
|
if (!mounted) return;
|
||||||
|
const converted = convertHealthKitSamplesToCycleRecords(samples);
|
||||||
|
setRecords(converted);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load menstrual flow samples', error);
|
||||||
|
if (mounted) {
|
||||||
|
setRecords([]);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadMenstrualData();
|
||||||
|
|
||||||
|
// Listen for data changes
|
||||||
|
const handleDataChange = () => {
|
||||||
|
loadMenstrualData();
|
||||||
|
};
|
||||||
|
healthDataEvents.on('menstrualDataChanged', handleDataChange);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
healthDataEvents.off('menstrualDataChanged', handleDataChange);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const timeline = useMemo(
|
||||||
|
() =>
|
||||||
|
buildMenstrualTimeline({
|
||||||
|
records,
|
||||||
|
monthsBefore: 2,
|
||||||
|
monthsAfter: 4,
|
||||||
|
defaultPeriodLength: DEFAULT_PERIOD_LENGTH,
|
||||||
|
}),
|
||||||
|
[records]
|
||||||
|
);
|
||||||
|
|
||||||
|
const summary = useMemo(() => {
|
||||||
|
if (loading && records.length === 0) {
|
||||||
|
return {
|
||||||
|
state: t('menstrual.card.syncingState'),
|
||||||
|
fallbackText: t('menstrual.card.syncingDesc'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return deriveSummary(timeline, records.length > 0, t);
|
||||||
|
}, [loading, records.length, timeline, t]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity activeOpacity={0.92} onPress={onPress} style={styles.wrapper}>
|
||||||
|
<View style={styles.headerRow}>
|
||||||
|
<RingIcon />
|
||||||
|
<Text style={styles.title}>{t('menstrual.card.title')}</Text>
|
||||||
|
<View style={styles.badgeOuter}>
|
||||||
|
<View style={styles.badgeInner} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View style={styles.content}>
|
||||||
|
<Text style={styles.stateText}>{summary.state}</Text>
|
||||||
|
<Text style={styles.dayRow}>
|
||||||
|
{summary.number !== undefined ? (
|
||||||
|
<>
|
||||||
|
{summary.prefix}
|
||||||
|
<Text style={styles.dayNumber}>{summary.number}</Text>
|
||||||
|
{summary.suffix}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
summary.fallbackText
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const periodStatuses = new Set<MenstrualDayStatus>(['period', 'predicted-period']);
|
||||||
|
const fertileStatuses = new Set<MenstrualDayStatus>(['fertile', 'ovulation-day']);
|
||||||
|
const ovulationStatuses = new Set<MenstrualDayStatus>(['ovulation-day']);
|
||||||
|
|
||||||
|
const deriveSummary = (
|
||||||
|
timeline: MenstrualTimeline,
|
||||||
|
hasRecords: boolean,
|
||||||
|
t: (key: string, options?: Record<string, any>) => string
|
||||||
|
): Summary => {
|
||||||
|
const today = dayjs();
|
||||||
|
const { dayMap, todayInfo } = timeline;
|
||||||
|
|
||||||
|
if (!hasRecords || !Object.keys(dayMap).length) {
|
||||||
|
return {
|
||||||
|
state: t('menstrual.card.emptyState'),
|
||||||
|
fallbackText: t('menstrual.card.emptyDesc'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedInfos = Object.values(dayMap).sort(
|
||||||
|
(a, b) => a.date.valueOf() - b.date.valueOf()
|
||||||
|
);
|
||||||
|
|
||||||
|
const findContinuousRange = (
|
||||||
|
date: Dayjs,
|
||||||
|
targetStatuses: Set<MenstrualDayStatus>
|
||||||
|
): { start: Dayjs; end: Dayjs } | null => {
|
||||||
|
const key = date.format('YYYY-MM-DD');
|
||||||
|
if (!targetStatuses.has(dayMap[key]?.status)) return null;
|
||||||
|
|
||||||
|
let start = date;
|
||||||
|
let end = date;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const prev = start.subtract(1, 'day');
|
||||||
|
const prevInfo = dayMap[prev.format('YYYY-MM-DD')];
|
||||||
|
if (prevInfo && targetStatuses.has(prevInfo.status)) {
|
||||||
|
start = prev;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const next = end.add(1, 'day');
|
||||||
|
const nextInfo = dayMap[next.format('YYYY-MM-DD')];
|
||||||
|
if (nextInfo && targetStatuses.has(nextInfo.status)) {
|
||||||
|
end = next;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { start, end };
|
||||||
|
};
|
||||||
|
|
||||||
|
const findFutureStatus = (
|
||||||
|
targetStatuses: Set<MenstrualDayStatus>,
|
||||||
|
inclusive = true
|
||||||
|
): MenstrualDayInfo | undefined => {
|
||||||
|
return sortedInfos.find((info) => {
|
||||||
|
const isInRange = inclusive
|
||||||
|
? !info.date.isBefore(today, 'day')
|
||||||
|
: info.date.isAfter(today, 'day');
|
||||||
|
return isInRange && targetStatuses.has(info.status);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const findPastStatus = (targetStatuses: Set<MenstrualDayStatus>) => {
|
||||||
|
for (let i = sortedInfos.length - 1; i >= 0; i -= 1) {
|
||||||
|
const info = sortedInfos[i];
|
||||||
|
if (!info.date.isAfter(today, 'day') && targetStatuses.has(info.status)) {
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (todayInfo && periodStatuses.has(todayInfo.status)) {
|
||||||
|
const range = findContinuousRange(today, periodStatuses);
|
||||||
|
const end = range?.end ?? today;
|
||||||
|
const daysLeft = Math.max(end.diff(today, 'day'), 0);
|
||||||
|
|
||||||
|
if (daysLeft === 0) {
|
||||||
|
return {
|
||||||
|
state:
|
||||||
|
todayInfo.status === 'period'
|
||||||
|
? t('menstrual.card.periodState')
|
||||||
|
: t('menstrual.card.predictedPeriodState'),
|
||||||
|
fallbackText: t('menstrual.card.periodEndToday', {
|
||||||
|
date: end.format(t('menstrual.dateFormatShort')),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
state:
|
||||||
|
todayInfo.status === 'period'
|
||||||
|
? t('menstrual.card.periodState')
|
||||||
|
: t('menstrual.card.predictedPeriodState'),
|
||||||
|
prefix: t('menstrual.card.periodEndPrefix'),
|
||||||
|
number: daysLeft,
|
||||||
|
suffix: t('menstrual.card.periodEndSuffix', {
|
||||||
|
date: end.format(t('menstrual.dateFormatShort')),
|
||||||
|
}),
|
||||||
|
fallbackText: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextPeriod = findFutureStatus(periodStatuses, false);
|
||||||
|
const lastPeriodInfo = findPastStatus(periodStatuses);
|
||||||
|
const lastPeriodStart = lastPeriodInfo
|
||||||
|
? findContinuousRange(lastPeriodInfo.date, periodStatuses)?.start
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const ovulationThisCycle = sortedInfos.find((info) => {
|
||||||
|
if (!ovulationStatuses.has(info.status)) return false;
|
||||||
|
if (lastPeriodStart && info.date.isBefore(lastPeriodStart, 'day')) return false;
|
||||||
|
if (nextPeriod && !info.date.isBefore(nextPeriod.date, 'day')) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (todayInfo?.status === 'fertile') {
|
||||||
|
const targetOvulation = ovulationThisCycle ?? findFutureStatus(ovulationStatuses);
|
||||||
|
if (targetOvulation) {
|
||||||
|
const days = Math.max(targetOvulation.date.diff(today, 'day'), 0);
|
||||||
|
if (days === 0) {
|
||||||
|
return {
|
||||||
|
state: t('menstrual.card.fertileState'),
|
||||||
|
fallbackText: t('menstrual.card.ovulationToday'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
state: t('menstrual.card.fertileState'),
|
||||||
|
prefix: t('menstrual.card.ovulationCountdownPrefix'),
|
||||||
|
number: days,
|
||||||
|
suffix: t('menstrual.card.ovulationCountdownSuffix'),
|
||||||
|
fallbackText: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextFertile = findFutureStatus(fertileStatuses);
|
||||||
|
if (nextFertile && (!nextPeriod || nextFertile.date.isBefore(nextPeriod.date))) {
|
||||||
|
const days = Math.max(nextFertile.date.diff(today, 'day'), 0);
|
||||||
|
if (days === 0) {
|
||||||
|
return {
|
||||||
|
state: t('menstrual.card.fertileState'),
|
||||||
|
fallbackText: t('menstrual.card.fertileToday'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
state: t('menstrual.card.fertileState'),
|
||||||
|
prefix: t('menstrual.card.fertileCountdownPrefix'),
|
||||||
|
number: days,
|
||||||
|
suffix: t('menstrual.card.fertileCountdownSuffix'),
|
||||||
|
fallbackText: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
ovulationThisCycle &&
|
||||||
|
nextPeriod &&
|
||||||
|
today.isAfter(ovulationThisCycle.date, 'day') &&
|
||||||
|
today.isBefore(nextPeriod.date, 'day')
|
||||||
|
) {
|
||||||
|
const days = Math.max(nextPeriod.date.diff(today, 'day'), 0);
|
||||||
|
return {
|
||||||
|
state: t('menstrual.card.periodState'),
|
||||||
|
prefix: t('menstrual.card.nextPeriodPrefix'),
|
||||||
|
number: days,
|
||||||
|
suffix: t('menstrual.card.nextPeriodSuffix'),
|
||||||
|
fallbackText: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextPeriod) {
|
||||||
|
const days = Math.max(nextPeriod.date.diff(today, 'day'), 0);
|
||||||
|
return {
|
||||||
|
state: t('menstrual.card.periodState'),
|
||||||
|
prefix: t('menstrual.card.nextPeriodPrefix'),
|
||||||
|
number: days,
|
||||||
|
suffix: t('menstrual.card.nextPeriodSuffix'),
|
||||||
|
fallbackText: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
state: t('menstrual.card.emptyState'),
|
||||||
|
fallbackText: t('menstrual.card.emptyDesc'),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
wrapper: {
|
||||||
|
width: '100%',
|
||||||
|
},
|
||||||
|
headerRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 10,
|
||||||
|
},
|
||||||
|
iconWrapper: {
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
borderRadius: 12,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
iconGradient: {
|
||||||
|
width: 22,
|
||||||
|
height: 22,
|
||||||
|
borderRadius: 11,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
iconInner: {
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
borderRadius: 5,
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#192126',
|
||||||
|
fontWeight: '600',
|
||||||
|
flex: 1,
|
||||||
|
fontFamily: 'AliBold',
|
||||||
|
},
|
||||||
|
badgeOuter: {
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
borderRadius: 9,
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: '#fbcfe8',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
badgeInner: {
|
||||||
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
borderRadius: 3,
|
||||||
|
backgroundColor: Colors.light.primary,
|
||||||
|
opacity: 0.35,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
marginTop: 12,
|
||||||
|
},
|
||||||
|
stateText: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#515558',
|
||||||
|
marginBottom: 4,
|
||||||
|
fontFamily: 'AliRegular',
|
||||||
|
},
|
||||||
|
dayRow: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#192126',
|
||||||
|
fontFamily: 'AliRegular',
|
||||||
|
},
|
||||||
|
dayNumber: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#192126',
|
||||||
|
fontFamily: 'AliBold',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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,9 +1,15 @@
|
|||||||
import { Colors } from '@/constants/Colors';
|
import { Colors } from '@/constants/Colors';
|
||||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
|
import { fetchHRVSamples, HRVData } from '@/utils/health';
|
||||||
|
import { convertHrvToStressIndex, getStressLevelInfo } from '@/utils/stress';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
import React from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
Modal,
|
Modal,
|
||||||
|
Platform,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
Text,
|
Text,
|
||||||
@@ -18,18 +24,103 @@ interface StressAnalysisModalProps {
|
|||||||
updateTime: Date;
|
updateTime: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface StressStats {
|
||||||
|
percentage: number;
|
||||||
|
count: number;
|
||||||
|
range: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HistoryData {
|
||||||
|
goodEvents: StressStats;
|
||||||
|
energetic: StressStats;
|
||||||
|
stressed: StressStats;
|
||||||
|
totalSamples: number;
|
||||||
|
}
|
||||||
|
|
||||||
export function StressAnalysisModal({ visible, onClose, hrvValue, updateTime }: StressAnalysisModalProps) {
|
export function StressAnalysisModal({ visible, onClose, hrvValue, updateTime }: StressAnalysisModalProps) {
|
||||||
const colorScheme = useColorScheme();
|
const colorScheme = useColorScheme();
|
||||||
const colors = Colors[colorScheme ?? 'light'];
|
const colors = Colors[colorScheme ?? 'light'];
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [historyData, setHistoryData] = useState<HistoryData>({
|
||||||
|
goodEvents: { percentage: 0, count: 0, range: '>75毫秒' },
|
||||||
|
energetic: { percentage: 0, count: 0, range: '40-75毫秒' },
|
||||||
|
stressed: { percentage: 0, count: 0, range: '<40毫秒' },
|
||||||
|
totalSamples: 0
|
||||||
|
});
|
||||||
|
|
||||||
// 模拟30天HRV数据
|
// 当前压力状态
|
||||||
const hrvData = {
|
const stressIndex = convertHrvToStressIndex(hrvValue);
|
||||||
goodEvents: { percentage: 26, count: 53, range: '>80毫秒' },
|
const stressInfo = getStressLevelInfo(stressIndex);
|
||||||
energetic: { percentage: 47, count: 97, range: '43-80毫秒' },
|
|
||||||
stressed: { percentage: 27, count: 56, range: '<43毫秒' },
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
loadHistoryData();
|
||||||
|
}
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
|
const loadHistoryData = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const endDate = new Date();
|
||||||
|
const startDate = dayjs().subtract(30, 'day').toDate();
|
||||||
|
|
||||||
|
const samples = await fetchHRVSamples(startDate, endDate);
|
||||||
|
processHistoryData(samples);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load HRV history:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const processHistoryData = (samples: HRVData[]) => {
|
||||||
|
if (!samples.length) return;
|
||||||
|
|
||||||
|
let goodCount = 0;
|
||||||
|
let energeticCount = 0;
|
||||||
|
let stressedCount = 0;
|
||||||
|
|
||||||
|
samples.forEach(sample => {
|
||||||
|
const val = sample.value;
|
||||||
|
if (val > 75) {
|
||||||
|
goodCount++;
|
||||||
|
} else if (val >= 40) {
|
||||||
|
energeticCount++;
|
||||||
|
} else {
|
||||||
|
stressedCount++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const total = samples.length;
|
||||||
|
|
||||||
|
setHistoryData({
|
||||||
|
goodEvents: {
|
||||||
|
percentage: Math.round((goodCount / total) * 100),
|
||||||
|
count: goodCount,
|
||||||
|
range: '>75毫秒'
|
||||||
|
},
|
||||||
|
energetic: {
|
||||||
|
percentage: Math.round((energeticCount / total) * 100),
|
||||||
|
count: energeticCount,
|
||||||
|
range: '40-75毫秒'
|
||||||
|
},
|
||||||
|
stressed: {
|
||||||
|
percentage: Math.round((stressedCount / total) * 100),
|
||||||
|
count: stressedCount,
|
||||||
|
range: '<40毫秒'
|
||||||
|
},
|
||||||
|
totalSamples: total
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusColor = (level: string) => {
|
||||||
|
switch (level) {
|
||||||
|
case 'low': return '#10B981';
|
||||||
|
case 'moderate': return '#3B82F6';
|
||||||
|
case 'high': return '#F59E0B';
|
||||||
|
default: return colors.text;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
@@ -45,80 +136,139 @@ export function StressAnalysisModal({ visible, onClose, hrvValue, updateTime }:
|
|||||||
end={{ x: 0, y: 1 }}
|
end={{ x: 0, y: 1 }}
|
||||||
>
|
>
|
||||||
<ScrollView style={styles.content} showsVerticalScrollIndicator={false}>
|
<ScrollView style={styles.content} showsVerticalScrollIndicator={false}>
|
||||||
{/* 标题 */}
|
{/* 标题区域 */}
|
||||||
<Text style={styles.title}>压力情况分析</Text>
|
<Text style={styles.title}>压力分析</Text>
|
||||||
|
|
||||||
|
{/* 当前状态卡片 */}
|
||||||
|
<View style={styles.currentStatusCard}>
|
||||||
|
<View style={styles.statusHeader}>
|
||||||
|
<Text style={styles.statusLabel}>当前状态</Text>
|
||||||
|
<Text style={styles.updateTime}>更新于 {dayjs(updateTime).format('HH:mm')}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.statusValueContainer}>
|
||||||
|
<View>
|
||||||
|
<Text style={[styles.statusText, { color: getStatusColor(stressInfo.level) }]}>
|
||||||
|
{stressInfo.label}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.statusDesc}>{stressInfo.description}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.hrvValueBox}>
|
||||||
|
<Text style={styles.hrvValueLabel}>HRV</Text>
|
||||||
|
<Text style={styles.hrvValue}>{Math.round(hrvValue)}<Text style={styles.hrvUnit}>ms</Text></Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
|
||||||
{/* 最近30天HRV情况 */}
|
{/* 最近30天HRV情况 */}
|
||||||
<Text style={styles.sectionTitle}>最近30天HRV情况</Text>
|
<Text style={styles.sectionTitle}>最近30天压力分布</Text>
|
||||||
|
|
||||||
{/* 彩色横条图 */}
|
{loading ? (
|
||||||
<View style={styles.chartContainer}>
|
<ActivityIndicator size="large" color={colors.primary} style={{ marginTop: 20 }} />
|
||||||
<View style={styles.colorBar}>
|
) : (
|
||||||
<LinearGradient
|
<>
|
||||||
colors={['#F59E0B', '#3B82F6', '#10B981']}
|
{/* 彩色横条图 */}
|
||||||
start={{ x: 0, y: 0 }}
|
<View style={styles.chartContainer}>
|
||||||
end={{ x: 1, y: 0 }}
|
<View style={styles.colorBar}>
|
||||||
style={styles.gradientBar}
|
{historyData.totalSamples > 0 ? (
|
||||||
/>
|
<View style={styles.progressBarContainer}>
|
||||||
</View>
|
{historyData.stressed.percentage > 0 && (
|
||||||
<View style={styles.legend}>
|
<View style={[styles.progressSegment, { flex: historyData.stressed.percentage, backgroundColor: '#F59E0B', marginRight: 2 }]} />
|
||||||
<View style={styles.legendItem}>
|
)}
|
||||||
<View style={[styles.legendDot, { backgroundColor: '#F59E0B' }]} />
|
{historyData.energetic.percentage > 0 && (
|
||||||
<Text style={styles.legendText}>鸭梨山大</Text>
|
<View style={[styles.progressSegment, { flex: historyData.energetic.percentage, backgroundColor: '#3B82F6', marginRight: 2 }]} />
|
||||||
</View>
|
)}
|
||||||
<View style={styles.legendItem}>
|
{historyData.goodEvents.percentage > 0 && (
|
||||||
<View style={[styles.legendDot, { backgroundColor: '#3B82F6' }]} />
|
<View style={[styles.progressSegment, { flex: historyData.goodEvents.percentage, backgroundColor: '#10B981' }]} />
|
||||||
<Text style={styles.legendText}>活力满满</Text>
|
)}
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.legendItem}>
|
) : (
|
||||||
<View style={[styles.legendDot, { backgroundColor: '#10B981' }]} />
|
<View style={[styles.progressBarContainer, { backgroundColor: '#E5E7EB' }]} />
|
||||||
<Text style={styles.legendText}>好事发生</Text>
|
)}
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 数据统计卡片 */}
|
|
||||||
<View style={styles.statsCard}>
|
|
||||||
{/* 好事发生 & 活力满满 */}
|
|
||||||
<View style={styles.statsRow}>
|
|
||||||
<View style={styles.statItem}>
|
|
||||||
<Text style={[styles.statTitle, { color: '#10B981' }]}>好事发生</Text>
|
|
||||||
<Text style={styles.statPercentage}>{hrvData.goodEvents.percentage}%</Text>
|
|
||||||
<View style={styles.statDetails}>
|
|
||||||
<Text style={styles.statRange}>❤️ {hrvData.goodEvents.range}</Text>
|
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.statCount}>{hrvData.goodEvents.count}次</Text>
|
|
||||||
</View>
|
<View style={styles.legend}>
|
||||||
|
<View style={styles.legendItem}>
|
||||||
<View style={styles.statItem}>
|
<View style={[styles.legendDot, { backgroundColor: '#F59E0B' }]} />
|
||||||
<Text style={[styles.statTitle, { color: '#3B82F6' }]}>活力满满</Text>
|
<Text style={styles.legendText}>鸭梨山大</Text>
|
||||||
<Text style={styles.statPercentage}>{hrvData.energetic.percentage}%</Text>
|
</View>
|
||||||
<View style={styles.statDetails}>
|
<View style={styles.legendItem}>
|
||||||
<Text style={styles.statRange}>❤️ {hrvData.energetic.range}</Text>
|
<View style={[styles.legendDot, { backgroundColor: '#3B82F6' }]} />
|
||||||
|
<Text style={styles.legendText}>活力满满</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.legendItem}>
|
||||||
|
<View style={[styles.legendDot, { backgroundColor: '#10B981' }]} />
|
||||||
|
<Text style={styles.legendText}>好事发生</Text>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.statCount}>{hrvData.energetic.count}次</Text>
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 鸭梨山大 */}
|
{/* 数据统计卡片 */}
|
||||||
<View style={styles.statItem}>
|
<View style={styles.statsCard}>
|
||||||
<Text style={[styles.statTitle, { color: '#F59E0B' }]}>鸭梨山大</Text>
|
{/* 好事发生 & 活力满满 */}
|
||||||
<Text style={styles.statPercentage}>{hrvData.stressed.percentage}%</Text>
|
<View style={styles.statsRow}>
|
||||||
<View style={styles.statDetails}>
|
<View style={styles.statItem}>
|
||||||
<Text style={styles.statRange}>❤️ {hrvData.stressed.range}</Text>
|
<Text style={[styles.statTitle, { color: '#10B981' }]}>好事发生</Text>
|
||||||
|
<Text style={styles.statPercentage}>{historyData.goodEvents.percentage}%</Text>
|
||||||
|
<View style={styles.statDetails}>
|
||||||
|
<Text style={[styles.statRange, { color: '#10B981', backgroundColor: '#ECFDF5' }]}>
|
||||||
|
HRV {historyData.goodEvents.range}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.statCount}>{historyData.goodEvents.count}次</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.statItem}>
|
||||||
|
<Text style={[styles.statTitle, { color: '#3B82F6' }]}>活力满满</Text>
|
||||||
|
<Text style={styles.statPercentage}>{historyData.energetic.percentage}%</Text>
|
||||||
|
<View style={styles.statDetails}>
|
||||||
|
<Text style={[styles.statRange, { color: '#3B82F6', backgroundColor: '#EFF6FF' }]}>
|
||||||
|
HRV {historyData.energetic.range}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.statCount}>{historyData.energetic.count}次</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 鸭梨山大 */}
|
||||||
|
<View style={styles.statItem}>
|
||||||
|
<Text style={[styles.statTitle, { color: '#F59E0B' }]}>鸭梨山大</Text>
|
||||||
|
<Text style={styles.statPercentage}>{historyData.stressed.percentage}%</Text>
|
||||||
|
<View style={styles.statDetails}>
|
||||||
|
<Text style={[styles.statRange, { color: '#F59E0B', backgroundColor: '#FFFBEB' }]}>
|
||||||
|
HRV {historyData.stressed.range}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.statCount}>{historyData.stressed.count}次</Text>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.statCount}>{hrvData.stressed.count}次</Text>
|
</>
|
||||||
</View>
|
)}
|
||||||
</View>
|
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
{/* 底部继续按钮 */}
|
{/* 底部继续按钮 */}
|
||||||
<View style={styles.bottomContainer}>
|
<View style={styles.bottomContainer}>
|
||||||
<TouchableOpacity style={styles.continueButton} onPress={onClose}>
|
<TouchableOpacity style={styles.continueButton} onPress={onClose} activeOpacity={0.85}>
|
||||||
<View style={styles.buttonBackground}>
|
{isLiquidGlassAvailable() ? (
|
||||||
<Text style={styles.buttonText}>继续</Text>
|
<GlassView
|
||||||
</View>
|
glassEffectStyle="regular"
|
||||||
|
tintColor="rgba(139, 92, 246, 0.85)"
|
||||||
|
isInteractive={true}
|
||||||
|
style={styles.glassButton}
|
||||||
|
>
|
||||||
|
<Text style={styles.buttonText}>继续</Text>
|
||||||
|
</GlassView>
|
||||||
|
) : (
|
||||||
|
<LinearGradient
|
||||||
|
colors={['#8B5CF6', '#7C3AED']}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 0 }}
|
||||||
|
style={styles.buttonGradient}
|
||||||
|
>
|
||||||
|
<Text style={styles.buttonText}>继续</Text>
|
||||||
|
</LinearGradient>
|
||||||
|
)}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<View style={styles.homeIndicator} />
|
<View style={styles.homeIndicator} />
|
||||||
</View>
|
</View>
|
||||||
@@ -140,15 +290,78 @@ const styles = StyleSheet.create({
|
|||||||
fontWeight: '800',
|
fontWeight: '800',
|
||||||
color: '#111827',
|
color: '#111827',
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
marginTop: 20,
|
marginTop: 24,
|
||||||
marginBottom: 32,
|
marginBottom: 32,
|
||||||
|
fontFamily: 'AliBold',
|
||||||
},
|
},
|
||||||
|
currentStatusCard: {
|
||||||
sectionTitle: {
|
backgroundColor: '#FFFFFF',
|
||||||
fontSize: 22,
|
borderRadius: 20,
|
||||||
|
padding: 20,
|
||||||
|
marginBottom: 32,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowOpacity: 0.06,
|
||||||
|
shadowRadius: 12,
|
||||||
|
elevation: 4,
|
||||||
|
},
|
||||||
|
statusHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
statusLabel: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#374151',
|
||||||
|
},
|
||||||
|
updateTime: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#9CA3AF',
|
||||||
|
},
|
||||||
|
statusValueContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
statusText: {
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: '800',
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
statusDesc: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#6B7280',
|
||||||
|
maxWidth: 200,
|
||||||
|
},
|
||||||
|
hrvValueBox: {
|
||||||
|
alignItems: 'flex-end',
|
||||||
|
},
|
||||||
|
hrvValueLabel: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#9CA3AF',
|
||||||
|
fontWeight: '600',
|
||||||
|
marginBottom: 2,
|
||||||
|
},
|
||||||
|
hrvValue: {
|
||||||
|
fontSize: 32,
|
||||||
fontWeight: '800',
|
fontWeight: '800',
|
||||||
color: '#111827',
|
color: '#111827',
|
||||||
|
lineHeight: 36,
|
||||||
|
},
|
||||||
|
hrvUnit: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#6B7280',
|
||||||
|
marginLeft: 2,
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#111827',
|
||||||
marginBottom: 20,
|
marginBottom: 20,
|
||||||
|
fontFamily: 'AliBold',
|
||||||
},
|
},
|
||||||
chartContainer: {
|
chartContainer: {
|
||||||
marginBottom: 32,
|
marginBottom: 32,
|
||||||
@@ -158,6 +371,15 @@ const styles = StyleSheet.create({
|
|||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
marginBottom: 16,
|
marginBottom: 16,
|
||||||
|
backgroundColor: '#F3F4F6',
|
||||||
|
},
|
||||||
|
progressBarContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
},
|
||||||
|
progressSegment: {
|
||||||
|
height: '100%',
|
||||||
},
|
},
|
||||||
gradientBar: {
|
gradientBar: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
@@ -171,96 +393,102 @@ const styles = StyleSheet.create({
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
legendDot: {
|
legendDot: {
|
||||||
width: 12,
|
width: 10,
|
||||||
height: 12,
|
height: 10,
|
||||||
borderRadius: 6,
|
borderRadius: 5,
|
||||||
marginRight: 6,
|
marginRight: 8,
|
||||||
},
|
},
|
||||||
legendText: {
|
legendText: {
|
||||||
fontSize: 14,
|
fontSize: 13,
|
||||||
fontWeight: '600',
|
fontWeight: '500',
|
||||||
color: '#374151',
|
color: '#4B5563',
|
||||||
},
|
},
|
||||||
statsCard: {
|
statsCard: {
|
||||||
backgroundColor: '#FFFFFF',
|
backgroundColor: '#FFFFFF',
|
||||||
borderRadius: 16,
|
borderRadius: 20,
|
||||||
padding: 20,
|
padding: 24,
|
||||||
marginBottom: 32,
|
marginBottom: 32,
|
||||||
shadowColor: '#000',
|
shadowColor: '#000',
|
||||||
shadowOffset: {
|
shadowOffset: { width: 0, height: 4 },
|
||||||
width: 0,
|
shadowOpacity: 0.04,
|
||||||
height: 2,
|
shadowRadius: 12,
|
||||||
},
|
elevation: 3,
|
||||||
shadowOpacity: 0.05,
|
|
||||||
shadowRadius: 8,
|
|
||||||
elevation: 2,
|
|
||||||
},
|
},
|
||||||
statsRow: {
|
statsRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
gap: 20,
|
gap: 24,
|
||||||
marginBottom: 24,
|
marginBottom: 32,
|
||||||
},
|
},
|
||||||
statItem: {
|
statItem: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
statTitle: {
|
statTitle: {
|
||||||
fontSize: 16,
|
fontSize: 15,
|
||||||
fontWeight: '700',
|
fontWeight: '600',
|
||||||
marginBottom: 8,
|
marginBottom: 12,
|
||||||
},
|
},
|
||||||
statPercentage: {
|
statPercentage: {
|
||||||
fontSize: 36,
|
fontSize: 32,
|
||||||
fontWeight: '800',
|
fontWeight: '800',
|
||||||
color: '#111827',
|
color: '#111827',
|
||||||
marginBottom: 4,
|
marginBottom: 8,
|
||||||
|
fontFamily: 'AliBold',
|
||||||
},
|
},
|
||||||
statDetails: {
|
statDetails: {
|
||||||
marginBottom: 4,
|
marginBottom: 8,
|
||||||
},
|
},
|
||||||
statRange: {
|
statRange: {
|
||||||
fontSize: 14,
|
fontSize: 12,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
color: '#DC2626',
|
|
||||||
backgroundColor: '#FEE2E2',
|
|
||||||
paddingHorizontal: 8,
|
paddingHorizontal: 8,
|
||||||
paddingVertical: 3,
|
paddingVertical: 4,
|
||||||
borderRadius: 10,
|
borderRadius: 6,
|
||||||
alignSelf: 'flex-start',
|
alignSelf: 'flex-start',
|
||||||
|
overflow: 'hidden',
|
||||||
},
|
},
|
||||||
statCount: {
|
statCount: {
|
||||||
fontSize: 16,
|
fontSize: 13,
|
||||||
fontWeight: '600',
|
fontWeight: '500',
|
||||||
color: '#6B7280',
|
color: '#6B7280',
|
||||||
},
|
},
|
||||||
bottomContainer: {
|
bottomContainer: {
|
||||||
paddingHorizontal: 20,
|
paddingHorizontal: 20,
|
||||||
paddingBottom: 34,
|
paddingBottom: Platform.OS === 'ios' ? 34 : 20,
|
||||||
|
backgroundColor: 'transparent',
|
||||||
},
|
},
|
||||||
continueButton: {
|
continueButton: {
|
||||||
borderRadius: 25,
|
borderRadius: 28,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
marginBottom: 8,
|
marginBottom: 12,
|
||||||
|
shadowColor: '#8B5CF6',
|
||||||
|
shadowOffset: { width: 0, height: 8 },
|
||||||
|
shadowOpacity: 0.3,
|
||||||
|
shadowRadius: 16,
|
||||||
|
elevation: 8,
|
||||||
|
},
|
||||||
|
glassButton: {
|
||||||
|
paddingVertical: 18,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
flexDirection: 'row',
|
||||||
|
borderRadius: 28,
|
||||||
},
|
},
|
||||||
buttonGradient: {
|
buttonGradient: {
|
||||||
paddingVertical: 18,
|
paddingVertical: 18,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
flexDirection: 'row',
|
||||||
buttonBackground: {
|
|
||||||
backgroundColor: Colors.light.accentGreen, // 应用主色调
|
|
||||||
paddingVertical: 18,
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
},
|
},
|
||||||
buttonText: {
|
buttonText: {
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
color: '#192126', // 主色调上的文字颜色
|
color: '#FFFFFF',
|
||||||
|
letterSpacing: 0.5,
|
||||||
},
|
},
|
||||||
homeIndicator: {
|
homeIndicator: {
|
||||||
width: 134,
|
width: 134,
|
||||||
height: 5,
|
height: 5,
|
||||||
backgroundColor: '#000',
|
backgroundColor: Platform.OS === 'ios' ? 'rgba(0, 0, 0, 0.3)' : '#000',
|
||||||
borderRadius: 3,
|
borderRadius: 3,
|
||||||
alignSelf: 'center',
|
alignSelf: 'center',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -15,6 +15,7 @@ export function StressMeter({ curDate }: StressMeterProps) {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const [hrvValue, setHrvValue] = useState(0)
|
const [hrvValue, setHrvValue] = useState(0)
|
||||||
|
const [updateTime, setUpdateTime] = useState<Date>(new Date())
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -32,6 +33,9 @@ export function StressMeter({ curDate }: StressMeterProps) {
|
|||||||
|
|
||||||
if (result.hrvData) {
|
if (result.hrvData) {
|
||||||
setHrvValue(Math.round(result.hrvData.value));
|
setHrvValue(Math.round(result.hrvData.value));
|
||||||
|
if (result.hrvData.recordedAt) {
|
||||||
|
setUpdateTime(new Date(result.hrvData.recordedAt));
|
||||||
|
}
|
||||||
console.log(`StressMeter: Using ${result.message}, HRV value: ${result.hrvData.value}ms`);
|
console.log(`StressMeter: Using ${result.message}, HRV value: ${result.hrvData.value}ms`);
|
||||||
} else {
|
} else {
|
||||||
console.log('StressMeter: No HRV data obtained');
|
console.log('StressMeter: No HRV data obtained');
|
||||||
@@ -92,7 +96,7 @@ export function StressMeter({ curDate }: StressMeterProps) {
|
|||||||
{/* 渐变背景进度条 */}
|
{/* 渐变背景进度条 */}
|
||||||
<View style={[styles.progressBar, { width: '100%' }]}>
|
<View style={[styles.progressBar, { width: '100%' }]}>
|
||||||
<LinearGradient
|
<LinearGradient
|
||||||
colors={['#EF4444', '#FCD34D', '#10B981']}
|
colors={['#10B981', '#FCD34D', '#EF4444']}
|
||||||
start={{ x: 0, y: 0 }}
|
start={{ x: 0, y: 0 }}
|
||||||
end={{ x: 1, y: 0 }}
|
end={{ x: 1, y: 0 }}
|
||||||
style={styles.gradientBar}
|
style={styles.gradientBar}
|
||||||
@@ -110,7 +114,7 @@ export function StressMeter({ curDate }: StressMeterProps) {
|
|||||||
visible={showStressModal}
|
visible={showStressModal}
|
||||||
onClose={() => setShowStressModal(false)}
|
onClose={() => setShowStressModal(false)}
|
||||||
hrvValue={hrvValue}
|
hrvValue={hrvValue}
|
||||||
updateTime={new Date()}
|
updateTime={updateTime}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
132
components/health/HealthProgressRing.tsx
Normal file
132
components/health/HealthProgressRing.tsx
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import React, { useEffect, useRef } from 'react';
|
||||||
|
import { Animated, Easing, StyleSheet, Text, View } from 'react-native';
|
||||||
|
import Svg, { Circle, Defs, LinearGradient, Stop } from 'react-native-svg';
|
||||||
|
|
||||||
|
const AnimatedCircle = Animated.createAnimatedComponent(Circle);
|
||||||
|
|
||||||
|
export type HealthProgressRingProps = {
|
||||||
|
progress: number; // 0-100
|
||||||
|
size?: number;
|
||||||
|
strokeWidth?: number;
|
||||||
|
gradientColors?: string[];
|
||||||
|
label?: string;
|
||||||
|
suffix?: string;
|
||||||
|
title: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function HealthProgressRing({
|
||||||
|
progress,
|
||||||
|
size = 80,
|
||||||
|
strokeWidth = 8,
|
||||||
|
gradientColors = ['#5B4CFF', '#9B8AFB'],
|
||||||
|
label,
|
||||||
|
suffix = '%',
|
||||||
|
title,
|
||||||
|
}: HealthProgressRingProps) {
|
||||||
|
const animatedProgress = useRef(new Animated.Value(0)).current;
|
||||||
|
const radius = (size - strokeWidth) / 2;
|
||||||
|
const circumference = 2 * Math.PI * radius;
|
||||||
|
const center = size / 2;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Animated.timing(animatedProgress, {
|
||||||
|
toValue: progress,
|
||||||
|
duration: 1000,
|
||||||
|
easing: Easing.out(Easing.cubic),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}).start();
|
||||||
|
}, [progress]);
|
||||||
|
|
||||||
|
const strokeDashoffset = animatedProgress.interpolate({
|
||||||
|
inputRange: [0, 100],
|
||||||
|
outputRange: [circumference, 0],
|
||||||
|
extrapolate: 'clamp',
|
||||||
|
});
|
||||||
|
|
||||||
|
const gradientId = useRef(`grad-${Math.random().toString(36).substr(2, 9)}`).current;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<View style={{ width: size, height: size, alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<Svg width={size} height={size}>
|
||||||
|
<Defs>
|
||||||
|
<LinearGradient id={gradientId} x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<Stop offset="0" stopColor={gradientColors[0]} stopOpacity="1" />
|
||||||
|
<Stop offset="1" stopColor={gradientColors[1]} stopOpacity="1" />
|
||||||
|
</LinearGradient>
|
||||||
|
</Defs>
|
||||||
|
|
||||||
|
{/* Background Circle */}
|
||||||
|
<Circle
|
||||||
|
cx={center}
|
||||||
|
cy={center}
|
||||||
|
r={radius}
|
||||||
|
stroke="#F3F4F6"
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
fill="none"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Progress Circle */}
|
||||||
|
<AnimatedCircle
|
||||||
|
cx={center}
|
||||||
|
cy={center}
|
||||||
|
r={radius}
|
||||||
|
stroke={`url(#${gradientId})`}
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
fill="none"
|
||||||
|
strokeDasharray={circumference}
|
||||||
|
strokeDashoffset={strokeDashoffset}
|
||||||
|
strokeLinecap="round"
|
||||||
|
transform={`rotate(-90 ${center} ${center})`}
|
||||||
|
/>
|
||||||
|
</Svg>
|
||||||
|
|
||||||
|
<View style={styles.centerContent}>
|
||||||
|
<View style={styles.valueContainer}>
|
||||||
|
<Text style={styles.valueText}>{label ?? progress}</Text>
|
||||||
|
<Text style={styles.suffixText}>{suffix}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={styles.titleText}>{title}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
centerContent: {
|
||||||
|
position: 'absolute',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
valueContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'flex-end',
|
||||||
|
},
|
||||||
|
valueText: {
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#1F2937',
|
||||||
|
fontFamily: 'AliBold',
|
||||||
|
lineHeight: 24,
|
||||||
|
},
|
||||||
|
suffixText: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#6B7280',
|
||||||
|
fontWeight: '500',
|
||||||
|
marginLeft: 1,
|
||||||
|
marginBottom: 3,
|
||||||
|
fontFamily: 'AliRegular',
|
||||||
|
},
|
||||||
|
titleText: {
|
||||||
|
marginTop: 8,
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#4B5563', // gray-600
|
||||||
|
fontFamily: 'AliRegular',
|
||||||
|
},
|
||||||
|
});
|
||||||
151
components/health/MedicalRecordCard.tsx
Normal file
151
components/health/MedicalRecordCard.tsx
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
import { Image } from '@/components/ui/Image';
|
||||||
|
import { palette } from '@/constants/Colors';
|
||||||
|
import { MedicalRecordItem } from '@/services/healthProfile';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import React from 'react';
|
||||||
|
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||||
|
|
||||||
|
interface MedicalRecordCardProps {
|
||||||
|
item: MedicalRecordItem;
|
||||||
|
onPress: (item: MedicalRecordItem) => void;
|
||||||
|
onDelete: (item: MedicalRecordItem) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MedicalRecordCard: React.FC<MedicalRecordCardProps> = ({ item, onPress, onDelete }) => {
|
||||||
|
const firstAttachment = item.images && item.images.length > 0 ? item.images[0] : null;
|
||||||
|
const isPdf = firstAttachment?.toLowerCase().endsWith('.pdf');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.container}
|
||||||
|
onPress={() => onPress(item)}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
<View style={styles.thumbnailContainer}>
|
||||||
|
{firstAttachment ? (
|
||||||
|
isPdf ? (
|
||||||
|
<View style={styles.pdfThumbnail}>
|
||||||
|
<Ionicons name="document-text" size={32} color="#EF4444" />
|
||||||
|
<Text style={styles.pdfText}>PDF</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<Image
|
||||||
|
source={{ uri: firstAttachment }}
|
||||||
|
style={styles.thumbnail}
|
||||||
|
contentFit="cover"
|
||||||
|
transition={200}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<View style={styles.placeholderThumbnail}>
|
||||||
|
<Ionicons name="document-text-outline" size={32} color={palette.gray[300]} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{item.images && item.images.length > 1 && (
|
||||||
|
<View style={styles.badge}>
|
||||||
|
<Text style={styles.badgeText}>+{item.images.length - 1}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.content}>
|
||||||
|
<Text style={styles.title} numberOfLines={1}>{item.title}</Text>
|
||||||
|
<Text style={styles.date}>{dayjs(item.date).format('YYYY-MM-DD')}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.deleteButton}
|
||||||
|
onPress={() => onDelete(item)}
|
||||||
|
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||||
|
>
|
||||||
|
<Ionicons name="trash-outline" size={16} color={palette.gray[400]} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderRadius: 16,
|
||||||
|
marginBottom: 12,
|
||||||
|
shadowColor: palette.gray[200],
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.5,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 2,
|
||||||
|
overflow: 'hidden',
|
||||||
|
flexDirection: 'row',
|
||||||
|
height: 100,
|
||||||
|
},
|
||||||
|
thumbnailContainer: {
|
||||||
|
width: 100,
|
||||||
|
height: '100%',
|
||||||
|
backgroundColor: palette.gray[50],
|
||||||
|
position: 'relative',
|
||||||
|
},
|
||||||
|
thumbnail: {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
},
|
||||||
|
pdfThumbnail: {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: '#F3F4F6',
|
||||||
|
},
|
||||||
|
pdfText: {
|
||||||
|
fontSize: 10,
|
||||||
|
marginTop: 4,
|
||||||
|
color: '#EF4444',
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
placeholderThumbnail: {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: palette.gray[50],
|
||||||
|
},
|
||||||
|
badge: {
|
||||||
|
position: 'absolute',
|
||||||
|
right: 8,
|
||||||
|
bottom: 8,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.6)',
|
||||||
|
borderRadius: 10,
|
||||||
|
paddingHorizontal: 6,
|
||||||
|
paddingVertical: 2,
|
||||||
|
},
|
||||||
|
badgeText: {
|
||||||
|
color: '#FFFFFF',
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flex: 1,
|
||||||
|
padding: 12,
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: palette.gray[800],
|
||||||
|
marginBottom: 4,
|
||||||
|
fontFamily: 'AliBold',
|
||||||
|
},
|
||||||
|
date: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: palette.purple[600],
|
||||||
|
fontWeight: '500',
|
||||||
|
fontFamily: 'AliRegular',
|
||||||
|
},
|
||||||
|
deleteButton: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 8,
|
||||||
|
right: 8,
|
||||||
|
padding: 4,
|
||||||
|
},
|
||||||
|
});
|
||||||
161
components/health/tabs/BasicInfoTab.tsx
Normal file
161
components/health/tabs/BasicInfoTab.tsx
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
import { ROUTES } from '@/constants/Routes';
|
||||||
|
import { useI18n } from '@/hooks/useI18n';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import React from 'react';
|
||||||
|
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||||
|
|
||||||
|
type BasicInfoTabProps = {
|
||||||
|
healthData: {
|
||||||
|
bmi: string;
|
||||||
|
height: string;
|
||||||
|
weight: string;
|
||||||
|
waist: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function BasicInfoTab({ healthData }: BasicInfoTabProps) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const handleHeightWeightPress = () => {
|
||||||
|
router.push(ROUTES.PROFILE_EDIT);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleWaistPress = () => {
|
||||||
|
router.push('/circumference-detail');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.card}>
|
||||||
|
<Text style={styles.cardTitle}>{t('health.tabs.healthProfile.basicInfoCard.title')}</Text>
|
||||||
|
<View style={styles.metricsGrid}>
|
||||||
|
{/* BMI - Highlighted */}
|
||||||
|
<View style={styles.metricItemMain}>
|
||||||
|
<Text style={styles.metricLabelMain}>{t('health.tabs.healthProfile.basicInfoCard.bmi')}</Text>
|
||||||
|
<Text style={styles.metricValueMain}>
|
||||||
|
{healthData.bmi === '--' ? t('health.tabs.healthProfile.basicInfoCard.noData') : healthData.bmi}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Height - Clickable */}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.metricItem}
|
||||||
|
onPress={handleHeightWeightPress}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<View style={styles.metricHeaderSmall}>
|
||||||
|
<Text style={styles.metricValue}>{healthData.height}</Text>
|
||||||
|
<Ionicons name="chevron-forward" size={12} color="#9CA3AF" />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.metricLabel}>
|
||||||
|
{t('health.tabs.healthProfile.basicInfoCard.height')}/{t('health.tabs.healthProfile.basicInfoCard.heightUnit')}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{/* Weight - Clickable */}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.metricItem}
|
||||||
|
onPress={handleHeightWeightPress}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<View style={styles.metricHeaderSmall}>
|
||||||
|
<Text style={styles.metricValue}>{healthData.weight}</Text>
|
||||||
|
<Ionicons name="chevron-forward" size={12} color="#9CA3AF" />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.metricLabel}>
|
||||||
|
{t('health.tabs.healthProfile.basicInfoCard.weight')}/{t('health.tabs.healthProfile.basicInfoCard.weightUnit')}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{/* Waist - Clickable */}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.metricItem}
|
||||||
|
onPress={handleWaistPress}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<View style={styles.metricHeaderSmall}>
|
||||||
|
<Text style={styles.metricValue}>{healthData.waist}</Text>
|
||||||
|
<Ionicons name="chevron-forward" size={12} color="#9CA3AF" />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.metricLabel}>
|
||||||
|
{t('health.tabs.healthProfile.basicInfoCard.waist')}/{t('health.tabs.healthProfile.basicInfoCard.waistUnit')}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
card: {
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderRadius: 20,
|
||||||
|
padding: 20,
|
||||||
|
marginBottom: 16,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.03,
|
||||||
|
shadowRadius: 6,
|
||||||
|
elevation: 1,
|
||||||
|
},
|
||||||
|
cardTitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#1F2937',
|
||||||
|
marginBottom: 16,
|
||||||
|
fontFamily: 'AliBold',
|
||||||
|
},
|
||||||
|
metricsGrid: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
},
|
||||||
|
metricItemMain: {
|
||||||
|
flex: 1.5,
|
||||||
|
backgroundColor: '#F5F3FF',
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 12,
|
||||||
|
marginRight: 12,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
metricHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 2,
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
metricLabelMain: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#5B4CFF',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
marginBottom: 4,
|
||||||
|
fontFamily: 'AliBold',
|
||||||
|
},
|
||||||
|
metricValueMain: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: '#5B4CFF',
|
||||||
|
fontFamily: 'AliRegular',
|
||||||
|
},
|
||||||
|
metricItem: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
metricHeaderSmall: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 8,
|
||||||
|
gap: 2,
|
||||||
|
},
|
||||||
|
metricLabel: {
|
||||||
|
fontSize: 11,
|
||||||
|
color: '#6B7280',
|
||||||
|
marginBottom: 4,
|
||||||
|
fontFamily: 'AliRegular',
|
||||||
|
},
|
||||||
|
metricValue: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#1F2937',
|
||||||
|
fontWeight: '600',
|
||||||
|
fontFamily: 'AliBold',
|
||||||
|
},
|
||||||
|
});
|
||||||
49
components/health/tabs/CheckupRecordsTab.tsx
Normal file
49
components/health/tabs/CheckupRecordsTab.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import React from 'react';
|
||||||
|
import { StyleSheet, Text, View } from 'react-native';
|
||||||
|
|
||||||
|
export function CheckupRecordsTab() {
|
||||||
|
return (
|
||||||
|
<View style={styles.card}>
|
||||||
|
<View style={styles.emptyState}>
|
||||||
|
<Ionicons name="clipboard-outline" size={48} color="#E5E7EB" />
|
||||||
|
<Text style={styles.emptyText}>暂无体检记录</Text>
|
||||||
|
<Text style={styles.emptySubtext}>记录并追踪您的体检数据变化</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
card: {
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderRadius: 20,
|
||||||
|
padding: 40,
|
||||||
|
marginBottom: 16,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.03,
|
||||||
|
shadowRadius: 6,
|
||||||
|
elevation: 1,
|
||||||
|
minHeight: 200,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
emptyState: {
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
marginTop: 16,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#374151',
|
||||||
|
fontFamily: 'AliBold',
|
||||||
|
},
|
||||||
|
emptySubtext: {
|
||||||
|
marginTop: 8,
|
||||||
|
fontSize: 13,
|
||||||
|
color: '#9CA3AF',
|
||||||
|
fontFamily: 'AliRegular',
|
||||||
|
},
|
||||||
|
});
|
||||||
788
components/health/tabs/HealthHistoryTab.tsx
Normal file
788
components/health/tabs/HealthHistoryTab.tsx
Normal file
@@ -0,0 +1,788 @@
|
|||||||
|
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||||
|
import { HealthHistoryCategory } from '@/services/healthProfile';
|
||||||
|
import {
|
||||||
|
HistoryItemDetail,
|
||||||
|
fetchHealthHistory,
|
||||||
|
saveHealthHistoryCategory,
|
||||||
|
selectHealthLoading,
|
||||||
|
selectHistoryData,
|
||||||
|
updateHistoryData,
|
||||||
|
} from '@/store/healthSlice';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Alert,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Modal,
|
||||||
|
Platform,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
import DateTimePickerModal from 'react-native-modal-datetime-picker';
|
||||||
|
import { palette } from '../../../constants/Colors';
|
||||||
|
|
||||||
|
// Translation Keys for Recommendations
|
||||||
|
const RECOMMENDATION_KEYS: Record<string, string[]> = {
|
||||||
|
allergy: ['penicillin', 'sulfonamides', 'peanuts', 'seafood', 'pollen', 'dustMites', 'alcohol', 'mango'],
|
||||||
|
disease: ['hypertension', 'diabetes', 'asthma', 'heartDisease', 'gastritis', 'migraine'],
|
||||||
|
surgery: ['appendectomy', 'cesareanSection', 'tonsillectomy', 'fractureRepair', 'none'],
|
||||||
|
familyDisease: ['hypertension', 'diabetes', 'cancer', 'heartDisease', 'stroke', 'alzheimers'],
|
||||||
|
};
|
||||||
|
|
||||||
|
interface HistoryItemProps {
|
||||||
|
title: string;
|
||||||
|
categoryKey: string;
|
||||||
|
data: {
|
||||||
|
hasHistory: boolean | null;
|
||||||
|
items: HistoryItemDetail[];
|
||||||
|
};
|
||||||
|
onPress?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function HistoryItem({ title, categoryKey, data, onPress }: HistoryItemProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const translateItemName = (name: string) => {
|
||||||
|
const keys = RECOMMENDATION_KEYS[categoryKey];
|
||||||
|
if (keys && keys.includes(name)) {
|
||||||
|
return t(`health.tabs.healthProfile.history.recommendationItems.${categoryKey}.${name}`);
|
||||||
|
}
|
||||||
|
return name;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasItems = data.hasHistory === true && data.items.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.itemContainer, hasItems && styles.itemContainerWithList]}
|
||||||
|
onPress={onPress}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
{/* Header Row */}
|
||||||
|
<View style={styles.itemHeader}>
|
||||||
|
<View style={styles.itemLeft}>
|
||||||
|
<LinearGradient
|
||||||
|
colors={[palette.purple[400], palette.purple[600]]}
|
||||||
|
style={styles.indicator}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 0, y: 1 }}
|
||||||
|
/>
|
||||||
|
<Text style={styles.itemTitle}>{title}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{!hasItems && (
|
||||||
|
<Text style={[
|
||||||
|
styles.itemStatus,
|
||||||
|
(data.hasHistory === true && data.items.length === 0) || data.hasHistory === false ? styles.itemStatusActive : null
|
||||||
|
]}>
|
||||||
|
{data.hasHistory === null
|
||||||
|
? t('health.tabs.healthProfile.history.pending')
|
||||||
|
: data.hasHistory === false
|
||||||
|
? t('health.tabs.healthProfile.history.modal.none')
|
||||||
|
: t('health.tabs.healthProfile.history.modal.yesNoDetails')}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* List of Items */}
|
||||||
|
{hasItems && (
|
||||||
|
<View style={styles.subListContainer}>
|
||||||
|
{data.items.map(item => (
|
||||||
|
<View key={item.id} style={styles.subItemRow}>
|
||||||
|
<View style={styles.subItemDot} />
|
||||||
|
<Text style={styles.subItemName}>{translateItemName(item.name)}</Text>
|
||||||
|
{item.date && (
|
||||||
|
<Text style={styles.subItemDate}>
|
||||||
|
{dayjs(item.date).format('YYYY-MM-DD')}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HealthHistoryTab() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
// 从 Redux store 获取健康史数据和加载状态
|
||||||
|
const historyData = useAppSelector(selectHistoryData);
|
||||||
|
const isLoading = useAppSelector(selectHealthLoading);
|
||||||
|
|
||||||
|
// Modal State
|
||||||
|
const [modalVisible, setModalVisible] = useState(false);
|
||||||
|
const [currentType, setCurrentType] = useState<string | null>(null);
|
||||||
|
const [tempHasHistory, setTempHasHistory] = useState<boolean | null>(null);
|
||||||
|
const [tempItems, setTempItems] = useState<HistoryItemDetail[]>([]);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
|
// Date Picker State
|
||||||
|
const [isDatePickerVisible, setDatePickerVisibility] = useState(false);
|
||||||
|
const [currentEditingId, setCurrentEditingId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 初始化时从服务端获取健康史数据(如果父组件未加载)
|
||||||
|
useEffect(() => {
|
||||||
|
// 只在数据为空时才主动拉取,避免重复请求
|
||||||
|
if (!historyData || Object.keys(historyData).length === 0) {
|
||||||
|
dispatch(fetchHealthHistory());
|
||||||
|
}
|
||||||
|
}, [dispatch, historyData]);
|
||||||
|
|
||||||
|
const historyItems = [
|
||||||
|
{ title: t('health.tabs.healthProfile.history.allergy'), key: 'allergy' },
|
||||||
|
{ title: t('health.tabs.healthProfile.history.disease'), key: 'disease' },
|
||||||
|
{ title: t('health.tabs.healthProfile.history.surgery'), key: 'surgery' },
|
||||||
|
{ title: t('health.tabs.healthProfile.history.familyDisease'), key: 'familyDisease' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Helper to translate item (try to find key, fallback to item itself)
|
||||||
|
const translateItem = (type: string, item: string) => {
|
||||||
|
// Check if item is a predefined key
|
||||||
|
const keys = RECOMMENDATION_KEYS[type];
|
||||||
|
if (keys && keys.includes(item)) {
|
||||||
|
return t(`health.tabs.healthProfile.history.recommendationItems.${type}.${item}`);
|
||||||
|
}
|
||||||
|
// Fallback for manual input
|
||||||
|
return item;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Open Modal
|
||||||
|
const handleItemPress = (key: string) => {
|
||||||
|
setCurrentType(key);
|
||||||
|
const currentData = historyData[key];
|
||||||
|
setTempHasHistory(currentData.hasHistory);
|
||||||
|
// Deep copy items to avoid reference issues
|
||||||
|
setTempItems(currentData.items.map(item => ({ ...item })));
|
||||||
|
setModalVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Close Modal
|
||||||
|
const handleCloseModal = () => {
|
||||||
|
setModalVisible(false);
|
||||||
|
setCurrentType(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Save Data
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (currentType) {
|
||||||
|
// Filter out empty items
|
||||||
|
const validItems = tempItems.filter(item => item.name.trim() !== '');
|
||||||
|
|
||||||
|
// If "No" history is selected, clear items
|
||||||
|
const finalItems = tempHasHistory === false ? [] : validItems;
|
||||||
|
|
||||||
|
setIsSaving(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 先乐观更新本地状态
|
||||||
|
dispatch(updateHistoryData({
|
||||||
|
type: currentType,
|
||||||
|
data: {
|
||||||
|
hasHistory: tempHasHistory,
|
||||||
|
items: finalItems,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 同步到服务端
|
||||||
|
await dispatch(saveHealthHistoryCategory({
|
||||||
|
category: currentType as HealthHistoryCategory,
|
||||||
|
data: {
|
||||||
|
hasHistory: tempHasHistory ?? false,
|
||||||
|
items: finalItems.map(item => ({
|
||||||
|
name: item.name,
|
||||||
|
date: item.date ? dayjs(item.date).format('YYYY-MM-DD') : undefined,
|
||||||
|
isRecommendation: item.isRecommendation,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
})).unwrap();
|
||||||
|
|
||||||
|
handleCloseModal();
|
||||||
|
} catch (error: any) {
|
||||||
|
// 如果保存失败,显示错误提示(本地数据已更新,下次打开会从服务端同步)
|
||||||
|
Alert.alert(
|
||||||
|
t('health.tabs.healthProfile.history.modal.saveError') || '保存失败',
|
||||||
|
error?.message || '请稍后重试',
|
||||||
|
[{ text: t('health.tabs.healthProfile.history.modal.ok') || '确定' }]
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add Item (Manual or Recommendation)
|
||||||
|
const addItem = (name: string = '', isRecommendation: boolean = false) => {
|
||||||
|
// Avoid duplicates for recommendations if already exists
|
||||||
|
if (isRecommendation && tempItems.some(item => item.name === name)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newItem: HistoryItemDetail = {
|
||||||
|
id: Date.now().toString() + Math.random().toString(),
|
||||||
|
name,
|
||||||
|
isRecommendation
|
||||||
|
};
|
||||||
|
setTempItems([...tempItems, newItem]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Remove Item
|
||||||
|
const removeItem = (id: string) => {
|
||||||
|
setTempItems(tempItems.filter(item => item.id !== id));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update Item Name
|
||||||
|
const updateItemName = (id: string, text: string) => {
|
||||||
|
setTempItems(tempItems.map(item =>
|
||||||
|
item.id === id ? { ...item, name: text } : item
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Date Picker Handlers
|
||||||
|
const showDatePicker = (id: string) => {
|
||||||
|
setCurrentEditingId(id);
|
||||||
|
setDatePickerVisibility(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const hideDatePicker = () => {
|
||||||
|
setDatePickerVisibility(false);
|
||||||
|
setCurrentEditingId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmDate = (date: Date) => {
|
||||||
|
if (currentEditingId) {
|
||||||
|
setTempItems(tempItems.map(item =>
|
||||||
|
item.id === currentEditingId ? { ...item, date: date.toISOString() } : item
|
||||||
|
));
|
||||||
|
}
|
||||||
|
hideDatePicker();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
{/* Glow effect background */}
|
||||||
|
<View style={styles.glowContainer}>
|
||||||
|
<View style={styles.glow} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.card}>
|
||||||
|
{/* Header */}
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.headerTitle}>{t('health.tabs.healthProfile.healthHistory')}</Text>
|
||||||
|
{isLoading && <ActivityIndicator size="small" color={palette.purple[500]} />}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* List */}
|
||||||
|
<View style={styles.list}>
|
||||||
|
{historyItems.map((item) => (
|
||||||
|
<HistoryItem
|
||||||
|
key={item.key}
|
||||||
|
title={item.title}
|
||||||
|
categoryKey={item.key}
|
||||||
|
data={historyData[item.key]}
|
||||||
|
onPress={() => handleItemPress(item.key)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Edit Modal */}
|
||||||
|
<Modal
|
||||||
|
animationType="fade"
|
||||||
|
transparent={true}
|
||||||
|
visible={modalVisible}
|
||||||
|
onRequestClose={handleCloseModal}
|
||||||
|
>
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
style={styles.modalOverlay}
|
||||||
|
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||||
|
>
|
||||||
|
<View style={styles.modalContent}>
|
||||||
|
{/* Modal Header */}
|
||||||
|
<View style={styles.modalHeader}>
|
||||||
|
<Text style={styles.modalTitle}>
|
||||||
|
{currentType ? t(`health.tabs.healthProfile.history.${currentType}`) : ''}
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity onPress={handleCloseModal} style={styles.closeButton}>
|
||||||
|
<Ionicons name="close" size={24} color={palette.gray[400]} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView showsVerticalScrollIndicator={false}>
|
||||||
|
{/* Question: Do you have history? */}
|
||||||
|
<Text style={styles.questionText}>
|
||||||
|
{t('health.tabs.healthProfile.history.modal.question', {
|
||||||
|
type: currentType ? t(`health.tabs.healthProfile.history.${currentType}`) : ''
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View style={styles.radioGroup}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.radioButton,
|
||||||
|
tempHasHistory === true && styles.radioButtonActive
|
||||||
|
]}
|
||||||
|
onPress={() => setTempHasHistory(true)}
|
||||||
|
>
|
||||||
|
<Text style={[
|
||||||
|
styles.radioText,
|
||||||
|
tempHasHistory === true && styles.radioTextActive
|
||||||
|
]}>{t('health.tabs.healthProfile.history.modal.yes')}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.radioButton,
|
||||||
|
tempHasHistory === false && styles.radioButtonActive
|
||||||
|
]}
|
||||||
|
onPress={() => setTempHasHistory(false)}
|
||||||
|
>
|
||||||
|
<Text style={[
|
||||||
|
styles.radioText,
|
||||||
|
tempHasHistory === false && styles.radioTextActive
|
||||||
|
]}>{t('health.tabs.healthProfile.history.modal.no')}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Conditional Content */}
|
||||||
|
{tempHasHistory === true && currentType && (
|
||||||
|
<View style={styles.detailsContainer}>
|
||||||
|
|
||||||
|
{/* Recommendations */}
|
||||||
|
{RECOMMENDATION_KEYS[currentType] && (
|
||||||
|
<View style={styles.recommendationContainer}>
|
||||||
|
<Text style={styles.sectionLabel}>{t('health.tabs.healthProfile.history.modal.recommendations')}</Text>
|
||||||
|
<View style={styles.tagsContainer}>
|
||||||
|
{RECOMMENDATION_KEYS[currentType].map((tagKey, index) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={index}
|
||||||
|
style={styles.tag}
|
||||||
|
onPress={() => addItem(tagKey, true)}
|
||||||
|
>
|
||||||
|
<Text style={styles.tagText}>
|
||||||
|
{t(`health.tabs.healthProfile.history.recommendationItems.${currentType}.${tagKey}`)}
|
||||||
|
</Text>
|
||||||
|
<Ionicons name="add" size={16} color={palette.gray[600]} style={{ marginLeft: 4 }} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* History List Items */}
|
||||||
|
<View style={styles.listContainer}>
|
||||||
|
<Text style={styles.sectionLabel}>{t('health.tabs.healthProfile.history.modal.addDetails')}</Text>
|
||||||
|
|
||||||
|
{tempItems.map((item) => (
|
||||||
|
<View key={item.id} style={styles.listItemCard}>
|
||||||
|
<View style={styles.listItemHeader}>
|
||||||
|
<TextInput
|
||||||
|
style={styles.listItemNameInput}
|
||||||
|
placeholder={t('health.tabs.healthProfile.history.modal.namePlaceholder')}
|
||||||
|
placeholderTextColor={palette.gray[300]}
|
||||||
|
value={item.isRecommendation ? translateItem(currentType!, item.name) : item.name}
|
||||||
|
onChangeText={(text) => updateItemName(item.id, text)}
|
||||||
|
editable={!item.isRecommendation}
|
||||||
|
/>
|
||||||
|
<TouchableOpacity onPress={() => removeItem(item.id)} style={styles.deleteButton}>
|
||||||
|
<Ionicons name="trash-outline" size={20} color={palette.error[500]} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.datePickerTrigger}
|
||||||
|
onPress={() => showDatePicker(item.id)}
|
||||||
|
>
|
||||||
|
<Ionicons name="calendar-outline" size={18} color={palette.purple[500]} />
|
||||||
|
<Text style={[
|
||||||
|
styles.dateText,
|
||||||
|
!item.date && styles.placeholderText
|
||||||
|
]}>
|
||||||
|
{item.date
|
||||||
|
? dayjs(item.date).format('YYYY-MM-DD')
|
||||||
|
: t('health.tabs.healthProfile.history.modal.selectDate')}
|
||||||
|
</Text>
|
||||||
|
<Ionicons name="chevron-down" size={14} color={palette.gray[400]} style={{ marginLeft: 'auto' }} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Add Button */}
|
||||||
|
<TouchableOpacity style={styles.addItemButton} onPress={() => addItem()}>
|
||||||
|
<Ionicons name="add-circle" size={20} color={palette.purple[500]} />
|
||||||
|
<Text style={styles.addItemText}>{t('health.tabs.healthProfile.history.modal.addItem')}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
</View>
|
||||||
|
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
{/* Save Button */}
|
||||||
|
<View style={styles.modalFooter}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.saveButton, isSaving && styles.saveButtonDisabled]}
|
||||||
|
onPress={handleSave}
|
||||||
|
disabled={isSaving}
|
||||||
|
>
|
||||||
|
<LinearGradient
|
||||||
|
colors={isSaving ? [palette.gray[300], palette.gray[400]] : [palette.purple[500], palette.purple[700]]}
|
||||||
|
style={styles.saveButtonGradient}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 0 }}
|
||||||
|
>
|
||||||
|
{isSaving ? (
|
||||||
|
<ActivityIndicator size="small" color="#FFFFFF" />
|
||||||
|
) : (
|
||||||
|
<Text style={styles.saveButtonText}>{t('health.tabs.healthProfile.history.modal.save')}</Text>
|
||||||
|
)}
|
||||||
|
</LinearGradient>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<DateTimePickerModal
|
||||||
|
isVisible={isDatePickerVisible}
|
||||||
|
mode="date"
|
||||||
|
onConfirm={handleConfirmDate}
|
||||||
|
onCancel={hideDatePicker}
|
||||||
|
maximumDate={new Date()} // Cannot select future date for history
|
||||||
|
confirmTextIOS={t('health.tabs.healthProfile.history.modal.save')} // Reuse save
|
||||||
|
cancelTextIOS={t('health.tabs.healthProfile.history.modal.none') === 'None' ? 'Cancel' : '取消'} // Fallback
|
||||||
|
/>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
</Modal>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
marginBottom: 16,
|
||||||
|
position: 'relative',
|
||||||
|
},
|
||||||
|
glowContainer: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 20,
|
||||||
|
left: 20,
|
||||||
|
right: 20,
|
||||||
|
bottom: 20,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
zIndex: -1,
|
||||||
|
},
|
||||||
|
glow: {
|
||||||
|
width: '90%',
|
||||||
|
height: '90%',
|
||||||
|
backgroundColor: palette.purple[200],
|
||||||
|
opacity: 0.3,
|
||||||
|
borderRadius: 40,
|
||||||
|
transform: [{ scale: 1.05 }],
|
||||||
|
shadowColor: palette.purple[500],
|
||||||
|
shadowOffset: { width: 0, height: 0 },
|
||||||
|
shadowOpacity: 0.4,
|
||||||
|
shadowRadius: 20,
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderRadius: 24,
|
||||||
|
padding: 20,
|
||||||
|
shadowColor: palette.purple[100],
|
||||||
|
shadowOffset: { width: 0, height: 8 },
|
||||||
|
shadowOpacity: 0.6,
|
||||||
|
shadowRadius: 24,
|
||||||
|
elevation: 4,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#F5F3FF',
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 12,
|
||||||
|
paddingHorizontal: 4,
|
||||||
|
},
|
||||||
|
headerTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontFamily: 'AliBold',
|
||||||
|
color: palette.gray[900],
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
list: {
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
},
|
||||||
|
itemContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingVertical: 16,
|
||||||
|
paddingHorizontal: 4,
|
||||||
|
},
|
||||||
|
itemContainerWithList: {
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'stretch',
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
},
|
||||||
|
itemHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
width: '100%',
|
||||||
|
},
|
||||||
|
itemLeft: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
indicator: {
|
||||||
|
width: 4,
|
||||||
|
height: 14,
|
||||||
|
borderRadius: 2,
|
||||||
|
marginRight: 12,
|
||||||
|
},
|
||||||
|
itemTitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: palette.gray[700],
|
||||||
|
fontFamily: 'AliRegular',
|
||||||
|
},
|
||||||
|
itemStatus: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: palette.gray[300],
|
||||||
|
fontFamily: 'AliRegular',
|
||||||
|
textAlign: 'right',
|
||||||
|
maxWidth: 150,
|
||||||
|
},
|
||||||
|
itemStatusActive: {
|
||||||
|
color: palette.purple[600],
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
subListContainer: {
|
||||||
|
marginTop: 12,
|
||||||
|
paddingLeft: 16, // Align with title (4px indicator + 12px margin)
|
||||||
|
},
|
||||||
|
subItemRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingVertical: 6,
|
||||||
|
},
|
||||||
|
subItemDot: {
|
||||||
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
borderRadius: 3,
|
||||||
|
backgroundColor: palette.purple[300],
|
||||||
|
marginRight: 8,
|
||||||
|
},
|
||||||
|
subItemName: {
|
||||||
|
flex: 1,
|
||||||
|
fontSize: 15,
|
||||||
|
color: palette.gray[800],
|
||||||
|
fontFamily: 'AliRegular',
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
subItemDate: {
|
||||||
|
fontSize: 13,
|
||||||
|
color: palette.gray[400],
|
||||||
|
fontFamily: 'AliRegular',
|
||||||
|
},
|
||||||
|
// Modal Styles
|
||||||
|
modalOverlay: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: 20,
|
||||||
|
},
|
||||||
|
modalContent: {
|
||||||
|
width: '100%',
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderRadius: 24,
|
||||||
|
padding: 24,
|
||||||
|
maxHeight: '85%', // Increased height
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 10 },
|
||||||
|
shadowOpacity: 0.2,
|
||||||
|
shadowRadius: 20,
|
||||||
|
elevation: 10,
|
||||||
|
},
|
||||||
|
modalHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
modalTitle: {
|
||||||
|
fontSize: 20,
|
||||||
|
fontFamily: 'AliBold',
|
||||||
|
color: palette.gray[900],
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
closeButton: {
|
||||||
|
padding: 4,
|
||||||
|
},
|
||||||
|
questionText: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: palette.gray[700],
|
||||||
|
marginBottom: 12,
|
||||||
|
fontFamily: 'AliRegular',
|
||||||
|
},
|
||||||
|
radioGroup: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
marginBottom: 24,
|
||||||
|
},
|
||||||
|
radioButton: {
|
||||||
|
flex: 1,
|
||||||
|
paddingVertical: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: palette.gray[200],
|
||||||
|
borderRadius: 12,
|
||||||
|
alignItems: 'center',
|
||||||
|
marginRight: 8,
|
||||||
|
},
|
||||||
|
radioButtonActive: {
|
||||||
|
backgroundColor: palette.purple[50],
|
||||||
|
borderColor: palette.purple[500],
|
||||||
|
},
|
||||||
|
radioText: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: palette.gray[600],
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
radioTextActive: {
|
||||||
|
color: palette.purple[600],
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
detailsContainer: {
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
sectionLabel: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: palette.gray[500],
|
||||||
|
marginBottom: 12,
|
||||||
|
marginTop: 8,
|
||||||
|
fontFamily: 'AliRegular',
|
||||||
|
},
|
||||||
|
recommendationContainer: {
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
tagsContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: 10,
|
||||||
|
},
|
||||||
|
tag: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 8,
|
||||||
|
borderRadius: 20,
|
||||||
|
backgroundColor: '#F5F7FA',
|
||||||
|
},
|
||||||
|
tagText: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: palette.gray[600],
|
||||||
|
fontFamily: 'AliRegular',
|
||||||
|
},
|
||||||
|
listContainer: {
|
||||||
|
marginTop: 8,
|
||||||
|
},
|
||||||
|
listItemCard: {
|
||||||
|
backgroundColor: '#F9FAFB',
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 16,
|
||||||
|
marginBottom: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: palette.gray[100],
|
||||||
|
},
|
||||||
|
listItemHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
listItemNameInput: {
|
||||||
|
flex: 1,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: palette.gray[900],
|
||||||
|
fontFamily: 'AliBold',
|
||||||
|
padding: 0,
|
||||||
|
},
|
||||||
|
deleteButton: {
|
||||||
|
padding: 4,
|
||||||
|
},
|
||||||
|
datePickerTrigger: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 10,
|
||||||
|
borderRadius: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: palette.gray[200],
|
||||||
|
},
|
||||||
|
dateText: {
|
||||||
|
marginLeft: 8,
|
||||||
|
fontSize: 14,
|
||||||
|
color: palette.gray[900],
|
||||||
|
fontFamily: 'AliRegular',
|
||||||
|
},
|
||||||
|
placeholderText: {
|
||||||
|
color: palette.gray[400],
|
||||||
|
},
|
||||||
|
addItemButton: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingVertical: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: palette.purple[200],
|
||||||
|
borderRadius: 12,
|
||||||
|
borderStyle: 'dashed',
|
||||||
|
backgroundColor: palette.purple[25],
|
||||||
|
marginTop: 4,
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
addItemText: {
|
||||||
|
marginLeft: 8,
|
||||||
|
fontSize: 14,
|
||||||
|
color: palette.purple[600],
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
modalFooter: {
|
||||||
|
marginTop: 8,
|
||||||
|
},
|
||||||
|
saveButton: {
|
||||||
|
borderRadius: 16,
|
||||||
|
overflow: 'hidden',
|
||||||
|
shadowColor: palette.purple[500],
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowOpacity: 0.3,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 4,
|
||||||
|
},
|
||||||
|
saveButtonDisabled: {
|
||||||
|
shadowOpacity: 0,
|
||||||
|
},
|
||||||
|
saveButtonGradient: {
|
||||||
|
paddingVertical: 14,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
saveButtonText: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: '#FFFFFF',
|
||||||
|
fontWeight: '600',
|
||||||
|
fontFamily: 'AliBold',
|
||||||
|
},
|
||||||
|
});
|
||||||
666
components/health/tabs/MedicalRecordsTab.tsx
Normal file
666
components/health/tabs/MedicalRecordsTab.tsx
Normal file
@@ -0,0 +1,666 @@
|
|||||||
|
import { MedicalRecordCard } from '@/components/health/MedicalRecordCard';
|
||||||
|
import { Image } from '@/components/ui/Image';
|
||||||
|
import { palette } from '@/constants/Colors';
|
||||||
|
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||||
|
import { useCosUpload } from '@/hooks/useCosUpload';
|
||||||
|
import { MedicalRecordItem, MedicalRecordType } from '@/services/healthProfile';
|
||||||
|
import {
|
||||||
|
addNewMedicalRecord,
|
||||||
|
deleteMedicalRecordItem,
|
||||||
|
fetchMedicalRecords,
|
||||||
|
selectHealthLoading,
|
||||||
|
selectMedicalRecords,
|
||||||
|
} from '@/store/healthSlice';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import * as DocumentPicker from 'expo-document-picker';
|
||||||
|
import * as ImagePicker from 'expo-image-picker';
|
||||||
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Alert,
|
||||||
|
FlatList,
|
||||||
|
Modal,
|
||||||
|
Platform,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
import ImageViewing from 'react-native-image-viewing';
|
||||||
|
import DateTimePickerModal from 'react-native-modal-datetime-picker';
|
||||||
|
|
||||||
|
export function MedicalRecordsTab() {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const medicalRecords = useAppSelector(selectMedicalRecords);
|
||||||
|
const records = medicalRecords?.records || [];
|
||||||
|
const prescriptions = medicalRecords?.prescriptions || [];
|
||||||
|
const isLoading = useAppSelector(selectHealthLoading);
|
||||||
|
|
||||||
|
// COS 上传
|
||||||
|
const { upload: uploadToCos, uploading: isUploading } = useCosUpload({
|
||||||
|
prefix: 'images/health/medical-records'
|
||||||
|
});
|
||||||
|
|
||||||
|
const [activeTab, setActiveTab] = useState<MedicalRecordType>('medical_record');
|
||||||
|
const [isModalVisible, setModalVisible] = useState(false);
|
||||||
|
const [isDatePickerVisible, setDatePickerVisibility] = useState(false);
|
||||||
|
|
||||||
|
// Form State
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [date, setDate] = useState(new Date());
|
||||||
|
const [images, setImages] = useState<string[]>([]);
|
||||||
|
const [note, setNote] = useState('');
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// Image Viewer State
|
||||||
|
const [viewerVisible, setViewerVisible] = useState(false);
|
||||||
|
const [currentViewerImages, setCurrentViewerImages] = useState<{ uri: string }[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
dispatch(fetchMedicalRecords());
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
const currentList = activeTab === 'medical_record' ? records : prescriptions;
|
||||||
|
|
||||||
|
const handleTabPress = (tab: MedicalRecordType) => {
|
||||||
|
setActiveTab(tab);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setTitle('');
|
||||||
|
setDate(new Date());
|
||||||
|
setImages([]);
|
||||||
|
setNote('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const openAddModal = () => {
|
||||||
|
resetForm();
|
||||||
|
setModalVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePickImage = async () => {
|
||||||
|
const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||||
|
if (status !== 'granted') {
|
||||||
|
Alert.alert('需要权限', '请允许访问相册以上传图片');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await ImagePicker.launchImageLibraryAsync({
|
||||||
|
mediaTypes: ImagePicker.MediaTypeOptions.Images,
|
||||||
|
allowsEditing: true,
|
||||||
|
quality: 0.8,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.canceled && result.assets && result.assets.length > 0) {
|
||||||
|
setImages([...images, result.assets[0].uri]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTakePhoto = async () => {
|
||||||
|
const { status } = await ImagePicker.requestCameraPermissionsAsync();
|
||||||
|
if (status !== 'granted') {
|
||||||
|
Alert.alert('需要权限', '请允许访问相机以拍摄照片');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await ImagePicker.launchCameraAsync({
|
||||||
|
allowsEditing: true,
|
||||||
|
quality: 0.8,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.canceled && result.assets && result.assets.length > 0) {
|
||||||
|
setImages([...images, result.assets[0].uri]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePickDocument = async () => {
|
||||||
|
try {
|
||||||
|
const result = await DocumentPicker.getDocumentAsync({
|
||||||
|
type: ['application/pdf', 'image/*'],
|
||||||
|
copyToCacheDirectory: true,
|
||||||
|
multiple: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.canceled && result.assets && result.assets.length > 0) {
|
||||||
|
setImages([...images, result.assets[0].uri]);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error picking document:', error);
|
||||||
|
Alert.alert('错误', '选择文件失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!title.trim()) {
|
||||||
|
Alert.alert('提示', '请输入标题');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (images.length === 0) {
|
||||||
|
Alert.alert('提示', '请至少上传一张图片');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
// 1. 上传所有图片到 COS
|
||||||
|
const uploadPromises = images.map(async (uri) => {
|
||||||
|
const result = await uploadToCos({ uri });
|
||||||
|
return result.url;
|
||||||
|
});
|
||||||
|
|
||||||
|
const uploadedUrls = await Promise.all(uploadPromises);
|
||||||
|
|
||||||
|
// 2. 创建就医资料记录
|
||||||
|
await dispatch(addNewMedicalRecord({
|
||||||
|
type: activeTab,
|
||||||
|
title: title.trim(),
|
||||||
|
date: dayjs(date).format('YYYY-MM-DD'),
|
||||||
|
images: uploadedUrls,
|
||||||
|
note: note.trim() || undefined,
|
||||||
|
})).unwrap();
|
||||||
|
|
||||||
|
setModalVisible(false);
|
||||||
|
resetForm();
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('保存失败:', error);
|
||||||
|
const errorMessage = error?.message || '保存失败,请重试';
|
||||||
|
Alert.alert('错误', errorMessage);
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (item: MedicalRecordItem) => {
|
||||||
|
Alert.alert(
|
||||||
|
'确认删除',
|
||||||
|
'确定要删除这条记录吗?',
|
||||||
|
[
|
||||||
|
{ text: '取消', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: '删除',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: () => dispatch(deleteMedicalRecordItem({ id: item.id, type: item.type })),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleViewImages = (item: MedicalRecordItem) => {
|
||||||
|
if (item.images && item.images.length > 0) {
|
||||||
|
setCurrentViewerImages(item.images.map(uri => ({ uri })));
|
||||||
|
setViewerVisible(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderItem = ({ item }: { item: MedicalRecordItem }) => (
|
||||||
|
<MedicalRecordCard
|
||||||
|
item={item}
|
||||||
|
onPress={handleViewImages}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
{/* Segmented Control */}
|
||||||
|
<View style={styles.segmentContainer}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.segmentButton, activeTab === 'medical_record' && styles.segmentButtonActive]}
|
||||||
|
onPress={() => handleTabPress('medical_record')}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
<Text style={[styles.segmentText, activeTab === 'medical_record' && styles.segmentTextActive]}>
|
||||||
|
病历资料
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.segmentButton, activeTab === 'prescription' && styles.segmentButtonActive]}
|
||||||
|
onPress={() => handleTabPress('prescription')}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
<Text style={[styles.segmentText, activeTab === 'prescription' && styles.segmentTextActive]}>
|
||||||
|
处方单据
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Content List */}
|
||||||
|
<View style={styles.contentContainer}>
|
||||||
|
{isLoading && records.length === 0 && prescriptions.length === 0 ? (
|
||||||
|
<View style={styles.loadingContainer}>
|
||||||
|
<ActivityIndicator size="large" color={palette.purple[500]} />
|
||||||
|
</View>
|
||||||
|
) : currentList.length > 0 ? (
|
||||||
|
<FlatList
|
||||||
|
data={currentList}
|
||||||
|
renderItem={renderItem}
|
||||||
|
keyExtractor={(item) => item.id}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
contentContainerStyle={styles.listContent}
|
||||||
|
scrollEnabled={false} // Since it's inside a parent ScrollView
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View style={styles.emptyState}>
|
||||||
|
<View style={styles.emptyIconContainer}>
|
||||||
|
<Ionicons
|
||||||
|
name={activeTab === 'medical_record' ? "folder-open-outline" : "receipt-outline"}
|
||||||
|
size={48}
|
||||||
|
color={palette.gray[300]}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.emptyText}>
|
||||||
|
{activeTab === 'medical_record' ? '暂无病历资料' : '暂无处方单据'}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.emptySubtext}>
|
||||||
|
{activeTab === 'medical_record' ? '上传您的检查报告、诊断证明等' : '上传您的处方单、用药清单等'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Add Button */}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.fab}
|
||||||
|
onPress={openAddModal}
|
||||||
|
activeOpacity={0.9}
|
||||||
|
>
|
||||||
|
<LinearGradient
|
||||||
|
colors={[palette.purple[500], palette.purple[700]]}
|
||||||
|
style={styles.fabGradient}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 1 }}
|
||||||
|
>
|
||||||
|
<Ionicons name="add" size={28} color="#FFFFFF" />
|
||||||
|
</LinearGradient>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{/* Add/Edit Modal */}
|
||||||
|
<Modal
|
||||||
|
visible={isModalVisible}
|
||||||
|
animationType="slide"
|
||||||
|
presentationStyle="pageSheet"
|
||||||
|
onRequestClose={() => setModalVisible(false)}
|
||||||
|
>
|
||||||
|
<View style={styles.modalContainer}>
|
||||||
|
<View style={styles.modalHeader}>
|
||||||
|
<TouchableOpacity onPress={() => setModalVisible(false)} style={styles.modalCloseButton}>
|
||||||
|
<Text style={styles.modalCloseText}>取消</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text style={styles.modalTitle}>
|
||||||
|
{activeTab === 'medical_record' ? '添加病历' : '添加处方'}
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleSubmit}
|
||||||
|
style={[styles.modalSaveButton, (isSubmitting || isUploading) && styles.modalSaveButtonDisabled]}
|
||||||
|
disabled={isSubmitting || isUploading}
|
||||||
|
>
|
||||||
|
{(isSubmitting || isUploading) ? (
|
||||||
|
<ActivityIndicator size="small" color="#FFFFFF" />
|
||||||
|
) : (
|
||||||
|
<Text style={styles.modalSaveText}>保存</Text>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.formContainer}>
|
||||||
|
{/* Title Input */}
|
||||||
|
<View style={styles.inputGroup}>
|
||||||
|
<Text style={styles.label}>标题 <Text style={styles.required}>*</Text></Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
placeholder={activeTab === 'medical_record' ? "例如:血常规检查" : "例如:感冒药处方"}
|
||||||
|
value={title}
|
||||||
|
onChangeText={setTitle}
|
||||||
|
placeholderTextColor={palette.gray[400]}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Date Picker */}
|
||||||
|
<View style={styles.inputGroup}>
|
||||||
|
<Text style={styles.label}>日期</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.dateInput}
|
||||||
|
onPress={() => setDatePickerVisibility(true)}
|
||||||
|
>
|
||||||
|
<Text style={styles.dateText}>{dayjs(date).format('YYYY年MM月DD日')}</Text>
|
||||||
|
<Ionicons name="calendar-outline" size={20} color={palette.gray[500]} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Images */}
|
||||||
|
<View style={styles.inputGroup}>
|
||||||
|
<Text style={styles.label}>图片资料 <Text style={styles.required}>*</Text></Text>
|
||||||
|
<View style={styles.imageGrid}>
|
||||||
|
{images.map((uri, index) => {
|
||||||
|
const isPdf = uri.toLowerCase().endsWith('.pdf');
|
||||||
|
return (
|
||||||
|
<View key={index} style={styles.imagePreviewContainer}>
|
||||||
|
{isPdf ? (
|
||||||
|
<View style={[styles.imagePreview, styles.pdfPreview]}>
|
||||||
|
<Ionicons name="document-text" size={32} color="#EF4444" />
|
||||||
|
<Text style={styles.pdfText} numberOfLines={1}>PDF</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<Image
|
||||||
|
source={{ uri }}
|
||||||
|
style={styles.imagePreview}
|
||||||
|
contentFit="cover"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.removeImageButton}
|
||||||
|
onPress={() => setImages(images.filter((_, i) => i !== index))}
|
||||||
|
>
|
||||||
|
<Ionicons name="close-circle" size={20} color={palette.error[500]} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{images.length < 9 && (
|
||||||
|
<TouchableOpacity style={styles.addImageButton} onPress={() => {
|
||||||
|
Alert.alert(
|
||||||
|
'上传文件',
|
||||||
|
'请选择上传方式',
|
||||||
|
[
|
||||||
|
{ text: '拍照', onPress: handleTakePhoto },
|
||||||
|
{ text: '从相册选择', onPress: handlePickImage },
|
||||||
|
{ text: '选择文档 (PDF)', onPress: handlePickDocument },
|
||||||
|
{ text: '取消', style: 'cancel' },
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}}>
|
||||||
|
<Ionicons name="add" size={32} color={palette.purple[500]} />
|
||||||
|
<Text style={styles.addImageText}>上传</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Note */}
|
||||||
|
<View style={styles.inputGroup}>
|
||||||
|
<Text style={styles.label}>备注</Text>
|
||||||
|
<TextInput
|
||||||
|
style={[styles.input, styles.textArea]}
|
||||||
|
placeholder="添加备注信息..."
|
||||||
|
value={note}
|
||||||
|
onChangeText={setNote}
|
||||||
|
multiline
|
||||||
|
numberOfLines={4}
|
||||||
|
placeholderTextColor={palette.gray[400]}
|
||||||
|
textAlignVertical="top"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<DateTimePickerModal
|
||||||
|
isVisible={isDatePickerVisible}
|
||||||
|
mode="date"
|
||||||
|
onConfirm={(d) => {
|
||||||
|
setDate(d);
|
||||||
|
setDatePickerVisibility(false);
|
||||||
|
}}
|
||||||
|
onCancel={() => setDatePickerVisibility(false)}
|
||||||
|
maximumDate={new Date()}
|
||||||
|
locale="zh_CN"
|
||||||
|
confirmTextIOS="确定"
|
||||||
|
cancelTextIOS="取消"
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<ImageViewing
|
||||||
|
images={currentViewerImages}
|
||||||
|
imageIndex={0}
|
||||||
|
visible={viewerVisible}
|
||||||
|
onRequestClose={() => setViewerVisible(false)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
segmentContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
backgroundColor: '#F3F4F6',
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 4,
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
segmentButton: {
|
||||||
|
flex: 1,
|
||||||
|
paddingVertical: 10,
|
||||||
|
alignItems: 'center',
|
||||||
|
borderRadius: 10,
|
||||||
|
},
|
||||||
|
segmentButtonActive: {
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 1 },
|
||||||
|
shadowOpacity: 0.05,
|
||||||
|
shadowRadius: 2,
|
||||||
|
elevation: 1,
|
||||||
|
},
|
||||||
|
segmentText: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '500',
|
||||||
|
color: '#6B7280',
|
||||||
|
fontFamily: 'AliRegular',
|
||||||
|
},
|
||||||
|
segmentTextActive: {
|
||||||
|
color: palette.purple[600],
|
||||||
|
fontWeight: '600',
|
||||||
|
fontFamily: 'AliBold',
|
||||||
|
},
|
||||||
|
contentContainer: {
|
||||||
|
minHeight: 300,
|
||||||
|
},
|
||||||
|
loadingContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: 40,
|
||||||
|
},
|
||||||
|
listContent: {
|
||||||
|
paddingBottom: 80,
|
||||||
|
},
|
||||||
|
emptyState: {
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginTop: 40,
|
||||||
|
paddingHorizontal: 40,
|
||||||
|
},
|
||||||
|
emptyIconContainer: {
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
borderRadius: 40,
|
||||||
|
backgroundColor: '#F9FAFB',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#374151',
|
||||||
|
marginBottom: 8,
|
||||||
|
fontFamily: 'AliBold',
|
||||||
|
},
|
||||||
|
emptySubtext: {
|
||||||
|
fontSize: 13,
|
||||||
|
color: '#9CA3AF',
|
||||||
|
textAlign: 'center',
|
||||||
|
lineHeight: 20,
|
||||||
|
fontFamily: 'AliRegular',
|
||||||
|
},
|
||||||
|
fab: {
|
||||||
|
position: 'absolute',
|
||||||
|
right: 16,
|
||||||
|
bottom: 16,
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
borderRadius: 28,
|
||||||
|
shadowColor: palette.purple[500],
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowOpacity: 0.3,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 6,
|
||||||
|
},
|
||||||
|
fabGradient: {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
borderRadius: 28,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
// Modal Styles
|
||||||
|
modalContainer: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#F9FAFB',
|
||||||
|
},
|
||||||
|
modalHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 12,
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||||
|
borderBottomColor: '#E5E7EB',
|
||||||
|
paddingTop: Platform.OS === 'ios' ? 12 : 12,
|
||||||
|
},
|
||||||
|
modalCloseButton: {
|
||||||
|
padding: 8,
|
||||||
|
},
|
||||||
|
modalCloseText: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: '#6B7280',
|
||||||
|
fontFamily: 'AliRegular',
|
||||||
|
},
|
||||||
|
modalTitle: {
|
||||||
|
fontSize: 17,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#111827',
|
||||||
|
fontFamily: 'AliBold',
|
||||||
|
},
|
||||||
|
modalSaveButton: {
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 6,
|
||||||
|
backgroundColor: palette.purple[600],
|
||||||
|
borderRadius: 6,
|
||||||
|
},
|
||||||
|
modalSaveButtonDisabled: {
|
||||||
|
opacity: 0.6,
|
||||||
|
},
|
||||||
|
modalSaveText: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#FFFFFF',
|
||||||
|
fontFamily: 'AliBold',
|
||||||
|
},
|
||||||
|
formContainer: {
|
||||||
|
padding: 16,
|
||||||
|
},
|
||||||
|
inputGroup: {
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '500',
|
||||||
|
color: '#374151',
|
||||||
|
marginBottom: 8,
|
||||||
|
fontFamily: 'AliRegular',
|
||||||
|
},
|
||||||
|
required: {
|
||||||
|
color: palette.error[500],
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 12,
|
||||||
|
fontSize: 16,
|
||||||
|
color: '#111827',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#E5E7EB',
|
||||||
|
fontFamily: 'AliRegular',
|
||||||
|
},
|
||||||
|
textArea: {
|
||||||
|
height: 100,
|
||||||
|
textAlignVertical: 'top',
|
||||||
|
},
|
||||||
|
dateInput: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#E5E7EB',
|
||||||
|
},
|
||||||
|
dateText: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: '#111827',
|
||||||
|
fontFamily: 'AliRegular',
|
||||||
|
},
|
||||||
|
imageGrid: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
imagePreviewContainer: {
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
borderRadius: 8,
|
||||||
|
overflow: 'hidden',
|
||||||
|
position: 'relative',
|
||||||
|
},
|
||||||
|
imagePreview: {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
},
|
||||||
|
pdfPreview: {
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: '#F3F4F6',
|
||||||
|
},
|
||||||
|
pdfText: {
|
||||||
|
fontSize: 10,
|
||||||
|
marginTop: 4,
|
||||||
|
color: '#EF4444',
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
removeImageButton: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 2,
|
||||||
|
right: 2,
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.8)',
|
||||||
|
borderRadius: 10,
|
||||||
|
},
|
||||||
|
addImageButton: {
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
borderRadius: 8,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: palette.purple[200],
|
||||||
|
borderStyle: 'dashed',
|
||||||
|
backgroundColor: palette.purple[50],
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
addImageText: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: palette.purple[600],
|
||||||
|
marginTop: 4,
|
||||||
|
fontFamily: 'AliRegular',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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';
|
|
||||||
|
|
||||||
// 导入统一的食物类型定义
|
// 导入统一的食物类型定义
|
||||||
|
|
||||||
|
|||||||
@@ -4,12 +4,15 @@ import { useI18n } from '@/hooks/useI18n';
|
|||||||
import { formatTime, getSleepStageColor, SleepStage, type SleepSample } from '@/utils/sleepHealthKit';
|
import { formatTime, getSleepStageColor, SleepStage, type SleepSample } from '@/utils/sleepHealthKit';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
import React, { useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
import { Dimensions, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||||
import Svg, { Rect, Text as SvgText } from 'react-native-svg';
|
import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop, Text as SvgText } from 'react-native-svg';
|
||||||
|
|
||||||
import { StyleProp, ViewStyle } from 'react-native';
|
import { StyleProp, ViewStyle } from 'react-native';
|
||||||
|
|
||||||
|
const { width: SCREEN_WIDTH } = Dimensions.get('window');
|
||||||
|
|
||||||
export type SleepStageTimelineProps = {
|
export type SleepStageTimelineProps = {
|
||||||
sleepSamples: SleepSample[];
|
sleepSamples: SleepSample[];
|
||||||
bedtime: string;
|
bedtime: string;
|
||||||
@@ -31,14 +34,14 @@ export const SleepStageTimeline = ({
|
|||||||
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||||
const colorTokens = Colors[theme];
|
const colorTokens = Colors[theme];
|
||||||
|
|
||||||
// 图表尺寸参数
|
// 图表尺寸参数 - 更宽更现代的设计
|
||||||
const containerWidth = 320;
|
const containerWidth = SCREEN_WIDTH - 64; // 留出左右边距
|
||||||
const chartPadding = 25; // 左右边距,为时间标签预留空间
|
const chartPadding = 24; // 增加左右内边距,避免时间轴和标签被截断
|
||||||
const chartWidth = containerWidth - chartPadding * 2;
|
const chartWidth = containerWidth - chartPadding * 2;
|
||||||
const chartHeight = 80;
|
const chartHeight = 140; // 增加高度以容纳更高的条形图
|
||||||
const timelineHeight = 32;
|
const timelineHeight = 48; // 更高的条形图
|
||||||
const timelineY = 24;
|
const timelineY = 16;
|
||||||
const timeScaleY = timelineY + timelineHeight + 16;
|
const timeScaleY = timelineY + timelineHeight + 24;
|
||||||
|
|
||||||
// 计算时间范围和刻度
|
// 计算时间范围和刻度
|
||||||
const { timelineData, timeLabels } = useMemo(() => {
|
const { timelineData, timeLabels } = useMemo(() => {
|
||||||
@@ -64,7 +67,7 @@ export const SleepStageTimeline = ({
|
|||||||
const duration = sampleEnd.diff(sampleStart, 'minute');
|
const duration = sampleEnd.diff(sampleStart, 'minute');
|
||||||
|
|
||||||
const x = Math.max(0, (startOffset / totalMinutes) * chartWidth) + chartPadding;
|
const x = Math.max(0, (startOffset / totalMinutes) * chartWidth) + chartPadding;
|
||||||
const width = Math.max(2, (duration / totalMinutes) * chartWidth);
|
const width = Math.max(3, (duration / totalMinutes) * chartWidth);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
x,
|
x,
|
||||||
@@ -74,29 +77,27 @@ export const SleepStageTimeline = ({
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// 智能生成时间标签,避免重合
|
// 智能生成时间标签
|
||||||
const labels = [];
|
const labels = [];
|
||||||
const minLabelSpacing = 50; // 最小标签间距(像素)
|
const minLabelSpacing = 60;
|
||||||
|
|
||||||
// 总是显示起始时间
|
// 起始时间标签
|
||||||
labels.push({
|
labels.push({
|
||||||
time: startTime.format('HH:mm'),
|
time: startTime.format('HH:mm'),
|
||||||
x: chartPadding
|
x: chartPadding
|
||||||
});
|
});
|
||||||
|
|
||||||
// 根据睡眠总时长动态调整时间间隔
|
|
||||||
const sleepDurationHours = totalMinutes / 60;
|
const sleepDurationHours = totalMinutes / 60;
|
||||||
let timeStepMinutes;
|
let timeStepMinutes;
|
||||||
|
|
||||||
if (sleepDurationHours <= 4) {
|
if (sleepDurationHours <= 4) {
|
||||||
timeStepMinutes = 60; // 1小时间隔
|
timeStepMinutes = 60;
|
||||||
} else if (sleepDurationHours <= 8) {
|
} else if (sleepDurationHours <= 8) {
|
||||||
timeStepMinutes = 120; // 2小时间隔
|
timeStepMinutes = 120;
|
||||||
} else {
|
} else {
|
||||||
timeStepMinutes = 180; // 3小时间隔
|
timeStepMinutes = 180;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 添加中间时间标签,确保不重合
|
|
||||||
let currentTime = startTime;
|
let currentTime = startTime;
|
||||||
let stepCount = 1;
|
let stepCount = 1;
|
||||||
|
|
||||||
@@ -104,7 +105,6 @@ export const SleepStageTimeline = ({
|
|||||||
const stepTime = currentTime.add(timeStepMinutes * stepCount, 'minute');
|
const stepTime = currentTime.add(timeStepMinutes * stepCount, 'minute');
|
||||||
const x = (stepTime.diff(startTime, 'minute') / totalMinutes) * chartWidth + chartPadding;
|
const x = (stepTime.diff(startTime, 'minute') / totalMinutes) * chartWidth + chartPadding;
|
||||||
|
|
||||||
// 检查与前一个标签的间距
|
|
||||||
const lastLabel = labels[labels.length - 1];
|
const lastLabel = labels[labels.length - 1];
|
||||||
if (x - lastLabel.x >= minLabelSpacing && x <= containerWidth - chartPadding) {
|
if (x - lastLabel.x >= minLabelSpacing && x <= containerWidth - chartPadding) {
|
||||||
labels.push({
|
labels.push({
|
||||||
@@ -116,7 +116,7 @@ export const SleepStageTimeline = ({
|
|||||||
stepCount++;
|
stepCount++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 总是显示结束时间,但要确保与前一个标签有足够间距
|
// 结束时间标签
|
||||||
const endX = containerWidth - chartPadding;
|
const endX = containerWidth - chartPadding;
|
||||||
const lastLabel = labels[labels.length - 1];
|
const lastLabel = labels[labels.length - 1];
|
||||||
if (endX - lastLabel.x >= minLabelSpacing) {
|
if (endX - lastLabel.x >= minLabelSpacing) {
|
||||||
@@ -125,7 +125,6 @@ export const SleepStageTimeline = ({
|
|||||||
x: endX
|
x: endX
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// 如果空间不够,替换最后一个标签为结束时间
|
|
||||||
labels[labels.length - 1] = {
|
labels[labels.length - 1] = {
|
||||||
time: endTime.format('HH:mm'),
|
time: endTime.format('HH:mm'),
|
||||||
x: endX
|
x: endX
|
||||||
@@ -161,7 +160,7 @@ export const SleepStageTimeline = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.container, { backgroundColor: colorTokens.background }, style]}>
|
<View style={[styles.container, { backgroundColor: 'transparent' }, style]}>
|
||||||
{/* 标题栏 */}
|
{/* 标题栏 */}
|
||||||
{!hideHeader && (
|
{!hideHeader && (
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
@@ -176,60 +175,104 @@ export const SleepStageTimeline = ({
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 睡眠时间范围 */}
|
{/* 睡眠时间范围 - 更简洁的设计 */}
|
||||||
<View style={styles.timeRange}>
|
<View style={styles.timeRange}>
|
||||||
<View style={styles.timePoint}>
|
<View style={styles.timePoint}>
|
||||||
<Text style={[styles.timeLabel, { color: colorTokens.textSecondary }]}>
|
<Ionicons name="moon" size={16} color="#8B9DC3" style={{ marginBottom: 4 }} />
|
||||||
{t('sleepDetail.infoModalTitles.sleepTime')}
|
<Text style={[styles.timeValue, { color: '#1c1f3a' }]}>
|
||||||
</Text>
|
|
||||||
<Text style={[styles.timeValue, { color: colorTokens.text }]}>
|
|
||||||
{formatTime(bedtime)}
|
{formatTime(bedtime)}
|
||||||
</Text>
|
</Text>
|
||||||
|
<Text style={[styles.timeLabel, { color: '#8B9DC3' }]}>
|
||||||
|
{t('sleepDetail.infoModalTitles.sleepTime')}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.timePoint}>
|
<View style={styles.timePoint}>
|
||||||
<Text style={[styles.timeLabel, { color: colorTokens.textSecondary }]}>
|
<Ionicons name="sunny" size={16} color="#F59E0B" style={{ marginBottom: 4 }} />
|
||||||
{t('sleepDetail.sleepDuration')}
|
<Text style={[styles.timeValue, { color: '#1c1f3a' }]}>
|
||||||
</Text>
|
|
||||||
<Text style={[styles.timeValue, { color: colorTokens.text }]}>
|
|
||||||
{formatTime(wakeupTime)}
|
{formatTime(wakeupTime)}
|
||||||
</Text>
|
</Text>
|
||||||
|
<Text style={[styles.timeLabel, { color: '#8B9DC3' }]}>
|
||||||
|
{t('sleepDetail.sleepDuration')}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* SVG 图表 */}
|
{/* SVG 图表 - iOS 健康风格 */}
|
||||||
<View style={styles.chartContainer}>
|
<View style={styles.chartContainer}>
|
||||||
|
{/* 背景轨道 */}
|
||||||
|
<View style={[styles.trackBackground, {
|
||||||
|
left: chartPadding,
|
||||||
|
right: chartPadding,
|
||||||
|
width: chartWidth
|
||||||
|
}]} />
|
||||||
|
|
||||||
<Svg width={containerWidth} height={chartHeight}>
|
<Svg width={containerWidth} height={chartHeight}>
|
||||||
{/* 绘制睡眠阶段条形图 */}
|
<Defs>
|
||||||
{timelineData.map((segment, index) => (
|
{/* 为每种睡眠阶段定义渐变 */}
|
||||||
<Rect
|
<SvgLinearGradient id="gradDeep" x1="0" y1="0" x2="0" y2="1">
|
||||||
key={index}
|
<Stop offset="0" stopColor="#60A5FA" stopOpacity="1" />
|
||||||
x={segment.x}
|
<Stop offset="1" stopColor="#3B82F6" stopOpacity="0.85" />
|
||||||
y={timelineY}
|
</SvgLinearGradient>
|
||||||
width={segment.width}
|
<SvgLinearGradient id="gradCore" x1="0" y1="0" x2="0" y2="1">
|
||||||
height={timelineHeight}
|
<Stop offset="0" stopColor="#A78BFA" stopOpacity="1" />
|
||||||
fill={segment.color}
|
<Stop offset="1" stopColor="#8B5CF6" stopOpacity="0.85" />
|
||||||
rx={2}
|
</SvgLinearGradient>
|
||||||
/>
|
<SvgLinearGradient id="gradREM" x1="0" y1="0" x2="0" y2="1">
|
||||||
))}
|
<Stop offset="0" stopColor="#F472B6" stopOpacity="1" />
|
||||||
|
<Stop offset="1" stopColor="#EC4899" stopOpacity="0.85" />
|
||||||
|
</SvgLinearGradient>
|
||||||
|
<SvgLinearGradient id="gradAwake" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<Stop offset="0" stopColor="#FCD34D" stopOpacity="1" />
|
||||||
|
<Stop offset="1" stopColor="#F59E0B" stopOpacity="0.85" />
|
||||||
|
</SvgLinearGradient>
|
||||||
|
<SvgLinearGradient id="gradAsleep" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<Stop offset="0" stopColor="#F472B6" stopOpacity="1" />
|
||||||
|
<Stop offset="1" stopColor="#EC4899" stopOpacity="0.85" />
|
||||||
|
</SvgLinearGradient>
|
||||||
|
</Defs>
|
||||||
|
|
||||||
{/* 绘制时间刻度标签 */}
|
{/* 绘制睡眠阶段条形图 - 使用渐变和圆角 */}
|
||||||
|
{timelineData.map((segment, index) => {
|
||||||
|
const gradientId =
|
||||||
|
segment.stage === SleepStage.Deep ? 'gradDeep' :
|
||||||
|
segment.stage === SleepStage.Core ? 'gradCore' :
|
||||||
|
segment.stage === SleepStage.REM || segment.stage === SleepStage.Asleep ? 'gradREM' :
|
||||||
|
segment.stage === SleepStage.Awake ? 'gradAwake' : 'gradAsleep';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Rect
|
||||||
|
key={index}
|
||||||
|
x={segment.x}
|
||||||
|
y={timelineY}
|
||||||
|
width={segment.width}
|
||||||
|
height={timelineHeight}
|
||||||
|
fill={`url(#${gradientId})`}
|
||||||
|
rx={8}
|
||||||
|
opacity={0.95}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* 绘制时间刻度标签 - 更细腻的设计 */}
|
||||||
{timeLabels.map((label, index) => (
|
{timeLabels.map((label, index) => (
|
||||||
<React.Fragment key={index}>
|
<React.Fragment key={index}>
|
||||||
{/* 刻度线 */}
|
{/* 刻度线 */}
|
||||||
<Rect
|
<Rect
|
||||||
x={label.x - 0.5}
|
x={label.x - 0.5}
|
||||||
y={timelineY + timelineHeight}
|
y={timelineY + timelineHeight + 4}
|
||||||
width={1}
|
width={1}
|
||||||
height={6}
|
height={4}
|
||||||
fill={colorTokens.border}
|
fill="#D1D5DB"
|
||||||
|
opacity={0.4}
|
||||||
/>
|
/>
|
||||||
{/* 时间标签 */}
|
{/* 时间标签 */}
|
||||||
<SvgText
|
<SvgText
|
||||||
x={label.x}
|
x={label.x}
|
||||||
y={timeScaleY}
|
y={timeScaleY}
|
||||||
fontSize={11}
|
fontSize={11}
|
||||||
fill={colorTokens.textSecondary}
|
fill="#8B9DC3"
|
||||||
textAnchor="middle"
|
textAnchor="middle"
|
||||||
|
fontWeight="500"
|
||||||
>
|
>
|
||||||
{label.time}
|
{label.time}
|
||||||
</SvgText>
|
</SvgText>
|
||||||
@@ -238,35 +281,43 @@ export const SleepStageTimeline = ({
|
|||||||
</Svg>
|
</Svg>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* 图例 */}
|
{/* 图例 - iOS 风格的标签 */}
|
||||||
<View style={styles.legend}>
|
<View style={styles.legend}>
|
||||||
<View style={styles.legendRow}>
|
<View style={styles.legendItem}>
|
||||||
<View style={styles.legendItem}>
|
<LinearGradient
|
||||||
<View style={[styles.legendDot, { backgroundColor: getSleepStageColor(SleepStage.Deep) }]} />
|
colors={['#60A5FA', '#3B82F6']}
|
||||||
<Text style={[styles.legendText, { color: colorTokens.textSecondary }]}>
|
style={styles.legendPill}
|
||||||
{t('sleepDetail.deep')}
|
start={{ x: 0, y: 0 }}
|
||||||
</Text>
|
end={{ x: 0, y: 1 }}
|
||||||
</View>
|
/>
|
||||||
<View style={styles.legendItem}>
|
<Text style={styles.legendText}>{t('sleepDetail.deep')}</Text>
|
||||||
<View style={[styles.legendDot, { backgroundColor: getSleepStageColor(SleepStage.Core) }]} />
|
|
||||||
<Text style={[styles.legendText, { color: colorTokens.textSecondary }]}>
|
|
||||||
{t('sleepDetail.core')}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.legendRow}>
|
<View style={styles.legendItem}>
|
||||||
<View style={styles.legendItem}>
|
<LinearGradient
|
||||||
<View style={[styles.legendDot, { backgroundColor: getSleepStageColor(SleepStage.REM) }]} />
|
colors={['#A78BFA', '#8B5CF6']}
|
||||||
<Text style={[styles.legendText, { color: colorTokens.textSecondary }]}>
|
style={styles.legendPill}
|
||||||
{t('sleepDetail.rem')}
|
start={{ x: 0, y: 0 }}
|
||||||
</Text>
|
end={{ x: 0, y: 1 }}
|
||||||
</View>
|
/>
|
||||||
<View style={styles.legendItem}>
|
<Text style={styles.legendText}>{t('sleepDetail.core')}</Text>
|
||||||
<View style={[styles.legendDot, { backgroundColor: getSleepStageColor(SleepStage.Awake) }]} />
|
</View>
|
||||||
<Text style={[styles.legendText, { color: colorTokens.textSecondary }]}>
|
<View style={styles.legendItem}>
|
||||||
{t('sleepDetail.awake')}
|
<LinearGradient
|
||||||
</Text>
|
colors={['#F472B6', '#EC4899']}
|
||||||
</View>
|
style={styles.legendPill}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 0, y: 1 }}
|
||||||
|
/>
|
||||||
|
<Text style={styles.legendText}>{t('sleepDetail.rem')}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.legendItem}>
|
||||||
|
<LinearGradient
|
||||||
|
colors={['#FCD34D', '#F59E0B']}
|
||||||
|
style={styles.legendPill}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 0, y: 1 }}
|
||||||
|
/>
|
||||||
|
<Text style={styles.legendText}>{t('sleepDetail.awake')}</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -276,14 +327,8 @@ export const SleepStageTimeline = ({
|
|||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
borderRadius: 16,
|
borderRadius: 16,
|
||||||
padding: 16,
|
paddingVertical: 20,
|
||||||
marginBottom: 24,
|
paddingHorizontal: 16,
|
||||||
shadowColor: '#000',
|
|
||||||
shadowOffset: { width: 0, height: 2 },
|
|
||||||
shadowOpacity: 0.1,
|
|
||||||
shadowRadius: 8,
|
|
||||||
elevation: 3,
|
|
||||||
marginHorizontal: 4,
|
|
||||||
},
|
},
|
||||||
header: {
|
header: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@@ -294,31 +339,44 @@ const styles = StyleSheet.create({
|
|||||||
title: {
|
title: {
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
|
fontFamily: 'AliBold',
|
||||||
},
|
},
|
||||||
infoButton: {
|
infoButton: {
|
||||||
padding: 4,
|
padding: 4,
|
||||||
},
|
},
|
||||||
timeRange: {
|
timeRange: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-around',
|
||||||
marginBottom: 20,
|
marginBottom: 28,
|
||||||
|
paddingHorizontal: 20,
|
||||||
},
|
},
|
||||||
timePoint: {
|
timePoint: {
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
gap: 2,
|
||||||
},
|
},
|
||||||
timeLabel: {
|
timeLabel: {
|
||||||
fontSize: 12,
|
fontSize: 11,
|
||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
marginBottom: 4,
|
fontFamily: 'AliRegular',
|
||||||
},
|
},
|
||||||
timeValue: {
|
timeValue: {
|
||||||
fontSize: 16,
|
fontSize: 20,
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
letterSpacing: -0.2,
|
fontFamily: 'AliBold',
|
||||||
|
letterSpacing: -0.5,
|
||||||
},
|
},
|
||||||
chartContainer: {
|
chartContainer: {
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
marginBottom: 16,
|
marginBottom: 20,
|
||||||
|
position: 'relative',
|
||||||
|
},
|
||||||
|
trackBackground: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 16,
|
||||||
|
height: 48,
|
||||||
|
backgroundColor: '#F0F2F9',
|
||||||
|
borderRadius: 24,
|
||||||
|
opacity: 0.5,
|
||||||
},
|
},
|
||||||
emptyState: {
|
emptyState: {
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -327,27 +385,29 @@ const styles = StyleSheet.create({
|
|||||||
emptyText: {
|
emptyText: {
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontStyle: 'italic',
|
fontStyle: 'italic',
|
||||||
|
fontFamily: 'AliRegular',
|
||||||
},
|
},
|
||||||
legend: {
|
legend: {
|
||||||
gap: 8,
|
|
||||||
},
|
|
||||||
legendRow: {
|
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
gap: 24,
|
flexWrap: 'wrap',
|
||||||
|
gap: 16,
|
||||||
|
paddingTop: 8,
|
||||||
},
|
},
|
||||||
legendItem: {
|
legendItem: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 6,
|
gap: 6,
|
||||||
},
|
},
|
||||||
legendDot: {
|
legendPill: {
|
||||||
width: 8,
|
width: 20,
|
||||||
height: 8,
|
height: 10,
|
||||||
borderRadius: 4,
|
borderRadius: 5,
|
||||||
},
|
},
|
||||||
legendText: {
|
legendText: {
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
|
color: '#6B7280',
|
||||||
|
fontFamily: 'AliRegular',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -34,4 +35,4 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default HeartRateCard;
|
export default HeartRateCard;
|
||||||
|
|||||||
@@ -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,8 +8,8 @@ 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 React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
@@ -20,18 +21,26 @@ import {
|
|||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
View
|
View
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import Svg, { Circle, Path } from 'react-native-svg';
|
import Svg, { Circle, Defs, Path, Stop, LinearGradient as SvgLinearGradient } from 'react-native-svg';
|
||||||
|
import { WeightProgressBar } from './WeightProgressBar';
|
||||||
|
|
||||||
const { width: screenWidth } = Dimensions.get('window');
|
const { width: screenWidth } = Dimensions.get('window');
|
||||||
const CARD_WIDTH = screenWidth - 40; // Subtract left and right margins
|
const CARD_WIDTH = screenWidth - 40;
|
||||||
const CHART_WIDTH = CARD_WIDTH - 36; // Subtract card padding
|
const CHART_WIDTH = CARD_WIDTH - 36;
|
||||||
const CHART_HEIGHT = 60;
|
const CHART_HEIGHT = 70;
|
||||||
const PADDING = 10;
|
const PADDING = 10;
|
||||||
|
|
||||||
|
// 主题色
|
||||||
|
const THEME_PRIMARY = '#4F5BD5';
|
||||||
|
const THEME_SECONDARY = '#6B6CFF';
|
||||||
|
const THEME_SUCCESS = '#22C55E';
|
||||||
|
const THEME_TEXT_PRIMARY = '#1c1f3a';
|
||||||
|
const THEME_TEXT_SECONDARY = '#6f7ba7';
|
||||||
|
|
||||||
export function WeightHistoryCard() {
|
export function WeightHistoryCard() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
const router = useRouter();
|
||||||
const userProfile = useAppSelector((s) => s.user.profile);
|
const userProfile = useAppSelector((s) => s.user.profile);
|
||||||
const weightHistory = useAppSelector((s) => s.user.weightHistory);
|
const weightHistory = useAppSelector((s) => s.user.weightHistory);
|
||||||
|
|
||||||
@@ -44,7 +53,6 @@ export function WeightHistoryCard() {
|
|||||||
|
|
||||||
const hasWeight = userProfile?.weight && parseFloat(userProfile.weight) > 0;
|
const hasWeight = userProfile?.weight && parseFloat(userProfile.weight) > 0;
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isLoggedIn) {
|
if (isLoggedIn) {
|
||||||
loadWeightHistory();
|
loadWeightHistory();
|
||||||
@@ -59,7 +67,8 @@ export function WeightHistoryCard() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const navigateToCoach = () => {
|
// 点击添加按钮 - 需要登录
|
||||||
|
const handleAddWeight = () => {
|
||||||
pushIfAuthedElseLogin(ROUTES.WEIGHT_RECORDS);
|
pushIfAuthedElseLogin(ROUTES.WEIGHT_RECORDS);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -67,85 +76,97 @@ export function WeightHistoryCard() {
|
|||||||
setShowBMIModal(false);
|
setShowBMIModal(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 点击卡片 - 直接跳转,不需要登录
|
||||||
const navigateToWeightRecords = () => {
|
const navigateToWeightRecords = () => {
|
||||||
pushIfAuthedElseLogin(ROUTES.WEIGHT_RECORDS);
|
router.push(ROUTES.WEIGHT_RECORDS);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
// Process weight history data
|
// Process weight history data
|
||||||
const sortedHistory = [...weightHistory]
|
const sortedHistory = [...weightHistory]
|
||||||
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
|
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
|
||||||
.slice(-7); // Show only the last 7 records
|
.slice(-7);
|
||||||
|
|
||||||
// return (
|
// 是否有数据
|
||||||
// <TouchableOpacity style={styles.card} onPress={navigateToWeightRecords} activeOpacity={0.8}>
|
const hasData = sortedHistory.length > 0;
|
||||||
// <View style={styles.cardHeader}>
|
|
||||||
// <Text style={styles.cardTitle}>{t('statistics.components.weight.title')}</Text>
|
|
||||||
// </View>
|
|
||||||
|
|
||||||
// <View style={styles.emptyContent}>
|
// 计算减重进度
|
||||||
// <Text style={styles.emptyDescription}>
|
const currentWeight = userProfile?.weight ? parseFloat(userProfile.weight) : 0;
|
||||||
// No weight records yet, click the button below to start recording
|
const initialWeight = userProfile?.initialWeight
|
||||||
// </Text>
|
? parseFloat(userProfile.initialWeight)
|
||||||
// <TouchableOpacity
|
: (sortedHistory.length > 0 ? parseFloat(sortedHistory[0].weight) : 0);
|
||||||
// style={styles.recordButton}
|
const targetWeight = userProfile?.targetWeight ? parseFloat(userProfile.targetWeight) : 0;
|
||||||
// onPress={(e) => {
|
|
||||||
// e.stopPropagation();
|
// 计算进度百分比
|
||||||
// navigateToCoach();
|
const hasTargetWeight = targetWeight > 0 && initialWeight > targetWeight;
|
||||||
// }}
|
const totalToLose = initialWeight - targetWeight;
|
||||||
// activeOpacity={0.8}
|
const actualLost = initialWeight - currentWeight;
|
||||||
// >
|
const weightProgress = hasTargetWeight && totalToLose > 0 ? actualLost / totalToLose : 0;
|
||||||
// <Ionicons name="add" size={18} color="#FFFFFF" />
|
|
||||||
// <Text style={styles.recordButtonText}>{t('statistics.components.weight.addButton')}</Text>
|
|
||||||
// </TouchableOpacity>
|
|
||||||
// </View>
|
|
||||||
// </TouchableOpacity>
|
|
||||||
// );
|
|
||||||
// }
|
|
||||||
|
|
||||||
// Generate chart data
|
// Generate chart data
|
||||||
const weights = sortedHistory.map(item => parseFloat(item.weight));
|
const weights = hasData ? sortedHistory.map(item => parseFloat(item.weight)) : [];
|
||||||
const minWeight = Math.min(...weights);
|
const minWeight = hasData ? Math.min(...weights) : 0;
|
||||||
const maxWeight = Math.max(...weights);
|
const maxWeight = hasData ? Math.max(...weights) : 0;
|
||||||
const weightRange = maxWeight - minWeight || 1;
|
const weightRange = maxWeight - minWeight || 1;
|
||||||
|
|
||||||
const points = sortedHistory.map((item, index) => {
|
const points = hasData ? sortedHistory.map((item, index) => {
|
||||||
const x = PADDING + (index / Math.max(sortedHistory.length - 1, 1)) * (CHART_WIDTH - 2 * PADDING);
|
const x = PADDING + (index / Math.max(sortedHistory.length - 1, 1)) * (CHART_WIDTH - 2 * PADDING);
|
||||||
const normalizedWeight = (parseFloat(item.weight) - minWeight) / weightRange;
|
const normalizedWeight = (parseFloat(item.weight) - minWeight) / weightRange;
|
||||||
// Reduce top margin, compress whitespace
|
const y = PADDING + 10 + (1 - normalizedWeight) * (CHART_HEIGHT - 2 * PADDING - 20);
|
||||||
const y = PADDING + 8 + (1 - normalizedWeight) * (CHART_HEIGHT - 2 * PADDING - 16);
|
|
||||||
return { x, y, weight: item.weight, date: item.createdAt };
|
return { x, y, weight: item.weight, date: item.createdAt };
|
||||||
});
|
}) : [];
|
||||||
|
|
||||||
// Generate path
|
// 生成平滑曲线路径(使用贝塞尔曲线)
|
||||||
const pathData = points.map((point, index) => {
|
const generateSmoothPath = (pts: typeof points) => {
|
||||||
if (index === 0) return `M ${point.x} ${point.y}`;
|
if (pts.length === 0) return '';
|
||||||
return `L ${point.x} ${point.y}`;
|
if (pts.length === 1) return `M ${pts[0].x} ${pts[0].y}`;
|
||||||
}).join(' ');
|
|
||||||
|
|
||||||
// If there's only one data point, display as a horizontal line
|
let path = `M ${pts[0].x} ${pts[0].y}`;
|
||||||
const singlePointPath = points.length === 1 ?
|
|
||||||
`M ${PADDING} ${points[0].y} L ${CHART_WIDTH - PADDING} ${points[0].y}` :
|
for (let i = 0; i < pts.length - 1; i++) {
|
||||||
pathData;
|
const p0 = pts[Math.max(0, i - 1)];
|
||||||
|
const p1 = pts[i];
|
||||||
|
const p2 = pts[i + 1];
|
||||||
|
const p3 = pts[Math.min(pts.length - 1, i + 2)];
|
||||||
|
|
||||||
|
const cp1x = p1.x + (p2.x - p0.x) / 6;
|
||||||
|
const cp1y = p1.y + (p2.y - p0.y) / 6;
|
||||||
|
const cp2x = p2.x - (p3.x - p1.x) / 6;
|
||||||
|
const cp2y = p2.y - (p3.y - p1.y) / 6;
|
||||||
|
|
||||||
|
path += ` C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${p2.x} ${p2.y}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return path;
|
||||||
|
};
|
||||||
|
|
||||||
|
const smoothPath = generateSmoothPath(points);
|
||||||
|
const singlePointPath = points.length === 1
|
||||||
|
? `M ${PADDING} ${points[0].y} L ${CHART_WIDTH - PADDING} ${points[0].y}`
|
||||||
|
: smoothPath;
|
||||||
|
|
||||||
|
// 空状态下的占位曲线路径(水平虚线效果)
|
||||||
|
const emptyLinePath = `M ${PADDING} ${CHART_HEIGHT / 2} L ${CHART_WIDTH - PADDING} ${CHART_HEIGHT / 2}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity style={styles.card} onPress={navigateToWeightRecords} activeOpacity={0.8}>
|
<TouchableOpacity style={styles.card} onPress={navigateToWeightRecords} activeOpacity={0.8}>
|
||||||
<View style={styles.cardHeader}>
|
<View style={styles.cardHeader}>
|
||||||
<Image
|
<View style={styles.iconContainer}>
|
||||||
source={require('@/assets/images/icons/icon-weight.png')}
|
<Image
|
||||||
style={styles.iconSquare}
|
source={require('@/assets/images/icons/icon-weight.png')}
|
||||||
/>
|
style={styles.iconSquare}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
<Text style={styles.cardTitle}>{t('statistics.components.weight.title')}</Text>
|
<Text style={styles.cardTitle}>{t('statistics.components.weight.title')}</Text>
|
||||||
{isLgAvaliable ? (
|
{isLgAvaliable ? (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={(e) => {
|
onPress={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
navigateToCoach();
|
handleAddWeight();
|
||||||
}}
|
}}
|
||||||
activeOpacity={0.8}
|
activeOpacity={0.8}
|
||||||
>
|
>
|
||||||
<GlassView style={styles.addButtonGlass}>
|
<GlassView style={styles.addButtonGlass}>
|
||||||
<Ionicons name="add" size={18} color={Colors.light.primary} />
|
<Ionicons name="add" size={18} color={THEME_PRIMARY} />
|
||||||
</GlassView>
|
</GlassView>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
) : (
|
) : (
|
||||||
@@ -153,68 +174,125 @@ export function WeightHistoryCard() {
|
|||||||
style={styles.addButton}
|
style={styles.addButton}
|
||||||
onPress={(e) => {
|
onPress={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
navigateToCoach();
|
handleAddWeight();
|
||||||
}}
|
}}
|
||||||
activeOpacity={0.8}
|
activeOpacity={0.8}
|
||||||
>
|
>
|
||||||
<Ionicons name="add" size={18} color={Colors.light.primary} />
|
<Ionicons name="add" size={18} color={THEME_PRIMARY} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Default chart display */}
|
{/* 当前体重显示 */}
|
||||||
{sortedHistory.length > 0 && (
|
<View style={styles.currentWeightSection}>
|
||||||
<View style={styles.chartContainer}>
|
<View style={styles.weightValueContainer}>
|
||||||
<Svg width={CHART_WIDTH} height={CHART_HEIGHT + 15}>
|
<Text style={styles.weightValue}>{hasWeight ? currentWeight.toFixed(1) : '--'}</Text>
|
||||||
{/* Background grid lines */}
|
<Text style={styles.weightUnit}>kg</Text>
|
||||||
|
</View>
|
||||||
|
{sortedHistory.length > 1 && (
|
||||||
|
<View style={[
|
||||||
|
styles.changeTag,
|
||||||
|
{ backgroundColor: actualLost >= 0 ? 'rgba(34, 197, 94, 0.1)' : 'rgba(255, 107, 107, 0.1)' }
|
||||||
|
]}>
|
||||||
|
<Ionicons
|
||||||
|
name={actualLost >= 0 ? 'trending-down' : 'trending-up'}
|
||||||
|
size={12}
|
||||||
|
color={actualLost >= 0 ? THEME_SUCCESS : '#FF6B6B'}
|
||||||
|
/>
|
||||||
|
<Text style={[
|
||||||
|
styles.changeText,
|
||||||
|
{ color: actualLost >= 0 ? THEME_SUCCESS : '#FF6B6B' }
|
||||||
|
]}>
|
||||||
|
{actualLost >= 0 ? '-' : '+'}{Math.abs(actualLost).toFixed(1)}kg
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
{/* More abstract line - reduce line width and display details */}
|
{/* 图表显示 */}
|
||||||
|
<View style={styles.chartContainer}>
|
||||||
|
<Svg width={CHART_WIDTH} height={CHART_HEIGHT + 15}>
|
||||||
|
<Defs>
|
||||||
|
<SvgLinearGradient id="lineGradient" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<Stop offset="0%" stopColor={THEME_PRIMARY} stopOpacity="1" />
|
||||||
|
<Stop offset="100%" stopColor={THEME_SECONDARY} stopOpacity="1" />
|
||||||
|
</SvgLinearGradient>
|
||||||
|
</Defs>
|
||||||
|
|
||||||
|
{hasData ? (
|
||||||
|
<>
|
||||||
|
{/* 平滑曲线 */}
|
||||||
|
<Path
|
||||||
|
d={singlePointPath}
|
||||||
|
stroke="url(#lineGradient)"
|
||||||
|
strokeWidth={2.5}
|
||||||
|
fill="none"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 数据点 */}
|
||||||
|
{points.map((point, index) => {
|
||||||
|
const isLastPoint = index === points.length - 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment key={index}>
|
||||||
|
{/* 外圈光晕 */}
|
||||||
|
{isLastPoint && (
|
||||||
|
<Circle
|
||||||
|
cx={point.x}
|
||||||
|
cy={point.y}
|
||||||
|
r={8}
|
||||||
|
fill={THEME_PRIMARY}
|
||||||
|
opacity={0.15}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{/* 数据点 */}
|
||||||
|
<Circle
|
||||||
|
cx={point.x}
|
||||||
|
cy={point.y}
|
||||||
|
r={isLastPoint ? 4 : 2.5}
|
||||||
|
fill={isLastPoint ? THEME_PRIMARY : THEME_SECONDARY}
|
||||||
|
stroke={isLastPoint ? '#ffffff' : 'none'}
|
||||||
|
strokeWidth={isLastPoint ? 2 : 0}
|
||||||
|
/>
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
/* 空状态 - 虚线占位 */
|
||||||
<Path
|
<Path
|
||||||
d={singlePointPath}
|
d={emptyLinePath}
|
||||||
stroke={Colors.light.accentGreen}
|
stroke="#E8EAF0"
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
fill="none"
|
fill="none"
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeDasharray="8,6"
|
||||||
opacity={0.8}
|
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
</Svg>
|
||||||
|
|
||||||
{/* Simplified data points - smaller and more refined */}
|
{/* 图表信息 */}
|
||||||
{points.map((point, index) => {
|
<View style={styles.chartInfo}>
|
||||||
const isLastPoint = index === points.length - 1;
|
<View style={styles.infoItem}>
|
||||||
|
<Text style={styles.infoLabel}>{hasData ? sortedHistory.length : '--'}{t('statistics.components.weight.days')}</Text>
|
||||||
return (
|
</View>
|
||||||
<React.Fragment key={index}>
|
<View style={styles.infoItem}>
|
||||||
<Circle
|
<Text style={styles.infoLabel}>
|
||||||
cx={point.x}
|
{hasData ? `${minWeight.toFixed(1)}-${maxWeight.toFixed(1)}kg` : '--'}
|
||||||
cy={point.y}
|
</Text>
|
||||||
r={isLastPoint ? 3 : 2}
|
|
||||||
fill={Colors.light.accentGreen}
|
|
||||||
opacity={0.9}
|
|
||||||
/>
|
|
||||||
</React.Fragment>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
|
|
||||||
</Svg>
|
|
||||||
|
|
||||||
{/* Concise chart information */}
|
|
||||||
<View style={styles.chartInfo}>
|
|
||||||
<View style={styles.infoItem}>
|
|
||||||
<Text style={styles.infoLabel}>{userProfile.weight}kg</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.infoItem}>
|
|
||||||
<Text style={styles.infoLabel}>{sortedHistory.length}{t('statistics.components.weight.days')}</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.infoItem}>
|
|
||||||
<Text style={styles.infoLabel}>
|
|
||||||
{minWeight.toFixed(1)}-{maxWeight.toFixed(1)}kg
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
)}
|
</View>
|
||||||
|
|
||||||
|
{/* 减重进度条 - 始终显示 */}
|
||||||
|
<WeightProgressBar
|
||||||
|
progress={weightProgress}
|
||||||
|
currentWeight={currentWeight}
|
||||||
|
targetWeight={targetWeight}
|
||||||
|
initialWeight={initialWeight}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* BMI information modal */}
|
{/* BMI information modal */}
|
||||||
<Modal
|
<Modal
|
||||||
@@ -323,32 +401,38 @@ export function WeightHistoryCard() {
|
|||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
card: {
|
card: {
|
||||||
backgroundColor: '#FFFFFF',
|
backgroundColor: '#FFFFFF',
|
||||||
borderRadius: 22,
|
borderRadius: 24,
|
||||||
padding: 16,
|
padding: 18,
|
||||||
shadowColor: '#000',
|
shadowColor: 'rgba(30, 41, 59, 0.08)',
|
||||||
shadowOffset: { width: 0, height: 2 },
|
shadowOffset: { width: 0, height: 4 },
|
||||||
shadowOpacity: 0.1,
|
shadowOpacity: 0.12,
|
||||||
shadowRadius: 8,
|
shadowRadius: 12,
|
||||||
elevation: 3,
|
elevation: 4,
|
||||||
marginTop: 16
|
marginTop: 16,
|
||||||
},
|
},
|
||||||
cardHeader: {
|
cardHeader: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
iconSquare: {
|
iconContainer: {
|
||||||
width: 14,
|
width: 28,
|
||||||
height: 14,
|
height: 28,
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
|
backgroundColor: 'rgba(79, 91, 213, 0.1)',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
marginRight: 4,
|
marginRight: 10,
|
||||||
|
},
|
||||||
|
iconSquare: {
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
tintColor: THEME_PRIMARY,
|
||||||
},
|
},
|
||||||
cardTitle: {
|
cardTitle: {
|
||||||
fontSize: 14,
|
fontSize: 15,
|
||||||
color: '#192126',
|
color: THEME_TEXT_PRIMARY,
|
||||||
flex: 1,
|
flex: 1,
|
||||||
fontWeight: '600',
|
fontWeight: '700',
|
||||||
fontFamily: 'AliBold',
|
fontFamily: 'AliBold',
|
||||||
},
|
},
|
||||||
headerButtons: {
|
headerButtons: {
|
||||||
@@ -364,19 +448,56 @@ const styles = StyleSheet.create({
|
|||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
addButton: {
|
addButton: {
|
||||||
width: 28,
|
width: 32,
|
||||||
height: 28,
|
height: 32,
|
||||||
borderRadius: 14,
|
borderRadius: 16,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
|
backgroundColor: 'rgba(79, 91, 213, 0.1)',
|
||||||
},
|
},
|
||||||
addButtonGlass: {
|
addButtonGlass: {
|
||||||
width: 28,
|
width: 32,
|
||||||
height: 28,
|
height: 32,
|
||||||
borderRadius: 14,
|
borderRadius: 16,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
backgroundColor: 'rgba(147, 112, 219, 0.3)',
|
backgroundColor: 'rgba(79, 91, 213, 0.15)',
|
||||||
|
},
|
||||||
|
currentWeightSection: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginTop: 12,
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
weightValueContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'baseline',
|
||||||
|
},
|
||||||
|
weightValue: {
|
||||||
|
fontSize: 32,
|
||||||
|
fontWeight: '800',
|
||||||
|
color: THEME_TEXT_PRIMARY,
|
||||||
|
fontFamily: 'AliBold',
|
||||||
|
},
|
||||||
|
weightUnit: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: THEME_TEXT_SECONDARY,
|
||||||
|
fontFamily: 'AliRegular',
|
||||||
|
marginLeft: 4,
|
||||||
|
},
|
||||||
|
changeTag: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 4,
|
||||||
|
borderRadius: 12,
|
||||||
|
gap: 4,
|
||||||
|
},
|
||||||
|
changeText: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '700',
|
||||||
|
fontFamily: 'AliBold',
|
||||||
},
|
},
|
||||||
emptyContent: {
|
emptyContent: {
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -384,12 +505,12 @@ const styles = StyleSheet.create({
|
|||||||
emptyTitle: {
|
emptyTitle: {
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
color: '#192126',
|
color: THEME_TEXT_PRIMARY,
|
||||||
marginBottom: 6,
|
marginBottom: 6,
|
||||||
},
|
},
|
||||||
emptyDescription: {
|
emptyDescription: {
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
color: '#687076',
|
color: THEME_TEXT_SECONDARY,
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
marginBottom: 16,
|
marginBottom: 16,
|
||||||
lineHeight: 20,
|
lineHeight: 20,
|
||||||
@@ -397,14 +518,14 @@ const styles = StyleSheet.create({
|
|||||||
recordButton: {
|
recordButton: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
backgroundColor: Colors.light.accentGreen,
|
backgroundColor: THEME_PRIMARY,
|
||||||
paddingHorizontal: 16,
|
paddingHorizontal: 16,
|
||||||
paddingVertical: 10,
|
paddingVertical: 10,
|
||||||
borderRadius: 20,
|
borderRadius: 20,
|
||||||
gap: 6,
|
gap: 6,
|
||||||
},
|
},
|
||||||
recordButtonText: {
|
recordButtonText: {
|
||||||
color: '#192126',
|
color: '#FFFFFF',
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
fontFamily: 'AliBold',
|
fontFamily: 'AliBold',
|
||||||
@@ -418,20 +539,25 @@ const styles = StyleSheet.create({
|
|||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
justifyContent: 'space-around',
|
justifyContent: 'space-around',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
|
marginTop: -14,
|
||||||
},
|
},
|
||||||
infoItem: {
|
infoItem: {
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
backgroundColor: 'rgba(79, 91, 213, 0.06)',
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 4,
|
||||||
|
borderRadius: 10,
|
||||||
},
|
},
|
||||||
infoLabel: {
|
infoLabel: {
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
color: '#687076',
|
color: THEME_TEXT_SECONDARY,
|
||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
fontFamily: 'AliRegular',
|
fontFamily: 'AliRegular',
|
||||||
},
|
},
|
||||||
infoValue: {
|
infoValue: {
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
color: '#192126',
|
color: THEME_TEXT_PRIMARY,
|
||||||
},
|
},
|
||||||
|
|
||||||
// BMI modal styles
|
// BMI modal styles
|
||||||
@@ -556,7 +682,7 @@ const styles = StyleSheet.create({
|
|||||||
marginBottom: 8,
|
marginBottom: 8,
|
||||||
},
|
},
|
||||||
bmiModalButtonBackground: {
|
bmiModalButtonBackground: {
|
||||||
backgroundColor: '#192126',
|
backgroundColor: THEME_TEXT_PRIMARY,
|
||||||
borderRadius: 16,
|
borderRadius: 16,
|
||||||
paddingVertical: 16,
|
paddingVertical: 16,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
|||||||
278
components/weight/WeightProgressBar.tsx
Normal file
278
components/weight/WeightProgressBar.tsx
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import {
|
||||||
|
Animated,
|
||||||
|
Easing,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
View,
|
||||||
|
ViewStyle
|
||||||
|
} from 'react-native';
|
||||||
|
|
||||||
|
// 主题色
|
||||||
|
const THEME_PRIMARY = '#4F5BD5';
|
||||||
|
const THEME_SECONDARY = '#6B6CFF';
|
||||||
|
const THEME_SUCCESS = '#22C55E';
|
||||||
|
const THEME_TEXT_SECONDARY = '#6f7ba7';
|
||||||
|
|
||||||
|
export interface WeightProgressBarProps {
|
||||||
|
/** 进度值 0-1 */
|
||||||
|
progress: number;
|
||||||
|
/** 当前体重 */
|
||||||
|
currentWeight: number;
|
||||||
|
/** 目标体重 */
|
||||||
|
targetWeight: number;
|
||||||
|
/** 初始体重 */
|
||||||
|
initialWeight: number;
|
||||||
|
/** 容器样式 */
|
||||||
|
style?: ViewStyle;
|
||||||
|
/** 是否显示顶部分隔线,默认 true */
|
||||||
|
showTopBorder?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WeightProgressBar: React.FC<WeightProgressBarProps> = ({
|
||||||
|
progress,
|
||||||
|
currentWeight,
|
||||||
|
targetWeight,
|
||||||
|
initialWeight,
|
||||||
|
style,
|
||||||
|
showTopBorder = true,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const animatedProgress = useRef(new Animated.Value(0)).current;
|
||||||
|
const [barWidth, setBarWidth] = useState(0);
|
||||||
|
|
||||||
|
const clampedProgress = Math.min(1, Math.max(0, progress));
|
||||||
|
const percent = Math.round(clampedProgress * 100);
|
||||||
|
|
||||||
|
// 判断是否有有效数据
|
||||||
|
const hasInitialWeight = initialWeight > 0;
|
||||||
|
const hasTargetWeight = targetWeight > 0;
|
||||||
|
const hasCurrentWeight = currentWeight > 0;
|
||||||
|
// 只要有初始体重和当前体重,就可以显示已减重量
|
||||||
|
const canShowLost = hasInitialWeight && hasCurrentWeight;
|
||||||
|
// 需要有目标体重才能显示距离目标和进度
|
||||||
|
const canShowTarget = hasTargetWeight && hasCurrentWeight;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 延迟 500ms 开始动画,避免页面刚进入时卡顿
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
Animated.timing(animatedProgress, {
|
||||||
|
toValue: clampedProgress,
|
||||||
|
duration: 800,
|
||||||
|
easing: Easing.out(Easing.cubic),
|
||||||
|
useNativeDriver: false,
|
||||||
|
}).start();
|
||||||
|
}, 800);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [clampedProgress]);
|
||||||
|
|
||||||
|
const fillWidth = animatedProgress.interpolate({
|
||||||
|
inputRange: [0, 1],
|
||||||
|
outputRange: [0, barWidth],
|
||||||
|
});
|
||||||
|
|
||||||
|
const sliderPosition = animatedProgress.interpolate({
|
||||||
|
inputRange: [0, 1],
|
||||||
|
outputRange: [-12, barWidth - 12],
|
||||||
|
});
|
||||||
|
|
||||||
|
const weightLost = initialWeight - currentWeight;
|
||||||
|
const weightToGo = currentWeight - targetWeight;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[
|
||||||
|
styles.container,
|
||||||
|
showTopBorder && styles.topBorder,
|
||||||
|
style
|
||||||
|
]}>
|
||||||
|
{/* 进度信息 */}
|
||||||
|
<View style={styles.infoRow}>
|
||||||
|
<View style={styles.infoItem}>
|
||||||
|
<Text style={styles.infoLabel}>{t('statistics.components.weight.progress.lost')}</Text>
|
||||||
|
<Text style={[styles.infoValue, { color: canShowLost && weightLost >= 0 ? THEME_SUCCESS : (canShowLost ? '#FF6B6B' : THEME_TEXT_SECONDARY) }]}>
|
||||||
|
{canShowLost ? `${weightLost >= 0 ? '-' : '+'}${Math.abs(weightLost).toFixed(1)}kg` : '--'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.percentContainer}>
|
||||||
|
<Text style={styles.percentValue}>{percent}</Text>
|
||||||
|
<Text style={styles.percentSymbol}>%</Text>
|
||||||
|
</View>
|
||||||
|
<View style={[styles.infoItem, { alignItems: 'flex-end' }]}>
|
||||||
|
<Text style={styles.infoLabel}>{t('statistics.components.weight.progress.toGo')}</Text>
|
||||||
|
<Text style={[styles.infoValue, { color: THEME_PRIMARY }]}>
|
||||||
|
{canShowTarget ? `${weightToGo > 0 ? weightToGo.toFixed(1) : '0'}kg` : '--'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 进度条 */}
|
||||||
|
<View
|
||||||
|
style={styles.trackContainer}
|
||||||
|
onLayout={(e) => setBarWidth(e.nativeEvent.layout.width)}
|
||||||
|
>
|
||||||
|
{/* 背景轨道 */}
|
||||||
|
<View style={styles.track} />
|
||||||
|
|
||||||
|
{/* 填充进度 */}
|
||||||
|
<Animated.View style={[styles.fill, { width: fillWidth }]}>
|
||||||
|
<LinearGradient
|
||||||
|
colors={[THEME_PRIMARY, THEME_SECONDARY]}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 0 }}
|
||||||
|
style={StyleSheet.absoluteFillObject}
|
||||||
|
/>
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
{/* 滑块 - 圆角矩形 */}
|
||||||
|
<Animated.View style={[styles.slider, { left: sliderPosition }]}>
|
||||||
|
<LinearGradient
|
||||||
|
colors={['#ffffff', '#f8f9fc']}
|
||||||
|
style={styles.sliderInner}
|
||||||
|
>
|
||||||
|
<View style={styles.sliderLine} />
|
||||||
|
</LinearGradient>
|
||||||
|
</Animated.View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 起止标签 */}
|
||||||
|
<View style={styles.labelRow}>
|
||||||
|
<Text style={styles.labelText}>{hasInitialWeight ? `${initialWeight.toFixed(1)}kg` : '--'}</Text>
|
||||||
|
<View style={styles.targetBadge}>
|
||||||
|
<Ionicons name="flag" size={10} color={THEME_PRIMARY} />
|
||||||
|
<Text style={styles.targetText}>{hasTargetWeight ? `${targetWeight.toFixed(1)}kg` : '--'}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
marginTop: 12,
|
||||||
|
paddingTop: 10,
|
||||||
|
marginLeft:12,
|
||||||
|
marginRight: 12
|
||||||
|
},
|
||||||
|
topBorder: {
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: 'rgba(0,0,0,0.04)',
|
||||||
|
},
|
||||||
|
infoRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
infoItem: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
infoLabel: {
|
||||||
|
fontSize: 11,
|
||||||
|
color: THEME_TEXT_SECONDARY,
|
||||||
|
fontFamily: 'AliRegular',
|
||||||
|
marginBottom: 2,
|
||||||
|
},
|
||||||
|
infoValue: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '700',
|
||||||
|
fontFamily: 'AliBold',
|
||||||
|
},
|
||||||
|
percentContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'baseline',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
percentValue: {
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: '800',
|
||||||
|
color: THEME_PRIMARY,
|
||||||
|
fontFamily: 'AliBold',
|
||||||
|
},
|
||||||
|
percentSymbol: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: THEME_PRIMARY,
|
||||||
|
fontFamily: 'AliBold',
|
||||||
|
marginLeft: 2,
|
||||||
|
},
|
||||||
|
trackContainer: {
|
||||||
|
height: 8,
|
||||||
|
position: 'relative',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
track: {
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
backgroundColor: '#E8EAF0',
|
||||||
|
borderRadius: 4,
|
||||||
|
},
|
||||||
|
fill: {
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
borderRadius: 4,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
slider: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: -8,
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
borderRadius: 8,
|
||||||
|
shadowColor: THEME_PRIMARY,
|
||||||
|
shadowOffset: { width: 0, height: 3 },
|
||||||
|
shadowOpacity: 0.35,
|
||||||
|
shadowRadius: 6,
|
||||||
|
elevation: 6,
|
||||||
|
},
|
||||||
|
sliderInner: {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
borderRadius: 8,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
borderWidth: 2.5,
|
||||||
|
borderColor: THEME_PRIMARY,
|
||||||
|
},
|
||||||
|
sliderLine: {
|
||||||
|
width: 8,
|
||||||
|
height: 3,
|
||||||
|
borderRadius: 1.5,
|
||||||
|
backgroundColor: THEME_PRIMARY,
|
||||||
|
},
|
||||||
|
labelRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
labelText: {
|
||||||
|
fontSize: 11,
|
||||||
|
color: THEME_TEXT_SECONDARY,
|
||||||
|
fontFamily: 'AliRegular',
|
||||||
|
},
|
||||||
|
targetBadge: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: 'rgba(79, 91, 213, 0.1)',
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 3,
|
||||||
|
borderRadius: 10,
|
||||||
|
gap: 4,
|
||||||
|
},
|
||||||
|
targetText: {
|
||||||
|
fontSize: 11,
|
||||||
|
color: THEME_PRIMARY,
|
||||||
|
fontWeight: '600',
|
||||||
|
fontFamily: 'AliBold',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default WeightProgressBar;
|
||||||
@@ -1,20 +1,24 @@
|
|||||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
import { Ionicons, MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import 'dayjs/locale/en';
|
import 'dayjs/locale/en';
|
||||||
import 'dayjs/locale/zh-cn';
|
import 'dayjs/locale/zh-cn';
|
||||||
|
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Dimensions,
|
Dimensions,
|
||||||
Modal,
|
Modal,
|
||||||
|
Pressable,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
|
Share,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
Text,
|
Text,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
TouchableWithoutFeedback,
|
TouchableWithoutFeedback,
|
||||||
View,
|
View,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
|
import ViewShot, { captureRef } from 'react-native-view-shot';
|
||||||
|
|
||||||
import { useI18n } from '@/hooks/useI18n';
|
import { useI18n } from '@/hooks/useI18n';
|
||||||
import {
|
import {
|
||||||
@@ -26,6 +30,7 @@ import {
|
|||||||
WorkoutActivityType,
|
WorkoutActivityType,
|
||||||
WorkoutData,
|
WorkoutData,
|
||||||
} from '@/utils/health';
|
} from '@/utils/health';
|
||||||
|
import { Toast } from '@/utils/toast.utils';
|
||||||
|
|
||||||
export interface IntensityBadge {
|
export interface IntensityBadge {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -65,6 +70,8 @@ export function WorkoutDetailModal({
|
|||||||
const [isMounted, setIsMounted] = useState(visible);
|
const [isMounted, setIsMounted] = useState(visible);
|
||||||
const [shouldRenderChart, setShouldRenderChart] = useState(visible);
|
const [shouldRenderChart, setShouldRenderChart] = useState(visible);
|
||||||
const [showIntensityInfo, setShowIntensityInfo] = useState(false);
|
const [showIntensityInfo, setShowIntensityInfo] = useState(false);
|
||||||
|
const [sharing, setSharing] = useState(false);
|
||||||
|
const shareContentRef = useRef<ViewShot | null>(null);
|
||||||
|
|
||||||
const locale = useMemo(() => (i18n.language?.startsWith('en') ? 'en' : 'zh-cn'), [i18n.language]);
|
const locale = useMemo(() => (i18n.language?.startsWith('en') ? 'en' : 'zh-cn'), [i18n.language]);
|
||||||
|
|
||||||
@@ -138,6 +145,50 @@ export function WorkoutDetailModal({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleShare = useCallback(async () => {
|
||||||
|
if (!shareContentRef.current || !workout || sharing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSharing(true);
|
||||||
|
try {
|
||||||
|
Toast.show({
|
||||||
|
type: 'info',
|
||||||
|
text1: t('workoutDetail.share.generating', '正在生成分享卡片…'),
|
||||||
|
});
|
||||||
|
const uri = await captureRef(shareContentRef, {
|
||||||
|
format: 'png',
|
||||||
|
quality: 0.95,
|
||||||
|
snapshotContentContainer: true,
|
||||||
|
});
|
||||||
|
if (!uri) {
|
||||||
|
throw new Error('share-capture-failed');
|
||||||
|
}
|
||||||
|
const shareUri = uri.startsWith('file://') ? uri : `file://${uri}`;
|
||||||
|
const shareTitle = t('workoutDetail.share.title', { defaultValue: activityName || t('workoutDetail.title', '锻炼详情') });
|
||||||
|
const caloriesLabel = metrics?.calories != null
|
||||||
|
? `${metrics.calories} ${t('workoutDetail.metrics.caloriesUnit')}`
|
||||||
|
: '--';
|
||||||
|
const shareMessage = t('workoutDetail.share.message', {
|
||||||
|
activity: activityName || t('workoutDetail.share.activityFallback', '锻炼'),
|
||||||
|
duration: metrics?.durationLabel ?? '--',
|
||||||
|
calories: caloriesLabel,
|
||||||
|
date: dateInfo.subtitle,
|
||||||
|
defaultValue: `我的${activityName || '锻炼'}:${dateInfo.subtitle},持续${metrics?.durationLabel ?? '--'},消耗${caloriesLabel}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
await Share.share({
|
||||||
|
title: shareTitle,
|
||||||
|
message: shareMessage,
|
||||||
|
url: shareUri,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('workout-detail-share-failed', error);
|
||||||
|
Toast.error(t('workoutDetail.share.failed', '分享失败,请稍后再试'));
|
||||||
|
} finally {
|
||||||
|
setSharing(false);
|
||||||
|
}
|
||||||
|
}, [activityName, dateInfo.subtitle, metrics?.calories, metrics?.durationLabel, sharing, t, workout]);
|
||||||
|
|
||||||
if (!isMounted) {
|
if (!isMounted) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -176,7 +227,48 @@ export function WorkoutDetailModal({
|
|||||||
<Text style={styles.headerSubtitle}>{dateInfo.subtitle}</Text>
|
<Text style={styles.headerSubtitle}>{dateInfo.subtitle}</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.headerSpacer} />
|
{isLiquidGlassAvailable() ? (
|
||||||
|
<Pressable
|
||||||
|
onPress={handleShare}
|
||||||
|
disabled={loading || sharing || !workout}
|
||||||
|
style={({ pressed }) => [
|
||||||
|
styles.headerIconButton,
|
||||||
|
styles.glassButtonWrapper,
|
||||||
|
pressed && styles.headerIconPressed,
|
||||||
|
(loading || sharing || !workout) && styles.headerIconDisabled,
|
||||||
|
]}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={t('workoutDetail.share.accessibilityLabel', '分享锻炼记录')}
|
||||||
|
>
|
||||||
|
<GlassView glassEffectStyle="regular" tintColor="rgba(255,255,255,0.9)" isInteractive style={styles.glassButton}>
|
||||||
|
<View style={styles.glassButtonInner}>
|
||||||
|
<Ionicons name="share-outline" size={20} color="#1E2148" />
|
||||||
|
</View>
|
||||||
|
</GlassView>
|
||||||
|
</Pressable>
|
||||||
|
) : (
|
||||||
|
<Pressable
|
||||||
|
onPress={handleShare}
|
||||||
|
disabled={loading || sharing || !workout}
|
||||||
|
style={({ pressed }) => [
|
||||||
|
styles.headerIconButton,
|
||||||
|
styles.headerIconFallback,
|
||||||
|
pressed && styles.headerIconPressed,
|
||||||
|
(loading || sharing || !workout) && styles.headerIconDisabled,
|
||||||
|
]}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={t('workoutDetail.share.accessibilityLabel', '分享锻炼记录')}
|
||||||
|
>
|
||||||
|
<LinearGradient
|
||||||
|
colors={['#EEF2FF', '#E0E7FF']}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 1 }}
|
||||||
|
style={styles.glassButtonInner}
|
||||||
|
>
|
||||||
|
<Ionicons name="share-outline" size={20} color="#1E2148" />
|
||||||
|
</LinearGradient>
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.heroIconWrapper}>
|
<View style={styles.heroIconWrapper}>
|
||||||
@@ -390,6 +482,237 @@ export function WorkoutDetailModal({
|
|||||||
<View style={styles.homeIndicatorSpacer} />
|
<View style={styles.homeIndicatorSpacer} />
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{/* Hidden share capture renders full content height for complete screenshots */}
|
||||||
|
<ViewShot
|
||||||
|
ref={shareContentRef}
|
||||||
|
style={[styles.sheetContainer, styles.shareCaptureContainer]}
|
||||||
|
options={{ format: 'png', quality: 0.95, snapshotContentContainer: true }}
|
||||||
|
>
|
||||||
|
<LinearGradient
|
||||||
|
colors={['#FFFFFF', '#F3F5FF']}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 0, y: 1 }}
|
||||||
|
style={styles.gradientBackground}
|
||||||
|
/>
|
||||||
|
<View style={styles.handleWrapper}>
|
||||||
|
<View style={styles.handle} />
|
||||||
|
</View>
|
||||||
|
<View style={styles.headerRow}>
|
||||||
|
<View style={styles.headerIconButton} />
|
||||||
|
<View style={styles.headerTitleWrapper}>
|
||||||
|
<Text style={styles.headerTitle}>{dateInfo.title}</Text>
|
||||||
|
<Text style={styles.headerSubtitle}>{dateInfo.subtitle}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.headerIconButton} />
|
||||||
|
</View>
|
||||||
|
<View style={styles.heroIconWrapper}>
|
||||||
|
<MaterialCommunityIcons name="run" size={160} color="#E8EAFE" />
|
||||||
|
</View>
|
||||||
|
<ScrollView
|
||||||
|
bounces={false}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
contentContainerStyle={styles.contentContainer}
|
||||||
|
>
|
||||||
|
<View style={[styles.summaryCard, loading ? styles.summaryCardLoading : null]}>
|
||||||
|
<View style={styles.summaryHeader}>
|
||||||
|
<Text style={styles.activityName}>{activityName}</Text>
|
||||||
|
{intensityBadge ? (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.intensityPill,
|
||||||
|
{ backgroundColor: intensityBadge.background },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text style={[styles.intensityPillText, { color: intensityBadge.color }]}>
|
||||||
|
{intensityBadge.label}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
<Text style={styles.summarySubtitle}>
|
||||||
|
{dayjs(workout?.startDate || workout?.endDate)
|
||||||
|
.locale(locale)
|
||||||
|
.format(locale === 'en' ? 'dddd, MMM D, YYYY HH:mm' : 'YYYY年M月D日 dddd HH:mm')}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<View style={styles.loadingBlock}>
|
||||||
|
<ActivityIndicator color="#5C55FF" />
|
||||||
|
<Text style={styles.loadingLabel}>{t('workoutDetail.loading')}</Text>
|
||||||
|
</View>
|
||||||
|
) : metrics ? (
|
||||||
|
<>
|
||||||
|
<View style={styles.metricsRow}>
|
||||||
|
<View style={styles.metricItem}>
|
||||||
|
<Text style={styles.metricTitle}>{t('workoutDetail.metrics.duration')}</Text>
|
||||||
|
<Text style={styles.metricValue}>{metrics.durationLabel}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.metricItem}>
|
||||||
|
<Text style={styles.metricTitle}>{t('workoutDetail.metrics.calories')}</Text>
|
||||||
|
<Text style={styles.metricValue}>
|
||||||
|
{metrics.calories != null ? `${metrics.calories} ${t('workoutDetail.metrics.caloriesUnit')}` : '--'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View style={styles.metricsRow}>
|
||||||
|
<View style={styles.metricItem}>
|
||||||
|
<View style={styles.metricTitleRow}>
|
||||||
|
<Text style={styles.metricTitle}>{t('workoutDetail.metrics.intensity')}</Text>
|
||||||
|
<View style={styles.metricInfoButton}>
|
||||||
|
<MaterialCommunityIcons name="information-outline" size={16} color="#7780AA" />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.metricValue}>
|
||||||
|
{formatMetsValue(metrics.mets)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.metricItem}>
|
||||||
|
<Text style={styles.metricTitle}>{t('workoutDetail.metrics.averageHeartRate')}</Text>
|
||||||
|
<Text style={styles.metricValue}>
|
||||||
|
{metrics.averageHeartRate != null ? `${metrics.averageHeartRate} ${t('workoutDetail.metrics.heartRateUnit')}` : '--'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
{monthOccurrenceText ? (
|
||||||
|
<Text style={styles.monthOccurrenceText}>{monthOccurrenceText}</Text>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<View style={styles.errorBlock}>
|
||||||
|
<Text style={styles.errorText}>
|
||||||
|
{errorMessage || t('workoutDetail.errors.loadFailed')}
|
||||||
|
</Text>
|
||||||
|
{onRetry ? (
|
||||||
|
<View style={[styles.retryButton, styles.retryButtonDisabled]}>
|
||||||
|
<Text style={styles.retryButtonText}>{t('workoutDetail.retry')}</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={[styles.section, loading ? styles.sectionHeartRateLoading : null]}>
|
||||||
|
<View style={styles.sectionHeader}>
|
||||||
|
<Text style={styles.sectionTitle}>{t('workoutDetail.sections.heartRateRange')}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<View style={styles.sectionLoading}>
|
||||||
|
<ActivityIndicator color="#5C55FF" />
|
||||||
|
</View>
|
||||||
|
) : metrics ? (
|
||||||
|
<>
|
||||||
|
<View style={styles.heartRateSummaryRow}>
|
||||||
|
<View style={styles.heartRateStat}>
|
||||||
|
<Text style={styles.statLabel}>{t('workoutDetail.sections.averageHeartRate')}</Text>
|
||||||
|
<Text style={styles.statValue}>
|
||||||
|
{metrics.averageHeartRate != null ? `${metrics.averageHeartRate} ${t('workoutDetail.sections.heartRateUnit')}` : '--'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.heartRateStat}>
|
||||||
|
<Text style={styles.statLabel}>{t('workoutDetail.sections.maximumHeartRate')}</Text>
|
||||||
|
<Text style={styles.statValue}>
|
||||||
|
{metrics.maximumHeartRate != null ? `${metrics.maximumHeartRate} ${t('workoutDetail.sections.heartRateUnit')}` : '--'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.heartRateStat}>
|
||||||
|
<Text style={styles.statLabel}>{t('workoutDetail.sections.minimumHeartRate')}</Text>
|
||||||
|
<Text style={styles.statValue}>
|
||||||
|
{metrics.minimumHeartRate != null ? `${metrics.minimumHeartRate} ${t('workoutDetail.sections.heartRateUnit')}` : '--'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{heartRateChart ? (
|
||||||
|
LineChart ? (
|
||||||
|
<View style={styles.chartWrapper}>
|
||||||
|
{shouldRenderChart ? (
|
||||||
|
/* @ts-ignore - react-native-chart-kit types are outdated */
|
||||||
|
<LineChart
|
||||||
|
data={{
|
||||||
|
labels: heartRateChart.labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
data: heartRateChart.data,
|
||||||
|
color: () => '#5C55FF',
|
||||||
|
strokeWidth: 2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
width={chartWidth}
|
||||||
|
height={220}
|
||||||
|
fromZero={false}
|
||||||
|
yAxisSuffix={t('workoutDetail.sections.heartRateUnit')}
|
||||||
|
withInnerLines={false}
|
||||||
|
bezier
|
||||||
|
paddingRight={48}
|
||||||
|
chartConfig={{
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
backgroundGradientFrom: '#FFFFFF',
|
||||||
|
backgroundGradientTo: '#FFFFFF',
|
||||||
|
decimalPlaces: 0,
|
||||||
|
color: (opacity = 1) => `rgba(92, 85, 255, ${opacity})`,
|
||||||
|
labelColor: (opacity = 1) => `rgba(98, 105, 138, ${opacity})`,
|
||||||
|
propsForDots: {
|
||||||
|
r: '3',
|
||||||
|
strokeWidth: '2',
|
||||||
|
stroke: '#FFFFFF',
|
||||||
|
},
|
||||||
|
fillShadowGradientFromOpacity: 0.1,
|
||||||
|
fillShadowGradientToOpacity: 0.02,
|
||||||
|
}}
|
||||||
|
style={styles.chartStyle}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View style={[styles.chartLoading, { width: chartWidth }]}>
|
||||||
|
<ActivityIndicator color="#5C55FF" />
|
||||||
|
<Text style={styles.chartLoadingText}>{t('workoutDetail.loading')}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View style={styles.chartEmpty}>
|
||||||
|
<MaterialCommunityIcons name="chart-line-variant" size={32} color="#C5CBE2" />
|
||||||
|
<Text style={styles.chartEmptyText}>{t('workoutDetail.chart.unavailable')}</Text>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<View style={styles.chartEmpty}>
|
||||||
|
<MaterialCommunityIcons name="heart-off-outline" size={32} color="#C5CBE2" />
|
||||||
|
<Text style={styles.chartEmptyText}>{t('workoutDetail.chart.noData')}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<View style={styles.sectionError}>
|
||||||
|
<Text style={styles.errorTextSmall}>
|
||||||
|
{errorMessage || t('workoutDetail.errors.noHeartRateData')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={[styles.section, loading ? styles.sectionZonesLoading : null]}>
|
||||||
|
<View style={styles.sectionHeader}>
|
||||||
|
<Text style={styles.sectionTitle}>{t('workoutDetail.sections.heartRateZones')}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<View style={styles.sectionLoading}>
|
||||||
|
<ActivityIndicator color="#5C55FF" />
|
||||||
|
</View>
|
||||||
|
) : metrics ? (
|
||||||
|
metrics.heartRateZones.map((zone) => renderHeartRateZone(zone, t))
|
||||||
|
) : (
|
||||||
|
<Text style={styles.errorTextSmall}>{t('workoutDetail.errors.noZoneStats')}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.homeIndicatorSpacer} />
|
||||||
|
</ScrollView>
|
||||||
|
</ViewShot>
|
||||||
|
|
||||||
{showIntensityInfo ? (
|
{showIntensityInfo ? (
|
||||||
<Modal
|
<Modal
|
||||||
transparent
|
transparent
|
||||||
@@ -634,6 +957,39 @@ const styles = StyleSheet.create({
|
|||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
|
headerIconFallback: {
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
headerIconPressed: {
|
||||||
|
opacity: 0.75,
|
||||||
|
},
|
||||||
|
headerIconDisabled: {
|
||||||
|
opacity: 0.4,
|
||||||
|
},
|
||||||
|
glassButtonWrapper: {
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
glassButton: {
|
||||||
|
borderRadius: 20,
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
},
|
||||||
|
glassButtonInner: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
borderRadius: 20,
|
||||||
|
},
|
||||||
|
shareCaptureContainer: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: -9999,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
opacity: 0,
|
||||||
|
zIndex: -1,
|
||||||
|
},
|
||||||
headerTitleWrapper: {
|
headerTitleWrapper: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -761,6 +1117,9 @@ const styles = StyleSheet.create({
|
|||||||
backgroundColor: '#5C55FF',
|
backgroundColor: '#5C55FF',
|
||||||
borderRadius: 16,
|
borderRadius: 16,
|
||||||
},
|
},
|
||||||
|
retryButtonDisabled: {
|
||||||
|
opacity: 0.4,
|
||||||
|
},
|
||||||
retryButtonText: {
|
retryButtonText: {
|
||||||
color: '#FFFFFF',
|
color: '#FFFFFF',
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
@@ -970,8 +1329,4 @@ const styles = StyleSheet.create({
|
|||||||
intensityHigh: {
|
intensityHigh: {
|
||||||
color: '#FF6767',
|
color: '#FF6767',
|
||||||
},
|
},
|
||||||
headerSpacer: {
|
|
||||||
width: 40,
|
|
||||||
height: 40,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ export const ROUTES = {
|
|||||||
FITNESS_RINGS_DETAIL: '/fitness-rings-detail',
|
FITNESS_RINGS_DETAIL: '/fitness-rings-detail',
|
||||||
SLEEP_DETAIL: '/sleep-detail',
|
SLEEP_DETAIL: '/sleep-detail',
|
||||||
BASAL_METABOLISM_DETAIL: '/basal-metabolism-detail',
|
BASAL_METABOLISM_DETAIL: '/basal-metabolism-detail',
|
||||||
|
HEALTH_PROFILE: '/health/profile',
|
||||||
|
HEALTH_FAMILY_INVITE: '/health/family-invite',
|
||||||
|
|
||||||
// 饮水相关路由
|
// 饮水相关路由
|
||||||
WATER_DETAIL: '/water/detail',
|
WATER_DETAIL: '/water/detail',
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||||
import { ChallengeType } from '@/services/challengesApi';
|
import { ChallengeType } from '@/services/challengesApi';
|
||||||
|
import { WaterRecordSource } from '@/services/waterRecords';
|
||||||
import { reportChallengeProgress, selectChallengeList } from '@/store/challengesSlice';
|
import { reportChallengeProgress, selectChallengeList } from '@/store/challengesSlice';
|
||||||
|
import { createWaterRecordAction } from '@/store/waterSlice';
|
||||||
import { deleteWaterIntakeFromHealthKit, getWaterIntakeFromHealthKit, saveWaterIntakeToHealthKit } from '@/utils/health';
|
import { deleteWaterIntakeFromHealthKit, getWaterIntakeFromHealthKit, saveWaterIntakeToHealthKit } from '@/utils/health';
|
||||||
import { Toast } from '@/utils/toast.utils';
|
import { Toast } from '@/utils/toast.utils';
|
||||||
import { getQuickWaterAmount, getWaterGoalFromStorage, setWaterGoalToStorage } from '@/utils/userPreferences';
|
import { getQuickWaterAmount, getWaterGoalFromStorage, setWaterGoalToStorage } from '@/utils/userPreferences';
|
||||||
@@ -81,6 +83,9 @@ export const useWaterData = () => {
|
|||||||
const [waterRecords, setWaterRecords] = useState<{ [date: string]: WaterRecord[] }>({});
|
const [waterRecords, setWaterRecords] = useState<{ [date: string]: WaterRecord[] }>({});
|
||||||
const [selectedDate, setSelectedDate] = useState<string>(dayjs().format('YYYY-MM-DD'));
|
const [selectedDate, setSelectedDate] = useState<string>(dayjs().format('YYYY-MM-DD'));
|
||||||
|
|
||||||
|
// Redux dispatch
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
// 获取指定日期的记录
|
// 获取指定日期的记录
|
||||||
const getWaterRecordsByDate = useCallback(async (date: string, page = 1, limit = 20) => {
|
const getWaterRecordsByDate = useCallback(async (date: string, page = 1, limit = 20) => {
|
||||||
setLoading(prev => ({ ...prev, records: true }));
|
setLoading(prev => ({ ...prev, records: true }));
|
||||||
@@ -196,6 +201,15 @@ export const useWaterData = () => {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 同步到服务端(后台执行,不阻塞 UI)
|
||||||
|
dispatch(createWaterRecordAction({
|
||||||
|
amount,
|
||||||
|
recordedAt: recordTime,
|
||||||
|
source: WaterRecordSource.Manual,
|
||||||
|
})).catch((err) => {
|
||||||
|
console.warn('同步饮水记录到服务端失败:', err);
|
||||||
|
});
|
||||||
|
|
||||||
// 重新获取当前日期的数据以刷新界面
|
// 重新获取当前日期的数据以刷新界面
|
||||||
const updatedRecords = await getWaterRecordsByDate(date);
|
const updatedRecords = await getWaterRecordsByDate(date);
|
||||||
const totalAmount = updatedRecords.reduce((sum, record) => sum + record.amount, 0);
|
const totalAmount = updatedRecords.reduce((sum, record) => sum + record.amount, 0);
|
||||||
@@ -225,7 +239,7 @@ export const useWaterData = () => {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[dailyWaterGoal, getWaterRecordsByDate, reportWaterChallengeProgress]
|
[dailyWaterGoal, getWaterRecordsByDate, reportWaterChallengeProgress, dispatch]
|
||||||
);
|
);
|
||||||
|
|
||||||
// 更新喝水记录(HealthKit不支持更新,只能删除后重新添加)
|
// 更新喝水记录(HealthKit不支持更新,只能删除后重新添加)
|
||||||
@@ -554,6 +568,7 @@ export const useWaterDataByDate = (targetDate?: string) => {
|
|||||||
|
|
||||||
// 创建喝水记录
|
// 创建喝水记录
|
||||||
const reportWaterChallengeProgress = useWaterChallengeProgressReporter();
|
const reportWaterChallengeProgress = useWaterChallengeProgressReporter();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
const addWaterRecord = useCallback(
|
const addWaterRecord = useCallback(
|
||||||
async (amount: number, recordedAt?: string) => {
|
async (amount: number, recordedAt?: string) => {
|
||||||
@@ -567,6 +582,15 @@ export const useWaterDataByDate = (targetDate?: string) => {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 同步到服务端(后台执行,不阻塞 UI)
|
||||||
|
dispatch(createWaterRecordAction({
|
||||||
|
amount,
|
||||||
|
recordedAt: recordTime,
|
||||||
|
source: WaterRecordSource.Manual,
|
||||||
|
})).catch((err) => {
|
||||||
|
console.warn('同步饮水记录到服务端失败:', err);
|
||||||
|
});
|
||||||
|
|
||||||
// 重新获取当前日期的数据以刷新界面
|
// 重新获取当前日期的数据以刷新界面
|
||||||
const updatedRecords = await getWaterRecordsByDate(dateToUse);
|
const updatedRecords = await getWaterRecordsByDate(dateToUse);
|
||||||
const totalAmount = updatedRecords.reduce((sum, record) => sum + record.amount, 0);
|
const totalAmount = updatedRecords.reduce((sum, record) => sum + record.amount, 0);
|
||||||
@@ -596,7 +620,7 @@ export const useWaterDataByDate = (targetDate?: string) => {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[dailyWaterGoal, dateToUse, getWaterRecordsByDate, reportWaterChallengeProgress]
|
[dailyWaterGoal, dateToUse, getWaterRecordsByDate, reportWaterChallengeProgress, dispatch]
|
||||||
);
|
);
|
||||||
|
|
||||||
// 更新喝水记录
|
// 更新喝水记录
|
||||||
|
|||||||
@@ -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}}',
|
||||||
@@ -176,6 +199,11 @@ export const statistics = {
|
|||||||
days: 'days',
|
days: 'days',
|
||||||
range: 'Range',
|
range: 'Range',
|
||||||
unit: 'kg',
|
unit: 'kg',
|
||||||
|
progress: {
|
||||||
|
lost: 'Lost',
|
||||||
|
toGo: 'To go',
|
||||||
|
},
|
||||||
|
demo: 'Demo',
|
||||||
bmiModal: {
|
bmiModal: {
|
||||||
title: 'BMI Index Explanation',
|
title: 'BMI Index Explanation',
|
||||||
description: 'BMI (Body Mass Index) is an internationally recognized health indicator for assessing weight relative to height',
|
description: 'BMI (Body Mass Index) is an internationally recognized health indicator for assessing weight relative to height',
|
||||||
@@ -205,13 +233,6 @@ export const statistics = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
tabs: {
|
|
||||||
health: 'Health',
|
|
||||||
medications: 'Meds',
|
|
||||||
fasting: 'Fasting',
|
|
||||||
challenges: 'Challenges',
|
|
||||||
personal: 'Me',
|
|
||||||
},
|
|
||||||
activityHeatMap: {
|
activityHeatMap: {
|
||||||
subtitle: 'Active {{days}} days in the last 6 months',
|
subtitle: 'Active {{days}} days in the last 6 months',
|
||||||
activeRate: '{{rate}}%',
|
activeRate: '{{rate}}%',
|
||||||
@@ -656,6 +677,45 @@ export const workoutDetail = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const sleepNotification = {
|
||||||
|
// Notification body template
|
||||||
|
body: 'You slept {{duration}} last night with {{efficiency}}% efficiency. Score: {{score}} 🎯',
|
||||||
|
|
||||||
|
// Sleep quality titles - warm and encouraging tone
|
||||||
|
quality: {
|
||||||
|
excellent: 'Amazing! You slept great',
|
||||||
|
good: 'Nice! Good sleep quality',
|
||||||
|
fair: 'Not bad, tomorrow will be better',
|
||||||
|
poor: 'Hang in there, rest well tonight',
|
||||||
|
veryPoor: 'Take care of yourself',
|
||||||
|
default: 'Sleep analysis complete',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Sleep duration formatting
|
||||||
|
duration: {
|
||||||
|
hoursOnly: '{{hours}} hours',
|
||||||
|
hoursAndMinutes: '{{hours}}h {{minutes}}m',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Sleep tips - encouraging tone
|
||||||
|
tips: {
|
||||||
|
excellent: {
|
||||||
|
keepItUp: 'Keep it up, you\'re doing amazing!',
|
||||||
|
greatJob: 'Your body thanks you for the great care!',
|
||||||
|
energized: 'You\'ll be full of energy today!',
|
||||||
|
proud: 'Give yourself a pat on the back!',
|
||||||
|
},
|
||||||
|
suggestions: {
|
||||||
|
shortSleep: 'Try hitting the pillow earlier - 7-9 hours will boost your energy!',
|
||||||
|
longSleep: 'Too much sleep can be tiring too - try a consistent wake time!',
|
||||||
|
lowDeepSleep: 'Put your phone away before bed for deeper rest~',
|
||||||
|
lowRemSleep: 'A regular schedule helps you dream better!',
|
||||||
|
lowEfficiency: 'A cozy bedroom environment can work wonders~',
|
||||||
|
},
|
||||||
|
general: 'Every night is a fresh start - take care of yourself!',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export const workoutHistory = {
|
export const workoutHistory = {
|
||||||
title: 'Workout Summary',
|
title: 'Workout Summary',
|
||||||
loading: 'Loading workout records...',
|
loading: 'Loading workout records...',
|
||||||
@@ -687,3 +747,127 @@ export const workoutHistory = {
|
|||||||
},
|
},
|
||||||
monthOccurrence: 'This is your {{index}} {{activity}} in {{month}}.',
|
monthOccurrence: 'This is your {{index}} {{activity}} in {{month}}.',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const familyGroup = {
|
||||||
|
joinTitle: 'Join Family Group',
|
||||||
|
joinDescription: 'Enter the invite code shared by your family member to join health management',
|
||||||
|
inviteCodePlaceholder: 'Enter invite code',
|
||||||
|
relationshipLabel: 'Relationship to creator',
|
||||||
|
relationshipPlaceholder: 'Select relationship',
|
||||||
|
joinButton: 'Join',
|
||||||
|
joining: 'Joining...',
|
||||||
|
cancel: 'Cancel',
|
||||||
|
errors: {
|
||||||
|
emptyCode: 'Please enter invite code',
|
||||||
|
emptyRelationship: 'Please select relationship',
|
||||||
|
},
|
||||||
|
success: 'Successfully joined family group',
|
||||||
|
relationships: {
|
||||||
|
spouse: 'Spouse',
|
||||||
|
father: 'Father',
|
||||||
|
mother: 'Mother',
|
||||||
|
son: 'Son',
|
||||||
|
daughter: 'Daughter',
|
||||||
|
grandfather: 'Grandfather',
|
||||||
|
grandmother: 'Grandmother',
|
||||||
|
grandson: 'Grandson',
|
||||||
|
granddaughter: 'Granddaughter',
|
||||||
|
brother: 'Brother',
|
||||||
|
sister: 'Sister',
|
||||||
|
uncle: 'Uncle',
|
||||||
|
aunt: 'Aunt',
|
||||||
|
nephew: 'Nephew',
|
||||||
|
niece: 'Niece',
|
||||||
|
cousin: 'Cousin',
|
||||||
|
other: 'Other',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const health = {
|
||||||
|
tabs: {
|
||||||
|
health: 'Health',
|
||||||
|
medications: 'Meds',
|
||||||
|
fasting: 'Fasting',
|
||||||
|
challenges: 'Challenges',
|
||||||
|
personal: 'Me',
|
||||||
|
healthProfile: {
|
||||||
|
title: 'Health Profile',
|
||||||
|
subtitle: 'Invite family to join health management for timely anomaly alerts',
|
||||||
|
privacyNotice: 'Profile content is visible only to you. We strictly protect your privacy.',
|
||||||
|
basicInfo: 'Basic Info',
|
||||||
|
healthHistory: 'History',
|
||||||
|
medicalRecords: 'Records',
|
||||||
|
checkupRecords: 'Checkups',
|
||||||
|
medicineBox: 'Medications',
|
||||||
|
basicInfoCard: {
|
||||||
|
title: 'Basic Information',
|
||||||
|
noData: 'No data',
|
||||||
|
bmi: 'BMI',
|
||||||
|
height: 'Height',
|
||||||
|
heightUnit: 'CM',
|
||||||
|
weight: 'Weight',
|
||||||
|
weightUnit: 'KG',
|
||||||
|
waist: 'Waist',
|
||||||
|
waistUnit: 'CM',
|
||||||
|
},
|
||||||
|
history: {
|
||||||
|
allergy: 'Allergies',
|
||||||
|
disease: 'Conditions',
|
||||||
|
surgery: 'Surgeries',
|
||||||
|
familyDisease: 'Family History',
|
||||||
|
pending: 'To be added',
|
||||||
|
edit: 'Edit',
|
||||||
|
modal: {
|
||||||
|
question: 'Do you have {{type}}?',
|
||||||
|
yes: 'Yes',
|
||||||
|
no: 'No',
|
||||||
|
addDetails: 'Add Details',
|
||||||
|
enterSpecific: 'Enter specific condition...',
|
||||||
|
recommendations: 'Recommendations',
|
||||||
|
save: 'Save',
|
||||||
|
none: 'None',
|
||||||
|
yesNoDetails: 'Yes (No details)',
|
||||||
|
diagnosisDate: 'Diagnosis Date',
|
||||||
|
namePlaceholder: 'Condition Name',
|
||||||
|
addItem: 'Add Record',
|
||||||
|
selectDate: 'Select Date'
|
||||||
|
},
|
||||||
|
recommendationItems: {
|
||||||
|
allergy: {
|
||||||
|
penicillin: 'Penicillin',
|
||||||
|
sulfonamides: 'Sulfonamides',
|
||||||
|
peanuts: 'Peanuts',
|
||||||
|
seafood: 'Seafood',
|
||||||
|
pollen: 'Pollen',
|
||||||
|
dustMites: 'Dust Mites',
|
||||||
|
alcohol: 'Alcohol',
|
||||||
|
mango: 'Mango'
|
||||||
|
},
|
||||||
|
disease: {
|
||||||
|
hypertension: 'Hypertension',
|
||||||
|
diabetes: 'Diabetes',
|
||||||
|
asthma: 'Asthma',
|
||||||
|
heartDisease: 'Heart Disease',
|
||||||
|
gastritis: 'Gastritis',
|
||||||
|
migraine: 'Migraine'
|
||||||
|
},
|
||||||
|
surgery: {
|
||||||
|
appendectomy: 'Appendectomy',
|
||||||
|
cesareanSection: 'Cesarean Section',
|
||||||
|
tonsillectomy: 'Tonsillectomy',
|
||||||
|
fractureRepair: 'Fracture Repair',
|
||||||
|
none: 'None'
|
||||||
|
},
|
||||||
|
familyDisease: {
|
||||||
|
hypertension: 'Hypertension',
|
||||||
|
diabetes: 'Diabetes',
|
||||||
|
cancer: 'Cancer',
|
||||||
|
heartDisease: 'Heart Disease',
|
||||||
|
stroke: 'Stroke',
|
||||||
|
alzheimers: 'Alzheimer\'s'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -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, // 确保通用翻译被正确导出
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -238,7 +238,7 @@ export const medications = {
|
|||||||
periodRange: 'From {{startDate}} to {{endDate}}',
|
periodRange: 'From {{startDate}} to {{endDate}}',
|
||||||
periodLongTerm: 'From {{startDate}} until indefinitely',
|
periodLongTerm: 'From {{startDate}} until indefinitely',
|
||||||
expiryStatus: {
|
expiryStatus: {
|
||||||
notSet: 'Not set',
|
notSet: 'Set Expiry',
|
||||||
expired: 'Expired',
|
expired: 'Expired',
|
||||||
expiresToday: 'Expires today',
|
expiresToday: 'Expires today',
|
||||||
expiresInDays: 'Expires in {{days}} days',
|
expiresInDays: 'Expires in {{days}} days',
|
||||||
|
|||||||
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.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -27,6 +27,11 @@ export const personal = {
|
|||||||
validForever: 'No expiry',
|
validForever: 'No expiry',
|
||||||
dateFormat: 'YYYY-MM-DD',
|
dateFormat: 'YYYY-MM-DD',
|
||||||
},
|
},
|
||||||
|
membershipBanner: {
|
||||||
|
title: 'Unlock Premium Access',
|
||||||
|
subtitle: 'Get unlimited access to AI features & custom plans',
|
||||||
|
cta: 'Upgrade Now',
|
||||||
|
},
|
||||||
sections: {
|
sections: {
|
||||||
notifications: 'Notifications',
|
notifications: 'Notifications',
|
||||||
developer: 'Developer',
|
developer: 'Developer',
|
||||||
@@ -37,6 +42,10 @@ export const personal = {
|
|||||||
medicalSources: 'Medical Advice Sources',
|
medicalSources: 'Medical Advice Sources',
|
||||||
customization: 'Customization',
|
customization: 'Customization',
|
||||||
},
|
},
|
||||||
|
healthProfile: {
|
||||||
|
title: 'Health Profile',
|
||||||
|
subtitle: 'Manage your personal health data and family profile',
|
||||||
|
},
|
||||||
versionCheck: {
|
versionCheck: {
|
||||||
sectionTitle: 'Updates',
|
sectionTitle: 'Updates',
|
||||||
menuTitle: 'Check for updates',
|
menuTitle: 'Check for updates',
|
||||||
@@ -103,6 +112,30 @@ export const personal = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const statisticsCustomization = {
|
||||||
|
title: 'Home Content Settings',
|
||||||
|
sectionTitle: 'Body Metrics Cards',
|
||||||
|
description: {
|
||||||
|
text: '• Customize the body metrics modules displayed on the home page\n• Hidden modules will not be shown on the home page, but data will be retained',
|
||||||
|
},
|
||||||
|
items: {
|
||||||
|
mood: 'Mood',
|
||||||
|
steps: 'Steps',
|
||||||
|
stress: 'Stress',
|
||||||
|
sleep: 'Sleep',
|
||||||
|
sunlight: 'Sun',
|
||||||
|
fitnessRings: 'Fitness Rings',
|
||||||
|
water: 'Water Intake',
|
||||||
|
basalMetabolism: 'Basal Metabolism',
|
||||||
|
oxygenSaturation: 'Oxygen Saturation',
|
||||||
|
wristTemperature: 'Wrist Temperature',
|
||||||
|
menstrualCycle: 'Menstrual Cycle',
|
||||||
|
weight: 'Weight',
|
||||||
|
circumference: 'Circumference',
|
||||||
|
},
|
||||||
|
vipRequired: 'VIP membership required to customize home layout',
|
||||||
|
};
|
||||||
|
|
||||||
export const editProfile = {
|
export const editProfile = {
|
||||||
title: 'Edit Profile',
|
title: 'Edit Profile',
|
||||||
fields: {
|
fields: {
|
||||||
@@ -385,6 +418,10 @@ export const notificationSettings = {
|
|||||||
title: 'Nutrition Record Reminder',
|
title: 'Nutrition Record Reminder',
|
||||||
description: 'Receive nutrition record reminders at meal times',
|
description: 'Receive nutrition record reminders at meal times',
|
||||||
},
|
},
|
||||||
|
hrvReminder: {
|
||||||
|
title: 'HRV Stress Alert',
|
||||||
|
description: 'Get guidance when elevated stress is detected from HRV',
|
||||||
|
},
|
||||||
moodReminder: {
|
moodReminder: {
|
||||||
title: 'Mood Record Reminder',
|
title: 'Mood Record Reminder',
|
||||||
description: 'Receive mood record reminders in the evening',
|
description: 'Receive mood record reminders in the evening',
|
||||||
@@ -406,6 +443,7 @@ export const notificationSettings = {
|
|||||||
saveFailed: 'Failed to save settings',
|
saveFailed: 'Failed to save settings',
|
||||||
medicationReminderFailed: 'Failed to set medication reminder',
|
medicationReminderFailed: 'Failed to set medication reminder',
|
||||||
nutritionReminderFailed: 'Failed to set nutrition reminder',
|
nutritionReminderFailed: 'Failed to set nutrition reminder',
|
||||||
|
hrvReminderFailed: 'Failed to set HRV reminder',
|
||||||
moodReminderFailed: 'Failed to set mood reminder',
|
moodReminderFailed: 'Failed to set mood reminder',
|
||||||
},
|
},
|
||||||
notificationsEnabled: {
|
notificationsEnabled: {
|
||||||
@@ -420,6 +458,10 @@ export const notificationSettings = {
|
|||||||
title: 'Nutrition Reminder Enabled',
|
title: 'Nutrition Reminder Enabled',
|
||||||
body: 'You will receive nutrition record reminders at meal times',
|
body: 'You will receive nutrition record reminders at meal times',
|
||||||
},
|
},
|
||||||
|
hrvReminderEnabled: {
|
||||||
|
title: 'HRV Reminder Enabled',
|
||||||
|
body: 'You will get tips when elevated stress is detected from HRV',
|
||||||
|
},
|
||||||
moodReminderEnabled: {
|
moodReminderEnabled: {
|
||||||
title: 'Mood Reminder Enabled',
|
title: 'Mood Reminder Enabled',
|
||||||
body: 'You will receive mood record reminders in the evening',
|
body: 'You will receive mood record reminders in the evening',
|
||||||
|
|||||||
@@ -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}}',
|
||||||
@@ -177,6 +200,11 @@ export const statistics = {
|
|||||||
days: '天',
|
days: '天',
|
||||||
range: '范围',
|
range: '范围',
|
||||||
unit: 'kg',
|
unit: 'kg',
|
||||||
|
progress: {
|
||||||
|
lost: '已减',
|
||||||
|
toGo: '距目标',
|
||||||
|
},
|
||||||
|
demo: '示例数据',
|
||||||
bmiModal: {
|
bmiModal: {
|
||||||
title: 'BMI 指数说明',
|
title: 'BMI 指数说明',
|
||||||
description: 'BMI(身体质量指数)是评估体重与身高关系的国际通用健康指标',
|
description: 'BMI(身体质量指数)是评估体重与身高关系的国际通用健康指标',
|
||||||
@@ -206,13 +234,6 @@ export const statistics = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
tabs: {
|
|
||||||
health: '健康',
|
|
||||||
medications: '用药',
|
|
||||||
fasting: '断食',
|
|
||||||
challenges: '挑战',
|
|
||||||
personal: '个人',
|
|
||||||
},
|
|
||||||
activityHeatMap: {
|
activityHeatMap: {
|
||||||
subtitle: '最近6个月活跃 {{days}} 天',
|
subtitle: '最近6个月活跃 {{days}} 天',
|
||||||
activeRate: '{{rate}}%',
|
activeRate: '{{rate}}%',
|
||||||
@@ -657,6 +678,45 @@ export const workoutDetail = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const sleepNotification = {
|
||||||
|
// 通知正文模板
|
||||||
|
body: '昨晚睡了 {{duration}},睡眠效率 {{efficiency}}%,得分 {{score}} 分 🎯',
|
||||||
|
|
||||||
|
// 睡眠质量标题 - 更温暖鼓励的语气
|
||||||
|
quality: {
|
||||||
|
excellent: '太棒了!睡得真好',
|
||||||
|
good: '不错哦!睡眠质量良好',
|
||||||
|
fair: '还行,明天会更好',
|
||||||
|
poor: '辛苦了,今晚早点休息',
|
||||||
|
veryPoor: '抱抱,好好照顾自己',
|
||||||
|
default: '睡眠分析完成啦',
|
||||||
|
},
|
||||||
|
|
||||||
|
// 睡眠时长格式化
|
||||||
|
duration: {
|
||||||
|
hoursOnly: '{{hours}} 小时',
|
||||||
|
hoursAndMinutes: '{{hours}} 小时 {{minutes}} 分钟',
|
||||||
|
},
|
||||||
|
|
||||||
|
// 睡眠建议 - 更鼓励的语气
|
||||||
|
tips: {
|
||||||
|
excellent: {
|
||||||
|
keepItUp: '继续保持,你真的很棒!',
|
||||||
|
greatJob: '身体一定很感谢你的照顾~',
|
||||||
|
energized: '今天一定精力满满!',
|
||||||
|
proud: '为自己的好习惯点赞!',
|
||||||
|
},
|
||||||
|
suggestions: {
|
||||||
|
shortSleep: '试着早点上床吧,7-9 小时的睡眠会让你更有活力哦~',
|
||||||
|
longSleep: '睡太久也会累哦,试试固定起床时间~',
|
||||||
|
lowDeepSleep: '睡前放下手机,让大脑好好休息~',
|
||||||
|
lowRemSleep: '规律作息能帮助你做更多好梦~',
|
||||||
|
lowEfficiency: '调整一下卧室环境,会睡得更香哦~',
|
||||||
|
},
|
||||||
|
general: '每一晚都是新的开始,照顾好自己~',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export const workoutHistory = {
|
export const workoutHistory = {
|
||||||
title: '锻炼总结',
|
title: '锻炼总结',
|
||||||
loading: '正在加载锻炼记录...',
|
loading: '正在加载锻炼记录...',
|
||||||
@@ -688,3 +748,127 @@ export const workoutHistory = {
|
|||||||
},
|
},
|
||||||
monthOccurrence: '这是你{{month}}的第 {{index}} 次{{activity}}。',
|
monthOccurrence: '这是你{{month}}的第 {{index}} 次{{activity}}。',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const familyGroup = {
|
||||||
|
joinTitle: '加入家庭组',
|
||||||
|
joinDescription: '输入家人分享的邀请码,加入家庭健康管理',
|
||||||
|
inviteCodePlaceholder: '请输入邀请码',
|
||||||
|
relationshipLabel: '与创建者的关系',
|
||||||
|
relationshipPlaceholder: '请选择关系',
|
||||||
|
joinButton: '加入',
|
||||||
|
joining: '加入中...',
|
||||||
|
cancel: '取消',
|
||||||
|
errors: {
|
||||||
|
emptyCode: '请输入邀请码',
|
||||||
|
emptyRelationship: '请选择与创建者的关系',
|
||||||
|
},
|
||||||
|
success: '成功加入家庭组',
|
||||||
|
relationships: {
|
||||||
|
spouse: '配偶',
|
||||||
|
father: '父亲',
|
||||||
|
mother: '母亲',
|
||||||
|
son: '儿子',
|
||||||
|
daughter: '女儿',
|
||||||
|
grandfather: '爷爷/外公',
|
||||||
|
grandmother: '奶奶/外婆',
|
||||||
|
grandson: '孙子/外孙',
|
||||||
|
granddaughter: '孙女/外孙女',
|
||||||
|
brother: '兄弟',
|
||||||
|
sister: '姐妹',
|
||||||
|
uncle: '叔叔/舅舅',
|
||||||
|
aunt: '阿姨/姑姑',
|
||||||
|
nephew: '侄子/外甥',
|
||||||
|
niece: '侄女/外甥女',
|
||||||
|
cousin: '表/堂兄弟姐妹',
|
||||||
|
other: '其他',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const health = {
|
||||||
|
tabs: {
|
||||||
|
health: '健康',
|
||||||
|
medications: '用药',
|
||||||
|
fasting: '断食',
|
||||||
|
challenges: '挑战',
|
||||||
|
personal: '个人',
|
||||||
|
healthProfile: {
|
||||||
|
title: '健康档案',
|
||||||
|
subtitle: '邀请家人加入家庭健康管理,异常及时提醒',
|
||||||
|
privacyNotice: '档案内容仅供本人查看,我们将严格保护您的隐私',
|
||||||
|
basicInfo: '基础信息',
|
||||||
|
healthHistory: '健康史',
|
||||||
|
medicalRecords: '就医资料',
|
||||||
|
checkupRecords: '体检记录',
|
||||||
|
medicineBox: '药品管理',
|
||||||
|
basicInfoCard: {
|
||||||
|
title: '基础信息',
|
||||||
|
noData: '暂无数据',
|
||||||
|
bmi: 'BMI',
|
||||||
|
height: '身高',
|
||||||
|
heightUnit: 'CM',
|
||||||
|
weight: '体重',
|
||||||
|
weightUnit: 'KG',
|
||||||
|
waist: '腰围',
|
||||||
|
waistUnit: 'CM',
|
||||||
|
},
|
||||||
|
history: {
|
||||||
|
allergy: '过敏史',
|
||||||
|
disease: '疾病史',
|
||||||
|
surgery: '手术史',
|
||||||
|
familyDisease: '家族疾病史',
|
||||||
|
pending: '待补充',
|
||||||
|
edit: '编辑',
|
||||||
|
modal: {
|
||||||
|
question: '您是否有{{type}}?',
|
||||||
|
yes: '有',
|
||||||
|
no: '没有',
|
||||||
|
addDetails: '添加详情',
|
||||||
|
enterSpecific: '请输入具体情况...',
|
||||||
|
recommendations: '推荐选项',
|
||||||
|
save: '保存',
|
||||||
|
none: '无',
|
||||||
|
yesNoDetails: '有 (未填写详情)',
|
||||||
|
diagnosisDate: '确诊时间',
|
||||||
|
namePlaceholder: '疾病/手术名称',
|
||||||
|
addItem: '添加记录',
|
||||||
|
selectDate: '选择日期'
|
||||||
|
},
|
||||||
|
recommendationItems: {
|
||||||
|
allergy: {
|
||||||
|
penicillin: '青霉素',
|
||||||
|
sulfonamides: '磺胺类',
|
||||||
|
peanuts: '花生',
|
||||||
|
seafood: '海鲜',
|
||||||
|
pollen: '花粉',
|
||||||
|
dustMites: '尘螨',
|
||||||
|
alcohol: '酒精',
|
||||||
|
mango: '芒果'
|
||||||
|
},
|
||||||
|
disease: {
|
||||||
|
hypertension: '高血压',
|
||||||
|
diabetes: '糖尿病',
|
||||||
|
asthma: '哮喘',
|
||||||
|
heartDisease: '心脏病',
|
||||||
|
gastritis: '胃炎',
|
||||||
|
migraine: '偏头痛'
|
||||||
|
},
|
||||||
|
surgery: {
|
||||||
|
appendectomy: '阑尾切除术',
|
||||||
|
cesareanSection: '剖腹产',
|
||||||
|
tonsillectomy: '扁桃体切除术',
|
||||||
|
fractureRepair: '骨折复位术',
|
||||||
|
none: '无'
|
||||||
|
},
|
||||||
|
familyDisease: {
|
||||||
|
hypertension: '高血压',
|
||||||
|
diabetes: '糖尿病',
|
||||||
|
cancer: '癌症',
|
||||||
|
heartDisease: '心脏病',
|
||||||
|
stroke: '中风',
|
||||||
|
alzheimers: '阿尔茨海默病'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -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, // 确保通用翻译被正确导出
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -238,7 +238,7 @@ export const medications = {
|
|||||||
periodRange: '从 {{startDate}} 至 {{endDate}}',
|
periodRange: '从 {{startDate}} 至 {{endDate}}',
|
||||||
periodLongTerm: '从 {{startDate}} 至长期',
|
periodLongTerm: '从 {{startDate}} 至长期',
|
||||||
expiryStatus: {
|
expiryStatus: {
|
||||||
notSet: '未设置',
|
notSet: '未设置(过期预警)',
|
||||||
expired: '已过期',
|
expired: '已过期',
|
||||||
expiresToday: '今天到期',
|
expiresToday: '今天到期',
|
||||||
expiresInDays: '{{days}}天后到期',
|
expiresInDays: '{{days}}天后到期',
|
||||||
|
|||||||
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 个周期的记录,计算平均经期和周期长度,后续会展示趋势和预测准确度。',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -27,6 +27,11 @@ export const personal = {
|
|||||||
validForever: '长期有效',
|
validForever: '长期有效',
|
||||||
dateFormat: 'YYYY年MM月DD日',
|
dateFormat: 'YYYY年MM月DD日',
|
||||||
},
|
},
|
||||||
|
membershipBanner: {
|
||||||
|
title: '解锁尊享会员权益',
|
||||||
|
subtitle: '无限次使用 AI 功能,定制专属健康计划',
|
||||||
|
cta: '立即升级',
|
||||||
|
},
|
||||||
sections: {
|
sections: {
|
||||||
notifications: '通知',
|
notifications: '通知',
|
||||||
developer: '开发者',
|
developer: '开发者',
|
||||||
@@ -37,6 +42,10 @@ export const personal = {
|
|||||||
medicalSources: '医学建议来源',
|
medicalSources: '医学建议来源',
|
||||||
customization: '个性化',
|
customization: '个性化',
|
||||||
},
|
},
|
||||||
|
healthProfile: {
|
||||||
|
title: '健康档案',
|
||||||
|
subtitle: '管理您的个人健康数据与家庭档案',
|
||||||
|
},
|
||||||
versionCheck: {
|
versionCheck: {
|
||||||
sectionTitle: '版本与更新',
|
sectionTitle: '版本与更新',
|
||||||
menuTitle: '检查更新',
|
menuTitle: '检查更新',
|
||||||
@@ -103,6 +112,30 @@ export const personal = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const statisticsCustomization = {
|
||||||
|
title: '首页内容设置',
|
||||||
|
sectionTitle: '身体指标卡片',
|
||||||
|
description: {
|
||||||
|
text: '• 自定义首页展示的身体指标模块\n• 关闭的模块将不会在首页显示,但数据仍会保留',
|
||||||
|
},
|
||||||
|
items: {
|
||||||
|
mood: '心情',
|
||||||
|
steps: '步数',
|
||||||
|
stress: '压力',
|
||||||
|
sleep: '睡眠',
|
||||||
|
sunlight: '晒太阳',
|
||||||
|
fitnessRings: '健身圆环',
|
||||||
|
water: '饮水',
|
||||||
|
basalMetabolism: '基础代谢',
|
||||||
|
oxygenSaturation: '血氧',
|
||||||
|
wristTemperature: '手腕温度',
|
||||||
|
menstrualCycle: '经期',
|
||||||
|
weight: '体重',
|
||||||
|
circumference: '围度',
|
||||||
|
},
|
||||||
|
vipRequired: '需要开通 VIP 会员才能自定义首页布局',
|
||||||
|
};
|
||||||
|
|
||||||
export const editProfile = {
|
export const editProfile = {
|
||||||
title: '编辑资料',
|
title: '编辑资料',
|
||||||
fields: {
|
fields: {
|
||||||
@@ -389,6 +422,10 @@ export const notificationSettings = {
|
|||||||
title: '营养记录提醒',
|
title: '营养记录提醒',
|
||||||
description: '在用餐时间接收营养记录提醒',
|
description: '在用餐时间接收营养记录提醒',
|
||||||
},
|
},
|
||||||
|
hrvReminder: {
|
||||||
|
title: 'HRV 压力提醒',
|
||||||
|
description: '监测到压力偏高时发送健康建议',
|
||||||
|
},
|
||||||
moodReminder: {
|
moodReminder: {
|
||||||
title: '心情记录提醒',
|
title: '心情记录提醒',
|
||||||
description: '在晚间接收心情记录提醒',
|
description: '在晚间接收心情记录提醒',
|
||||||
@@ -410,6 +447,7 @@ export const notificationSettings = {
|
|||||||
saveFailed: '保存设置失败',
|
saveFailed: '保存设置失败',
|
||||||
medicationReminderFailed: '设置药品提醒失败',
|
medicationReminderFailed: '设置药品提醒失败',
|
||||||
nutritionReminderFailed: '设置营养提醒失败',
|
nutritionReminderFailed: '设置营养提醒失败',
|
||||||
|
hrvReminderFailed: '设置 HRV 提醒失败',
|
||||||
moodReminderFailed: '设置心情提醒失败',
|
moodReminderFailed: '设置心情提醒失败',
|
||||||
},
|
},
|
||||||
notificationsEnabled: {
|
notificationsEnabled: {
|
||||||
@@ -424,6 +462,10 @@ export const notificationSettings = {
|
|||||||
title: '营养提醒已开启',
|
title: '营养提醒已开启',
|
||||||
body: '您将在用餐时间收到营养记录提醒',
|
body: '您将在用餐时间收到营养记录提醒',
|
||||||
},
|
},
|
||||||
|
hrvReminderEnabled: {
|
||||||
|
title: 'HRV 提醒已开启',
|
||||||
|
body: '检测到压力升高时将收到健康建议推送',
|
||||||
|
},
|
||||||
moodReminderEnabled: {
|
moodReminderEnabled: {
|
||||||
title: '心情提醒已开启',
|
title: '心情提醒已开启',
|
||||||
body: '您将在晚间收到心情记录提醒',
|
body: '您将在晚间收到心情记录提醒',
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
792C52632EB05B8F002F3F09 /* NativeToastManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 792C52612EB05B8F002F3F09 /* NativeToastManager.swift */; };
|
792C52632EB05B8F002F3F09 /* NativeToastManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 792C52612EB05B8F002F3F09 /* NativeToastManager.swift */; };
|
||||||
794DD5D62ED3E3BB0046E2B4 /* AppStoreReviewManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 794DD5D42ED3E3BB0046E2B4 /* AppStoreReviewManager.m */; };
|
794DD5D62ED3E3BB0046E2B4 /* AppStoreReviewManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 794DD5D42ED3E3BB0046E2B4 /* AppStoreReviewManager.m */; };
|
||||||
794DD5D72ED3E3BB0046E2B4 /* AppStoreReviewManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 794DD5D52ED3E3BB0046E2B4 /* AppStoreReviewManager.swift */; };
|
794DD5D72ED3E3BB0046E2B4 /* AppStoreReviewManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 794DD5D52ED3E3BB0046E2B4 /* AppStoreReviewManager.swift */; };
|
||||||
|
7981D9922EDFC0B5008D5F2D /* InfoPlist.strings in Sources */ = {isa = PBXBuildFile; fileRef = 7981D9912EDFC0B5008D5F2D /* InfoPlist.strings */; };
|
||||||
79B2CB702E7B954600B51753 /* OutLive-Bridging-Header.h in Sources */ = {isa = PBXBuildFile; fileRef = F11748442D0722820044C1D9 /* OutLive-Bridging-Header.h */; };
|
79B2CB702E7B954600B51753 /* OutLive-Bridging-Header.h in Sources */ = {isa = PBXBuildFile; fileRef = F11748442D0722820044C1D9 /* OutLive-Bridging-Header.h */; };
|
||||||
79B2CB732E7B954F00B51753 /* HealthKitManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 79B2CB712E7B954F00B51753 /* HealthKitManager.m */; };
|
79B2CB732E7B954F00B51753 /* HealthKitManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 79B2CB712E7B954F00B51753 /* HealthKitManager.m */; };
|
||||||
79B2CB742E7B954F00B51753 /* HealthKitManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79B2CB722E7B954F00B51753 /* HealthKitManager.swift */; };
|
79B2CB742E7B954F00B51753 /* HealthKitManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79B2CB722E7B954F00B51753 /* HealthKitManager.swift */; };
|
||||||
@@ -68,6 +69,8 @@
|
|||||||
792C52612EB05B8F002F3F09 /* NativeToastManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = NativeToastManager.swift; path = OutLive/NativeToastManager.swift; sourceTree = "<group>"; };
|
792C52612EB05B8F002F3F09 /* NativeToastManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = NativeToastManager.swift; path = OutLive/NativeToastManager.swift; sourceTree = "<group>"; };
|
||||||
794DD5D42ED3E3BB0046E2B4 /* AppStoreReviewManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = AppStoreReviewManager.m; sourceTree = "<group>"; };
|
794DD5D42ED3E3BB0046E2B4 /* AppStoreReviewManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = AppStoreReviewManager.m; sourceTree = "<group>"; };
|
||||||
794DD5D52ED3E3BB0046E2B4 /* AppStoreReviewManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStoreReviewManager.swift; sourceTree = "<group>"; };
|
794DD5D52ED3E3BB0046E2B4 /* AppStoreReviewManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStoreReviewManager.swift; sourceTree = "<group>"; };
|
||||||
|
7981D9902EDFC0B5008D5F2D /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/InfoPlist.strings"; sourceTree = "<group>"; };
|
||||||
|
7981D9932EDFC0B8008D5F2D /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/InfoPlist.strings; sourceTree = "<group>"; };
|
||||||
79B2CB712E7B954F00B51753 /* HealthKitManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = HealthKitManager.m; path = OutLive/HealthKitManager.m; sourceTree = "<group>"; };
|
79B2CB712E7B954F00B51753 /* HealthKitManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = HealthKitManager.m; path = OutLive/HealthKitManager.m; sourceTree = "<group>"; };
|
||||||
79B2CB722E7B954F00B51753 /* HealthKitManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = HealthKitManager.swift; path = OutLive/HealthKitManager.swift; sourceTree = "<group>"; };
|
79B2CB722E7B954F00B51753 /* HealthKitManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = HealthKitManager.swift; path = OutLive/HealthKitManager.swift; sourceTree = "<group>"; };
|
||||||
79E80BA22EC5D92A004425BE /* medicineExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = medicineExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
79E80BA22EC5D92A004425BE /* medicineExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = medicineExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
@@ -140,6 +143,7 @@
|
|||||||
13B07FAE1A68108700A75B9A /* OutLive */ = {
|
13B07FAE1A68108700A75B9A /* OutLive */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
7981D9912EDFC0B5008D5F2D /* InfoPlist.strings */,
|
||||||
F11748412D0307B40044C1D9 /* AppDelegate.swift */,
|
F11748412D0307B40044C1D9 /* AppDelegate.swift */,
|
||||||
F11748442D0722820044C1D9 /* OutLive-Bridging-Header.h */,
|
F11748442D0722820044C1D9 /* OutLive-Bridging-Header.h */,
|
||||||
BB2F792B24A3F905000567C9 /* Supporting */,
|
BB2F792B24A3F905000567C9 /* Supporting */,
|
||||||
@@ -309,10 +313,11 @@
|
|||||||
};
|
};
|
||||||
buildConfigurationList = 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "OutLive" */;
|
buildConfigurationList = 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "OutLive" */;
|
||||||
compatibilityVersion = "Xcode 3.2";
|
compatibilityVersion = "Xcode 3.2";
|
||||||
developmentRegion = en;
|
developmentRegion = "zh-Hans";
|
||||||
hasScannedForEncodings = 0;
|
hasScannedForEncodings = 0;
|
||||||
knownRegions = (
|
knownRegions = (
|
||||||
en,
|
en,
|
||||||
|
"zh-Hans",
|
||||||
Base,
|
Base,
|
||||||
);
|
);
|
||||||
mainGroup = 83CBB9F61A601CBA00E9B192;
|
mainGroup = 83CBB9F61A601CBA00E9B192;
|
||||||
@@ -399,6 +404,7 @@
|
|||||||
"${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/ExpoConstants_privacy.bundle",
|
"${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/ExpoConstants_privacy.bundle",
|
||||||
"${PODS_CONFIGURATION_BUILD_DIR}/EXNotifications/ExpoNotifications_privacy.bundle",
|
"${PODS_CONFIGURATION_BUILD_DIR}/EXNotifications/ExpoNotifications_privacy.bundle",
|
||||||
"${PODS_CONFIGURATION_BUILD_DIR}/EXTaskManager/ExpoTaskManager_privacy.bundle",
|
"${PODS_CONFIGURATION_BUILD_DIR}/EXTaskManager/ExpoTaskManager_privacy.bundle",
|
||||||
|
"${PODS_CONFIGURATION_BUILD_DIR}/EXUpdates/EXUpdates.bundle",
|
||||||
"${PODS_CONFIGURATION_BUILD_DIR}/ExpoFileSystem/ExpoFileSystem_privacy.bundle",
|
"${PODS_CONFIGURATION_BUILD_DIR}/ExpoFileSystem/ExpoFileSystem_privacy.bundle",
|
||||||
"${PODS_CONFIGURATION_BUILD_DIR}/ExpoLocalization/ExpoLocalization_privacy.bundle",
|
"${PODS_CONFIGURATION_BUILD_DIR}/ExpoLocalization/ExpoLocalization_privacy.bundle",
|
||||||
"${PODS_CONFIGURATION_BUILD_DIR}/ExpoMediaLibrary/ExpoMediaLibrary_privacy.bundle",
|
"${PODS_CONFIGURATION_BUILD_DIR}/ExpoMediaLibrary/ExpoMediaLibrary_privacy.bundle",
|
||||||
@@ -407,6 +413,7 @@
|
|||||||
"${PODS_CONFIGURATION_BUILD_DIR}/RNCAsyncStorage/RNCAsyncStorage_resources.bundle",
|
"${PODS_CONFIGURATION_BUILD_DIR}/RNCAsyncStorage/RNCAsyncStorage_resources.bundle",
|
||||||
"${PODS_CONFIGURATION_BUILD_DIR}/RNDeviceInfo/RNDeviceInfoPrivacyInfo.bundle",
|
"${PODS_CONFIGURATION_BUILD_DIR}/RNDeviceInfo/RNDeviceInfoPrivacyInfo.bundle",
|
||||||
"${PODS_CONFIGURATION_BUILD_DIR}/RNSVG/RNSVGFilters.bundle",
|
"${PODS_CONFIGURATION_BUILD_DIR}/RNSVG/RNSVGFilters.bundle",
|
||||||
|
"${PODS_CONFIGURATION_BUILD_DIR}/ReachabilitySwift/ReachabilitySwift.bundle",
|
||||||
"${PODS_CONFIGURATION_BUILD_DIR}/React-Core/React-Core_privacy.bundle",
|
"${PODS_CONFIGURATION_BUILD_DIR}/React-Core/React-Core_privacy.bundle",
|
||||||
"${PODS_CONFIGURATION_BUILD_DIR}/React-cxxreact/React-cxxreact_privacy.bundle",
|
"${PODS_CONFIGURATION_BUILD_DIR}/React-cxxreact/React-cxxreact_privacy.bundle",
|
||||||
"${PODS_CONFIGURATION_BUILD_DIR}/RevenueCat/RevenueCat.bundle",
|
"${PODS_CONFIGURATION_BUILD_DIR}/RevenueCat/RevenueCat.bundle",
|
||||||
@@ -422,6 +429,7 @@
|
|||||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoConstants_privacy.bundle",
|
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoConstants_privacy.bundle",
|
||||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoNotifications_privacy.bundle",
|
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoNotifications_privacy.bundle",
|
||||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoTaskManager_privacy.bundle",
|
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoTaskManager_privacy.bundle",
|
||||||
|
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXUpdates.bundle",
|
||||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoFileSystem_privacy.bundle",
|
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoFileSystem_privacy.bundle",
|
||||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoLocalization_privacy.bundle",
|
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoLocalization_privacy.bundle",
|
||||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoMediaLibrary_privacy.bundle",
|
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoMediaLibrary_privacy.bundle",
|
||||||
@@ -430,6 +438,7 @@
|
|||||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNCAsyncStorage_resources.bundle",
|
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNCAsyncStorage_resources.bundle",
|
||||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNDeviceInfoPrivacyInfo.bundle",
|
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNDeviceInfoPrivacyInfo.bundle",
|
||||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNSVGFilters.bundle",
|
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNSVGFilters.bundle",
|
||||||
|
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ReachabilitySwift.bundle",
|
||||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-Core_privacy.bundle",
|
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-Core_privacy.bundle",
|
||||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-cxxreact_privacy.bundle",
|
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-cxxreact_privacy.bundle",
|
||||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RevenueCat.bundle",
|
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RevenueCat.bundle",
|
||||||
@@ -500,6 +509,7 @@
|
|||||||
79E80BFF2EC5E127004425BE /* AppGroupUserDefaultsManager.m in Sources */,
|
79E80BFF2EC5E127004425BE /* AppGroupUserDefaultsManager.m in Sources */,
|
||||||
79E80C002EC5E127004425BE /* WidgetManager.m in Sources */,
|
79E80C002EC5E127004425BE /* WidgetManager.m in Sources */,
|
||||||
79E80C522EC5E500004425BE /* WidgetCenterHelper.swift in Sources */,
|
79E80C522EC5E500004425BE /* WidgetCenterHelper.swift in Sources */,
|
||||||
|
7981D9922EDFC0B5008D5F2D /* InfoPlist.strings in Sources */,
|
||||||
792C52632EB05B8F002F3F09 /* NativeToastManager.swift in Sources */,
|
792C52632EB05B8F002F3F09 /* NativeToastManager.swift in Sources */,
|
||||||
79B2CB732E7B954F00B51753 /* HealthKitManager.m in Sources */,
|
79B2CB732E7B954F00B51753 /* HealthKitManager.m in Sources */,
|
||||||
79B2CB742E7B954F00B51753 /* HealthKitManager.swift in Sources */,
|
79B2CB742E7B954F00B51753 /* HealthKitManager.swift in Sources */,
|
||||||
@@ -530,6 +540,18 @@
|
|||||||
};
|
};
|
||||||
/* End PBXTargetDependency section */
|
/* End PBXTargetDependency section */
|
||||||
|
|
||||||
|
/* Begin PBXVariantGroup section */
|
||||||
|
7981D9912EDFC0B5008D5F2D /* InfoPlist.strings */ = {
|
||||||
|
isa = PBXVariantGroup;
|
||||||
|
children = (
|
||||||
|
7981D9902EDFC0B5008D5F2D /* zh-Hans */,
|
||||||
|
7981D9932EDFC0B8008D5F2D /* en */,
|
||||||
|
);
|
||||||
|
name = InfoPlist.strings;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
/* End PBXVariantGroup section */
|
||||||
|
|
||||||
/* Begin XCBuildConfiguration section */
|
/* Begin XCBuildConfiguration section */
|
||||||
13B07F941A680F5B00A75B9A /* Debug */ = {
|
13B07F941A680F5B00A75B9A /* Debug */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
@@ -620,7 +642,7 @@
|
|||||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = medicine/Info.plist;
|
INFOPLIST_FILE = medicine/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = medicine;
|
INFOPLIST_KEY_CFBundleDisplayName = "用药计划";
|
||||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 26.1;
|
IPHONEOS_DEPLOYMENT_TARGET = 26.1;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
@@ -670,7 +692,7 @@
|
|||||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = medicine/Info.plist;
|
INFOPLIST_FILE = medicine/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = medicine;
|
INFOPLIST_KEY_CFBundleDisplayName = "用药计划";
|
||||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 26.1;
|
IPHONEOS_DEPLOYMENT_TARGET = 26.1;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
|||||||
@@ -41,7 +41,7 @@
|
|||||||
</Testables>
|
</Testables>
|
||||||
</TestAction>
|
</TestAction>
|
||||||
<LaunchAction
|
<LaunchAction
|
||||||
buildConfiguration = "Debug"
|
buildConfiguration = "Release"
|
||||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
launchStyle = "0"
|
launchStyle = "0"
|
||||||
|
|||||||
@@ -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.4</string>
|
<string>1.1.6</string>
|
||||||
<key>CFBundleSignature</key>
|
<key>CFBundleSignature</key>
|
||||||
<string>????</string>
|
<string>????</string>
|
||||||
<key>CFBundleURLTypes</key>
|
<key>CFBundleURLTypes</key>
|
||||||
|
|||||||
@@ -2,11 +2,15 @@
|
|||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
|
<key>EXUpdatesEnabled</key>
|
||||||
|
<true/>
|
||||||
|
<key>EXUpdatesURL</key>
|
||||||
|
<string>https://pilate.richarjiang.com/api/expo-updates/manifest</string>
|
||||||
<key>EXUpdatesCheckOnLaunch</key>
|
<key>EXUpdatesCheckOnLaunch</key>
|
||||||
<string>ALWAYS</string>
|
<string>ALWAYS</string>
|
||||||
<key>EXUpdatesEnabled</key>
|
|
||||||
<false/>
|
|
||||||
<key>EXUpdatesLaunchWaitMs</key>
|
<key>EXUpdatesLaunchWaitMs</key>
|
||||||
<integer>0</integer>
|
<integer>0</integer>
|
||||||
|
<key>EXUpdatesRuntimeVersion</key>
|
||||||
|
<string>1.1.4</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
157
ios/Podfile.lock
157
ios/Podfile.lock
@@ -1,4 +1,6 @@
|
|||||||
PODS:
|
PODS:
|
||||||
|
- EASClient (1.0.7):
|
||||||
|
- ExpoModulesCore
|
||||||
- EXApplication (7.0.7):
|
- EXApplication (7.0.7):
|
||||||
- ExpoModulesCore
|
- ExpoModulesCore
|
||||||
- EXConstants (18.0.10):
|
- EXConstants (18.0.10):
|
||||||
@@ -6,9 +8,12 @@ PODS:
|
|||||||
- EXImageLoader (6.0.0):
|
- EXImageLoader (6.0.0):
|
||||||
- ExpoModulesCore
|
- ExpoModulesCore
|
||||||
- React-Core
|
- React-Core
|
||||||
- EXNotifications (0.32.12):
|
- EXJSONUtils (0.15.0)
|
||||||
|
- EXManifests (1.0.9):
|
||||||
- ExpoModulesCore
|
- ExpoModulesCore
|
||||||
- Expo (54.0.25):
|
- EXNotifications (0.32.13):
|
||||||
|
- ExpoModulesCore
|
||||||
|
- Expo (54.0.26):
|
||||||
- ExpoModulesCore
|
- ExpoModulesCore
|
||||||
- hermes-engine
|
- hermes-engine
|
||||||
- RCTRequired
|
- RCTRequired
|
||||||
@@ -37,7 +42,7 @@ PODS:
|
|||||||
- ExpoModulesCore
|
- ExpoModulesCore
|
||||||
- ExpoAsset (12.0.10):
|
- ExpoAsset (12.0.10):
|
||||||
- ExpoModulesCore
|
- ExpoModulesCore
|
||||||
- ExpoBackgroundTask (1.0.8):
|
- ExpoBackgroundTask (1.0.9):
|
||||||
- ExpoModulesCore
|
- ExpoModulesCore
|
||||||
- ExpoBlur (15.0.7):
|
- ExpoBlur (15.0.7):
|
||||||
- ExpoModulesCore
|
- ExpoModulesCore
|
||||||
@@ -47,6 +52,8 @@ PODS:
|
|||||||
- ZXingObjC/PDF417
|
- ZXingObjC/PDF417
|
||||||
- ExpoClipboard (8.0.7):
|
- ExpoClipboard (8.0.7):
|
||||||
- ExpoModulesCore
|
- ExpoModulesCore
|
||||||
|
- ExpoDocumentPicker (14.0.7):
|
||||||
|
- ExpoModulesCore
|
||||||
- ExpoFileSystem (19.0.19):
|
- ExpoFileSystem (19.0.19):
|
||||||
- ExpoModulesCore
|
- ExpoModulesCore
|
||||||
- ExpoFont (14.0.9):
|
- ExpoFont (14.0.9):
|
||||||
@@ -55,7 +62,7 @@ PODS:
|
|||||||
- ExpoModulesCore
|
- ExpoModulesCore
|
||||||
- ExpoHaptics (15.0.7):
|
- ExpoHaptics (15.0.7):
|
||||||
- ExpoModulesCore
|
- ExpoModulesCore
|
||||||
- ExpoHead (6.0.15):
|
- ExpoHead (6.0.16):
|
||||||
- ExpoModulesCore
|
- ExpoModulesCore
|
||||||
- RNScreens
|
- RNScreens
|
||||||
- ExpoImage (3.0.10):
|
- ExpoImage (3.0.10):
|
||||||
@@ -78,7 +85,7 @@ PODS:
|
|||||||
- ExpoMediaLibrary (18.2.0):
|
- ExpoMediaLibrary (18.2.0):
|
||||||
- ExpoModulesCore
|
- ExpoModulesCore
|
||||||
- React-Core
|
- React-Core
|
||||||
- ExpoModulesCore (3.0.26):
|
- ExpoModulesCore (3.0.27):
|
||||||
- hermes-engine
|
- hermes-engine
|
||||||
- RCTRequired
|
- RCTRequired
|
||||||
- RCTTypeSafety
|
- RCTTypeSafety
|
||||||
@@ -105,7 +112,7 @@ PODS:
|
|||||||
- ExpoModulesCore
|
- ExpoModulesCore
|
||||||
- ExpoSplashScreen (31.0.11):
|
- ExpoSplashScreen (31.0.11):
|
||||||
- ExpoModulesCore
|
- ExpoModulesCore
|
||||||
- ExpoSQLite (16.0.8):
|
- ExpoSQLite (16.0.9):
|
||||||
- ExpoModulesCore
|
- ExpoModulesCore
|
||||||
- ExpoSymbols (1.0.7):
|
- ExpoSymbols (1.0.7):
|
||||||
- ExpoModulesCore
|
- ExpoModulesCore
|
||||||
@@ -113,11 +120,42 @@ PODS:
|
|||||||
- ExpoModulesCore
|
- ExpoModulesCore
|
||||||
- ExpoUI (0.2.0-beta.7):
|
- ExpoUI (0.2.0-beta.7):
|
||||||
- ExpoModulesCore
|
- ExpoModulesCore
|
||||||
- ExpoWebBrowser (15.0.8):
|
- ExpoWebBrowser (15.0.9):
|
||||||
- ExpoModulesCore
|
- ExpoModulesCore
|
||||||
|
- EXStructuredHeaders (5.0.0)
|
||||||
- EXTaskManager (14.0.8):
|
- EXTaskManager (14.0.8):
|
||||||
- ExpoModulesCore
|
- ExpoModulesCore
|
||||||
- UMAppLoader
|
- UMAppLoader
|
||||||
|
- EXUpdates (29.0.14):
|
||||||
|
- EASClient
|
||||||
|
- EXManifests
|
||||||
|
- ExpoModulesCore
|
||||||
|
- EXStructuredHeaders
|
||||||
|
- EXUpdatesInterface
|
||||||
|
- hermes-engine
|
||||||
|
- RCTRequired
|
||||||
|
- RCTTypeSafety
|
||||||
|
- ReachabilitySwift
|
||||||
|
- React-Core
|
||||||
|
- React-Core-prebuilt
|
||||||
|
- React-debug
|
||||||
|
- React-Fabric
|
||||||
|
- React-featureflags
|
||||||
|
- React-graphics
|
||||||
|
- React-ImageManager
|
||||||
|
- React-jsi
|
||||||
|
- React-NativeModulesApple
|
||||||
|
- React-RCTFabric
|
||||||
|
- React-renderercss
|
||||||
|
- React-rendererdebug
|
||||||
|
- React-utils
|
||||||
|
- ReactCodegen
|
||||||
|
- ReactCommon/turbomodule/bridging
|
||||||
|
- ReactCommon/turbomodule/core
|
||||||
|
- ReactNativeDependencies
|
||||||
|
- Yoga
|
||||||
|
- EXUpdatesInterface (2.0.0):
|
||||||
|
- ExpoModulesCore
|
||||||
- FBLazyVector (0.81.5)
|
- FBLazyVector (0.81.5)
|
||||||
- hermes-engine (0.81.5):
|
- hermes-engine (0.81.5):
|
||||||
- hermes-engine/Pre-built (= 0.81.5)
|
- hermes-engine/Pre-built (= 0.81.5)
|
||||||
@@ -163,14 +201,15 @@ PODS:
|
|||||||
- ReactCommon/turbomodule/core
|
- ReactCommon/turbomodule/core
|
||||||
- ReactNativeDependencies
|
- ReactNativeDependencies
|
||||||
- Yoga
|
- Yoga
|
||||||
- PurchasesHybridCommon (17.19.1):
|
- PurchasesHybridCommon (17.21.2):
|
||||||
- RevenueCat (= 5.48.0)
|
- RevenueCat (= 5.49.2)
|
||||||
- RCTDeprecation (0.81.5)
|
- RCTDeprecation (0.81.5)
|
||||||
- RCTRequired (0.81.5)
|
- RCTRequired (0.81.5)
|
||||||
- RCTTypeSafety (0.81.5):
|
- RCTTypeSafety (0.81.5):
|
||||||
- FBLazyVector (= 0.81.5)
|
- FBLazyVector (= 0.81.5)
|
||||||
- RCTRequired (= 0.81.5)
|
- RCTRequired (= 0.81.5)
|
||||||
- React-Core (= 0.81.5)
|
- React-Core (= 0.81.5)
|
||||||
|
- ReachabilitySwift (5.2.4)
|
||||||
- React (0.81.5):
|
- React (0.81.5):
|
||||||
- React-Core (= 0.81.5)
|
- React-Core (= 0.81.5)
|
||||||
- React-Core/DevSupport (= 0.81.5)
|
- React-Core/DevSupport (= 0.81.5)
|
||||||
@@ -1446,7 +1485,7 @@ PODS:
|
|||||||
- ReactNativeDependencies
|
- ReactNativeDependencies
|
||||||
- react-native-render-html (6.3.4):
|
- react-native-render-html (6.3.4):
|
||||||
- React-Core
|
- React-Core
|
||||||
- react-native-safe-area-context (5.6.1):
|
- react-native-safe-area-context (5.6.2):
|
||||||
- hermes-engine
|
- hermes-engine
|
||||||
- RCTRequired
|
- RCTRequired
|
||||||
- RCTTypeSafety
|
- RCTTypeSafety
|
||||||
@@ -1458,8 +1497,8 @@ PODS:
|
|||||||
- React-graphics
|
- React-graphics
|
||||||
- React-ImageManager
|
- React-ImageManager
|
||||||
- React-jsi
|
- React-jsi
|
||||||
- react-native-safe-area-context/common (= 5.6.1)
|
- react-native-safe-area-context/common (= 5.6.2)
|
||||||
- react-native-safe-area-context/fabric (= 5.6.1)
|
- react-native-safe-area-context/fabric (= 5.6.2)
|
||||||
- React-NativeModulesApple
|
- React-NativeModulesApple
|
||||||
- React-RCTFabric
|
- React-RCTFabric
|
||||||
- React-renderercss
|
- React-renderercss
|
||||||
@@ -1470,7 +1509,7 @@ PODS:
|
|||||||
- ReactCommon/turbomodule/core
|
- ReactCommon/turbomodule/core
|
||||||
- ReactNativeDependencies
|
- ReactNativeDependencies
|
||||||
- Yoga
|
- Yoga
|
||||||
- react-native-safe-area-context/common (5.6.1):
|
- react-native-safe-area-context/common (5.6.2):
|
||||||
- hermes-engine
|
- hermes-engine
|
||||||
- RCTRequired
|
- RCTRequired
|
||||||
- RCTTypeSafety
|
- RCTTypeSafety
|
||||||
@@ -1492,7 +1531,7 @@ PODS:
|
|||||||
- ReactCommon/turbomodule/core
|
- ReactCommon/turbomodule/core
|
||||||
- ReactNativeDependencies
|
- ReactNativeDependencies
|
||||||
- Yoga
|
- Yoga
|
||||||
- react-native-safe-area-context/fabric (5.6.1):
|
- react-native-safe-area-context/fabric (5.6.2):
|
||||||
- hermes-engine
|
- hermes-engine
|
||||||
- RCTRequired
|
- RCTRequired
|
||||||
- RCTTypeSafety
|
- RCTTypeSafety
|
||||||
@@ -1911,7 +1950,7 @@ PODS:
|
|||||||
- React-utils (= 0.81.5)
|
- React-utils (= 0.81.5)
|
||||||
- ReactNativeDependencies
|
- ReactNativeDependencies
|
||||||
- ReactNativeDependencies (0.81.5)
|
- ReactNativeDependencies (0.81.5)
|
||||||
- RevenueCat (5.48.0)
|
- RevenueCat (5.49.2)
|
||||||
- RNCAsyncStorage (2.2.0):
|
- RNCAsyncStorage (2.2.0):
|
||||||
- hermes-engine
|
- hermes-engine
|
||||||
- RCTRequired
|
- RCTRequired
|
||||||
@@ -2024,10 +2063,10 @@ PODS:
|
|||||||
- ReactCommon/turbomodule/core
|
- ReactCommon/turbomodule/core
|
||||||
- ReactNativeDependencies
|
- ReactNativeDependencies
|
||||||
- Yoga
|
- Yoga
|
||||||
- RNPurchases (9.6.7):
|
- RNPurchases (9.6.9):
|
||||||
- PurchasesHybridCommon (= 17.19.1)
|
- PurchasesHybridCommon (= 17.21.2)
|
||||||
- React-Core
|
- React-Core
|
||||||
- RNReanimated (4.1.5):
|
- RNReanimated (4.1.6):
|
||||||
- hermes-engine
|
- hermes-engine
|
||||||
- RCTRequired
|
- RCTRequired
|
||||||
- RCTTypeSafety
|
- RCTTypeSafety
|
||||||
@@ -2049,10 +2088,10 @@ PODS:
|
|||||||
- ReactCommon/turbomodule/bridging
|
- ReactCommon/turbomodule/bridging
|
||||||
- ReactCommon/turbomodule/core
|
- ReactCommon/turbomodule/core
|
||||||
- ReactNativeDependencies
|
- ReactNativeDependencies
|
||||||
- RNReanimated/reanimated (= 4.1.5)
|
- RNReanimated/reanimated (= 4.1.6)
|
||||||
- RNWorklets
|
- RNWorklets
|
||||||
- Yoga
|
- Yoga
|
||||||
- RNReanimated/reanimated (4.1.5):
|
- RNReanimated/reanimated (4.1.6):
|
||||||
- hermes-engine
|
- hermes-engine
|
||||||
- RCTRequired
|
- RCTRequired
|
||||||
- RCTTypeSafety
|
- RCTTypeSafety
|
||||||
@@ -2074,10 +2113,10 @@ PODS:
|
|||||||
- ReactCommon/turbomodule/bridging
|
- ReactCommon/turbomodule/bridging
|
||||||
- ReactCommon/turbomodule/core
|
- ReactCommon/turbomodule/core
|
||||||
- ReactNativeDependencies
|
- ReactNativeDependencies
|
||||||
- RNReanimated/reanimated/apple (= 4.1.5)
|
- RNReanimated/reanimated/apple (= 4.1.6)
|
||||||
- RNWorklets
|
- RNWorklets
|
||||||
- Yoga
|
- Yoga
|
||||||
- RNReanimated/reanimated/apple (4.1.5):
|
- RNReanimated/reanimated/apple (4.1.6):
|
||||||
- hermes-engine
|
- hermes-engine
|
||||||
- RCTRequired
|
- RCTRequired
|
||||||
- RCTTypeSafety
|
- RCTTypeSafety
|
||||||
@@ -2217,7 +2256,7 @@ PODS:
|
|||||||
- ReactCommon/turbomodule/core
|
- ReactCommon/turbomodule/core
|
||||||
- ReactNativeDependencies
|
- ReactNativeDependencies
|
||||||
- Yoga
|
- Yoga
|
||||||
- RNWorklets (0.6.1):
|
- RNWorklets (0.7.1):
|
||||||
- hermes-engine
|
- hermes-engine
|
||||||
- RCTRequired
|
- RCTRequired
|
||||||
- RCTTypeSafety
|
- RCTTypeSafety
|
||||||
@@ -2239,9 +2278,9 @@ PODS:
|
|||||||
- ReactCommon/turbomodule/bridging
|
- ReactCommon/turbomodule/bridging
|
||||||
- ReactCommon/turbomodule/core
|
- ReactCommon/turbomodule/core
|
||||||
- ReactNativeDependencies
|
- ReactNativeDependencies
|
||||||
- RNWorklets/worklets (= 0.6.1)
|
- RNWorklets/worklets (= 0.7.1)
|
||||||
- Yoga
|
- Yoga
|
||||||
- RNWorklets/worklets (0.6.1):
|
- RNWorklets/worklets (0.7.1):
|
||||||
- hermes-engine
|
- hermes-engine
|
||||||
- RCTRequired
|
- RCTRequired
|
||||||
- RCTTypeSafety
|
- RCTTypeSafety
|
||||||
@@ -2263,9 +2302,9 @@ PODS:
|
|||||||
- ReactCommon/turbomodule/bridging
|
- ReactCommon/turbomodule/bridging
|
||||||
- ReactCommon/turbomodule/core
|
- ReactCommon/turbomodule/core
|
||||||
- ReactNativeDependencies
|
- ReactNativeDependencies
|
||||||
- RNWorklets/worklets/apple (= 0.6.1)
|
- RNWorklets/worklets/apple (= 0.7.1)
|
||||||
- Yoga
|
- Yoga
|
||||||
- RNWorklets/worklets/apple (0.6.1):
|
- RNWorklets/worklets/apple (0.7.1):
|
||||||
- hermes-engine
|
- hermes-engine
|
||||||
- RCTRequired
|
- RCTRequired
|
||||||
- RCTTypeSafety
|
- RCTTypeSafety
|
||||||
@@ -2288,9 +2327,9 @@ PODS:
|
|||||||
- ReactCommon/turbomodule/core
|
- ReactCommon/turbomodule/core
|
||||||
- ReactNativeDependencies
|
- ReactNativeDependencies
|
||||||
- Yoga
|
- Yoga
|
||||||
- SDWebImage (5.21.4):
|
- SDWebImage (5.21.5):
|
||||||
- SDWebImage/Core (= 5.21.4)
|
- SDWebImage/Core (= 5.21.5)
|
||||||
- SDWebImage/Core (5.21.4)
|
- SDWebImage/Core (5.21.5)
|
||||||
- SDWebImageAVIFCoder (0.11.1):
|
- SDWebImageAVIFCoder (0.11.1):
|
||||||
- libavif/core (>= 0.11.0)
|
- libavif/core (>= 0.11.0)
|
||||||
- SDWebImage (~> 5.10)
|
- SDWebImage (~> 5.10)
|
||||||
@@ -2309,9 +2348,12 @@ PODS:
|
|||||||
- ZXingObjC/Core
|
- ZXingObjC/Core
|
||||||
|
|
||||||
DEPENDENCIES:
|
DEPENDENCIES:
|
||||||
|
- EASClient (from `../node_modules/expo-eas-client/ios`)
|
||||||
- EXApplication (from `../node_modules/expo-application/ios`)
|
- EXApplication (from `../node_modules/expo-application/ios`)
|
||||||
- EXConstants (from `../node_modules/expo-constants/ios`)
|
- EXConstants (from `../node_modules/expo-constants/ios`)
|
||||||
- EXImageLoader (from `../node_modules/expo-image-loader/ios`)
|
- EXImageLoader (from `../node_modules/expo-image-loader/ios`)
|
||||||
|
- EXJSONUtils (from `../node_modules/expo-json-utils/ios`)
|
||||||
|
- EXManifests (from `../node_modules/expo-manifests/ios`)
|
||||||
- EXNotifications (from `../node_modules/expo-notifications/ios`)
|
- EXNotifications (from `../node_modules/expo-notifications/ios`)
|
||||||
- Expo (from `../node_modules/expo`)
|
- Expo (from `../node_modules/expo`)
|
||||||
- ExpoAppleAuthentication (from `../node_modules/expo-apple-authentication/ios`)
|
- ExpoAppleAuthentication (from `../node_modules/expo-apple-authentication/ios`)
|
||||||
@@ -2320,6 +2362,7 @@ DEPENDENCIES:
|
|||||||
- ExpoBlur (from `../node_modules/expo-blur/ios`)
|
- ExpoBlur (from `../node_modules/expo-blur/ios`)
|
||||||
- ExpoCamera (from `../node_modules/expo-camera/ios`)
|
- ExpoCamera (from `../node_modules/expo-camera/ios`)
|
||||||
- ExpoClipboard (from `../node_modules/expo-clipboard/ios`)
|
- ExpoClipboard (from `../node_modules/expo-clipboard/ios`)
|
||||||
|
- ExpoDocumentPicker (from `../node_modules/expo-document-picker/ios`)
|
||||||
- ExpoFileSystem (from `../node_modules/expo-file-system/ios`)
|
- ExpoFileSystem (from `../node_modules/expo-file-system/ios`)
|
||||||
- ExpoFont (from `../node_modules/expo-font/ios`)
|
- ExpoFont (from `../node_modules/expo-font/ios`)
|
||||||
- ExpoGlassEffect (from `../node_modules/expo-glass-effect/ios`)
|
- ExpoGlassEffect (from `../node_modules/expo-glass-effect/ios`)
|
||||||
@@ -2340,7 +2383,10 @@ DEPENDENCIES:
|
|||||||
- ExpoSystemUI (from `../node_modules/expo-system-ui/ios`)
|
- ExpoSystemUI (from `../node_modules/expo-system-ui/ios`)
|
||||||
- "ExpoUI (from `../node_modules/@expo/ui/ios`)"
|
- "ExpoUI (from `../node_modules/@expo/ui/ios`)"
|
||||||
- ExpoWebBrowser (from `../node_modules/expo-web-browser/ios`)
|
- ExpoWebBrowser (from `../node_modules/expo-web-browser/ios`)
|
||||||
|
- EXStructuredHeaders (from `../node_modules/expo-structured-headers/ios`)
|
||||||
- EXTaskManager (from `../node_modules/expo-task-manager/ios`)
|
- EXTaskManager (from `../node_modules/expo-task-manager/ios`)
|
||||||
|
- EXUpdates (from `../node_modules/expo-updates/ios`)
|
||||||
|
- EXUpdatesInterface (from `../node_modules/expo-updates-interface/ios`)
|
||||||
- FBLazyVector (from `../node_modules/react-native/Libraries/FBLazyVector`)
|
- FBLazyVector (from `../node_modules/react-native/Libraries/FBLazyVector`)
|
||||||
- hermes-engine (from `../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec`)
|
- hermes-engine (from `../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec`)
|
||||||
- lottie-react-native (from `../node_modules/lottie-react-native`)
|
- lottie-react-native (from `../node_modules/lottie-react-native`)
|
||||||
@@ -2436,6 +2482,7 @@ SPEC REPOS:
|
|||||||
- libwebp
|
- libwebp
|
||||||
- lottie-ios
|
- lottie-ios
|
||||||
- PurchasesHybridCommon
|
- PurchasesHybridCommon
|
||||||
|
- ReachabilitySwift
|
||||||
- RevenueCat
|
- RevenueCat
|
||||||
- SDWebImage
|
- SDWebImage
|
||||||
- SDWebImageAVIFCoder
|
- SDWebImageAVIFCoder
|
||||||
@@ -2445,12 +2492,18 @@ SPEC REPOS:
|
|||||||
- ZXingObjC
|
- ZXingObjC
|
||||||
|
|
||||||
EXTERNAL SOURCES:
|
EXTERNAL SOURCES:
|
||||||
|
EASClient:
|
||||||
|
:path: "../node_modules/expo-eas-client/ios"
|
||||||
EXApplication:
|
EXApplication:
|
||||||
:path: "../node_modules/expo-application/ios"
|
:path: "../node_modules/expo-application/ios"
|
||||||
EXConstants:
|
EXConstants:
|
||||||
:path: "../node_modules/expo-constants/ios"
|
:path: "../node_modules/expo-constants/ios"
|
||||||
EXImageLoader:
|
EXImageLoader:
|
||||||
:path: "../node_modules/expo-image-loader/ios"
|
:path: "../node_modules/expo-image-loader/ios"
|
||||||
|
EXJSONUtils:
|
||||||
|
:path: "../node_modules/expo-json-utils/ios"
|
||||||
|
EXManifests:
|
||||||
|
:path: "../node_modules/expo-manifests/ios"
|
||||||
EXNotifications:
|
EXNotifications:
|
||||||
:path: "../node_modules/expo-notifications/ios"
|
:path: "../node_modules/expo-notifications/ios"
|
||||||
Expo:
|
Expo:
|
||||||
@@ -2467,6 +2520,8 @@ EXTERNAL SOURCES:
|
|||||||
:path: "../node_modules/expo-camera/ios"
|
:path: "../node_modules/expo-camera/ios"
|
||||||
ExpoClipboard:
|
ExpoClipboard:
|
||||||
:path: "../node_modules/expo-clipboard/ios"
|
:path: "../node_modules/expo-clipboard/ios"
|
||||||
|
ExpoDocumentPicker:
|
||||||
|
:path: "../node_modules/expo-document-picker/ios"
|
||||||
ExpoFileSystem:
|
ExpoFileSystem:
|
||||||
:path: "../node_modules/expo-file-system/ios"
|
:path: "../node_modules/expo-file-system/ios"
|
||||||
ExpoFont:
|
ExpoFont:
|
||||||
@@ -2507,8 +2562,14 @@ EXTERNAL SOURCES:
|
|||||||
:path: "../node_modules/@expo/ui/ios"
|
:path: "../node_modules/@expo/ui/ios"
|
||||||
ExpoWebBrowser:
|
ExpoWebBrowser:
|
||||||
:path: "../node_modules/expo-web-browser/ios"
|
:path: "../node_modules/expo-web-browser/ios"
|
||||||
|
EXStructuredHeaders:
|
||||||
|
:path: "../node_modules/expo-structured-headers/ios"
|
||||||
EXTaskManager:
|
EXTaskManager:
|
||||||
:path: "../node_modules/expo-task-manager/ios"
|
:path: "../node_modules/expo-task-manager/ios"
|
||||||
|
EXUpdates:
|
||||||
|
:path: "../node_modules/expo-updates/ios"
|
||||||
|
EXUpdatesInterface:
|
||||||
|
:path: "../node_modules/expo-updates-interface/ios"
|
||||||
FBLazyVector:
|
FBLazyVector:
|
||||||
:path: "../node_modules/react-native/Libraries/FBLazyVector"
|
:path: "../node_modules/react-native/Libraries/FBLazyVector"
|
||||||
hermes-engine:
|
hermes-engine:
|
||||||
@@ -2684,22 +2745,26 @@ EXTERNAL SOURCES:
|
|||||||
:path: "../node_modules/react-native/ReactCommon/yoga"
|
:path: "../node_modules/react-native/ReactCommon/yoga"
|
||||||
|
|
||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
|
EASClient: 68127f1248d2b25fdc82dbbfb17be95d1c4700be
|
||||||
EXApplication: 296622817d459f46b6c5fe8691f4aac44d2b79e7
|
EXApplication: 296622817d459f46b6c5fe8691f4aac44d2b79e7
|
||||||
EXConstants: fd688cef4e401dcf798a021cfb5d87c890c30ba3
|
EXConstants: fd688cef4e401dcf798a021cfb5d87c890c30ba3
|
||||||
EXImageLoader: 189e3476581efe3ad4d1d3fb4735b7179eb26f05
|
EXImageLoader: 189e3476581efe3ad4d1d3fb4735b7179eb26f05
|
||||||
EXNotifications: 7cff475adb5d7a255a9ea46bbd2589cb3b454506
|
EXJSONUtils: 1d3e4590438c3ee593684186007028a14b3686cd
|
||||||
Expo: 111394d38f32be09385d4c7f70cc96d2da438d0d
|
EXManifests: 26e15640538c3d5ef028077ebcaf004b744d4932
|
||||||
|
EXNotifications: a62e1f8e3edd258dc3b155d3caa49f32920f1c6c
|
||||||
|
Expo: 7af24402df45b9384900104e88a11896ffc48161
|
||||||
ExpoAppleAuthentication: bc9de6e9ff3340604213ab9031d4c4f7f802623e
|
ExpoAppleAuthentication: bc9de6e9ff3340604213ab9031d4c4f7f802623e
|
||||||
ExpoAsset: d839c8eae8124470332408427327e8f88beb2dfd
|
ExpoAsset: d839c8eae8124470332408427327e8f88beb2dfd
|
||||||
ExpoBackgroundTask: e0d201d38539c571efc5f9cb661fae8ab36ed61b
|
ExpoBackgroundTask: c498ce99a10f125d8370a5b2f4405e2583a3c896
|
||||||
ExpoBlur: 2dd8f64aa31f5d405652c21d3deb2d2588b1852f
|
ExpoBlur: 2dd8f64aa31f5d405652c21d3deb2d2588b1852f
|
||||||
ExpoCamera: 2a87c210f8955350ea5c70f1d539520b2fc5d940
|
ExpoCamera: 2a87c210f8955350ea5c70f1d539520b2fc5d940
|
||||||
ExpoClipboard: af650d14765f19c60ce2a1eaf9dfe6445eff7365
|
ExpoClipboard: af650d14765f19c60ce2a1eaf9dfe6445eff7365
|
||||||
|
ExpoDocumentPicker: 2200eefc2817f19315fa18f0147e0b80ece86926
|
||||||
ExpoFileSystem: 77157a101e03150a4ea4f854b4dd44883c93ae0a
|
ExpoFileSystem: 77157a101e03150a4ea4f854b4dd44883c93ae0a
|
||||||
ExpoFont: cf9d90ec1d3b97c4f513211905724c8171f82961
|
ExpoFont: cf9d90ec1d3b97c4f513211905724c8171f82961
|
||||||
ExpoGlassEffect: 265fa3d75b46bc58262e4dfa513135fa9dfe4aac
|
ExpoGlassEffect: 265fa3d75b46bc58262e4dfa513135fa9dfe4aac
|
||||||
ExpoHaptics: 807476b0c39e9d82b7270349d6487928ce32df84
|
ExpoHaptics: 807476b0c39e9d82b7270349d6487928ce32df84
|
||||||
ExpoHead: 95a6ee0be1142320bccf07961d6a1502ded5d6ac
|
ExpoHead: fc0185d5c2a51ea599aff223aba5d61782301044
|
||||||
ExpoImage: 9c3428921c536ab29e5c6721d001ad5c1f469566
|
ExpoImage: 9c3428921c536ab29e5c6721d001ad5c1f469566
|
||||||
ExpoImagePicker: d251aab45a1b1857e4156fed88511b278b4eee1c
|
ExpoImagePicker: d251aab45a1b1857e4156fed88511b278b4eee1c
|
||||||
ExpoKeepAwake: 1a2e820692e933c94a565ec3fbbe38ac31658ffe
|
ExpoKeepAwake: 1a2e820692e933c94a565ec3fbbe38ac31658ffe
|
||||||
@@ -2707,15 +2772,18 @@ SPEC CHECKSUMS:
|
|||||||
ExpoLinking: 77455aa013e9b6a3601de03ecfab09858ee1b031
|
ExpoLinking: 77455aa013e9b6a3601de03ecfab09858ee1b031
|
||||||
ExpoLocalization: b852a5d8ec14c5349c1593eca87896b5b3ebfcca
|
ExpoLocalization: b852a5d8ec14c5349c1593eca87896b5b3ebfcca
|
||||||
ExpoMediaLibrary: 641a6952299b395159ccd459bd8f5f6764bf55fe
|
ExpoMediaLibrary: 641a6952299b395159ccd459bd8f5f6764bf55fe
|
||||||
ExpoModulesCore: e8ec7f8727caf51a49d495598303dd420ca994bf
|
ExpoModulesCore: bdc95c6daa1639e235a16350134152a0b28e5c72
|
||||||
ExpoQuickActions: 31a70aa6a606128de4416a4830e09cfabfe6667f
|
ExpoQuickActions: 31a70aa6a606128de4416a4830e09cfabfe6667f
|
||||||
ExpoSplashScreen: 268b2f128dc04284c21010540a6c4dd9f95003e3
|
ExpoSplashScreen: 268b2f128dc04284c21010540a6c4dd9f95003e3
|
||||||
ExpoSQLite: 7fa091ba5562474093fef09be644161a65e11b3f
|
ExpoSQLite: b312b02c8b77ab55951396e6cd13992f8db9215f
|
||||||
ExpoSymbols: 1ae04ce686de719b9720453b988d8bc5bf776c68
|
ExpoSymbols: 1ae04ce686de719b9720453b988d8bc5bf776c68
|
||||||
ExpoSystemUI: 2761aa6875849af83286364811d46e8ed8ea64c7
|
ExpoSystemUI: 2761aa6875849af83286364811d46e8ed8ea64c7
|
||||||
ExpoUI: b99a1d1ef5352a60bebf4f4fd3a50d2f896ae804
|
ExpoUI: b99a1d1ef5352a60bebf4f4fd3a50d2f896ae804
|
||||||
ExpoWebBrowser: d04a0d6247a0bea4519fbc2ea816610019ad83e0
|
ExpoWebBrowser: b973e1351fdcf5fec0c400997b1851f5a8219ec3
|
||||||
|
EXStructuredHeaders: c951e77f2d936f88637421e9588c976da5827368
|
||||||
EXTaskManager: cbbb80cbccea6487ccca0631809fbba2ed3e5271
|
EXTaskManager: cbbb80cbccea6487ccca0631809fbba2ed3e5271
|
||||||
|
EXUpdates: 9042dc213f17593a02d59ef7dd9d297edf621936
|
||||||
|
EXUpdatesInterface: 5adf50cb41e079c861da6d9b4b954c3db9a50734
|
||||||
FBLazyVector: e95a291ad2dadb88e42b06e0c5fb8262de53ec12
|
FBLazyVector: e95a291ad2dadb88e42b06e0c5fb8262de53ec12
|
||||||
hermes-engine: 9f4dfe93326146a1c99eb535b1cb0b857a3cd172
|
hermes-engine: 9f4dfe93326146a1c99eb535b1cb0b857a3cd172
|
||||||
libavif: 84bbb62fb232c3018d6f1bab79beea87e35de7b7
|
libavif: 84bbb62fb232c3018d6f1bab79beea87e35de7b7
|
||||||
@@ -2723,10 +2791,11 @@ SPEC CHECKSUMS:
|
|||||||
libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8
|
libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8
|
||||||
lottie-ios: a881093fab623c467d3bce374367755c272bdd59
|
lottie-ios: a881093fab623c467d3bce374367755c272bdd59
|
||||||
lottie-react-native: cbe3d931a7c24f7891a8e8032c2bb9b2373c4b9c
|
lottie-react-native: cbe3d931a7c24f7891a8e8032c2bb9b2373c4b9c
|
||||||
PurchasesHybridCommon: a4837eebc889b973668af685d6c23b89a038461d
|
PurchasesHybridCommon: 71c94158ff8985657d37d5f3be05602881227619
|
||||||
RCTDeprecation: 943572d4be82d480a48f4884f670135ae30bf990
|
RCTDeprecation: 943572d4be82d480a48f4884f670135ae30bf990
|
||||||
RCTRequired: 8f3cfc90cc25cf6e420ddb3e7caaaabc57df6043
|
RCTRequired: 8f3cfc90cc25cf6e420ddb3e7caaaabc57df6043
|
||||||
RCTTypeSafety: 16a4144ca3f959583ab019b57d5633df10b5e97c
|
RCTTypeSafety: 16a4144ca3f959583ab019b57d5633df10b5e97c
|
||||||
|
ReachabilitySwift: 32793e867593cfc1177f5d16491e3a197d2fccda
|
||||||
React: 914f8695f9bf38e6418228c2ffb70021e559f92f
|
React: 914f8695f9bf38e6418228c2ffb70021e559f92f
|
||||||
React-callinvoker: 1c0808402aee0c6d4a0d8e7220ce6547af9fba71
|
React-callinvoker: 1c0808402aee0c6d4a0d8e7220ce6547af9fba71
|
||||||
React-Core: c61410ef0ca6055e204a963992e363227e0fd1c5
|
React-Core: c61410ef0ca6055e204a963992e363227e0fd1c5
|
||||||
@@ -2758,7 +2827,7 @@ SPEC CHECKSUMS:
|
|||||||
React-Mapbuffer: 9050ee10c19f4f7fca8963d0211b2854d624973e
|
React-Mapbuffer: 9050ee10c19f4f7fca8963d0211b2854d624973e
|
||||||
React-microtasksnativemodule: f775db9e991c6f3b8ccbc02bfcde22770f96e23b
|
React-microtasksnativemodule: f775db9e991c6f3b8ccbc02bfcde22770f96e23b
|
||||||
react-native-render-html: 5afc4751f1a98621b3009432ef84c47019dcb2bd
|
react-native-render-html: 5afc4751f1a98621b3009432ef84c47019dcb2bd
|
||||||
react-native-safe-area-context: 42a1b4f8774b577d03b53de7326e3d5757fe9513
|
react-native-safe-area-context: 37e680fc4cace3c0030ee46e8987d24f5d3bdab2
|
||||||
react-native-view-shot: fb3c0774edb448f42705491802a455beac1502a2
|
react-native-view-shot: fb3c0774edb448f42705491802a455beac1502a2
|
||||||
react-native-voice: 908a0eba96c8c3d643e4f98b7232c6557d0a6f9c
|
react-native-voice: 908a0eba96c8c3d643e4f98b7232c6557d0a6f9c
|
||||||
react-native-webview: b29007f4723bca10872028067b07abacfa1cb35a
|
react-native-webview: b29007f4723bca10872028067b07abacfa1cb35a
|
||||||
@@ -2793,20 +2862,20 @@ SPEC CHECKSUMS:
|
|||||||
ReactCodegen: 7d4593f7591f002d137fe40cef3f6c11f13c88cc
|
ReactCodegen: 7d4593f7591f002d137fe40cef3f6c11f13c88cc
|
||||||
ReactCommon: 08810150b1206cc44aecf5f6ae19af32f29151a8
|
ReactCommon: 08810150b1206cc44aecf5f6ae19af32f29151a8
|
||||||
ReactNativeDependencies: 71ce9c28beb282aa720ea7b46980fff9669f428a
|
ReactNativeDependencies: 71ce9c28beb282aa720ea7b46980fff9669f428a
|
||||||
RevenueCat: 1e61140a343a77dc286f171b3ffab99ca09a4b57
|
RevenueCat: d185cbff8be9425b5835042afd6889389bb756c8
|
||||||
RNCAsyncStorage: 3a4f5e2777dae1688b781a487923a08569e27fe4
|
RNCAsyncStorage: 3a4f5e2777dae1688b781a487923a08569e27fe4
|
||||||
RNCMaskedView: d2578d41c59b936db122b2798ba37e4722d21035
|
RNCMaskedView: d2578d41c59b936db122b2798ba37e4722d21035
|
||||||
RNCPicker: c8a3584b74133464ee926224463fcc54dfdaebca
|
RNCPicker: c8a3584b74133464ee926224463fcc54dfdaebca
|
||||||
RNDateTimePicker: 19ffa303c4524ec0a2dfdee2658198451c16b7f1
|
RNDateTimePicker: 19ffa303c4524ec0a2dfdee2658198451c16b7f1
|
||||||
RNDeviceInfo: bcce8752b5043a623fe3c26789679b473f705d3c
|
RNDeviceInfo: bcce8752b5043a623fe3c26789679b473f705d3c
|
||||||
RNGestureHandler: 2914750df066d89bf9d8f48a10ad5f0051108ac3
|
RNGestureHandler: 2914750df066d89bf9d8f48a10ad5f0051108ac3
|
||||||
RNPurchases: 5f3cd4fea5ef2b3914c925b2201dd5cecd31922f
|
RNPurchases: 34da99c0e14ee484ed57e77dc06dcfe8e7cb1cee
|
||||||
RNReanimated: 1442a577e066e662f0ce1cd1864a65c8e547aee0
|
RNReanimated: e5c702a3e24cc1c68b2de67671713f35461678f4
|
||||||
RNScreens: d8d6f1792f6e7ac12b0190d33d8d390efc0c1845
|
RNScreens: d8d6f1792f6e7ac12b0190d33d8d390efc0c1845
|
||||||
RNSentry: 1d7b9fdae7a01ad8f9053335b5d44e75c39a955e
|
RNSentry: 1d7b9fdae7a01ad8f9053335b5d44e75c39a955e
|
||||||
RNSVG: 31d6639663c249b7d5abc9728dde2041eb2a3c34
|
RNSVG: 31d6639663c249b7d5abc9728dde2041eb2a3c34
|
||||||
RNWorklets: 54d8dffb7f645873a58484658ddfd4bd1a9a0bc1
|
RNWorklets: 9eb6d567fa43984e96b6924a6df504b8a15980cd
|
||||||
SDWebImage: d0184764be51240d49c761c37f53dd017e1ccaaf
|
SDWebImage: e9c98383c7572d713c1a0d7dd2783b10599b9838
|
||||||
SDWebImageAVIFCoder: afe194a084e851f70228e4be35ef651df0fc5c57
|
SDWebImageAVIFCoder: afe194a084e851f70228e4be35ef651df0fc5c57
|
||||||
SDWebImageSVGCoder: 15a300a97ec1c8ac958f009c02220ac0402e936c
|
SDWebImageSVGCoder: 15a300a97ec1c8ac958f009c02220ac0402e936c
|
||||||
SDWebImageWebPCoder: e38c0a70396191361d60c092933e22c20d5b1380
|
SDWebImageWebPCoder: e38c0a70396191361d60c092933e22c20d5b1380
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user