diff --git a/.kilocode/rules/memory-bank/context.md b/.kilocode/rules/memory-bank/context.md
index 37a7f9e..84b551e 100644
--- a/.kilocode/rules/memory-bank/context.md
+++ b/.kilocode/rules/memory-bank/context.md
@@ -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 教练对话能力和分析精度
### 待解决问题
diff --git a/.kilocode/rules/memory-bank/product.md b/.kilocode/rules/memory-bank/product.md
index be199ba..1b46154 100644
--- a/.kilocode/rules/memory-bank/product.md
+++ b/.kilocode/rules/memory-bank/product.md
@@ -1,9 +1,11 @@
# 产品概述
## 产品定位
+
Out Live(超越生命)是一款专注于健康、减肥、瘦身和生活习惯养成的 iOS 应用。该应用通过整合健康数据追踪、AI 教练指导、目标管理和社区挑战等功能,为用户提供全方位的健康生活管理解决方案。
## 目标用户
+
- 关注健康和体重管理的用户
- 希望养成良好生活习惯的用户
- 对普拉提和健身感兴趣的用户
@@ -11,6 +13,7 @@ Out Live(超越生命)是一款专注于健康、减肥、瘦身和生活习
- 希望通过 AI 获得个性化健康指导的用户
## 核心价值主张
+
1. **全方位健康数据管理**:整合 HealthKit 数据,提供步数、心率、睡眠、饮水量等多维度健康指标追踪
2. **AI 智能教练**:基于用户健康数据提供个性化的健康建议和指导
3. **目标管理系统**:帮助用户设定、追踪和完成健康目标
@@ -20,50 +23,59 @@ Out Live(超越生命)是一款专注于健康、减肥、瘦身和生活习
## 主要功能模块
### 健康数据追踪
+
- **活动圆环**:展示活动卡路里、锻炼分钟和站立小时
- **步数统计**:按小时显示步数数据和趋势
- **心率监测**:实时心率和心率变异性(HRV)分析
- **睡眠分析**:睡眠质量和时长追踪
+- **手腕温度**:追踪睡眠期间的手腕温度变化
- **体重管理**:体重记录和 BMI 计算
- **饮水量追踪**:每日饮水目标设定和记录
### 营养管理
+
- **饮食记录**:支持文字、语音和拍照识别食物
- **营养分析**:卡路里、蛋白质、碳水化合物等营养成分分析
- **食物库**:丰富的食物数据库和自定义食物功能
- **营养标签识别**:通过拍照识别食品营养标签
### 目标与习惯管理
+
- **目标设定**:支持日、周、月重复模式的目标设定
- **任务管理**:将目标分解为可执行的任务
- **进度追踪**:可视化目标完成进度
- **提醒功能**:智能提醒帮助用户坚持目标
### 轻断食功能
-- **断食计划**:多种预设断食方案(16:8、18:6等)
+
+- **断食计划**:多种预设断食方案(16:8、18:6 等)
- **断食追踪**:实时显示断食进度和状态
- **智能提醒**:断食开始和结束提醒
- **断食历史**:记录和分析断食历史数据
### AI 教练系统
+
- **智能对话**:基于用户健康数据提供个性化建议
- **体态评估**:通过 AI 分析用户体态照片
- **健康指导**:提供运动、营养和生活方式建议
- **情绪分析**:基于 HRV 数据分析压力水平
### 社区与挑战
+
- **挑战赛**:参与各种健康主题挑战
- **排行榜**:与好友或其他用户比较进度
- **成就系统**:完成目标获得成就奖励
- **社交分享**:分享健康成果到社交平台
### 训练计划
+
- **个性化计划**:基于用户目标生成训练计划
- **运动库**:丰富的运动动作库和指导
- **进度追踪**:记录训练完成情况和效果
- **智能推荐**:根据用户表现调整训练计划
## 用户体验特色
+
1. **Liquid Glass 设计风格**:采用现代化的毛玻璃效果设计
2. **数据可视化**:丰富的图表和动画展示健康数据
3. **快捷操作**:支持快捷动作和小组件快速记录
@@ -71,6 +83,7 @@ Out Live(超越生命)是一款专注于健康、减肥、瘦身和生活习
5. **隐私保护**:严格保护用户健康数据隐私
## 技术亮点
+
- **HealthKit 深度集成**:充分利用 iOS 健康生态系统
- **实时数据同步**:支持多设备数据实时同步
- **智能通知系统**:基于用户行为的智能提醒
@@ -78,13 +91,15 @@ Out Live(超越生命)是一款专注于健康、减肥、瘦身和生活习
- **无障碍支持**:完整的无障碍功能支持
## 商业模式
+
- **免费增值模式**:基础功能免费,高级功能付费
- **VIP 会员**:提供更多个性化功能和专业指导
- **企业健康**:面向企业提供的员工健康管理解决方案
## 竞争优势
+
1. **全平台整合**:深度整合 iOS 健康生态系统
2. **AI 技术应用**:先进的 AI 分析和个性化推荐
3. **用户体验**:优秀的界面设计和交互体验
4. **数据安全**:严格的数据隐私保护措施
-5. **专业内容**:基于科学研究的健康指导内容
\ No newline at end of file
+5. **专业内容**:基于科学研究的健康指导内容
diff --git a/.kilocode/rules/memory-bank/tech.md b/.kilocode/rules/memory-bank/tech.md
index 485084e..d738a01 100644
--- a/.kilocode/rules/memory-bank/tech.md
+++ b/.kilocode/rules/memory-bank/tech.md
@@ -3,17 +3,20 @@
## 核心技术
### 前端框架
+
- **React Native**: 0.81.4 - 跨平台移动应用开发框架
- **Expo SDK**: 54.0.13 - React Native 开发平台和工具链
- **Expo Router**: 6.0.12 - 基于文件系统的路由库
- **TypeScript**: 5.9.2 - 类型安全的 JavaScript 超集
### 状态管理
+
- **Redux Toolkit**: 2.9.0 - 状态管理解决方案
- **React Redux**: 9.2.0 - React Redux 绑定
- **Redux Listener Middleware**: 自定义中间件用于自动同步
### UI 框架和样式
+
- **React Native Elements**: UI 组件库
- **Expo UI**: 0.2.0-beta.7 - Expo UI 组件
- **Expo Glass Effect**: 0.1.4 - Liquid Glass 毛玻璃效果, 优先使用
@@ -22,17 +25,20 @@
- **React Native SVG**: 15.12.1 - SVG 图形支持
### 导航
+
- **Expo Router**: 6.0.12 - 文件系统路由
- **React Navigation**: 7.x - 导航库
## 数据和存储
### 本地存储
+
- **Expo SQLite**: 16.0.8 - SQLite 数据库
- **Expo SQLite KV Store**: 键值存储
- **Async Storage**: 2.2.0 - 异步存储(兼容层)
### 网络和 API
+
- **Fetch API**: 原生网络请求
- **XMLHttpRequest**: 流式请求支持
- **Axios**: HTTP 客户端(可选)
@@ -40,16 +46,19 @@
## 原生功能集成
### HealthKit 集成
+
- **自定义 HealthKit Manager**: iOS 原生模块
-- **健康数据类型**: 步数、心率、HRV、睡眠、活动圆环等
+- **健康数据类型**: 步数、心率、HRV、睡眠、活动圆环、手腕温度(appleSleepingWristTemperature)等
- **权限管理**: 动态权限请求和状态监控
### 通知系统
+
- **Expo Notifications**: 0.32.12 - 本地和推送通知
- **后台任务**: Expo Task Manager
- **推送通知**: 远程推送支持
### 设备功能
+
- **Expo Camera**: 17.0.8 - 相机功能
- **Expo Image Picker**: 17.0.8 - 图片选择
- **Expo Haptics**: 15.0.7 - 触觉反馈
@@ -59,17 +68,20 @@
## 开发工具和构建
### 构建系统
+
- **Expo Prebuild**: 原生构建生成
- **Metro**: JavaScript 打包工具
- **Babel**: JavaScript 编译器
### 代码质量
+
- **ESLint**: 9.35.0 - 代码检查
- **ESLint Config Expo**: 10.0.0 - Expo ESLint 配置
- **Prettier**: 代码格式化
- **TypeScript**: 类型检查
### 开发环境
+
- **VS Code**: 主要开发 IDE
- **Expo Go**: 开发调试
- **iOS Simulator**: iOS 模拟器
@@ -78,21 +90,25 @@
## 第三方服务
### 云存储
+
- **腾讯云 COS**: 图片和文件存储
- **上传服务**: 自定义上传实现
### AI 服务
+
- **AI 教练**: 自定义 AI 对话服务
- **图像识别**: 食物识别
- **语音识别**: 语音转文字
### 分析和监控
+
- **Sentry**: 7.2.0 - 错误监控和性能分析
- **崩溃报告**: 自动崩溃收集
## UI 组件库
### 基础组件
+
- **ThemedView**: 主题化视图组件
- **ThemedText**: 主题化文本组件
- **IconSymbol**: 图标组件
@@ -100,6 +116,7 @@
- **AnimatedNumber**: 数字动画组件
### 业务组件
+
- **FitnessRingsCard**: 健身圆环卡片
- **StepsCard**: 步数卡片
- **NutritionRadarCard**: 营养雷达图
@@ -109,6 +126,7 @@
- **TaskCard**: 任务卡片
### 图表组件
+
- **RadarChart**: 雷达图
- **CircularRing**: 圆形进度环
- **CalorieRingChart**: 卡路里环形图
@@ -117,17 +135,20 @@
## 开发依赖
### 类型定义
+
- **React Types**: 19.1.13
- **React Native Types**: 内置
- **Expo Types**: 内置
### 工具库
+
- **Day.js**: 1.11.18 - 日期处理
- **Lodash**: 4.17.21 - 工具函数库
- **React Native Chart Kit**: 6.12.0 - 图表库
- **Lottie React Native**: 7.3.4 - 动画库
### 音频和媒体
+
- **React Native Voice**: 3.2.4 - 语音识别
- **Expo Media Library**: 18.2.0 - 媒体库
- **Expo Audio**: 音频处理
@@ -135,12 +156,14 @@
## 平台特定配置
### iOS 配置
+
- **最低版本**: iOS 16.0
- **Bundle ID**: com.anonymous.digitalpilates
- **Team ID**: 756WVXJ6MT
- **权限配置**: 相机、相册、麦克风、健康数据、通知等
### 构建配置
+
- **New Arch**: 启用
- **JS Engine**: JSC
- **Metro 配置**: 自定义配置
@@ -149,18 +172,21 @@
## 性能优化
### 渲染优化
+
- **React.memo**: 组件记忆化
- **useMemo/useCallback**: 钩子优化
- **FlatList**: 大列表优化
- **InteractionManager**: 延迟渲染
### 数据优化
+
- **Redux Toolkit**: 自动优化
- **数据分页**: 分页加载
- **缓存策略**: 智能缓存
- **后台同步**: 异步同步
### 资源优化
+
- **图片优化**: WebP 格式
- **Bundle 分割**: 代码分割
- **内存管理**: 资源释放
@@ -169,12 +195,14 @@
## 安全措施
### 数据安全
+
- **HTTPS**: 加密通信
- **Token 管理**: JWT 存储
- **数据加密**: 本地加密
- **权限控制**: 细粒度权限
### 隐私保护
+
- **数据脱敏**: 敏感数据处理
- **权限最小化**: 最小权限原则
- **用户控制**: 数据控制权
@@ -183,11 +211,13 @@
## 测试框架
### 单元测试
+
- **Jest**: 测试框架
- **React Native Testing Library**: 组件测试
- **Mock**: 模拟数据和服务
### 集成测试
+
- **Detox**: E2E 测试(可选)
- **手动测试**: 功能验证
- **性能测试**: 性能基准
@@ -195,12 +225,14 @@
## 部署和发布
### 构建流程
+
- **Expo EAS Build**: 云端构建
- **App Store Connect**: 应用商店发布
- **OTA 更新**: 热更新
- **版本管理**: 语义化版本
### 持续集成
+
- **GitHub Actions**: 自动化流程
- **代码检查**: 自动化检查
- **测试执行**: 自动化测试
@@ -209,19 +241,22 @@
## 开发规范
### 代码规范
+
- **ESLint**: 代码检查
- **Prettier**: 代码格式化
- **TypeScript**: 类型安全
- **命名规范**: 统一命名
### Git 工作流
+
- **Conventional Commits**: 提交规范
- **分支策略**: Git Flow
- **代码审查**: PR 流程
- **版本标签**: 标签管理
### 文档规范
+
- **JSDoc**: 代码注释
- **README**: 项目文档
- **API 文档**: 接口文档
-- **组件文档**: 组件说明
\ No newline at end of file
+- **组件文档**: 组件说明
diff --git a/app/(tabs)/personal.tsx b/app/(tabs)/personal.tsx
index c908c06..e825e20 100644
--- a/app/(tabs)/personal.tsx
+++ b/app/(tabs)/personal.tsx
@@ -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';
diff --git a/app/(tabs)/statistics.tsx b/app/(tabs)/statistics.tsx
index 0d28674..ea8d343 100644
--- a/app/(tabs)/statistics.tsx
+++ b/app/(tabs)/statistics.tsx
@@ -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: (
+
+ )
+ },
menstrual: {
visible: cardVisibility.showMenstrualCycle,
component: (
diff --git a/app/menstrual-cycle.tsx b/app/menstrual-cycle.tsx
index 9b8ce60..31f4daf 100644
--- a/app/menstrual-cycle.tsx
+++ b/app/menstrual-cycle.tsx
@@ -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 = {
- 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 = (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;
- isSelected: boolean;
- onPress: () => void;
-}) => {
- const status = cell.info?.status;
- const colors = status ? STATUS_COLORS[status] : undefined;
-
- return (
-
-
-
- {cell.label}
-
-
- {cell.isToday && 今天}
-
- );
-};
-
-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 (
-
-
- {month.title}
- {month.subtitle}
-
-
- {WEEK_LABELS.map((label) => (
-
- {label}
-
- ))}
-
-
- {weeks.map((week, weekIndex) => {
- const selectedIndex = week.findIndex(
- (c) => c.type === 'day' && c.date.format('YYYY-MM-DD') === selectedDateKey
- );
-
- return (
-
-
- {week.map((cell) => {
- if (cell.type === 'placeholder') {
- return ;
- }
- const dateKey = cell.date.format('YYYY-MM-DD');
- return (
- onSelect(dateKey)}
- />
- );
- })}
-
- {selectedIndex !== -1 && (
-
- {renderTip(selectedIndex)}
-
- )}
-
- );
- })}
-
-
- );
-};
-
export default function MenstrualCycleScreen() {
const router = useRouter();
+ const { t } = useTranslation();
const [records, setRecords] = useState([]);
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 = () => (
-
- {[
- { label: '经期', key: 'period' as const },
- { label: '预测经期', key: 'predicted-period' as const },
- { label: '排卵期', key: 'fertile' as const },
- { label: '排卵日', key: 'ovulation-day' as const },
- ].map((item) => (
-
-
- {item.label}
-
- ))}
-
- );
+
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 = (
-
-
-
-
-
- {selectedDate.format('M月D日')}
-
- {!isFuture && (!selectedInfo || !selectedInfo.confirmed) && (
-
-
- 标记经期
-
- )}
- {!isFuture && selectedInfo?.confirmed && selectedInfo.status === 'period' && (
-
- 取消标记
-
- )}
-
-
- );
-
- return base;
- };
+ const renderInlineTip = (columnIndex: number) => (
+
+ );
const renderCycleTab = () => (
- {renderLegend()}
+
(
- 分析
+ {t('menstrual.screen.analysis.title')}
- 基于最近 6 个周期的记录,计算平均经期和周期长度,后续会展示趋势和预测准确度。
+ {t('menstrual.screen.analysis.description')}
@@ -433,7 +348,7 @@ export default function MenstrualCycleScreen() {
router.back()} style={styles.headerIcon}>
- 生理周期
+ {t('menstrual.screen.header')}
@@ -441,8 +356,8 @@ export default function MenstrualCycleScreen() {
{([
- { 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,
},
diff --git a/app/statistics-customization.tsx b/app/statistics-customization.tsx
index 0ccba49..9b9be9b 100644
--- a/app/statistics-customization.tsx
+++ b/app/statistics-customization.tsx
@@ -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' },
diff --git a/components/MenstrualCycleCard.tsx b/components/MenstrualCycleCard.tsx
index bd3e7a7..82ba0b2 100644
--- a/components/MenstrualCycleCard.tsx
+++ b/components/MenstrualCycleCard.tsx
@@ -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 = () => (
(
);
export const MenstrualCycleCard: React.FC = ({ onPress }) => {
- const { todayInfo, periodLength } = useMemo(() => buildMenstrualTimeline(), []);
+ const { t } = useTranslation();
+ const [records, setRecords] = useState([]);
+ 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 (
- 生理周期
+ {t('menstrual.card.title')}
@@ -71,10 +113,12 @@ export const MenstrualCycleCard: React.FC = ({ onPress }) => {
{summary.number !== undefined ? (
<>
- 第 {summary.number} {summary.dayText}
+ {summary.prefix}
+ {summary.number}
+ {summary.suffix}
>
) : (
- summary.dayText
+ summary.fallbackText
)}
@@ -82,6 +126,199 @@ export const MenstrualCycleCard: React.FC = ({ onPress }) => {
);
};
+const periodStatuses = new Set(['period', 'predicted-period']);
+const fertileStatuses = new Set(['fertile', 'ovulation-day']);
+const ovulationStatuses = new Set(['ovulation-day']);
+
+const deriveSummary = (
+ timeline: MenstrualTimeline,
+ hasRecords: boolean,
+ t: (key: string, options?: Record) => 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
+ ): { 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,
+ 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) => {
+ 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%',
diff --git a/components/menstrual-cycle/DayCell.tsx b/components/menstrual-cycle/DayCell.tsx
new file mode 100644
index 0000000..48fb0da
--- /dev/null
+++ b/components/menstrual-cycle/DayCell.tsx
@@ -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 = ({ cell, isSelected, onPress }) => {
+ const status = cell.info?.status;
+ const colors = status ? STATUS_COLORS[status] : undefined;
+
+ return (
+
+
+
+ {cell.label}
+
+
+ {cell.isToday && 今天}
+
+ );
+};
+
+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',
+ },
+});
diff --git a/components/menstrual-cycle/InlineTip.tsx b/components/menstrual-cycle/InlineTip.tsx
new file mode 100644
index 0000000..c7340e0
--- /dev/null
+++ b/components/menstrual-cycle/InlineTip.tsx
@@ -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 = ({
+ 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 (
+
+
+
+
+
+ {selectedDate.format('M月D日')}
+
+ {!isFuture && (!selectedInfo || !selectedInfo.confirmed) && (
+
+
+ 标记经期
+
+ )}
+ {!isFuture && selectedInfo?.confirmed && selectedInfo.status === 'period' && (
+
+ 取消标记
+
+ )}
+
+
+ );
+};
+
+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',
+ },
+});
diff --git a/components/menstrual-cycle/Legend.tsx b/components/menstrual-cycle/Legend.tsx
new file mode 100644
index 0000000..05aabce
--- /dev/null
+++ b/components/menstrual-cycle/Legend.tsx
@@ -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 (
+
+ {LEGEND_ITEMS.map((item) => (
+
+
+ {item.label}
+
+ ))}
+
+ );
+};
+
+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',
+ },
+});
diff --git a/components/menstrual-cycle/MonthBlock.tsx b/components/menstrual-cycle/MonthBlock.tsx
new file mode 100644
index 0000000..b776439
--- /dev/null
+++ b/components/menstrual-cycle/MonthBlock.tsx
@@ -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 = (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 = ({
+ month,
+ selectedDateKey,
+ onSelect,
+ renderTip,
+}) => {
+ const weeks = useMemo(() => chunkArray(month.cells, 7), [month.cells]);
+
+ return (
+
+
+ {month.title}
+ {month.subtitle}
+
+
+ {WEEK_LABELS.map((label) => (
+
+ {label}
+
+ ))}
+
+
+ {weeks.map((week, weekIndex) => {
+ const selectedIndex = week.findIndex(
+ (c) => c.type === 'day' && c.date.format('YYYY-MM-DD') === selectedDateKey
+ );
+
+ return (
+
+
+ {week.map((cell) => {
+ if (cell.type === 'placeholder') {
+ return ;
+ }
+ const dateKey = cell.date.format('YYYY-MM-DD');
+ return (
+ onSelect(dateKey)}
+ />
+ );
+ })}
+
+ {selectedIndex !== -1 && (
+
+ {renderTip(selectedIndex)}
+
+ )}
+
+ );
+ })}
+
+
+ );
+};
+
+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,
+ },
+});
diff --git a/components/menstrual-cycle/constants.ts b/components/menstrual-cycle/constants.ts
new file mode 100644
index 0000000..301a101
--- /dev/null
+++ b/components/menstrual-cycle/constants.ts
@@ -0,0 +1,12 @@
+import { MenstrualDayStatus } from '@/utils/menstrualCycle';
+
+export const STATUS_COLORS: Record = {
+ 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;
diff --git a/components/menstrual-cycle/index.ts b/components/menstrual-cycle/index.ts
new file mode 100644
index 0000000..297790a
--- /dev/null
+++ b/components/menstrual-cycle/index.ts
@@ -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';
+
diff --git a/components/menstrual-cycle/types.ts b/components/menstrual-cycle/types.ts
new file mode 100644
index 0000000..a4c603a
--- /dev/null
+++ b/components/menstrual-cycle/types.ts
@@ -0,0 +1,21 @@
+import { MenstrualDayCell, MenstrualDayInfo } from '@/utils/menstrualCycle';
+import { Dayjs } from 'dayjs';
+
+export interface DayCellProps {
+ cell: Extract;
+ 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';
+}
diff --git a/components/statistic/HealthDataCard.tsx b/components/statistic/HealthDataCard.tsx
index e8604ba..38119de 100644
--- a/components/statistic/HealthDataCard.tsx
+++ b/components/statistic/HealthDataCard.tsx
@@ -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 = ({
title,
value,
unit,
- style
+ style,
+ onPress
}) => {
+ const Container = onPress ? Pressable : View;
+
return (
-
-
-
-
+
+
+
+
{title}
{value}
{unit}
-
+
);
};
@@ -62,6 +61,11 @@ const styles = StyleSheet.create({
flex: 1,
justifyContent: 'center',
},
+ headerRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ marginBottom: 14,
+ },
titleIcon: {
width: 16,
height: 16,
@@ -94,4 +98,4 @@ const styles = StyleSheet.create({
},
});
-export default HealthDataCard;
\ No newline at end of file
+export default HealthDataCard;
diff --git a/components/statistic/WristTemperatureCard.tsx b/components/statistic/WristTemperatureCard.tsx
new file mode 100644
index 0000000..964459b
--- /dev/null
+++ b/components/statistic/WristTemperatureCard.tsx
@@ -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 = ({
+ style,
+ selectedDate
+}) => {
+ const { t } = useTranslation();
+ const isFocused = useIsFocused();
+ const [temperature, setTemperature] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const loadingRef = useRef(false);
+ const [historyVisible, setHistoryVisible] = useState(false);
+ const [history, setHistory] = useState([]);
+ 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 (
+ <>
+
+
+
+
+
+
+
+
+ {t('statistics.components.wristTemperature.title')}
+ {t('statistics.components.wristTemperature.last30Days')}
+
+
+
+
+
+
+
+
+
+ {historyLoading ? (
+ {t('statistics.components.wristTemperature.syncing')}
+ ) : null}
+
+ {history.length === 0 ? (
+
+ {t('statistics.components.wristTemperature.noData')}
+
+ ) : (
+ {
+ const nextWidth = event.nativeEvent.layout.width;
+ if (nextWidth > 120 && Math.abs(nextWidth - chartWidth) > 2) {
+ setChartWidth(nextWidth);
+ }
+ }}
+ >
+
+
+ {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 (
+
+ {item.label}
+
+ );
+ })}
+
+
+
+ {t('statistics.components.wristTemperature.baseline')}
+ {baseline !== null && (
+
+ {baseline.toFixed(1)}
+ °C
+
+ )}
+
+
+ {latestChange !== null && (
+
+
+ {latestChange >= 0 ? '+' : ''}
+ {latestChange.toFixed(1)}°C
+
+
+ )}
+
+
+ )}
+
+
+
+ {t('statistics.components.wristTemperature.average')}
+
+ {baseline !== null ? baseline.toFixed(1) : '--'}
+ °C
+
+
+
+ {t('statistics.components.wristTemperature.latest')}
+
+ {latestValue !== null ? latestValue.toFixed(1) : '--'}
+ °C
+
+ {latestChange !== null && (
+
+ {latestChange >= 0 ? '+' : ''}
+ {latestChange.toFixed(1)}°C {t('statistics.components.wristTemperature.vsBaseline')}
+
+ )}
+
+
+
+
+
+ >
+ );
+};
+
+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'
+ }
+});
diff --git a/i18n/en/health.ts b/i18n/en/health.ts
index 328059a..c355b92 100644
--- a/i18n/en/health.ts
+++ b/i18n/en/health.ts
@@ -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}}',
diff --git a/i18n/en/index.ts b/i18n/en/index.ts
index 32a6558..dbfd7bd 100644
--- a/i18n/en/index.ts
+++ b/i18n/en/index.ts
@@ -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, // 确保通用翻译被正确导出
};
diff --git a/i18n/en/menstrual.ts b/i18n/en/menstrual.ts
new file mode 100644
index 0000000..157cf19
--- /dev/null
+++ b/i18n/en/menstrual.ts
@@ -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.',
+ },
+ },
+};
diff --git a/i18n/en/personal.ts b/i18n/en/personal.ts
index 0af3f55..90a67a1 100644
--- a/i18n/en/personal.ts
+++ b/i18n/en/personal.ts
@@ -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',
diff --git a/i18n/zh/health.ts b/i18n/zh/health.ts
index ba746ff..1c73d5b 100644
--- a/i18n/zh/health.ts
+++ b/i18n/zh/health.ts
@@ -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}}',
diff --git a/i18n/zh/index.ts b/i18n/zh/index.ts
index 32a6558..dbfd7bd 100644
--- a/i18n/zh/index.ts
+++ b/i18n/zh/index.ts
@@ -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, // 确保通用翻译被正确导出
};
diff --git a/i18n/zh/menstrual.ts b/i18n/zh/menstrual.ts
new file mode 100644
index 0000000..1e1cc13
--- /dev/null
+++ b/i18n/zh/menstrual.ts
@@ -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 个周期的记录,计算平均经期和周期长度,后续会展示趋势和预测准确度。',
+ },
+ },
+};
diff --git a/i18n/zh/personal.ts b/i18n/zh/personal.ts
index 61dc8ac..cc77a91 100644
--- a/i18n/zh/personal.ts
+++ b/i18n/zh/personal.ts
@@ -127,6 +127,7 @@ export const statisticsCustomization = {
water: '饮水',
basalMetabolism: '基础代谢',
oxygenSaturation: '血氧',
+ wristTemperature: '手腕温度',
menstrualCycle: '经期',
weight: '体重',
circumference: '围度',
diff --git a/ios/OutLive/HealthKitManager.m b/ios/OutLive/HealthKitManager.m
index 1d878a1..fa38127 100644
--- a/ios/OutLive/HealthKitManager.m
+++ b/ios/OutLive/HealthKitManager.m
@@ -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
diff --git a/ios/OutLive/HealthKitManager.swift b/ios/OutLive/HealthKitManager.swift
index bd964cb..2bbbf35 100644
--- a/ios/OutLive/HealthKitManager.swift
+++ b/ios/OutLive/HealthKitManager.swift
@@ -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 {
var types: Set = [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 {
var types: Set = []
@@ -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]! {
diff --git a/store/tabBarConfigSlice.ts b/store/tabBarConfigSlice.ts
index b7eafb7..620e2c2 100644
--- a/store/tabBarConfigSlice.ts
+++ b/store/tabBarConfigSlice.ts
@@ -31,7 +31,7 @@ export const DEFAULT_TAB_CONFIGS: TabConfig[] = [
{
id: 'statistics',
icon: 'chart.pie.fill',
- titleKey: 'statistics.tabs.health',
+ titleKey: 'health.tabs.health',
enabled: true,
canBeDisabled: false,
order: 1,
@@ -39,7 +39,7 @@ export const DEFAULT_TAB_CONFIGS: TabConfig[] = [
{
id: 'medications',
icon: 'pills.fill',
- titleKey: 'statistics.tabs.medications',
+ titleKey: 'health.tabs.medications',
enabled: true,
canBeDisabled: true, // 用药管理可以被关闭
order: 2,
@@ -47,7 +47,7 @@ export const DEFAULT_TAB_CONFIGS: TabConfig[] = [
{
id: 'fasting',
icon: 'timer',
- titleKey: 'statistics.tabs.fasting',
+ titleKey: 'health.tabs.fasting',
enabled: true,
canBeDisabled: true, // 断食可以被关闭
order: 3,
@@ -55,7 +55,7 @@ export const DEFAULT_TAB_CONFIGS: TabConfig[] = [
{
id: 'challenges',
icon: 'trophy.fill',
- titleKey: 'statistics.tabs.challenges',
+ titleKey: 'health.tabs.challenges',
enabled: true,
canBeDisabled: true, // 挑战可以被关闭
order: 4,
@@ -63,7 +63,7 @@ export const DEFAULT_TAB_CONFIGS: TabConfig[] = [
{
id: 'personal',
icon: 'person.fill',
- titleKey: 'statistics.tabs.personal',
+ titleKey: 'health.tabs.personal',
enabled: true,
canBeDisabled: false,
order: 5,
@@ -227,4 +227,4 @@ export const selectIsInitialized = (state: RootState) => state.tabBarConfig.isIn
export const selectTabConfigById = (tabId: string) => (state: RootState) =>
state.tabBarConfig.configs.find(config => config.id === tabId);
-export default tabBarConfigSlice.reducer;
\ No newline at end of file
+export default tabBarConfigSlice.reducer;
diff --git a/utils/health.ts b/utils/health.ts
index 519bd1f..c3d2897 100644
--- a/utils/health.ts
+++ b/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;
+};
+
// 更新:使用新的权限管理系统
export async function ensureHealthPermissions(): Promise {
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 {
+ 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 {
+ 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 = {};
+
+ 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 {
+ 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 {
+ 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 {
+ 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 {
try {
diff --git a/utils/menstrualCycle.ts b/utils/menstrualCycle.ts
index d2515d9..9ce8b04 100644
--- a/utils/menstrualCycle.ts
+++ b/utils/menstrualCycle.ts
@@ -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 = {
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;
+};
diff --git a/utils/userPreferences.ts b/utils/userPreferences.ts
index 6fa3326..9ab5eb0 100644
--- a/utils/userPreferences.ts
+++ b/utils/userPreferences.ts
@@ -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 => {
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 => {
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