Compare commits
5 Commits
9b4a300380
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17664c679d | ||
|
|
e51aca2fdb | ||
|
|
76c37bfeb0 | ||
|
|
feb5052fcd | ||
|
|
4836058d56 |
@@ -21,10 +21,11 @@
|
||||
|
||||
### 健康数据追踪 ✅
|
||||
|
||||
- HealthKit 集成完成,支持步数、心率、HRV、睡眠等数据
|
||||
- HealthKit 集成完成,支持步数、心率、HRV、睡眠、手腕温度等数据
|
||||
- 活动圆环显示(活动卡路里、锻炼分钟、站立小时)
|
||||
- 实时健康数据监控和历史数据查看
|
||||
- 健康权限管理系统
|
||||
- 经期跟踪与 HealthKit 同步
|
||||
|
||||
### 营养管理 ✅
|
||||
|
||||
@@ -95,11 +96,12 @@
|
||||
|
||||
### 近期更新
|
||||
|
||||
1. **多语言支持**: 完善挑战页面的多语言翻译支持,建立翻译最佳实践指南
|
||||
2. **性能优化**: 优化健康数据加载和图表渲染性能
|
||||
3. **用户体验**: 改进 Liquid Glass 设计效果和交互动画
|
||||
4. **数据同步**: 增强离线功能和数据同步稳定性
|
||||
5. **AI 功能**: 扩展 AI 教练对话能力和分析精度
|
||||
1. **健康数据**: 新增手腕温度监测功能(支持 Apple Watch 睡眠手腕温度)
|
||||
2. **健康数据**: 实现经期数据与 HealthKit 的双向同步(读写与删除)
|
||||
3. **多语言支持**: 完善挑战页面的多语言翻译支持,建立翻译最佳实践指南
|
||||
4. **用户体验**: 改进 Liquid Glass 设计效果和交互动画
|
||||
5. **数据同步**: 增强离线功能和数据同步稳定性
|
||||
6. **AI 功能**: 扩展 AI 教练对话能力和分析精度
|
||||
|
||||
### 待解决问题
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
# 产品概述
|
||||
|
||||
## 产品定位
|
||||
|
||||
Out Live(超越生命)是一款专注于健康、减肥、瘦身和生活习惯养成的 iOS 应用。该应用通过整合健康数据追踪、AI 教练指导、目标管理和社区挑战等功能,为用户提供全方位的健康生活管理解决方案。
|
||||
|
||||
## 目标用户
|
||||
|
||||
- 关注健康和体重管理的用户
|
||||
- 希望养成良好生活习惯的用户
|
||||
- 对普拉提和健身感兴趣的用户
|
||||
@@ -11,6 +13,7 @@ Out Live(超越生命)是一款专注于健康、减肥、瘦身和生活习
|
||||
- 希望通过 AI 获得个性化健康指导的用户
|
||||
|
||||
## 核心价值主张
|
||||
|
||||
1. **全方位健康数据管理**:整合 HealthKit 数据,提供步数、心率、睡眠、饮水量等多维度健康指标追踪
|
||||
2. **AI 智能教练**:基于用户健康数据提供个性化的健康建议和指导
|
||||
3. **目标管理系统**:帮助用户设定、追踪和完成健康目标
|
||||
@@ -20,50 +23,59 @@ Out Live(超越生命)是一款专注于健康、减肥、瘦身和生活习
|
||||
## 主要功能模块
|
||||
|
||||
### 健康数据追踪
|
||||
|
||||
- **活动圆环**:展示活动卡路里、锻炼分钟和站立小时
|
||||
- **步数统计**:按小时显示步数数据和趋势
|
||||
- **心率监测**:实时心率和心率变异性(HRV)分析
|
||||
- **睡眠分析**:睡眠质量和时长追踪
|
||||
- **手腕温度**:追踪睡眠期间的手腕温度变化
|
||||
- **体重管理**:体重记录和 BMI 计算
|
||||
- **饮水量追踪**:每日饮水目标设定和记录
|
||||
|
||||
### 营养管理
|
||||
|
||||
- **饮食记录**:支持文字、语音和拍照识别食物
|
||||
- **营养分析**:卡路里、蛋白质、碳水化合物等营养成分分析
|
||||
- **食物库**:丰富的食物数据库和自定义食物功能
|
||||
- **营养标签识别**:通过拍照识别食品营养标签
|
||||
|
||||
### 目标与习惯管理
|
||||
|
||||
- **目标设定**:支持日、周、月重复模式的目标设定
|
||||
- **任务管理**:将目标分解为可执行的任务
|
||||
- **进度追踪**:可视化目标完成进度
|
||||
- **提醒功能**:智能提醒帮助用户坚持目标
|
||||
|
||||
### 轻断食功能
|
||||
- **断食计划**:多种预设断食方案(16:8、18:6等)
|
||||
|
||||
- **断食计划**:多种预设断食方案(16:8、18:6 等)
|
||||
- **断食追踪**:实时显示断食进度和状态
|
||||
- **智能提醒**:断食开始和结束提醒
|
||||
- **断食历史**:记录和分析断食历史数据
|
||||
|
||||
### AI 教练系统
|
||||
|
||||
- **智能对话**:基于用户健康数据提供个性化建议
|
||||
- **体态评估**:通过 AI 分析用户体态照片
|
||||
- **健康指导**:提供运动、营养和生活方式建议
|
||||
- **情绪分析**:基于 HRV 数据分析压力水平
|
||||
|
||||
### 社区与挑战
|
||||
|
||||
- **挑战赛**:参与各种健康主题挑战
|
||||
- **排行榜**:与好友或其他用户比较进度
|
||||
- **成就系统**:完成目标获得成就奖励
|
||||
- **社交分享**:分享健康成果到社交平台
|
||||
|
||||
### 训练计划
|
||||
|
||||
- **个性化计划**:基于用户目标生成训练计划
|
||||
- **运动库**:丰富的运动动作库和指导
|
||||
- **进度追踪**:记录训练完成情况和效果
|
||||
- **智能推荐**:根据用户表现调整训练计划
|
||||
|
||||
## 用户体验特色
|
||||
|
||||
1. **Liquid Glass 设计风格**:采用现代化的毛玻璃效果设计
|
||||
2. **数据可视化**:丰富的图表和动画展示健康数据
|
||||
3. **快捷操作**:支持快捷动作和小组件快速记录
|
||||
@@ -71,6 +83,7 @@ Out Live(超越生命)是一款专注于健康、减肥、瘦身和生活习
|
||||
5. **隐私保护**:严格保护用户健康数据隐私
|
||||
|
||||
## 技术亮点
|
||||
|
||||
- **HealthKit 深度集成**:充分利用 iOS 健康生态系统
|
||||
- **实时数据同步**:支持多设备数据实时同步
|
||||
- **智能通知系统**:基于用户行为的智能提醒
|
||||
@@ -78,13 +91,15 @@ Out Live(超越生命)是一款专注于健康、减肥、瘦身和生活习
|
||||
- **无障碍支持**:完整的无障碍功能支持
|
||||
|
||||
## 商业模式
|
||||
|
||||
- **免费增值模式**:基础功能免费,高级功能付费
|
||||
- **VIP 会员**:提供更多个性化功能和专业指导
|
||||
- **企业健康**:面向企业提供的员工健康管理解决方案
|
||||
|
||||
## 竞争优势
|
||||
|
||||
1. **全平台整合**:深度整合 iOS 健康生态系统
|
||||
2. **AI 技术应用**:先进的 AI 分析和个性化推荐
|
||||
3. **用户体验**:优秀的界面设计和交互体验
|
||||
4. **数据安全**:严格的数据隐私保护措施
|
||||
5. **专业内容**:基于科学研究的健康指导内容
|
||||
5. **专业内容**:基于科学研究的健康指导内容
|
||||
|
||||
@@ -751,3 +751,44 @@ list: {
|
||||
2. **保持翻译一致性**:相同含义的文本使用相同的翻译键
|
||||
3. **定期审查**:定期检查是否有硬编码文本遗漏
|
||||
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 - 跨平台移动应用开发框架
|
||||
- **Expo SDK**: 54.0.13 - React Native 开发平台和工具链
|
||||
- **Expo Router**: 6.0.12 - 基于文件系统的路由库
|
||||
- **TypeScript**: 5.9.2 - 类型安全的 JavaScript 超集
|
||||
|
||||
### 状态管理
|
||||
|
||||
- **Redux Toolkit**: 2.9.0 - 状态管理解决方案
|
||||
- **React Redux**: 9.2.0 - React Redux 绑定
|
||||
- **Redux Listener Middleware**: 自定义中间件用于自动同步
|
||||
|
||||
### UI 框架和样式
|
||||
|
||||
- **React Native Elements**: UI 组件库
|
||||
- **Expo UI**: 0.2.0-beta.7 - Expo UI 组件
|
||||
- **Expo Glass Effect**: 0.1.4 - Liquid Glass 毛玻璃效果, 优先使用
|
||||
@@ -22,17 +25,20 @@
|
||||
- **React Native SVG**: 15.12.1 - SVG 图形支持
|
||||
|
||||
### 导航
|
||||
|
||||
- **Expo Router**: 6.0.12 - 文件系统路由
|
||||
- **React Navigation**: 7.x - 导航库
|
||||
|
||||
## 数据和存储
|
||||
|
||||
### 本地存储
|
||||
|
||||
- **Expo SQLite**: 16.0.8 - SQLite 数据库
|
||||
- **Expo SQLite KV Store**: 键值存储
|
||||
- **Async Storage**: 2.2.0 - 异步存储(兼容层)
|
||||
|
||||
### 网络和 API
|
||||
|
||||
- **Fetch API**: 原生网络请求
|
||||
- **XMLHttpRequest**: 流式请求支持
|
||||
- **Axios**: HTTP 客户端(可选)
|
||||
@@ -40,16 +46,19 @@
|
||||
## 原生功能集成
|
||||
|
||||
### HealthKit 集成
|
||||
|
||||
- **自定义 HealthKit Manager**: iOS 原生模块
|
||||
- **健康数据类型**: 步数、心率、HRV、睡眠、活动圆环等
|
||||
- **健康数据类型**: 步数、心率、HRV、睡眠、活动圆环、手腕温度(appleSleepingWristTemperature)等
|
||||
- **权限管理**: 动态权限请求和状态监控
|
||||
|
||||
### 通知系统
|
||||
|
||||
- **Expo Notifications**: 0.32.12 - 本地和推送通知
|
||||
- **后台任务**: Expo Task Manager
|
||||
- **推送通知**: 远程推送支持
|
||||
|
||||
### 设备功能
|
||||
|
||||
- **Expo Camera**: 17.0.8 - 相机功能
|
||||
- **Expo Image Picker**: 17.0.8 - 图片选择
|
||||
- **Expo Haptics**: 15.0.7 - 触觉反馈
|
||||
@@ -59,17 +68,20 @@
|
||||
## 开发工具和构建
|
||||
|
||||
### 构建系统
|
||||
|
||||
- **Expo Prebuild**: 原生构建生成
|
||||
- **Metro**: JavaScript 打包工具
|
||||
- **Babel**: JavaScript 编译器
|
||||
|
||||
### 代码质量
|
||||
|
||||
- **ESLint**: 9.35.0 - 代码检查
|
||||
- **ESLint Config Expo**: 10.0.0 - Expo ESLint 配置
|
||||
- **Prettier**: 代码格式化
|
||||
- **TypeScript**: 类型检查
|
||||
|
||||
### 开发环境
|
||||
|
||||
- **VS Code**: 主要开发 IDE
|
||||
- **Expo Go**: 开发调试
|
||||
- **iOS Simulator**: iOS 模拟器
|
||||
@@ -78,21 +90,25 @@
|
||||
## 第三方服务
|
||||
|
||||
### 云存储
|
||||
|
||||
- **腾讯云 COS**: 图片和文件存储
|
||||
- **上传服务**: 自定义上传实现
|
||||
|
||||
### AI 服务
|
||||
|
||||
- **AI 教练**: 自定义 AI 对话服务
|
||||
- **图像识别**: 食物识别
|
||||
- **语音识别**: 语音转文字
|
||||
|
||||
### 分析和监控
|
||||
|
||||
- **Sentry**: 7.2.0 - 错误监控和性能分析
|
||||
- **崩溃报告**: 自动崩溃收集
|
||||
|
||||
## UI 组件库
|
||||
|
||||
### 基础组件
|
||||
|
||||
- **ThemedView**: 主题化视图组件
|
||||
- **ThemedText**: 主题化文本组件
|
||||
- **IconSymbol**: 图标组件
|
||||
@@ -100,6 +116,7 @@
|
||||
- **AnimatedNumber**: 数字动画组件
|
||||
|
||||
### 业务组件
|
||||
|
||||
- **FitnessRingsCard**: 健身圆环卡片
|
||||
- **StepsCard**: 步数卡片
|
||||
- **NutritionRadarCard**: 营养雷达图
|
||||
@@ -109,6 +126,7 @@
|
||||
- **TaskCard**: 任务卡片
|
||||
|
||||
### 图表组件
|
||||
|
||||
- **RadarChart**: 雷达图
|
||||
- **CircularRing**: 圆形进度环
|
||||
- **CalorieRingChart**: 卡路里环形图
|
||||
@@ -117,17 +135,20 @@
|
||||
## 开发依赖
|
||||
|
||||
### 类型定义
|
||||
|
||||
- **React Types**: 19.1.13
|
||||
- **React Native Types**: 内置
|
||||
- **Expo Types**: 内置
|
||||
|
||||
### 工具库
|
||||
|
||||
- **Day.js**: 1.11.18 - 日期处理
|
||||
- **Lodash**: 4.17.21 - 工具函数库
|
||||
- **React Native Chart Kit**: 6.12.0 - 图表库
|
||||
- **Lottie React Native**: 7.3.4 - 动画库
|
||||
|
||||
### 音频和媒体
|
||||
|
||||
- **React Native Voice**: 3.2.4 - 语音识别
|
||||
- **Expo Media Library**: 18.2.0 - 媒体库
|
||||
- **Expo Audio**: 音频处理
|
||||
@@ -135,12 +156,14 @@
|
||||
## 平台特定配置
|
||||
|
||||
### iOS 配置
|
||||
|
||||
- **最低版本**: iOS 16.0
|
||||
- **Bundle ID**: com.anonymous.digitalpilates
|
||||
- **Team ID**: 756WVXJ6MT
|
||||
- **权限配置**: 相机、相册、麦克风、健康数据、通知等
|
||||
|
||||
### 构建配置
|
||||
|
||||
- **New Arch**: 启用
|
||||
- **JS Engine**: JSC
|
||||
- **Metro 配置**: 自定义配置
|
||||
@@ -149,18 +172,21 @@
|
||||
## 性能优化
|
||||
|
||||
### 渲染优化
|
||||
|
||||
- **React.memo**: 组件记忆化
|
||||
- **useMemo/useCallback**: 钩子优化
|
||||
- **FlatList**: 大列表优化
|
||||
- **InteractionManager**: 延迟渲染
|
||||
|
||||
### 数据优化
|
||||
|
||||
- **Redux Toolkit**: 自动优化
|
||||
- **数据分页**: 分页加载
|
||||
- **缓存策略**: 智能缓存
|
||||
- **后台同步**: 异步同步
|
||||
|
||||
### 资源优化
|
||||
|
||||
- **图片优化**: WebP 格式
|
||||
- **Bundle 分割**: 代码分割
|
||||
- **内存管理**: 资源释放
|
||||
@@ -169,12 +195,14 @@
|
||||
## 安全措施
|
||||
|
||||
### 数据安全
|
||||
|
||||
- **HTTPS**: 加密通信
|
||||
- **Token 管理**: JWT 存储
|
||||
- **数据加密**: 本地加密
|
||||
- **权限控制**: 细粒度权限
|
||||
|
||||
### 隐私保护
|
||||
|
||||
- **数据脱敏**: 敏感数据处理
|
||||
- **权限最小化**: 最小权限原则
|
||||
- **用户控制**: 数据控制权
|
||||
@@ -183,11 +211,13 @@
|
||||
## 测试框架
|
||||
|
||||
### 单元测试
|
||||
|
||||
- **Jest**: 测试框架
|
||||
- **React Native Testing Library**: 组件测试
|
||||
- **Mock**: 模拟数据和服务
|
||||
|
||||
### 集成测试
|
||||
|
||||
- **Detox**: E2E 测试(可选)
|
||||
- **手动测试**: 功能验证
|
||||
- **性能测试**: 性能基准
|
||||
@@ -195,12 +225,14 @@
|
||||
## 部署和发布
|
||||
|
||||
### 构建流程
|
||||
|
||||
- **Expo EAS Build**: 云端构建
|
||||
- **App Store Connect**: 应用商店发布
|
||||
- **OTA 更新**: 热更新
|
||||
- **版本管理**: 语义化版本
|
||||
|
||||
### 持续集成
|
||||
|
||||
- **GitHub Actions**: 自动化流程
|
||||
- **代码检查**: 自动化检查
|
||||
- **测试执行**: 自动化测试
|
||||
@@ -209,19 +241,22 @@
|
||||
## 开发规范
|
||||
|
||||
### 代码规范
|
||||
|
||||
- **ESLint**: 代码检查
|
||||
- **Prettier**: 代码格式化
|
||||
- **TypeScript**: 类型安全
|
||||
- **命名规范**: 统一命名
|
||||
|
||||
### Git 工作流
|
||||
|
||||
- **Conventional Commits**: 提交规范
|
||||
- **分支策略**: Git Flow
|
||||
- **代码审查**: PR 流程
|
||||
- **版本标签**: 标签管理
|
||||
|
||||
### 文档规范
|
||||
|
||||
- **JSDoc**: 代码注释
|
||||
- **README**: 项目文档
|
||||
- **API 文档**: 接口文档
|
||||
- **组件文档**: 组件说明
|
||||
- **组件文档**: 组件说明
|
||||
|
||||
2
app.json
2
app.json
@@ -2,7 +2,7 @@
|
||||
"expo": {
|
||||
"name": "Out Live",
|
||||
"slug": "digital-pilates",
|
||||
"version": "1.1.5",
|
||||
"version": "1.1.6",
|
||||
"orientation": "portrait",
|
||||
"scheme": "digitalpilates",
|
||||
"userInterfaceStyle": "light",
|
||||
|
||||
@@ -2,6 +2,7 @@ import dayjs from 'dayjs';
|
||||
|
||||
import ChallengeProgressCard from '@/components/challenges/ChallengeProgressCard';
|
||||
import { ConfirmationSheet } from '@/components/ui/ConfirmationSheet';
|
||||
import { Image } from '@/components/ui/Image';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||
@@ -23,7 +24,6 @@ import {
|
||||
import { Toast } from '@/utils/toast.utils';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
import { Image } from 'expo-image';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { useRouter } from 'expo-router';
|
||||
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 { ThemedText } from '@/components/ThemedText';
|
||||
import { IconSymbol } from '@/components/ui/IconSymbol';
|
||||
import { Image } from '@/components/ui/Image';
|
||||
import { MedicalDisclaimerSheet } from '@/components/ui/MedicalDisclaimerSheet';
|
||||
import { MedicationAiSummaryInfoSheet } from '@/components/ui/MedicationAiSummaryInfoSheet';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
@@ -20,7 +21,6 @@ import { useFocusEffect } from '@react-navigation/native';
|
||||
import dayjs, { Dayjs } from 'dayjs';
|
||||
import 'dayjs/locale/zh-cn';
|
||||
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
import { Image } from 'expo-image';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { router } from 'expo-router';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
@@ -10,7 +10,8 @@ import { useVersionCheck } from '@/contexts/VersionCheckContext';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
|
||||
import { Image } from '@/components/ui/Image';
|
||||
import type { BadgeDto } from '@/services/badges';
|
||||
import { reportBadgeShowcaseDisplayed } from '@/services/badges';
|
||||
import { updateUser, type UserLanguage } from '@/services/users';
|
||||
@@ -24,7 +25,6 @@ import { Ionicons } from '@expo/vector-icons';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
import dayjs from 'dayjs';
|
||||
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
import { Image } from 'expo-image';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { useRouter } from 'expo-router';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
@@ -7,6 +7,8 @@ import { NutritionRadarCard } from '@/components/NutritionRadarCard';
|
||||
import CircumferenceCard from '@/components/statistic/CircumferenceCard';
|
||||
import OxygenSaturationCard from '@/components/statistic/OxygenSaturationCard';
|
||||
import SleepCard from '@/components/statistic/SleepCard';
|
||||
import SunlightCard from '@/components/statistic/SunlightCard';
|
||||
import WristTemperatureCard from '@/components/statistic/WristTemperatureCard';
|
||||
import StepsCard from '@/components/StepsCard';
|
||||
import { StressMeter } from '@/components/StressMeter';
|
||||
import WaterIntakeCard from '@/components/WaterIntakeCard';
|
||||
@@ -109,9 +111,11 @@ export default function ExploreScreen() {
|
||||
showWater: true,
|
||||
showBasalMetabolism: true,
|
||||
showOxygenSaturation: true,
|
||||
showWristTemperature: true,
|
||||
showMenstrualCycle: true,
|
||||
showWeight: true,
|
||||
showCircumference: true,
|
||||
showSunlight: true,
|
||||
});
|
||||
const [cardOrder, setCardOrder] = useState<string[]>(DEFAULT_CARD_ORDER);
|
||||
|
||||
@@ -443,7 +447,7 @@ export default function ExploreScreen() {
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={{
|
||||
paddingTop: insets.top,
|
||||
paddingBottom: 60,
|
||||
paddingBottom: 100,
|
||||
paddingHorizontal: 20
|
||||
}}
|
||||
showsVerticalScrollIndicator={false}
|
||||
@@ -579,6 +583,15 @@ export default function ExploreScreen() {
|
||||
/>
|
||||
)
|
||||
},
|
||||
sunlight: {
|
||||
visible: cardVisibility.showSunlight,
|
||||
component: (
|
||||
<SunlightCard
|
||||
selectedDate={currentSelectedDate}
|
||||
style={styles.basalMetabolismCardOverride}
|
||||
/>
|
||||
)
|
||||
},
|
||||
fitness: {
|
||||
visible: cardVisibility.showFitnessRings,
|
||||
component: (
|
||||
@@ -615,6 +628,15 @@ export default function ExploreScreen() {
|
||||
/>
|
||||
)
|
||||
},
|
||||
temperature: {
|
||||
visible: cardVisibility.showWristTemperature,
|
||||
component: (
|
||||
<WristTemperatureCard
|
||||
selectedDate={currentSelectedDate}
|
||||
style={styles.basalMetabolismCardOverride}
|
||||
/>
|
||||
)
|
||||
},
|
||||
menstrual: {
|
||||
visible: cardVisibility.showMenstrualCycle,
|
||||
component: (
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { BadgeShowcaseModal } from '@/components/badges/BadgeShowcaseModal';
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { Image } from '@/components/ui/Image';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import type { BadgeDto } from '@/services/badges';
|
||||
import { fetchAvailableBadges, selectBadgesLoading, selectSortedBadges } from '@/store/badgesSlice';
|
||||
import { DEFAULT_MEMBER_NAME, selectUserProfile } from '@/store/userSlice';
|
||||
import { BadgeShowcaseModal } from '@/components/badges/BadgeShowcaseModal';
|
||||
import { Toast } from '@/utils/toast.utils';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { Image } from 'expo-image';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FlatList, Pressable, RefreshControl, StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import ChallengeProgressCard from '@/components/challenges/ChallengeProgressCard';
|
||||
import { ChallengeRankingItem } from '@/components/challenges/ChallengeRankingItem';
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { Image } from '@/components/ui/Image';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||
@@ -33,7 +34,6 @@ import { BlurView } from 'expo-blur';
|
||||
import * as Clipboard from 'expo-clipboard';
|
||||
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { Image } from 'expo-image';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import LottieView from 'lottie-react-native';
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Image } from '@/components/ui/Image';
|
||||
import i18n from '@/i18n';
|
||||
import dayjs from 'dayjs';
|
||||
import { BlurView } from 'expo-blur';
|
||||
import * as Clipboard from 'expo-clipboard';
|
||||
import { Image } from 'expo-image';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
import Animated, { FadeInDown, FadeInUp, Layout } from 'react-native-reanimated';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
import { Image } from '@/components/ui/Image';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { getTabBarBottomPadding } from '@/constants/TabBar';
|
||||
import { useAppSelector } from '@/hooks/redux';
|
||||
@@ -29,7 +30,6 @@ import { loadAiCoachSessionCache, saveAiCoachSessionCache } from '@/services/aiC
|
||||
import { api, getAuthToken, postTextStream } from '@/services/api';
|
||||
import { selectLatestMoodRecordByDate } from '@/store/moodSlice';
|
||||
import { generateWelcomeMessage, hasRecordedMoodToday } from '@/utils/welcomeMessage';
|
||||
import { Image } from 'expo-image';
|
||||
import { HistoryModal } from '../components/model/HistoryModal';
|
||||
import { ActionSheet } from '../components/ui/ActionSheet';
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { CreateCustomFoodModal, type CustomFoodData } from '@/components/model/food/CreateCustomFoodModal';
|
||||
import { FoodDetailModal } from '@/components/model/food/FoodDetailModal';
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { Image } from '@/components/ui/Image';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { DEFAULT_IMAGE_FOOD } from '@/constants/Image';
|
||||
import { useAppDispatch } from '@/hooks/redux';
|
||||
@@ -13,7 +14,6 @@ import { fetchDailyNutritionData } from '@/store/nutritionSlice';
|
||||
import type { FoodItem, MealType, SelectedFoodItem } from '@/types/food';
|
||||
import { saveNutritionToHealthKit } from '@/utils/health';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { Image } from 'expo-image';
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { CircularRing } from '@/components/CircularRing';
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { Image } from '@/components/ui/Image';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { ROUTES } from '@/constants/Routes';
|
||||
import { useAppSelector } from '@/hooks/redux';
|
||||
@@ -9,7 +10,6 @@ import { addDietRecord, type CreateDietRecordDto, type MealType } from '@/servic
|
||||
import { selectFoodRecognitionResult } from '@/store/foodRecognitionSlice';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import dayjs from 'dayjs';
|
||||
import { Image } from 'expo-image';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { Image } from '@/components/ui/Image';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
@@ -6,7 +7,6 @@ import { useI18n } from '@/hooks/useI18n';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { CameraType, CameraView, useCameraPermissions } from 'expo-camera';
|
||||
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
import { Image } from 'expo-image';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { Image } from '@/components/ui/Image';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useMembershipModal } from '@/contexts/MembershipModalContext';
|
||||
import { useAppDispatch } from '@/hooks/redux';
|
||||
@@ -11,7 +12,6 @@ import { recognizeFood } from '@/services/foodRecognition';
|
||||
import { saveRecognitionResult, setError, setLoading } from '@/store/foodRecognitionSlice';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
import { Image } from 'expo-image';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { Image } from '@/components/ui/Image';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||
@@ -13,7 +14,6 @@ import { triggerLightHaptic } from '@/utils/haptics';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import dayjs from 'dayjs';
|
||||
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
import { Image } from 'expo-image';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { useRouter } from 'expo-router';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { Image } from '@/components/ui/Image';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||
import { useCosUpload } from '@/hooks/useCosUpload';
|
||||
@@ -12,7 +13,6 @@ import { triggerLightHaptic } from '@/utils/haptics';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import dayjs from 'dayjs';
|
||||
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
import { Image } from 'expo-image';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { useRouter } from 'expo-router';
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { Image as ExpoImage } from '@/components/ui/Image';
|
||||
import { useMembershipModal } from '@/contexts/MembershipModalContext';
|
||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||
import { useVipService } from '@/hooks/useVipService';
|
||||
@@ -9,7 +10,6 @@ import { Ionicons } from '@expo/vector-icons';
|
||||
import dayjs from 'dayjs';
|
||||
import * as FileSystem from 'expo-file-system/legacy';
|
||||
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
import { Image as ExpoImage } from 'expo-image';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import * as MediaLibrary from 'expo-media-library';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
@@ -5,6 +5,7 @@ import { HealthHistoryTab } from '@/components/health/tabs/HealthHistoryTab';
|
||||
import { MedicalRecordsTab } from '@/components/health/tabs/MedicalRecordsTab';
|
||||
import { 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';
|
||||
@@ -16,15 +17,14 @@ import {
|
||||
joinFamilyGroup,
|
||||
selectFamilyGroup,
|
||||
} from '@/store/familyHealthSlice';
|
||||
import {
|
||||
import {
|
||||
fetchHealthHistory,
|
||||
selectHealthHistoryProgress
|
||||
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 { Image } from 'expo-image';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { Stack, useRouter } from 'expo-router';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ExpiryDatePickerModal } from '@/components/medications/ExpiryDatePicker
|
||||
import { ThemedText } from '@/components/ThemedText';
|
||||
import { ConfirmationSheet } from '@/components/ui/ConfirmationSheet';
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { Image } from '@/components/ui/Image';
|
||||
import InfoCard from '@/components/ui/InfoCard';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
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 dayjs from 'dayjs';
|
||||
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
import { Image } from 'expo-image';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ThemedText } from '@/components/ThemedText';
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { IconSymbol } from '@/components/ui/IconSymbol';
|
||||
import { Image } from '@/components/ui/Image';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { DOSAGE_UNITS, FORM_OPTIONS } from '@/constants/Medication';
|
||||
import { useAppDispatch } from '@/hooks/redux';
|
||||
@@ -15,7 +16,6 @@ import { Picker } from '@react-native-picker/picker';
|
||||
import Voice from '@react-native-voice/voice';
|
||||
import dayjs from 'dayjs';
|
||||
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
import { Image } from 'expo-image';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { router } from 'expo-router';
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { MedicationPhotoGuideModal } from '@/components/medications/MedicationPhotoGuideModal';
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { Image } from '@/components/ui/Image';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
@@ -10,7 +11,6 @@ import { getItem, setItem } from '@/utils/kvStore';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { CameraView, useCameraPermissions } from 'expo-camera';
|
||||
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
import { Image } from 'expo-image';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { router } from 'expo-router';
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { Image } from '@/components/ui/Image';
|
||||
import { Colors, palette } from '@/constants/Colors';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
import { getMedicationRecognitionStatus } from '@/services/medications';
|
||||
import { MedicationRecognitionTask } from '@/types/medication';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
import { Image } from 'expo-image';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { router, useLocalSearchParams } from 'expo-router';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ThemedText } from '@/components/ThemedText';
|
||||
import { ConfirmationSheet } from '@/components/ui/ConfirmationSheet';
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { IconSymbol } from '@/components/ui/IconSymbol';
|
||||
import { Image } from '@/components/ui/Image';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
@@ -18,7 +19,6 @@ import type { Medication, MedicationForm } from '@/types/medication';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
import dayjs from 'dayjs';
|
||||
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
import { Image } from 'expo-image';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { router } from 'expo-router';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
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 {
|
||||
DimensionValue,
|
||||
FlatList,
|
||||
StyleSheet,
|
||||
Text,
|
||||
@@ -12,144 +11,54 @@ import {
|
||||
View,
|
||||
} from 'react-native';
|
||||
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { InlineTip, ITEM_HEIGHT, Legend, MonthBlock } from '@/components/menstrual-cycle';
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||
import {
|
||||
deleteMenstrualFlow,
|
||||
fetchMenstrualFlowSamples,
|
||||
saveMenstrualFlow
|
||||
} from '@/utils/health';
|
||||
import {
|
||||
buildMenstrualTimeline,
|
||||
convertHealthKitSamplesToCycleRecords,
|
||||
CycleRecord,
|
||||
DEFAULT_PERIOD_LENGTH,
|
||||
MenstrualDayCell,
|
||||
MenstrualDayStatus,
|
||||
MenstrualTimeline,
|
||||
buildMenstrualTimeline
|
||||
DEFAULT_PERIOD_LENGTH
|
||||
} from '@/utils/menstrualCycle';
|
||||
|
||||
type TabKey = 'cycle' | 'analysis';
|
||||
|
||||
const ITEM_HEIGHT = 380;
|
||||
const STATUS_COLORS: Record<MenstrualDayStatus, { bg: string; text: string }> = {
|
||||
period: { bg: '#f5679f', text: '#fff' },
|
||||
'predicted-period': { bg: '#f8d9e9', text: '#9b2c6a' },
|
||||
fertile: { bg: '#d9d2ff', text: '#5a52c5' },
|
||||
'ovulation-day': { bg: '#5b4ee4', text: '#fff' },
|
||||
};
|
||||
|
||||
const WEEK_LABELS = ['一', '二', '三', '四', '五', '六', '日'];
|
||||
|
||||
const chunkArray = <T,>(array: T[], size: number): T[][] => {
|
||||
const result: T[][] = [];
|
||||
for (let i = 0; i < array.length; i += size) {
|
||||
result.push(array.slice(i, i + size));
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const DayCell = ({
|
||||
cell,
|
||||
isSelected,
|
||||
onPress,
|
||||
}: {
|
||||
cell: Extract<MenstrualDayCell, { type: 'day' }>;
|
||||
isSelected: boolean;
|
||||
onPress: () => void;
|
||||
}) => {
|
||||
const status = cell.info?.status;
|
||||
const colors = status ? STATUS_COLORS[status] : undefined;
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.8}
|
||||
style={styles.dayCell}
|
||||
onPress={onPress}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
styles.dayCircle,
|
||||
colors && { backgroundColor: colors.bg },
|
||||
isSelected && styles.dayCircleSelected,
|
||||
cell.isToday && styles.todayOutline,
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.dayLabel,
|
||||
colors && { color: colors.text },
|
||||
!colors && styles.dayLabelDefault,
|
||||
]}
|
||||
>
|
||||
{cell.label}
|
||||
</Text>
|
||||
</View>
|
||||
{cell.isToday && <Text style={styles.todayText}>今天</Text>}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
const MonthBlock = ({
|
||||
month,
|
||||
selectedDateKey,
|
||||
onSelect,
|
||||
renderTip,
|
||||
}: {
|
||||
month: MenstrualTimeline['months'][number];
|
||||
selectedDateKey: string;
|
||||
onSelect: (dateKey: string) => void;
|
||||
renderTip: (colIndex: number) => React.ReactNode;
|
||||
}) => {
|
||||
const weeks = useMemo(() => chunkArray(month.cells, 7), [month.cells]);
|
||||
|
||||
return (
|
||||
<View style={styles.monthCard}>
|
||||
<View style={styles.monthHeader}>
|
||||
<Text style={styles.monthTitle}>{month.title}</Text>
|
||||
<Text style={styles.monthSubtitle}>{month.subtitle}</Text>
|
||||
</View>
|
||||
<View style={styles.weekRow}>
|
||||
{WEEK_LABELS.map((label) => (
|
||||
<Text key={label} style={styles.weekLabel}>
|
||||
{label}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
<View style={styles.monthGrid}>
|
||||
{weeks.map((week, weekIndex) => {
|
||||
const selectedIndex = week.findIndex(
|
||||
(c) => c.type === 'day' && c.date.format('YYYY-MM-DD') === selectedDateKey
|
||||
);
|
||||
|
||||
return (
|
||||
<React.Fragment key={weekIndex}>
|
||||
<View style={styles.daysRow}>
|
||||
{week.map((cell) => {
|
||||
if (cell.type === 'placeholder') {
|
||||
return <View key={cell.key} style={styles.dayCell} />;
|
||||
}
|
||||
const dateKey = cell.date.format('YYYY-MM-DD');
|
||||
return (
|
||||
<DayCell
|
||||
key={cell.key}
|
||||
cell={cell}
|
||||
isSelected={selectedDateKey === dateKey}
|
||||
onPress={() => onSelect(dateKey)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
{selectedIndex !== -1 && (
|
||||
<View style={styles.inlineTipContainer}>
|
||||
{renderTip(selectedIndex)}
|
||||
</View>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default function MenstrualCycleScreen() {
|
||||
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({
|
||||
@@ -157,8 +66,11 @@ export default function MenstrualCycleScreen() {
|
||||
monthsAfter: windowConfig.after,
|
||||
records,
|
||||
defaultPeriodLength: DEFAULT_PERIOD_LENGTH,
|
||||
locale,
|
||||
monthTitleFormat,
|
||||
monthSubtitleFormat,
|
||||
}),
|
||||
[records, windowConfig]
|
||||
[records, windowConfig, locale, monthSubtitleFormat, monthTitleFormat]
|
||||
);
|
||||
const [activeTab, setActiveTab] = useState<TabKey>('cycle');
|
||||
const [selectedDateKey, setSelectedDateKey] = useState(
|
||||
@@ -168,15 +80,32 @@ export default function MenstrualCycleScreen() {
|
||||
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]);
|
||||
|
||||
|
||||
const handleMarkStart = () => {
|
||||
// 标记当天为经期开始(包含乐观更新与 HealthKit 同步)
|
||||
const handleMarkStart = async () => {
|
||||
if (selectedDate.isAfter(dayjs(), 'day')) return;
|
||||
|
||||
// Check if the selected date is already covered by an existing record (including duration)
|
||||
// Check if the selected date is already covered
|
||||
const isCovered = records.some((r) => {
|
||||
const start = dayjs(r.startDate);
|
||||
const end = start.add((r.periodLength ?? DEFAULT_PERIOD_LENGTH) - 1, 'day');
|
||||
@@ -187,45 +116,36 @@ export default function MenstrualCycleScreen() {
|
||||
});
|
||||
if (isCovered) return;
|
||||
|
||||
// Optimistic Update
|
||||
const originalRecords = [...records];
|
||||
setRecords((prev) => {
|
||||
const updated = [...prev];
|
||||
|
||||
// 1. Check if selectedDate is immediately after an existing period
|
||||
// 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');
|
||||
});
|
||||
|
||||
// 2. Check if selectedDate is immediately before an existing period
|
||||
const nextRecordIndex = updated.findIndex((r) => {
|
||||
return dayjs(r.startDate).subtract(1, 'day').isSame(selectedDate, 'day');
|
||||
});
|
||||
|
||||
if (prevRecordIndex !== -1 && nextRecordIndex !== -1) {
|
||||
// Merge three parts: Prev + Selected + Next
|
||||
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,
|
||||
};
|
||||
// Remove the next record since it's merged
|
||||
updated[prevRecordIndex] = { ...prevRecord, periodLength: newLength };
|
||||
updated.splice(nextRecordIndex, 1);
|
||||
} else if (prevRecordIndex !== -1) {
|
||||
// Extend previous record
|
||||
const prevRecord = updated[prevRecordIndex];
|
||||
updated[prevRecordIndex] = {
|
||||
...prevRecord,
|
||||
periodLength: (prevRecord.periodLength ?? DEFAULT_PERIOD_LENGTH) + 1,
|
||||
};
|
||||
} else if (nextRecordIndex !== -1) {
|
||||
// Extend next record (start earlier)
|
||||
const nextRecord = updated[nextRecordIndex];
|
||||
updated[nextRecordIndex] = {
|
||||
...nextRecord,
|
||||
@@ -233,7 +153,6 @@ export default function MenstrualCycleScreen() {
|
||||
periodLength: (nextRecord.periodLength ?? DEFAULT_PERIOD_LENGTH) + 1,
|
||||
};
|
||||
} else {
|
||||
// Create new isolated record
|
||||
const newRecord: CycleRecord = {
|
||||
startDate: selectedDate.format('YYYY-MM-DD'),
|
||||
periodLength: 7,
|
||||
@@ -241,18 +160,60 @@ export default function MenstrualCycleScreen() {
|
||||
};
|
||||
updated.push(newRecord);
|
||||
}
|
||||
|
||||
return updated.sort(
|
||||
(a, b) => dayjs(a.startDate).valueOf() - dayjs(b.startDate).valueOf()
|
||||
);
|
||||
return updated.sort((a, b) => dayjs(a.startDate).valueOf() - dayjs(b.startDate).valueOf());
|
||||
});
|
||||
|
||||
try {
|
||||
// Determine what to save to HealthKit
|
||||
// If we are merging or extending, we are effectively adding one day of flow
|
||||
// If we are creating a new record, we default to 7 days
|
||||
// However, accurate HealthKit logging should be per day.
|
||||
// The previous UI logic "creates" a 7-day period for a single tap.
|
||||
// We should replicate this behavior in HealthKit for consistency.
|
||||
|
||||
const isNewIsolatedRecord = !records.some((r) => {
|
||||
const start = dayjs(r.startDate);
|
||||
const end = start.add((r.periodLength ?? DEFAULT_PERIOD_LENGTH) - 1, 'day');
|
||||
// Check adjacency
|
||||
return (
|
||||
end.add(1, 'day').isSame(selectedDate, 'day') ||
|
||||
dayjs(r.startDate).subtract(1, 'day').isSame(selectedDate, 'day')
|
||||
);
|
||||
});
|
||||
|
||||
if (isNewIsolatedRecord) {
|
||||
// Save 7 days of flow starting from selectedDate
|
||||
const promises = [];
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const date = selectedDate.add(i, 'day');
|
||||
// Don't save future dates if they exceed today (though logic allows predicting)
|
||||
// But for flow logging, we usually only log past/present.
|
||||
// However, UI allows setting a period that might extend slightly?
|
||||
// Let's stick to the selected date logic.
|
||||
// Wait, if I tap "Mark Start", it creates a 7 day period.
|
||||
// Should I write 7 samples? Yes, to match the UI state.
|
||||
promises.push(saveMenstrualFlow(date.toDate(), 1, i === 0)); // 1=unspecified
|
||||
}
|
||||
await Promise.all(promises);
|
||||
} else {
|
||||
// Just adding a single day to bridge/extend
|
||||
await saveMenstrualFlow(selectedDate.toDate(), 1, false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to save to HealthKit', error);
|
||||
// Revert optimistic update
|
||||
setRecords(originalRecords);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelMark = () => {
|
||||
// 取消选中日期的经期标记(与 HealthKit 同步)
|
||||
const handleCancelMark = async () => {
|
||||
if (!selectedInfo || !selectedInfo.confirmed) return;
|
||||
if (selectedDate.isAfter(dayjs(), 'day')) return;
|
||||
const target = selectedDate;
|
||||
|
||||
// Optimistic Update
|
||||
const originalRecords = [...records];
|
||||
setRecords((prev) => {
|
||||
const updated: CycleRecord[] = [];
|
||||
prev.forEach((record) => {
|
||||
@@ -264,23 +225,50 @@ export default function MenstrualCycleScreen() {
|
||||
updated.push(record);
|
||||
return;
|
||||
}
|
||||
|
||||
if (diff === 0) {
|
||||
// 取消开始日:移除整段记录
|
||||
return;
|
||||
}
|
||||
|
||||
// diff > 0 且在区间内:将该日标记为结束日 (选中当日也被取消,所以长度为 diff)
|
||||
updated.push({
|
||||
...record,
|
||||
periodLength: diff,
|
||||
});
|
||||
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;
|
||||
@@ -289,6 +277,7 @@ export default function MenstrualCycleScreen() {
|
||||
setWindowConfig((prev) => ({ ...prev, before: prev.before + delta }));
|
||||
};
|
||||
|
||||
// 向前追加月份时,保持当前视口位置不跳动
|
||||
useEffect(() => {
|
||||
if (prependDeltaRef.current > 0 && listRef.current) {
|
||||
const offset = offsetRef.current + prependDeltaRef.current * ITEM_HEIGHT;
|
||||
@@ -304,6 +293,7 @@ export default function MenstrualCycleScreen() {
|
||||
viewAreaCoveragePercentThreshold: 10,
|
||||
}).current;
|
||||
|
||||
// 监测可视区域,接近顶部时触发加载更早月份
|
||||
const onViewableItemsChanged = useRef(({ viewableItems }: any) => {
|
||||
const minIndex = viewableItems.reduce(
|
||||
(acc: number, cur: any) => Math.min(acc, cur.index ?? acc),
|
||||
@@ -314,28 +304,9 @@ export default function MenstrualCycleScreen() {
|
||||
}
|
||||
}).current;
|
||||
|
||||
const renderLegend = () => (
|
||||
<View style={styles.legendRow}>
|
||||
{[
|
||||
{ label: '经期', key: 'period' as const },
|
||||
{ label: '预测经期', key: 'predicted-period' as const },
|
||||
{ label: '排卵期', key: 'fertile' as const },
|
||||
{ label: '排卵日', key: 'ovulation-day' as const },
|
||||
].map((item) => (
|
||||
<View key={item.key} style={styles.legendItem}>
|
||||
<View
|
||||
style={[
|
||||
styles.legendDot,
|
||||
{ backgroundColor: STATUS_COLORS[item.key].bg },
|
||||
item.key === 'ovulation-day' && styles.legendDotRing,
|
||||
]}
|
||||
/>
|
||||
<Text style={styles.legendLabel}>{item.label}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
|
||||
|
||||
// FlatList 数据源:按月份拆分
|
||||
const listData = useMemo(() => {
|
||||
return timeline.months.map((m) => ({
|
||||
type: 'month' as const,
|
||||
@@ -344,40 +315,19 @@ export default function MenstrualCycleScreen() {
|
||||
}));
|
||||
}, [timeline.months]);
|
||||
|
||||
const renderInlineTip = (columnIndex: number) => {
|
||||
// 14.28% per cell. Center is 7.14%.
|
||||
const pointerLeft = `${columnIndex * 14.2857 + 7.1428}%` as DimensionValue;
|
||||
const isFuture = selectedDate.isAfter(dayjs(), 'day');
|
||||
|
||||
const base = (
|
||||
<View style={styles.inlineTipCard}>
|
||||
<View style={[styles.inlineTipPointer, { left: pointerLeft }]} />
|
||||
<View style={styles.inlineTipRow}>
|
||||
<View style={styles.inlineTipDate}>
|
||||
<Ionicons name="calendar-outline" size={16} color="#111827" />
|
||||
<Text style={styles.inlineTipDateText}>{selectedDate.format('M月D日')}</Text>
|
||||
</View>
|
||||
{!isFuture && (!selectedInfo || !selectedInfo.confirmed) && (
|
||||
<TouchableOpacity style={styles.inlinePrimaryBtn} onPress={handleMarkStart}>
|
||||
<Ionicons name="add" size={14} color="#fff" />
|
||||
<Text style={styles.inlinePrimaryText}>标记经期</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
{!isFuture && selectedInfo?.confirmed && selectedInfo.status === 'period' && (
|
||||
<TouchableOpacity style={styles.inlineSecondaryBtn} onPress={handleCancelMark}>
|
||||
<Text style={styles.inlineSecondaryText}>取消标记</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
return base;
|
||||
};
|
||||
const renderInlineTip = (columnIndex: number) => (
|
||||
<InlineTip
|
||||
selectedDate={selectedDate}
|
||||
selectedInfo={selectedInfo}
|
||||
columnIndex={columnIndex}
|
||||
onMarkStart={handleMarkStart}
|
||||
onCancelMark={handleCancelMark}
|
||||
/>
|
||||
);
|
||||
|
||||
const renderCycleTab = () => (
|
||||
<View style={styles.tabContent}>
|
||||
{renderLegend()}
|
||||
<Legend />
|
||||
|
||||
|
||||
<FlatList
|
||||
@@ -390,6 +340,7 @@ export default function MenstrualCycleScreen() {
|
||||
selectedDateKey={selectedDateKey}
|
||||
onSelect={(key) => setSelectedDateKey(key)}
|
||||
renderTip={renderInlineTip}
|
||||
weekLabels={weekLabels}
|
||||
/>
|
||||
)}
|
||||
showsVerticalScrollIndicator={false}
|
||||
@@ -398,6 +349,19 @@ export default function MenstrualCycleScreen() {
|
||||
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) => {
|
||||
@@ -411,9 +375,9 @@ export default function MenstrualCycleScreen() {
|
||||
const renderAnalysisTab = () => (
|
||||
<View style={styles.tabContent}>
|
||||
<View style={styles.analysisCard}>
|
||||
<Text style={styles.analysisTitle}>分析</Text>
|
||||
<Text style={styles.analysisTitle}>{t('menstrual.screen.analysis.title')}</Text>
|
||||
<Text style={styles.analysisBody}>
|
||||
基于最近 6 个周期的记录,计算平均经期和周期长度,后续会展示趋势和预测准确度。
|
||||
{t('menstrual.screen.analysis.description')}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
@@ -429,20 +393,35 @@ export default function MenstrualCycleScreen() {
|
||||
end={{ x: 0, y: 1 }}
|
||||
/>
|
||||
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity onPress={() => router.back()} style={styles.headerIcon}>
|
||||
<Ionicons name="chevron-back" size={22} color="#0f172a" />
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.headerTitle}>生理周期</Text>
|
||||
<TouchableOpacity style={styles.headerIcon}>
|
||||
<Ionicons name="settings-outline" size={20} color="#0f172a" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<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: '生理周期' },
|
||||
{ key: 'analysis', label: '分析' },
|
||||
{ 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 (
|
||||
@@ -468,29 +447,29 @@ export default function MenstrualCycleScreen() {
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
paddingTop: 52,
|
||||
paddingHorizontal: 16,
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 12,
|
||||
},
|
||||
headerIcon: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: 'rgba(255,255,255,0.9)',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.5)',
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '800',
|
||||
color: '#0f172a',
|
||||
fontFamily: 'AliBold',
|
||||
headerIconButton: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
headerIconGlass: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
tabSwitcher: {
|
||||
flexDirection: 'row',
|
||||
@@ -525,32 +504,7 @@ const styles = StyleSheet.create({
|
||||
tabContent: {
|
||||
flex: 1,
|
||||
},
|
||||
legendRow: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 12,
|
||||
marginBottom: 12,
|
||||
paddingHorizontal: 4,
|
||||
},
|
||||
legendItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
legendDot: {
|
||||
width: 16,
|
||||
height: 16,
|
||||
borderRadius: 8,
|
||||
marginRight: 6,
|
||||
},
|
||||
legendDotRing: {
|
||||
borderWidth: 2,
|
||||
borderColor: '#fff',
|
||||
},
|
||||
legendLabel: {
|
||||
fontSize: 13,
|
||||
color: '#111827',
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
|
||||
selectedCard: {
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 16,
|
||||
@@ -569,206 +523,7 @@ const styles = StyleSheet.create({
|
||||
fontWeight: '700',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
tipCard: {
|
||||
backgroundColor: '#f4f3ff',
|
||||
borderRadius: 14,
|
||||
padding: 12,
|
||||
marginTop: 10,
|
||||
borderWidth: 1,
|
||||
borderColor: '#ede9fe',
|
||||
},
|
||||
tipTitle: {
|
||||
fontSize: 14,
|
||||
color: '#111827',
|
||||
fontWeight: '700',
|
||||
marginBottom: 4,
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
tipDesc: {
|
||||
fontSize: 12,
|
||||
color: '#6b7280',
|
||||
lineHeight: 18,
|
||||
marginBottom: 8,
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
tipButton: {
|
||||
backgroundColor: Colors.light.primary,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
},
|
||||
tipButtonText: {
|
||||
color: '#fff',
|
||||
fontSize: 14,
|
||||
fontWeight: '700',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
tipSecondaryButton: {
|
||||
backgroundColor: '#fff',
|
||||
paddingVertical: 10,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
borderWidth: 1,
|
||||
borderColor: '#e5e7eb',
|
||||
},
|
||||
tipSecondaryButtonText: {
|
||||
color: '#0f172a',
|
||||
fontSize: 14,
|
||||
fontWeight: '700',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
inlineTipCard: {
|
||||
backgroundColor: '#e8e7ff',
|
||||
borderRadius: 18,
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 12,
|
||||
shadowColor: '#000',
|
||||
shadowOpacity: 0.04,
|
||||
shadowRadius: 6,
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
elevation: 1,
|
||||
},
|
||||
inlineTipPointer: {
|
||||
position: 'absolute',
|
||||
top: -6,
|
||||
width: 12,
|
||||
height: 12,
|
||||
marginLeft: -6,
|
||||
backgroundColor: '#e8e7ff',
|
||||
transform: [{ rotate: '45deg' }],
|
||||
borderRadius: 3,
|
||||
},
|
||||
inlineTipRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 8,
|
||||
},
|
||||
inlineTipDate: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
},
|
||||
inlineTipDateText: {
|
||||
fontSize: 14,
|
||||
color: '#111827',
|
||||
fontWeight: '800',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
inlinePrimaryBtn: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: Colors.light.primary,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 14,
|
||||
gap: 6,
|
||||
},
|
||||
inlinePrimaryText: {
|
||||
color: '#fff',
|
||||
fontSize: 13,
|
||||
fontWeight: '700',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
inlineSecondaryBtn: {
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 14,
|
||||
backgroundColor: '#fff',
|
||||
borderWidth: 1,
|
||||
borderColor: '#d1d5db',
|
||||
},
|
||||
inlineSecondaryText: {
|
||||
color: '#111827',
|
||||
fontSize: 13,
|
||||
fontWeight: '700',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
monthCard: {
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 16,
|
||||
padding: 14,
|
||||
marginBottom: 12,
|
||||
shadowColor: '#000',
|
||||
shadowOpacity: 0.08,
|
||||
shadowRadius: 8,
|
||||
shadowOffset: { width: 0, height: 6 },
|
||||
elevation: 2,
|
||||
},
|
||||
monthHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 8,
|
||||
},
|
||||
monthTitle: {
|
||||
fontSize: 17,
|
||||
fontWeight: '800',
|
||||
color: '#0f172a',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
monthSubtitle: {
|
||||
fontSize: 12,
|
||||
color: '#6b7280',
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
weekRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 6,
|
||||
paddingHorizontal: 4,
|
||||
},
|
||||
weekLabel: {
|
||||
width: '14.28%',
|
||||
textAlign: 'center',
|
||||
fontSize: 12,
|
||||
color: '#94a3b8',
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
monthGrid: {
|
||||
flexDirection: 'column',
|
||||
},
|
||||
daysRow: {
|
||||
flexDirection: 'row',
|
||||
},
|
||||
dayCell: {
|
||||
width: '14.28%',
|
||||
alignItems: 'center',
|
||||
marginVertical: 6,
|
||||
},
|
||||
inlineTipContainer: {
|
||||
paddingBottom: 6,
|
||||
marginBottom: 6,
|
||||
},
|
||||
dayCircle: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: '#f3f4f6',
|
||||
},
|
||||
dayCircleSelected: {
|
||||
borderWidth: 2,
|
||||
borderColor: Colors.light.primary,
|
||||
},
|
||||
todayOutline: {
|
||||
borderWidth: 2,
|
||||
borderColor: '#94a3b8',
|
||||
},
|
||||
dayLabel: {
|
||||
fontSize: 15,
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
dayLabelDefault: {
|
||||
color: '#111827',
|
||||
},
|
||||
todayText: {
|
||||
fontSize: 10,
|
||||
color: '#9ca3af',
|
||||
marginTop: 2,
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
|
||||
listContent: {
|
||||
paddingBottom: 80,
|
||||
},
|
||||
|
||||
@@ -3,6 +3,7 @@ import { DateSelector } from '@/components/DateSelector';
|
||||
import { FloatingFoodOverlay } from '@/components/FloatingFoodOverlay';
|
||||
import { NutritionRecordCard } from '@/components/NutritionRecordCard';
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { Image } from '@/components/ui/Image';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||
@@ -27,7 +28,6 @@ import { Ionicons } from '@expo/vector-icons';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
import dayjs from 'dayjs';
|
||||
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
import { Image } from 'expo-image';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { router } from 'expo-router';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { Image } from '@/components/ui/Image';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
@@ -13,7 +14,6 @@ import { Ionicons } from '@expo/vector-icons';
|
||||
import DateTimePicker from '@react-native-community/datetimepicker';
|
||||
import { Picker } from '@react-native-picker/picker';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
import { Image } from 'expo-image';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import { router } from 'expo-router';
|
||||
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 { SleepStageTimeline } from '@/components/sleep/SleepStageTimeline';
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { Image } from '@/components/ui/Image';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
import {
|
||||
@@ -14,7 +15,6 @@ import {
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import dayjs from 'dayjs';
|
||||
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
import { Image } from 'expo-image';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { router, useLocalSearchParams } from 'expo-router';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
@@ -42,10 +42,12 @@ export default function StatisticsCustomizationScreen() {
|
||||
steps: { icon: 'footsteps-outline', titleKey: 'statisticsCustomization.items.steps', visibilityKey: 'showSteps' },
|
||||
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' },
|
||||
@@ -354,4 +356,4 @@ const styles = StyleSheet.create({
|
||||
switch: {
|
||||
transform: [{ scaleX: 0.9 }, { scaleY: 0.9 }],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Image } from '@/components/ui/Image';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { useWaterDataByDate } from '@/hooks/useWaterData';
|
||||
import { getQuickWaterAmount } from '@/utils/userPreferences';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
import { Image } from 'expo-image';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { router, useLocalSearchParams } from 'expo-router';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Image } from '@/components/ui/Image';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { useWaterDataByDate } from '@/hooks/useWaterData';
|
||||
import { getQuickWaterAmount, setQuickWaterAmount } from '@/utils/userPreferences';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import dayjs from 'dayjs';
|
||||
import { Image } from 'expo-image';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Image } from '@/components/ui/Image';
|
||||
import { ROUTES } from '@/constants/Routes';
|
||||
import { useAppSelector } from '@/hooks/redux';
|
||||
import { selectUserAge, selectUserProfile } from '@/store/userSlice';
|
||||
import { fetchBasalEnergyBurned } from '@/utils/health';
|
||||
import dayjs from 'dayjs';
|
||||
import { Image } from 'expo-image';
|
||||
import { router } from 'expo-router';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -1,14 +1,33 @@
|
||||
import dayjs, { Dayjs } from 'dayjs';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import React, { useMemo } from 'react';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { buildMenstrualTimeline } from '@/utils/menstrualCycle';
|
||||
import { fetchMenstrualFlowSamples, healthDataEvents } from '@/utils/health';
|
||||
import {
|
||||
buildMenstrualTimeline,
|
||||
convertHealthKitSamplesToCycleRecords,
|
||||
CycleRecord,
|
||||
DEFAULT_PERIOD_LENGTH,
|
||||
MenstrualDayInfo,
|
||||
MenstrualDayStatus,
|
||||
MenstrualTimeline,
|
||||
} from '@/utils/menstrualCycle';
|
||||
|
||||
type Props = {
|
||||
onPress?: () => void;
|
||||
};
|
||||
|
||||
type Summary = {
|
||||
state: string;
|
||||
prefix?: string;
|
||||
suffix?: string;
|
||||
number?: number;
|
||||
fallbackText: string;
|
||||
};
|
||||
|
||||
const RingIcon = () => (
|
||||
<View style={styles.iconWrapper}>
|
||||
<LinearGradient
|
||||
@@ -23,45 +42,79 @@ const RingIcon = () => (
|
||||
);
|
||||
|
||||
export const MenstrualCycleCard: React.FC<Props> = ({ onPress }) => {
|
||||
const { todayInfo, periodLength } = useMemo(() => buildMenstrualTimeline(), []);
|
||||
const { t } = useTranslation();
|
||||
const [records, setRecords] = useState<CycleRecord[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
const loadMenstrualData = async () => {
|
||||
// Avoid setting loading to true for background updates to prevent UI flicker
|
||||
if (records.length === 0) {
|
||||
setLoading(true);
|
||||
}
|
||||
try {
|
||||
const today = dayjs();
|
||||
const startDate = today.subtract(3, 'month').startOf('month').toDate();
|
||||
const endDate = today.add(4, 'month').endOf('month').toDate();
|
||||
|
||||
const samples = await fetchMenstrualFlowSamples(startDate, endDate);
|
||||
if (!mounted) return;
|
||||
const converted = convertHealthKitSamplesToCycleRecords(samples);
|
||||
setRecords(converted);
|
||||
} catch (error) {
|
||||
console.error('Failed to load menstrual flow samples', error);
|
||||
if (mounted) {
|
||||
setRecords([]);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadMenstrualData();
|
||||
|
||||
// Listen for data changes
|
||||
const handleDataChange = () => {
|
||||
loadMenstrualData();
|
||||
};
|
||||
healthDataEvents.on('menstrualDataChanged', handleDataChange);
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
healthDataEvents.off('menstrualDataChanged', handleDataChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const timeline = useMemo(
|
||||
() =>
|
||||
buildMenstrualTimeline({
|
||||
records,
|
||||
monthsBefore: 2,
|
||||
monthsAfter: 4,
|
||||
defaultPeriodLength: DEFAULT_PERIOD_LENGTH,
|
||||
}),
|
||||
[records]
|
||||
);
|
||||
|
||||
const summary = useMemo(() => {
|
||||
if (!todayInfo) {
|
||||
if (loading && records.length === 0) {
|
||||
return {
|
||||
state: '待记录',
|
||||
dayText: '点击记录本次经期',
|
||||
number: undefined,
|
||||
state: t('menstrual.card.syncingState'),
|
||||
fallbackText: t('menstrual.card.syncingDesc'),
|
||||
};
|
||||
}
|
||||
|
||||
if (todayInfo.status === 'period' || todayInfo.status === 'predicted-period') {
|
||||
return {
|
||||
state: todayInfo.status === 'period' ? '经期' : '预测经期',
|
||||
dayText: '天',
|
||||
number: todayInfo.dayOfCycle ?? 1,
|
||||
};
|
||||
}
|
||||
|
||||
if (todayInfo.status === 'ovulation-day') {
|
||||
return {
|
||||
state: '排卵日',
|
||||
dayText: '易孕窗口',
|
||||
number: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
state: '排卵期',
|
||||
dayText: `距离排卵日${Math.max(periodLength - 1, 1)}天`,
|
||||
number: undefined,
|
||||
};
|
||||
}, [periodLength, todayInfo]);
|
||||
return 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}>生理周期</Text>
|
||||
<Text style={styles.title}>{t('menstrual.card.title')}</Text>
|
||||
<View style={styles.badgeOuter}>
|
||||
<View style={styles.badgeInner} />
|
||||
</View>
|
||||
@@ -71,10 +124,12 @@ export const MenstrualCycleCard: React.FC<Props> = ({ onPress }) => {
|
||||
<Text style={styles.dayRow}>
|
||||
{summary.number !== undefined ? (
|
||||
<>
|
||||
第 <Text style={styles.dayNumber}>{summary.number}</Text> {summary.dayText}
|
||||
{summary.prefix}
|
||||
<Text style={styles.dayNumber}>{summary.number}</Text>
|
||||
{summary.suffix}
|
||||
</>
|
||||
) : (
|
||||
summary.dayText
|
||||
summary.fallbackText
|
||||
)}
|
||||
</Text>
|
||||
</View>
|
||||
@@ -82,6 +137,199 @@ export const MenstrualCycleCard: React.FC<Props> = ({ onPress }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const periodStatuses = new Set<MenstrualDayStatus>(['period', 'predicted-period']);
|
||||
const fertileStatuses = new Set<MenstrualDayStatus>(['fertile', 'ovulation-day']);
|
||||
const ovulationStatuses = new Set<MenstrualDayStatus>(['ovulation-day']);
|
||||
|
||||
const deriveSummary = (
|
||||
timeline: MenstrualTimeline,
|
||||
hasRecords: boolean,
|
||||
t: (key: string, options?: Record<string, any>) => string
|
||||
): Summary => {
|
||||
const today = dayjs();
|
||||
const { dayMap, todayInfo } = timeline;
|
||||
|
||||
if (!hasRecords || !Object.keys(dayMap).length) {
|
||||
return {
|
||||
state: t('menstrual.card.emptyState'),
|
||||
fallbackText: t('menstrual.card.emptyDesc'),
|
||||
};
|
||||
}
|
||||
|
||||
const sortedInfos = Object.values(dayMap).sort(
|
||||
(a, b) => a.date.valueOf() - b.date.valueOf()
|
||||
);
|
||||
|
||||
const findContinuousRange = (
|
||||
date: Dayjs,
|
||||
targetStatuses: Set<MenstrualDayStatus>
|
||||
): { start: Dayjs; end: Dayjs } | null => {
|
||||
const key = date.format('YYYY-MM-DD');
|
||||
if (!targetStatuses.has(dayMap[key]?.status)) return null;
|
||||
|
||||
let start = date;
|
||||
let end = date;
|
||||
|
||||
while (true) {
|
||||
const prev = start.subtract(1, 'day');
|
||||
const prevInfo = dayMap[prev.format('YYYY-MM-DD')];
|
||||
if (prevInfo && targetStatuses.has(prevInfo.status)) {
|
||||
start = prev;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
while (true) {
|
||||
const next = end.add(1, 'day');
|
||||
const nextInfo = dayMap[next.format('YYYY-MM-DD')];
|
||||
if (nextInfo && targetStatuses.has(nextInfo.status)) {
|
||||
end = next;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { start, end };
|
||||
};
|
||||
|
||||
const findFutureStatus = (
|
||||
targetStatuses: Set<MenstrualDayStatus>,
|
||||
inclusive = true
|
||||
): MenstrualDayInfo | undefined => {
|
||||
return sortedInfos.find((info) => {
|
||||
const isInRange = inclusive
|
||||
? !info.date.isBefore(today, 'day')
|
||||
: info.date.isAfter(today, 'day');
|
||||
return isInRange && targetStatuses.has(info.status);
|
||||
});
|
||||
};
|
||||
|
||||
const findPastStatus = (targetStatuses: Set<MenstrualDayStatus>) => {
|
||||
for (let i = sortedInfos.length - 1; i >= 0; i -= 1) {
|
||||
const info = sortedInfos[i];
|
||||
if (!info.date.isAfter(today, 'day') && targetStatuses.has(info.status)) {
|
||||
return info;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
if (todayInfo && periodStatuses.has(todayInfo.status)) {
|
||||
const range = findContinuousRange(today, periodStatuses);
|
||||
const end = range?.end ?? today;
|
||||
const daysLeft = Math.max(end.diff(today, 'day'), 0);
|
||||
|
||||
if (daysLeft === 0) {
|
||||
return {
|
||||
state:
|
||||
todayInfo.status === 'period'
|
||||
? t('menstrual.card.periodState')
|
||||
: t('menstrual.card.predictedPeriodState'),
|
||||
fallbackText: t('menstrual.card.periodEndToday', {
|
||||
date: end.format(t('menstrual.dateFormatShort')),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
state:
|
||||
todayInfo.status === 'period'
|
||||
? t('menstrual.card.periodState')
|
||||
: t('menstrual.card.predictedPeriodState'),
|
||||
prefix: t('menstrual.card.periodEndPrefix'),
|
||||
number: daysLeft,
|
||||
suffix: t('menstrual.card.periodEndSuffix', {
|
||||
date: end.format(t('menstrual.dateFormatShort')),
|
||||
}),
|
||||
fallbackText: '',
|
||||
};
|
||||
}
|
||||
|
||||
const nextPeriod = findFutureStatus(periodStatuses, false);
|
||||
const lastPeriodInfo = findPastStatus(periodStatuses);
|
||||
const lastPeriodStart = lastPeriodInfo
|
||||
? findContinuousRange(lastPeriodInfo.date, periodStatuses)?.start
|
||||
: undefined;
|
||||
|
||||
const ovulationThisCycle = sortedInfos.find((info) => {
|
||||
if (!ovulationStatuses.has(info.status)) return false;
|
||||
if (lastPeriodStart && info.date.isBefore(lastPeriodStart, 'day')) return false;
|
||||
if (nextPeriod && !info.date.isBefore(nextPeriod.date, 'day')) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
if (todayInfo?.status === 'fertile') {
|
||||
const targetOvulation = ovulationThisCycle ?? findFutureStatus(ovulationStatuses);
|
||||
if (targetOvulation) {
|
||||
const days = Math.max(targetOvulation.date.diff(today, 'day'), 0);
|
||||
if (days === 0) {
|
||||
return {
|
||||
state: t('menstrual.card.fertileState'),
|
||||
fallbackText: t('menstrual.card.ovulationToday'),
|
||||
};
|
||||
}
|
||||
return {
|
||||
state: t('menstrual.card.fertileState'),
|
||||
prefix: t('menstrual.card.ovulationCountdownPrefix'),
|
||||
number: days,
|
||||
suffix: t('menstrual.card.ovulationCountdownSuffix'),
|
||||
fallbackText: '',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const nextFertile = findFutureStatus(fertileStatuses);
|
||||
if (nextFertile && (!nextPeriod || nextFertile.date.isBefore(nextPeriod.date))) {
|
||||
const days = Math.max(nextFertile.date.diff(today, 'day'), 0);
|
||||
if (days === 0) {
|
||||
return {
|
||||
state: t('menstrual.card.fertileState'),
|
||||
fallbackText: t('menstrual.card.fertileToday'),
|
||||
};
|
||||
}
|
||||
return {
|
||||
state: t('menstrual.card.fertileState'),
|
||||
prefix: t('menstrual.card.fertileCountdownPrefix'),
|
||||
number: days,
|
||||
suffix: t('menstrual.card.fertileCountdownSuffix'),
|
||||
fallbackText: '',
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
ovulationThisCycle &&
|
||||
nextPeriod &&
|
||||
today.isAfter(ovulationThisCycle.date, 'day') &&
|
||||
today.isBefore(nextPeriod.date, 'day')
|
||||
) {
|
||||
const days = Math.max(nextPeriod.date.diff(today, 'day'), 0);
|
||||
return {
|
||||
state: t('menstrual.card.periodState'),
|
||||
prefix: t('menstrual.card.nextPeriodPrefix'),
|
||||
number: days,
|
||||
suffix: t('menstrual.card.nextPeriodSuffix'),
|
||||
fallbackText: '',
|
||||
};
|
||||
}
|
||||
|
||||
if (nextPeriod) {
|
||||
const days = Math.max(nextPeriod.date.diff(today, 'day'), 0);
|
||||
return {
|
||||
state: t('menstrual.card.periodState'),
|
||||
prefix: t('menstrual.card.nextPeriodPrefix'),
|
||||
number: days,
|
||||
suffix: t('menstrual.card.nextPeriodSuffix'),
|
||||
fallbackText: '',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
state: t('menstrual.card.emptyState'),
|
||||
fallbackText: t('menstrual.card.emptyDesc'),
|
||||
};
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
wrapper: {
|
||||
width: '100%',
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { ThemedText } from '@/components/ThemedText';
|
||||
import { Image } from '@/components/ui/Image';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
import { useThemeColor } from '@/hooks/useThemeColor';
|
||||
import { DietRecord } from '@/services/dietRecords';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import dayjs from 'dayjs';
|
||||
import { Image } from 'expo-image';
|
||||
import React, { useMemo, useRef, useState } from 'react';
|
||||
import { Alert, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { RectButton, Swipeable } from 'react-native-gesture-handler';
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Animated,
|
||||
InteractionManager,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
ViewStyle
|
||||
Animated,
|
||||
InteractionManager,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
ViewStyle
|
||||
} from 'react-native';
|
||||
|
||||
import { Image } from '@/components/ui/Image';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { ChallengeType } from '@/services/challengesApi';
|
||||
import { reportChallengeProgress, selectChallengeList } from '@/store/challengesSlice';
|
||||
import { fetchHourlyStepSamples, fetchStepCount, HourlyStepData } from '@/utils/health';
|
||||
import { logger } from '@/utils/logger';
|
||||
import dayjs from 'dayjs';
|
||||
import { Image } from 'expo-image';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AnimatedNumber } from './AnimatedNumber';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Image } from '@/components/ui/Image';
|
||||
import { fetchHRVWithStatus } from '@/utils/health';
|
||||
import { convertHrvToStressIndex } from '@/utils/stress';
|
||||
import { Image } from 'expo-image';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Image } from '@/components/ui/Image';
|
||||
import { useWaterDataByDate } from '@/hooks/useWaterData';
|
||||
import { appStoreReviewService } from '@/services/appStoreReview';
|
||||
import { getQuickWaterAmount } from '@/utils/userPreferences';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
import dayjs from 'dayjs';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { Image } from 'expo-image';
|
||||
import { useRouter } from 'expo-router';
|
||||
import LottieView from 'lottie-react-native';
|
||||
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 dayjs from 'dayjs';
|
||||
import { Image } from 'expo-image';
|
||||
import { useRouter } from 'expo-router';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Image } from '@/components/ui/Image';
|
||||
import { Toast } from '@/utils/toast.utils';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import dayjs from 'dayjs';
|
||||
import { BlurView } from 'expo-blur';
|
||||
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
import { Image } from 'expo-image';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import dayjs from 'dayjs';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
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 type { RankingItem } from '@/store/challengesSlice';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { Image } from 'expo-image';
|
||||
import React from 'react';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Image } from '@/components/ui/Image';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { BlurView } from 'expo-blur';
|
||||
import { Image } from 'expo-image';
|
||||
import React from 'react';
|
||||
import { ScrollView, StyleSheet, Text, TextInput, TouchableOpacity, View } from 'react-native';
|
||||
import QuickChips from './QuickChips';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Image } from '@/components/ui/Image';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { Image } from 'expo-image';
|
||||
import React from 'react';
|
||||
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import Markdown from 'react-native-markdown-display';
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Colors, palette } from '@/constants/Colors';
|
||||
import { Image } from '@/components/ui/Image';
|
||||
import { palette } from '@/constants/Colors';
|
||||
import { MedicalRecordItem } from '@/services/healthProfile';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import dayjs from 'dayjs';
|
||||
import { Image } from 'expo-image';
|
||||
import React from 'react';
|
||||
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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';
|
||||
@@ -13,7 +14,6 @@ import {
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import dayjs from 'dayjs';
|
||||
import * as DocumentPicker from 'expo-document-picker';
|
||||
import { Image } from 'expo-image';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Image } from '@/components/ui/Image';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { Image } from 'expo-image';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Animated, Modal, Pressable, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ThemedText } from '@/components/ThemedText';
|
||||
import { Image } from '@/components/ui/Image';
|
||||
import { useAppDispatch } from '@/hooks/redux';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
import { skipMedicationAction, takeMedicationAction } from '@/store/medicationsSlice';
|
||||
@@ -6,7 +7,6 @@ import type { MedicationDisplayItem } from '@/types/medication';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import dayjs, { Dayjs } from 'dayjs';
|
||||
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
import { Image } from 'expo-image';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Alert, StyleSheet, TouchableOpacity, View } from 'react-native';
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Image } from '@/components/ui/Image';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
import { Image } from 'expo-image';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import React from 'react';
|
||||
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 { useCosUpload } from '@/hooks/useCosUpload';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { BlurView } from 'expo-blur';
|
||||
import { Image } from 'expo-image';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
@@ -15,10 +15,10 @@ import {
|
||||
View,
|
||||
} from 'react-native';
|
||||
// 导入统一的食物类型定义
|
||||
import { Image } from '@/components/ui/Image';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { DEFAULT_IMAGE_FOOD } from '@/constants/Image';
|
||||
import type { FoodItem } from '@/types/food';
|
||||
import { Image } from 'expo-image';
|
||||
|
||||
// 导入统一的食物类型定义
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Image } from 'expo-image';
|
||||
import { Image } from '@/components/ui/Image';
|
||||
import React from 'react';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
import { ImageSourcePropType, Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import Animated, { FadeIn, FadeOut } from 'react-native-reanimated';
|
||||
|
||||
interface HealthDataCardProps {
|
||||
@@ -8,37 +8,53 @@ interface HealthDataCardProps {
|
||||
value: string;
|
||||
unit: string;
|
||||
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> = ({
|
||||
title,
|
||||
value,
|
||||
unit,
|
||||
style
|
||||
style,
|
||||
onPress,
|
||||
icon,
|
||||
iconSource,
|
||||
subtitle
|
||||
}) => {
|
||||
const Container = onPress ? Pressable : View;
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
entering={FadeIn.duration(300)}
|
||||
exiting={FadeOut.duration(300)}
|
||||
style={[styles.card, style]}
|
||||
>
|
||||
<View style={styles.content}>
|
||||
<View style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 14,
|
||||
}}>
|
||||
<Image
|
||||
source={require('@/assets/images/icons/icon-blood-oxygen.png')}
|
||||
style={styles.titleIcon}
|
||||
/>
|
||||
<Animated.View entering={FadeIn.duration(300)} exiting={FadeOut.duration(300)} style={[styles.card, style]}>
|
||||
<Container
|
||||
style={styles.content}
|
||||
onPress={onPress}
|
||||
accessibilityRole={onPress ? 'button' : undefined}
|
||||
accessibilityLabel={title}
|
||||
accessibilityHint={onPress ? `${title} details` : undefined}
|
||||
>
|
||||
<View style={styles.headerRow}>
|
||||
{icon ? (
|
||||
<View style={styles.iconWrapper}>{icon}</View>
|
||||
) : (
|
||||
<Image source={iconSource ?? defaultIconSource} style={styles.titleIcon} />
|
||||
)}
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
</View>
|
||||
<View style={styles.valueContainer}>
|
||||
<Text style={styles.value}>{value}</Text>
|
||||
<Text style={styles.unit}>{unit}</Text>
|
||||
</View>
|
||||
</View>
|
||||
{subtitle ? (
|
||||
<Text style={styles.subtitle} numberOfLines={1}>
|
||||
{subtitle}
|
||||
</Text>
|
||||
) : null}
|
||||
</Container>
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
@@ -62,6 +78,18 @@ const styles = StyleSheet.create({
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
headerRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 14,
|
||||
},
|
||||
iconWrapper: {
|
||||
width: 16,
|
||||
height: 16,
|
||||
marginRight: 6,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
titleIcon: {
|
||||
width: 16,
|
||||
height: 16,
|
||||
@@ -92,6 +120,12 @@ const styles = StyleSheet.create({
|
||||
fontWeight: '500',
|
||||
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() : '--'}
|
||||
unit="bpm"
|
||||
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 { ChallengeType } from '@/services/challengesApi';
|
||||
import { reportChallengeProgress, selectChallengeList } from '@/store/challengesSlice';
|
||||
import { logger } from '@/utils/logger';
|
||||
import { fetchCompleteSleepData, formatSleepTime } from '@/utils/sleepHealthKit';
|
||||
import dayjs from 'dayjs';
|
||||
import { Image } from 'expo-image';
|
||||
import { router } from 'expo-router';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
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 { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { Image } from 'expo-image';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Image } from '@/components/ui/Image';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { ROUTES } from '@/constants/Routes';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
@@ -7,7 +8,6 @@ import { fetchWeightHistory } from '@/store/userSlice';
|
||||
import { BMI_CATEGORIES } from '@/utils/bmi';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
import { Image } from 'expo-image';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { useRouter } from 'expo-router';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
ActivityIndicator,
|
||||
Dimensions,
|
||||
Modal,
|
||||
Platform,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
Share,
|
||||
@@ -19,7 +18,7 @@ import {
|
||||
TouchableWithoutFeedback,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import ViewShot from 'react-native-view-shot';
|
||||
import ViewShot, { captureRef } from 'react-native-view-shot';
|
||||
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
import {
|
||||
@@ -156,7 +155,7 @@ export function WorkoutDetailModal({
|
||||
type: 'info',
|
||||
text1: t('workoutDetail.share.generating', '正在生成分享卡片…'),
|
||||
});
|
||||
const uri = await shareContentRef.current.capture?.({
|
||||
const uri = await captureRef(shareContentRef, {
|
||||
format: 'png',
|
||||
quality: 0.95,
|
||||
snapshotContentContainer: true,
|
||||
@@ -164,6 +163,7 @@ export function WorkoutDetailModal({
|
||||
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')}`
|
||||
@@ -179,7 +179,7 @@ export function WorkoutDetailModal({
|
||||
await Share.share({
|
||||
title: shareTitle,
|
||||
message: shareMessage,
|
||||
url: Platform.OS === 'ios' ? uri : `file://${uri}`,
|
||||
url: shareUri,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('workout-detail-share-failed', error);
|
||||
@@ -487,7 +487,6 @@ export function WorkoutDetailModal({
|
||||
<ViewShot
|
||||
ref={shareContentRef}
|
||||
style={[styles.sheetContainer, styles.shareCaptureContainer]}
|
||||
collapsable={false}
|
||||
options={{ format: 'png', quality: 0.95, snapshotContentContainer: true }}
|
||||
>
|
||||
<LinearGradient
|
||||
|
||||
@@ -139,9 +139,32 @@ export const statistics = {
|
||||
title: 'Sleep',
|
||||
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: {
|
||||
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: {
|
||||
title: 'Circumference (cm)',
|
||||
setTitle: 'Set {{label}}',
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as Challenge from './challenge';
|
||||
import * as Common from './common';
|
||||
import * as Diet from './diet';
|
||||
import * as Health from './health';
|
||||
import * as Menstrual from './menstrual';
|
||||
import * as Medication from './medication';
|
||||
import * as Mood from './mood';
|
||||
import * as Personal from './personal';
|
||||
@@ -15,6 +16,7 @@ export default {
|
||||
...Weight,
|
||||
...Challenge,
|
||||
...Mood,
|
||||
...Menstrual,
|
||||
...Common,
|
||||
...Common.common, // 确保通用翻译被正确导出
|
||||
};
|
||||
|
||||
53
i18n/en/menstrual.ts
Normal file
53
i18n/en/menstrual.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
export const menstrual = {
|
||||
dateFormatShort: 'MMM D',
|
||||
dateFormats: {
|
||||
monthTitle: 'MMM',
|
||||
monthSubtitle: 'YYYY',
|
||||
},
|
||||
weekdays: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'],
|
||||
today: 'Today',
|
||||
legend: {
|
||||
period: 'Period',
|
||||
predictedPeriod: 'Predicted period',
|
||||
fertile: 'Fertile window',
|
||||
ovulation: 'Ovulation',
|
||||
},
|
||||
actions: {
|
||||
markPeriod: 'Mark period',
|
||||
cancelMark: 'Cancel',
|
||||
},
|
||||
card: {
|
||||
title: 'Menstrual cycle',
|
||||
syncingState: 'Syncing',
|
||||
syncingDesc: 'Reading menstrual data…',
|
||||
emptyState: 'Not logged',
|
||||
emptyDesc: 'Tap to record this period',
|
||||
periodState: 'Period',
|
||||
predictedPeriodState: 'Predicted period',
|
||||
periodEndToday: 'Expected to end today ({{date}})',
|
||||
periodEndPrefix: 'Ends in ',
|
||||
periodEndSuffix: ' days ({{date}})',
|
||||
fertileState: 'Fertile window',
|
||||
fertileToday: 'Fertile window starts today',
|
||||
fertileCountdownPrefix: 'Enters fertile window in ',
|
||||
fertileCountdownSuffix: ' days',
|
||||
ovulationState: 'Ovulation',
|
||||
ovulationToday: 'Today is ovulation day',
|
||||
ovulationCountdownPrefix: 'Ovulation in ',
|
||||
ovulationCountdownSuffix: ' days',
|
||||
nextPeriodPrefix: 'Next period in ',
|
||||
nextPeriodSuffix: ' days',
|
||||
},
|
||||
screen: {
|
||||
header: 'Menstrual Cycle',
|
||||
tabs: {
|
||||
cycle: 'Cycle',
|
||||
analysis: 'Analysis',
|
||||
},
|
||||
analysis: {
|
||||
title: 'Analysis',
|
||||
description:
|
||||
'Based on the latest 6 cycles, we will calculate average period and cycle length. Trends and prediction accuracy will be shown here.',
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -123,10 +123,12 @@ export const statisticsCustomization = {
|
||||
steps: 'Steps',
|
||||
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',
|
||||
|
||||
@@ -140,9 +140,32 @@ export const statistics = {
|
||||
title: '睡眠',
|
||||
loading: '加载中...',
|
||||
},
|
||||
sunlight: {
|
||||
title: '晒太阳',
|
||||
unit: '分钟',
|
||||
compareIncrease: '与 {{date}} 相比增加 {{diff}} 分钟',
|
||||
compareDecrease: '与 {{date}} 相比减少 {{diff}} 分钟',
|
||||
compareSame: '与 {{date}} 相比无变化',
|
||||
compareNone: '暂无对比',
|
||||
last30Days: '最近30天',
|
||||
syncing: '正在同步健康数据...',
|
||||
noData: '暂无日照时间数据',
|
||||
average: '30天均值',
|
||||
latest: '最新值',
|
||||
},
|
||||
oxygen: {
|
||||
title: '血氧饱和度',
|
||||
},
|
||||
wristTemperature: {
|
||||
title: '手腕温度',
|
||||
last30Days: '最近30天',
|
||||
syncing: '正在同步健康数据...',
|
||||
noData: '暂无手腕温度数据',
|
||||
baseline: '基线',
|
||||
average: '30天均值',
|
||||
latest: '最新值',
|
||||
vsBaseline: '相对基线'
|
||||
},
|
||||
circumference: {
|
||||
title: '围度 (cm)',
|
||||
setTitle: '设置{{label}}',
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as Challenge from './challenge';
|
||||
import * as Common from './common';
|
||||
import * as Diet from './diet';
|
||||
import * as Health from './health';
|
||||
import * as Menstrual from './menstrual';
|
||||
import * as Medication from './medication';
|
||||
import * as Mood from './mood';
|
||||
import * as Personal from './personal';
|
||||
@@ -15,6 +16,7 @@ export default {
|
||||
...Weight,
|
||||
...Challenge,
|
||||
...Mood,
|
||||
...Menstrual,
|
||||
...Common,
|
||||
...Common.common, // 确保通用翻译被正确导出
|
||||
};
|
||||
|
||||
52
i18n/zh/menstrual.ts
Normal file
52
i18n/zh/menstrual.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
export const menstrual = {
|
||||
dateFormatShort: 'M月D日',
|
||||
dateFormats: {
|
||||
monthTitle: 'M月',
|
||||
monthSubtitle: 'YYYY年',
|
||||
},
|
||||
weekdays: ['一', '二', '三', '四', '五', '六', '日'],
|
||||
today: '今天',
|
||||
legend: {
|
||||
period: '经期',
|
||||
predictedPeriod: '预测经期',
|
||||
fertile: '排卵期',
|
||||
ovulation: '排卵日',
|
||||
},
|
||||
actions: {
|
||||
markPeriod: '标记经期',
|
||||
cancelMark: '取消标记',
|
||||
},
|
||||
card: {
|
||||
title: '生理周期',
|
||||
syncingState: '同步中',
|
||||
syncingDesc: '正在读取经期数据…',
|
||||
emptyState: '待记录',
|
||||
emptyDesc: '点击记录本次经期',
|
||||
periodState: '经期',
|
||||
predictedPeriodState: '预测经期',
|
||||
periodEndToday: '预计今日结束({{date}})',
|
||||
periodEndPrefix: '预计',
|
||||
periodEndSuffix: '天后结束({{date}})',
|
||||
fertileState: '排卵期',
|
||||
fertileToday: '今天进入排卵期',
|
||||
fertileCountdownPrefix: '还有',
|
||||
fertileCountdownSuffix: '天进入排卵期',
|
||||
ovulationState: '排卵日',
|
||||
ovulationToday: '今天是排卵日',
|
||||
ovulationCountdownPrefix: '距离排卵日',
|
||||
ovulationCountdownSuffix: '天',
|
||||
nextPeriodPrefix: '距离下次月经',
|
||||
nextPeriodSuffix: '天',
|
||||
},
|
||||
screen: {
|
||||
header: '生理周期',
|
||||
tabs: {
|
||||
cycle: '生理周期',
|
||||
analysis: '分析',
|
||||
},
|
||||
analysis: {
|
||||
title: '分析',
|
||||
description: '基于最近 6 个周期的记录,计算平均经期和周期长度,后续会展示趋势和预测准确度。',
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -123,10 +123,12 @@ export const statisticsCustomization = {
|
||||
steps: '步数',
|
||||
stress: '压力',
|
||||
sleep: '睡眠',
|
||||
sunlight: '晒太阳',
|
||||
fitnessRings: '健身圆环',
|
||||
water: '饮水',
|
||||
basalMetabolism: '基础代谢',
|
||||
oxygenSaturation: '血氧',
|
||||
wristTemperature: '手腕温度',
|
||||
menstrualCycle: '经期',
|
||||
weight: '体重',
|
||||
circumference: '围度',
|
||||
|
||||
@@ -30,6 +30,14 @@ RCT_EXTERN_METHOD(getAppleStandTime:(NSDictionary *)options
|
||||
resolver:(RCTPromiseResolveBlock)resolver
|
||||
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
|
||||
resolver:(RCTPromiseResolveBlock)resolver
|
||||
rejecter:(RCTPromiseRejectBlock)rejecter)
|
||||
@@ -43,6 +51,10 @@ RCT_EXTERN_METHOD(getOxygenSaturationSamples:(NSDictionary *)options
|
||||
resolver:(RCTPromiseResolveBlock)resolver
|
||||
rejecter:(RCTPromiseRejectBlock)rejecter)
|
||||
|
||||
RCT_EXTERN_METHOD(getWristTemperatureSamples:(NSDictionary *)options
|
||||
resolver:(RCTPromiseResolveBlock)resolver
|
||||
rejecter:(RCTPromiseRejectBlock)rejecter)
|
||||
|
||||
RCT_EXTERN_METHOD(getHeartRateSamples:(NSDictionary *)options
|
||||
resolver:(RCTPromiseResolveBlock)resolver
|
||||
rejecter:(RCTPromiseRejectBlock)rejecter)
|
||||
@@ -135,4 +147,17 @@ RCT_EXTERN_METHOD(saveWeight:(NSDictionary *)options
|
||||
resolver:(RCTPromiseResolveBlock)resolver
|
||||
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
|
||||
|
||||
@@ -68,6 +68,23 @@ class HealthKitManager: RCTEventEmitter {
|
||||
static var dateOfBirth: HKCharacteristicType {
|
||||
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> {
|
||||
var types: Set<HKObjectType> = [activitySummary, workout, dateOfBirth]
|
||||
@@ -83,6 +100,9 @@ class HealthKitManager: RCTEventEmitter {
|
||||
if let dietaryWater = dietaryWater { types.insert(dietaryWater) }
|
||||
if let height = height { types.insert(height) }
|
||||
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
|
||||
}
|
||||
|
||||
@@ -111,6 +131,9 @@ class HealthKitManager: RCTEventEmitter {
|
||||
static var dietaryCarbohydrates: HKQuantityType? {
|
||||
return HKObjectType.quantityType(forIdentifier: .dietaryCarbohydrates)
|
||||
}
|
||||
static var menstrualFlow: HKCategoryType? {
|
||||
return HKObjectType.categoryType(forIdentifier: .menstrualFlow)
|
||||
}
|
||||
|
||||
static var all: Set<HKSampleType> {
|
||||
var types: Set<HKSampleType> = []
|
||||
@@ -120,6 +143,7 @@ class HealthKitManager: RCTEventEmitter {
|
||||
if let dietaryProtein = dietaryProtein { types.insert(dietaryProtein) }
|
||||
if let dietaryFatTotal = dietaryFatTotal { types.insert(dietaryFatTotal) }
|
||||
if let dietaryCarbohydrates = dietaryCarbohydrates { types.insert(dietaryCarbohydrates) }
|
||||
if let menstrualFlow = menstrualFlow { types.insert(menstrualFlow) }
|
||||
return types
|
||||
}
|
||||
}
|
||||
@@ -607,6 +631,151 @@ class HealthKitManager: RCTEventEmitter {
|
||||
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
|
||||
func getActivitySummary(
|
||||
_ options: NSDictionary,
|
||||
@@ -852,6 +1021,86 @@ class HealthKitManager: RCTEventEmitter {
|
||||
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
|
||||
func getHeartRateSamples(
|
||||
_ 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
|
||||
|
||||
override func supportedEvents() -> [String]! {
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.1.5</string>
|
||||
<string>1.1.6</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
|
||||
@@ -31,7 +31,7 @@ export const DEFAULT_TAB_CONFIGS: TabConfig[] = [
|
||||
{
|
||||
id: 'statistics',
|
||||
icon: 'chart.pie.fill',
|
||||
titleKey: 'statistics.tabs.health',
|
||||
titleKey: 'health.tabs.health',
|
||||
enabled: true,
|
||||
canBeDisabled: false,
|
||||
order: 1,
|
||||
@@ -39,7 +39,7 @@ export const DEFAULT_TAB_CONFIGS: TabConfig[] = [
|
||||
{
|
||||
id: 'medications',
|
||||
icon: 'pills.fill',
|
||||
titleKey: 'statistics.tabs.medications',
|
||||
titleKey: 'health.tabs.medications',
|
||||
enabled: true,
|
||||
canBeDisabled: true, // 用药管理可以被关闭
|
||||
order: 2,
|
||||
@@ -47,7 +47,7 @@ export const DEFAULT_TAB_CONFIGS: TabConfig[] = [
|
||||
{
|
||||
id: 'fasting',
|
||||
icon: 'timer',
|
||||
titleKey: 'statistics.tabs.fasting',
|
||||
titleKey: 'health.tabs.fasting',
|
||||
enabled: true,
|
||||
canBeDisabled: true, // 断食可以被关闭
|
||||
order: 3,
|
||||
@@ -55,7 +55,7 @@ export const DEFAULT_TAB_CONFIGS: TabConfig[] = [
|
||||
{
|
||||
id: 'challenges',
|
||||
icon: 'trophy.fill',
|
||||
titleKey: 'statistics.tabs.challenges',
|
||||
titleKey: 'health.tabs.challenges',
|
||||
enabled: true,
|
||||
canBeDisabled: true, // 挑战可以被关闭
|
||||
order: 4,
|
||||
@@ -63,7 +63,7 @@ export const DEFAULT_TAB_CONFIGS: TabConfig[] = [
|
||||
{
|
||||
id: 'personal',
|
||||
icon: 'person.fill',
|
||||
titleKey: 'statistics.tabs.personal',
|
||||
titleKey: 'health.tabs.personal',
|
||||
enabled: true,
|
||||
canBeDisabled: false,
|
||||
order: 5,
|
||||
@@ -227,4 +227,4 @@ export const selectIsInitialized = (state: RootState) => state.tabBarConfig.isIn
|
||||
export const selectTabConfigById = (tabId: string) => (state: RootState) =>
|
||||
state.tabBarConfig.configs.find(config => config.id === tabId);
|
||||
|
||||
export default tabBarConfigSlice.reducer;
|
||||
export default tabBarConfigSlice.reducer;
|
||||
|
||||
229
utils/health.ts
229
utils/health.ts
@@ -2,6 +2,7 @@ import { CompleteSleepData, fetchCompleteSleepData } from '@/utils/sleepHealthKi
|
||||
import dayjs from 'dayjs';
|
||||
import { AppState, AppStateStatus, NativeModules } from 'react-native';
|
||||
import i18n from '../i18n';
|
||||
import { logger } from './logger';
|
||||
import { SimpleEventEmitter } from './SimpleEventEmitter';
|
||||
|
||||
type HealthDataOptions = {
|
||||
@@ -270,6 +271,9 @@ class HealthPermissionManager extends SimpleEventEmitter {
|
||||
// 全局权限管理实例
|
||||
export const healthPermissionManager = new HealthPermissionManager();
|
||||
|
||||
// 全局健康数据事件发射器
|
||||
export const healthDataEvents = new SimpleEventEmitter();
|
||||
|
||||
// Interface for activity summary data from HealthKit
|
||||
export interface HealthActivitySummary {
|
||||
activeEnergyBurned: number;
|
||||
@@ -343,6 +347,19 @@ export type TodayHealthData = {
|
||||
heartRate: number | null;
|
||||
};
|
||||
|
||||
export type MenstrualFlowSample = {
|
||||
id: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
value: number; // 1=unspecified, 2=light, 3=medium, 4=heavy, 5=none
|
||||
isStart: boolean;
|
||||
source: {
|
||||
name: string;
|
||||
bundleIdentifier: string;
|
||||
};
|
||||
metadata: Record<string, any>;
|
||||
};
|
||||
|
||||
// 更新:使用新的权限管理系统
|
||||
export async function ensureHealthPermissions(): Promise<boolean> {
|
||||
return await healthPermissionManager.requestPermission();
|
||||
@@ -370,15 +387,15 @@ function createDateRange(date: Date): HealthDataOptions {
|
||||
|
||||
// 通用错误处理
|
||||
function logError(operation: string, error: any): void {
|
||||
console.error(`获取${operation}失败:`, error);
|
||||
logger.error(`获取${operation}失败`, error);
|
||||
}
|
||||
|
||||
function logWarning(operation: string, message: string): void {
|
||||
console.warn(`${operation}数据${message}`);
|
||||
logger.warn(`${operation}数据${message}`);
|
||||
}
|
||||
|
||||
function logSuccess(operation: string, data: any): void {
|
||||
console.log(`${operation}数据:`, data);
|
||||
logger.info(`${operation}数据`, data);
|
||||
}
|
||||
|
||||
// 数值验证和转换
|
||||
@@ -784,6 +801,131 @@ export async function fetchOxygenSaturation(options: HealthDataOptions): Promise
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchTimeInDaylight(options: HealthDataOptions): Promise<number | null> {
|
||||
try {
|
||||
const result = await HealthKitManager.getTimeInDaylight(options);
|
||||
|
||||
if (result && result.totalValue !== undefined) {
|
||||
logSuccess('晒太阳时长', result);
|
||||
return result.totalValue;
|
||||
} else {
|
||||
logWarning('晒太阳时长', '为空或格式错误');
|
||||
return null;
|
||||
}
|
||||
} catch (error) {
|
||||
logError('晒太阳时长', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export interface SunlightHistoryPoint {
|
||||
date: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export async function fetchTimeInDaylightHistory(options: HealthDataOptions): Promise<SunlightHistoryPoint[]> {
|
||||
try {
|
||||
const result = await HealthKitManager.getTimeInDaylightSamples(options);
|
||||
|
||||
if (result && result.data && Array.isArray(result.data)) {
|
||||
logSuccess('晒太阳历史', result);
|
||||
return result.data
|
||||
.filter((item: any) => item && typeof item.value === 'number' && item.date)
|
||||
.map((item: any) => ({
|
||||
date: item.date,
|
||||
value: Number(item.value)
|
||||
}));
|
||||
} else {
|
||||
logWarning('晒太阳历史', '为空或格式错误');
|
||||
return [];
|
||||
}
|
||||
} catch (error) {
|
||||
logError('晒太阳历史', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchWristTemperature(options: HealthDataOptions, targetDate?: Date): Promise<number | null> {
|
||||
try {
|
||||
const result = await HealthKitManager.getWristTemperatureSamples(options);
|
||||
|
||||
if (result && result.data && Array.isArray(result.data) && result.data.length > 0) {
|
||||
logSuccess('手腕温度', result);
|
||||
|
||||
const samples = result.data as Array<{ endDate?: string; value?: number }>;
|
||||
const dayStart = targetDate ? dayjs(targetDate).startOf('day') : dayjs(options.startDate);
|
||||
const dayEnd = targetDate ? dayjs(targetDate).endOf('day') : dayjs(options.endDate);
|
||||
|
||||
const sampleForSelectedDay = samples.find((sample) => {
|
||||
const sampleEnd = dayjs(sample.endDate);
|
||||
return sampleEnd.isValid() && !sampleEnd.isBefore(dayStart) && !sampleEnd.isAfter(dayEnd);
|
||||
});
|
||||
|
||||
const sampleToUse = sampleForSelectedDay ?? samples[samples.length - 1];
|
||||
|
||||
if (sampleToUse?.value !== undefined) {
|
||||
return Number(Number(sampleToUse.value).toFixed(1));
|
||||
}
|
||||
|
||||
logWarning('手腕温度', '未找到有效的温度值');
|
||||
return null;
|
||||
} else {
|
||||
logWarning('手腕温度', '为空或格式错误');
|
||||
return null;
|
||||
}
|
||||
} catch (error) {
|
||||
logError('手腕温度', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export interface WristTemperatureHistoryPoint {
|
||||
date: string;
|
||||
endDate: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export async function fetchWristTemperatureHistory(options: HealthDataOptions): Promise<WristTemperatureHistoryPoint[]> {
|
||||
try {
|
||||
const result = await HealthKitManager.getWristTemperatureSamples(options);
|
||||
|
||||
if (result && result.data && Array.isArray(result.data) && result.data.length > 0) {
|
||||
logSuccess('手腕温度历史', result);
|
||||
|
||||
const samples = result.data as Array<{ endDate?: string; value?: number }>;
|
||||
const dailyLatest: Record<string, WristTemperatureHistoryPoint> = {};
|
||||
|
||||
samples.forEach((sample) => {
|
||||
if (!sample?.endDate || sample.value === undefined) return;
|
||||
|
||||
const end = dayjs(sample.endDate);
|
||||
if (!end.isValid()) return;
|
||||
|
||||
const dayKey = end.format('YYYY-MM-DD');
|
||||
const numericValue = Number(sample.value);
|
||||
if (Number.isNaN(numericValue)) return;
|
||||
|
||||
const existing = dailyLatest[dayKey];
|
||||
if (!existing || end.isAfter(dayjs(existing.endDate))) {
|
||||
dailyLatest[dayKey] = {
|
||||
date: dayKey,
|
||||
endDate: sample.endDate,
|
||||
value: Number(numericValue.toFixed(2)),
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
return Object.values(dailyLatest).sort((a, b) => dayjs(a.date).diff(dayjs(b.date)));
|
||||
} else {
|
||||
logWarning('手腕温度历史', '为空或格式错误');
|
||||
return [];
|
||||
}
|
||||
} catch (error) {
|
||||
logError('手腕温度历史', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchHeartRateSamplesForRange(
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
@@ -1493,6 +1635,87 @@ export async function fetchHourlyStandHoursForDate(date: Date): Promise<HourlySt
|
||||
export { fetchCompleteSleepData };
|
||||
export type { CompleteSleepData };
|
||||
|
||||
// === 经期数据相关方法 ===
|
||||
|
||||
export async function fetchMenstrualFlowSamples(
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
limit: number = 100
|
||||
): Promise<MenstrualFlowSample[]> {
|
||||
try {
|
||||
const options = {
|
||||
startDate: dayjs(startDate).startOf('day').toISOString(),
|
||||
endDate: dayjs(endDate).endOf('day').toISOString(),
|
||||
limit,
|
||||
};
|
||||
|
||||
const result = await HealthKitManager.getMenstrualFlowSamples(options);
|
||||
|
||||
if (result && Array.isArray(result.data)) {
|
||||
logSuccess('经期数据', result);
|
||||
return result.data;
|
||||
}
|
||||
|
||||
logWarning('经期数据', '为空或格式错误');
|
||||
return [];
|
||||
} catch (error) {
|
||||
logError('经期数据', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveMenstrualFlow(
|
||||
date: Date,
|
||||
value: number = 1, // Default to unspecified
|
||||
isStart: boolean = false
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const options = {
|
||||
date: dayjs(date).toISOString(),
|
||||
value,
|
||||
isStart,
|
||||
};
|
||||
|
||||
const result = await HealthKitManager.saveMenstrualFlow(options);
|
||||
if (result && result.success) {
|
||||
console.log('经期数据保存成功');
|
||||
// 触发数据变更事件
|
||||
healthDataEvents.emit('menstrualDataChanged');
|
||||
return true;
|
||||
}
|
||||
console.error('经期数据保存失败');
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('保存经期数据失败:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteMenstrualFlow(
|
||||
startDate: Date,
|
||||
endDate: Date
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const options = {
|
||||
startDate: dayjs(startDate).startOf('day').toISOString(),
|
||||
endDate: dayjs(endDate).endOf('day').toISOString(),
|
||||
};
|
||||
|
||||
const result = await HealthKitManager.deleteMenstrualFlow(options);
|
||||
if (result && result.success) {
|
||||
console.log(`经期数据删除成功,数量: ${result.count}`);
|
||||
// 触发数据变更事件
|
||||
healthDataEvents.emit('menstrualDataChanged');
|
||||
return true;
|
||||
}
|
||||
console.error('经期数据删除失败');
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('删除经期数据失败:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 专门为活动圆环详情页获取精简的数据
|
||||
export async function fetchActivityRingsForDate(date: Date): Promise<ActivityRingsData | null> {
|
||||
try {
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import dayjs, { Dayjs } from 'dayjs';
|
||||
import 'dayjs/locale/en';
|
||||
import 'dayjs/locale/zh-cn';
|
||||
import { MenstrualFlowSample } from './health';
|
||||
|
||||
export type MenstrualDayStatus = 'period' | 'predicted-period' | 'fertile' | 'ovulation-day';
|
||||
|
||||
@@ -54,18 +57,42 @@ const STATUS_PRIORITY: Record<MenstrualDayStatus, number> = {
|
||||
|
||||
export const DEFAULT_CYCLE_LENGTH = 28;
|
||||
export const DEFAULT_PERIOD_LENGTH = 5;
|
||||
const MIN_CYCLE_LENGTH = 21;
|
||||
const MAX_CYCLE_LENGTH = 45;
|
||||
const LOOKBACK_WINDOW = 6;
|
||||
const LUTEAL_PHASE_MEAN = 13; // 12–14 天之间较为稳定
|
||||
|
||||
export const createDefaultRecords = (): CycleRecord[] => {
|
||||
const today = dayjs();
|
||||
const latestStart = today.subtract(4, 'day'); // 默认让今天处于经期第5天
|
||||
const previousStart = latestStart.subtract(DEFAULT_CYCLE_LENGTH, 'day');
|
||||
const olderStart = previousStart.subtract(DEFAULT_CYCLE_LENGTH, 'day');
|
||||
const clampCycleLength = (value: number, fallback: number) => {
|
||||
if (!Number.isFinite(value) || value <= 0) return fallback;
|
||||
return Math.max(MIN_CYCLE_LENGTH, Math.min(MAX_CYCLE_LENGTH, Math.round(value)));
|
||||
};
|
||||
|
||||
return [
|
||||
{ startDate: olderStart.format('YYYY-MM-DD'), periodLength: DEFAULT_PERIOD_LENGTH },
|
||||
{ startDate: previousStart.format('YYYY-MM-DD'), periodLength: DEFAULT_PERIOD_LENGTH },
|
||||
{ startDate: latestStart.format('YYYY-MM-DD'), periodLength: DEFAULT_PERIOD_LENGTH },
|
||||
];
|
||||
const calcMedian = (values: number[]) => {
|
||||
if (!values.length) return undefined;
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
const mid = Math.floor(sorted.length / 2);
|
||||
if (sorted.length % 2 === 0) {
|
||||
return (sorted[mid - 1] + sorted[mid]) / 2;
|
||||
}
|
||||
return sorted[mid];
|
||||
};
|
||||
|
||||
const calcTrimmedMean = (values: number[], trim = 1) => {
|
||||
if (!values.length) return undefined;
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
const start = Math.min(trim, sorted.length);
|
||||
const end = Math.max(sorted.length - trim, start);
|
||||
const sliced = sorted.slice(start, end);
|
||||
if (!sliced.length) return undefined;
|
||||
return sliced.reduce((sum, cur) => sum + cur, 0) / sliced.length;
|
||||
};
|
||||
|
||||
const calcStdDev = (values: number[]) => {
|
||||
if (values.length < 2) return 0;
|
||||
const mean = values.reduce((s, v) => s + v, 0) / values.length;
|
||||
const variance =
|
||||
values.reduce((s, v) => s + (v - mean) * (v - mean), 0) / (values.length - 1);
|
||||
return Math.sqrt(variance);
|
||||
};
|
||||
|
||||
const calcAverageCycleLength = (records: CycleRecord[], fallback = DEFAULT_CYCLE_LENGTH) => {
|
||||
@@ -81,8 +108,11 @@ const calcAverageCycleLength = (records: CycleRecord[], fallback = DEFAULT_CYCLE
|
||||
}
|
||||
}
|
||||
if (!intervals.length) return fallback;
|
||||
const avg = intervals.reduce((sum, cur) => sum + cur, 0) / intervals.length;
|
||||
return Math.round(avg);
|
||||
const recent = intervals.slice(-LOOKBACK_WINDOW);
|
||||
const median = calcMedian(recent);
|
||||
const trimmed = calcTrimmedMean(recent);
|
||||
const blended = median ?? trimmed ?? calcTrimmedMean(intervals) ?? fallback;
|
||||
return clampCycleLength(blended, fallback);
|
||||
};
|
||||
|
||||
const calcAveragePeriodLength = (records: CycleRecord[], fallback = DEFAULT_PERIOD_LENGTH) => {
|
||||
@@ -90,8 +120,11 @@ const calcAveragePeriodLength = (records: CycleRecord[], fallback = DEFAULT_PERI
|
||||
.map((r) => r.periodLength)
|
||||
.filter((l): l is number => typeof l === 'number' && l > 0);
|
||||
if (!lengths.length) return fallback;
|
||||
const avg = lengths.reduce((sum, cur) => sum + cur, 0) / lengths.length;
|
||||
return Math.round(avg);
|
||||
const recent = lengths.slice(-LOOKBACK_WINDOW);
|
||||
const median = calcMedian(recent);
|
||||
const trimmed = calcTrimmedMean(recent);
|
||||
const blended = median ?? trimmed ?? calcTrimmedMean(lengths) ?? fallback;
|
||||
return Math.max(3, Math.round(blended)); // 极端短周期时仍保持生理期合理下限
|
||||
};
|
||||
|
||||
const addDayInfo = (
|
||||
@@ -110,9 +143,10 @@ const addDayInfo = (
|
||||
};
|
||||
|
||||
const getOvulationDay = (cycleStart: Dayjs, cycleLength: number) => {
|
||||
// 默认排卵日位于周期的中间偏后,兼容短/长周期
|
||||
const daysFromStart = Math.max(12, Math.round(cycleLength / 2));
|
||||
return cycleStart.add(daysFromStart, 'day');
|
||||
// 排卵日约在下次月经前 12-14 天,较稳定;对极端周期做边界约束
|
||||
const daysFromStart = cycleLength - LUTEAL_PHASE_MEAN;
|
||||
const offset = Math.min(Math.max(daysFromStart, 11), 16);
|
||||
return cycleStart.add(offset, 'day');
|
||||
};
|
||||
|
||||
export const buildMenstrualTimeline = (options?: {
|
||||
@@ -121,6 +155,9 @@ export const buildMenstrualTimeline = (options?: {
|
||||
monthsAfter?: number;
|
||||
defaultCycleLength?: number;
|
||||
defaultPeriodLength?: number;
|
||||
locale?: 'zh' | 'en';
|
||||
monthTitleFormat?: string;
|
||||
monthSubtitleFormat?: string;
|
||||
}): MenstrualTimeline => {
|
||||
const today = dayjs();
|
||||
const monthsBefore = options?.monthsBefore ?? 2;
|
||||
@@ -136,6 +173,23 @@ export const buildMenstrualTimeline = (options?: {
|
||||
options?.defaultCycleLength ?? calcAverageCycleLength(records, DEFAULT_CYCLE_LENGTH);
|
||||
const avgPeriodLength =
|
||||
options?.defaultPeriodLength ?? calcAveragePeriodLength(records, DEFAULT_PERIOD_LENGTH);
|
||||
const sortedStarts = [...records]
|
||||
.sort((a, b) => dayjs(a.startDate).valueOf() - dayjs(b.startDate).valueOf())
|
||||
.map((r) => dayjs(r.startDate));
|
||||
const cycleIntervals: number[] = [];
|
||||
for (let i = 1; i < sortedStarts.length; i += 1) {
|
||||
const diff = sortedStarts[i].diff(sortedStarts[i - 1], 'day');
|
||||
if (diff > 0) cycleIntervals.push(diff);
|
||||
}
|
||||
const recentIntervals = cycleIntervals.slice(-LOOKBACK_WINDOW);
|
||||
const cycleVariability = calcStdDev(recentIntervals);
|
||||
const lastInterval =
|
||||
recentIntervals.length > 0 ? recentIntervals[recentIntervals.length - 1] : avgCycleLength;
|
||||
// 对未来预测使用稳健均值 + 最新趋势的折中,以避免单次异常牵动过大
|
||||
const predictedCycleLength = clampCycleLength(
|
||||
0.55 * avgCycleLength + 0.45 * lastInterval,
|
||||
avgCycleLength
|
||||
);
|
||||
|
||||
const cycles = records.map((record) => ({
|
||||
start: dayjs(record.startDate),
|
||||
@@ -144,19 +198,36 @@ export const buildMenstrualTimeline = (options?: {
|
||||
cycleLength: record.cycleLength ?? avgCycleLength,
|
||||
}));
|
||||
|
||||
// 基于真实相邻开始日期矫正已记录周期长度,便于后续计算排卵日/易孕期
|
||||
for (let i = 0; i < cycles.length; i += 1) {
|
||||
const next = cycles[i + 1];
|
||||
if (next) {
|
||||
const interval = next.start.diff(cycles[i].start, 'day');
|
||||
if (interval > 0) {
|
||||
cycles[i].cycleLength = clampCycleLength(interval, cycles[i].cycleLength);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 只有当存在历史记录时,才进行后续预测
|
||||
if (cycles.length > 0) {
|
||||
const lastConfirmed = cycles[cycles.length - 1];
|
||||
let cursorStart = lastConfirmed.start;
|
||||
let nextCycleLength = predictedCycleLength;
|
||||
|
||||
while (cursorStart.isBefore(endMonth)) {
|
||||
cursorStart = cursorStart.add(avgCycleLength, 'day');
|
||||
cursorStart = cursorStart.add(nextCycleLength, 'day');
|
||||
cycles.push({
|
||||
start: cursorStart,
|
||||
confirmed: false,
|
||||
periodLength: avgPeriodLength,
|
||||
cycleLength: avgCycleLength,
|
||||
cycleLength: nextCycleLength,
|
||||
});
|
||||
// 趋势逐步回归稳健均值,避免长期漂移
|
||||
nextCycleLength = clampCycleLength(
|
||||
Math.round(nextCycleLength * 0.65 + avgCycleLength * 0.35),
|
||||
avgCycleLength
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,7 +235,9 @@ export const buildMenstrualTimeline = (options?: {
|
||||
|
||||
cycles.forEach((cycle) => {
|
||||
const ovulationDay = getOvulationDay(cycle.start, cycle.cycleLength);
|
||||
const fertileStart = ovulationDay.subtract(5, 'day');
|
||||
// 经前 luteal 稳定,易孕窗口 5-7 天:排卵日前 5 天 + 排卵日当日;高波动人群额外向前扩 1 天
|
||||
const fertileWindow = Math.min(7, 6 + (cycle.confirmed ? 0 : Math.round(cycleVariability)));
|
||||
const fertileStart = ovulationDay.subtract(fertileWindow, 'day');
|
||||
|
||||
for (let i = 0; i < cycle.periodLength; i += 1) {
|
||||
const date = cycle.start.add(i, 'day');
|
||||
@@ -177,7 +250,7 @@ export const buildMenstrualTimeline = (options?: {
|
||||
});
|
||||
}
|
||||
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
for (let i = 0; i < fertileWindow; i += 1) {
|
||||
const date = fertileStart.add(i, 'day');
|
||||
if (date.isBefore(startMonth) || date.isAfter(endMonth)) continue;
|
||||
addDayInfo(dayMap, date, {
|
||||
@@ -199,6 +272,11 @@ export const buildMenstrualTimeline = (options?: {
|
||||
const months: MenstrualMonth[] = [];
|
||||
let monthCursor = startMonth.startOf('month');
|
||||
|
||||
const locale = options?.locale ?? 'zh';
|
||||
const localeKey = locale === 'en' ? 'en' : 'zh-cn';
|
||||
const monthTitleFormat = options?.monthTitleFormat ?? (locale === 'en' ? 'MMM' : 'M月');
|
||||
const monthSubtitleFormat = options?.monthSubtitleFormat ?? (locale === 'en' ? 'YYYY' : 'YYYY年');
|
||||
|
||||
while (monthCursor.isBefore(endMonth) || monthCursor.isSame(endMonth, 'month')) {
|
||||
const firstDay = monthCursor.startOf('month');
|
||||
const daysInMonth = firstDay.daysInMonth();
|
||||
@@ -230,10 +308,12 @@ export const buildMenstrualTimeline = (options?: {
|
||||
});
|
||||
}
|
||||
|
||||
const formattedMonth = firstDay.locale(localeKey);
|
||||
|
||||
months.push({
|
||||
id: firstDay.format('YYYY-MM'),
|
||||
title: firstDay.format('M月'),
|
||||
subtitle: firstDay.format('YYYY年'),
|
||||
title: formattedMonth.format(monthTitleFormat),
|
||||
subtitle: formattedMonth.format(monthSubtitleFormat),
|
||||
cells,
|
||||
});
|
||||
|
||||
@@ -245,7 +325,7 @@ export const buildMenstrualTimeline = (options?: {
|
||||
return {
|
||||
months,
|
||||
dayMap,
|
||||
cycleLength: avgCycleLength,
|
||||
cycleLength: predictedCycleLength,
|
||||
periodLength: avgPeriodLength,
|
||||
todayInfo: dayMap[todayKey],
|
||||
};
|
||||
@@ -258,3 +338,62 @@ export const getMenstrualSummaryForDate = (
|
||||
const key = date.format('YYYY-MM-DD');
|
||||
return dayMap[key];
|
||||
};
|
||||
|
||||
export const convertHealthKitSamplesToCycleRecords = (
|
||||
samples: MenstrualFlowSample[]
|
||||
): CycleRecord[] => {
|
||||
if (!samples.length) return [];
|
||||
|
||||
// 1. Sort samples by date
|
||||
const sortedSamples = [...samples].sort(
|
||||
(a, b) => dayjs(a.startDate).valueOf() - dayjs(b.startDate).valueOf()
|
||||
);
|
||||
|
||||
const records: CycleRecord[] = [];
|
||||
let currentStart: Dayjs | null = null;
|
||||
let currentEnd: Dayjs | null = null;
|
||||
|
||||
// 2. Iterate and merge consecutive days
|
||||
for (const sample of sortedSamples) {
|
||||
const sampleDate = dayjs(sample.startDate);
|
||||
|
||||
// If we have no current period being tracked, start a new one
|
||||
if (!currentStart) {
|
||||
currentStart = sampleDate;
|
||||
currentEnd = sampleDate;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if this sample is contiguous with the current period
|
||||
// Allow 1 day gap? Standard logic usually assumes contiguous days for a single period.
|
||||
// However, spotting might cause gaps. For now, we'll merge if gap <= 1 day.
|
||||
const diff = sampleDate.diff(currentEnd, 'day');
|
||||
|
||||
if (diff <= 1) {
|
||||
// Extend current period
|
||||
currentEnd = sampleDate;
|
||||
} else {
|
||||
// Gap is too large, finalize current period and start new one
|
||||
if (currentStart && currentEnd) {
|
||||
records.push({
|
||||
startDate: currentStart.format('YYYY-MM-DD'),
|
||||
periodLength: currentEnd.diff(currentStart, 'day') + 1,
|
||||
source: 'healthkit',
|
||||
});
|
||||
}
|
||||
currentStart = sampleDate;
|
||||
currentEnd = sampleDate;
|
||||
}
|
||||
}
|
||||
|
||||
// Push the last record
|
||||
if (currentStart && currentEnd) {
|
||||
records.push({
|
||||
startDate: currentStart.format('YYYY-MM-DD'),
|
||||
periodLength: currentEnd.diff(currentStart, 'day') + 1,
|
||||
source: 'healthkit',
|
||||
});
|
||||
}
|
||||
|
||||
return records;
|
||||
};
|
||||
|
||||
@@ -28,6 +28,8 @@ const PREFERENCES_KEYS = {
|
||||
SHOW_MENSTRUAL_CYCLE_CARD: 'user_preference_show_menstrual_cycle_card',
|
||||
SHOW_WEIGHT_CARD: 'user_preference_show_weight_card',
|
||||
SHOW_CIRCUMFERENCE_CARD: 'user_preference_show_circumference_card',
|
||||
SHOW_WRIST_TEMPERATURE_CARD: 'user_preference_show_wrist_temperature_card',
|
||||
SHOW_SUNLIGHT_CARD: 'user_preference_show_sunlight_card',
|
||||
|
||||
// 首页身体指标卡片排序设置
|
||||
STATISTICS_CARD_ORDER: 'user_preference_statistics_card_order',
|
||||
@@ -46,6 +48,8 @@ export interface StatisticsCardsVisibility {
|
||||
showMenstrualCycle: boolean;
|
||||
showWeight: boolean;
|
||||
showCircumference: boolean;
|
||||
showWristTemperature: boolean;
|
||||
showSunlight: boolean;
|
||||
}
|
||||
|
||||
// 默认卡片顺序
|
||||
@@ -54,10 +58,12 @@ export const DEFAULT_CARD_ORDER: string[] = [
|
||||
'steps',
|
||||
'stress',
|
||||
'sleep',
|
||||
'sunlight',
|
||||
'fitness',
|
||||
'water',
|
||||
'metabolism',
|
||||
'oxygen',
|
||||
'temperature',
|
||||
'menstrual',
|
||||
'weight',
|
||||
'circumference',
|
||||
@@ -109,6 +115,8 @@ const DEFAULT_PREFERENCES: UserPreferences = {
|
||||
showMenstrualCycle: true,
|
||||
showWeight: true,
|
||||
showCircumference: true,
|
||||
showWristTemperature: true,
|
||||
showSunlight: true,
|
||||
|
||||
// 默认卡片顺序
|
||||
cardOrder: DEFAULT_CARD_ORDER,
|
||||
@@ -145,6 +153,8 @@ export const getUserPreferences = async (): Promise<UserPreferences> => {
|
||||
const showMenstrualCycle = await AsyncStorage.getItem(PREFERENCES_KEYS.SHOW_MENSTRUAL_CYCLE_CARD);
|
||||
const showWeight = await AsyncStorage.getItem(PREFERENCES_KEYS.SHOW_WEIGHT_CARD);
|
||||
const showCircumference = await AsyncStorage.getItem(PREFERENCES_KEYS.SHOW_CIRCUMFERENCE_CARD);
|
||||
const showWristTemperature = await AsyncStorage.getItem(PREFERENCES_KEYS.SHOW_WRIST_TEMPERATURE_CARD);
|
||||
const showSunlight = await AsyncStorage.getItem(PREFERENCES_KEYS.SHOW_SUNLIGHT_CARD);
|
||||
const cardOrderStr = await AsyncStorage.getItem(PREFERENCES_KEYS.STATISTICS_CARD_ORDER);
|
||||
const cardOrder = cardOrderStr ? JSON.parse(cardOrderStr) : DEFAULT_PREFERENCES.cardOrder;
|
||||
|
||||
@@ -174,6 +184,8 @@ export const getUserPreferences = async (): Promise<UserPreferences> => {
|
||||
showMenstrualCycle: showMenstrualCycle !== null ? showMenstrualCycle === 'true' : DEFAULT_PREFERENCES.showMenstrualCycle,
|
||||
showWeight: showWeight !== null ? showWeight === 'true' : DEFAULT_PREFERENCES.showWeight,
|
||||
showCircumference: showCircumference !== null ? showCircumference === 'true' : DEFAULT_PREFERENCES.showCircumference,
|
||||
showWristTemperature: showWristTemperature !== null ? showWristTemperature === 'true' : DEFAULT_PREFERENCES.showWristTemperature,
|
||||
showSunlight: showSunlight !== null ? showSunlight === 'true' : DEFAULT_PREFERENCES.showSunlight,
|
||||
cardOrder,
|
||||
};
|
||||
} catch (error) {
|
||||
@@ -611,6 +623,8 @@ export const getStatisticsCardsVisibility = async (): Promise<StatisticsCardsVis
|
||||
showMenstrualCycle: userPreferences.showMenstrualCycle,
|
||||
showWeight: userPreferences.showWeight,
|
||||
showCircumference: userPreferences.showCircumference,
|
||||
showWristTemperature: userPreferences.showWristTemperature,
|
||||
showSunlight: userPreferences.showSunlight,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('获取首页卡片显示设置失败:', error);
|
||||
@@ -626,6 +640,8 @@ export const getStatisticsCardsVisibility = async (): Promise<StatisticsCardsVis
|
||||
showMenstrualCycle: DEFAULT_PREFERENCES.showMenstrualCycle,
|
||||
showWeight: DEFAULT_PREFERENCES.showWeight,
|
||||
showCircumference: DEFAULT_PREFERENCES.showCircumference,
|
||||
showWristTemperature: DEFAULT_PREFERENCES.showWristTemperature,
|
||||
showSunlight: DEFAULT_PREFERENCES.showSunlight,
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -673,6 +689,8 @@ export const setStatisticsCardVisibility = async (key: keyof StatisticsCardsVisi
|
||||
case 'showMenstrualCycle': storageKey = PREFERENCES_KEYS.SHOW_MENSTRUAL_CYCLE_CARD; break;
|
||||
case 'showWeight': storageKey = PREFERENCES_KEYS.SHOW_WEIGHT_CARD; break;
|
||||
case 'showCircumference': storageKey = PREFERENCES_KEYS.SHOW_CIRCUMFERENCE_CARD; break;
|
||||
case 'showWristTemperature': storageKey = PREFERENCES_KEYS.SHOW_WRIST_TEMPERATURE_CARD; break;
|
||||
case 'showSunlight': storageKey = PREFERENCES_KEYS.SHOW_SUNLIGHT_CARD; break;
|
||||
default: return;
|
||||
}
|
||||
await AsyncStorage.setItem(storageKey, value.toString());
|
||||
|
||||
Reference in New Issue
Block a user