feat(health): 新增手腕温度监测和经期双向同步功能
新增手腕温度健康数据追踪,支持Apple Watch睡眠手腕温度数据展示和30天历史趋势分析 实现经期数据与HealthKit的完整双向同步,支持读取、写入和删除经期记录 优化经期预测算法,基于历史数据计算更准确的周期和排卵日预测 重构经期UI组件为模块化结构,提升代码可维护性 添加完整的中英文国际化支持,覆盖所有新增功能界面
This commit is contained in:
@@ -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,11 +91,13 @@ Out Live(超越生命)是一款专注于健康、减肥、瘦身和生活习
|
||||
- **无障碍支持**:完整的无障碍功能支持
|
||||
|
||||
## 商业模式
|
||||
|
||||
- **免费增值模式**:基础功能免费,高级功能付费
|
||||
- **VIP 会员**:提供更多个性化功能和专业指导
|
||||
- **企业健康**:面向企业提供的员工健康管理解决方案
|
||||
|
||||
## 竞争优势
|
||||
|
||||
1. **全平台整合**:深度整合 iOS 健康生态系统
|
||||
2. **AI 技术应用**:先进的 AI 分析和个性化推荐
|
||||
3. **用户体验**:优秀的界面设计和交互体验
|
||||
|
||||
@@ -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,18 +241,21 @@
|
||||
## 开发规范
|
||||
|
||||
### 代码规范
|
||||
|
||||
- **ESLint**: 代码检查
|
||||
- **Prettier**: 代码格式化
|
||||
- **TypeScript**: 类型安全
|
||||
- **命名规范**: 统一命名
|
||||
|
||||
### Git 工作流
|
||||
|
||||
- **Conventional Commits**: 提交规范
|
||||
- **分支策略**: Git Flow
|
||||
- **代码审查**: PR 流程
|
||||
- **版本标签**: 标签管理
|
||||
|
||||
### 文档规范
|
||||
|
||||
- **JSDoc**: 代码注释
|
||||
- **README**: 项目文档
|
||||
- **API 文档**: 接口文档
|
||||
|
||||
@@ -10,7 +10,7 @@ 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 type { BadgeDto } from '@/services/badges';
|
||||
import { reportBadgeShowcaseDisplayed } from '@/services/badges';
|
||||
import { updateUser, type UserLanguage } from '@/services/users';
|
||||
|
||||
@@ -7,6 +7,7 @@ import { NutritionRadarCard } from '@/components/NutritionRadarCard';
|
||||
import CircumferenceCard from '@/components/statistic/CircumferenceCard';
|
||||
import OxygenSaturationCard from '@/components/statistic/OxygenSaturationCard';
|
||||
import SleepCard from '@/components/statistic/SleepCard';
|
||||
import WristTemperatureCard from '@/components/statistic/WristTemperatureCard';
|
||||
import StepsCard from '@/components/StepsCard';
|
||||
import { StressMeter } from '@/components/StressMeter';
|
||||
import WaterIntakeCard from '@/components/WaterIntakeCard';
|
||||
@@ -109,6 +110,7 @@ export default function ExploreScreen() {
|
||||
showWater: true,
|
||||
showBasalMetabolism: true,
|
||||
showOxygenSaturation: true,
|
||||
showWristTemperature: true,
|
||||
showMenstrualCycle: true,
|
||||
showWeight: true,
|
||||
showCircumference: true,
|
||||
@@ -443,7 +445,7 @@ export default function ExploreScreen() {
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={{
|
||||
paddingTop: insets.top,
|
||||
paddingBottom: 60,
|
||||
paddingBottom: 100,
|
||||
paddingHorizontal: 20
|
||||
}}
|
||||
showsVerticalScrollIndicator={false}
|
||||
@@ -615,6 +617,15 @@ export default function ExploreScreen() {
|
||||
/>
|
||||
)
|
||||
},
|
||||
temperature: {
|
||||
visible: cardVisibility.showWristTemperature,
|
||||
component: (
|
||||
<WristTemperatureCard
|
||||
selectedDate={currentSelectedDate}
|
||||
style={styles.basalMetabolismCardOverride}
|
||||
/>
|
||||
)
|
||||
},
|
||||
menstrual: {
|
||||
visible: cardVisibility.showMenstrualCycle,
|
||||
component: (
|
||||
|
||||
@@ -4,152 +4,51 @@ import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { Stack, useRouter } from 'expo-router';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
DimensionValue,
|
||||
FlatList,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { InlineTip, ITEM_HEIGHT, Legend, MonthBlock } from '@/components/menstrual-cycle';
|
||||
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 } = useTranslation();
|
||||
const [records, setRecords] = useState<CycleRecord[]>([]);
|
||||
const [windowConfig, setWindowConfig] = useState({ before: 2, after: 3 });
|
||||
|
||||
// Load data from HealthKit
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
// Calculate date range based on 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({
|
||||
@@ -173,10 +72,10 @@ export default function MenstrualCycleScreen() {
|
||||
const selectedDate = dayjs(selectedDateKey);
|
||||
|
||||
|
||||
const handleMarkStart = () => {
|
||||
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 +86,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 +123,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 +130,59 @@ 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 = () => {
|
||||
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,21 +194,47 @@ 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 = () => {
|
||||
@@ -314,27 +270,7 @@ 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>
|
||||
);
|
||||
|
||||
|
||||
const listData = useMemo(() => {
|
||||
return timeline.months.map((m) => ({
|
||||
@@ -344,40 +280,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
|
||||
@@ -411,9 +326,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>
|
||||
@@ -433,7 +348,7 @@ export default function MenstrualCycleScreen() {
|
||||
<TouchableOpacity onPress={() => router.back()} style={styles.headerIcon}>
|
||||
<Ionicons name="chevron-back" size={22} color="#0f172a" />
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.headerTitle}>生理周期</Text>
|
||||
<Text style={styles.headerTitle}>{t('menstrual.screen.header')}</Text>
|
||||
<TouchableOpacity style={styles.headerIcon}>
|
||||
<Ionicons name="settings-outline" size={20} color="#0f172a" />
|
||||
</TouchableOpacity>
|
||||
@@ -441,8 +356,8 @@ export default function MenstrualCycleScreen() {
|
||||
|
||||
<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 (
|
||||
@@ -525,32 +440,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 +459,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,
|
||||
},
|
||||
|
||||
@@ -46,6 +46,7 @@ export default function StatisticsCustomizationScreen() {
|
||||
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' },
|
||||
|
||||
@@ -1,14 +1,33 @@
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import React, { useMemo } from 'react';
|
||||
import dayjs, { Dayjs } from 'dayjs';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { buildMenstrualTimeline } from '@/utils/menstrualCycle';
|
||||
import { fetchMenstrualFlowSamples } 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,68 @@ 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 () => {
|
||||
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();
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
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 +113,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 +126,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%',
|
||||
|
||||
75
components/menstrual-cycle/DayCell.tsx
Normal file
75
components/menstrual-cycle/DayCell.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import React from 'react';
|
||||
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 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 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',
|
||||
},
|
||||
});
|
||||
111
components/menstrual-cycle/InlineTip.tsx
Normal file
111
components/menstrual-cycle/InlineTip.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import dayjs from 'dayjs';
|
||||
import React from 'react';
|
||||
import { DimensionValue, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { InlineTipProps } from './types';
|
||||
|
||||
export const InlineTip: React.FC<InlineTipProps> = ({
|
||||
selectedDate,
|
||||
selectedInfo,
|
||||
columnIndex,
|
||||
onMarkStart,
|
||||
onCancelMark,
|
||||
}) => {
|
||||
// 14.28% per cell. Center is 7.14%.
|
||||
const pointerLeft = `${columnIndex * 14.2857 + 7.1428}%` as DimensionValue;
|
||||
const isFuture = selectedDate.isAfter(dayjs(), 'day');
|
||||
|
||||
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.format('M月D日')}</Text>
|
||||
</View>
|
||||
{!isFuture && (!selectedInfo || !selectedInfo.confirmed) && (
|
||||
<TouchableOpacity style={styles.inlinePrimaryBtn} onPress={onMarkStart}>
|
||||
<Ionicons name="add" size={14} color="#fff" />
|
||||
<Text style={styles.inlinePrimaryText}>标记经期</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
{!isFuture && selectedInfo?.confirmed && selectedInfo.status === 'period' && (
|
||||
<TouchableOpacity style={styles.inlineSecondaryBtn} onPress={onCancelMark}>
|
||||
<Text style={styles.inlineSecondaryText}>取消标记</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',
|
||||
},
|
||||
});
|
||||
59
components/menstrual-cycle/Legend.tsx
Normal file
59
components/menstrual-cycle/Legend.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import React from 'react';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
import { STATUS_COLORS } from './constants';
|
||||
import { LegendItem } from './types';
|
||||
|
||||
const LEGEND_ITEMS: LegendItem[] = [
|
||||
{ label: '经期', key: 'period' },
|
||||
{ label: '预测经期', key: 'predicted-period' },
|
||||
{ label: '排卵期', key: 'fertile' },
|
||||
{ label: '排卵日', key: 'ovulation-day' },
|
||||
];
|
||||
|
||||
export const Legend: React.FC = () => {
|
||||
return (
|
||||
<View style={styles.legendRow}>
|
||||
{LEGEND_ITEMS.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',
|
||||
},
|
||||
});
|
||||
137
components/menstrual-cycle/MonthBlock.tsx
Normal file
137
components/menstrual-cycle/MonthBlock.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
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;
|
||||
}
|
||||
|
||||
export const MonthBlock: React.FC<MonthBlockProps> = ({
|
||||
month,
|
||||
selectedDateKey,
|
||||
onSelect,
|
||||
renderTip,
|
||||
}) => {
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
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,6 +1,6 @@
|
||||
import { Image } from 'expo-image';
|
||||
import React from 'react';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import Animated, { FadeIn, FadeOut } from 'react-native-reanimated';
|
||||
|
||||
interface HealthDataCardProps {
|
||||
@@ -8,37 +8,36 @@ interface HealthDataCardProps {
|
||||
value: string;
|
||||
unit: string;
|
||||
style?: object;
|
||||
onPress?: () => void;
|
||||
}
|
||||
|
||||
const HealthDataCard: React.FC<HealthDataCardProps> = ({
|
||||
title,
|
||||
value,
|
||||
unit,
|
||||
style
|
||||
style,
|
||||
onPress
|
||||
}) => {
|
||||
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}>
|
||||
<Image source={require('@/assets/images/icons/icon-blood-oxygen.png')} 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>
|
||||
</Container>
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
@@ -62,6 +61,11 @@ const styles = StyleSheet.create({
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
headerRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 14,
|
||||
},
|
||||
titleIcon: {
|
||||
width: 16,
|
||||
height: 16,
|
||||
|
||||
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'
|
||||
}
|
||||
});
|
||||
@@ -142,6 +142,16 @@ export const statistics = {
|
||||
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, // 确保通用翻译被正确导出
|
||||
};
|
||||
|
||||
37
i18n/en/menstrual.ts
Normal file
37
i18n/en/menstrual.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
export const menstrual = {
|
||||
dateFormatShort: 'MMM D',
|
||||
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.',
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -127,6 +127,7 @@ export const statisticsCustomization = {
|
||||
water: 'Water Intake',
|
||||
basalMetabolism: 'Basal Metabolism',
|
||||
oxygenSaturation: 'Oxygen Saturation',
|
||||
wristTemperature: 'Wrist Temperature',
|
||||
menstrualCycle: 'Menstrual Cycle',
|
||||
weight: 'Weight',
|
||||
circumference: 'Circumference',
|
||||
|
||||
@@ -143,6 +143,16 @@ export const statistics = {
|
||||
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, // 确保通用翻译被正确导出
|
||||
};
|
||||
|
||||
36
i18n/zh/menstrual.ts
Normal file
36
i18n/zh/menstrual.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
export const menstrual = {
|
||||
dateFormatShort: 'M月D日',
|
||||
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 个周期的记录,计算平均经期和周期长度,后续会展示趋势和预测准确度。',
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -127,6 +127,7 @@ export const statisticsCustomization = {
|
||||
water: '饮水',
|
||||
basalMetabolism: '基础代谢',
|
||||
oxygenSaturation: '血氧',
|
||||
wristTemperature: '手腕温度',
|
||||
menstrualCycle: '经期',
|
||||
weight: '体重',
|
||||
circumference: '围度',
|
||||
|
||||
@@ -43,6 +43,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 +139,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,16 @@ 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 all: Set<HKObjectType> {
|
||||
var types: Set<HKObjectType> = [activitySummary, workout, dateOfBirth]
|
||||
@@ -83,6 +93,8 @@ 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) }
|
||||
return types
|
||||
}
|
||||
|
||||
@@ -111,6 +123,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 +135,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
|
||||
}
|
||||
}
|
||||
@@ -852,6 +868,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 +2644,210 @@ func saveWeight(
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Menstrual Cycle Methods
|
||||
|
||||
@objc
|
||||
func getMenstrualFlowSamples(
|
||||
_ options: NSDictionary,
|
||||
resolver: @escaping RCTPromiseResolveBlock,
|
||||
rejecter: @escaping RCTPromiseRejectBlock
|
||||
) {
|
||||
guard HKHealthStore.isHealthDataAvailable() else {
|
||||
rejecter("HEALTHKIT_NOT_AVAILABLE", "HealthKit is not available on this device", nil)
|
||||
return
|
||||
}
|
||||
|
||||
guard let menstrualType = ReadTypes.menstrualFlow else {
|
||||
rejecter("TYPE_NOT_AVAILABLE", "Menstrual flow type is not available", nil)
|
||||
return
|
||||
}
|
||||
|
||||
let startDate: Date
|
||||
if let startString = options["startDate"] as? String, let d = parseDate(from: startString) {
|
||||
startDate = d
|
||||
} else {
|
||||
startDate = Calendar.current.date(byAdding: .month, value: -3, to: Date()) ?? Date()
|
||||
}
|
||||
|
||||
let endDate: Date
|
||||
if let endString = options["endDate"] as? String, let d = parseDate(from: endString) {
|
||||
endDate = d
|
||||
} else {
|
||||
endDate = Date()
|
||||
}
|
||||
|
||||
let limit = options["limit"] as? Int ?? HKObjectQueryNoLimit
|
||||
|
||||
let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
|
||||
let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: true)
|
||||
|
||||
let query = HKSampleQuery(sampleType: menstrualType,
|
||||
predicate: predicate,
|
||||
limit: limit,
|
||||
sortDescriptors: [sortDescriptor]) { [weak self] (query, samples, error) in
|
||||
DispatchQueue.main.async {
|
||||
if let error = error {
|
||||
rejecter("QUERY_ERROR", "Failed to query menstrual flow: \(error.localizedDescription)", error)
|
||||
return
|
||||
}
|
||||
|
||||
guard let flowSamples = samples as? [HKCategorySample] else {
|
||||
resolver([
|
||||
"data": [],
|
||||
"count": 0,
|
||||
"startDate": self?.dateToISOString(startDate) ?? "",
|
||||
"endDate": self?.dateToISOString(endDate) ?? ""
|
||||
])
|
||||
return
|
||||
}
|
||||
|
||||
let flowData = flowSamples.map { sample in
|
||||
[
|
||||
"id": sample.uuid.uuidString,
|
||||
"startDate": self?.dateToISOString(sample.startDate) ?? "",
|
||||
"endDate": self?.dateToISOString(sample.endDate) ?? "",
|
||||
"value": sample.value,
|
||||
"isStart": sample.metadata?[HKMetadataKeyMenstrualCycleStart] as? Bool ?? false,
|
||||
"source": [
|
||||
"name": sample.sourceRevision.source.name,
|
||||
"bundleIdentifier": sample.sourceRevision.source.bundleIdentifier
|
||||
],
|
||||
"metadata": sample.metadata ?? [:]
|
||||
] as [String : Any]
|
||||
}
|
||||
|
||||
let result: [String: Any] = [
|
||||
"data": flowData,
|
||||
"count": flowData.count,
|
||||
"startDate": self?.dateToISOString(startDate) ?? "",
|
||||
"endDate": self?.dateToISOString(endDate) ?? ""
|
||||
]
|
||||
resolver(result)
|
||||
}
|
||||
}
|
||||
healthStore.execute(query)
|
||||
}
|
||||
|
||||
@objc
|
||||
func saveMenstrualFlow(
|
||||
_ options: NSDictionary,
|
||||
resolver: @escaping RCTPromiseResolveBlock,
|
||||
rejecter: @escaping RCTPromiseRejectBlock
|
||||
) {
|
||||
guard HKHealthStore.isHealthDataAvailable() else {
|
||||
rejecter("HEALTHKIT_NOT_AVAILABLE", "HealthKit is not available on this device", nil)
|
||||
return
|
||||
}
|
||||
|
||||
let date: Date
|
||||
if let dateString = options["date"] as? String, let d = parseDate(from: dateString) {
|
||||
date = d
|
||||
} else {
|
||||
rejecter("INVALID_PARAMETERS", "Date is required", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Default to unspecified (1) if not provided.
|
||||
// HKCategoryValueMenstrualFlow: unspecified=1, light=2, medium=3, heavy=4, none=5
|
||||
let value = options["value"] as? Int ?? HKCategoryValueMenstrualFlow.unspecified.rawValue
|
||||
let isStart = options["isStart"] as? Bool ?? false
|
||||
|
||||
guard let menstrualType = WriteTypes.menstrualFlow else {
|
||||
rejecter("TYPE_NOT_AVAILABLE", "Menstrual flow type is not available", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Normalize date to start of day and end of day for the sample
|
||||
let calendar = Calendar.current
|
||||
let startOfDay = calendar.startOfDay(for: date)
|
||||
// HealthKit docs suggest menstrual samples should represent the day.
|
||||
// Often recorded as start of day to next day or specific time.
|
||||
// Standard practice for cycle tracking is usually per-day samples.
|
||||
guard let endOfDay = calendar.date(byAdding: .day, value: 1, to: startOfDay) else {
|
||||
rejecter("DATE_ERROR", "Failed to calculate end of day", nil)
|
||||
return
|
||||
}
|
||||
|
||||
var metadata: [String: Any] = [:]
|
||||
// HKMetadataKeyMenstrualCycleStart is REQUIRED for HKCategoryTypeIdentifierMenstrualFlow
|
||||
// It indicates whether this sample represents the start of a menstrual cycle.
|
||||
metadata[HKMetadataKeyMenstrualCycleStart] = isStart
|
||||
metadata[HKMetadataKeyWasUserEntered] = true
|
||||
|
||||
let sample = HKCategorySample(
|
||||
type: menstrualType,
|
||||
value: value,
|
||||
start: startOfDay,
|
||||
end: endOfDay, // Using full day duration
|
||||
metadata: metadata
|
||||
)
|
||||
|
||||
healthStore.save(sample) { [weak self] (success, error) in
|
||||
DispatchQueue.main.async {
|
||||
if let error = error {
|
||||
rejecter("SAVE_ERROR", "Failed to save menstrual flow: \(error.localizedDescription)", error)
|
||||
return
|
||||
}
|
||||
|
||||
if success {
|
||||
resolver(["success": true])
|
||||
} else {
|
||||
rejecter("SAVE_FAILED", "Failed to save menstrual flow", nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc
|
||||
func deleteMenstrualFlow(
|
||||
_ options: NSDictionary,
|
||||
resolver: @escaping RCTPromiseResolveBlock,
|
||||
rejecter: @escaping RCTPromiseRejectBlock
|
||||
) {
|
||||
guard HKHealthStore.isHealthDataAvailable() else {
|
||||
rejecter("HEALTHKIT_NOT_AVAILABLE", "HealthKit is not available on this device", nil)
|
||||
return
|
||||
}
|
||||
|
||||
let startDate: Date
|
||||
if let startString = options["startDate"] as? String, let d = parseDate(from: startString) {
|
||||
startDate = d
|
||||
} else {
|
||||
rejecter("INVALID_PARAMETERS", "Start date is required", nil)
|
||||
return
|
||||
}
|
||||
|
||||
let endDate: Date
|
||||
if let endString = options["endDate"] as? String, let d = parseDate(from: endString) {
|
||||
endDate = d
|
||||
} else {
|
||||
rejecter("INVALID_PARAMETERS", "End date is required", nil)
|
||||
return
|
||||
}
|
||||
|
||||
guard let menstrualType = WriteTypes.menstrualFlow else {
|
||||
rejecter("TYPE_NOT_AVAILABLE", "Menstrual flow type is not available", nil)
|
||||
return
|
||||
}
|
||||
|
||||
let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
|
||||
|
||||
healthStore.deleteObjects(of: menstrualType, predicate: predicate) { (success, count, error) in
|
||||
DispatchQueue.main.async {
|
||||
if let error = error {
|
||||
rejecter("DELETE_ERROR", "Failed to delete menstrual flow: \(error.localizedDescription)", error)
|
||||
return
|
||||
}
|
||||
|
||||
if success {
|
||||
resolver(["success": true, "count": count])
|
||||
} else {
|
||||
rejecter("DELETE_FAILED", "Failed to delete menstrual flow", nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - RCTEventEmitter Overrides
|
||||
|
||||
override func supportedEvents() -> [String]! {
|
||||
|
||||
@@ -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,
|
||||
|
||||
178
utils/health.ts
178
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 = {
|
||||
@@ -343,6 +344,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 +384,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 +798,87 @@ export async function fetchOxygenSaturation(options: HealthDataOptions): Promise
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchWristTemperature(options: HealthDataOptions, targetDate?: Date): Promise<number | null> {
|
||||
try {
|
||||
const result = await HealthKitManager.getWristTemperatureSamples(options);
|
||||
|
||||
if (result && result.data && Array.isArray(result.data) && result.data.length > 0) {
|
||||
logSuccess('手腕温度', result);
|
||||
|
||||
const samples = result.data as Array<{ endDate?: string; value?: number }>;
|
||||
const dayStart = targetDate ? dayjs(targetDate).startOf('day') : dayjs(options.startDate);
|
||||
const dayEnd = targetDate ? dayjs(targetDate).endOf('day') : dayjs(options.endDate);
|
||||
|
||||
const sampleForSelectedDay = samples.find((sample) => {
|
||||
const sampleEnd = dayjs(sample.endDate);
|
||||
return sampleEnd.isValid() && !sampleEnd.isBefore(dayStart) && !sampleEnd.isAfter(dayEnd);
|
||||
});
|
||||
|
||||
const sampleToUse = sampleForSelectedDay ?? samples[samples.length - 1];
|
||||
|
||||
if (sampleToUse?.value !== undefined) {
|
||||
return Number(Number(sampleToUse.value).toFixed(1));
|
||||
}
|
||||
|
||||
logWarning('手腕温度', '未找到有效的温度值');
|
||||
return null;
|
||||
} else {
|
||||
logWarning('手腕温度', '为空或格式错误');
|
||||
return null;
|
||||
}
|
||||
} catch (error) {
|
||||
logError('手腕温度', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export interface WristTemperatureHistoryPoint {
|
||||
date: string;
|
||||
endDate: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export async function fetchWristTemperatureHistory(options: HealthDataOptions): Promise<WristTemperatureHistoryPoint[]> {
|
||||
try {
|
||||
const result = await HealthKitManager.getWristTemperatureSamples(options);
|
||||
|
||||
if (result && result.data && Array.isArray(result.data) && result.data.length > 0) {
|
||||
logSuccess('手腕温度历史', result);
|
||||
|
||||
const samples = result.data as Array<{ endDate?: string; value?: number }>;
|
||||
const dailyLatest: Record<string, WristTemperatureHistoryPoint> = {};
|
||||
|
||||
samples.forEach((sample) => {
|
||||
if (!sample?.endDate || sample.value === undefined) return;
|
||||
|
||||
const end = dayjs(sample.endDate);
|
||||
if (!end.isValid()) return;
|
||||
|
||||
const dayKey = end.format('YYYY-MM-DD');
|
||||
const numericValue = Number(sample.value);
|
||||
if (Number.isNaN(numericValue)) return;
|
||||
|
||||
const existing = dailyLatest[dayKey];
|
||||
if (!existing || end.isAfter(dayjs(existing.endDate))) {
|
||||
dailyLatest[dayKey] = {
|
||||
date: dayKey,
|
||||
endDate: sample.endDate,
|
||||
value: Number(numericValue.toFixed(2)),
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
return Object.values(dailyLatest).sort((a, b) => dayjs(a.date).diff(dayjs(b.date)));
|
||||
} else {
|
||||
logWarning('手腕温度历史', '为空或格式错误');
|
||||
return [];
|
||||
}
|
||||
} catch (error) {
|
||||
logError('手腕温度历史', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchHeartRateSamplesForRange(
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
@@ -1493,6 +1588,83 @@ 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('经期数据保存成功');
|
||||
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}`);
|
||||
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,5 @@
|
||||
import dayjs, { Dayjs } from 'dayjs';
|
||||
import { MenstrualFlowSample } from './health';
|
||||
|
||||
export type MenstrualDayStatus = 'period' | 'predicted-period' | 'fertile' | 'ovulation-day';
|
||||
|
||||
@@ -54,18 +55,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 +106,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 +118,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 +141,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?: {
|
||||
@@ -136,6 +168,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 +193,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 +230,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 +245,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, {
|
||||
@@ -245,7 +313,7 @@ export const buildMenstrualTimeline = (options?: {
|
||||
return {
|
||||
months,
|
||||
dayMap,
|
||||
cycleLength: avgCycleLength,
|
||||
cycleLength: predictedCycleLength,
|
||||
periodLength: avgPeriodLength,
|
||||
todayInfo: dayMap[todayKey],
|
||||
};
|
||||
@@ -258,3 +326,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,7 @@ 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',
|
||||
|
||||
// 首页身体指标卡片排序设置
|
||||
STATISTICS_CARD_ORDER: 'user_preference_statistics_card_order',
|
||||
@@ -46,6 +47,7 @@ export interface StatisticsCardsVisibility {
|
||||
showMenstrualCycle: boolean;
|
||||
showWeight: boolean;
|
||||
showCircumference: boolean;
|
||||
showWristTemperature: boolean;
|
||||
}
|
||||
|
||||
// 默认卡片顺序
|
||||
@@ -58,6 +60,7 @@ export const DEFAULT_CARD_ORDER: string[] = [
|
||||
'water',
|
||||
'metabolism',
|
||||
'oxygen',
|
||||
'temperature',
|
||||
'menstrual',
|
||||
'weight',
|
||||
'circumference',
|
||||
@@ -109,6 +112,7 @@ const DEFAULT_PREFERENCES: UserPreferences = {
|
||||
showMenstrualCycle: true,
|
||||
showWeight: true,
|
||||
showCircumference: true,
|
||||
showWristTemperature: true,
|
||||
|
||||
// 默认卡片顺序
|
||||
cardOrder: DEFAULT_CARD_ORDER,
|
||||
@@ -145,6 +149,7 @@ 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 cardOrderStr = await AsyncStorage.getItem(PREFERENCES_KEYS.STATISTICS_CARD_ORDER);
|
||||
const cardOrder = cardOrderStr ? JSON.parse(cardOrderStr) : DEFAULT_PREFERENCES.cardOrder;
|
||||
|
||||
@@ -174,6 +179,7 @@ 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,
|
||||
cardOrder,
|
||||
};
|
||||
} catch (error) {
|
||||
@@ -611,6 +617,7 @@ export const getStatisticsCardsVisibility = async (): Promise<StatisticsCardsVis
|
||||
showMenstrualCycle: userPreferences.showMenstrualCycle,
|
||||
showWeight: userPreferences.showWeight,
|
||||
showCircumference: userPreferences.showCircumference,
|
||||
showWristTemperature: userPreferences.showWristTemperature,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('获取首页卡片显示设置失败:', error);
|
||||
@@ -626,6 +633,7 @@ export const getStatisticsCardsVisibility = async (): Promise<StatisticsCardsVis
|
||||
showMenstrualCycle: DEFAULT_PREFERENCES.showMenstrualCycle,
|
||||
showWeight: DEFAULT_PREFERENCES.showWeight,
|
||||
showCircumference: DEFAULT_PREFERENCES.showCircumference,
|
||||
showWristTemperature: DEFAULT_PREFERENCES.showWristTemperature,
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -673,6 +681,7 @@ 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;
|
||||
default: return;
|
||||
}
|
||||
await AsyncStorage.setItem(storageKey, value.toString());
|
||||
|
||||
Reference in New Issue
Block a user