17 Commits

Author SHA1 Message Date
richarjiang
17664c679d feat(health): 新增日照时长监测卡片与 HealthKit 集成
- iOS 端集成 HealthKit 日照时间 (TimeInDaylight) 数据获取接口
- 新增 SunlightCard 组件,支持查看今日数据及最近30天历史趋势图表
- 更新统计页和自定义设置页,支持开启/关闭日照卡片
- 优化 HealthDataCard 组件,支持自定义图标组件和副标题显示
- 更新多语言文件及应用版本号至 1.1.6
2025-12-19 17:38:16 +08:00
richarjiang
e51aca2fdb feat(image): 封装 expo-image 组件以支持安全的图片请求头 2025-12-18 16:37:00 +08:00
richarjiang
76c37bfeb0 feat: 抽离 imaghe 组件,为图片增加 header 2025-12-18 16:36:53 +08:00
richarjiang
feb5052fcd feat(i18n): 增强生理周期模块的国际化支持,添加多语言格式和翻译 2025-12-18 09:36:08 +08:00
richarjiang
4836058d56 feat(health): 新增手腕温度监测和经期双向同步功能
新增手腕温度健康数据追踪,支持Apple Watch睡眠手腕温度数据展示和30天历史趋势分析
实现经期数据与HealthKit的完整双向同步,支持读取、写入和删除经期记录
优化经期预测算法,基于历史数据计算更准确的周期和排卵日预测
重构经期UI组件为模块化结构,提升代码可维护性
添加完整的中英文国际化支持,覆盖所有新增功能界面
2025-12-18 08:40:08 +08:00
richarjiang
9b4a300380 feat(app): 新增生理周期记录功能与首页卡片自定义支持
- 新增生理周期追踪页面及相关算法逻辑,支持经期记录与预测
- 新增首页统计卡片自定义页面,支持VIP用户调整卡片显示状态与顺序
- 重构首页统计页面布局逻辑,支持动态渲染与混合布局
- 引入 react-native-draggable-flatlist 用于实现拖拽排序功能
- 添加相关多语言配置及用户偏好设置存储接口
2025-12-16 17:25:21 +08:00
richarjiang
5e11da34ee feat(app): 新增HRV压力提醒设置与锻炼记录分享功能
- 通知设置页面新增 HRV 压力提醒开关,支持自定义开启或关闭压力监测推送
- 锻炼详情页集成分享功能,支持将运动数据生成精美长图并分享
- 优化 HRV 监测服务逻辑,在发送通知前检查用户偏好设置
- 更新多语言配置文件,添加相关文案翻译
- 将应用版本号更新至 1.1.5
2025-12-16 11:27:11 +08:00
409f125db1 feat: 去掉热更新 2025-12-06 12:33:12 +08:00
richarjiang
eef0134ddc feat(app): 优化Expo Updates更新检查机制,防止重复执行 2025-12-06 10:05:19 +08:00
richarjiang
0013dc3266 feat(个人中心): 移除会员横幅中的皇冠图标 2025-12-05 22:34:43 +08:00
richarjiang
37a0687456 feat(app): 优化Expo Updates更新机制,改进睡眠阶段时间轴UI设计,升级项目依赖 2025-12-05 17:17:16 +08:00
richarjiang
74b49efe23 feat(app): 启用Expo Updates自动更新功能,优化医疗记录上传流程与API集成 2025-12-05 16:09:09 +08:00
richarjiang
3d08721474 feat(个人中心): 优化会员横幅组件,支持深色模式与国际化;新增医疗记录卡片组件,完善健康档案功能 2025-12-05 14:35:10 +08:00
richarjiang
f3d4264b53 feat(家庭健康): 优化家庭组加入流程,移除自动创建家庭组逻辑 2025-12-04 19:10:05 +08:00
richarjiang
a254af92c7 feat: 新增健康档案模块,支持家庭邀请与个人健康数据管理 2025-12-04 17:56:04 +08:00
richarjiang
e713ffbace feat: 增加睡眠分析通知功能,支持睡眠质量评估与建议 2025-12-03 10:13:14 +08:00
richarjiang
02b2de3ea3 feat: 支持健康数据上报 2025-12-02 19:10:55 +08:00
121 changed files with 14232 additions and 3905 deletions

View File

@@ -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 教练对话能力和分析精度
### 待解决问题

View File

@@ -1,9 +1,11 @@
# 产品概述
## 产品定位
Out Live超越生命是一款专注于健康、减肥、瘦身和生活习惯养成的 iOS 应用。该应用通过整合健康数据追踪、AI 教练指导、目标管理和社区挑战等功能,为用户提供全方位的健康生活管理解决方案。
## 目标用户
- 关注健康和体重管理的用户
- 希望养成良好生活习惯的用户
- 对普拉提和健身感兴趣的用户
@@ -11,6 +13,7 @@ Out Live超越生命是一款专注于健康、减肥、瘦身和生活习
- 希望通过 AI 获得个性化健康指导的用户
## 核心价值主张
1. **全方位健康数据管理**:整合 HealthKit 数据,提供步数、心率、睡眠、饮水量等多维度健康指标追踪
2. **AI 智能教练**:基于用户健康数据提供个性化的健康建议和指导
3. **目标管理系统**:帮助用户设定、追踪和完成健康目标
@@ -20,50 +23,59 @@ Out Live超越生命是一款专注于健康、减肥、瘦身和生活习
## 主要功能模块
### 健康数据追踪
- **活动圆环**:展示活动卡路里、锻炼分钟和站立小时
- **步数统计**:按小时显示步数数据和趋势
- **心率监测**实时心率和心率变异性HRV分析
- **睡眠分析**:睡眠质量和时长追踪
- **手腕温度**:追踪睡眠期间的手腕温度变化
- **体重管理**:体重记录和 BMI 计算
- **饮水量追踪**:每日饮水目标设定和记录
### 营养管理
- **饮食记录**:支持文字、语音和拍照识别食物
- **营养分析**:卡路里、蛋白质、碳水化合物等营养成分分析
- **食物库**:丰富的食物数据库和自定义食物功能
- **营养标签识别**:通过拍照识别食品营养标签
### 目标与习惯管理
- **目标设定**:支持日、周、月重复模式的目标设定
- **任务管理**:将目标分解为可执行的任务
- **进度追踪**:可视化目标完成进度
- **提醒功能**:智能提醒帮助用户坚持目标
### 轻断食功能
- **断食计划**多种预设断食方案16:8、18:6等
- **断食计划**多种预设断食方案16:8、18:6 等)
- **断食追踪**:实时显示断食进度和状态
- **智能提醒**:断食开始和结束提醒
- **断食历史**:记录和分析断食历史数据
### AI 教练系统
- **智能对话**:基于用户健康数据提供个性化建议
- **体态评估**:通过 AI 分析用户体态照片
- **健康指导**:提供运动、营养和生活方式建议
- **情绪分析**:基于 HRV 数据分析压力水平
### 社区与挑战
- **挑战赛**:参与各种健康主题挑战
- **排行榜**:与好友或其他用户比较进度
- **成就系统**:完成目标获得成就奖励
- **社交分享**:分享健康成果到社交平台
### 训练计划
- **个性化计划**:基于用户目标生成训练计划
- **运动库**:丰富的运动动作库和指导
- **进度追踪**:记录训练完成情况和效果
- **智能推荐**:根据用户表现调整训练计划
## 用户体验特色
1. **Liquid Glass 设计风格**:采用现代化的毛玻璃效果设计
2. **数据可视化**:丰富的图表和动画展示健康数据
3. **快捷操作**:支持快捷动作和小组件快速记录
@@ -71,6 +83,7 @@ Out Live超越生命是一款专注于健康、减肥、瘦身和生活习
5. **隐私保护**:严格保护用户健康数据隐私
## 技术亮点
- **HealthKit 深度集成**:充分利用 iOS 健康生态系统
- **实时数据同步**:支持多设备数据实时同步
- **智能通知系统**:基于用户行为的智能提醒
@@ -78,13 +91,15 @@ Out Live超越生命是一款专注于健康、减肥、瘦身和生活习
- **无障碍支持**:完整的无障碍功能支持
## 商业模式
- **免费增值模式**:基础功能免费,高级功能付费
- **VIP 会员**:提供更多个性化功能和专业指导
- **企业健康**:面向企业提供的员工健康管理解决方案
## 竞争优势
1. **全平台整合**:深度整合 iOS 健康生态系统
2. **AI 技术应用**:先进的 AI 分析和个性化推荐
3. **用户体验**:优秀的界面设计和交互体验
4. **数据安全**:严格的数据隐私保护措施
5. **专业内容**:基于科学研究的健康指导内容
5. **专业内容**:基于科学研究的健康指导内容

View File

@@ -751,3 +751,44 @@ list: {
2. **保持翻译一致性**:相同含义的文本使用相同的翻译键
3. **定期审查**:定期检查是否有硬编码文本遗漏
4. **测试验证**:在开发完成后测试语言切换功能是否正常
## Expo Image 封装与使用规范
**最后更新**: 2025-12-18
### 重要原则
**禁止直接使用 `expo-image` 的 `Image` 组件**,必须使用封装好的 `@/components/ui/Image` 组件。
### 问题描述
为了满足后端 API 安全要求,所有图片请求都需要携带特定的 `User-Agent``Referer` 请求头。`expo-image` 默认不会添加这些头信息。
### 解决方案
创建了一个封装组件 `@/components/ui/Image.tsx`,该组件自动拦截 `source` 属性并注入所需的请求头。
### 实现模式
#### 1. 替换导入语句
```typescript
// ❌ 禁止使用
import { Image } from "expo-image";
// ✅ 正确写法
import { Image } from "@/components/ui/Image";
```
#### 2. 组件功能
封装的组件会自动处理以下逻辑:
1. **注入 User-Agent**: 使用 `Out Live/{version} (iOS)` 格式
2. **注入 Referer**: 使用 `API_ORIGIN` 常量 (`https://pilate.richarjiang.com`)
3. **支持多种 Source 类型**: 自动处理 `string` (URL), `object` (带 uri), `number` (本地资源) 以及它们的数组形式
### 参考实现
- `components/ui/Image.tsx`: 核心封装实现
- `components/WorkoutSummaryCard.tsx`: 使用示例

View File

@@ -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 文档**: 接口文档
- **组件文档**: 组件说明
- **组件文档**: 组件说明

View File

@@ -4,5 +4,6 @@
"source.organizeImports": "explicit",
"source.sortMembers": "explicit"
},
"kiroAgent.configureMCP": "Enabled"
"kiroAgent.configureMCP": "Enabled",
"codingcopilot.enableCompletionLanguage": {}
}

View File

@@ -2,7 +2,7 @@
"expo": {
"name": "Out Live",
"slug": "digital-pilates",
"version": "1.1.4",
"version": "1.1.6",
"orientation": "portrait",
"scheme": "digitalpilates",
"userInterfaceStyle": "light",
@@ -36,6 +36,7 @@
},
"plugins": [
"expo-router",
"expo-updates",
[
"expo-splash-screen",
{
@@ -70,8 +71,16 @@
"experiments": {
"typedRoutes": true
},
"runtimeVersion": {
"policy": "appVersion"
},
"android": {
"package": "com.anonymous.digitalpilates"
},
"updates": {
"enabled": true,
"checkAutomatically": "ON_LOAD",
"url": "https://pilate.richarjiang.com/api/expo-updates/manifest"
}
}
}

View File

@@ -23,11 +23,11 @@ type TabConfig = {
};
const TAB_CONFIGS: Record<string, TabConfig> = {
statistics: { icon: 'chart.pie.fill', titleKey: 'statistics.tabs.health' },
medications: { icon: 'pills.fill', titleKey: 'statistics.tabs.medications' },
fasting: { icon: 'timer', titleKey: 'statistics.tabs.fasting' },
challenges: { icon: 'trophy.fill', titleKey: 'statistics.tabs.challenges' },
personal: { icon: 'person.fill', titleKey: 'statistics.tabs.personal' },
statistics: { icon: 'chart.pie.fill', titleKey: 'health.tabs.health' },
medications: { icon: 'pills.fill', titleKey: 'health.tabs.medications' },
fasting: { icon: 'timer', titleKey: 'health.tabs.fasting' },
challenges: { icon: 'trophy.fill', titleKey: 'health.tabs.challenges' },
personal: { icon: 'person.fill', titleKey: 'health.tabs.personal' },
};
export default function TabLayout() {

View File

@@ -2,6 +2,7 @@ import dayjs from 'dayjs';
import ChallengeProgressCard from '@/components/challenges/ChallengeProgressCard';
import { ConfirmationSheet } from '@/components/ui/ConfirmationSheet';
import { Image } from '@/components/ui/Image';
import { Colors } from '@/constants/Colors';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useAuthGuard } from '@/hooks/useAuthGuard';
@@ -23,7 +24,6 @@ import {
import { Toast } from '@/utils/toast.utils';
import { Ionicons } from '@expo/vector-icons';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient';
import { useRouter } from 'expo-router';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';

View File

@@ -4,6 +4,7 @@ import { MedicationCard } from '@/components/medication/MedicationCard';
import { TakenMedicationsStack } from '@/components/medication/TakenMedicationsStack';
import { ThemedText } from '@/components/ThemedText';
import { IconSymbol } from '@/components/ui/IconSymbol';
import { Image } from '@/components/ui/Image';
import { MedicalDisclaimerSheet } from '@/components/ui/MedicalDisclaimerSheet';
import { MedicationAiSummaryInfoSheet } from '@/components/ui/MedicationAiSummaryInfoSheet';
import { Colors } from '@/constants/Colors';
@@ -20,7 +21,6 @@ import { useFocusEffect } from '@react-navigation/native';
import dayjs, { Dayjs } from 'dayjs';
import 'dayjs/locale/zh-cn';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient';
import { router } from 'expo-router';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';

View File

@@ -1,13 +1,17 @@
import ActivityHeatMap from '@/components/ActivityHeatMap';
import { BadgeShowcaseModal } from '@/components/badges/BadgeShowcaseModal';
import { MembershipBanner } from '@/components/MembershipBanner';
import { PRIVACY_POLICY_URL, USER_AGREEMENT_URL } from '@/constants/Agree';
import { palette } from '@/constants/Colors';
import { Colors } from '@/constants/Colors';
import { ROUTES } from '@/constants/Routes';
import { getTabBarBottomPadding } from '@/constants/TabBar';
import { useMembershipModal } from '@/contexts/MembershipModalContext';
import { useVersionCheck } from '@/contexts/VersionCheckContext';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useAuthGuard } from '@/hooks/useAuthGuard';
import { useColorScheme } from '@/hooks/useColorScheme';
import { Image } from '@/components/ui/Image';
import type { BadgeDto } from '@/services/badges';
import { reportBadgeShowcaseDisplayed } from '@/services/badges';
import { updateUser, type UserLanguage } from '@/services/users';
@@ -21,7 +25,6 @@ import { Ionicons } from '@expo/vector-icons';
import { useFocusEffect } from '@react-navigation/native';
import dayjs from 'dayjs';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient';
import { useRouter } from 'expo-router';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
@@ -56,6 +59,8 @@ type LanguageOption = {
};
export default function PersonalScreen() {
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme];
const dispatch = useAppDispatch();
const { confirmLogout, confirmDeleteAccount, isLoggedIn, pushIfAuthedElseLogin, ensureLoggedIn } = useAuthGuard();
const { openMembershipModal } = useMembershipModal();
@@ -70,6 +75,11 @@ export default function PersonalScreen() {
const [refreshing, setRefreshing] = useState(false);
const { checkForUpdate, isChecking: isCheckingVersion, updateInfo } = useVersionCheck();
const gradientColors: [string, string] =
theme === 'dark'
? ['#1f2230', '#10131e']
: [colorTokens.backgroundGradientStart, colorTokens.backgroundGradientEnd];
const languageOptions = useMemo<LanguageOption[]>(() => ([
{
code: 'zh' as AppLanguage,
@@ -260,25 +270,6 @@ export default function PersonalScreen() {
}
};
// 数据格式化函数
const formatHeight = () => {
if (userProfile.height == null) return '--';
return `${parseFloat(userProfile.height).toFixed(1)}cm`;
};
const formatWeight = () => {
if (userProfile.weight == null) return '--';
return `${parseFloat(userProfile.weight).toFixed(1)}kg`;
};
const formatAge = () => {
if (!userProfile.birthDate) return '--';
const birthDate = new Date(userProfile.birthDate);
const today = new Date();
const age = today.getFullYear() - birthDate.getFullYear();
return `${age}${t('personal.stats.ageSuffix')}`;
};
// 显示名称
const displayName = (userProfile.name?.trim()) ? userProfile.name : DEFAULT_MEMBER_NAME;
const profileActionLabel = isLoggedIn ? t('personal.edit') : t('personal.login');
@@ -369,25 +360,6 @@ export default function PersonalScreen() {
</View>
);
const MembershipBanner = () => (
<View style={styles.sectionContainer}>
<TouchableOpacity
activeOpacity={0.9}
onPress={() => {
void handleMembershipPress();
}}
>
<Image
source={{ uri: 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/banner/vip2.png' }}
style={styles.membershipBannerImage}
contentFit="cover"
transition={200}
cachePolicy="memory-disk"
/>
</TouchableOpacity>
</View>
);
const VipMembershipCard = () => {
const fallbackProfile = userProfile as Record<string, unknown>;
const fallbackExpire = ['membershipExpiration', 'vipExpiredAt', 'vipExpiresAt', 'vipExpireDate']
@@ -454,27 +426,33 @@ export default function PersonalScreen() {
);
};
// 数据统计部分
const StatsSection = () => (
// 健康档案入口组件
const HealthProfileEntry = () => (
<View style={styles.sectionContainer}>
<View style={[styles.cardContainer, {
backgroundColor: 'transparent'
}]}>
<View style={styles.statsContainer}>
<View style={styles.statItem}>
<Text style={styles.statValue}>{formatHeight()}</Text>
<Text style={styles.statLabel}>{t('personal.stats.height')}</Text>
<TouchableOpacity
style={styles.healthProfileCard}
activeOpacity={0.9}
onPress={() => router.push(ROUTES.HEALTH_PROFILE)}
>
<LinearGradient
colors={['#FFFFFF', '#F0F4FF']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.healthProfileGradient}
>
<View style={styles.healthProfileContent}>
<View style={styles.healthProfileLeft}>
<View style={styles.healthProfileTitleRow}>
<Text style={styles.healthProfileTitle}>{t('personal.healthProfile.title') || '健康档案'}</Text>
</View>
<Text style={styles.healthProfileSubtitle}>{t('personal.healthProfile.subtitle') || '管理您的个人健康数据与家庭档案'}</Text>
</View>
<View style={styles.healthProfileRight}>
<Ionicons name="chevron-forward" size={20} color="#9CA3AF" />
</View>
</View>
<View style={styles.statItem}>
<Text style={styles.statValue}>{formatWeight()}</Text>
<Text style={styles.statLabel}>{t('personal.stats.weight')}</Text>
</View>
<View style={styles.statItem}>
<Text style={styles.statValue}>{formatAge()}</Text>
<Text style={styles.statLabel}>{t('personal.stats.age')}</Text>
</View>
</View>
</View>
</LinearGradient>
</TouchableOpacity>
</View>
);
@@ -793,15 +771,13 @@ export default function PersonalScreen() {
);
return (
<View style={styles.container}>
<StatusBar barStyle={'dark-content'} backgroundColor="transparent" translucent />
<View style={[styles.container, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}>
<StatusBar barStyle={theme === 'dark' ? 'light-content' : 'dark-content'} backgroundColor="transparent" translucent />
{/* 背景渐变 */}
<LinearGradient
colors={[palette.purple[100], '#F5F5F5']}
start={{ x: 1, y: 0 }}
end={{ x: 0.3, y: 0.4 }}
style={styles.gradientBackground}
colors={gradientColors}
style={StyleSheet.absoluteFillObject}
/>
<ScrollView
@@ -823,8 +799,8 @@ export default function PersonalScreen() {
}
>
<UserHeader />
{userProfile.isVip ? <VipMembershipCard /> : <MembershipBanner />}
<StatsSection />
{userProfile.isVip ? <VipMembershipCard /> : <MembershipBanner onPress={() => void handleMembershipPress()} />}
<HealthProfileEntry />
<BadgesPreviewSection />
<View style={styles.fishRecordContainer}>
{/* <Image
@@ -855,14 +831,6 @@ export default function PersonalScreen() {
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#F5F5F5',
},
gradientBackground: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
height: '60%',
},
scrollView: {
flex: 1,
@@ -889,11 +857,6 @@ const styles = StyleSheet.create({
elevation: 2,
overflow: 'hidden',
},
membershipBannerImage: {
width: '100%',
height: 180,
borderRadius: 16,
},
vipCard: {
borderRadius: 20,
padding: 20,
@@ -1315,4 +1278,60 @@ const styles = StyleSheet.create({
color: '#9370DB',
fontFamily: 'AliBold',
},
// 健康档案入口样式
healthProfileCard: {
borderRadius: 16,
overflow: 'hidden',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.05,
shadowRadius: 8,
elevation: 2,
backgroundColor: '#FFFFFF',
},
healthProfileGradient: {
padding: 16,
},
healthProfileContent: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
healthProfileLeft: {
flex: 1,
marginRight: 16,
},
healthProfileTitleRow: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 6,
},
healthProfileTitle: {
fontSize: 16,
fontWeight: 'bold',
color: '#1F2937',
marginRight: 8,
fontFamily: 'AliBold',
},
healthStatusBadge: {
backgroundColor: '#ECFDF5',
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 10,
borderWidth: 1,
borderColor: '#A7F3D0',
},
healthStatusText: {
fontSize: 10,
color: '#059669',
fontWeight: '600',
},
healthProfileSubtitle: {
fontSize: 12,
color: '#6B7280',
fontFamily: 'AliRegular',
},
healthProfileRight: {
justifyContent: 'center',
},
});

View File

@@ -1,11 +1,14 @@
import { BasalMetabolismCard } from '@/components/BasalMetabolismCard';
import { DateSelector } from '@/components/DateSelector';
import { FitnessRingsCard } from '@/components/FitnessRingsCard';
import { MenstrualCycleCard } from '@/components/MenstrualCycleCard';
import { MoodCard } from '@/components/MoodCard';
import { NutritionRadarCard } from '@/components/NutritionRadarCard';
import CircumferenceCard from '@/components/statistic/CircumferenceCard';
import OxygenSaturationCard from '@/components/statistic/OxygenSaturationCard';
import SleepCard from '@/components/statistic/SleepCard';
import SunlightCard from '@/components/statistic/SunlightCard';
import WristTemperatureCard from '@/components/statistic/WristTemperatureCard';
import StepsCard from '@/components/StepsCard';
import { StressMeter } from '@/components/StressMeter';
import WaterIntakeCard from '@/components/WaterIntakeCard';
@@ -14,7 +17,7 @@ import { WorkoutSummaryCard } from '@/components/WorkoutSummaryCard';
import { Colors } from '@/constants/Colors';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useAuthGuard } from '@/hooks/useAuthGuard';
import { syncHealthKitToServer } from '@/services/healthKitSync';
import { syncDailyHealthReport, syncHealthKitToServer } from '@/services/healthKitSync';
import { setHealthData } from '@/store/healthSlice';
import { fetchDailyMoodCheckins, selectLatestMoodRecordByDate } from '@/store/moodSlice';
import { updateUserProfile } from '@/store/userSlice';
@@ -22,13 +25,14 @@ import { fetchTodayWaterStats } from '@/store/waterSlice';
import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date';
import { fetchHealthDataForDate } from '@/utils/health';
import { logger } from '@/utils/logger';
import { DEFAULT_CARD_ORDER, getStatisticsCardOrder, getStatisticsCardsVisibility, StatisticsCardsVisibility } from '@/utils/userPreferences';
import { Ionicons } from '@expo/vector-icons';
import dayjs from 'dayjs';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { LinearGradient } from 'expo-linear-gradient';
import { useRouter } from 'expo-router';
import { useFocusEffect, useRouter } from 'expo-router';
import { debounce } from 'lodash';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
AppState,
@@ -64,7 +68,9 @@ export default function ExploreScreen() {
const { t } = useTranslation();
const stepGoal = useAppSelector((s) => s.user.profile?.dailyStepsGoal) ?? 2000;
const userProfile = useAppSelector((s) => s.user.profile);
const todayWaterStats = useAppSelector((s) => s.water.todayStats);
const { pushIfAuthedElseLogin, isLoggedIn, ensureLoggedIn } = useAuthGuard();
const router = useRouter();
@@ -88,9 +94,51 @@ export default function ExploreScreen() {
router.push('/gallery');
}, [ensureLoggedIn, router]);
const handleOpenCustomization = React.useCallback(() => {
router.push('/statistics-customization');
}, [router]);
// 用于触发动画重置的 token当日期或数据变化时更新
const [animToken, setAnimToken] = useState(0);
// 首页卡片显示设置
const [cardVisibility, setCardVisibility] = useState<StatisticsCardsVisibility>({
showMood: true,
showSteps: true,
showStress: true,
showSleep: true,
showFitnessRings: true,
showWater: true,
showBasalMetabolism: true,
showOxygenSaturation: true,
showWristTemperature: true,
showMenstrualCycle: true,
showWeight: true,
showCircumference: true,
showSunlight: true,
});
const [cardOrder, setCardOrder] = useState<string[]>(DEFAULT_CARD_ORDER);
// 加载卡片设置
const loadSettings = useCallback(async () => {
try {
const [visibility, order] = await Promise.all([
getStatisticsCardsVisibility(),
getStatisticsCardOrder(),
]);
setCardVisibility(visibility);
setCardOrder(order);
} catch (error) {
console.error('Failed to load card settings:', error);
}
}, []);
// 页面聚焦时加载设置
useFocusEffect(
useCallback(() => {
loadSettings();
}, [loadSettings])
);
// 心情相关状态
const dispatch = useAppDispatch();
@@ -293,6 +341,7 @@ export default function ExploreScreen() {
try {
logger.info('开始同步 HealthKit 个人健康数据到服务端...');
// 1. 同步个人资料 (身高、体重、出生日期)
// 传入当前用户资料,用于 diff 比较
const success = await syncHealthKitToServer(
async (data) => {
@@ -302,20 +351,36 @@ export default function ExploreScreen() {
);
if (success) {
logger.info('HealthKit 数据同步到服务端成功');
logger.info('HealthKit 个人资料同步到服务端成功');
} else {
logger.info('HealthKit 数据同步到服务端跳过(无变化)或失败');
logger.info('HealthKit 个人资料同步到服务端跳过(无变化)或失败');
}
// 2. 同步每日健康数据报表 (活动、睡眠、心率等)
// 传入今日饮水量
const waterIntake = todayWaterStats?.totalAmount;
logger.info('开始同步每日健康数据报表...', { waterIntake });
const reportSuccess = await syncDailyHealthReport(waterIntake);
if (reportSuccess) {
logger.info('每日健康数据报表同步成功');
} else {
logger.info('每日健康数据报表同步跳过(无变化)或失败');
}
} catch (error) {
logger.error('同步 HealthKit 数据到服务端失败:', error);
}
}, [isLoggedIn, dispatch, userProfile]);
}, [isLoggedIn, dispatch, userProfile, todayWaterStats]);
// 初始加载时执行数据加载和同步
useEffect(() => {
loadAllData(currentSelectedDate);
// 延迟1秒后执行同步避免影响初始加载性能
// 如果 todayWaterStats 还未加载完成,可能会导致第一次同步时 waterIntake 为 undefined
// 但 waterSlice.fetchTodayWaterStats 会在 loadAllData 中被调用
const syncTimer = setTimeout(() => {
syncHealthDataToServer();
}, 1000);
@@ -382,7 +447,7 @@ export default function ExploreScreen() {
style={styles.scrollView}
contentContainerStyle={{
paddingTop: insets.top,
paddingBottom: 60,
paddingBottom: 100,
paddingHorizontal: 20
}}
showsVerticalScrollIndicator={false}
@@ -404,6 +469,26 @@ export default function ExploreScreen() {
</View>
<View style={styles.headerActions}>
<TouchableOpacity
activeOpacity={0.85}
onPress={handleOpenCustomization}
>
{isLiquidGlassAvailable() ? (
<GlassView
style={styles.liquidGlassButton}
glassEffectStyle="regular"
tintColor="rgba(255, 255, 255, 0.3)"
isInteractive={true}
>
<Ionicons name="options-outline" size={20} color="#0F172A" />
</GlassView>
) : (
<View style={[styles.liquidGlassButton, styles.liquidGlassFallback]}>
<Ionicons name="options-outline" size={20} color="#0F172A" />
</View>
)}
</TouchableOpacity>
<TouchableOpacity
activeOpacity={0.85}
onPress={handleOpenGallery}
@@ -457,90 +542,192 @@ export default function ExploreScreen() {
<Text style={styles.sectionTitle}>{t('statistics.sections.bodyMetrics')}</Text>
</View>
{/* 真正瀑布流布局 */}
<View style={styles.masonryContainer}>
{/* 左列 */}
<View style={styles.masonryColumn}>
{/* 心情卡片 */}
<FloatingCard style={styles.masonryCard}>
<MoodCard
moodCheckin={currentMoodCheckin}
onPress={() => pushIfAuthedElseLogin('/mood/calendar')}
isLoading={isMoodLoading}
/>
</FloatingCard>
{/* 动态布局:支持混合瀑布流和全宽卡片 */}
<View style={styles.layoutContainer}>
{(() => {
// 定义所有卡片及其显示状态
const allCardsMap: Record<string, any> = {
mood: {
visible: cardVisibility.showMood,
component: (
<MoodCard
moodCheckin={currentMoodCheckin}
onPress={() => pushIfAuthedElseLogin('/mood/calendar')}
isLoading={isMoodLoading}
/>
)
},
steps: {
visible: cardVisibility.showSteps,
component: (
<StepsCard
curDate={currentSelectedDate}
stepGoal={stepGoal}
style={styles.stepsCardOverride}
/>
)
},
stress: {
visible: cardVisibility.showStress,
component: (
<StressMeter
curDate={currentSelectedDate}
/>
)
},
sleep: {
visible: cardVisibility.showSleep,
component: (
<SleepCard
selectedDate={currentSelectedDate}
/>
)
},
sunlight: {
visible: cardVisibility.showSunlight,
component: (
<SunlightCard
selectedDate={currentSelectedDate}
style={styles.basalMetabolismCardOverride}
/>
)
},
fitness: {
visible: cardVisibility.showFitnessRings,
component: (
<FitnessRingsCard
selectedDate={currentSelectedDate}
resetToken={animToken}
/>
)
},
water: {
visible: cardVisibility.showWater,
component: (
<WaterIntakeCard
selectedDate={currentSelectedDateString}
style={styles.waterCardOverride}
/>
)
},
metabolism: {
visible: cardVisibility.showBasalMetabolism,
component: (
<BasalMetabolismCard
selectedDate={currentSelectedDate}
style={styles.basalMetabolismCardOverride}
/>
)
},
oxygen: {
visible: cardVisibility.showOxygenSaturation,
component: (
<OxygenSaturationCard
selectedDate={currentSelectedDate}
style={styles.basalMetabolismCardOverride}
/>
)
},
temperature: {
visible: cardVisibility.showWristTemperature,
component: (
<WristTemperatureCard
selectedDate={currentSelectedDate}
style={styles.basalMetabolismCardOverride}
/>
)
},
menstrual: {
visible: cardVisibility.showMenstrualCycle,
component: (
<MenstrualCycleCard
onPress={() => pushIfAuthedElseLogin('/menstrual-cycle')}
/>
)
},
weight: {
visible: cardVisibility.showWeight,
isFullWidth: true,
component: (
<WeightHistoryCard />
)
},
circumference: {
visible: cardVisibility.showCircumference,
isFullWidth: true,
component: (
<CircumferenceCard style={{ marginBottom: 0, marginTop: 16 }} />
)
}
};
<FloatingCard style={styles.masonryCard}>
<StepsCard
curDate={currentSelectedDate}
stepGoal={stepGoal}
style={styles.stepsCardOverride}
/>
</FloatingCard>
const allKeys = Object.keys(allCardsMap);
const sortedKeys = Array.from(new Set([...cardOrder, ...allKeys]))
.filter(key => allCardsMap[key]);
const visibleCards = sortedKeys
.map(key => ({ id: key, ...allCardsMap[key] }))
.filter(card => card.visible);
<FloatingCard style={styles.masonryCard}>
<StressMeter
curDate={currentSelectedDate}
/>
</FloatingCard>
// 分组逻辑:将连续的瀑布流卡片聚合,全宽卡片单独作为一组
const blocks: any[] = [];
let currentMasonryBlock: any[] = [];
{/* 心率卡片 */}
{/* <FloatingCard style={styles.masonryCard} delay={2000}>
<HeartRateCard
resetToken={animToken}
style={styles.basalMetabolismCardOverride}
heartRate={heartRate}
/>
</FloatingCard> */}
visibleCards.forEach(card => {
if (card.isFullWidth) {
// 如果有未处理的瀑布流卡片,先结算
if (currentMasonryBlock.length > 0) {
blocks.push({ type: 'masonry', items: [...currentMasonryBlock] });
currentMasonryBlock = [];
}
// 添加全宽卡片
blocks.push({ type: 'full', item: card });
} else {
// 添加到当前瀑布流组
currentMasonryBlock.push(card);
}
});
<FloatingCard style={styles.masonryCard}>
<SleepCard
selectedDate={currentSelectedDate}
/>
</FloatingCard>
</View>
// 结算剩余的瀑布流卡片
if (currentMasonryBlock.length > 0) {
blocks.push({ type: 'masonry', items: [...currentMasonryBlock] });
}
{/* 右列 */}
<View style={styles.masonryColumn}>
<FloatingCard style={styles.masonryCard}>
<FitnessRingsCard
selectedDate={currentSelectedDate}
resetToken={animToken}
/>
</FloatingCard>
{/* 饮水记录卡片 */}
<FloatingCard style={styles.masonryCard}>
<WaterIntakeCard
selectedDate={currentSelectedDateString}
style={styles.waterCardOverride}
/>
</FloatingCard>
return blocks.map((block, blockIndex) => {
if (block.type === 'full') {
return (
<View key={`block-${blockIndex}-${block.item.id}`}>
{block.item.component}
</View>
);
} else {
// 渲染瀑布流块
const leftColumnCards = block.items.filter((_: any, index: number) => index % 2 === 0);
const rightColumnCards = block.items.filter((_: any, index: number) => index % 2 !== 0);
{/* 基础代谢卡片 */}
<FloatingCard style={styles.masonryCard}>
<BasalMetabolismCard
selectedDate={currentSelectedDate}
style={styles.basalMetabolismCardOverride}
/>
</FloatingCard>
{/* 血氧饱和度卡片 */}
<FloatingCard style={styles.masonryCard}>
<OxygenSaturationCard
selectedDate={currentSelectedDate}
style={styles.basalMetabolismCardOverride}
/>
</FloatingCard>
</View>
return (
<View key={`block-${blockIndex}-masonry`} style={styles.masonryContainer}>
<View style={styles.masonryColumn}>
{leftColumnCards.map((card: any) => (
<FloatingCard key={card.id} style={styles.masonryCard}>
{card.component}
</FloatingCard>
))}
</View>
<View style={styles.masonryColumn}>
{rightColumnCards.map((card: any) => (
<FloatingCard key={card.id} style={styles.masonryCard}>
{card.component}
</FloatingCard>
))}
</View>
</View>
);
}
});
})()}
</View>
<WeightHistoryCard />
{/* 围度数据卡片 - 占满底部一行 */}
<CircumferenceCard style={styles.circumferenceCard} />
</ScrollView>
</View>
@@ -634,9 +821,6 @@ const styles = StyleSheet.create({
shadowRadius: 4,
elevation: 3,
},
hrvTestButton: {
backgroundColor: '#8B5CF6',
},
debugButtonText: {
fontSize: 12,
fontFamily: 'AliRegular',
@@ -852,6 +1036,9 @@ const styles = StyleSheet.create({
justifyContent: 'space-between',
marginBottom: 16,
},
layoutContainer: {
flex: 1,
},
masonryContainer: {
flexDirection: 'row',
gap: 16,

View File

@@ -1,14 +1,7 @@
import '@/i18n';
import { DefaultTheme, ThemeProvider } from '@react-navigation/native';
import { useFonts } from 'expo-font';
import { Stack, useRouter } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import 'react-native-reanimated';
import PrivacyConsentModal from '@/components/PrivacyConsentModal';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useQuickActions } from '@/hooks/useQuickActions';
import '@/i18n';
import { hrvMonitorService } from '@/services/hrvMonitor';
import { cleanupLegacyMedicationNotifications } from '@/services/medicationNotificationCleanup';
import { clearBadgeCount, notificationService } from '@/services/notifications';
@@ -26,8 +19,14 @@ import { initializeHealthPermissions } from '@/utils/health';
import { MoodNotificationHelpers, NutritionNotificationHelpers, WaterNotificationHelpers } from '@/utils/notificationHelpers';
import { getMoodReminderEnabled, getNutritionReminderEnabled, getWaterReminderSettings } from '@/utils/userPreferences';
import { clearPendingWaterRecords, syncPendingWidgetChanges } from '@/utils/widgetDataSync';
import { DefaultTheme, ThemeProvider } from '@react-navigation/native';
import { useFonts } from 'expo-font';
import { Stack, useRouter } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import React, { useEffect } from 'react';
import { AppState, AppStateStatus } from 'react-native';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import 'react-native-reanimated';
import { DialogProvider } from '@/components/ui/DialogProvider';
import { MembershipModalProvider } from '@/contexts/MembershipModalContext';
@@ -485,6 +484,51 @@ function Bootstrapper({ children }: { children: React.ReactNode }) {
getPrivacyAgreed();
}, []);
// 使用 ref 确保更新检查只执行一次
// const updateCheckRequestedRef = React.useRef(false);
// useEffect(() => {
// // 如果已经执行过更新检查,直接返回
// if (updateCheckRequestedRef.current) {
// return;
// }
// updateCheckRequestedRef.current = true;
// async function checkUpdate() {
// try {
// logger.info(`Checking for updates..., env: ${__DEV__}, Updates.isEnabled: ${Updates.isEnabled}`);
// // 只有在 expo-updates 启用时才检查更新
// // 开发环境或 Updates 未启用时跳过
// if (__DEV__ || !Updates.isEnabled) {
// logger.info('Skipping update check: dev mode or updates not enabled');
// return;
// }
// const update = await Updates.checkForUpdateAsync();
// logger.info("Update check:", update);
// if (update.isAvailable) {
// logger.info("Update available, fetching...");
// const result = await Updates.fetchUpdateAsync();
// logger.info("Fetch result:", result);
// if (result.isNew) {
// logger.info("Reloading app to apply update...");
// await Updates.reloadAsync();
// }
// }
// } catch (e) {
// logger.error("Update error:", e);
// }
// }
// setTimeout(() => {
// checkUpdate();
// }, 5000);
// }, []);
const handlePrivacyAgree = () => {
dispatch(setPrivacyAgreed());
setShowPrivacyModal(false);

View File

@@ -1,14 +1,14 @@
import { BadgeShowcaseModal } from '@/components/badges/BadgeShowcaseModal';
import { HeaderBar } from '@/components/ui/HeaderBar';
import { Image } from '@/components/ui/Image';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import type { BadgeDto } from '@/services/badges';
import { fetchAvailableBadges, selectBadgesLoading, selectSortedBadges } from '@/store/badgesSlice';
import { DEFAULT_MEMBER_NAME, selectUserProfile } from '@/store/userSlice';
import { BadgeShowcaseModal } from '@/components/badges/BadgeShowcaseModal';
import { Toast } from '@/utils/toast.utils';
import { Ionicons } from '@expo/vector-icons';
import { useFocusEffect } from '@react-navigation/native';
import * as Haptics from 'expo-haptics';
import { Image } from 'expo-image';
import React, { useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { FlatList, Pressable, RefreshControl, StyleSheet, Text, View } from 'react-native';

View File

@@ -1,6 +1,7 @@
import ChallengeProgressCard from '@/components/challenges/ChallengeProgressCard';
import { ChallengeRankingItem } from '@/components/challenges/ChallengeRankingItem';
import { HeaderBar } from '@/components/ui/HeaderBar';
import { Image } from '@/components/ui/Image';
import { Colors } from '@/constants/Colors';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useAuthGuard } from '@/hooks/useAuthGuard';
@@ -33,7 +34,6 @@ import { BlurView } from 'expo-blur';
import * as Clipboard from 'expo-clipboard';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import * as Haptics from 'expo-haptics';
import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient';
import { useLocalSearchParams, useRouter } from 'expo-router';
import LottieView from 'lottie-react-native';

View File

@@ -1,8 +1,8 @@
import { Image } from '@/components/ui/Image';
import i18n from '@/i18n';
import dayjs from 'dayjs';
import { BlurView } from 'expo-blur';
import * as Clipboard from 'expo-clipboard';
import { Image } from 'expo-image';
import * as ImagePicker from 'expo-image-picker';
import { LinearGradient } from 'expo-linear-gradient';
import { useLocalSearchParams, useRouter } from 'expo-router';

View File

@@ -18,6 +18,7 @@ import {
import Animated, { FadeInDown, FadeInUp, Layout } from 'react-native-reanimated';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Image } from '@/components/ui/Image';
import { Colors } from '@/constants/Colors';
import { getTabBarBottomPadding } from '@/constants/TabBar';
import { useAppSelector } from '@/hooks/redux';
@@ -29,7 +30,6 @@ import { loadAiCoachSessionCache, saveAiCoachSessionCache } from '@/services/aiC
import { api, getAuthToken, postTextStream } from '@/services/api';
import { selectLatestMoodRecordByDate } from '@/store/moodSlice';
import { generateWelcomeMessage, hasRecordedMoodToday } from '@/utils/welcomeMessage';
import { Image } from 'expo-image';
import { HistoryModal } from '../components/model/HistoryModal';
import { ActionSheet } from '../components/ui/ActionSheet';

View File

@@ -1,6 +1,7 @@
import { CreateCustomFoodModal, type CustomFoodData } from '@/components/model/food/CreateCustomFoodModal';
import { FoodDetailModal } from '@/components/model/food/FoodDetailModal';
import { HeaderBar } from '@/components/ui/HeaderBar';
import { Image } from '@/components/ui/Image';
import { Colors } from '@/constants/Colors';
import { DEFAULT_IMAGE_FOOD } from '@/constants/Image';
import { useAppDispatch } from '@/hooks/redux';
@@ -13,7 +14,6 @@ import { fetchDailyNutritionData } from '@/store/nutritionSlice';
import type { FoodItem, MealType, SelectedFoodItem } from '@/types/food';
import { saveNutritionToHealthKit } from '@/utils/health';
import { Ionicons } from '@expo/vector-icons';
import { Image } from 'expo-image';
import { useLocalSearchParams, useRouter } from 'expo-router';
import React, { useEffect, useMemo, useState } from 'react';
import {

View File

@@ -1,5 +1,6 @@
import { CircularRing } from '@/components/CircularRing';
import { HeaderBar } from '@/components/ui/HeaderBar';
import { Image } from '@/components/ui/Image';
import { Colors } from '@/constants/Colors';
import { ROUTES } from '@/constants/Routes';
import { useAppSelector } from '@/hooks/redux';
@@ -9,7 +10,6 @@ import { addDietRecord, type CreateDietRecordDto, type MealType } from '@/servic
import { selectFoodRecognitionResult } from '@/store/foodRecognitionSlice';
import { Ionicons } from '@expo/vector-icons';
import dayjs from 'dayjs';
import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient';
import { useLocalSearchParams, useRouter } from 'expo-router';
import React, { useEffect, useState } from 'react';

View File

@@ -1,4 +1,5 @@
import { HeaderBar } from '@/components/ui/HeaderBar';
import { Image } from '@/components/ui/Image';
import { Colors } from '@/constants/Colors';
import { useAuthGuard } from '@/hooks/useAuthGuard';
import { useColorScheme } from '@/hooks/useColorScheme';
@@ -6,7 +7,6 @@ import { useI18n } from '@/hooks/useI18n';
import { Ionicons } from '@expo/vector-icons';
import { CameraType, CameraView, useCameraPermissions } from 'expo-camera';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { Image } from 'expo-image';
import * as ImagePicker from 'expo-image-picker';
import { LinearGradient } from 'expo-linear-gradient';
import { useLocalSearchParams, useRouter } from 'expo-router';

View File

@@ -1,4 +1,5 @@
import { HeaderBar } from '@/components/ui/HeaderBar';
import { Image } from '@/components/ui/Image';
import { Colors } from '@/constants/Colors';
import { useMembershipModal } from '@/contexts/MembershipModalContext';
import { useAppDispatch } from '@/hooks/redux';
@@ -11,7 +12,6 @@ import { recognizeFood } from '@/services/foodRecognition';
import { saveRecognitionResult, setError, setLoading } from '@/store/foodRecognitionSlice';
import { Ionicons } from '@expo/vector-icons';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient';
import { useLocalSearchParams, useRouter } from 'expo-router';
import React, { useEffect, useState } from 'react';

View File

@@ -1,4 +1,5 @@
import { HeaderBar } from '@/components/ui/HeaderBar';
import { Image } from '@/components/ui/Image';
import { Colors } from '@/constants/Colors';
import { useI18n } from '@/hooks/useI18n';
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
@@ -13,7 +14,6 @@ import { triggerLightHaptic } from '@/utils/haptics';
import { Ionicons } from '@expo/vector-icons';
import dayjs from 'dayjs';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient';
import { useRouter } from 'expo-router';
import React, { useCallback, useEffect, useState } from 'react';

View File

@@ -1,4 +1,5 @@
import { HeaderBar } from '@/components/ui/HeaderBar';
import { Image } from '@/components/ui/Image';
import { Colors } from '@/constants/Colors';
import { useAuthGuard } from '@/hooks/useAuthGuard';
import { useCosUpload } from '@/hooks/useCosUpload';
@@ -12,7 +13,6 @@ import { triggerLightHaptic } from '@/utils/haptics';
import { Ionicons } from '@expo/vector-icons';
import dayjs from 'dayjs';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { Image } from 'expo-image';
import * as ImagePicker from 'expo-image-picker';
import { LinearGradient } from 'expo-linear-gradient';
import { useRouter } from 'expo-router';

View File

@@ -1,4 +1,5 @@
import { HeaderBar } from '@/components/ui/HeaderBar';
import { Image as ExpoImage } from '@/components/ui/Image';
import { useMembershipModal } from '@/contexts/MembershipModalContext';
import { useAuthGuard } from '@/hooks/useAuthGuard';
import { useVipService } from '@/hooks/useVipService';
@@ -9,7 +10,6 @@ import { Ionicons } from '@expo/vector-icons';
import dayjs from 'dayjs';
import * as FileSystem from 'expo-file-system/legacy';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { Image as ExpoImage } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient';
import * as MediaLibrary from 'expo-media-library';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';

View File

@@ -0,0 +1,620 @@
import { HeaderBar } from '@/components/ui/HeaderBar';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import {
fetchFamilyGroup,
generateInviteCode,
selectFamilyGroup,
selectFamilyHealthLoading,
selectInviteCode,
selectInviteLoading,
} from '@/store/familyHealthSlice';
import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import { Stack } from 'expo-router';
import React, { useEffect, useState } from 'react';
import {
ActivityIndicator,
Alert,
Modal,
ScrollView,
Share,
StyleSheet,
Text,
TouchableOpacity,
View
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
export default function FamilyInviteScreen() {
const insets = useSafeAreaInsets();
const dispatch = useAppDispatch();
const [agreed, setAgreed] = useState(true);
const [showQRModal, setShowQRModal] = useState(false);
// Redux state
const familyGroup = useAppSelector(selectFamilyGroup);
const inviteCode = useAppSelector(selectInviteCode);
const isLoading = useAppSelector(selectFamilyHealthLoading);
const isInviteLoading = useAppSelector(selectInviteLoading);
// 初始化时获取家庭组信息
useEffect(() => {
dispatch(fetchFamilyGroup());
}, [dispatch]);
// 处理邀请按钮点击
const handleInvite = async () => {
try {
// 生成邀请码
await dispatch(generateInviteCode(24)).unwrap();
// 显示二维码弹窗
setShowQRModal(true);
} catch (error: any) {
Alert.alert('邀请失败', error?.message || '请稍后重试');
}
};
// 分享邀请码
const handleShare = async () => {
if (!inviteCode) return;
try {
await Share.share({
message: `邀请您加入我的家庭健康管理组!\n邀请码${inviteCode.inviteCode}\n有效期至${new Date(inviteCode.expiresAt).toLocaleString()}`,
title: '家庭健康管理邀请',
});
} catch (error) {
console.error('分享失败:', error);
}
};
return (
<View style={styles.container}>
<Stack.Screen options={{ headerShown: false }} />
<HeaderBar title="" transparent />
<LinearGradient
colors={['#Eef2FF', '#F5F3FF', '#FFFFFF']}
style={styles.background}
/>
<ScrollView
contentContainerStyle={[styles.scrollContent, { paddingTop: insets.top + 40 }]}
showsVerticalScrollIndicator={false}
>
{/* Header Title Area */}
<View style={styles.headerSection}>
<Text style={styles.mainTitle}></Text>
<Text style={styles.mainTitle}></Text>
<View style={styles.subtitleBadge}>
<Ionicons name="home" size={12} color="#5B4CFF" />
<Text style={styles.subtitleText}></Text>
</View>
</View>
{/* Hero Image / House Icon Area */}
<View style={styles.heroContainer}>
{/* Floating Labels */}
<View style={[styles.floatingLabel, styles.labelLeft]}>
<Text style={styles.floatingLabelText}></Text>
<View style={styles.dot} />
</View>
<View style={[styles.floatingLabel, styles.labelRight]}>
<View style={styles.dot} />
<Text style={styles.floatingLabelText}></Text>
</View>
{/* Main 3D House Icon Placeholder */}
<View style={styles.houseIconPlaceholder}>
<LinearGradient
colors={['#A78BFA', '#5B4CFF']}
style={styles.houseIconGradient}
>
<Ionicons name="heart" size={60} color="#FFFFFF" />
</LinearGradient>
</View>
</View>
{/* Features Grid */}
<View style={styles.featuresCard}>
<View style={styles.featureItem}>
<View style={[styles.featureIcon, { backgroundColor: '#EEF2FF' }]}>
<Ionicons name="share-social" size={24} color="#5B4CFF" />
</View>
<Text style={styles.featureTitle}></Text>
<Text style={styles.featureDesc}></Text>
</View>
<View style={styles.featureItem}>
<View style={[styles.featureIcon, { backgroundColor: '#FEF2F2' }]}>
<Ionicons name="alert-circle" size={24} color="#EF4444" />
</View>
<Text style={styles.featureTitle}></Text>
<Text style={styles.featureDesc}></Text>
</View>
<View style={styles.featureItem}>
<View style={[styles.featureIcon, { backgroundColor: '#FFF7ED' }]}>
<Ionicons name="medkit" size={24} color="#F97316" />
</View>
<Text style={styles.featureTitle}></Text>
<Text style={styles.featureDesc}></Text>
</View>
</View>
{/* Steps Section */}
<View style={styles.stepsContainer}>
<Text style={styles.stepsTitle}>3</Text>
<View style={styles.stepsSubtitleContainer}>
<Text style={styles.stepsSubtitle}>624</Text>
</View>
<View style={styles.stepsRow}>
<View style={styles.stepItem}>
<Text style={styles.stepNumber}>1</Text>
<Text style={styles.stepDesc}></Text>
<View style={styles.stepPhoneMockup}>
<View style={styles.mockupScreen} />
</View>
</View>
<Ionicons name="chevron-forward" size={20} color="#D1D5DB" style={{ marginTop: 40 }} />
<View style={styles.stepItem}>
<Text style={styles.stepNumber}>2</Text>
<Text style={styles.stepDesc}>App</Text>
<View style={styles.stepPhoneMockup}>
<View style={styles.mockupScreen} />
</View>
</View>
<Ionicons name="chevron-forward" size={20} color="#D1D5DB" style={{ marginTop: 40 }} />
<View style={styles.stepItem}>
<Text style={styles.stepNumber}>3</Text>
<Text style={styles.stepDesc}></Text>
<View style={styles.stepPhoneMockup}>
<View style={styles.mockupScreen} />
</View>
</View>
</View>
</View>
{/* Bottom Spacing */}
<View style={{ height: 120 }} />
</ScrollView>
{/* Bottom Action Area */}
<View style={[styles.bottomArea, { paddingBottom: insets.bottom + 16 }]}>
<TouchableOpacity
style={styles.checkboxRow}
onPress={() => setAgreed(!agreed)}
activeOpacity={0.8}
>
<Ionicons
name={agreed ? "checkmark-circle" : "ellipse-outline"}
size={20}
color={agreed ? "#5B4CFF" : "#9CA3AF"}
/>
<Text style={styles.checkboxText}>
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.inviteButton, (!agreed || isLoading) && styles.inviteButtonDisabled]}
disabled={!agreed || isLoading}
onPress={handleInvite}
>
{isLoading ? (
<ActivityIndicator size="small" color="#FFFFFF" />
) : (
<Text style={styles.inviteButtonText}></Text>
)}
</TouchableOpacity>
</View>
{/* QR Code Modal */}
<Modal
visible={showQRModal}
transparent
animationType="fade"
onRequestClose={() => setShowQRModal(false)}
>
<View style={styles.modalOverlay}>
<View style={styles.modalContent}>
<View style={styles.modalHeader}>
<Text style={styles.modalTitle}></Text>
<TouchableOpacity onPress={() => setShowQRModal(false)}>
<Ionicons name="close" size={24} color="#6B7280" />
</TouchableOpacity>
</View>
{isInviteLoading ? (
<View style={styles.qrContainer}>
<ActivityIndicator size="large" color="#5B4CFF" />
</View>
) : inviteCode ? (
<>
<View style={styles.qrContainer}>
{/* 邀请码大字展示(替代二维码,后续可安装 react-native-qrcode-svg 实现) */}
<View style={styles.inviteCodeDisplay}>
<Ionicons name="qr-code-outline" size={48} color="#5B4CFF" />
<Text style={styles.inviteCodeBig}>{inviteCode.inviteCode}</Text>
<Text style={styles.inviteCodeHint}> App </Text>
</View>
</View>
<View style={styles.inviteCodeContainer}>
<Text style={styles.inviteCodeLabel}></Text>
<Text style={styles.inviteCodeText}>{inviteCode.inviteCode}</Text>
</View>
<Text style={styles.expireText}>
{new Date(inviteCode.expiresAt).toLocaleString()}
</Text>
<TouchableOpacity style={styles.shareButton} onPress={handleShare}>
<Ionicons name="share-outline" size={20} color="#FFFFFF" />
<Text style={styles.shareButtonText}></Text>
</TouchableOpacity>
</>
) : null}
</View>
</View>
</Modal>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#F9FAFB',
},
background: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
},
scrollContent: {
paddingHorizontal: 20,
},
headerSection: {
alignItems: 'center',
marginBottom: 30,
},
mainTitle: {
fontSize: 28,
fontWeight: 'bold',
color: '#1F2937',
lineHeight: 36,
textAlign: 'center',
},
subtitleBadge: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(255,255,255,0.6)',
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 16,
marginTop: 16,
borderWidth: 1,
borderColor: '#FFFFFF',
},
subtitleText: {
fontSize: 12,
color: '#5B4CFF',
marginLeft: 6,
fontWeight: '600',
},
heroContainer: {
alignItems: 'center',
justifyContent: 'center',
height: 180,
marginBottom: 20,
position: 'relative',
},
houseIconPlaceholder: {
width: 140,
height: 140,
alignItems: 'center',
justifyContent: 'center',
},
houseIconGradient: {
width: 100,
height: 100,
borderRadius: 30,
alignItems: 'center',
justifyContent: 'center',
transform: [{ rotate: '45deg' }],
shadowColor: '#5B4CFF',
shadowOffset: { width: 0, height: 10 },
shadowOpacity: 0.3,
shadowRadius: 20,
elevation: 10,
},
floatingLabel: {
position: 'absolute',
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(255,255,255,0.8)',
paddingHorizontal: 10,
paddingVertical: 6,
borderRadius: 12,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.05,
shadowRadius: 4,
elevation: 2,
},
labelLeft: {
left: 0,
top: 40,
},
labelRight: {
right: 0,
top: 20,
},
floatingLabelText: {
fontSize: 12,
color: '#6B7280',
fontWeight: '600',
marginHorizontal: 4,
},
dot: {
width: 6,
height: 6,
borderRadius: 3,
backgroundColor: '#5B4CFF',
},
featuresCard: {
flexDirection: 'row',
backgroundColor: '#FFFFFF',
borderRadius: 20,
padding: 20,
justifyContent: 'space-between',
marginBottom: 24,
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.03,
shadowRadius: 8,
elevation: 2,
},
featureItem: {
flex: 1,
alignItems: 'center',
},
featureIcon: {
width: 48,
height: 48,
borderRadius: 24,
alignItems: 'center',
justifyContent: 'center',
marginBottom: 12,
},
featureTitle: {
fontSize: 14,
fontWeight: 'bold',
color: '#1F2937',
marginBottom: 4,
},
featureDesc: {
fontSize: 10,
color: '#9CA3AF',
textAlign: 'center',
},
stepsContainer: {
backgroundColor: 'rgba(255,255,255,0.6)',
borderRadius: 24,
padding: 20,
paddingBottom: 30,
marginBottom: 20,
},
stepsTitle: {
fontSize: 18,
fontWeight: 'bold',
color: '#1F2937',
textAlign: 'center',
marginBottom: 8,
},
stepsSubtitleContainer: {
backgroundColor: '#F3F4F6',
alignSelf: 'center',
paddingHorizontal: 12,
paddingVertical: 4,
borderRadius: 10,
marginBottom: 24,
},
stepsSubtitle: {
fontSize: 11,
color: '#6B7280',
},
stepsRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
},
stepItem: {
flex: 1,
alignItems: 'center',
},
stepNumber: {
fontSize: 20,
fontWeight: 'bold',
color: '#5B4CFF',
marginBottom: 8,
fontStyle: 'italic',
},
stepDesc: {
fontSize: 12,
color: '#4B5563',
textAlign: 'center',
marginBottom: 12,
height: 32,
},
stepPhoneMockup: {
width: 60,
height: 100,
backgroundColor: '#FFFFFF',
borderRadius: 10,
borderWidth: 2,
borderColor: '#E5E7EB',
padding: 4,
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.05,
shadowRadius: 4,
elevation: 2,
},
mockupScreen: {
flex: 1,
backgroundColor: '#F3F4F6',
borderRadius: 6,
},
bottomArea: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
backgroundColor: '#FFFFFF',
paddingTop: 16,
paddingHorizontal: 20,
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
shadowColor: '#000',
shadowOffset: { width: 0, height: -4 },
shadowOpacity: 0.05,
shadowRadius: 8,
elevation: 10,
},
checkboxRow: {
flexDirection: 'row',
alignItems: 'flex-start',
marginBottom: 16,
paddingHorizontal: 4,
},
checkboxText: {
flex: 1,
marginLeft: 8,
fontSize: 12,
color: '#6B7280',
lineHeight: 18,
},
inviteButton: {
backgroundColor: '#5B4CFF',
borderRadius: 28,
height: 56,
alignItems: 'center',
justifyContent: 'center',
shadowColor: '#5B4CFF',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 4,
},
inviteButtonDisabled: {
backgroundColor: '#C4B5FD',
shadowOpacity: 0,
},
inviteButtonText: {
color: '#FFFFFF',
fontSize: 18,
fontWeight: 'bold',
},
// Modal styles
modalOverlay: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
justifyContent: 'center',
alignItems: 'center',
padding: 20,
},
modalContent: {
width: '100%',
backgroundColor: '#FFFFFF',
borderRadius: 24,
padding: 24,
},
modalHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 24,
},
modalTitle: {
fontSize: 20,
fontWeight: 'bold',
color: '#1F2937',
},
qrContainer: {
alignItems: 'center',
justifyContent: 'center',
padding: 20,
backgroundColor: '#F9FAFB',
borderRadius: 16,
marginBottom: 20,
minHeight: 180,
},
inviteCodeDisplay: {
alignItems: 'center',
justifyContent: 'center',
},
inviteCodeBig: {
fontSize: 36,
fontWeight: 'bold',
color: '#5B4CFF',
letterSpacing: 4,
marginTop: 16,
marginBottom: 8,
},
inviteCodeHint: {
fontSize: 12,
color: '#9CA3AF',
},
inviteCodeContainer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#F3F4F6',
borderRadius: 12,
padding: 12,
marginBottom: 12,
},
inviteCodeLabel: {
fontSize: 14,
color: '#6B7280',
marginRight: 8,
},
inviteCodeText: {
fontSize: 20,
fontWeight: 'bold',
color: '#5B4CFF',
letterSpacing: 2,
},
expireText: {
fontSize: 12,
color: '#9CA3AF',
textAlign: 'center',
marginBottom: 20,
},
shareButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#5B4CFF',
borderRadius: 16,
paddingVertical: 14,
},
shareButtonText: {
color: '#FFFFFF',
fontSize: 16,
fontWeight: '600',
marginLeft: 8,
},
});

617
app/health/profile.tsx Normal file
View File

@@ -0,0 +1,617 @@
import { HealthProgressRing } from '@/components/health/HealthProgressRing';
import { BasicInfoTab } from '@/components/health/tabs/BasicInfoTab';
import { CheckupRecordsTab } from '@/components/health/tabs/CheckupRecordsTab';
import { HealthHistoryTab } from '@/components/health/tabs/HealthHistoryTab';
import { MedicalRecordsTab } from '@/components/health/tabs/MedicalRecordsTab';
import { ConfirmationSheet } from '@/components/ui/ConfirmationSheet';
import { HeaderBar } from '@/components/ui/HeaderBar';
import { Image } from '@/components/ui/Image';
import { Colors } from '@/constants/Colors';
import { ROUTES } from '@/constants/Routes';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useAuthGuard } from '@/hooks/useAuthGuard';
import { useColorScheme } from '@/hooks/useColorScheme';
import { useI18n } from '@/hooks/useI18n';
import {
fetchFamilyGroup,
joinFamilyGroup,
selectFamilyGroup,
} from '@/store/familyHealthSlice';
import {
fetchHealthHistory,
selectHealthHistoryProgress
} from '@/store/healthSlice';
import { DEFAULT_MEMBER_NAME } from '@/store/userSlice';
import { Toast } from '@/utils/toast.utils';
import { Ionicons } from '@expo/vector-icons';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { LinearGradient } from 'expo-linear-gradient';
import { Stack, useRouter } from 'expo-router';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Pressable, ScrollView, StyleSheet, Text, TextInput, TouchableOpacity, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
export default function HealthProfileScreen() {
const router = useRouter();
const insets = useSafeAreaInsets();
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme];
const { t } = useI18n();
const dispatch = useAppDispatch();
const { ensureLoggedIn } = useAuthGuard();
const glassAvailable = isLiquidGlassAvailable();
const [activeTab, setActiveTab] = useState(0);
const [joinModalVisible, setJoinModalVisible] = useState(false);
const [inviteCodeInput, setInviteCodeInput] = useState('');
const [selectedRelationship, setSelectedRelationship] = useState('');
const [isJoining, setIsJoining] = useState(false);
const [joinError, setJoinError] = useState<string | null>(null);
// Redux state
const familyGroup = useAppSelector(selectFamilyGroup);
const medicalRecords = useAppSelector((state) => state.health.medicalRecords);
const records = medicalRecords?.records || [];
const prescriptions = medicalRecords?.prescriptions || [];
// Calculate Medical Records Count
const medicalRecordsCount = useMemo(() => records.length + prescriptions.length, [records, prescriptions]);
// 亲属关系选项
const relationshipOptions = useMemo(() => [
{ key: 'spouse', label: t('familyGroup.relationships.spouse') },
{ key: 'father', label: t('familyGroup.relationships.father') },
{ key: 'mother', label: t('familyGroup.relationships.mother') },
{ key: 'son', label: t('familyGroup.relationships.son') },
{ key: 'daughter', label: t('familyGroup.relationships.daughter') },
{ key: 'grandfather', label: t('familyGroup.relationships.grandfather') },
{ key: 'grandmother', label: t('familyGroup.relationships.grandmother') },
{ key: 'grandson', label: t('familyGroup.relationships.grandson') },
{ key: 'granddaughter', label: t('familyGroup.relationships.granddaughter') },
{ key: 'brother', label: t('familyGroup.relationships.brother') },
{ key: 'sister', label: t('familyGroup.relationships.sister') },
{ key: 'uncle', label: t('familyGroup.relationships.uncle') },
{ key: 'aunt', label: t('familyGroup.relationships.aunt') },
{ key: 'nephew', label: t('familyGroup.relationships.nephew') },
{ key: 'niece', label: t('familyGroup.relationships.niece') },
{ key: 'cousin', label: t('familyGroup.relationships.cousin') },
{ key: 'other', label: t('familyGroup.relationships.other') },
], [t]);
// Mock user data - in a real app this would come from Redux/Context
const userProfile = useAppSelector((state) => state.user.profile);
const displayName = userProfile.name?.trim() ? userProfile.name : DEFAULT_MEMBER_NAME;
const avatarUrl = userProfile.avatar || 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/seal-avatar/2.jpeg';
// 从 Redux 获取健康史进度
const healthHistoryProgress = useAppSelector(selectHealthHistoryProgress);
// Mock health data
const healthData = {
bmi: userProfile.weight && userProfile.height ? (parseFloat(userProfile.weight) / Math.pow(parseFloat(userProfile.height) / 100, 2)).toFixed(1) : '--',
height: userProfile.height ? `${parseFloat(userProfile.height).toFixed(1)}` : '--',
weight: userProfile.weight ? `${parseFloat(userProfile.weight).toFixed(1)}` : '--',
waist: userProfile.waistCircumference ? `${parseFloat(userProfile.waistCircumference.toString()).toFixed(1)}` : '--',
status: '健康状况良好',
statusDesc: '请继续保持良好的生活习惯',
statusMessage: '您的健康状况不错哦~'
};
// Calculate Basic Info completion percentage
const basicInfoProgress = useMemo(() => {
let filledCount = 0;
const totalFields = 3; // height, weight, waist
if (userProfile.height && parseFloat(userProfile.height) > 0) filledCount++;
if (userProfile.weight && parseFloat(userProfile.weight) > 0) filledCount++;
if (userProfile.waistCircumference && parseFloat(userProfile.waistCircumference.toString()) > 0) filledCount++;
return Math.round((filledCount / totalFields) * 100);
}, [userProfile.height, userProfile.weight, userProfile.waistCircumference]);
// 初始化获取家庭组信息和健康史数据
useEffect(() => {
dispatch(fetchFamilyGroup());
dispatch(fetchHealthHistory());
}, [dispatch]);
// 重置弹窗状态
useEffect(() => {
if (!joinModalVisible) {
setInviteCodeInput('');
setSelectedRelationship('');
setJoinError(null);
}
}, [joinModalVisible]);
// 打开加入弹窗
const handleOpenJoin = useCallback(async () => {
const ok = await ensureLoggedIn();
if (!ok) return;
setJoinModalVisible(true);
}, [ensureLoggedIn]);
// 提交加入家庭组
const handleSubmitJoin = useCallback(async () => {
if (isJoining) return;
const ok = await ensureLoggedIn();
if (!ok) return;
const code = inviteCodeInput.trim().toUpperCase();
if (!code) {
setJoinError(t('familyGroup.errors.emptyCode'));
return;
}
if (!selectedRelationship) {
setJoinError(t('familyGroup.errors.emptyRelationship'));
return;
}
// 获取选中关系的显示文本
const relationshipLabel = relationshipOptions.find(r => r.key === selectedRelationship)?.label || selectedRelationship;
setIsJoining(true);
setJoinError(null);
try {
await dispatch(joinFamilyGroup({ inviteCode: code, relationship: relationshipLabel })).unwrap();
await dispatch(fetchFamilyGroup());
setJoinModalVisible(false);
Toast.success(t('familyGroup.success'));
} catch (error) {
const message = typeof error === 'string' ? error : '加入失败,请检查邀请码是否正确';
setJoinError(message);
} finally {
setIsJoining(false);
}
}, [dispatch, ensureLoggedIn, inviteCodeInput, isJoining, selectedRelationship, relationshipOptions, t]);
const gradientColors: [string, string] =
theme === 'dark'
? ['#1f2230', '#10131e']
: [colorTokens.backgroundGradientStart, colorTokens.backgroundGradientEnd];
const tabs = [
t('health.tabs.healthProfile.basicInfo'),
t('health.tabs.healthProfile.healthHistory'),
// t('health.tabs.healthProfile.medicalRecords'),
t('health.tabs.healthProfile.checkupRecords'),
t('health.tabs.healthProfile.medicineBox')
];
const tabIcons = ["person", "time", "folder", "clipboard", "medkit"];
const handleTabPress = (index: number) => {
if (index === 3) {
// Handle Medicine Box tab specially
router.push('/medications/manage-medications');
return;
}
setActiveTab(index);
};
const renderActiveTab = () => {
switch (activeTab) {
case 0:
return <BasicInfoTab healthData={healthData} />;
case 1:
return <HealthHistoryTab />;
case 2:
return <MedicalRecordsTab />;
case 3:
return <CheckupRecordsTab />;
default:
return <BasicInfoTab healthData={healthData} />;
}
};
return (
<View style={[styles.container, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}>
<Stack.Screen options={{ headerShown: false }} />
<LinearGradient colors={gradientColors} style={StyleSheet.absoluteFillObject} />
<HeaderBar
title={t('health.tabs.healthProfile.title')}
transparent
right={
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
{/* 加入家庭组按钮 - 仅在未加入家庭组时显示 */}
{!familyGroup && (
<TouchableOpacity activeOpacity={0.85} onPress={handleOpenJoin} style={{ marginRight: 10 }}>
{glassAvailable ? (
<GlassView
style={styles.joinButtonGlass}
glassEffectStyle="regular"
tintColor="rgba(255,255,255,0.18)"
isInteractive
>
<Text style={styles.joinButtonLabel}></Text>
</GlassView>
) : (
<View style={[styles.joinButtonGlass, styles.joinButtonFallback]}>
<Text style={[styles.joinButtonLabel, { color: colorTokens.text }]}></Text>
</View>
)}
</TouchableOpacity>
)}
<TouchableOpacity style={{ marginRight: 12 }}>
<Ionicons name="settings-outline" size={22} color="#1F2937" />
</TouchableOpacity>
</View>
}
/>
<ScrollView
contentContainerStyle={[styles.scrollContent, { paddingTop: insets.top + 60 }]}
showsVerticalScrollIndicator={false}
>
{/* Top Section with Avatar and Status */}
<View style={styles.topSection}>
<View style={styles.avatarRow}>
<View style={styles.miniAvatarContainer}>
<Image source={{ uri: avatarUrl }} style={styles.miniAvatar} />
<Text style={styles.miniAvatarName}>{displayName}</Text>
</View>
<TouchableOpacity
style={styles.addButton}
onPress={() => router.push(ROUTES.HEALTH_FAMILY_INVITE)}
>
<Ionicons name="add" size={16} color="#6B7280" />
</TouchableOpacity>
</View>
{/* Action Buttons - Replaced with HealthProgressRing */}
<View style={styles.actionButtonsRow}>
<HealthProgressRing
title={t('health.tabs.healthProfile.basicInfo')}
progress={basicInfoProgress}
gradientColors={['#9B8AFB', '#5B4CFF']}
/>
<HealthProgressRing
title={t('health.tabs.healthProfile.healthHistory')}
progress={healthHistoryProgress}
gradientColors={['#E0E7FF', '#C7D2FE']}
label={healthHistoryProgress.toString()}
suffix="%"
/>
<HealthProgressRing
title={t('health.tabs.healthProfile.medicalRecords')}
progress={0}
gradientColors={['#E0E7FF', '#C7D2FE']}
label={medicalRecordsCount.toString()}
suffix="份"
/>
</View>
</View>
{/* Family Invite Banner */}
<TouchableOpacity
style={styles.inviteBanner}
activeOpacity={0.9}
onPress={() => router.push(ROUTES.HEALTH_FAMILY_INVITE)}
>
<View style={styles.inviteContent}>
<View style={styles.inviteIconContainer}>
<Ionicons name="home" size={18} color="#5B4CFF" />
</View>
<Text style={styles.inviteText}>{t('health.tabs.healthProfile.subtitle')}</Text>
<Ionicons name="chevron-forward" size={18} color="#6B7280" />
</View>
</TouchableOpacity>
{/* Tab/Segment Control */}
<View style={styles.segmentControl}>
{tabs.map((tab, index) => (
<TouchableOpacity
key={index}
style={styles.segmentItem}
onPress={() => handleTabPress(index)}
activeOpacity={0.7}
>
<View style={[styles.segmentIconPlaceholder, index === activeTab && styles.segmentIconActive]}>
<Ionicons
name={tabIcons[index] as any}
size={20}
color={index === activeTab ? "#5B4CFF" : "#6B7280"}
/>
</View>
<Text style={[styles.segmentText, index === activeTab && styles.segmentTextActive]}>{tab}</Text>
</TouchableOpacity>
))}
</View>
{/* Active Tab Content */}
{renderActiveTab()}
{/* Privacy Notice Footer */}
<View style={styles.privacyNoticeContainer}>
<View style={styles.privacyIconWrapper}>
<Ionicons name="shield-checkmark" size={16} color="#9CA3AF" />
</View>
<Text style={styles.privacyNoticeText}>
{t('health.tabs.healthProfile.privacyNotice')}
</Text>
</View>
</ScrollView>
{/* 加入家庭组弹窗 */}
<ConfirmationSheet
visible={joinModalVisible}
onClose={() => setJoinModalVisible(false)}
onConfirm={handleSubmitJoin}
title={t('familyGroup.joinTitle')}
description={t('familyGroup.joinDescription')}
confirmText={isJoining ? t('familyGroup.joining') : t('familyGroup.joinButton')}
cancelText={t('familyGroup.cancel')}
loading={isJoining}
content={
<View style={styles.joinModalContent}>
{/* 邀请码输入 */}
<TextInput
style={styles.inviteCodeInput}
placeholder={t('familyGroup.inviteCodePlaceholder')}
placeholderTextColor="#9ca3af"
value={inviteCodeInput}
onChangeText={(text) => setInviteCodeInput(text.toUpperCase())}
autoCapitalize="characters"
autoCorrect={false}
keyboardType="default"
maxLength={12}
/>
{/* 关系选择标签 */}
<Text style={styles.relationshipLabel}>{t('familyGroup.relationshipLabel')}</Text>
{/* 关系选项网格 - 固定高度可滚动 */}
<ScrollView
style={styles.relationshipScrollView}
contentContainerStyle={styles.relationshipGrid}
showsVerticalScrollIndicator={true}
nestedScrollEnabled
keyboardShouldPersistTaps="handled"
>
{relationshipOptions.map((option) => {
const isSelected = selectedRelationship === option.key;
return (
<Pressable
key={option.key}
style={[
styles.relationshipChip,
isSelected && styles.relationshipChipSelected,
]}
onPress={() => setSelectedRelationship(option.key)}
>
<Text
style={[
styles.relationshipChipText,
isSelected && styles.relationshipChipTextSelected,
]}
>
{option.label}
</Text>
</Pressable>
);
})}
</ScrollView>
{/* 错误提示 */}
{joinError && joinModalVisible ? (
<Text style={styles.modalError}>{joinError}</Text>
) : null}
</View>
}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
scrollContent: {
paddingHorizontal: 16,
paddingBottom: 100,
},
topSection: {
marginBottom: 20,
},
avatarRow: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 10,
},
miniAvatarContainer: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#5B4CFF',
paddingVertical: 4,
paddingHorizontal: 4,
paddingRight: 12,
borderRadius: 20,
},
miniAvatar: {
width: 24,
height: 24,
borderRadius: 12,
marginRight: 6,
borderWidth: 1,
borderColor: '#FFF',
},
miniAvatarName: {
color: '#FFF',
fontSize: 12,
fontWeight: 'bold',
},
addButton: {
width: 28,
height: 28,
borderRadius: 14,
backgroundColor: '#FFFFFF',
alignItems: 'center',
justifyContent: 'center',
marginLeft: 8,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.1,
shadowRadius: 2,
elevation: 2,
},
actionButtonsRow: {
flexDirection: 'row',
justifyContent: 'space-around',
marginTop: 24,
marginBottom: 12,
},
inviteBanner: {
backgroundColor: '#FFFFFF',
borderRadius: 20,
padding: 16,
marginBottom: 20,
shadowColor: '#5B4CFF',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.05,
shadowRadius: 8,
elevation: 2,
},
inviteContent: {
flexDirection: 'row',
alignItems: 'center',
},
inviteIconContainer: {
marginRight: 8,
},
inviteText: {
flex: 1,
fontSize: 13,
color: '#1F2138',
fontWeight: '600',
},
segmentControl: {
flexDirection: 'row',
justifyContent: 'space-between',
marginBottom: 16,
paddingHorizontal: 18,
},
segmentItem: {
alignItems: 'center',
},
segmentIconPlaceholder: {
width: 48,
height: 48,
borderRadius: 12,
backgroundColor: '#F3F4F6',
alignItems: 'center',
justifyContent: 'center',
marginBottom: 4,
},
segmentIconActive: {
backgroundColor: '#E0E7FF',
},
segmentText: {
fontSize: 14,
color: '#6B7280',
},
segmentTextActive: {
color: '#5B4CFF',
fontWeight: 'bold',
},
privacyNoticeContainer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 20,
paddingHorizontal: 16,
marginTop: 32,
marginBottom: 16,
},
privacyIconWrapper: {
marginRight: 6,
},
privacyNoticeText: {
fontSize: 12,
color: '#9CA3AF',
textAlign: 'center',
lineHeight: 18,
},
joinButtonGlass: {
paddingHorizontal: 14,
paddingVertical: 8,
borderRadius: 16,
minWidth: 60,
alignItems: 'center',
justifyContent: 'center',
borderWidth: StyleSheet.hairlineWidth,
borderColor: 'rgba(255,255,255,0.45)',
},
joinButtonLabel: {
fontSize: 12,
fontWeight: '700',
color: '#0f1528',
letterSpacing: 0.5,
fontFamily: 'AliBold',
},
joinButtonFallback: {
backgroundColor: 'rgba(255,255,255,0.7)',
},
// 加入家庭组弹窗样式
joinModalContent: {
gap: 12,
},
inviteCodeInput: {
backgroundColor: '#f8fafc',
borderRadius: 14,
paddingHorizontal: 16,
paddingVertical: 14,
fontSize: 18,
fontWeight: '700',
letterSpacing: 2,
color: '#0f1528',
textAlign: 'center',
},
relationshipLabel: {
fontSize: 14,
fontWeight: '600',
color: '#374151',
marginTop: 4,
marginBottom: 2,
},
relationshipScrollView: {
maxHeight: 160,
borderRadius: 12,
backgroundColor: '#fafafa',
},
relationshipGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
padding: 8,
},
relationshipChip: {
paddingHorizontal: 14,
paddingVertical: 8,
borderRadius: 20,
backgroundColor: '#f3f4f6',
borderWidth: 1.5,
borderColor: 'transparent',
},
relationshipChipSelected: {
backgroundColor: '#ede9fe',
borderColor: '#8b5cf6',
},
relationshipChipText: {
fontSize: 14,
color: '#6b7280',
fontWeight: '500',
},
relationshipChipTextSelected: {
color: '#7c3aed',
fontWeight: '600',
},
modalError: {
marginTop: 6,
fontSize: 12,
color: '#ef4444',
},
});

View File

@@ -2,6 +2,7 @@ import { ExpiryDatePickerModal } from '@/components/medications/ExpiryDatePicker
import { ThemedText } from '@/components/ThemedText';
import { ConfirmationSheet } from '@/components/ui/ConfirmationSheet';
import { HeaderBar } from '@/components/ui/HeaderBar';
import { Image } from '@/components/ui/Image';
import InfoCard from '@/components/ui/InfoCard';
import { Colors } from '@/constants/Colors';
import { DOSAGE_UNITS, DOSAGE_VALUES, FORM_OPTIONS } from '@/constants/Medication';
@@ -37,7 +38,6 @@ import { Picker } from '@react-native-picker/picker';
import Voice from '@react-native-voice/voice';
import dayjs from 'dayjs';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { Image } from 'expo-image';
import * as ImagePicker from 'expo-image-picker';
import { LinearGradient } from 'expo-linear-gradient';
import { useLocalSearchParams, useRouter } from 'expo-router';

View File

@@ -1,6 +1,7 @@
import { ThemedText } from '@/components/ThemedText';
import { HeaderBar } from '@/components/ui/HeaderBar';
import { IconSymbol } from '@/components/ui/IconSymbol';
import { Image } from '@/components/ui/Image';
import { Colors } from '@/constants/Colors';
import { DOSAGE_UNITS, FORM_OPTIONS } from '@/constants/Medication';
import { useAppDispatch } from '@/hooks/redux';
@@ -15,7 +16,6 @@ import { Picker } from '@react-native-picker/picker';
import Voice from '@react-native-voice/voice';
import dayjs from 'dayjs';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { Image } from 'expo-image';
import * as ImagePicker from 'expo-image-picker';
import { LinearGradient } from 'expo-linear-gradient';
import { router } from 'expo-router';

View File

@@ -1,5 +1,6 @@
import { MedicationPhotoGuideModal } from '@/components/medications/MedicationPhotoGuideModal';
import { HeaderBar } from '@/components/ui/HeaderBar';
import { Image } from '@/components/ui/Image';
import { Colors } from '@/constants/Colors';
import { useAuthGuard } from '@/hooks/useAuthGuard';
import { useColorScheme } from '@/hooks/useColorScheme';
@@ -10,7 +11,6 @@ import { getItem, setItem } from '@/utils/kvStore';
import { Ionicons } from '@expo/vector-icons';
import { CameraView, useCameraPermissions } from 'expo-camera';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { Image } from 'expo-image';
import * as ImagePicker from 'expo-image-picker';
import { LinearGradient } from 'expo-linear-gradient';
import { router } from 'expo-router';

View File

@@ -1,11 +1,11 @@
import { HeaderBar } from '@/components/ui/HeaderBar';
import { Image } from '@/components/ui/Image';
import { Colors, palette } from '@/constants/Colors';
import { useI18n } from '@/hooks/useI18n';
import { getMedicationRecognitionStatus } from '@/services/medications';
import { MedicationRecognitionTask } from '@/types/medication';
import { Ionicons } from '@expo/vector-icons';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient';
import { router, useLocalSearchParams } from 'expo-router';
import React, { useEffect, useMemo, useRef, useState } from 'react';

View File

@@ -2,6 +2,7 @@ import { ThemedText } from '@/components/ThemedText';
import { ConfirmationSheet } from '@/components/ui/ConfirmationSheet';
import { HeaderBar } from '@/components/ui/HeaderBar';
import { IconSymbol } from '@/components/ui/IconSymbol';
import { Image } from '@/components/ui/Image';
import { Colors } from '@/constants/Colors';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useColorScheme } from '@/hooks/useColorScheme';
@@ -18,7 +19,6 @@ import type { Medication, MedicationForm } from '@/types/medication';
import { useFocusEffect } from '@react-navigation/native';
import dayjs from 'dayjs';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient';
import { router } from 'expo-router';
import React, { useCallback, useMemo, useState } from 'react';

554
app/menstrual-cycle.tsx Normal file
View File

@@ -0,0 +1,554 @@
import dayjs from 'dayjs';
import { LinearGradient } from 'expo-linear-gradient';
import { Stack, useRouter } from 'expo-router';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
FlatList,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native';
import { InlineTip, ITEM_HEIGHT, Legend, MonthBlock } from '@/components/menstrual-cycle';
import { HeaderBar } from '@/components/ui/HeaderBar';
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
import {
deleteMenstrualFlow,
fetchMenstrualFlowSamples,
saveMenstrualFlow
} from '@/utils/health';
import {
buildMenstrualTimeline,
convertHealthKitSamplesToCycleRecords,
CycleRecord,
DEFAULT_PERIOD_LENGTH
} from '@/utils/menstrualCycle';
type TabKey = 'cycle' | 'analysis';
export default function MenstrualCycleScreen() {
const router = useRouter();
const { t, i18n } = useTranslation();
const safeAreaTop = useSafeAreaTop();
const [records, setRecords] = useState<CycleRecord[]>([]);
const [windowConfig, setWindowConfig] = useState({ before: 2, after: 3 });
const locale = i18n.language.startsWith('en') ? 'en' : 'zh';
const monthTitleFormat = t('menstrual.dateFormats.monthTitle', { defaultValue: 'M月' });
const monthSubtitleFormat = t('menstrual.dateFormats.monthSubtitle', { defaultValue: 'YYYY年' });
const weekLabels = useMemo(() => {
const labels = t('menstrual.weekdays', { returnObjects: true }) as string[];
return Array.isArray(labels) && labels.length === 7 ? labels : undefined;
}, [t]);
// 从 HealthKit 拉取当前窗口范围内的经期数据
useEffect(() => {
const loadData = async () => {
// 根据 windowConfig 计算需要拉取的月份区间
const today = dayjs();
const startDate = today.subtract(windowConfig.before, 'month').startOf('month').toDate();
const endDate = today.add(windowConfig.after, 'month').endOf('month').toDate();
const samples = await fetchMenstrualFlowSamples(startDate, endDate);
const convertedRecords = convertHealthKitSamplesToCycleRecords(samples);
setRecords(convertedRecords);
};
loadData();
}, [windowConfig]);
// 根据记录生成时间轴(包含预测周期、易孕期等)
const timeline = useMemo(
() =>
buildMenstrualTimeline({
monthsBefore: windowConfig.before,
monthsAfter: windowConfig.after,
records,
defaultPeriodLength: DEFAULT_PERIOD_LENGTH,
locale,
monthTitleFormat,
monthSubtitleFormat,
}),
[records, windowConfig, locale, monthSubtitleFormat, monthTitleFormat]
);
const [activeTab, setActiveTab] = useState<TabKey>('cycle');
const [selectedDateKey, setSelectedDateKey] = useState(
dayjs().format('YYYY-MM-DD')
);
const listRef = useRef<FlatList>(null);
const offsetRef = useRef(0);
const prependDeltaRef = useRef(0);
const loadingPrevRef = useRef(false);
const hasAutoScrolledRef = useRef(false);
const todayMonthId = useMemo(() => dayjs().format('YYYY-MM'), []);
const selectedInfo = timeline.dayMap[selectedDateKey];
const selectedDate = dayjs(selectedDateKey);
const initialMonthIndex = useMemo(
() => timeline.months.findIndex((month) => month.id === todayMonthId),
[timeline.months, todayMonthId]
);
useEffect(() => {
if (hasAutoScrolledRef.current) return;
if (initialMonthIndex < 0 || !listRef.current) return;
hasAutoScrolledRef.current = true;
offsetRef.current = initialMonthIndex * ITEM_HEIGHT;
requestAnimationFrame(() => {
listRef.current?.scrollToIndex({ index: initialMonthIndex, animated: false });
});
}, [initialMonthIndex]);
// 标记当天为经期开始(包含乐观更新与 HealthKit 同步)
const handleMarkStart = async () => {
if (selectedDate.isAfter(dayjs(), 'day')) return;
// Check if the selected date is already covered
const isCovered = records.some((r) => {
const start = dayjs(r.startDate);
const end = start.add((r.periodLength ?? DEFAULT_PERIOD_LENGTH) - 1, 'day');
return (
(selectedDate.isSame(start, 'day') || selectedDate.isAfter(start, 'day')) &&
(selectedDate.isSame(end, 'day') || selectedDate.isBefore(end, 'day'))
);
});
if (isCovered) return;
// Optimistic Update
const originalRecords = [...records];
setRecords((prev) => {
const updated = [...prev];
// Logic for optimistic UI update (same as original logic)
const prevRecordIndex = updated.findIndex((r) => {
const start = dayjs(r.startDate);
const end = start.add((r.periodLength ?? DEFAULT_PERIOD_LENGTH) - 1, 'day');
return end.add(1, 'day').isSame(selectedDate, 'day');
});
const nextRecordIndex = updated.findIndex((r) => {
return dayjs(r.startDate).subtract(1, 'day').isSame(selectedDate, 'day');
});
if (prevRecordIndex !== -1 && nextRecordIndex !== -1) {
const prevRecord = updated[prevRecordIndex];
const nextRecord = updated[nextRecordIndex];
const newLength =
(prevRecord.periodLength ?? DEFAULT_PERIOD_LENGTH) +
1 +
(nextRecord.periodLength ?? DEFAULT_PERIOD_LENGTH);
updated[prevRecordIndex] = { ...prevRecord, periodLength: newLength };
updated.splice(nextRecordIndex, 1);
} else if (prevRecordIndex !== -1) {
const prevRecord = updated[prevRecordIndex];
updated[prevRecordIndex] = {
...prevRecord,
periodLength: (prevRecord.periodLength ?? DEFAULT_PERIOD_LENGTH) + 1,
};
} else if (nextRecordIndex !== -1) {
const nextRecord = updated[nextRecordIndex];
updated[nextRecordIndex] = {
...nextRecord,
startDate: selectedDate.format('YYYY-MM-DD'),
periodLength: (nextRecord.periodLength ?? DEFAULT_PERIOD_LENGTH) + 1,
};
} else {
const newRecord: CycleRecord = {
startDate: selectedDate.format('YYYY-MM-DD'),
periodLength: 7,
source: 'manual',
};
updated.push(newRecord);
}
return updated.sort((a, b) => dayjs(a.startDate).valueOf() - dayjs(b.startDate).valueOf());
});
try {
// Determine what to save to HealthKit
// If we are merging or extending, we are effectively adding one day of flow
// If we are creating a new record, we default to 7 days
// However, accurate HealthKit logging should be per day.
// The previous UI logic "creates" a 7-day period for a single tap.
// We should replicate this behavior in HealthKit for consistency.
const isNewIsolatedRecord = !records.some((r) => {
const start = dayjs(r.startDate);
const end = start.add((r.periodLength ?? DEFAULT_PERIOD_LENGTH) - 1, 'day');
// Check adjacency
return (
end.add(1, 'day').isSame(selectedDate, 'day') ||
dayjs(r.startDate).subtract(1, 'day').isSame(selectedDate, 'day')
);
});
if (isNewIsolatedRecord) {
// Save 7 days of flow starting from selectedDate
const promises = [];
for (let i = 0; i < 7; i++) {
const date = selectedDate.add(i, 'day');
// Don't save future dates if they exceed today (though logic allows predicting)
// But for flow logging, we usually only log past/present.
// However, UI allows setting a period that might extend slightly?
// Let's stick to the selected date logic.
// Wait, if I tap "Mark Start", it creates a 7 day period.
// Should I write 7 samples? Yes, to match the UI state.
promises.push(saveMenstrualFlow(date.toDate(), 1, i === 0)); // 1=unspecified
}
await Promise.all(promises);
} else {
// Just adding a single day to bridge/extend
await saveMenstrualFlow(selectedDate.toDate(), 1, false);
}
} catch (error) {
console.error('Failed to save to HealthKit', error);
// Revert optimistic update
setRecords(originalRecords);
}
};
// 取消选中日期的经期标记(与 HealthKit 同步)
const handleCancelMark = async () => {
if (!selectedInfo || !selectedInfo.confirmed) return;
if (selectedDate.isAfter(dayjs(), 'day')) return;
const target = selectedDate;
// Optimistic Update
const originalRecords = [...records];
setRecords((prev) => {
const updated: CycleRecord[] = [];
prev.forEach((record) => {
const start = dayjs(record.startDate);
const periodLength = record.periodLength ?? DEFAULT_PERIOD_LENGTH;
const diff = target.diff(start, 'day');
if (diff < 0 || diff >= periodLength) {
updated.push(record);
return;
}
if (diff === 0) return; // Remove entire record (or start of it)
updated.push({ ...record, periodLength: diff }); // Shorten it
});
return updated;
});
try {
// Logic:
// 1. Find the record covering the target date
const record = records.find((r) => {
const start = dayjs(r.startDate);
const end = start.add((r.periodLength ?? DEFAULT_PERIOD_LENGTH) - 1, 'day');
return (
(target.isSame(start, 'day') || target.isAfter(start, 'day')) &&
(target.isSame(end, 'day') || target.isBefore(end, 'day'))
);
});
if (record) {
const start = dayjs(record.startDate);
const diff = target.diff(start, 'day');
if (diff === 0) {
// If cancelling the start date, the UI removes the ENTIRE period record.
// So we should delete all samples for this period range.
const periodLength = record.periodLength ?? DEFAULT_PERIOD_LENGTH;
const endDate = start.add(periodLength - 1, 'day');
await deleteMenstrualFlow(start.toDate(), endDate.toDate());
} else {
// If cancelling a middle/end date, the UI shortens the period to end BEFORE target.
// So we delete from target date onwards to the original end date.
const periodLength = record.periodLength ?? DEFAULT_PERIOD_LENGTH;
const originalEnd = start.add(periodLength - 1, 'day');
// Delete from target to originalEnd
await deleteMenstrualFlow(target.toDate(), originalEnd.toDate());
}
}
} catch (error) {
console.error('Failed to delete from HealthKit', error);
setRecords(originalRecords);
}
};
// 下拉到顶部时加载更早的月份
const handleLoadPrevious = () => {
if (loadingPrevRef.current) return;
loadingPrevRef.current = true;
const delta = 3;
prependDeltaRef.current = delta;
setWindowConfig((prev) => ({ ...prev, before: prev.before + delta }));
};
// 向前追加月份时,保持当前视口位置不跳动
useEffect(() => {
if (prependDeltaRef.current > 0 && listRef.current) {
const offset = offsetRef.current + prependDeltaRef.current * ITEM_HEIGHT;
requestAnimationFrame(() => {
listRef.current?.scrollToOffset({ offset, animated: false });
prependDeltaRef.current = 0;
loadingPrevRef.current = false;
});
}
}, [timeline.months.length]);
const viewabilityConfig = useRef({
viewAreaCoveragePercentThreshold: 10,
}).current;
// 监测可视区域,接近顶部时触发加载更早月份
const onViewableItemsChanged = useRef(({ viewableItems }: any) => {
const minIndex = viewableItems.reduce(
(acc: number, cur: any) => Math.min(acc, cur.index ?? acc),
Number.MAX_SAFE_INTEGER
);
if (minIndex <= 1) {
handleLoadPrevious();
}
}).current;
// FlatList 数据源:按月份拆分
const listData = useMemo(() => {
return timeline.months.map((m) => ({
type: 'month' as const,
id: m.id,
month: m,
}));
}, [timeline.months]);
const renderInlineTip = (columnIndex: number) => (
<InlineTip
selectedDate={selectedDate}
selectedInfo={selectedInfo}
columnIndex={columnIndex}
onMarkStart={handleMarkStart}
onCancelMark={handleCancelMark}
/>
);
const renderCycleTab = () => (
<View style={styles.tabContent}>
<Legend />
<FlatList
ref={listRef}
data={listData}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<MonthBlock
month={item.month}
selectedDateKey={selectedDateKey}
onSelect={(key) => setSelectedDateKey(key)}
renderTip={renderInlineTip}
weekLabels={weekLabels}
/>
)}
showsVerticalScrollIndicator={false}
initialNumToRender={3}
windowSize={5}
maxToRenderPerBatch={4}
removeClippedSubviews
contentContainerStyle={styles.listContent}
// 使用固定高度优化初始滚动定位
getItemLayout={(_, index) => ({
length: ITEM_HEIGHT,
offset: ITEM_HEIGHT * index,
index,
})}
initialScrollIndex={initialMonthIndex >= 0 ? initialMonthIndex : undefined}
onScrollToIndexFailed={({ index }) => {
listRef.current?.scrollToOffset({
offset: ITEM_HEIGHT * index,
animated: false,
});
}}
viewabilityConfig={viewabilityConfig}
onViewableItemsChanged={onViewableItemsChanged}
onScroll={(e) => {
offsetRef.current = e.nativeEvent.contentOffset.y;
}}
scrollEventThrottle={16}
/>
</View>
);
const renderAnalysisTab = () => (
<View style={styles.tabContent}>
<View style={styles.analysisCard}>
<Text style={styles.analysisTitle}>{t('menstrual.screen.analysis.title')}</Text>
<Text style={styles.analysisBody}>
{t('menstrual.screen.analysis.description')}
</Text>
</View>
</View>
);
return (
<View style={styles.container}>
<Stack.Screen options={{ headerShown: false }} />
<LinearGradient
colors={['#fdf1ff', '#f3f4ff', '#f7f8ff']}
style={StyleSheet.absoluteFill}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
/>
<HeaderBar
title={t('menstrual.screen.header')}
onBack={() => router.back()}
// right={
// isLiquidGlassAvailable() ? (
// <TouchableOpacity style={styles.headerIconButton} activeOpacity={0.7}>
// <GlassView
// style={styles.headerIconGlass}
// glassEffectStyle="clear"
// tintColor="rgba(255, 255, 255, 0.35)"
// isInteractive={true}
// >
// <Ionicons name="settings-outline" size={20} color="#0f172a" />
// </GlassView>
// </TouchableOpacity>
// ) : (
// <TouchableOpacity style={styles.headerIcon} activeOpacity={0.7}>
// <Ionicons name="settings-outline" size={20} color="#0f172a" />
// </TouchableOpacity>
// )
// }
/>
<View style={{ height: safeAreaTop }} />
<View style={styles.tabSwitcher}>
{([
{ key: 'cycle', label: t('menstrual.screen.tabs.cycle') },
{ key: 'analysis', label: t('menstrual.screen.tabs.analysis') },
] as { key: TabKey; label: string }[]).map((tab) => {
const active = activeTab === tab.key;
return (
<TouchableOpacity
key={tab.key}
style={[styles.tabPill, active && styles.tabPillActive]}
onPress={() => setActiveTab(tab.key)}
activeOpacity={0.9}
>
<Text style={[styles.tabLabel, active && styles.tabLabelActive]}>
{tab.label}
</Text>
</TouchableOpacity>
);
})}
</View>
{activeTab === 'cycle' ? renderCycleTab() : renderAnalysisTab()}
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
paddingHorizontal: 16,
backgroundColor: 'transparent',
},
headerIcon: {
width: 36,
height: 36,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(255, 255, 255, 0.5)',
},
headerIconButton: {
width: 36,
height: 36,
borderRadius: 18,
overflow: 'hidden',
},
headerIconGlass: {
width: 36,
height: 36,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
},
tabSwitcher: {
flexDirection: 'row',
backgroundColor: 'rgba(255,255,255,0.7)',
borderRadius: 18,
padding: 4,
marginBottom: 16,
},
tabPill: {
flex: 1,
alignItems: 'center',
paddingVertical: 10,
borderRadius: 14,
},
tabPillActive: {
backgroundColor: '#fff',
shadowColor: '#000',
shadowOpacity: 0.08,
shadowOffset: { width: 0, height: 8 },
shadowRadius: 10,
elevation: 3,
},
tabLabel: {
color: '#4b5563',
fontWeight: '600',
fontFamily: 'AliRegular',
},
tabLabelActive: {
color: '#0f172a',
fontFamily: 'AliBold',
},
tabContent: {
flex: 1,
},
selectedCard: {
backgroundColor: '#fff',
borderRadius: 16,
paddingVertical: 10,
paddingHorizontal: 12,
marginBottom: 10,
shadowColor: '#000',
shadowOpacity: 0.08,
shadowRadius: 10,
shadowOffset: { width: 0, height: 8 },
elevation: 3,
},
selectedStatus: {
fontSize: 14,
color: '#111827',
fontWeight: '700',
fontFamily: 'AliBold',
},
listContent: {
paddingBottom: 80,
},
analysisCard: {
backgroundColor: '#fff',
borderRadius: 16,
padding: 16,
marginTop: 8,
shadowColor: '#000',
shadowOpacity: 0.08,
shadowRadius: 10,
shadowOffset: { width: 0, height: 6 },
elevation: 3,
},
analysisTitle: {
fontSize: 17,
fontWeight: '800',
color: '#0f172a',
marginBottom: 8,
fontFamily: 'AliBold',
},
analysisBody: {
fontSize: 14,
color: '#6b7280',
lineHeight: 20,
fontFamily: 'AliRegular',
},
});

View File

@@ -8,10 +8,12 @@ import {
getMoodReminderEnabled,
getNotificationEnabled,
getNutritionReminderEnabled,
getHRVReminderEnabled,
setMedicationReminderEnabled,
setMoodReminderEnabled,
setNotificationEnabled,
setNutritionReminderEnabled
setNutritionReminderEnabled,
setHRVReminderEnabled
} from '@/utils/userPreferences';
import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
@@ -29,21 +31,24 @@ export default function NotificationSettingsScreen() {
const [medicationReminderEnabled, setMedicationReminderEnabledState] = useState(false);
const [nutritionReminderEnabled, setNutritionReminderEnabledState] = useState(false);
const [moodReminderEnabled, setMoodReminderEnabledState] = useState(false);
const [hrvReminderEnabled, setHrvReminderEnabledState] = useState(false);
const [isLoading, setIsLoading] = useState(true);
// 加载通知设置
const loadNotificationSettings = useCallback(async () => {
try {
const [notification, medicationReminder, nutritionReminder, moodReminder] = await Promise.all([
const [notification, medicationReminder, nutritionReminder, moodReminder, hrvReminder] = await Promise.all([
getNotificationEnabled(),
getMedicationReminderEnabled(),
getNutritionReminderEnabled(),
getMoodReminderEnabled(),
getHRVReminderEnabled(),
]);
setNotificationEnabledState(notification);
setMedicationReminderEnabledState(medicationReminder);
setNutritionReminderEnabledState(nutritionReminder);
setMoodReminderEnabledState(moodReminder);
setHrvReminderEnabledState(hrvReminder);
} catch (error) {
console.error('Failed to load notification settings:', error);
} finally {
@@ -103,6 +108,8 @@ export default function NotificationSettingsScreen() {
setNutritionReminderEnabledState(false);
await setMoodReminderEnabled(false);
setMoodReminderEnabledState(false);
await setHRVReminderEnabled(false);
setHrvReminderEnabledState(false);
} catch (error) {
console.error('Failed to disable push notifications:', error);
Alert.alert(t('notificationSettings.alerts.error.title'), t('notificationSettings.alerts.error.saveFailed'));
@@ -173,6 +180,26 @@ export default function NotificationSettingsScreen() {
}
};
// 处理 HRV 通知提醒开关变化
const handleHrvReminderToggle = async (value: boolean) => {
try {
await setHRVReminderEnabled(value);
setHrvReminderEnabledState(value);
if (value) {
await sendNotification({
title: t('notificationSettings.alerts.hrvReminderEnabled.title'),
body: t('notificationSettings.alerts.hrvReminderEnabled.body'),
sound: true,
priority: 'high',
});
}
} catch (error) {
console.error('Failed to set HRV reminder:', error);
Alert.alert(t('notificationSettings.alerts.error.title'), t('notificationSettings.alerts.error.hrvReminderFailed'));
}
};
// 渲染设置项
const renderSettingItem = (
icon: keyof typeof Ionicons.glyphMap,
@@ -297,6 +324,16 @@ export default function NotificationSettingsScreen() {
true
)}
{renderSettingItem(
'pulse-outline',
t('notificationSettings.items.hrvReminder.title'),
t('notificationSettings.items.hrvReminder.description'),
hrvReminderEnabled,
handleHrvReminderToggle,
!notificationEnabled,
true
)}
{renderSettingItem(
'happy-outline',
t('notificationSettings.items.moodReminder.title'),
@@ -432,4 +469,4 @@ const styles = StyleSheet.create({
height: 1,
backgroundColor: '#F0F0F0',
},
});
});

View File

@@ -3,6 +3,7 @@ import { DateSelector } from '@/components/DateSelector';
import { FloatingFoodOverlay } from '@/components/FloatingFoodOverlay';
import { NutritionRecordCard } from '@/components/NutritionRecordCard';
import { HeaderBar } from '@/components/ui/HeaderBar';
import { Image } from '@/components/ui/Image';
import { Colors } from '@/constants/Colors';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useAuthGuard } from '@/hooks/useAuthGuard';
@@ -27,7 +28,6 @@ import { Ionicons } from '@expo/vector-icons';
import { useFocusEffect } from '@react-navigation/native';
import dayjs from 'dayjs';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient';
import { router } from 'expo-router';
import React, { useCallback, useEffect, useState } from 'react';

View File

@@ -1,4 +1,5 @@
import { HeaderBar } from '@/components/ui/HeaderBar';
import { Image } from '@/components/ui/Image';
import { Colors } from '@/constants/Colors';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useColorScheme } from '@/hooks/useColorScheme';
@@ -13,7 +14,6 @@ import { Ionicons } from '@expo/vector-icons';
import DateTimePicker from '@react-native-community/datetimepicker';
import { Picker } from '@react-native-picker/picker';
import { useFocusEffect } from '@react-navigation/native';
import { Image } from 'expo-image';
import * as ImagePicker from 'expo-image-picker';
import { router } from 'expo-router';
import React, { useEffect, useMemo, useState } from 'react';

View File

@@ -2,6 +2,7 @@ import { InfoModal, type SleepDetailData } from '@/components/sleep/InfoModal';
import { SleepStagesInfoModal } from '@/components/sleep/SleepStagesInfoModal';
import { SleepStageTimeline } from '@/components/sleep/SleepStageTimeline';
import { HeaderBar } from '@/components/ui/HeaderBar';
import { Image } from '@/components/ui/Image';
import { useColorScheme } from '@/hooks/useColorScheme';
import { useI18n } from '@/hooks/useI18n';
import {
@@ -14,7 +15,6 @@ import {
import { Ionicons } from '@expo/vector-icons';
import dayjs from 'dayjs';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient';
import { router, useLocalSearchParams } from 'expo-router';
import React, { useCallback, useEffect, useState } from 'react';

View File

@@ -0,0 +1,359 @@
import { HeaderBar } from '@/components/ui/HeaderBar';
import { palette } from '@/constants/Colors';
import { useMembershipModal } from '@/contexts/MembershipModalContext';
import { useToast } from '@/contexts/ToastContext';
import { useI18n } from '@/hooks/useI18n';
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
import { useVipService } from '@/hooks/useVipService';
import {
getStatisticsCardOrder,
getStatisticsCardsVisibility,
setStatisticsCardOrder,
setStatisticsCardVisibility,
StatisticsCardsVisibility
} from '@/utils/userPreferences';
import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import { router, useFocusEffect } from 'expo-router';
import React, { useCallback, useState } from 'react';
import { StatusBar, StyleSheet, Switch, Text, TouchableOpacity, View } from 'react-native';
import DraggableFlatList, { RenderItemParams, ScaleDecorator } from 'react-native-draggable-flatlist';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
type CardItem = {
key: string;
title: string;
icon: keyof typeof Ionicons.glyphMap;
visible: boolean;
visibilityKey: keyof StatisticsCardsVisibility;
};
export default function StatisticsCustomizationScreen() {
const safeAreaTop = useSafeAreaTop(60);
const { t } = useI18n();
const { isVip } = useVipService();
const { openMembershipModal } = useMembershipModal();
const { showToast } = useToast();
const [isLoading, setIsLoading] = useState(true);
const [data, setData] = useState<CardItem[]>([]);
const CARD_CONFIG: Record<string, { icon: keyof typeof Ionicons.glyphMap; titleKey: string; visibilityKey: keyof StatisticsCardsVisibility }> = {
mood: { icon: 'happy-outline', titleKey: 'statisticsCustomization.items.mood', visibilityKey: 'showMood' },
steps: { icon: 'footsteps-outline', titleKey: 'statisticsCustomization.items.steps', visibilityKey: 'showSteps' },
stress: { icon: 'pulse-outline', titleKey: 'statisticsCustomization.items.stress', visibilityKey: 'showStress' },
sleep: { icon: 'moon-outline', titleKey: 'statisticsCustomization.items.sleep', visibilityKey: 'showSleep' },
sunlight: { icon: 'sunny-outline', titleKey: 'statisticsCustomization.items.sunlight', visibilityKey: 'showSunlight' },
fitness: { icon: 'fitness-outline', titleKey: 'statisticsCustomization.items.fitnessRings', visibilityKey: 'showFitnessRings' },
water: { icon: 'water-outline', titleKey: 'statisticsCustomization.items.water', visibilityKey: 'showWater' },
metabolism: { icon: 'flame-outline', titleKey: 'statisticsCustomization.items.basalMetabolism', visibilityKey: 'showBasalMetabolism' },
oxygen: { icon: 'water-outline', titleKey: 'statisticsCustomization.items.oxygenSaturation', visibilityKey: 'showOxygenSaturation' },
temperature: { icon: 'thermometer-outline', titleKey: 'statisticsCustomization.items.wristTemperature', visibilityKey: 'showWristTemperature' },
menstrual: { icon: 'rose-outline', titleKey: 'statisticsCustomization.items.menstrualCycle', visibilityKey: 'showMenstrualCycle' },
weight: { icon: 'scale-outline', titleKey: 'statisticsCustomization.items.weight', visibilityKey: 'showWeight' },
circumference: { icon: 'body-outline', titleKey: 'statisticsCustomization.items.circumference', visibilityKey: 'showCircumference' },
};
// 加载设置
const loadSettings = useCallback(async () => {
try {
const [visibility, order] = await Promise.all([
getStatisticsCardsVisibility(),
getStatisticsCardOrder(),
]);
// 确保 order 包含所有配置的 key (处理新增 key 的情况)
const allKeys = Object.keys(CARD_CONFIG);
const uniqueOrder = Array.from(new Set([...order, ...allKeys]));
const listData: CardItem[] = uniqueOrder
.filter(key => CARD_CONFIG[key]) // 过滤掉无效 key
.map(key => {
const config = CARD_CONFIG[key];
return {
key,
title: t(config.titleKey),
icon: config.icon,
visible: visibility[config.visibilityKey],
visibilityKey: config.visibilityKey,
};
});
setData(listData);
} catch (error) {
console.error('Failed to load statistics customization settings:', error);
} finally {
setIsLoading(false);
}
}, [t]);
// 页面聚焦时加载设置
useFocusEffect(
useCallback(() => {
loadSettings();
}, [loadSettings])
);
// 处理开关切换
const handleToggle = async (item: CardItem, value: boolean) => {
if (!isVip) {
showToast({
type: 'info',
message: t('statisticsCustomization.vipRequired'),
});
openMembershipModal();
return;
}
try {
// 乐观更新 UI
setData(prev => prev.map(d => d.key === item.key ? { ...d, visible: value } : d));
await setStatisticsCardVisibility(item.visibilityKey, value);
} catch (error) {
console.error(`Failed to set ${item.key}:`, error);
// 回滚
setData(prev => prev.map(d => d.key === item.key ? { ...d, visible: !value } : d));
}
};
// 处理排序结束
const handleDragEnd = async ({ data: newData }: { data: CardItem[] }) => {
setData(newData);
const newOrder = newData.map(item => item.key);
try {
await setStatisticsCardOrder(newOrder);
} catch (error) {
console.error('Failed to save card order:', error);
}
};
const renderItem = useCallback(({ item, drag, isActive }: RenderItemParams<CardItem>) => {
const handleDrag = () => {
if (!isVip) {
showToast({
type: 'info',
message: t('statisticsCustomization.vipRequired'),
});
openMembershipModal();
return;
}
drag();
};
return (
<ScaleDecorator>
<TouchableOpacity
onLongPress={handleDrag}
disabled={isActive}
activeOpacity={1}
style={[
styles.rowItem,
isActive && styles.activeItem,
]}
>
<View style={styles.itemContent}>
<View style={styles.leftContent}>
<View style={styles.dragHandle}>
<Ionicons name="reorder-three-outline" size={24} color="#C7C7CC" />
</View>
<View style={styles.iconContainer}>
<Ionicons name={item.icon} size={24} color={'#9370DB'} />
</View>
<Text style={styles.itemTitle}>{item.title}</Text>
</View>
<Switch
value={item.visible}
onValueChange={(v) => handleToggle(item, v)}
trackColor={{ false: '#E5E5E5', true: '#9370DB' }}
thumbColor="#FFFFFF"
style={styles.switch}
/>
</View>
</TouchableOpacity>
</ScaleDecorator>
);
}, [handleToggle, isVip, t, showToast, openMembershipModal]);
if (isLoading) {
return (
<View style={styles.container}>
<StatusBar barStyle="dark-content" backgroundColor="transparent" translucent />
<LinearGradient
colors={[palette.purple[100], '#F5F5F5']}
start={{ x: 1, y: 0 }}
end={{ x: 0.3, y: 0.4 }}
style={styles.gradientBackground}
/>
<View style={styles.loadingContainer}>
<Text style={styles.loadingText}>{t('notificationSettings.loading')}</Text>
</View>
</View>
);
}
return (
<GestureHandlerRootView style={styles.container}>
<StatusBar barStyle="dark-content" backgroundColor="transparent" translucent />
<LinearGradient
colors={[palette.purple[100], '#F5F5F5']}
start={{ x: 1, y: 0 }}
end={{ x: 0.3, y: 0.4 }}
style={styles.gradientBackground}
/>
<HeaderBar
title={t('statisticsCustomization.title')}
onBack={() => router.back()}
/>
<DraggableFlatList
data={data}
onDragEnd={handleDragEnd}
keyExtractor={(item) => item.key}
renderItem={renderItem}
contentContainerStyle={[
styles.scrollContent,
{ paddingTop: safeAreaTop }
]}
showsVerticalScrollIndicator={false}
ListHeaderComponent={() => (
<>
<View style={styles.headerSection}>
<Text style={styles.subtitle}>{t('notificationSettings.sections.description')}</Text>
<View style={styles.descriptionCard}>
<View style={styles.hintRow}>
<Ionicons name="information-circle-outline" size={20} color="#9370DB" />
<Text style={styles.descriptionText}>
{t('statisticsCustomization.description.text')}
</Text>
</View>
</View>
</View>
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>{t('statisticsCustomization.sectionTitle')}</Text>
</View>
</>
)}
/>
</GestureHandlerRootView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#F5F5F5',
},
gradientBackground: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
height: '60%',
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
loadingText: {
fontSize: 16,
color: '#666',
},
scrollContent: {
paddingHorizontal: 16,
paddingBottom: 40,
},
headerSection: {
marginBottom: 20,
},
subtitle: {
fontSize: 14,
color: '#6C757D',
marginBottom: 12,
marginLeft: 4,
},
descriptionCard: {
backgroundColor: 'rgba(255, 255, 255, 0.6)',
borderRadius: 12,
padding: 12,
gap: 8,
borderWidth: 1,
borderColor: 'rgba(147, 112, 219, 0.1)',
},
hintRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
descriptionText: {
flex: 1,
fontSize: 13,
color: '#2C3E50',
lineHeight: 18,
},
sectionHeader: {
marginBottom: 12,
marginLeft: 4,
},
sectionTitle: {
fontSize: 16,
fontWeight: '600',
color: '#2C3E50',
},
rowItem: {
backgroundColor: '#FFFFFF',
borderRadius: 16,
marginBottom: 12,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.03,
shadowRadius: 4,
elevation: 2,
},
activeItem: {
backgroundColor: '#FAFAFA',
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 4,
zIndex: 100,
transform: [{ scale: 1.02 }],
},
itemContent: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
padding: 16,
height: 72,
},
leftContent: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
},
dragHandle: {
paddingRight: 12,
},
iconContainer: {
width: 40,
height: 40,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(147, 112, 219, 0.05)',
borderRadius: 12,
marginRight: 12,
},
itemTitle: {
fontSize: 16,
fontWeight: '500',
color: '#2C3E50',
flex: 1,
},
switch: {
transform: [{ scaleX: 0.9 }, { scaleY: 0.9 }],
},
});

View File

@@ -1,10 +1,10 @@
import { Image } from '@/components/ui/Image';
import { Colors } from '@/constants/Colors';
import { useColorScheme } from '@/hooks/useColorScheme';
import { useWaterDataByDate } from '@/hooks/useWaterData';
import { getQuickWaterAmount } from '@/utils/userPreferences';
import { Ionicons } from '@expo/vector-icons';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient';
import { router, useLocalSearchParams } from 'expo-router';
import React, { useEffect, useState } from 'react';

View File

@@ -1,9 +1,11 @@
import NumberKeyboard from '@/components/NumberKeyboard';
import { HeaderBar } from '@/components/ui/HeaderBar';
import { WeightProgressBar } from '@/components/weight/WeightProgressBar';
import { WeightRecordCard } from '@/components/weight/WeightRecordCard';
import { Colors } from '@/constants/Colors';
import { getTabBarBottomPadding } from '@/constants/TabBar';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useAuthGuard } from '@/hooks/useAuthGuard';
import { useColorScheme } from '@/hooks/useColorScheme';
import { useI18n } from '@/hooks/useI18n';
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
@@ -39,14 +41,16 @@ export default function WeightRecordsPage() {
const colorScheme = useColorScheme();
const themeColors = Colors[colorScheme ?? 'light'];
const { isLoggedIn, ensureLoggedIn } = useAuthGuard();
const loadWeightHistory = useCallback(async () => {
if (!isLoggedIn) return;
try {
await dispatch(fetchWeightHistory() as any);
} catch (error) {
console.error(t('weightRecords.loadingHistory'), error);
}
}, [dispatch]);
}, [dispatch, isLoggedIn]);
useEffect(() => {
loadWeightHistory();
@@ -56,28 +60,36 @@ export default function WeightRecordsPage() {
setInputWeight(weight.toString());
};
const handleAddWeight = () => {
const handleAddWeight = async () => {
const ok = await ensureLoggedIn();
if (!ok) return;
setPickerType('current');
const weight = userProfile?.weight ? parseFloat(userProfile.weight) : 70.0;
initializeInput(weight);
setShowWeightPicker(true);
};
const handleEditInitialWeight = () => {
const handleEditInitialWeight = async () => {
const ok = await ensureLoggedIn();
if (!ok) return;
setPickerType('initial');
const initialWeight = userProfile?.initialWeight || userProfile?.weight || '70.0';
initializeInput(parseFloat(initialWeight));
setShowWeightPicker(true);
};
const handleEditTargetWeight = () => {
const handleEditTargetWeight = async () => {
const ok = await ensureLoggedIn();
if (!ok) return;
setPickerType('target');
const targetWeight = userProfile?.targetWeight || '60.0';
initializeInput(parseFloat(targetWeight));
setShowWeightPicker(true);
};
const handleEditWeightRecord = (record: WeightHistoryItem) => {
const handleEditWeightRecord = async (record: WeightHistoryItem) => {
const ok = await ensureLoggedIn();
if (!ok) return;
setPickerType('edit');
setEditingRecord(record);
initializeInput(parseFloat(record.weight));
@@ -85,6 +97,8 @@ export default function WeightRecordsPage() {
};
const handleDeleteWeightRecord = async (id: string) => {
const ok = await ensureLoggedIn();
if (!ok) return;
try {
await dispatch(deleteWeightRecord(id) as any);
await loadWeightHistory();
@@ -180,6 +194,12 @@ export default function WeightRecordsPage() {
const targetWeight = userProfile?.targetWeight ? parseFloat(userProfile.targetWeight) : 60.0;
const totalWeightLoss = initialWeight - currentWeight;
// 计算减重进度
const hasTargetWeight = targetWeight > 0 && initialWeight > targetWeight;
const totalToLose = initialWeight - targetWeight;
const actualLost = initialWeight - currentWeight;
const weightProgress = hasTargetWeight && totalToLose > 0 ? actualLost / totalToLose : 0;
return (
<View style={styles.container}>
{/* 背景 */}
@@ -245,9 +265,9 @@ export default function WeightRecordsPage() {
<Text style={styles.mainStatUnit}>kg</Text>
</View>
<View style={styles.totalLossTag}>
<Ionicons name={totalWeightLoss <= 0 ? "trending-down" : "trending-up"} size={16} color="#ffffff" />
<Ionicons name={totalWeightLoss > 0 ? "trending-down" : "trending-up"} size={16} color="#ffffff" />
<Text style={styles.totalLossText}>
{totalWeightLoss > 0 ? '+' : ''}{totalWeightLoss.toFixed(1)} kg
{totalWeightLoss > 0 ? '-' : totalWeightLoss < 0 ? '+' : ''}{Math.abs(totalWeightLoss).toFixed(1)} kg
</Text>
</View>
</View>
@@ -295,6 +315,19 @@ export default function WeightRecordsPage() {
</View>
</View>
{/* 减重进度条 - 仅在设置了目标体重时显示 */}
{hasTargetWeight && (
<View style={styles.progressContainer}>
<WeightProgressBar
progress={weightProgress}
currentWeight={currentWeight}
targetWeight={targetWeight}
initialWeight={initialWeight}
showTopBorder={false}
/>
</View>
)}
{/* Monthly Records */}
{Object.keys(groupedHistory).length > 0 ? (
<View style={styles.historySection}>
@@ -628,6 +661,20 @@ const styles = StyleSheet.create({
marginLeft: 2,
},
// Progress Container
progressContainer: {
marginHorizontal: 24,
marginBottom: 24,
backgroundColor: '#ffffff',
borderRadius: 24,
padding: 20,
shadowColor: 'rgba(30, 41, 59, 0.06)',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.1,
shadowRadius: 12,
elevation: 3,
},
// History Section
historySection: {
paddingHorizontal: 24,

View File

@@ -1,10 +1,10 @@
import { Image } from '@/components/ui/Image';
import { Colors } from '@/constants/Colors';
import { useColorScheme } from '@/hooks/useColorScheme';
import { useWaterDataByDate } from '@/hooks/useWaterData';
import { getQuickWaterAmount, setQuickWaterAmount } from '@/utils/userPreferences';
import { Ionicons } from '@expo/vector-icons';
import dayjs from 'dayjs';
import { Image } from 'expo-image';
import React, { useEffect, useState } from 'react';
import {
Alert,

View File

@@ -1,9 +1,9 @@
import { Image } from '@/components/ui/Image';
import { ROUTES } from '@/constants/Routes';
import { useAppSelector } from '@/hooks/redux';
import { selectUserAge, selectUserProfile } from '@/store/userSlice';
import { fetchBasalEnergyBurned } from '@/utils/health';
import dayjs from 'dayjs';
import { Image } from 'expo-image';
import { router } from 'expo-router';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';

View File

@@ -0,0 +1,176 @@
import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
interface MembershipBannerProps {
onPress: () => void;
}
export const MembershipBanner: React.FC<MembershipBannerProps> = ({ onPress }) => {
const { t } = useTranslation();
return (
<View style={styles.container}>
<TouchableOpacity
activeOpacity={0.9}
onPress={onPress}
style={styles.touchable}
>
<LinearGradient
colors={['#4C3AFF', '#8D5BEA']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.gradient}
>
{/* Decorative Elements */}
<View style={styles.decorationCircleLarge} />
<View style={styles.decorationCircleSmall} />
<View style={styles.contentContainer}>
<View style={styles.textContainer}>
<View style={styles.badgeContainer}>
<Text style={styles.badgeText}>PRO</Text>
</View>
<Text style={styles.title}>
{t('personal.membershipBanner.title', 'Unlock Premium Access')}
</Text>
<Text style={styles.subtitle} numberOfLines={1}>
{t('personal.membershipBanner.subtitle', 'Get unlimited access to all features')}
</Text>
<View style={styles.ctaButton}>
<Text style={styles.ctaText}>{t('personal.membershipBanner.cta', 'Upgrade')}</Text>
<Ionicons name="arrow-forward" size={12} color="#4C3AFF" />
</View>
</View>
<View style={styles.illustrationContainer}>
{/* Use Ionicons as illustration or you can use Image if passed as prop */}
<Ionicons name="diamond-outline" size={56} color="rgba(255,255,255,0.15)" />
</View>
</View>
</LinearGradient>
</TouchableOpacity>
</View>
);
};
const styles = StyleSheet.create({
container: {
marginBottom: 20,
borderRadius: 16,
// Premium Shadow
shadowColor: '#4C3AFF',
shadowOffset: { width: 0, height: 6 },
shadowOpacity: 0.15,
shadowRadius: 12,
elevation: 6,
marginHorizontal: 4, // Add margin to avoid cutting off shadow
},
touchable: {
borderRadius: 16,
overflow: 'hidden',
},
gradient: {
padding: 16,
minHeight: 100,
position: 'relative',
justifyContent: 'center',
},
decorationCircleLarge: {
position: 'absolute',
top: -40,
right: -40,
width: 160,
height: 160,
borderRadius: 80,
backgroundColor: 'rgba(255,255,255,0.08)',
},
decorationCircleSmall: {
position: 'absolute',
bottom: -30,
left: -30,
width: 100,
height: 100,
borderRadius: 50,
backgroundColor: 'rgba(255,255,255,0.05)',
},
contentContainer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
zIndex: 1,
},
textContainer: {
flex: 1,
paddingRight: 12,
zIndex: 2,
},
badgeContainer: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(255,255,255,0.2)',
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: 8,
alignSelf: 'flex-start',
marginBottom: 8,
borderWidth: 0.5,
borderColor: 'rgba(255,255,255,0.3)',
},
badgeIcon: {
marginRight: 3,
},
badgeText: {
color: '#FFD700',
fontSize: 9,
fontWeight: '800',
letterSpacing: 0.5,
fontFamily: 'AliBold',
},
title: {
fontSize: 16,
fontWeight: '700',
color: '#FFFFFF',
marginBottom: 4,
fontFamily: 'AliBold',
lineHeight: 20,
},
subtitle: {
fontSize: 11,
color: 'rgba(255,255,255,0.9)',
marginBottom: 12,
lineHeight: 14,
fontFamily: 'AliRegular',
},
ctaButton: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#FFFFFF',
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 14,
alignSelf: 'flex-start',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 2,
},
ctaText: {
color: '#4C3AFF',
fontSize: 11,
fontWeight: '700',
marginRight: 4,
fontFamily: 'AliBold',
},
illustrationContainer: {
position: 'absolute',
right: -6,
bottom: -6,
zIndex: 1,
transform: [{ rotate: '-15deg' }]
}
});

View File

@@ -0,0 +1,405 @@
import dayjs, { Dayjs } from 'dayjs';
import { LinearGradient } from 'expo-linear-gradient';
import React, { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { Colors } from '@/constants/Colors';
import { fetchMenstrualFlowSamples, healthDataEvents } from '@/utils/health';
import {
buildMenstrualTimeline,
convertHealthKitSamplesToCycleRecords,
CycleRecord,
DEFAULT_PERIOD_LENGTH,
MenstrualDayInfo,
MenstrualDayStatus,
MenstrualTimeline,
} from '@/utils/menstrualCycle';
type Props = {
onPress?: () => void;
};
type Summary = {
state: string;
prefix?: string;
suffix?: string;
number?: number;
fallbackText: string;
};
const RingIcon = () => (
<View style={styles.iconWrapper}>
<LinearGradient
colors={['#f572a7', '#f0a4ff', '#6f6ced']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.iconGradient}
>
<View style={styles.iconInner} />
</LinearGradient>
</View>
);
export const MenstrualCycleCard: React.FC<Props> = ({ onPress }) => {
const { t } = useTranslation();
const [records, setRecords] = useState<CycleRecord[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
let mounted = true;
const loadMenstrualData = async () => {
// Avoid setting loading to true for background updates to prevent UI flicker
if (records.length === 0) {
setLoading(true);
}
try {
const today = dayjs();
const startDate = today.subtract(3, 'month').startOf('month').toDate();
const endDate = today.add(4, 'month').endOf('month').toDate();
const samples = await fetchMenstrualFlowSamples(startDate, endDate);
if (!mounted) return;
const converted = convertHealthKitSamplesToCycleRecords(samples);
setRecords(converted);
} catch (error) {
console.error('Failed to load menstrual flow samples', error);
if (mounted) {
setRecords([]);
}
} finally {
if (mounted) {
setLoading(false);
}
}
};
loadMenstrualData();
// Listen for data changes
const handleDataChange = () => {
loadMenstrualData();
};
healthDataEvents.on('menstrualDataChanged', handleDataChange);
return () => {
mounted = false;
healthDataEvents.off('menstrualDataChanged', handleDataChange);
};
}, []);
const timeline = useMemo(
() =>
buildMenstrualTimeline({
records,
monthsBefore: 2,
monthsAfter: 4,
defaultPeriodLength: DEFAULT_PERIOD_LENGTH,
}),
[records]
);
const summary = useMemo(() => {
if (loading && records.length === 0) {
return {
state: t('menstrual.card.syncingState'),
fallbackText: t('menstrual.card.syncingDesc'),
};
}
return deriveSummary(timeline, records.length > 0, t);
}, [loading, records.length, timeline, t]);
return (
<TouchableOpacity activeOpacity={0.92} onPress={onPress} style={styles.wrapper}>
<View style={styles.headerRow}>
<RingIcon />
<Text style={styles.title}>{t('menstrual.card.title')}</Text>
<View style={styles.badgeOuter}>
<View style={styles.badgeInner} />
</View>
</View>
<View style={styles.content}>
<Text style={styles.stateText}>{summary.state}</Text>
<Text style={styles.dayRow}>
{summary.number !== undefined ? (
<>
{summary.prefix}
<Text style={styles.dayNumber}>{summary.number}</Text>
{summary.suffix}
</>
) : (
summary.fallbackText
)}
</Text>
</View>
</TouchableOpacity>
);
};
const periodStatuses = new Set<MenstrualDayStatus>(['period', 'predicted-period']);
const fertileStatuses = new Set<MenstrualDayStatus>(['fertile', 'ovulation-day']);
const ovulationStatuses = new Set<MenstrualDayStatus>(['ovulation-day']);
const deriveSummary = (
timeline: MenstrualTimeline,
hasRecords: boolean,
t: (key: string, options?: Record<string, any>) => string
): Summary => {
const today = dayjs();
const { dayMap, todayInfo } = timeline;
if (!hasRecords || !Object.keys(dayMap).length) {
return {
state: t('menstrual.card.emptyState'),
fallbackText: t('menstrual.card.emptyDesc'),
};
}
const sortedInfos = Object.values(dayMap).sort(
(a, b) => a.date.valueOf() - b.date.valueOf()
);
const findContinuousRange = (
date: Dayjs,
targetStatuses: Set<MenstrualDayStatus>
): { start: Dayjs; end: Dayjs } | null => {
const key = date.format('YYYY-MM-DD');
if (!targetStatuses.has(dayMap[key]?.status)) return null;
let start = date;
let end = date;
while (true) {
const prev = start.subtract(1, 'day');
const prevInfo = dayMap[prev.format('YYYY-MM-DD')];
if (prevInfo && targetStatuses.has(prevInfo.status)) {
start = prev;
} else {
break;
}
}
while (true) {
const next = end.add(1, 'day');
const nextInfo = dayMap[next.format('YYYY-MM-DD')];
if (nextInfo && targetStatuses.has(nextInfo.status)) {
end = next;
} else {
break;
}
}
return { start, end };
};
const findFutureStatus = (
targetStatuses: Set<MenstrualDayStatus>,
inclusive = true
): MenstrualDayInfo | undefined => {
return sortedInfos.find((info) => {
const isInRange = inclusive
? !info.date.isBefore(today, 'day')
: info.date.isAfter(today, 'day');
return isInRange && targetStatuses.has(info.status);
});
};
const findPastStatus = (targetStatuses: Set<MenstrualDayStatus>) => {
for (let i = sortedInfos.length - 1; i >= 0; i -= 1) {
const info = sortedInfos[i];
if (!info.date.isAfter(today, 'day') && targetStatuses.has(info.status)) {
return info;
}
}
return undefined;
};
if (todayInfo && periodStatuses.has(todayInfo.status)) {
const range = findContinuousRange(today, periodStatuses);
const end = range?.end ?? today;
const daysLeft = Math.max(end.diff(today, 'day'), 0);
if (daysLeft === 0) {
return {
state:
todayInfo.status === 'period'
? t('menstrual.card.periodState')
: t('menstrual.card.predictedPeriodState'),
fallbackText: t('menstrual.card.periodEndToday', {
date: end.format(t('menstrual.dateFormatShort')),
}),
};
}
return {
state:
todayInfo.status === 'period'
? t('menstrual.card.periodState')
: t('menstrual.card.predictedPeriodState'),
prefix: t('menstrual.card.periodEndPrefix'),
number: daysLeft,
suffix: t('menstrual.card.periodEndSuffix', {
date: end.format(t('menstrual.dateFormatShort')),
}),
fallbackText: '',
};
}
const nextPeriod = findFutureStatus(periodStatuses, false);
const lastPeriodInfo = findPastStatus(periodStatuses);
const lastPeriodStart = lastPeriodInfo
? findContinuousRange(lastPeriodInfo.date, periodStatuses)?.start
: undefined;
const ovulationThisCycle = sortedInfos.find((info) => {
if (!ovulationStatuses.has(info.status)) return false;
if (lastPeriodStart && info.date.isBefore(lastPeriodStart, 'day')) return false;
if (nextPeriod && !info.date.isBefore(nextPeriod.date, 'day')) return false;
return true;
});
if (todayInfo?.status === 'fertile') {
const targetOvulation = ovulationThisCycle ?? findFutureStatus(ovulationStatuses);
if (targetOvulation) {
const days = Math.max(targetOvulation.date.diff(today, 'day'), 0);
if (days === 0) {
return {
state: t('menstrual.card.fertileState'),
fallbackText: t('menstrual.card.ovulationToday'),
};
}
return {
state: t('menstrual.card.fertileState'),
prefix: t('menstrual.card.ovulationCountdownPrefix'),
number: days,
suffix: t('menstrual.card.ovulationCountdownSuffix'),
fallbackText: '',
};
}
}
const nextFertile = findFutureStatus(fertileStatuses);
if (nextFertile && (!nextPeriod || nextFertile.date.isBefore(nextPeriod.date))) {
const days = Math.max(nextFertile.date.diff(today, 'day'), 0);
if (days === 0) {
return {
state: t('menstrual.card.fertileState'),
fallbackText: t('menstrual.card.fertileToday'),
};
}
return {
state: t('menstrual.card.fertileState'),
prefix: t('menstrual.card.fertileCountdownPrefix'),
number: days,
suffix: t('menstrual.card.fertileCountdownSuffix'),
fallbackText: '',
};
}
if (
ovulationThisCycle &&
nextPeriod &&
today.isAfter(ovulationThisCycle.date, 'day') &&
today.isBefore(nextPeriod.date, 'day')
) {
const days = Math.max(nextPeriod.date.diff(today, 'day'), 0);
return {
state: t('menstrual.card.periodState'),
prefix: t('menstrual.card.nextPeriodPrefix'),
number: days,
suffix: t('menstrual.card.nextPeriodSuffix'),
fallbackText: '',
};
}
if (nextPeriod) {
const days = Math.max(nextPeriod.date.diff(today, 'day'), 0);
return {
state: t('menstrual.card.periodState'),
prefix: t('menstrual.card.nextPeriodPrefix'),
number: days,
suffix: t('menstrual.card.nextPeriodSuffix'),
fallbackText: '',
};
}
return {
state: t('menstrual.card.emptyState'),
fallbackText: t('menstrual.card.emptyDesc'),
};
};
const styles = StyleSheet.create({
wrapper: {
width: '100%',
},
headerRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
},
iconWrapper: {
width: 24,
height: 24,
borderRadius: 12,
alignItems: 'center',
justifyContent: 'center',
},
iconGradient: {
width: 22,
height: 22,
borderRadius: 11,
alignItems: 'center',
justifyContent: 'center',
},
iconInner: {
width: 10,
height: 10,
borderRadius: 5,
backgroundColor: '#fff',
},
title: {
fontSize: 14,
color: '#192126',
fontWeight: '600',
flex: 1,
fontFamily: 'AliBold',
},
badgeOuter: {
width: 18,
height: 18,
borderRadius: 9,
borderWidth: 2,
borderColor: '#fbcfe8',
alignItems: 'center',
justifyContent: 'center',
},
badgeInner: {
width: 6,
height: 6,
borderRadius: 3,
backgroundColor: Colors.light.primary,
opacity: 0.35,
},
content: {
marginTop: 12,
},
stateText: {
fontSize: 12,
color: '#515558',
marginBottom: 4,
fontFamily: 'AliRegular',
},
dayRow: {
fontSize: 14,
color: '#192126',
fontFamily: 'AliRegular',
},
dayNumber: {
fontSize: 18,
fontWeight: '700',
color: '#192126',
fontFamily: 'AliBold',
},
});

View File

@@ -1,10 +1,10 @@
import { ThemedText } from '@/components/ThemedText';
import { Image } from '@/components/ui/Image';
import { useI18n } from '@/hooks/useI18n';
import { useThemeColor } from '@/hooks/useThemeColor';
import { DietRecord } from '@/services/dietRecords';
import { Ionicons } from '@expo/vector-icons';
import dayjs from 'dayjs';
import { Image } from 'expo-image';
import React, { useMemo, useRef, useState } from 'react';
import { Alert, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { RectButton, Swipeable } from 'react-native-gesture-handler';

View File

@@ -1,21 +1,21 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Animated,
InteractionManager,
StyleSheet,
Text,
TouchableOpacity,
View,
ViewStyle
Animated,
InteractionManager,
StyleSheet,
Text,
TouchableOpacity,
View,
ViewStyle
} from 'react-native';
import { Image } from '@/components/ui/Image';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { ChallengeType } from '@/services/challengesApi';
import { reportChallengeProgress, selectChallengeList } from '@/store/challengesSlice';
import { fetchHourlyStepSamples, fetchStepCount, HourlyStepData } from '@/utils/health';
import { logger } from '@/utils/logger';
import dayjs from 'dayjs';
import { Image } from 'expo-image';
import { useRouter } from 'expo-router';
import { useTranslation } from 'react-i18next';
import { AnimatedNumber } from './AnimatedNumber';

View File

@@ -1,9 +1,15 @@
import { Colors } from '@/constants/Colors';
import { useColorScheme } from '@/hooks/useColorScheme';
import { fetchHRVSamples, HRVData } from '@/utils/health';
import { convertHrvToStressIndex, getStressLevelInfo } from '@/utils/stress';
import dayjs from 'dayjs';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { LinearGradient } from 'expo-linear-gradient';
import React from 'react';
import React, { useEffect, useState } from 'react';
import {
ActivityIndicator,
Modal,
Platform,
ScrollView,
StyleSheet,
Text,
@@ -18,18 +24,103 @@ interface StressAnalysisModalProps {
updateTime: Date;
}
interface StressStats {
percentage: number;
count: number;
range: string;
}
interface HistoryData {
goodEvents: StressStats;
energetic: StressStats;
stressed: StressStats;
totalSamples: number;
}
export function StressAnalysisModal({ visible, onClose, hrvValue, updateTime }: StressAnalysisModalProps) {
const colorScheme = useColorScheme();
const colors = Colors[colorScheme ?? 'light'];
const [loading, setLoading] = useState(true);
const [historyData, setHistoryData] = useState<HistoryData>({
goodEvents: { percentage: 0, count: 0, range: '>75毫秒' },
energetic: { percentage: 0, count: 0, range: '40-75毫秒' },
stressed: { percentage: 0, count: 0, range: '<40毫秒' },
totalSamples: 0
});
// 模拟30天HRV数据
const hrvData = {
goodEvents: { percentage: 26, count: 53, range: '>80毫秒' },
energetic: { percentage: 47, count: 97, range: '43-80毫秒' },
stressed: { percentage: 27, count: 56, range: '<43毫秒' },
// 当前压力状态
const stressIndex = convertHrvToStressIndex(hrvValue);
const stressInfo = getStressLevelInfo(stressIndex);
useEffect(() => {
if (visible) {
loadHistoryData();
}
}, [visible]);
const loadHistoryData = async () => {
setLoading(true);
try {
const endDate = new Date();
const startDate = dayjs().subtract(30, 'day').toDate();
const samples = await fetchHRVSamples(startDate, endDate);
processHistoryData(samples);
} catch (error) {
console.error('Failed to load HRV history:', error);
} finally {
setLoading(false);
}
};
const processHistoryData = (samples: HRVData[]) => {
if (!samples.length) return;
let goodCount = 0;
let energeticCount = 0;
let stressedCount = 0;
samples.forEach(sample => {
const val = sample.value;
if (val > 75) {
goodCount++;
} else if (val >= 40) {
energeticCount++;
} else {
stressedCount++;
}
});
const total = samples.length;
setHistoryData({
goodEvents: {
percentage: Math.round((goodCount / total) * 100),
count: goodCount,
range: '>75毫秒'
},
energetic: {
percentage: Math.round((energeticCount / total) * 100),
count: energeticCount,
range: '40-75毫秒'
},
stressed: {
percentage: Math.round((stressedCount / total) * 100),
count: stressedCount,
range: '<40毫秒'
},
totalSamples: total
});
};
const getStatusColor = (level: string) => {
switch (level) {
case 'low': return '#10B981';
case 'moderate': return '#3B82F6';
case 'high': return '#F59E0B';
default: return colors.text;
}
};
return (
<Modal
@@ -45,80 +136,139 @@ export function StressAnalysisModal({ visible, onClose, hrvValue, updateTime }:
end={{ x: 0, y: 1 }}
>
<ScrollView style={styles.content} showsVerticalScrollIndicator={false}>
{/* 标题 */}
<Text style={styles.title}></Text>
{/* 标题区域 */}
<Text style={styles.title}></Text>
{/* 当前状态卡片 */}
<View style={styles.currentStatusCard}>
<View style={styles.statusHeader}>
<Text style={styles.statusLabel}></Text>
<Text style={styles.updateTime}> {dayjs(updateTime).format('HH:mm')}</Text>
</View>
<View style={styles.statusValueContainer}>
<View>
<Text style={[styles.statusText, { color: getStatusColor(stressInfo.level) }]}>
{stressInfo.label}
</Text>
<Text style={styles.statusDesc}>{stressInfo.description}</Text>
</View>
<View style={styles.hrvValueBox}>
<Text style={styles.hrvValueLabel}>HRV</Text>
<Text style={styles.hrvValue}>{Math.round(hrvValue)}<Text style={styles.hrvUnit}>ms</Text></Text>
</View>
</View>
</View>
{/* 最近30天HRV情况 */}
<Text style={styles.sectionTitle}>30HRV情况</Text>
<Text style={styles.sectionTitle}>30</Text>
{/* 彩色横条图 */}
<View style={styles.chartContainer}>
<View style={styles.colorBar}>
<LinearGradient
colors={['#F59E0B', '#3B82F6', '#10B981']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.gradientBar}
/>
</View>
<View style={styles.legend}>
<View style={styles.legendItem}>
<View style={[styles.legendDot, { backgroundColor: '#F59E0B' }]} />
<Text style={styles.legendText}></Text>
</View>
<View style={styles.legendItem}>
<View style={[styles.legendDot, { backgroundColor: '#3B82F6' }]} />
<Text style={styles.legendText}></Text>
</View>
<View style={styles.legendItem}>
<View style={[styles.legendDot, { backgroundColor: '#10B981' }]} />
<Text style={styles.legendText}></Text>
</View>
</View>
</View>
{/* 数据统计卡片 */}
<View style={styles.statsCard}>
{/* 好事发生 & 活力满满 */}
<View style={styles.statsRow}>
<View style={styles.statItem}>
<Text style={[styles.statTitle, { color: '#10B981' }]}></Text>
<Text style={styles.statPercentage}>{hrvData.goodEvents.percentage}%</Text>
<View style={styles.statDetails}>
<Text style={styles.statRange}> {hrvData.goodEvents.range}</Text>
{loading ? (
<ActivityIndicator size="large" color={colors.primary} style={{ marginTop: 20 }} />
) : (
<>
{/* 彩色横条图 */}
<View style={styles.chartContainer}>
<View style={styles.colorBar}>
{historyData.totalSamples > 0 ? (
<View style={styles.progressBarContainer}>
{historyData.stressed.percentage > 0 && (
<View style={[styles.progressSegment, { flex: historyData.stressed.percentage, backgroundColor: '#F59E0B', marginRight: 2 }]} />
)}
{historyData.energetic.percentage > 0 && (
<View style={[styles.progressSegment, { flex: historyData.energetic.percentage, backgroundColor: '#3B82F6', marginRight: 2 }]} />
)}
{historyData.goodEvents.percentage > 0 && (
<View style={[styles.progressSegment, { flex: historyData.goodEvents.percentage, backgroundColor: '#10B981' }]} />
)}
</View>
) : (
<View style={[styles.progressBarContainer, { backgroundColor: '#E5E7EB' }]} />
)}
</View>
<Text style={styles.statCount}>{hrvData.goodEvents.count}</Text>
</View>
<View style={styles.statItem}>
<Text style={[styles.statTitle, { color: '#3B82F6' }]}></Text>
<Text style={styles.statPercentage}>{hrvData.energetic.percentage}%</Text>
<View style={styles.statDetails}>
<Text style={styles.statRange}> {hrvData.energetic.range}</Text>
<View style={styles.legend}>
<View style={styles.legendItem}>
<View style={[styles.legendDot, { backgroundColor: '#F59E0B' }]} />
<Text style={styles.legendText}></Text>
</View>
<View style={styles.legendItem}>
<View style={[styles.legendDot, { backgroundColor: '#3B82F6' }]} />
<Text style={styles.legendText}></Text>
</View>
<View style={styles.legendItem}>
<View style={[styles.legendDot, { backgroundColor: '#10B981' }]} />
<Text style={styles.legendText}></Text>
</View>
</View>
<Text style={styles.statCount}>{hrvData.energetic.count}</Text>
</View>
</View>
{/* 鸭梨山大 */}
<View style={styles.statItem}>
<Text style={[styles.statTitle, { color: '#F59E0B' }]}></Text>
<Text style={styles.statPercentage}>{hrvData.stressed.percentage}%</Text>
<View style={styles.statDetails}>
<Text style={styles.statRange}> {hrvData.stressed.range}</Text>
{/* 数据统计卡片 */}
<View style={styles.statsCard}>
{/* 好事发生 & 活力满满 */}
<View style={styles.statsRow}>
<View style={styles.statItem}>
<Text style={[styles.statTitle, { color: '#10B981' }]}></Text>
<Text style={styles.statPercentage}>{historyData.goodEvents.percentage}%</Text>
<View style={styles.statDetails}>
<Text style={[styles.statRange, { color: '#10B981', backgroundColor: '#ECFDF5' }]}>
HRV {historyData.goodEvents.range}
</Text>
</View>
<Text style={styles.statCount}>{historyData.goodEvents.count}</Text>
</View>
<View style={styles.statItem}>
<Text style={[styles.statTitle, { color: '#3B82F6' }]}></Text>
<Text style={styles.statPercentage}>{historyData.energetic.percentage}%</Text>
<View style={styles.statDetails}>
<Text style={[styles.statRange, { color: '#3B82F6', backgroundColor: '#EFF6FF' }]}>
HRV {historyData.energetic.range}
</Text>
</View>
<Text style={styles.statCount}>{historyData.energetic.count}</Text>
</View>
</View>
{/* 鸭梨山大 */}
<View style={styles.statItem}>
<Text style={[styles.statTitle, { color: '#F59E0B' }]}></Text>
<Text style={styles.statPercentage}>{historyData.stressed.percentage}%</Text>
<View style={styles.statDetails}>
<Text style={[styles.statRange, { color: '#F59E0B', backgroundColor: '#FFFBEB' }]}>
HRV {historyData.stressed.range}
</Text>
</View>
<Text style={styles.statCount}>{historyData.stressed.count}</Text>
</View>
</View>
<Text style={styles.statCount}>{hrvData.stressed.count}</Text>
</View>
</View>
</>
)}
</ScrollView>
{/* 底部继续按钮 */}
<View style={styles.bottomContainer}>
<TouchableOpacity style={styles.continueButton} onPress={onClose}>
<View style={styles.buttonBackground}>
<Text style={styles.buttonText}></Text>
</View>
<TouchableOpacity style={styles.continueButton} onPress={onClose} activeOpacity={0.85}>
{isLiquidGlassAvailable() ? (
<GlassView
glassEffectStyle="regular"
tintColor="rgba(139, 92, 246, 0.85)"
isInteractive={true}
style={styles.glassButton}
>
<Text style={styles.buttonText}></Text>
</GlassView>
) : (
<LinearGradient
colors={['#8B5CF6', '#7C3AED']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.buttonGradient}
>
<Text style={styles.buttonText}></Text>
</LinearGradient>
)}
</TouchableOpacity>
<View style={styles.homeIndicator} />
</View>
@@ -140,15 +290,78 @@ const styles = StyleSheet.create({
fontWeight: '800',
color: '#111827',
textAlign: 'center',
marginTop: 20,
marginTop: 24,
marginBottom: 32,
fontFamily: 'AliBold',
},
sectionTitle: {
fontSize: 22,
currentStatusCard: {
backgroundColor: '#FFFFFF',
borderRadius: 20,
padding: 20,
marginBottom: 32,
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.06,
shadowRadius: 12,
elevation: 4,
},
statusHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 16,
},
statusLabel: {
fontSize: 16,
fontWeight: '700',
color: '#374151',
},
updateTime: {
fontSize: 12,
color: '#9CA3AF',
},
statusValueContainer: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
statusText: {
fontSize: 28,
fontWeight: '800',
marginBottom: 4,
},
statusDesc: {
fontSize: 14,
color: '#6B7280',
maxWidth: 200,
},
hrvValueBox: {
alignItems: 'flex-end',
},
hrvValueLabel: {
fontSize: 12,
color: '#9CA3AF',
fontWeight: '600',
marginBottom: 2,
},
hrvValue: {
fontSize: 32,
fontWeight: '800',
color: '#111827',
lineHeight: 36,
},
hrvUnit: {
fontSize: 14,
fontWeight: '600',
color: '#6B7280',
marginLeft: 2,
},
sectionTitle: {
fontSize: 20,
fontWeight: '700',
color: '#111827',
marginBottom: 20,
fontFamily: 'AliBold',
},
chartContainer: {
marginBottom: 32,
@@ -158,6 +371,15 @@ const styles = StyleSheet.create({
borderRadius: 8,
overflow: 'hidden',
marginBottom: 16,
backgroundColor: '#F3F4F6',
},
progressBarContainer: {
flexDirection: 'row',
width: '100%',
height: '100%',
},
progressSegment: {
height: '100%',
},
gradientBar: {
flex: 1,
@@ -171,96 +393,102 @@ const styles = StyleSheet.create({
alignItems: 'center',
},
legendDot: {
width: 12,
height: 12,
borderRadius: 6,
marginRight: 6,
width: 10,
height: 10,
borderRadius: 5,
marginRight: 8,
},
legendText: {
fontSize: 14,
fontWeight: '600',
color: '#374151',
fontSize: 13,
fontWeight: '500',
color: '#4B5563',
},
statsCard: {
backgroundColor: '#FFFFFF',
borderRadius: 16,
padding: 20,
borderRadius: 20,
padding: 24,
marginBottom: 32,
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.05,
shadowRadius: 8,
elevation: 2,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.04,
shadowRadius: 12,
elevation: 3,
},
statsRow: {
flexDirection: 'row',
gap: 20,
marginBottom: 24,
gap: 24,
marginBottom: 32,
},
statItem: {
flex: 1,
},
statTitle: {
fontSize: 16,
fontWeight: '700',
marginBottom: 8,
fontSize: 15,
fontWeight: '600',
marginBottom: 12,
},
statPercentage: {
fontSize: 36,
fontSize: 32,
fontWeight: '800',
color: '#111827',
marginBottom: 4,
marginBottom: 8,
fontFamily: 'AliBold',
},
statDetails: {
marginBottom: 4,
marginBottom: 8,
},
statRange: {
fontSize: 14,
fontSize: 12,
fontWeight: '600',
color: '#DC2626',
backgroundColor: '#FEE2E2',
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: 10,
paddingVertical: 4,
borderRadius: 6,
alignSelf: 'flex-start',
overflow: 'hidden',
},
statCount: {
fontSize: 16,
fontWeight: '600',
fontSize: 13,
fontWeight: '500',
color: '#6B7280',
},
bottomContainer: {
paddingHorizontal: 20,
paddingBottom: 34,
paddingBottom: Platform.OS === 'ios' ? 34 : 20,
backgroundColor: 'transparent',
},
continueButton: {
borderRadius: 25,
borderRadius: 28,
overflow: 'hidden',
marginBottom: 8,
marginBottom: 12,
shadowColor: '#8B5CF6',
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.3,
shadowRadius: 16,
elevation: 8,
},
glassButton: {
paddingVertical: 18,
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'row',
borderRadius: 28,
},
buttonGradient: {
paddingVertical: 18,
alignItems: 'center',
justifyContent: 'center',
},
buttonBackground: {
backgroundColor: Colors.light.accentGreen, // 应用主色调
paddingVertical: 18,
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'row',
},
buttonText: {
fontSize: 18,
fontWeight: '700',
color: '#192126', // 主色调上的文字颜色
color: '#FFFFFF',
letterSpacing: 0.5,
},
homeIndicator: {
width: 134,
height: 5,
backgroundColor: '#000',
backgroundColor: Platform.OS === 'ios' ? 'rgba(0, 0, 0, 0.3)' : '#000',
borderRadius: 3,
alignSelf: 'center',
},

View File

@@ -1,6 +1,6 @@
import { Image } from '@/components/ui/Image';
import { fetchHRVWithStatus } from '@/utils/health';
import { convertHrvToStressIndex } from '@/utils/stress';
import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient';
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
@@ -15,6 +15,7 @@ export function StressMeter({ curDate }: StressMeterProps) {
const { t } = useTranslation();
const [hrvValue, setHrvValue] = useState(0)
const [updateTime, setUpdateTime] = useState<Date>(new Date())
useEffect(() => {
@@ -32,6 +33,9 @@ export function StressMeter({ curDate }: StressMeterProps) {
if (result.hrvData) {
setHrvValue(Math.round(result.hrvData.value));
if (result.hrvData.recordedAt) {
setUpdateTime(new Date(result.hrvData.recordedAt));
}
console.log(`StressMeter: Using ${result.message}, HRV value: ${result.hrvData.value}ms`);
} else {
console.log('StressMeter: No HRV data obtained');
@@ -92,7 +96,7 @@ export function StressMeter({ curDate }: StressMeterProps) {
{/* 渐变背景进度条 */}
<View style={[styles.progressBar, { width: '100%' }]}>
<LinearGradient
colors={['#EF4444', '#FCD34D', '#10B981']}
colors={['#10B981', '#FCD34D', '#EF4444']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.gradientBar}
@@ -110,7 +114,7 @@ export function StressMeter({ curDate }: StressMeterProps) {
visible={showStressModal}
onClose={() => setShowStressModal(false)}
hrvValue={hrvValue}
updateTime={new Date()}
updateTime={updateTime}
/>
</>
);

View File

@@ -1,10 +1,10 @@
import { Image } from '@/components/ui/Image';
import { useWaterDataByDate } from '@/hooks/useWaterData';
import { appStoreReviewService } from '@/services/appStoreReview';
import { getQuickWaterAmount } from '@/utils/userPreferences';
import { useFocusEffect } from '@react-navigation/native';
import dayjs from 'dayjs';
import * as Haptics from 'expo-haptics';
import { Image } from 'expo-image';
import { useRouter } from 'expo-router';
import LottieView from 'lottie-react-native';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';

View File

@@ -1,6 +1,6 @@
import { Image } from '@/components/ui/Image';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import dayjs from 'dayjs';
import { Image } from 'expo-image';
import { useRouter } from 'expo-router';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';

View File

@@ -1,10 +1,10 @@
import { Image } from '@/components/ui/Image';
import { Toast } from '@/utils/toast.utils';
import { Ionicons } from '@expo/vector-icons';
import dayjs from 'dayjs';
import { BlurView } from 'expo-blur';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient';
import dayjs from 'dayjs';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Animated, Modal, Platform, Pressable, Share, StyleSheet, Text, View } from 'react-native';

View File

@@ -1,7 +1,7 @@
import { Image } from '@/components/ui/Image';
import { useI18n } from '@/hooks/useI18n';
import type { RankingItem } from '@/store/challengesSlice';
import { Ionicons } from '@expo/vector-icons';
import { Image } from 'expo-image';
import React from 'react';
import { StyleSheet, Text, View } from 'react-native';

View File

@@ -1,8 +1,8 @@
import { Image } from '@/components/ui/Image';
import { Colors } from '@/constants/Colors';
import { useColorScheme } from '@/hooks/useColorScheme';
import { Ionicons } from '@expo/vector-icons';
import { BlurView } from 'expo-blur';
import { Image } from 'expo-image';
import React from 'react';
import { ScrollView, StyleSheet, Text, TextInput, TouchableOpacity, View } from 'react-native';
import QuickChips from './QuickChips';

View File

@@ -1,7 +1,7 @@
import { Image } from '@/components/ui/Image';
import { Colors } from '@/constants/Colors';
import { useColorScheme } from '@/hooks/useColorScheme';
import { Ionicons } from '@expo/vector-icons';
import { Image } from 'expo-image';
import React from 'react';
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import Markdown from 'react-native-markdown-display';

View File

@@ -0,0 +1,132 @@
import React, { useEffect, useRef } from 'react';
import { Animated, Easing, StyleSheet, Text, View } from 'react-native';
import Svg, { Circle, Defs, LinearGradient, Stop } from 'react-native-svg';
const AnimatedCircle = Animated.createAnimatedComponent(Circle);
export type HealthProgressRingProps = {
progress: number; // 0-100
size?: number;
strokeWidth?: number;
gradientColors?: string[];
label?: string;
suffix?: string;
title: string;
};
export function HealthProgressRing({
progress,
size = 80,
strokeWidth = 8,
gradientColors = ['#5B4CFF', '#9B8AFB'],
label,
suffix = '%',
title,
}: HealthProgressRingProps) {
const animatedProgress = useRef(new Animated.Value(0)).current;
const radius = (size - strokeWidth) / 2;
const circumference = 2 * Math.PI * radius;
const center = size / 2;
useEffect(() => {
Animated.timing(animatedProgress, {
toValue: progress,
duration: 1000,
easing: Easing.out(Easing.cubic),
useNativeDriver: true,
}).start();
}, [progress]);
const strokeDashoffset = animatedProgress.interpolate({
inputRange: [0, 100],
outputRange: [circumference, 0],
extrapolate: 'clamp',
});
const gradientId = useRef(`grad-${Math.random().toString(36).substr(2, 9)}`).current;
return (
<View style={styles.container}>
<View style={{ width: size, height: size, alignItems: 'center', justifyContent: 'center' }}>
<Svg width={size} height={size}>
<Defs>
<LinearGradient id={gradientId} x1="0" y1="0" x2="1" y2="1">
<Stop offset="0" stopColor={gradientColors[0]} stopOpacity="1" />
<Stop offset="1" stopColor={gradientColors[1]} stopOpacity="1" />
</LinearGradient>
</Defs>
{/* Background Circle */}
<Circle
cx={center}
cy={center}
r={radius}
stroke="#F3F4F6"
strokeWidth={strokeWidth}
fill="none"
/>
{/* Progress Circle */}
<AnimatedCircle
cx={center}
cy={center}
r={radius}
stroke={`url(#${gradientId})`}
strokeWidth={strokeWidth}
fill="none"
strokeDasharray={circumference}
strokeDashoffset={strokeDashoffset}
strokeLinecap="round"
transform={`rotate(-90 ${center} ${center})`}
/>
</Svg>
<View style={styles.centerContent}>
<View style={styles.valueContainer}>
<Text style={styles.valueText}>{label ?? progress}</Text>
<Text style={styles.suffixText}>{suffix}</Text>
</View>
</View>
</View>
<Text style={styles.titleText}>{title}</Text>
</View>
);
}
const styles = StyleSheet.create({
container: {
alignItems: 'center',
justifyContent: 'center',
},
centerContent: {
position: 'absolute',
alignItems: 'center',
justifyContent: 'center',
},
valueContainer: {
flexDirection: 'row',
alignItems: 'flex-end',
},
valueText: {
fontSize: 20,
fontWeight: 'bold',
color: '#1F2937',
fontFamily: 'AliBold',
lineHeight: 24,
},
suffixText: {
fontSize: 12,
color: '#6B7280',
fontWeight: '500',
marginLeft: 1,
marginBottom: 3,
fontFamily: 'AliRegular',
},
titleText: {
marginTop: 8,
fontSize: 14,
color: '#4B5563', // gray-600
fontFamily: 'AliRegular',
},
});

View File

@@ -0,0 +1,151 @@
import { Image } from '@/components/ui/Image';
import { palette } from '@/constants/Colors';
import { MedicalRecordItem } from '@/services/healthProfile';
import { Ionicons } from '@expo/vector-icons';
import dayjs from 'dayjs';
import React from 'react';
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
interface MedicalRecordCardProps {
item: MedicalRecordItem;
onPress: (item: MedicalRecordItem) => void;
onDelete: (item: MedicalRecordItem) => void;
}
export const MedicalRecordCard: React.FC<MedicalRecordCardProps> = ({ item, onPress, onDelete }) => {
const firstAttachment = item.images && item.images.length > 0 ? item.images[0] : null;
const isPdf = firstAttachment?.toLowerCase().endsWith('.pdf');
return (
<TouchableOpacity
style={styles.container}
onPress={() => onPress(item)}
activeOpacity={0.8}
>
<View style={styles.thumbnailContainer}>
{firstAttachment ? (
isPdf ? (
<View style={styles.pdfThumbnail}>
<Ionicons name="document-text" size={32} color="#EF4444" />
<Text style={styles.pdfText}>PDF</Text>
</View>
) : (
<Image
source={{ uri: firstAttachment }}
style={styles.thumbnail}
contentFit="cover"
transition={200}
/>
)
) : (
<View style={styles.placeholderThumbnail}>
<Ionicons name="document-text-outline" size={32} color={palette.gray[300]} />
</View>
)}
{item.images && item.images.length > 1 && (
<View style={styles.badge}>
<Text style={styles.badgeText}>+{item.images.length - 1}</Text>
</View>
)}
</View>
<View style={styles.content}>
<Text style={styles.title} numberOfLines={1}>{item.title}</Text>
<Text style={styles.date}>{dayjs(item.date).format('YYYY-MM-DD')}</Text>
</View>
<TouchableOpacity
style={styles.deleteButton}
onPress={() => onDelete(item)}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Ionicons name="trash-outline" size={16} color={palette.gray[400]} />
</TouchableOpacity>
</TouchableOpacity>
);
};
const styles = StyleSheet.create({
container: {
backgroundColor: '#FFFFFF',
borderRadius: 16,
marginBottom: 12,
shadowColor: palette.gray[200],
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.5,
shadowRadius: 8,
elevation: 2,
overflow: 'hidden',
flexDirection: 'row',
height: 100,
},
thumbnailContainer: {
width: 100,
height: '100%',
backgroundColor: palette.gray[50],
position: 'relative',
},
thumbnail: {
width: '100%',
height: '100%',
},
pdfThumbnail: {
width: '100%',
height: '100%',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#F3F4F6',
},
pdfText: {
fontSize: 10,
marginTop: 4,
color: '#EF4444',
fontWeight: '600',
},
placeholderThumbnail: {
width: '100%',
height: '100%',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: palette.gray[50],
},
badge: {
position: 'absolute',
right: 8,
bottom: 8,
backgroundColor: 'rgba(0,0,0,0.6)',
borderRadius: 10,
paddingHorizontal: 6,
paddingVertical: 2,
},
badgeText: {
color: '#FFFFFF',
fontSize: 10,
fontWeight: '600',
},
content: {
flex: 1,
padding: 12,
justifyContent: 'center',
},
title: {
fontSize: 16,
fontWeight: '600',
color: palette.gray[800],
marginBottom: 4,
fontFamily: 'AliBold',
},
date: {
fontSize: 12,
color: palette.purple[600],
fontWeight: '500',
fontFamily: 'AliRegular',
},
deleteButton: {
position: 'absolute',
top: 8,
right: 8,
padding: 4,
},
});

View File

@@ -0,0 +1,161 @@
import { ROUTES } from '@/constants/Routes';
import { useI18n } from '@/hooks/useI18n';
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import React from 'react';
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
type BasicInfoTabProps = {
healthData: {
bmi: string;
height: string;
weight: string;
waist: string;
};
};
export function BasicInfoTab({ healthData }: BasicInfoTabProps) {
const { t } = useI18n();
const router = useRouter();
const handleHeightWeightPress = () => {
router.push(ROUTES.PROFILE_EDIT);
};
const handleWaistPress = () => {
router.push('/circumference-detail');
};
return (
<View style={styles.card}>
<Text style={styles.cardTitle}>{t('health.tabs.healthProfile.basicInfoCard.title')}</Text>
<View style={styles.metricsGrid}>
{/* BMI - Highlighted */}
<View style={styles.metricItemMain}>
<Text style={styles.metricLabelMain}>{t('health.tabs.healthProfile.basicInfoCard.bmi')}</Text>
<Text style={styles.metricValueMain}>
{healthData.bmi === '--' ? t('health.tabs.healthProfile.basicInfoCard.noData') : healthData.bmi}
</Text>
</View>
{/* Height - Clickable */}
<TouchableOpacity
style={styles.metricItem}
onPress={handleHeightWeightPress}
activeOpacity={0.7}
>
<View style={styles.metricHeaderSmall}>
<Text style={styles.metricValue}>{healthData.height}</Text>
<Ionicons name="chevron-forward" size={12} color="#9CA3AF" />
</View>
<Text style={styles.metricLabel}>
{t('health.tabs.healthProfile.basicInfoCard.height')}/{t('health.tabs.healthProfile.basicInfoCard.heightUnit')}
</Text>
</TouchableOpacity>
{/* Weight - Clickable */}
<TouchableOpacity
style={styles.metricItem}
onPress={handleHeightWeightPress}
activeOpacity={0.7}
>
<View style={styles.metricHeaderSmall}>
<Text style={styles.metricValue}>{healthData.weight}</Text>
<Ionicons name="chevron-forward" size={12} color="#9CA3AF" />
</View>
<Text style={styles.metricLabel}>
{t('health.tabs.healthProfile.basicInfoCard.weight')}/{t('health.tabs.healthProfile.basicInfoCard.weightUnit')}
</Text>
</TouchableOpacity>
{/* Waist - Clickable */}
<TouchableOpacity
style={styles.metricItem}
onPress={handleWaistPress}
activeOpacity={0.7}
>
<View style={styles.metricHeaderSmall}>
<Text style={styles.metricValue}>{healthData.waist}</Text>
<Ionicons name="chevron-forward" size={12} color="#9CA3AF" />
</View>
<Text style={styles.metricLabel}>
{t('health.tabs.healthProfile.basicInfoCard.waist')}/{t('health.tabs.healthProfile.basicInfoCard.waistUnit')}
</Text>
</TouchableOpacity>
</View>
</View>
);
}
const styles = StyleSheet.create({
card: {
backgroundColor: '#FFFFFF',
borderRadius: 20,
padding: 20,
marginBottom: 16,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.03,
shadowRadius: 6,
elevation: 1,
},
cardTitle: {
fontSize: 16,
fontWeight: 'bold',
color: '#1F2937',
marginBottom: 16,
fontFamily: 'AliBold',
},
metricsGrid: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
metricItemMain: {
flex: 1.5,
backgroundColor: '#F5F3FF',
borderRadius: 12,
padding: 12,
marginRight: 12,
alignItems: 'center',
},
metricHeader: {
flexDirection: 'row',
gap: 2,
marginBottom: 8,
},
metricLabelMain: {
fontSize: 14,
color: '#5B4CFF',
fontWeight: 'bold',
marginBottom: 4,
fontFamily: 'AliBold',
},
metricValueMain: {
fontSize: 16,
color: '#5B4CFF',
fontFamily: 'AliRegular',
},
metricItem: {
flex: 1,
alignItems: 'center',
},
metricHeaderSmall: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 8,
gap: 2,
},
metricLabel: {
fontSize: 11,
color: '#6B7280',
marginBottom: 4,
fontFamily: 'AliRegular',
},
metricValue: {
fontSize: 14,
color: '#1F2937',
fontWeight: '600',
fontFamily: 'AliBold',
},
});

View File

@@ -0,0 +1,49 @@
import { Ionicons } from '@expo/vector-icons';
import React from 'react';
import { StyleSheet, Text, View } from 'react-native';
export function CheckupRecordsTab() {
return (
<View style={styles.card}>
<View style={styles.emptyState}>
<Ionicons name="clipboard-outline" size={48} color="#E5E7EB" />
<Text style={styles.emptyText}></Text>
<Text style={styles.emptySubtext}></Text>
</View>
</View>
);
}
const styles = StyleSheet.create({
card: {
backgroundColor: '#FFFFFF',
borderRadius: 20,
padding: 40,
marginBottom: 16,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.03,
shadowRadius: 6,
elevation: 1,
minHeight: 200,
alignItems: 'center',
justifyContent: 'center',
},
emptyState: {
alignItems: 'center',
justifyContent: 'center',
},
emptyText: {
marginTop: 16,
fontSize: 16,
fontWeight: '600',
color: '#374151',
fontFamily: 'AliBold',
},
emptySubtext: {
marginTop: 8,
fontSize: 13,
color: '#9CA3AF',
fontFamily: 'AliRegular',
},
});

View File

@@ -0,0 +1,788 @@
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { HealthHistoryCategory } from '@/services/healthProfile';
import {
HistoryItemDetail,
fetchHealthHistory,
saveHealthHistoryCategory,
selectHealthLoading,
selectHistoryData,
updateHistoryData,
} from '@/store/healthSlice';
import { Ionicons } from '@expo/vector-icons';
import dayjs from 'dayjs';
import { LinearGradient } from 'expo-linear-gradient';
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
ActivityIndicator,
Alert,
KeyboardAvoidingView,
Modal,
Platform,
ScrollView,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
View,
} from 'react-native';
import DateTimePickerModal from 'react-native-modal-datetime-picker';
import { palette } from '../../../constants/Colors';
// Translation Keys for Recommendations
const RECOMMENDATION_KEYS: Record<string, string[]> = {
allergy: ['penicillin', 'sulfonamides', 'peanuts', 'seafood', 'pollen', 'dustMites', 'alcohol', 'mango'],
disease: ['hypertension', 'diabetes', 'asthma', 'heartDisease', 'gastritis', 'migraine'],
surgery: ['appendectomy', 'cesareanSection', 'tonsillectomy', 'fractureRepair', 'none'],
familyDisease: ['hypertension', 'diabetes', 'cancer', 'heartDisease', 'stroke', 'alzheimers'],
};
interface HistoryItemProps {
title: string;
categoryKey: string;
data: {
hasHistory: boolean | null;
items: HistoryItemDetail[];
};
onPress?: () => void;
}
function HistoryItem({ title, categoryKey, data, onPress }: HistoryItemProps) {
const { t } = useTranslation();
const translateItemName = (name: string) => {
const keys = RECOMMENDATION_KEYS[categoryKey];
if (keys && keys.includes(name)) {
return t(`health.tabs.healthProfile.history.recommendationItems.${categoryKey}.${name}`);
}
return name;
};
const hasItems = data.hasHistory === true && data.items.length > 0;
return (
<TouchableOpacity
style={[styles.itemContainer, hasItems && styles.itemContainerWithList]}
onPress={onPress}
activeOpacity={0.7}
>
{/* Header Row */}
<View style={styles.itemHeader}>
<View style={styles.itemLeft}>
<LinearGradient
colors={[palette.purple[400], palette.purple[600]]}
style={styles.indicator}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
/>
<Text style={styles.itemTitle}>{title}</Text>
</View>
{!hasItems && (
<Text style={[
styles.itemStatus,
(data.hasHistory === true && data.items.length === 0) || data.hasHistory === false ? styles.itemStatusActive : null
]}>
{data.hasHistory === null
? t('health.tabs.healthProfile.history.pending')
: data.hasHistory === false
? t('health.tabs.healthProfile.history.modal.none')
: t('health.tabs.healthProfile.history.modal.yesNoDetails')}
</Text>
)}
</View>
{/* List of Items */}
{hasItems && (
<View style={styles.subListContainer}>
{data.items.map(item => (
<View key={item.id} style={styles.subItemRow}>
<View style={styles.subItemDot} />
<Text style={styles.subItemName}>{translateItemName(item.name)}</Text>
{item.date && (
<Text style={styles.subItemDate}>
{dayjs(item.date).format('YYYY-MM-DD')}
</Text>
)}
</View>
))}
</View>
)}
</TouchableOpacity>
);
}
export function HealthHistoryTab() {
const { t } = useTranslation();
const dispatch = useAppDispatch();
// 从 Redux store 获取健康史数据和加载状态
const historyData = useAppSelector(selectHistoryData);
const isLoading = useAppSelector(selectHealthLoading);
// Modal State
const [modalVisible, setModalVisible] = useState(false);
const [currentType, setCurrentType] = useState<string | null>(null);
const [tempHasHistory, setTempHasHistory] = useState<boolean | null>(null);
const [tempItems, setTempItems] = useState<HistoryItemDetail[]>([]);
const [isSaving, setIsSaving] = useState(false);
// Date Picker State
const [isDatePickerVisible, setDatePickerVisibility] = useState(false);
const [currentEditingId, setCurrentEditingId] = useState<string | null>(null);
// 初始化时从服务端获取健康史数据(如果父组件未加载)
useEffect(() => {
// 只在数据为空时才主动拉取,避免重复请求
if (!historyData || Object.keys(historyData).length === 0) {
dispatch(fetchHealthHistory());
}
}, [dispatch, historyData]);
const historyItems = [
{ title: t('health.tabs.healthProfile.history.allergy'), key: 'allergy' },
{ title: t('health.tabs.healthProfile.history.disease'), key: 'disease' },
{ title: t('health.tabs.healthProfile.history.surgery'), key: 'surgery' },
{ title: t('health.tabs.healthProfile.history.familyDisease'), key: 'familyDisease' },
];
// Helper to translate item (try to find key, fallback to item itself)
const translateItem = (type: string, item: string) => {
// Check if item is a predefined key
const keys = RECOMMENDATION_KEYS[type];
if (keys && keys.includes(item)) {
return t(`health.tabs.healthProfile.history.recommendationItems.${type}.${item}`);
}
// Fallback for manual input
return item;
};
// Open Modal
const handleItemPress = (key: string) => {
setCurrentType(key);
const currentData = historyData[key];
setTempHasHistory(currentData.hasHistory);
// Deep copy items to avoid reference issues
setTempItems(currentData.items.map(item => ({ ...item })));
setModalVisible(true);
};
// Close Modal
const handleCloseModal = () => {
setModalVisible(false);
setCurrentType(null);
};
// Save Data
const handleSave = async () => {
if (currentType) {
// Filter out empty items
const validItems = tempItems.filter(item => item.name.trim() !== '');
// If "No" history is selected, clear items
const finalItems = tempHasHistory === false ? [] : validItems;
setIsSaving(true);
try {
// 先乐观更新本地状态
dispatch(updateHistoryData({
type: currentType,
data: {
hasHistory: tempHasHistory,
items: finalItems,
},
}));
// 同步到服务端
await dispatch(saveHealthHistoryCategory({
category: currentType as HealthHistoryCategory,
data: {
hasHistory: tempHasHistory ?? false,
items: finalItems.map(item => ({
name: item.name,
date: item.date ? dayjs(item.date).format('YYYY-MM-DD') : undefined,
isRecommendation: item.isRecommendation,
})),
},
})).unwrap();
handleCloseModal();
} catch (error: any) {
// 如果保存失败,显示错误提示(本地数据已更新,下次打开会从服务端同步)
Alert.alert(
t('health.tabs.healthProfile.history.modal.saveError') || '保存失败',
error?.message || '请稍后重试',
[{ text: t('health.tabs.healthProfile.history.modal.ok') || '确定' }]
);
} finally {
setIsSaving(false);
}
}
};
// Add Item (Manual or Recommendation)
const addItem = (name: string = '', isRecommendation: boolean = false) => {
// Avoid duplicates for recommendations if already exists
if (isRecommendation && tempItems.some(item => item.name === name)) {
return;
}
const newItem: HistoryItemDetail = {
id: Date.now().toString() + Math.random().toString(),
name,
isRecommendation
};
setTempItems([...tempItems, newItem]);
};
// Remove Item
const removeItem = (id: string) => {
setTempItems(tempItems.filter(item => item.id !== id));
};
// Update Item Name
const updateItemName = (id: string, text: string) => {
setTempItems(tempItems.map(item =>
item.id === id ? { ...item, name: text } : item
));
};
// Date Picker Handlers
const showDatePicker = (id: string) => {
setCurrentEditingId(id);
setDatePickerVisibility(true);
};
const hideDatePicker = () => {
setDatePickerVisibility(false);
setCurrentEditingId(null);
};
const handleConfirmDate = (date: Date) => {
if (currentEditingId) {
setTempItems(tempItems.map(item =>
item.id === currentEditingId ? { ...item, date: date.toISOString() } : item
));
}
hideDatePicker();
};
return (
<View style={styles.container}>
{/* Glow effect background */}
<View style={styles.glowContainer}>
<View style={styles.glow} />
</View>
<View style={styles.card}>
{/* Header */}
<View style={styles.header}>
<Text style={styles.headerTitle}>{t('health.tabs.healthProfile.healthHistory')}</Text>
{isLoading && <ActivityIndicator size="small" color={palette.purple[500]} />}
</View>
{/* List */}
<View style={styles.list}>
{historyItems.map((item) => (
<HistoryItem
key={item.key}
title={item.title}
categoryKey={item.key}
data={historyData[item.key]}
onPress={() => handleItemPress(item.key)}
/>
))}
</View>
</View>
{/* Edit Modal */}
<Modal
animationType="fade"
transparent={true}
visible={modalVisible}
onRequestClose={handleCloseModal}
>
<KeyboardAvoidingView
style={styles.modalOverlay}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<View style={styles.modalContent}>
{/* Modal Header */}
<View style={styles.modalHeader}>
<Text style={styles.modalTitle}>
{currentType ? t(`health.tabs.healthProfile.history.${currentType}`) : ''}
</Text>
<TouchableOpacity onPress={handleCloseModal} style={styles.closeButton}>
<Ionicons name="close" size={24} color={palette.gray[400]} />
</TouchableOpacity>
</View>
<ScrollView showsVerticalScrollIndicator={false}>
{/* Question: Do you have history? */}
<Text style={styles.questionText}>
{t('health.tabs.healthProfile.history.modal.question', {
type: currentType ? t(`health.tabs.healthProfile.history.${currentType}`) : ''
})}
</Text>
<View style={styles.radioGroup}>
<TouchableOpacity
style={[
styles.radioButton,
tempHasHistory === true && styles.radioButtonActive
]}
onPress={() => setTempHasHistory(true)}
>
<Text style={[
styles.radioText,
tempHasHistory === true && styles.radioTextActive
]}>{t('health.tabs.healthProfile.history.modal.yes')}</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.radioButton,
tempHasHistory === false && styles.radioButtonActive
]}
onPress={() => setTempHasHistory(false)}
>
<Text style={[
styles.radioText,
tempHasHistory === false && styles.radioTextActive
]}>{t('health.tabs.healthProfile.history.modal.no')}</Text>
</TouchableOpacity>
</View>
{/* Conditional Content */}
{tempHasHistory === true && currentType && (
<View style={styles.detailsContainer}>
{/* Recommendations */}
{RECOMMENDATION_KEYS[currentType] && (
<View style={styles.recommendationContainer}>
<Text style={styles.sectionLabel}>{t('health.tabs.healthProfile.history.modal.recommendations')}</Text>
<View style={styles.tagsContainer}>
{RECOMMENDATION_KEYS[currentType].map((tagKey, index) => (
<TouchableOpacity
key={index}
style={styles.tag}
onPress={() => addItem(tagKey, true)}
>
<Text style={styles.tagText}>
{t(`health.tabs.healthProfile.history.recommendationItems.${currentType}.${tagKey}`)}
</Text>
<Ionicons name="add" size={16} color={palette.gray[600]} style={{ marginLeft: 4 }} />
</TouchableOpacity>
))}
</View>
</View>
)}
{/* History List Items */}
<View style={styles.listContainer}>
<Text style={styles.sectionLabel}>{t('health.tabs.healthProfile.history.modal.addDetails')}</Text>
{tempItems.map((item) => (
<View key={item.id} style={styles.listItemCard}>
<View style={styles.listItemHeader}>
<TextInput
style={styles.listItemNameInput}
placeholder={t('health.tabs.healthProfile.history.modal.namePlaceholder')}
placeholderTextColor={palette.gray[300]}
value={item.isRecommendation ? translateItem(currentType!, item.name) : item.name}
onChangeText={(text) => updateItemName(item.id, text)}
editable={!item.isRecommendation}
/>
<TouchableOpacity onPress={() => removeItem(item.id)} style={styles.deleteButton}>
<Ionicons name="trash-outline" size={20} color={palette.error[500]} />
</TouchableOpacity>
</View>
<TouchableOpacity
style={styles.datePickerTrigger}
onPress={() => showDatePicker(item.id)}
>
<Ionicons name="calendar-outline" size={18} color={palette.purple[500]} />
<Text style={[
styles.dateText,
!item.date && styles.placeholderText
]}>
{item.date
? dayjs(item.date).format('YYYY-MM-DD')
: t('health.tabs.healthProfile.history.modal.selectDate')}
</Text>
<Ionicons name="chevron-down" size={14} color={palette.gray[400]} style={{ marginLeft: 'auto' }} />
</TouchableOpacity>
</View>
))}
{/* Add Button */}
<TouchableOpacity style={styles.addItemButton} onPress={() => addItem()}>
<Ionicons name="add-circle" size={20} color={palette.purple[500]} />
<Text style={styles.addItemText}>{t('health.tabs.healthProfile.history.modal.addItem')}</Text>
</TouchableOpacity>
</View>
</View>
)}
</ScrollView>
{/* Save Button */}
<View style={styles.modalFooter}>
<TouchableOpacity
style={[styles.saveButton, isSaving && styles.saveButtonDisabled]}
onPress={handleSave}
disabled={isSaving}
>
<LinearGradient
colors={isSaving ? [palette.gray[300], palette.gray[400]] : [palette.purple[500], palette.purple[700]]}
style={styles.saveButtonGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
>
{isSaving ? (
<ActivityIndicator size="small" color="#FFFFFF" />
) : (
<Text style={styles.saveButtonText}>{t('health.tabs.healthProfile.history.modal.save')}</Text>
)}
</LinearGradient>
</TouchableOpacity>
</View>
</View>
<DateTimePickerModal
isVisible={isDatePickerVisible}
mode="date"
onConfirm={handleConfirmDate}
onCancel={hideDatePicker}
maximumDate={new Date()} // Cannot select future date for history
confirmTextIOS={t('health.tabs.healthProfile.history.modal.save')} // Reuse save
cancelTextIOS={t('health.tabs.healthProfile.history.modal.none') === 'None' ? 'Cancel' : '取消'} // Fallback
/>
</KeyboardAvoidingView>
</Modal>
</View>
);
}
const styles = StyleSheet.create({
container: {
marginBottom: 16,
position: 'relative',
},
glowContainer: {
position: 'absolute',
top: 20,
left: 20,
right: 20,
bottom: 20,
alignItems: 'center',
justifyContent: 'center',
zIndex: -1,
},
glow: {
width: '90%',
height: '90%',
backgroundColor: palette.purple[200],
opacity: 0.3,
borderRadius: 40,
transform: [{ scale: 1.05 }],
shadowColor: palette.purple[500],
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.4,
shadowRadius: 20,
},
card: {
backgroundColor: '#FFFFFF',
borderRadius: 24,
padding: 20,
shadowColor: palette.purple[100],
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.6,
shadowRadius: 24,
elevation: 4,
borderWidth: 1,
borderColor: '#F5F3FF',
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 12,
paddingHorizontal: 4,
},
headerTitle: {
fontSize: 18,
fontFamily: 'AliBold',
color: palette.gray[900],
fontWeight: '600',
},
list: {
backgroundColor: '#FFFFFF',
},
itemContainer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 16,
paddingHorizontal: 4,
},
itemContainerWithList: {
flexDirection: 'column',
alignItems: 'stretch',
justifyContent: 'flex-start',
},
itemHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
width: '100%',
},
itemLeft: {
flexDirection: 'row',
alignItems: 'center',
},
indicator: {
width: 4,
height: 14,
borderRadius: 2,
marginRight: 12,
},
itemTitle: {
fontSize: 16,
color: palette.gray[700],
fontFamily: 'AliRegular',
},
itemStatus: {
fontSize: 14,
color: palette.gray[300],
fontFamily: 'AliRegular',
textAlign: 'right',
maxWidth: 150,
},
itemStatusActive: {
color: palette.purple[600],
fontWeight: '500',
},
subListContainer: {
marginTop: 12,
paddingLeft: 16, // Align with title (4px indicator + 12px margin)
},
subItemRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 6,
},
subItemDot: {
width: 6,
height: 6,
borderRadius: 3,
backgroundColor: palette.purple[300],
marginRight: 8,
},
subItemName: {
flex: 1,
fontSize: 15,
color: palette.gray[800],
fontFamily: 'AliRegular',
fontWeight: '500',
},
subItemDate: {
fontSize: 13,
color: palette.gray[400],
fontFamily: 'AliRegular',
},
// Modal Styles
modalOverlay: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
justifyContent: 'center',
alignItems: 'center',
padding: 20,
},
modalContent: {
width: '100%',
backgroundColor: '#FFFFFF',
borderRadius: 24,
padding: 24,
maxHeight: '85%', // Increased height
shadowColor: '#000',
shadowOffset: { width: 0, height: 10 },
shadowOpacity: 0.2,
shadowRadius: 20,
elevation: 10,
},
modalHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 20,
},
modalTitle: {
fontSize: 20,
fontFamily: 'AliBold',
color: palette.gray[900],
fontWeight: '600',
},
closeButton: {
padding: 4,
},
questionText: {
fontSize: 16,
color: palette.gray[700],
marginBottom: 12,
fontFamily: 'AliRegular',
},
radioGroup: {
flexDirection: 'row',
marginBottom: 24,
},
radioButton: {
flex: 1,
paddingVertical: 12,
borderWidth: 1,
borderColor: palette.gray[200],
borderRadius: 12,
alignItems: 'center',
marginRight: 8,
},
radioButtonActive: {
backgroundColor: palette.purple[50],
borderColor: palette.purple[500],
},
radioText: {
fontSize: 16,
color: palette.gray[600],
fontWeight: '500',
},
radioTextActive: {
color: palette.purple[600],
fontWeight: '600',
},
detailsContainer: {
marginTop: 4,
},
sectionLabel: {
fontSize: 14,
color: palette.gray[500],
marginBottom: 12,
marginTop: 8,
fontFamily: 'AliRegular',
},
recommendationContainer: {
marginBottom: 20,
},
tagsContainer: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 10,
},
tag: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 14,
paddingVertical: 8,
borderRadius: 20,
backgroundColor: '#F5F7FA',
},
tagText: {
fontSize: 14,
color: palette.gray[600],
fontFamily: 'AliRegular',
},
listContainer: {
marginTop: 8,
},
listItemCard: {
backgroundColor: '#F9FAFB',
borderRadius: 16,
padding: 16,
marginBottom: 12,
borderWidth: 1,
borderColor: palette.gray[100],
},
listItemHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 12,
},
listItemNameInput: {
flex: 1,
fontSize: 16,
fontWeight: '600',
color: palette.gray[900],
fontFamily: 'AliBold',
padding: 0,
},
deleteButton: {
padding: 4,
},
datePickerTrigger: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#FFFFFF',
paddingHorizontal: 12,
paddingVertical: 10,
borderRadius: 12,
borderWidth: 1,
borderColor: palette.gray[200],
},
dateText: {
marginLeft: 8,
fontSize: 14,
color: palette.gray[900],
fontFamily: 'AliRegular',
},
placeholderText: {
color: palette.gray[400],
},
addItemButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 12,
borderWidth: 1,
borderColor: palette.purple[200],
borderRadius: 12,
borderStyle: 'dashed',
backgroundColor: palette.purple[25],
marginTop: 4,
marginBottom: 20,
},
addItemText: {
marginLeft: 8,
fontSize: 14,
color: palette.purple[600],
fontWeight: '500',
},
modalFooter: {
marginTop: 8,
},
saveButton: {
borderRadius: 16,
overflow: 'hidden',
shadowColor: palette.purple[500],
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 4,
},
saveButtonDisabled: {
shadowOpacity: 0,
},
saveButtonGradient: {
paddingVertical: 14,
alignItems: 'center',
},
saveButtonText: {
fontSize: 16,
color: '#FFFFFF',
fontWeight: '600',
fontFamily: 'AliBold',
},
});

View File

@@ -0,0 +1,666 @@
import { MedicalRecordCard } from '@/components/health/MedicalRecordCard';
import { Image } from '@/components/ui/Image';
import { palette } from '@/constants/Colors';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useCosUpload } from '@/hooks/useCosUpload';
import { MedicalRecordItem, MedicalRecordType } from '@/services/healthProfile';
import {
addNewMedicalRecord,
deleteMedicalRecordItem,
fetchMedicalRecords,
selectHealthLoading,
selectMedicalRecords,
} from '@/store/healthSlice';
import { Ionicons } from '@expo/vector-icons';
import dayjs from 'dayjs';
import * as DocumentPicker from 'expo-document-picker';
import * as ImagePicker from 'expo-image-picker';
import { LinearGradient } from 'expo-linear-gradient';
import React, { useEffect, useState } from 'react';
import {
ActivityIndicator,
Alert,
FlatList,
Modal,
Platform,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
View,
} from 'react-native';
import ImageViewing from 'react-native-image-viewing';
import DateTimePickerModal from 'react-native-modal-datetime-picker';
export function MedicalRecordsTab() {
const dispatch = useAppDispatch();
const medicalRecords = useAppSelector(selectMedicalRecords);
const records = medicalRecords?.records || [];
const prescriptions = medicalRecords?.prescriptions || [];
const isLoading = useAppSelector(selectHealthLoading);
// COS 上传
const { upload: uploadToCos, uploading: isUploading } = useCosUpload({
prefix: 'images/health/medical-records'
});
const [activeTab, setActiveTab] = useState<MedicalRecordType>('medical_record');
const [isModalVisible, setModalVisible] = useState(false);
const [isDatePickerVisible, setDatePickerVisibility] = useState(false);
// Form State
const [title, setTitle] = useState('');
const [date, setDate] = useState(new Date());
const [images, setImages] = useState<string[]>([]);
const [note, setNote] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
// Image Viewer State
const [viewerVisible, setViewerVisible] = useState(false);
const [currentViewerImages, setCurrentViewerImages] = useState<{ uri: string }[]>([]);
useEffect(() => {
dispatch(fetchMedicalRecords());
}, [dispatch]);
const currentList = activeTab === 'medical_record' ? records : prescriptions;
const handleTabPress = (tab: MedicalRecordType) => {
setActiveTab(tab);
};
const resetForm = () => {
setTitle('');
setDate(new Date());
setImages([]);
setNote('');
};
const openAddModal = () => {
resetForm();
setModalVisible(true);
};
const handlePickImage = async () => {
const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (status !== 'granted') {
Alert.alert('需要权限', '请允许访问相册以上传图片');
return;
}
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
allowsEditing: true,
quality: 0.8,
});
if (!result.canceled && result.assets && result.assets.length > 0) {
setImages([...images, result.assets[0].uri]);
}
};
const handleTakePhoto = async () => {
const { status } = await ImagePicker.requestCameraPermissionsAsync();
if (status !== 'granted') {
Alert.alert('需要权限', '请允许访问相机以拍摄照片');
return;
}
const result = await ImagePicker.launchCameraAsync({
allowsEditing: true,
quality: 0.8,
});
if (!result.canceled && result.assets && result.assets.length > 0) {
setImages([...images, result.assets[0].uri]);
}
};
const handlePickDocument = async () => {
try {
const result = await DocumentPicker.getDocumentAsync({
type: ['application/pdf', 'image/*'],
copyToCacheDirectory: true,
multiple: false,
});
if (!result.canceled && result.assets && result.assets.length > 0) {
setImages([...images, result.assets[0].uri]);
}
} catch (error) {
console.error('Error picking document:', error);
Alert.alert('错误', '选择文件失败');
}
};
const handleSubmit = async () => {
if (!title.trim()) {
Alert.alert('提示', '请输入标题');
return;
}
if (images.length === 0) {
Alert.alert('提示', '请至少上传一张图片');
return;
}
setIsSubmitting(true);
try {
// 1. 上传所有图片到 COS
const uploadPromises = images.map(async (uri) => {
const result = await uploadToCos({ uri });
return result.url;
});
const uploadedUrls = await Promise.all(uploadPromises);
// 2. 创建就医资料记录
await dispatch(addNewMedicalRecord({
type: activeTab,
title: title.trim(),
date: dayjs(date).format('YYYY-MM-DD'),
images: uploadedUrls,
note: note.trim() || undefined,
})).unwrap();
setModalVisible(false);
resetForm();
} catch (error: any) {
console.error('保存失败:', error);
const errorMessage = error?.message || '保存失败,请重试';
Alert.alert('错误', errorMessage);
} finally {
setIsSubmitting(false);
}
};
const handleDelete = (item: MedicalRecordItem) => {
Alert.alert(
'确认删除',
'确定要删除这条记录吗?',
[
{ text: '取消', style: 'cancel' },
{
text: '删除',
style: 'destructive',
onPress: () => dispatch(deleteMedicalRecordItem({ id: item.id, type: item.type })),
},
]
);
};
const handleViewImages = (item: MedicalRecordItem) => {
if (item.images && item.images.length > 0) {
setCurrentViewerImages(item.images.map(uri => ({ uri })));
setViewerVisible(true);
}
};
const renderItem = ({ item }: { item: MedicalRecordItem }) => (
<MedicalRecordCard
item={item}
onPress={handleViewImages}
onDelete={handleDelete}
/>
);
return (
<View style={styles.container}>
{/* Segmented Control */}
<View style={styles.segmentContainer}>
<TouchableOpacity
style={[styles.segmentButton, activeTab === 'medical_record' && styles.segmentButtonActive]}
onPress={() => handleTabPress('medical_record')}
activeOpacity={0.8}
>
<Text style={[styles.segmentText, activeTab === 'medical_record' && styles.segmentTextActive]}>
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.segmentButton, activeTab === 'prescription' && styles.segmentButtonActive]}
onPress={() => handleTabPress('prescription')}
activeOpacity={0.8}
>
<Text style={[styles.segmentText, activeTab === 'prescription' && styles.segmentTextActive]}>
</Text>
</TouchableOpacity>
</View>
{/* Content List */}
<View style={styles.contentContainer}>
{isLoading && records.length === 0 && prescriptions.length === 0 ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={palette.purple[500]} />
</View>
) : currentList.length > 0 ? (
<FlatList
data={currentList}
renderItem={renderItem}
keyExtractor={(item) => item.id}
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.listContent}
scrollEnabled={false} // Since it's inside a parent ScrollView
/>
) : (
<View style={styles.emptyState}>
<View style={styles.emptyIconContainer}>
<Ionicons
name={activeTab === 'medical_record' ? "folder-open-outline" : "receipt-outline"}
size={48}
color={palette.gray[300]}
/>
</View>
<Text style={styles.emptyText}>
{activeTab === 'medical_record' ? '暂无病历资料' : '暂无处方单据'}
</Text>
<Text style={styles.emptySubtext}>
{activeTab === 'medical_record' ? '上传您的检查报告、诊断证明等' : '上传您的处方单、用药清单等'}
</Text>
</View>
)}
</View>
{/* Add Button */}
<TouchableOpacity
style={styles.fab}
onPress={openAddModal}
activeOpacity={0.9}
>
<LinearGradient
colors={[palette.purple[500], palette.purple[700]]}
style={styles.fabGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<Ionicons name="add" size={28} color="#FFFFFF" />
</LinearGradient>
</TouchableOpacity>
{/* Add/Edit Modal */}
<Modal
visible={isModalVisible}
animationType="slide"
presentationStyle="pageSheet"
onRequestClose={() => setModalVisible(false)}
>
<View style={styles.modalContainer}>
<View style={styles.modalHeader}>
<TouchableOpacity onPress={() => setModalVisible(false)} style={styles.modalCloseButton}>
<Text style={styles.modalCloseText}></Text>
</TouchableOpacity>
<Text style={styles.modalTitle}>
{activeTab === 'medical_record' ? '添加病历' : '添加处方'}
</Text>
<TouchableOpacity
onPress={handleSubmit}
style={[styles.modalSaveButton, (isSubmitting || isUploading) && styles.modalSaveButtonDisabled]}
disabled={isSubmitting || isUploading}
>
{(isSubmitting || isUploading) ? (
<ActivityIndicator size="small" color="#FFFFFF" />
) : (
<Text style={styles.modalSaveText}></Text>
)}
</TouchableOpacity>
</View>
<View style={styles.formContainer}>
{/* Title Input */}
<View style={styles.inputGroup}>
<Text style={styles.label}> <Text style={styles.required}>*</Text></Text>
<TextInput
style={styles.input}
placeholder={activeTab === 'medical_record' ? "例如:血常规检查" : "例如:感冒药处方"}
value={title}
onChangeText={setTitle}
placeholderTextColor={palette.gray[400]}
/>
</View>
{/* Date Picker */}
<View style={styles.inputGroup}>
<Text style={styles.label}></Text>
<TouchableOpacity
style={styles.dateInput}
onPress={() => setDatePickerVisibility(true)}
>
<Text style={styles.dateText}>{dayjs(date).format('YYYY年MM月DD日')}</Text>
<Ionicons name="calendar-outline" size={20} color={palette.gray[500]} />
</TouchableOpacity>
</View>
{/* Images */}
<View style={styles.inputGroup}>
<Text style={styles.label}> <Text style={styles.required}>*</Text></Text>
<View style={styles.imageGrid}>
{images.map((uri, index) => {
const isPdf = uri.toLowerCase().endsWith('.pdf');
return (
<View key={index} style={styles.imagePreviewContainer}>
{isPdf ? (
<View style={[styles.imagePreview, styles.pdfPreview]}>
<Ionicons name="document-text" size={32} color="#EF4444" />
<Text style={styles.pdfText} numberOfLines={1}>PDF</Text>
</View>
) : (
<Image
source={{ uri }}
style={styles.imagePreview}
contentFit="cover"
/>
)}
<TouchableOpacity
style={styles.removeImageButton}
onPress={() => setImages(images.filter((_, i) => i !== index))}
>
<Ionicons name="close-circle" size={20} color={palette.error[500]} />
</TouchableOpacity>
</View>
);
})}
{images.length < 9 && (
<TouchableOpacity style={styles.addImageButton} onPress={() => {
Alert.alert(
'上传文件',
'请选择上传方式',
[
{ text: '拍照', onPress: handleTakePhoto },
{ text: '从相册选择', onPress: handlePickImage },
{ text: '选择文档 (PDF)', onPress: handlePickDocument },
{ text: '取消', style: 'cancel' },
]
);
}}>
<Ionicons name="add" size={32} color={palette.purple[500]} />
<Text style={styles.addImageText}></Text>
</TouchableOpacity>
)}
</View>
</View>
{/* Note */}
<View style={styles.inputGroup}>
<Text style={styles.label}></Text>
<TextInput
style={[styles.input, styles.textArea]}
placeholder="添加备注信息..."
value={note}
onChangeText={setNote}
multiline
numberOfLines={4}
placeholderTextColor={palette.gray[400]}
textAlignVertical="top"
/>
</View>
</View>
</View>
<DateTimePickerModal
isVisible={isDatePickerVisible}
mode="date"
onConfirm={(d) => {
setDate(d);
setDatePickerVisibility(false);
}}
onCancel={() => setDatePickerVisibility(false)}
maximumDate={new Date()}
locale="zh_CN"
confirmTextIOS="确定"
cancelTextIOS="取消"
/>
</Modal>
<ImageViewing
images={currentViewerImages}
imageIndex={0}
visible={viewerVisible}
onRequestClose={() => setViewerVisible(false)}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
segmentContainer: {
flexDirection: 'row',
backgroundColor: '#F3F4F6',
borderRadius: 12,
padding: 4,
marginBottom: 16,
},
segmentButton: {
flex: 1,
paddingVertical: 10,
alignItems: 'center',
borderRadius: 10,
},
segmentButtonActive: {
backgroundColor: '#FFFFFF',
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
elevation: 1,
},
segmentText: {
fontSize: 14,
fontWeight: '500',
color: '#6B7280',
fontFamily: 'AliRegular',
},
segmentTextActive: {
color: palette.purple[600],
fontWeight: '600',
fontFamily: 'AliBold',
},
contentContainer: {
minHeight: 300,
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingVertical: 40,
},
listContent: {
paddingBottom: 80,
},
emptyState: {
alignItems: 'center',
justifyContent: 'center',
marginTop: 40,
paddingHorizontal: 40,
},
emptyIconContainer: {
width: 80,
height: 80,
borderRadius: 40,
backgroundColor: '#F9FAFB',
alignItems: 'center',
justifyContent: 'center',
marginBottom: 16,
},
emptyText: {
fontSize: 16,
fontWeight: '600',
color: '#374151',
marginBottom: 8,
fontFamily: 'AliBold',
},
emptySubtext: {
fontSize: 13,
color: '#9CA3AF',
textAlign: 'center',
lineHeight: 20,
fontFamily: 'AliRegular',
},
fab: {
position: 'absolute',
right: 16,
bottom: 16,
width: 56,
height: 56,
borderRadius: 28,
shadowColor: palette.purple[500],
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 6,
},
fabGradient: {
width: '100%',
height: '100%',
borderRadius: 28,
alignItems: 'center',
justifyContent: 'center',
},
// Modal Styles
modalContainer: {
flex: 1,
backgroundColor: '#F9FAFB',
},
modalHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 12,
backgroundColor: '#FFFFFF',
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: '#E5E7EB',
paddingTop: Platform.OS === 'ios' ? 12 : 12,
},
modalCloseButton: {
padding: 8,
},
modalCloseText: {
fontSize: 16,
color: '#6B7280',
fontFamily: 'AliRegular',
},
modalTitle: {
fontSize: 17,
fontWeight: '600',
color: '#111827',
fontFamily: 'AliBold',
},
modalSaveButton: {
paddingHorizontal: 12,
paddingVertical: 6,
backgroundColor: palette.purple[600],
borderRadius: 6,
},
modalSaveButtonDisabled: {
opacity: 0.6,
},
modalSaveText: {
fontSize: 14,
fontWeight: '600',
color: '#FFFFFF',
fontFamily: 'AliBold',
},
formContainer: {
padding: 16,
},
inputGroup: {
marginBottom: 20,
},
label: {
fontSize: 14,
fontWeight: '500',
color: '#374151',
marginBottom: 8,
fontFamily: 'AliRegular',
},
required: {
color: palette.error[500],
},
input: {
backgroundColor: '#FFFFFF',
borderRadius: 12,
paddingHorizontal: 12,
paddingVertical: 12,
fontSize: 16,
color: '#111827',
borderWidth: 1,
borderColor: '#E5E7EB',
fontFamily: 'AliRegular',
},
textArea: {
height: 100,
textAlignVertical: 'top',
},
dateInput: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
backgroundColor: '#FFFFFF',
borderRadius: 12,
paddingHorizontal: 12,
paddingVertical: 12,
borderWidth: 1,
borderColor: '#E5E7EB',
},
dateText: {
fontSize: 16,
color: '#111827',
fontFamily: 'AliRegular',
},
imageGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 12,
},
imagePreviewContainer: {
width: 80,
height: 80,
borderRadius: 8,
overflow: 'hidden',
position: 'relative',
},
imagePreview: {
width: '100%',
height: '100%',
},
pdfPreview: {
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#F3F4F6',
},
pdfText: {
fontSize: 10,
marginTop: 4,
color: '#EF4444',
fontWeight: '600',
},
removeImageButton: {
position: 'absolute',
top: 2,
right: 2,
backgroundColor: 'rgba(255,255,255,0.8)',
borderRadius: 10,
},
addImageButton: {
width: 80,
height: 80,
borderRadius: 8,
borderWidth: 1,
borderColor: palette.purple[200],
borderStyle: 'dashed',
backgroundColor: palette.purple[50],
alignItems: 'center',
justifyContent: 'center',
},
addImageText: {
fontSize: 12,
color: palette.purple[600],
marginTop: 4,
fontFamily: 'AliRegular',
},
});

View File

@@ -1,5 +1,5 @@
import { Image } from '@/components/ui/Image';
import { Ionicons } from '@expo/vector-icons';
import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient';
import React, { useEffect, useRef, useState } from 'react';
import { Animated, Modal, Pressable, StyleSheet, Text, TouchableOpacity, View } from 'react-native';

View File

@@ -1,4 +1,5 @@
import { ThemedText } from '@/components/ThemedText';
import { Image } from '@/components/ui/Image';
import { useAppDispatch } from '@/hooks/redux';
import { useI18n } from '@/hooks/useI18n';
import { skipMedicationAction, takeMedicationAction } from '@/store/medicationsSlice';
@@ -6,7 +7,6 @@ import type { MedicationDisplayItem } from '@/types/medication';
import { Ionicons } from '@expo/vector-icons';
import dayjs, { Dayjs } from 'dayjs';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { Image } from 'expo-image';
import React, { useEffect, useState } from 'react';
import { Alert, StyleSheet, TouchableOpacity, View } from 'react-native';

View File

@@ -1,7 +1,7 @@
import { Image } from '@/components/ui/Image';
import { useI18n } from '@/hooks/useI18n';
import { Ionicons } from '@expo/vector-icons';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient';
import React from 'react';
import {

View File

@@ -0,0 +1,77 @@
import { Colors } from '@/constants/Colors';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { STATUS_COLORS } from './constants';
import { DayCellProps } from './types';
export const DayCell: React.FC<DayCellProps> = ({ cell, isSelected, onPress }) => {
const { t } = useTranslation();
const status = cell.info?.status;
const colors = status ? STATUS_COLORS[status] : undefined;
return (
<TouchableOpacity
activeOpacity={0.8}
style={styles.dayCell}
onPress={onPress}
>
<View
style={[
styles.dayCircle,
colors && { backgroundColor: colors.bg },
isSelected && styles.dayCircleSelected,
cell.isToday && styles.todayOutline,
]}
>
<Text
style={[
styles.dayLabel,
colors && { color: colors.text },
!colors && styles.dayLabelDefault,
]}
>
{cell.label}
</Text>
</View>
{cell.isToday && <Text style={styles.todayText}>{t('menstrual.today')}</Text>}
</TouchableOpacity>
);
};
const styles = StyleSheet.create({
dayCell: {
width: '14.28%',
alignItems: 'center',
marginVertical: 6,
},
dayCircle: {
width: 40,
height: 40,
borderRadius: 20,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#f3f4f6',
},
dayCircleSelected: {
borderWidth: 2,
borderColor: Colors.light.primary,
},
todayOutline: {
borderWidth: 2,
borderColor: '#94a3b8',
},
dayLabel: {
fontSize: 15,
fontFamily: 'AliBold',
},
dayLabelDefault: {
color: '#111827',
},
todayText: {
fontSize: 10,
color: '#9ca3af',
marginTop: 2,
fontFamily: 'AliRegular',
},
});

View File

@@ -0,0 +1,119 @@
import { Colors } from '@/constants/Colors';
import { Ionicons } from '@expo/vector-icons';
import dayjs from 'dayjs';
import 'dayjs/locale/en';
import 'dayjs/locale/zh-cn';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { DimensionValue, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { InlineTipProps } from './types';
export const InlineTip: React.FC<InlineTipProps> = ({
selectedDate,
selectedInfo,
columnIndex,
onMarkStart,
onCancelMark,
}) => {
const { t, i18n } = useTranslation();
// 14.28% per cell. Center is 7.14%.
const pointerLeft = `${columnIndex * 14.2857 + 7.1428}%` as DimensionValue;
const isFuture = selectedDate.isAfter(dayjs(), 'day');
const localeKey = i18n.language.startsWith('en') ? 'en' : 'zh-cn';
const dateFormat = t('menstrual.dateFormatShort', { defaultValue: 'M月D日' });
return (
<View style={styles.inlineTipCard}>
<View style={[styles.inlineTipPointer, { left: pointerLeft }]} />
<View style={styles.inlineTipRow}>
<View style={styles.inlineTipDate}>
<Ionicons name="calendar-outline" size={16} color="#111827" />
<Text style={styles.inlineTipDateText}>
{selectedDate.locale(localeKey).format(dateFormat)}
</Text>
</View>
{!isFuture && (!selectedInfo || !selectedInfo.confirmed) && (
<TouchableOpacity style={styles.inlinePrimaryBtn} onPress={onMarkStart}>
<Ionicons name="add" size={14} color="#fff" />
<Text style={styles.inlinePrimaryText}>{t('menstrual.actions.markPeriod')}</Text>
</TouchableOpacity>
)}
{!isFuture && selectedInfo?.confirmed && selectedInfo.status === 'period' && (
<TouchableOpacity style={styles.inlineSecondaryBtn} onPress={onCancelMark}>
<Text style={styles.inlineSecondaryText}>{t('menstrual.actions.cancelMark')}</Text>
</TouchableOpacity>
)}
</View>
</View>
);
};
const styles = StyleSheet.create({
inlineTipCard: {
backgroundColor: '#e8e7ff',
borderRadius: 18,
paddingVertical: 10,
paddingHorizontal: 12,
shadowColor: '#000',
shadowOpacity: 0.04,
shadowRadius: 6,
shadowOffset: { width: 0, height: 2 },
elevation: 1,
},
inlineTipPointer: {
position: 'absolute',
top: -6,
width: 12,
height: 12,
marginLeft: -6,
backgroundColor: '#e8e7ff',
transform: [{ rotate: '45deg' }],
borderRadius: 3,
},
inlineTipRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: 8,
},
inlineTipDate: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
inlineTipDateText: {
fontSize: 14,
color: '#111827',
fontWeight: '800',
fontFamily: 'AliBold',
},
inlinePrimaryBtn: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: Colors.light.primary,
paddingHorizontal: 12,
paddingVertical: 8,
borderRadius: 14,
gap: 6,
},
inlinePrimaryText: {
color: '#fff',
fontSize: 13,
fontWeight: '700',
fontFamily: 'AliBold',
},
inlineSecondaryBtn: {
paddingHorizontal: 12,
paddingVertical: 8,
borderRadius: 14,
backgroundColor: '#fff',
borderWidth: 1,
borderColor: '#d1d5db',
},
inlineSecondaryText: {
color: '#111827',
fontSize: 13,
fontWeight: '700',
fontFamily: 'AliBold',
},
});

View File

@@ -0,0 +1,61 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { StyleSheet, Text, View } from 'react-native';
import { STATUS_COLORS } from './constants';
import { LegendItem } from './types';
export const Legend: React.FC = () => {
const { t } = useTranslation();
const legendItems: LegendItem[] = [
{ label: t('menstrual.legend.period'), key: 'period' },
{ label: t('menstrual.legend.predictedPeriod'), key: 'predicted-period' },
{ label: t('menstrual.legend.fertile'), key: 'fertile' },
{ label: t('menstrual.legend.ovulation'), key: 'ovulation-day' },
];
return (
<View style={styles.legendRow}>
{legendItems.map((item) => (
<View key={item.key} style={styles.legendItem}>
<View
style={[
styles.legendDot,
{ backgroundColor: STATUS_COLORS[item.key].bg },
item.key === 'ovulation-day' && styles.legendDotRing,
]}
/>
<Text style={styles.legendLabel}>{item.label}</Text>
</View>
))}
</View>
);
};
const styles = StyleSheet.create({
legendRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 12,
marginBottom: 12,
paddingHorizontal: 4,
},
legendItem: {
flexDirection: 'row',
alignItems: 'center',
},
legendDot: {
width: 16,
height: 16,
borderRadius: 8,
marginRight: 6,
},
legendDotRing: {
borderWidth: 2,
borderColor: '#fff',
},
legendLabel: {
fontSize: 13,
color: '#111827',
fontFamily: 'AliRegular',
},
});

View File

@@ -0,0 +1,140 @@
import { MenstrualTimeline } from '@/utils/menstrualCycle';
import React, { useMemo } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { DayCell } from './DayCell';
import { WEEK_LABELS } from './constants';
const chunkArray = <T,>(array: T[], size: number): T[][] => {
const result: T[][] = [];
for (let i = 0; i < array.length; i += size) {
result.push(array.slice(i, i + size));
}
return result;
};
interface MonthBlockProps {
month: MenstrualTimeline['months'][number];
selectedDateKey: string;
onSelect: (dateKey: string) => void;
renderTip: (colIndex: number) => React.ReactNode;
weekLabels?: string[];
}
export const MonthBlock: React.FC<MonthBlockProps> = ({
month,
selectedDateKey,
onSelect,
renderTip,
weekLabels,
}) => {
const weeks = useMemo(() => chunkArray(month.cells, 7), [month.cells]);
const labels = weekLabels?.length === 7 ? weekLabels : WEEK_LABELS;
return (
<View style={styles.monthCard}>
<View style={styles.monthHeader}>
<Text style={styles.monthTitle}>{month.title}</Text>
<Text style={styles.monthSubtitle}>{month.subtitle}</Text>
</View>
<View style={styles.weekRow}>
{labels.map((label) => (
<Text key={label} style={styles.weekLabel}>
{label}
</Text>
))}
</View>
<View style={styles.monthGrid}>
{weeks.map((week, weekIndex) => {
const selectedIndex = week.findIndex(
(c) => c.type === 'day' && c.date.format('YYYY-MM-DD') === selectedDateKey
);
return (
<React.Fragment key={weekIndex}>
<View style={styles.daysRow}>
{week.map((cell) => {
if (cell.type === 'placeholder') {
return <View key={cell.key} style={styles.dayCell} />;
}
const dateKey = cell.date.format('YYYY-MM-DD');
return (
<DayCell
key={cell.key}
cell={cell}
isSelected={selectedDateKey === dateKey}
onPress={() => onSelect(dateKey)}
/>
);
})}
</View>
{selectedIndex !== -1 && (
<View style={styles.inlineTipContainer}>
{renderTip(selectedIndex)}
</View>
)}
</React.Fragment>
);
})}
</View>
</View>
);
};
const styles = StyleSheet.create({
monthCard: {
backgroundColor: '#fff',
borderRadius: 16,
padding: 14,
marginBottom: 12,
shadowColor: '#000',
shadowOpacity: 0.08,
shadowRadius: 8,
shadowOffset: { width: 0, height: 6 },
elevation: 2,
},
monthHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 8,
},
monthTitle: {
fontSize: 17,
fontWeight: '800',
color: '#0f172a',
fontFamily: 'AliBold',
},
monthSubtitle: {
fontSize: 12,
color: '#6b7280',
fontFamily: 'AliRegular',
},
weekRow: {
flexDirection: 'row',
justifyContent: 'space-between',
marginBottom: 6,
paddingHorizontal: 4,
},
weekLabel: {
width: '14.28%',
textAlign: 'center',
fontSize: 12,
color: '#94a3b8',
fontFamily: 'AliRegular',
},
monthGrid: {
flexDirection: 'column',
},
daysRow: {
flexDirection: 'row',
},
dayCell: {
width: '14.28%',
alignItems: 'center',
marginVertical: 6,
},
inlineTipContainer: {
paddingBottom: 6,
marginBottom: 6,
},
});

View File

@@ -0,0 +1,12 @@
import { MenstrualDayStatus } from '@/utils/menstrualCycle';
export const STATUS_COLORS: Record<MenstrualDayStatus, { bg: string; text: string }> = {
period: { bg: '#f5679f', text: '#fff' },
'predicted-period': { bg: '#f8d9e9', text: '#9b2c6a' },
fertile: { bg: '#d9d2ff', text: '#5a52c5' },
'ovulation-day': { bg: '#5b4ee4', text: '#fff' },
};
export const WEEK_LABELS = ['一', '二', '三', '四', '五', '六', '日'];
export const ITEM_HEIGHT = 380;

View File

@@ -0,0 +1,7 @@
export { ITEM_HEIGHT, STATUS_COLORS, WEEK_LABELS } from './constants';
export { DayCell } from './DayCell';
export { InlineTip } from './InlineTip';
export { Legend } from './Legend';
export { MonthBlock } from './MonthBlock';
export type { DayCellProps, InlineTipProps, LegendItem } from './types';

View File

@@ -0,0 +1,21 @@
import { MenstrualDayCell, MenstrualDayInfo } from '@/utils/menstrualCycle';
import { Dayjs } from 'dayjs';
export interface DayCellProps {
cell: Extract<MenstrualDayCell, { type: 'day' }>;
isSelected: boolean;
onPress: () => void;
}
export interface InlineTipProps {
selectedDate: Dayjs;
selectedInfo: MenstrualDayInfo | undefined;
columnIndex: number;
onMarkStart: () => void;
onCancelMark: () => void;
}
export interface LegendItem {
label: string;
key: 'period' | 'predicted-period' | 'fertile' | 'ovulation-day';
}

View File

@@ -1,9 +1,9 @@
import { Image } from '@/components/ui/Image';
import { useAppSelector } from '@/hooks/redux';
import { useCosUpload } from '@/hooks/useCosUpload';
import { useI18n } from '@/hooks/useI18n';
import { Ionicons } from '@expo/vector-icons';
import { BlurView } from 'expo-blur';
import { Image } from 'expo-image';
import * as ImagePicker from 'expo-image-picker';
import { LinearGradient } from 'expo-linear-gradient';
import React, { useEffect, useMemo, useState } from 'react';

View File

@@ -15,10 +15,10 @@ import {
View,
} from 'react-native';
// 导入统一的食物类型定义
import { Image } from '@/components/ui/Image';
import { Colors } from '@/constants/Colors';
import { DEFAULT_IMAGE_FOOD } from '@/constants/Image';
import type { FoodItem } from '@/types/food';
import { Image } from 'expo-image';
// 导入统一的食物类型定义

View File

@@ -4,12 +4,15 @@ import { useI18n } from '@/hooks/useI18n';
import { formatTime, getSleepStageColor, SleepStage, type SleepSample } from '@/utils/sleepHealthKit';
import { Ionicons } from '@expo/vector-icons';
import dayjs from 'dayjs';
import { LinearGradient } from 'expo-linear-gradient';
import React, { useMemo } from 'react';
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import Svg, { Rect, Text as SvgText } from 'react-native-svg';
import { Dimensions, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop, Text as SvgText } from 'react-native-svg';
import { StyleProp, ViewStyle } from 'react-native';
const { width: SCREEN_WIDTH } = Dimensions.get('window');
export type SleepStageTimelineProps = {
sleepSamples: SleepSample[];
bedtime: string;
@@ -31,14 +34,14 @@ export const SleepStageTimeline = ({
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme];
// 图表尺寸参数
const containerWidth = 320;
const chartPadding = 25; // 左右边距,为时间标签预留空间
// 图表尺寸参数 - 更宽更现代的设计
const containerWidth = SCREEN_WIDTH - 64; // 留出左右边距
const chartPadding = 24; // 增加左右边距,避免时间轴和标签被截断
const chartWidth = containerWidth - chartPadding * 2;
const chartHeight = 80;
const timelineHeight = 32;
const timelineY = 24;
const timeScaleY = timelineY + timelineHeight + 16;
const chartHeight = 140; // 增加高度以容纳更高的条形图
const timelineHeight = 48; // 更高的条形图
const timelineY = 16;
const timeScaleY = timelineY + timelineHeight + 24;
// 计算时间范围和刻度
const { timelineData, timeLabels } = useMemo(() => {
@@ -64,7 +67,7 @@ export const SleepStageTimeline = ({
const duration = sampleEnd.diff(sampleStart, 'minute');
const x = Math.max(0, (startOffset / totalMinutes) * chartWidth) + chartPadding;
const width = Math.max(2, (duration / totalMinutes) * chartWidth);
const width = Math.max(3, (duration / totalMinutes) * chartWidth);
return {
x,
@@ -74,29 +77,27 @@ export const SleepStageTimeline = ({
};
});
// 智能生成时间标签,避免重合
// 智能生成时间标签
const labels = [];
const minLabelSpacing = 50; // 最小标签间距(像素)
const minLabelSpacing = 60;
// 总是显示起始时间
// 起始时间标签
labels.push({
time: startTime.format('HH:mm'),
x: chartPadding
});
// 根据睡眠总时长动态调整时间间隔
const sleepDurationHours = totalMinutes / 60;
let timeStepMinutes;
if (sleepDurationHours <= 4) {
timeStepMinutes = 60; // 1小时间隔
timeStepMinutes = 60;
} else if (sleepDurationHours <= 8) {
timeStepMinutes = 120; // 2小时间隔
timeStepMinutes = 120;
} else {
timeStepMinutes = 180; // 3小时间隔
timeStepMinutes = 180;
}
// 添加中间时间标签,确保不重合
let currentTime = startTime;
let stepCount = 1;
@@ -104,7 +105,6 @@ export const SleepStageTimeline = ({
const stepTime = currentTime.add(timeStepMinutes * stepCount, 'minute');
const x = (stepTime.diff(startTime, 'minute') / totalMinutes) * chartWidth + chartPadding;
// 检查与前一个标签的间距
const lastLabel = labels[labels.length - 1];
if (x - lastLabel.x >= minLabelSpacing && x <= containerWidth - chartPadding) {
labels.push({
@@ -116,7 +116,7 @@ export const SleepStageTimeline = ({
stepCount++;
}
// 总是显示结束时间,但要确保与前一个标签有足够间距
// 结束时间标签
const endX = containerWidth - chartPadding;
const lastLabel = labels[labels.length - 1];
if (endX - lastLabel.x >= minLabelSpacing) {
@@ -125,7 +125,6 @@ export const SleepStageTimeline = ({
x: endX
});
} else {
// 如果空间不够,替换最后一个标签为结束时间
labels[labels.length - 1] = {
time: endTime.format('HH:mm'),
x: endX
@@ -161,7 +160,7 @@ export const SleepStageTimeline = ({
}
return (
<View style={[styles.container, { backgroundColor: colorTokens.background }, style]}>
<View style={[styles.container, { backgroundColor: 'transparent' }, style]}>
{/* 标题栏 */}
{!hideHeader && (
<View style={styles.header}>
@@ -176,60 +175,104 @@ export const SleepStageTimeline = ({
</View>
)}
{/* 睡眠时间范围 */}
{/* 睡眠时间范围 - 更简洁的设计 */}
<View style={styles.timeRange}>
<View style={styles.timePoint}>
<Text style={[styles.timeLabel, { color: colorTokens.textSecondary }]}>
{t('sleepDetail.infoModalTitles.sleepTime')}
</Text>
<Text style={[styles.timeValue, { color: colorTokens.text }]}>
<Ionicons name="moon" size={16} color="#8B9DC3" style={{ marginBottom: 4 }} />
<Text style={[styles.timeValue, { color: '#1c1f3a' }]}>
{formatTime(bedtime)}
</Text>
<Text style={[styles.timeLabel, { color: '#8B9DC3' }]}>
{t('sleepDetail.infoModalTitles.sleepTime')}
</Text>
</View>
<View style={styles.timePoint}>
<Text style={[styles.timeLabel, { color: colorTokens.textSecondary }]}>
{t('sleepDetail.sleepDuration')}
</Text>
<Text style={[styles.timeValue, { color: colorTokens.text }]}>
<Ionicons name="sunny" size={16} color="#F59E0B" style={{ marginBottom: 4 }} />
<Text style={[styles.timeValue, { color: '#1c1f3a' }]}>
{formatTime(wakeupTime)}
</Text>
<Text style={[styles.timeLabel, { color: '#8B9DC3' }]}>
{t('sleepDetail.sleepDuration')}
</Text>
</View>
</View>
{/* SVG 图表 */}
{/* SVG 图表 - iOS 健康风格 */}
<View style={styles.chartContainer}>
{/* 背景轨道 */}
<View style={[styles.trackBackground, {
left: chartPadding,
right: chartPadding,
width: chartWidth
}]} />
<Svg width={containerWidth} height={chartHeight}>
{/* 绘制睡眠阶段条形图 */}
{timelineData.map((segment, index) => (
<Rect
key={index}
x={segment.x}
y={timelineY}
width={segment.width}
height={timelineHeight}
fill={segment.color}
rx={2}
/>
))}
<Defs>
{/* 为每种睡眠阶段定义渐变 */}
<SvgLinearGradient id="gradDeep" x1="0" y1="0" x2="0" y2="1">
<Stop offset="0" stopColor="#60A5FA" stopOpacity="1" />
<Stop offset="1" stopColor="#3B82F6" stopOpacity="0.85" />
</SvgLinearGradient>
<SvgLinearGradient id="gradCore" x1="0" y1="0" x2="0" y2="1">
<Stop offset="0" stopColor="#A78BFA" stopOpacity="1" />
<Stop offset="1" stopColor="#8B5CF6" stopOpacity="0.85" />
</SvgLinearGradient>
<SvgLinearGradient id="gradREM" x1="0" y1="0" x2="0" y2="1">
<Stop offset="0" stopColor="#F472B6" stopOpacity="1" />
<Stop offset="1" stopColor="#EC4899" stopOpacity="0.85" />
</SvgLinearGradient>
<SvgLinearGradient id="gradAwake" x1="0" y1="0" x2="0" y2="1">
<Stop offset="0" stopColor="#FCD34D" stopOpacity="1" />
<Stop offset="1" stopColor="#F59E0B" stopOpacity="0.85" />
</SvgLinearGradient>
<SvgLinearGradient id="gradAsleep" x1="0" y1="0" x2="0" y2="1">
<Stop offset="0" stopColor="#F472B6" stopOpacity="1" />
<Stop offset="1" stopColor="#EC4899" stopOpacity="0.85" />
</SvgLinearGradient>
</Defs>
{/* 绘制时间刻度标签 */}
{/* 绘制睡眠阶段条形图 - 使用渐变和圆角 */}
{timelineData.map((segment, index) => {
const gradientId =
segment.stage === SleepStage.Deep ? 'gradDeep' :
segment.stage === SleepStage.Core ? 'gradCore' :
segment.stage === SleepStage.REM || segment.stage === SleepStage.Asleep ? 'gradREM' :
segment.stage === SleepStage.Awake ? 'gradAwake' : 'gradAsleep';
return (
<Rect
key={index}
x={segment.x}
y={timelineY}
width={segment.width}
height={timelineHeight}
fill={`url(#${gradientId})`}
rx={8}
opacity={0.95}
/>
);
})}
{/* 绘制时间刻度标签 - 更细腻的设计 */}
{timeLabels.map((label, index) => (
<React.Fragment key={index}>
{/* 刻度线 */}
<Rect
x={label.x - 0.5}
y={timelineY + timelineHeight}
y={timelineY + timelineHeight + 4}
width={1}
height={6}
fill={colorTokens.border}
height={4}
fill="#D1D5DB"
opacity={0.4}
/>
{/* 时间标签 */}
<SvgText
x={label.x}
y={timeScaleY}
fontSize={11}
fill={colorTokens.textSecondary}
fill="#8B9DC3"
textAnchor="middle"
fontWeight="500"
>
{label.time}
</SvgText>
@@ -238,35 +281,43 @@ export const SleepStageTimeline = ({
</Svg>
</View>
{/* 图例 */}
{/* 图例 - iOS 风格的标签 */}
<View style={styles.legend}>
<View style={styles.legendRow}>
<View style={styles.legendItem}>
<View style={[styles.legendDot, { backgroundColor: getSleepStageColor(SleepStage.Deep) }]} />
<Text style={[styles.legendText, { color: colorTokens.textSecondary }]}>
{t('sleepDetail.deep')}
</Text>
</View>
<View style={styles.legendItem}>
<View style={[styles.legendDot, { backgroundColor: getSleepStageColor(SleepStage.Core) }]} />
<Text style={[styles.legendText, { color: colorTokens.textSecondary }]}>
{t('sleepDetail.core')}
</Text>
</View>
<View style={styles.legendItem}>
<LinearGradient
colors={['#60A5FA', '#3B82F6']}
style={styles.legendPill}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
/>
<Text style={styles.legendText}>{t('sleepDetail.deep')}</Text>
</View>
<View style={styles.legendRow}>
<View style={styles.legendItem}>
<View style={[styles.legendDot, { backgroundColor: getSleepStageColor(SleepStage.REM) }]} />
<Text style={[styles.legendText, { color: colorTokens.textSecondary }]}>
{t('sleepDetail.rem')}
</Text>
</View>
<View style={styles.legendItem}>
<View style={[styles.legendDot, { backgroundColor: getSleepStageColor(SleepStage.Awake) }]} />
<Text style={[styles.legendText, { color: colorTokens.textSecondary }]}>
{t('sleepDetail.awake')}
</Text>
</View>
<View style={styles.legendItem}>
<LinearGradient
colors={['#A78BFA', '#8B5CF6']}
style={styles.legendPill}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
/>
<Text style={styles.legendText}>{t('sleepDetail.core')}</Text>
</View>
<View style={styles.legendItem}>
<LinearGradient
colors={['#F472B6', '#EC4899']}
style={styles.legendPill}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
/>
<Text style={styles.legendText}>{t('sleepDetail.rem')}</Text>
</View>
<View style={styles.legendItem}>
<LinearGradient
colors={['#FCD34D', '#F59E0B']}
style={styles.legendPill}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
/>
<Text style={styles.legendText}>{t('sleepDetail.awake')}</Text>
</View>
</View>
</View>
@@ -276,14 +327,8 @@ export const SleepStageTimeline = ({
const styles = StyleSheet.create({
container: {
borderRadius: 16,
padding: 16,
marginBottom: 24,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 3,
marginHorizontal: 4,
paddingVertical: 20,
paddingHorizontal: 16,
},
header: {
flexDirection: 'row',
@@ -294,31 +339,44 @@ const styles = StyleSheet.create({
title: {
fontSize: 16,
fontWeight: '600',
fontFamily: 'AliBold',
},
infoButton: {
padding: 4,
},
timeRange: {
flexDirection: 'row',
justifyContent: 'space-between',
marginBottom: 20,
justifyContent: 'space-around',
marginBottom: 28,
paddingHorizontal: 20,
},
timePoint: {
alignItems: 'center',
gap: 2,
},
timeLabel: {
fontSize: 12,
fontSize: 11,
fontWeight: '500',
marginBottom: 4,
fontFamily: 'AliRegular',
},
timeValue: {
fontSize: 16,
fontSize: 20,
fontWeight: '700',
letterSpacing: -0.2,
fontFamily: 'AliBold',
letterSpacing: -0.5,
},
chartContainer: {
alignItems: 'center',
marginBottom: 16,
marginBottom: 20,
position: 'relative',
},
trackBackground: {
position: 'absolute',
top: 16,
height: 48,
backgroundColor: '#F0F2F9',
borderRadius: 24,
opacity: 0.5,
},
emptyState: {
alignItems: 'center',
@@ -327,27 +385,29 @@ const styles = StyleSheet.create({
emptyText: {
fontSize: 14,
fontStyle: 'italic',
fontFamily: 'AliRegular',
},
legend: {
gap: 8,
},
legendRow: {
flexDirection: 'row',
justifyContent: 'center',
gap: 24,
flexWrap: 'wrap',
gap: 16,
paddingTop: 8,
},
legendItem: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
legendDot: {
width: 8,
height: 8,
borderRadius: 4,
legendPill: {
width: 20,
height: 10,
borderRadius: 5,
},
legendText: {
fontSize: 12,
fontWeight: '500',
color: '#6B7280',
fontFamily: 'AliRegular',
},
});

View File

@@ -1,6 +1,6 @@
import { Image } from 'expo-image';
import { Image } from '@/components/ui/Image';
import React from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { ImageSourcePropType, Pressable, StyleSheet, Text, View } from 'react-native';
import Animated, { FadeIn, FadeOut } from 'react-native-reanimated';
interface HealthDataCardProps {
@@ -8,37 +8,53 @@ interface HealthDataCardProps {
value: string;
unit: string;
style?: object;
onPress?: () => void;
icon?: React.ReactNode;
iconSource?: ImageSourcePropType;
subtitle?: string;
}
const defaultIconSource = require('@/assets/images/icons/icon-blood-oxygen.png');
const HealthDataCard: React.FC<HealthDataCardProps> = ({
title,
value,
unit,
style
style,
onPress,
icon,
iconSource,
subtitle
}) => {
const Container = onPress ? Pressable : View;
return (
<Animated.View
entering={FadeIn.duration(300)}
exiting={FadeOut.duration(300)}
style={[styles.card, style]}
>
<View style={styles.content}>
<View style={{
flexDirection: 'row',
alignItems: 'center',
marginBottom: 14,
}}>
<Image
source={require('@/assets/images/icons/icon-blood-oxygen.png')}
style={styles.titleIcon}
/>
<Animated.View entering={FadeIn.duration(300)} exiting={FadeOut.duration(300)} style={[styles.card, style]}>
<Container
style={styles.content}
onPress={onPress}
accessibilityRole={onPress ? 'button' : undefined}
accessibilityLabel={title}
accessibilityHint={onPress ? `${title} details` : undefined}
>
<View style={styles.headerRow}>
{icon ? (
<View style={styles.iconWrapper}>{icon}</View>
) : (
<Image source={iconSource ?? defaultIconSource} style={styles.titleIcon} />
)}
<Text style={styles.title}>{title}</Text>
</View>
<View style={styles.valueContainer}>
<Text style={styles.value}>{value}</Text>
<Text style={styles.unit}>{unit}</Text>
</View>
</View>
{subtitle ? (
<Text style={styles.subtitle} numberOfLines={1}>
{subtitle}
</Text>
) : null}
</Container>
</Animated.View>
);
};
@@ -62,6 +78,18 @@ const styles = StyleSheet.create({
flex: 1,
justifyContent: 'center',
},
headerRow: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 14,
},
iconWrapper: {
width: 16,
height: 16,
marginRight: 6,
alignItems: 'center',
justifyContent: 'center',
},
titleIcon: {
width: 16,
height: 16,
@@ -92,6 +120,12 @@ const styles = StyleSheet.create({
fontWeight: '500',
fontFamily: 'AliRegular',
},
subtitle: {
marginTop: 6,
fontSize: 12,
color: '#8A8A8A',
fontFamily: 'AliRegular',
},
});
export default HealthDataCard;
export default HealthDataCard;

View File

@@ -24,6 +24,7 @@ const HeartRateCard: React.FC<HeartRateCardProps> = ({
value={heartRate !== null && heartRate !== undefined ? Math.round(heartRate).toString() : '--'}
unit="bpm"
style={style}
icon={heartIcon}
/>
);
};
@@ -34,4 +35,4 @@ const styles = StyleSheet.create({
},
});
export default HeartRateCard;
export default HeartRateCard;

View File

@@ -1,10 +1,10 @@
import { Image } from '@/components/ui/Image';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { ChallengeType } from '@/services/challengesApi';
import { reportChallengeProgress, selectChallengeList } from '@/store/challengesSlice';
import { logger } from '@/utils/logger';
import { fetchCompleteSleepData, formatSleepTime } from '@/utils/sleepHealthKit';
import dayjs from 'dayjs';
import { Image } from 'expo-image';
import { router } from 'expo-router';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';

View File

@@ -0,0 +1,559 @@
import {
ensureHealthPermissions,
fetchTimeInDaylight,
fetchTimeInDaylightHistory,
SunlightHistoryPoint
} from '@/utils/health';
import { HealthKitUtils } from '@/utils/healthKit';
import { Ionicons } from '@expo/vector-icons';
import { useIsFocused } from '@react-navigation/native';
import dayjs from 'dayjs';
import { LinearGradient } from 'expo-linear-gradient';
import { BlurView } from 'expo-blur';
import React, { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Dimensions,
Modal,
Platform,
Pressable,
StyleSheet,
Text,
View
} from 'react-native';
import Svg, {
Defs,
LinearGradient as SvgLinearGradient,
Line,
Rect,
Stop,
Text as SvgText
} from 'react-native-svg';
import HealthDataCard from './HealthDataCard';
interface SunlightCardProps {
style?: object;
selectedDate?: Date;
}
const screenWidth = Dimensions.get('window').width;
const INITIAL_CHART_WIDTH = screenWidth - 32;
const CHART_HEIGHT = 190;
const CHART_RIGHT_PADDING = 12;
const AXIS_COLUMN_WIDTH = 36;
const CHART_INNER_PADDING = 4;
const AXIS_LABEL_WIDTH = 48;
const Y_TICK_COUNT = 4;
const BAR_GAP = 6;
const MIN_BAR_HEIGHT = 4;
const SunlightCard: React.FC<SunlightCardProps> = ({
style,
selectedDate
}) => {
const { t, i18n } = useTranslation();
const locale = i18n.language;
const isFocused = useIsFocused();
const [sunlightMinutes, setSunlightMinutes] = useState<number | null>(null);
const [comparisonText, setComparisonText] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const loadingRef = useRef(false);
const [historyVisible, setHistoryVisible] = useState(false);
const [historyLoading, setHistoryLoading] = useState(false);
const [history, setHistory] = useState<SunlightHistoryPoint[]>([]);
const historyLoadingRef = useRef(false);
const [chartWidth, setChartWidth] = useState(INITIAL_CHART_WIDTH);
const formatCompareDate = (date: Date) => {
if (locale?.startsWith('zh')) {
return dayjs(date).format('M月D日');
}
return dayjs(date).format('MMM D');
};
useEffect(() => {
const loadSunlightData = async () => {
const dateToUse = selectedDate || new Date();
if (!isFocused) return;
if (!HealthKitUtils.isAvailable()) {
setSunlightMinutes(null);
setComparisonText(null);
return;
}
if (loadingRef.current) return;
try {
loadingRef.current = true;
setLoading(true);
setComparisonText(null);
const hasPermission = await ensureHealthPermissions();
if (!hasPermission) {
setSunlightMinutes(null);
setComparisonText(null);
setLoading(false);
return;
}
const options = {
startDate: dayjs(dateToUse).startOf('day').toDate().toISOString(),
endDate: dayjs(dateToUse).endOf('day').toDate().toISOString()
};
const totalMinutes = await fetchTimeInDaylight(options);
setSunlightMinutes(totalMinutes);
setLoading(false);
if (totalMinutes !== null && totalMinutes !== undefined) {
try {
let previousMinutes: number | null = null;
let previousDate: Date | null = null;
for (let i = 1; i <= 30; i += 1) {
const targetDate = dayjs(dateToUse).subtract(i, 'day');
const previousOptions = {
startDate: targetDate.startOf('day').toDate().toISOString(),
endDate: targetDate.endOf('day').toDate().toISOString()
};
const candidateMinutes = await fetchTimeInDaylight(previousOptions);
if (candidateMinutes !== null && candidateMinutes !== undefined && candidateMinutes > 0) {
previousMinutes = candidateMinutes;
previousDate = targetDate.toDate();
break;
}
}
if (previousMinutes !== null && previousDate) {
const diff = Math.round(totalMinutes - previousMinutes);
const dateLabel = formatCompareDate(previousDate);
if (diff > 0) {
setComparisonText(t('statistics.components.sunlight.compareIncrease', { date: dateLabel, diff }));
} else if (diff < 0) {
setComparisonText(t('statistics.components.sunlight.compareDecrease', { date: dateLabel, diff: Math.abs(diff) }));
} else {
setComparisonText(t('statistics.components.sunlight.compareSame', { date: dateLabel }));
}
} else {
setComparisonText(t('statistics.components.sunlight.compareNone'));
}
} catch (error) {
console.error('SunlightCard: Failed to compare time in daylight:', error);
setComparisonText(t('statistics.components.sunlight.compareNone'));
}
} else {
setComparisonText(null);
}
} catch (error) {
console.error('SunlightCard: Failed to get time in daylight:', error);
setSunlightMinutes(null);
setComparisonText(null);
setLoading(false);
} finally {
loadingRef.current = false;
}
};
loadSunlightData();
}, [isFocused, selectedDate, t, locale]);
useEffect(() => {
if (!historyVisible || !isFocused) return;
const loadHistory = async () => {
if (historyLoadingRef.current) return;
if (!HealthKitUtils.isAvailable()) {
setHistory([]);
return;
}
try {
historyLoadingRef.current = true;
setHistoryLoading(true);
const hasPermission = await ensureHealthPermissions();
if (!hasPermission) {
setHistory([]);
return;
}
const end = dayjs(selectedDate || new Date()).endOf('day');
const start = end.subtract(29, 'day').startOf('day');
const options = {
startDate: start.toDate().toISOString(),
endDate: end.toDate().toISOString()
};
const historyData = await fetchTimeInDaylightHistory(options);
const sorted = historyData
.filter((item) => item && item.date)
.sort((a, b) => dayjs(a.date).valueOf() - dayjs(b.date).valueOf());
setHistory(sorted);
} catch (error) {
console.error('SunlightCard: Failed to get time in daylight history:', error);
setHistory([]);
} finally {
historyLoadingRef.current = false;
setHistoryLoading(false);
}
};
loadHistory();
}, [historyVisible, selectedDate, isFocused]);
const displayValue = loading
? '--'
: (sunlightMinutes !== null && sunlightMinutes !== undefined
? Math.max(0, Math.round(sunlightMinutes)).toString()
: '--');
const openHistory = () => setHistoryVisible(true);
const closeHistory = () => setHistoryVisible(false);
const maxValue = history.length ? Math.max(...history.map((item) => item.value), 10) : 10;
const averageValue = history.length
? history.reduce((sum, item) => sum + item.value, 0) / history.length
: null;
const latestValue = history.length ? history[history.length - 1].value : null;
const barCount = history.length || 1;
const chartInnerWidth = Math.max(0, chartWidth - 24);
const chartAreaWidth = Math.max(
0,
chartInnerWidth - AXIS_COLUMN_WIDTH - CHART_RIGHT_PADDING
);
const barWidth = Math.max(
6,
(chartAreaWidth - CHART_INNER_PADDING * 2 - BAR_GAP * (barCount - 1)) / barCount
);
const dateLabels = history.length
? [
history[0],
history[Math.floor(history.length / 2)],
history[history.length - 1]
].filter(Boolean)
: [];
return (
<>
<HealthDataCard
title={t('statistics.components.sunlight.title')}
value={displayValue}
unit={t('statistics.components.sunlight.unit')}
style={style}
icon={<Ionicons name="sunny-outline" size={16} color="#F59E0B" />}
subtitle={loading ? undefined : comparisonText ?? undefined}
onPress={openHistory}
/>
<Modal
visible={historyVisible}
animationType="slide"
presentationStyle={Platform.OS === 'ios' ? 'pageSheet' : 'fullScreen'}
onRequestClose={closeHistory}
>
<View style={styles.modalSafeArea}>
<LinearGradient
colors={['#FFF7E8', '#FFFFFF']}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
style={StyleSheet.absoluteFill}
/>
<View style={styles.modalContainer}>
<View style={styles.modalHeader}>
<View>
<Text style={styles.modalTitle}>{t('statistics.components.sunlight.title')}</Text>
<Text style={styles.modalSubtitle}>{t('statistics.components.sunlight.last30Days')}</Text>
</View>
<Pressable style={styles.closeButton} onPress={closeHistory} hitSlop={10}>
<BlurView intensity={24} tint="light" style={StyleSheet.absoluteFill} />
<View style={styles.closeButtonInner}>
<Ionicons name="close" size={18} color="#111827" />
</View>
</Pressable>
</View>
{historyLoading ? (
<Text style={styles.hintText}>{t('statistics.components.sunlight.syncing')}</Text>
) : null}
{history.length === 0 ? (
<View style={styles.emptyState}>
<Text style={styles.emptyText}>{t('statistics.components.sunlight.noData')}</Text>
</View>
) : (
<View
style={styles.chartCard}
onLayout={(event) => {
const nextWidth = event.nativeEvent.layout.width;
if (nextWidth > 120 && Math.abs(nextWidth - chartWidth) > 2) {
setChartWidth(nextWidth);
}
}}
>
<View style={styles.chartHeaderRow}>
<Text style={styles.axisUnit}>{t('statistics.components.sunlight.unit')}</Text>
</View>
<View style={styles.chartContentRow}>
<View style={styles.axisColumn}>
{Array.from({ length: Y_TICK_COUNT + 1 }).map((_, index) => {
const value = (maxValue / Y_TICK_COUNT) * (Y_TICK_COUNT - index);
const y = (CHART_HEIGHT / Y_TICK_COUNT) * index;
return (
<Text key={`tick-${index}`} style={[styles.axisTick, { top: Math.max(0, y - 6) }]}>
{Math.round(value)}
</Text>
);
})}
</View>
<Svg width={chartAreaWidth} height={CHART_HEIGHT + 10}>
<Defs>
<SvgLinearGradient id="sunBar" x1="0%" y1="0%" x2="0%" y2="100%">
<Stop offset="0%" stopColor="#F59E0B" stopOpacity="0.95" />
<Stop offset="100%" stopColor="#FDE68A" stopOpacity="0.8" />
</SvgLinearGradient>
</Defs>
{Array.from({ length: Y_TICK_COUNT + 1 }).map((_, index) => {
const value = (maxValue / Y_TICK_COUNT) * index;
const y = CHART_HEIGHT - (value / maxValue) * CHART_HEIGHT;
return (
<React.Fragment key={`tick-${index}`}>
<Line
x1={0}
y1={y}
x2={chartAreaWidth}
y2={y}
stroke="#FEF3C7"
strokeWidth={1}
/>
</React.Fragment>
);
})}
{history.map((item, index) => {
const value = item.value;
const barHeight = Math.max((value / maxValue) * CHART_HEIGHT, MIN_BAR_HEIGHT);
const x = CHART_INNER_PADDING + index * (barWidth + BAR_GAP);
const y = CHART_HEIGHT - barHeight;
return (
<Rect
key={item.date}
x={x}
y={y}
width={barWidth}
height={barHeight}
rx={barWidth > 8 ? 6 : 4}
fill="url(#sunBar)"
/>
);
})}
</Svg>
</View>
<View style={[styles.labelRow, { width: chartAreaWidth }]}>
{dateLabels.map((item) => {
const index = history.findIndex((point) => point.date === item.date);
const x = CHART_INNER_PADDING + index * (barWidth + BAR_GAP) + barWidth / 2;
const label = dayjs(item.date).format(locale?.startsWith('zh') ? 'M.D' : 'MMM D');
const maxLeft = Math.max(0, chartAreaWidth - AXIS_LABEL_WIDTH);
const clampedLeft = Math.min(
Math.max(x - AXIS_LABEL_WIDTH / 2, 0),
maxLeft
);
return (
<Text key={item.date} style={[styles.axisLabel, { left: clampedLeft, width: AXIS_LABEL_WIDTH }]}>
{label}
</Text>
);
})}
</View>
</View>
)}
<View style={styles.metricsRow}>
<View style={styles.metric}>
<Text style={styles.metricLabel}>{t('statistics.components.sunlight.average')}</Text>
<Text style={styles.metricValue}>
{averageValue !== null ? Math.round(averageValue) : '--'}
<Text style={styles.metricUnit}> {t('statistics.components.sunlight.unit')}</Text>
</Text>
</View>
<View style={styles.metric}>
<Text style={styles.metricLabel}>{t('statistics.components.sunlight.latest')}</Text>
<Text style={styles.metricValue}>
{latestValue !== null ? Math.round(latestValue) : '--'}
<Text style={styles.metricUnit}> {t('statistics.components.sunlight.unit')}</Text>
</Text>
</View>
</View>
</View>
</View>
</Modal>
</>
);
};
export default SunlightCard;
const styles = StyleSheet.create({
modalSafeArea: {
flex: 1,
backgroundColor: '#FFFFFF',
paddingTop: Platform.OS === 'ios' ? 10 : 0
},
modalContainer: {
flex: 1,
paddingHorizontal: 20,
paddingTop: 22
},
modalHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 14
},
modalTitle: {
fontSize: 22,
fontWeight: '700',
color: '#1C1C28',
fontFamily: 'AliBold'
},
modalSubtitle: {
fontSize: 13,
color: '#6B7280',
marginTop: 4,
fontFamily: 'AliRegular'
},
closeButton: {
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: 'rgba(255,255,255,0.42)',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
borderWidth: 0.5,
borderColor: 'rgba(255,255,255,0.6)',
shadowColor: '#0F172A',
shadowOpacity: 0.08,
shadowRadius: 8,
shadowOffset: { width: 0, height: 4 },
elevation: 2
},
closeButtonInner: {
flex: 1,
alignItems: 'center',
justifyContent: 'center'
},
chartCard: {
backgroundColor: '#FFFFFF',
borderRadius: 24,
paddingVertical: 12,
paddingHorizontal: 12,
shadowColor: '#000',
shadowOpacity: 0.05,
shadowRadius: 14,
shadowOffset: { width: 0, height: 12 },
elevation: 4,
marginTop: 8,
marginBottom: 14,
borderWidth: 1,
borderColor: '#FEF3C7'
},
chartHeaderRow: {
paddingLeft: AXIS_COLUMN_WIDTH,
paddingBottom: 6
},
axisUnit: {
fontSize: 10,
color: '#B45309',
fontFamily: 'AliRegular'
},
chartContentRow: {
flexDirection: 'row',
alignItems: 'flex-start'
},
axisColumn: {
width: AXIS_COLUMN_WIDTH,
height: CHART_HEIGHT,
position: 'relative',
justifyContent: 'space-between',
paddingRight: 6
},
axisTick: {
position: 'absolute',
right: 6,
fontSize: 10,
color: '#B45309',
fontFamily: 'AliRegular'
},
labelRow: {
marginTop: 4,
marginLeft: AXIS_COLUMN_WIDTH,
height: 24,
justifyContent: 'center'
},
axisLabel: {
position: 'absolute',
bottom: 0,
fontSize: 11,
color: '#9A6B2F',
fontFamily: 'AliRegular',
textAlign: 'center',
width: 48
},
metricsRow: {
flexDirection: 'row',
justifyContent: 'space-between',
gap: 12,
paddingVertical: 6
},
metric: {
flex: 1,
padding: 14,
backgroundColor: 'rgba(255, 247, 237, 0.8)',
borderRadius: 18,
borderWidth: 1,
borderColor: '#FED7AA'
},
metricLabel: {
fontSize: 12,
color: '#92400E',
marginBottom: 8,
fontFamily: 'AliRegular'
},
metricValue: {
fontSize: 20,
fontWeight: '700',
color: '#7C2D12',
fontFamily: 'AliBold'
},
metricUnit: {
fontSize: 12,
color: '#9A6B2F',
fontWeight: '500',
fontFamily: 'AliRegular'
},
emptyState: {
marginTop: 32,
padding: 20,
borderRadius: 20,
backgroundColor: 'rgba(255, 247, 237, 0.9)',
borderWidth: 1,
borderColor: '#FED7AA',
alignItems: 'center'
},
emptyText: {
fontSize: 14,
color: '#9A3412',
fontFamily: 'AliRegular'
},
hintText: {
fontSize: 13,
color: '#9CA3AF',
fontFamily: 'AliRegular'
}
});

View File

@@ -0,0 +1,568 @@
import {
ensureHealthPermissions,
fetchWristTemperature,
fetchWristTemperatureHistory,
WristTemperatureHistoryPoint
} from '@/utils/health';
import { HealthKitUtils } from '@/utils/healthKit';
import { Ionicons } from '@expo/vector-icons';
import { useIsFocused } from '@react-navigation/native';
import dayjs from 'dayjs';
import { LinearGradient } from 'expo-linear-gradient';
import { BlurView } from 'expo-blur';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Dimensions,
Modal,
Platform,
Pressable,
StyleSheet,
Text,
View
} from 'react-native';
import Svg, {
Circle,
Defs,
Line,
Path,
Stop,
LinearGradient as SvgLinearGradient
} from 'react-native-svg';
import HealthDataCard from './HealthDataCard';
interface WristTemperatureCardProps {
style?: object;
selectedDate?: Date;
}
const screenWidth = Dimensions.get('window').width;
const INITIAL_CHART_WIDTH = screenWidth - 32;
const CHART_HEIGHT = 240;
const CHART_HORIZONTAL_PADDING = 20;
const LABEL_ESTIMATED_WIDTH = 44;
const WristTemperatureCard: React.FC<WristTemperatureCardProps> = ({
style,
selectedDate
}) => {
const { t } = useTranslation();
const isFocused = useIsFocused();
const [temperature, setTemperature] = useState<number | null>(null);
const [loading, setLoading] = useState(false);
const loadingRef = useRef(false);
const [historyVisible, setHistoryVisible] = useState(false);
const [history, setHistory] = useState<WristTemperatureHistoryPoint[]>([]);
const [historyLoading, setHistoryLoading] = useState(false);
const historyLoadingRef = useRef(false);
const [chartWidth, setChartWidth] = useState(INITIAL_CHART_WIDTH);
useEffect(() => {
const loadData = async () => {
const dateToUse = selectedDate || new Date();
if (!isFocused) return;
if (!HealthKitUtils.isAvailable()) {
setTemperature(null);
return;
}
// 防止重复请求
if (loadingRef.current) return;
try {
loadingRef.current = true;
setLoading(true);
const hasPermission = await ensureHealthPermissions();
if (!hasPermission) {
setTemperature(null);
return;
}
const dayStart = dayjs(dateToUse).startOf('day');
// wrist temperature samples often start于前一晚查询时向前扩展一天以包含跨夜数据
const options = {
startDate: dayStart.subtract(1, 'day').toDate().toISOString(),
endDate: dayStart.endOf('day').toDate().toISOString()
};
const data = await fetchWristTemperature(options, dateToUse);
setTemperature(data);
} catch (error) {
console.error('WristTemperatureCard: Failed to get wrist temperature data:', error);
setTemperature(null);
} finally {
setLoading(false);
loadingRef.current = false;
}
};
loadData();
}, [isFocused, selectedDate]);
useEffect(() => {
if (!historyVisible || !isFocused) return;
const loadHistory = async () => {
if (historyLoadingRef.current) return;
if (!HealthKitUtils.isAvailable()) {
setHistory([]);
return;
}
try {
historyLoadingRef.current = true;
setHistoryLoading(true);
const hasPermission = await ensureHealthPermissions();
if (!hasPermission) {
setHistory([]);
return;
}
const end = dayjs(selectedDate || new Date()).endOf('day');
const start = end.subtract(30, 'day').startOf('day').subtract(1, 'day');
const options = {
startDate: start.toDate().toISOString(),
endDate: end.toDate().toISOString(),
limit: 1200
};
const historyData = await fetchWristTemperatureHistory(options);
setHistory(historyData);
} catch (error) {
console.error('WristTemperatureCard: Failed to get wrist temperature history:', error);
setHistory([]);
} finally {
historyLoadingRef.current = false;
setHistoryLoading(false);
}
};
loadHistory();
}, [historyVisible, selectedDate, isFocused]);
const baseline = useMemo(() => {
if (!history.length) return null;
const avg = history.reduce((sum, point) => sum + point.value, 0) / history.length;
return Number(avg.toFixed(2));
}, [history]);
const chartRange = useMemo(() => {
if (!history.length) return { min: -1, max: 1 };
const values = history.map((p) => p.value);
const minValue = Math.min(...values);
const maxValue = Math.max(...values);
const center = baseline ?? (minValue + maxValue) / 2;
const maxDeviation = Math.max(Math.abs(maxValue - center), Math.abs(minValue - center), 0.2);
const padding = Math.max(maxDeviation * 0.25, 0.15);
return {
min: center - maxDeviation - padding,
max: center + maxDeviation + padding
};
}, [baseline, history]);
const xStep = useMemo(() => {
if (history.length <= 1) return 0;
return (chartWidth - CHART_HORIZONTAL_PADDING * 2) / (history.length - 1);
}, [history.length, chartWidth]);
const valueToY = useCallback(
(value: number) => {
const range = chartRange.max - chartRange.min || 1;
return ((chartRange.max - value) / range) * CHART_HEIGHT;
},
[chartRange.max, chartRange.min]
);
const linePath = useMemo(() => {
if (!history.length) return '';
return history.reduce((path, point, index) => {
const x = CHART_HORIZONTAL_PADDING + xStep * index;
const y = valueToY(point.value);
if (index === 0) return `M ${x} ${y}`;
return `${path} L ${x} ${y}`;
}, '');
}, [history, valueToY, xStep]);
const latestValue = history.length ? history[history.length - 1].value : null;
const latestChange = baseline !== null && latestValue !== null ? latestValue - baseline : null;
const dateLabels = useMemo(() => {
if (!history.length) return [];
const first = history[0];
const middle = history[Math.floor(history.length / 2)];
const last = history[history.length - 1];
const uniqueDates = [first, middle, last].filter((item, idx, arr) => {
if (!item) return false;
return arr.findIndex((it) => it?.date === item.date) === idx;
});
return uniqueDates.map((point) => {
const index = history.findIndex((p) => p.date === point.date);
const positionIndex = index >= 0 ? index : 0;
return {
date: point.date,
label: dayjs(point.date).format('MM.DD'),
x: CHART_HORIZONTAL_PADDING + positionIndex * xStep
};
});
}, [history, xStep]);
const openHistory = useCallback(() => {
setHistoryVisible(true);
}, []);
const closeHistory = useCallback(() => {
setHistoryVisible(false);
}, []);
return (
<>
<HealthDataCard
title={t('statistics.components.wristTemperature.title')}
value={loading ? '--' : (temperature !== null && temperature !== undefined ? temperature.toFixed(1) : '--')}
unit="°C"
style={style}
onPress={openHistory}
/>
<Modal
visible={historyVisible}
animationType="slide"
presentationStyle={Platform.OS === 'ios' ? 'pageSheet' : 'fullScreen'}
onRequestClose={closeHistory}
>
<View style={styles.modalSafeArea}>
<LinearGradient
colors={['#F7F6FF', '#FFFFFF']}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
style={StyleSheet.absoluteFill}
/>
<View style={styles.modalContainer}>
<View style={styles.modalHeader}>
<View>
<Text style={styles.modalTitle}>{t('statistics.components.wristTemperature.title')}</Text>
<Text style={styles.modalSubtitle}>{t('statistics.components.wristTemperature.last30Days')}</Text>
</View>
<Pressable style={styles.closeButton} onPress={closeHistory} hitSlop={10}>
<BlurView intensity={24} tint="light" style={StyleSheet.absoluteFill} />
<View style={styles.closeButtonInner}>
<Ionicons name="close" size={18} color="#111827" />
</View>
</Pressable>
</View>
{historyLoading ? (
<Text style={styles.hintText}>{t('statistics.components.wristTemperature.syncing')}</Text>
) : null}
{history.length === 0 ? (
<View style={styles.emptyState}>
<Text style={styles.emptyText}>{t('statistics.components.wristTemperature.noData')}</Text>
</View>
) : (
<View
style={styles.chartCard}
onLayout={(event) => {
const nextWidth = event.nativeEvent.layout.width;
if (nextWidth > 120 && Math.abs(nextWidth - chartWidth) > 2) {
setChartWidth(nextWidth);
}
}}
>
<Svg width={chartWidth} height={CHART_HEIGHT + 36}>
<Defs>
<SvgLinearGradient id="lineFade" x1="0%" y1="0%" x2="0%" y2="100%">
<Stop offset="0%" stopColor="#1F2A44" stopOpacity="1" />
<Stop offset="100%" stopColor="#1F2A44" stopOpacity="0.78" />
</SvgLinearGradient>
</Defs>
<Line
x1={CHART_HORIZONTAL_PADDING}
y1={valueToY(baseline ?? 0)}
x2={chartWidth - CHART_HORIZONTAL_PADDING}
y2={valueToY(baseline ?? 0)}
stroke="#CBD5E1"
strokeDasharray="6 6"
strokeWidth={1.2}
/>
<Path d={linePath} stroke="url(#lineFade)" strokeWidth={2.6} fill="none" strokeLinecap="round" />
{history.map((point, index) => {
const x = CHART_HORIZONTAL_PADDING + xStep * index;
const y = valueToY(point.value);
return (
<Circle
key={point.date}
cx={x}
cy={y}
r={5}
stroke="#1F2A44"
strokeWidth={1.6}
fill="#FFFFFF"
/>
);
})}
</Svg>
<View style={styles.labelRow}>
{dateLabels.map((item) => {
const clampedLeft = Math.min(
Math.max(item.x - LABEL_ESTIMATED_WIDTH / 2, CHART_HORIZONTAL_PADDING),
chartWidth - CHART_HORIZONTAL_PADDING - LABEL_ESTIMATED_WIDTH
);
return (
<Text key={item.date} style={[styles.axisLabel, { left: clampedLeft, width: LABEL_ESTIMATED_WIDTH }]}>
{item.label}
</Text>
);
})}
<View style={styles.baselineLabelWrapper}>
<View style={styles.baselinePill}>
<View style={styles.baselineDot} />
<Text style={styles.axisHint}>{t('statistics.components.wristTemperature.baseline')}</Text>
{baseline !== null && (
<Text style={styles.axisHintValue}>
{baseline.toFixed(1)}
°C
</Text>
)}
</View>
</View>
{latestChange !== null && (
<View style={styles.deviationBadge}>
<Text style={styles.deviationBadgeText}>
{latestChange >= 0 ? '+' : ''}
{latestChange.toFixed(1)}°C
</Text>
</View>
)}
</View>
</View>
)}
<View style={styles.metricsRow}>
<View style={styles.metric}>
<Text style={styles.metricLabel}>{t('statistics.components.wristTemperature.average')}</Text>
<Text style={styles.metricValue}>
{baseline !== null ? baseline.toFixed(1) : '--'}
<Text style={styles.metricUnit}>°C</Text>
</Text>
</View>
<View style={styles.metric}>
<Text style={styles.metricLabel}>{t('statistics.components.wristTemperature.latest')}</Text>
<Text style={styles.metricValue}>
{latestValue !== null ? latestValue.toFixed(1) : '--'}
<Text style={styles.metricUnit}>°C</Text>
</Text>
{latestChange !== null && (
<Text style={styles.metricHint}>
{latestChange >= 0 ? '+' : ''}
{latestChange.toFixed(1)}°C {t('statistics.components.wristTemperature.vsBaseline')}
</Text>
)}
</View>
</View>
</View>
</View>
</Modal>
</>
);
};
export default WristTemperatureCard;
const styles = StyleSheet.create({
modalSafeArea: {
flex: 1,
backgroundColor: '#FFFFFF',
paddingTop: Platform.OS === 'ios' ? 10 : 0
},
modalContainer: {
flex: 1,
paddingHorizontal: 20,
paddingTop: 22
},
modalHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 14
},
modalTitle: {
fontSize: 22,
fontWeight: '700',
color: '#1C1C28',
fontFamily: 'AliBold'
},
modalSubtitle: {
fontSize: 13,
color: '#6B7280',
marginTop: 4,
fontFamily: 'AliRegular'
},
closeButton: {
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: 'rgba(255,255,255,0.42)',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
borderWidth: 0.5,
borderColor: 'rgba(255,255,255,0.6)',
shadowColor: '#0F172A',
shadowOpacity: 0.08,
shadowRadius: 8,
shadowOffset: { width: 0, height: 4 },
elevation: 2
},
closeButtonInner: {
flex: 1,
alignItems: 'center',
justifyContent: 'center'
},
chartCard: {
backgroundColor: '#FFFFFF',
borderRadius: 24,
paddingVertical: 12,
paddingHorizontal: 12,
shadowColor: '#000',
shadowOpacity: 0.04,
shadowRadius: 12,
shadowOffset: { width: 0, height: 10 },
elevation: 4,
marginTop: 8,
marginBottom: 14,
borderWidth: 1,
borderColor: '#F1F5F9'
},
labelRow: {
marginTop: -6,
paddingHorizontal: 12,
height: 44,
justifyContent: 'center'
},
axisLabel: {
position: 'absolute',
bottom: 0,
fontSize: 11,
color: '#94A3B8',
fontFamily: 'AliRegular',
textAlign: 'center'
},
baselineLabelWrapper: {
position: 'absolute',
left: 0,
top: -4,
flexDirection: 'row',
alignItems: 'center'
},
baselinePill: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 10,
paddingVertical: 6,
backgroundColor: '#F1F5F9',
borderRadius: 14,
borderWidth: 1,
borderColor: '#E2E8F0',
gap: 6
},
baselineDot: {
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: '#94A3B8'
},
axisHint: {
fontSize: 12,
color: '#6B7280',
fontFamily: 'AliRegular'
},
axisHintValue: {
fontSize: 13,
color: '#111827',
fontWeight: '700',
fontFamily: 'AliBold'
},
deviationBadge: {
position: 'absolute',
right: 12,
bottom: 2,
backgroundColor: '#ECFEFF',
borderRadius: 12,
paddingHorizontal: 12,
paddingVertical: 5,
borderWidth: 1,
borderColor: '#CFFAFE'
},
deviationBadgeText: {
fontSize: 12,
color: '#0EA5E9',
fontWeight: '700',
fontFamily: 'AliBold'
},
metricsRow: {
flexDirection: 'row',
justifyContent: 'space-between',
gap: 12,
paddingVertical: 6
},
metric: {
flex: 1,
backgroundColor: '#F8FAFC',
borderRadius: 18,
padding: 14,
borderWidth: 1,
borderColor: '#E2E8F0'
},
metricLabel: {
fontSize: 12,
color: '#6B7280',
marginBottom: 6,
fontFamily: 'AliRegular'
},
metricValue: {
fontSize: 20,
color: '#111827',
fontWeight: '700',
fontFamily: 'AliBold'
},
metricUnit: {
fontSize: 12,
color: '#6B7280',
marginLeft: 4,
fontWeight: '500',
fontFamily: 'AliRegular'
},
metricHint: {
marginTop: 6,
fontSize: 12,
color: '#6B21A8',
fontFamily: 'AliRegular'
},
emptyState: {
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 32
},
emptyText: {
fontSize: 14,
color: '#94A3B8',
fontFamily: 'AliRegular'
},
hintText: {
fontSize: 12,
color: '#6B7280',
marginBottom: 6,
fontFamily: 'AliRegular'
}
});

44
components/ui/Image.tsx Normal file
View File

@@ -0,0 +1,44 @@
import { API_ORIGIN } from '@/constants/Api';
import Constants from 'expo-constants';
import { Image as ExpoImage, ImageProps as ExpoImageProps } from 'expo-image';
import React, { forwardRef, useMemo } from 'react';
// Construct User-Agent
const APP_NAME = Constants.expoConfig?.name || 'Out Live';
const APP_VERSION = Constants.expoConfig?.version || '1.1.5';
const USER_AGENT = `${APP_NAME}/${APP_VERSION} (iOS)`;
export type ImageProps = ExpoImageProps;
export const Image = forwardRef<ExpoImage, ImageProps>(({ source, ...props }, ref) => {
const finalSource = useMemo(() => {
if (!source) return source;
const headers = {
'User-Agent': USER_AGENT,
'Referer': API_ORIGIN,
};
const addHeaders = (src: any) => {
if (typeof src === 'number' || src === null || src === undefined) return src;
if (typeof src === 'string') return { uri: src, headers };
if (typeof src === 'object' && 'uri' in src) {
return {
...src,
headers: { ...headers, ...(src.headers || {}) }
};
}
return src;
};
if (Array.isArray(source)) {
return source.map(addHeaders);
}
return addHeaders(source);
}, [source]);
return <ExpoImage {...props} source={finalSource} ref={ref} />;
});
Image.displayName = 'Image';

View File

@@ -1,7 +1,7 @@
import { Image } from '@/components/ui/Image';
import { Ionicons } from '@expo/vector-icons';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import * as Haptics from 'expo-haptics';
import { Image } from 'expo-image';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import {
ActivityIndicator,

View File

@@ -1,3 +1,4 @@
import { Image } from '@/components/ui/Image';
import { Colors } from '@/constants/Colors';
import { ROUTES } from '@/constants/Routes';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
@@ -7,8 +8,8 @@ import { fetchWeightHistory } from '@/store/userSlice';
import { BMI_CATEGORIES } from '@/utils/bmi';
import { Ionicons } from '@expo/vector-icons';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient';
import { useRouter } from 'expo-router';
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
@@ -20,18 +21,26 @@ import {
TouchableOpacity,
View
} from 'react-native';
import Svg, { Circle, Path } from 'react-native-svg';
import Svg, { Circle, Defs, Path, Stop, LinearGradient as SvgLinearGradient } from 'react-native-svg';
import { WeightProgressBar } from './WeightProgressBar';
const { width: screenWidth } = Dimensions.get('window');
const CARD_WIDTH = screenWidth - 40; // Subtract left and right margins
const CHART_WIDTH = CARD_WIDTH - 36; // Subtract card padding
const CHART_HEIGHT = 60;
const CARD_WIDTH = screenWidth - 40;
const CHART_WIDTH = CARD_WIDTH - 36;
const CHART_HEIGHT = 70;
const PADDING = 10;
// 主题色
const THEME_PRIMARY = '#4F5BD5';
const THEME_SECONDARY = '#6B6CFF';
const THEME_SUCCESS = '#22C55E';
const THEME_TEXT_PRIMARY = '#1c1f3a';
const THEME_TEXT_SECONDARY = '#6f7ba7';
export function WeightHistoryCard() {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const router = useRouter();
const userProfile = useAppSelector((s) => s.user.profile);
const weightHistory = useAppSelector((s) => s.user.weightHistory);
@@ -44,7 +53,6 @@ export function WeightHistoryCard() {
const hasWeight = userProfile?.weight && parseFloat(userProfile.weight) > 0;
useEffect(() => {
if (isLoggedIn) {
loadWeightHistory();
@@ -59,7 +67,8 @@ export function WeightHistoryCard() {
}
};
const navigateToCoach = () => {
// 点击添加按钮 - 需要登录
const handleAddWeight = () => {
pushIfAuthedElseLogin(ROUTES.WEIGHT_RECORDS);
};
@@ -67,85 +76,97 @@ export function WeightHistoryCard() {
setShowBMIModal(false);
};
// 点击卡片 - 直接跳转,不需要登录
const navigateToWeightRecords = () => {
pushIfAuthedElseLogin(ROUTES.WEIGHT_RECORDS);
router.push(ROUTES.WEIGHT_RECORDS);
};
// Process weight history data
const sortedHistory = [...weightHistory]
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
.slice(-7); // Show only the last 7 records
.slice(-7);
// return (
// <TouchableOpacity style={styles.card} onPress={navigateToWeightRecords} activeOpacity={0.8}>
// <View style={styles.cardHeader}>
// <Text style={styles.cardTitle}>{t('statistics.components.weight.title')}</Text>
// </View>
// 是否有数据
const hasData = sortedHistory.length > 0;
// <View style={styles.emptyContent}>
// <Text style={styles.emptyDescription}>
// No weight records yet, click the button below to start recording
// </Text>
// <TouchableOpacity
// style={styles.recordButton}
// onPress={(e) => {
// e.stopPropagation();
// navigateToCoach();
// }}
// activeOpacity={0.8}
// >
// <Ionicons name="add" size={18} color="#FFFFFF" />
// <Text style={styles.recordButtonText}>{t('statistics.components.weight.addButton')}</Text>
// </TouchableOpacity>
// </View>
// </TouchableOpacity>
// );
// }
// 计算减重进度
const currentWeight = userProfile?.weight ? parseFloat(userProfile.weight) : 0;
const initialWeight = userProfile?.initialWeight
? parseFloat(userProfile.initialWeight)
: (sortedHistory.length > 0 ? parseFloat(sortedHistory[0].weight) : 0);
const targetWeight = userProfile?.targetWeight ? parseFloat(userProfile.targetWeight) : 0;
// 计算进度百分比
const hasTargetWeight = targetWeight > 0 && initialWeight > targetWeight;
const totalToLose = initialWeight - targetWeight;
const actualLost = initialWeight - currentWeight;
const weightProgress = hasTargetWeight && totalToLose > 0 ? actualLost / totalToLose : 0;
// Generate chart data
const weights = sortedHistory.map(item => parseFloat(item.weight));
const minWeight = Math.min(...weights);
const maxWeight = Math.max(...weights);
const weights = hasData ? sortedHistory.map(item => parseFloat(item.weight)) : [];
const minWeight = hasData ? Math.min(...weights) : 0;
const maxWeight = hasData ? Math.max(...weights) : 0;
const weightRange = maxWeight - minWeight || 1;
const points = sortedHistory.map((item, index) => {
const points = hasData ? sortedHistory.map((item, index) => {
const x = PADDING + (index / Math.max(sortedHistory.length - 1, 1)) * (CHART_WIDTH - 2 * PADDING);
const normalizedWeight = (parseFloat(item.weight) - minWeight) / weightRange;
// Reduce top margin, compress whitespace
const y = PADDING + 8 + (1 - normalizedWeight) * (CHART_HEIGHT - 2 * PADDING - 16);
const y = PADDING + 10 + (1 - normalizedWeight) * (CHART_HEIGHT - 2 * PADDING - 20);
return { x, y, weight: item.weight, date: item.createdAt };
});
}) : [];
// Generate path
const pathData = points.map((point, index) => {
if (index === 0) return `M ${point.x} ${point.y}`;
return `L ${point.x} ${point.y}`;
}).join(' ');
// 生成平滑曲线路径(使用贝塞尔曲线)
const generateSmoothPath = (pts: typeof points) => {
if (pts.length === 0) return '';
if (pts.length === 1) return `M ${pts[0].x} ${pts[0].y}`;
// If there's only one data point, display as a horizontal line
const singlePointPath = points.length === 1 ?
`M ${PADDING} ${points[0].y} L ${CHART_WIDTH - PADDING} ${points[0].y}` :
pathData;
let path = `M ${pts[0].x} ${pts[0].y}`;
for (let i = 0; i < pts.length - 1; i++) {
const p0 = pts[Math.max(0, i - 1)];
const p1 = pts[i];
const p2 = pts[i + 1];
const p3 = pts[Math.min(pts.length - 1, i + 2)];
const cp1x = p1.x + (p2.x - p0.x) / 6;
const cp1y = p1.y + (p2.y - p0.y) / 6;
const cp2x = p2.x - (p3.x - p1.x) / 6;
const cp2y = p2.y - (p3.y - p1.y) / 6;
path += ` C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${p2.x} ${p2.y}`;
}
return path;
};
const smoothPath = generateSmoothPath(points);
const singlePointPath = points.length === 1
? `M ${PADDING} ${points[0].y} L ${CHART_WIDTH - PADDING} ${points[0].y}`
: smoothPath;
// 空状态下的占位曲线路径(水平虚线效果)
const emptyLinePath = `M ${PADDING} ${CHART_HEIGHT / 2} L ${CHART_WIDTH - PADDING} ${CHART_HEIGHT / 2}`;
return (
<TouchableOpacity style={styles.card} onPress={navigateToWeightRecords} activeOpacity={0.8}>
<View style={styles.cardHeader}>
<Image
source={require('@/assets/images/icons/icon-weight.png')}
style={styles.iconSquare}
/>
<View style={styles.iconContainer}>
<Image
source={require('@/assets/images/icons/icon-weight.png')}
style={styles.iconSquare}
/>
</View>
<Text style={styles.cardTitle}>{t('statistics.components.weight.title')}</Text>
{isLgAvaliable ? (
<TouchableOpacity
onPress={(e) => {
e.stopPropagation();
navigateToCoach();
handleAddWeight();
}}
activeOpacity={0.8}
>
<GlassView style={styles.addButtonGlass}>
<Ionicons name="add" size={18} color={Colors.light.primary} />
<Ionicons name="add" size={18} color={THEME_PRIMARY} />
</GlassView>
</TouchableOpacity>
) : (
@@ -153,68 +174,125 @@ export function WeightHistoryCard() {
style={styles.addButton}
onPress={(e) => {
e.stopPropagation();
navigateToCoach();
handleAddWeight();
}}
activeOpacity={0.8}
>
<Ionicons name="add" size={18} color={Colors.light.primary} />
<Ionicons name="add" size={18} color={THEME_PRIMARY} />
</TouchableOpacity>
)}
</View>
{/* Default chart display */}
{sortedHistory.length > 0 && (
<View style={styles.chartContainer}>
<Svg width={CHART_WIDTH} height={CHART_HEIGHT + 15}>
{/* Background grid lines */}
{/* 当前体重显示 */}
<View style={styles.currentWeightSection}>
<View style={styles.weightValueContainer}>
<Text style={styles.weightValue}>{hasWeight ? currentWeight.toFixed(1) : '--'}</Text>
<Text style={styles.weightUnit}>kg</Text>
</View>
{sortedHistory.length > 1 && (
<View style={[
styles.changeTag,
{ backgroundColor: actualLost >= 0 ? 'rgba(34, 197, 94, 0.1)' : 'rgba(255, 107, 107, 0.1)' }
]}>
<Ionicons
name={actualLost >= 0 ? 'trending-down' : 'trending-up'}
size={12}
color={actualLost >= 0 ? THEME_SUCCESS : '#FF6B6B'}
/>
<Text style={[
styles.changeText,
{ color: actualLost >= 0 ? THEME_SUCCESS : '#FF6B6B' }
]}>
{actualLost >= 0 ? '-' : '+'}{Math.abs(actualLost).toFixed(1)}kg
</Text>
</View>
)}
</View>
{/* More abstract line - reduce line width and display details */}
{/* 图表显示 */}
<View style={styles.chartContainer}>
<Svg width={CHART_WIDTH} height={CHART_HEIGHT + 15}>
<Defs>
<SvgLinearGradient id="lineGradient" x1="0%" y1="0%" x2="100%" y2="0%">
<Stop offset="0%" stopColor={THEME_PRIMARY} stopOpacity="1" />
<Stop offset="100%" stopColor={THEME_SECONDARY} stopOpacity="1" />
</SvgLinearGradient>
</Defs>
{hasData ? (
<>
{/* 平滑曲线 */}
<Path
d={singlePointPath}
stroke="url(#lineGradient)"
strokeWidth={2.5}
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
/>
{/* 数据点 */}
{points.map((point, index) => {
const isLastPoint = index === points.length - 1;
return (
<React.Fragment key={index}>
{/* 外圈光晕 */}
{isLastPoint && (
<Circle
cx={point.x}
cy={point.y}
r={8}
fill={THEME_PRIMARY}
opacity={0.15}
/>
)}
{/* 数据点 */}
<Circle
cx={point.x}
cy={point.y}
r={isLastPoint ? 4 : 2.5}
fill={isLastPoint ? THEME_PRIMARY : THEME_SECONDARY}
stroke={isLastPoint ? '#ffffff' : 'none'}
strokeWidth={isLastPoint ? 2 : 0}
/>
</React.Fragment>
);
})}
</>
) : (
/* 空状态 - 虚线占位 */
<Path
d={singlePointPath}
stroke={Colors.light.accentGreen}
d={emptyLinePath}
stroke="#E8EAF0"
strokeWidth={2}
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
opacity={0.8}
strokeDasharray="8,6"
/>
)}
</Svg>
{/* Simplified data points - smaller and more refined */}
{points.map((point, index) => {
const isLastPoint = index === points.length - 1;
return (
<React.Fragment key={index}>
<Circle
cx={point.x}
cy={point.y}
r={isLastPoint ? 3 : 2}
fill={Colors.light.accentGreen}
opacity={0.9}
/>
</React.Fragment>
);
})}
</Svg>
{/* Concise chart information */}
<View style={styles.chartInfo}>
<View style={styles.infoItem}>
<Text style={styles.infoLabel}>{userProfile.weight}kg</Text>
</View>
<View style={styles.infoItem}>
<Text style={styles.infoLabel}>{sortedHistory.length}{t('statistics.components.weight.days')}</Text>
</View>
<View style={styles.infoItem}>
<Text style={styles.infoLabel}>
{minWeight.toFixed(1)}-{maxWeight.toFixed(1)}kg
</Text>
</View>
{/* 图表信息 */}
<View style={styles.chartInfo}>
<View style={styles.infoItem}>
<Text style={styles.infoLabel}>{hasData ? sortedHistory.length : '--'}{t('statistics.components.weight.days')}</Text>
</View>
<View style={styles.infoItem}>
<Text style={styles.infoLabel}>
{hasData ? `${minWeight.toFixed(1)}-${maxWeight.toFixed(1)}kg` : '--'}
</Text>
</View>
</View>
)}
</View>
{/* 减重进度条 - 始终显示 */}
<WeightProgressBar
progress={weightProgress}
currentWeight={currentWeight}
targetWeight={targetWeight}
initialWeight={initialWeight}
/>
{/* BMI information modal */}
<Modal
@@ -323,32 +401,38 @@ export function WeightHistoryCard() {
const styles = StyleSheet.create({
card: {
backgroundColor: '#FFFFFF',
borderRadius: 22,
padding: 16,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 3,
marginTop: 16
borderRadius: 24,
padding: 18,
shadowColor: 'rgba(30, 41, 59, 0.08)',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.12,
shadowRadius: 12,
elevation: 4,
marginTop: 16,
},
cardHeader: {
flexDirection: 'row',
alignItems: 'center',
},
iconSquare: {
width: 14,
height: 14,
iconContainer: {
width: 28,
height: 28,
borderRadius: 8,
backgroundColor: 'rgba(79, 91, 213, 0.1)',
alignItems: 'center',
justifyContent: 'center',
marginRight: 4,
marginRight: 10,
},
iconSquare: {
width: 16,
height: 16,
tintColor: THEME_PRIMARY,
},
cardTitle: {
fontSize: 14,
color: '#192126',
fontSize: 15,
color: THEME_TEXT_PRIMARY,
flex: 1,
fontWeight: '600',
fontWeight: '700',
fontFamily: 'AliBold',
},
headerButtons: {
@@ -364,19 +448,56 @@ const styles = StyleSheet.create({
justifyContent: 'center',
},
addButton: {
width: 28,
height: 28,
borderRadius: 14,
width: 32,
height: 32,
borderRadius: 16,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(79, 91, 213, 0.1)',
},
addButtonGlass: {
width: 28,
height: 28,
borderRadius: 14,
width: 32,
height: 32,
borderRadius: 16,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(147, 112, 219, 0.3)',
backgroundColor: 'rgba(79, 91, 213, 0.15)',
},
currentWeightSection: {
flexDirection: 'row',
alignItems: 'center',
marginTop: 12,
gap: 12,
},
weightValueContainer: {
flexDirection: 'row',
alignItems: 'baseline',
},
weightValue: {
fontSize: 32,
fontWeight: '800',
color: THEME_TEXT_PRIMARY,
fontFamily: 'AliBold',
},
weightUnit: {
fontSize: 14,
fontWeight: '600',
color: THEME_TEXT_SECONDARY,
fontFamily: 'AliRegular',
marginLeft: 4,
},
changeTag: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 12,
gap: 4,
},
changeText: {
fontSize: 12,
fontWeight: '700',
fontFamily: 'AliBold',
},
emptyContent: {
alignItems: 'center',
@@ -384,12 +505,12 @@ const styles = StyleSheet.create({
emptyTitle: {
fontSize: 16,
fontWeight: '700',
color: '#192126',
color: THEME_TEXT_PRIMARY,
marginBottom: 6,
},
emptyDescription: {
fontSize: 14,
color: '#687076',
color: THEME_TEXT_SECONDARY,
textAlign: 'center',
marginBottom: 16,
lineHeight: 20,
@@ -397,14 +518,14 @@ const styles = StyleSheet.create({
recordButton: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: Colors.light.accentGreen,
backgroundColor: THEME_PRIMARY,
paddingHorizontal: 16,
paddingVertical: 10,
borderRadius: 20,
gap: 6,
},
recordButtonText: {
color: '#192126',
color: '#FFFFFF',
fontSize: 14,
fontWeight: '700',
fontFamily: 'AliBold',
@@ -418,20 +539,25 @@ const styles = StyleSheet.create({
flexDirection: 'row',
justifyContent: 'space-around',
width: '100%',
marginTop: -14,
},
infoItem: {
alignItems: 'center',
backgroundColor: 'rgba(79, 91, 213, 0.06)',
paddingHorizontal: 12,
paddingVertical: 4,
borderRadius: 10,
},
infoLabel: {
fontSize: 11,
color: '#687076',
color: THEME_TEXT_SECONDARY,
fontWeight: '500',
fontFamily: 'AliRegular',
},
infoValue: {
fontSize: 14,
fontWeight: '700',
color: '#192126',
color: THEME_TEXT_PRIMARY,
},
// BMI modal styles
@@ -556,7 +682,7 @@ const styles = StyleSheet.create({
marginBottom: 8,
},
bmiModalButtonBackground: {
backgroundColor: '#192126',
backgroundColor: THEME_TEXT_PRIMARY,
borderRadius: 16,
paddingVertical: 16,
alignItems: 'center',

View File

@@ -0,0 +1,278 @@
import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import React, { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Animated,
Easing,
StyleSheet,
Text,
View,
ViewStyle
} from 'react-native';
// 主题色
const THEME_PRIMARY = '#4F5BD5';
const THEME_SECONDARY = '#6B6CFF';
const THEME_SUCCESS = '#22C55E';
const THEME_TEXT_SECONDARY = '#6f7ba7';
export interface WeightProgressBarProps {
/** 进度值 0-1 */
progress: number;
/** 当前体重 */
currentWeight: number;
/** 目标体重 */
targetWeight: number;
/** 初始体重 */
initialWeight: number;
/** 容器样式 */
style?: ViewStyle;
/** 是否显示顶部分隔线,默认 true */
showTopBorder?: boolean;
}
export const WeightProgressBar: React.FC<WeightProgressBarProps> = ({
progress,
currentWeight,
targetWeight,
initialWeight,
style,
showTopBorder = true,
}) => {
const { t } = useTranslation();
const animatedProgress = useRef(new Animated.Value(0)).current;
const [barWidth, setBarWidth] = useState(0);
const clampedProgress = Math.min(1, Math.max(0, progress));
const percent = Math.round(clampedProgress * 100);
// 判断是否有有效数据
const hasInitialWeight = initialWeight > 0;
const hasTargetWeight = targetWeight > 0;
const hasCurrentWeight = currentWeight > 0;
// 只要有初始体重和当前体重,就可以显示已减重量
const canShowLost = hasInitialWeight && hasCurrentWeight;
// 需要有目标体重才能显示距离目标和进度
const canShowTarget = hasTargetWeight && hasCurrentWeight;
useEffect(() => {
// 延迟 500ms 开始动画,避免页面刚进入时卡顿
const timer = setTimeout(() => {
Animated.timing(animatedProgress, {
toValue: clampedProgress,
duration: 800,
easing: Easing.out(Easing.cubic),
useNativeDriver: false,
}).start();
}, 800);
return () => clearTimeout(timer);
}, [clampedProgress]);
const fillWidth = animatedProgress.interpolate({
inputRange: [0, 1],
outputRange: [0, barWidth],
});
const sliderPosition = animatedProgress.interpolate({
inputRange: [0, 1],
outputRange: [-12, barWidth - 12],
});
const weightLost = initialWeight - currentWeight;
const weightToGo = currentWeight - targetWeight;
return (
<View style={[
styles.container,
showTopBorder && styles.topBorder,
style
]}>
{/* 进度信息 */}
<View style={styles.infoRow}>
<View style={styles.infoItem}>
<Text style={styles.infoLabel}>{t('statistics.components.weight.progress.lost')}</Text>
<Text style={[styles.infoValue, { color: canShowLost && weightLost >= 0 ? THEME_SUCCESS : (canShowLost ? '#FF6B6B' : THEME_TEXT_SECONDARY) }]}>
{canShowLost ? `${weightLost >= 0 ? '-' : '+'}${Math.abs(weightLost).toFixed(1)}kg` : '--'}
</Text>
</View>
<View style={styles.percentContainer}>
<Text style={styles.percentValue}>{percent}</Text>
<Text style={styles.percentSymbol}>%</Text>
</View>
<View style={[styles.infoItem, { alignItems: 'flex-end' }]}>
<Text style={styles.infoLabel}>{t('statistics.components.weight.progress.toGo')}</Text>
<Text style={[styles.infoValue, { color: THEME_PRIMARY }]}>
{canShowTarget ? `${weightToGo > 0 ? weightToGo.toFixed(1) : '0'}kg` : '--'}
</Text>
</View>
</View>
{/* 进度条 */}
<View
style={styles.trackContainer}
onLayout={(e) => setBarWidth(e.nativeEvent.layout.width)}
>
{/* 背景轨道 */}
<View style={styles.track} />
{/* 填充进度 */}
<Animated.View style={[styles.fill, { width: fillWidth }]}>
<LinearGradient
colors={[THEME_PRIMARY, THEME_SECONDARY]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={StyleSheet.absoluteFillObject}
/>
</Animated.View>
{/* 滑块 - 圆角矩形 */}
<Animated.View style={[styles.slider, { left: sliderPosition }]}>
<LinearGradient
colors={['#ffffff', '#f8f9fc']}
style={styles.sliderInner}
>
<View style={styles.sliderLine} />
</LinearGradient>
</Animated.View>
</View>
{/* 起止标签 */}
<View style={styles.labelRow}>
<Text style={styles.labelText}>{hasInitialWeight ? `${initialWeight.toFixed(1)}kg` : '--'}</Text>
<View style={styles.targetBadge}>
<Ionicons name="flag" size={10} color={THEME_PRIMARY} />
<Text style={styles.targetText}>{hasTargetWeight ? `${targetWeight.toFixed(1)}kg` : '--'}</Text>
</View>
</View>
</View>
);
};
const styles = StyleSheet.create({
container: {
marginTop: 12,
paddingTop: 10,
marginLeft:12,
marginRight: 12
},
topBorder: {
borderTopWidth: 1,
borderTopColor: 'rgba(0,0,0,0.04)',
},
infoRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 8,
},
infoItem: {
flex: 1,
},
infoLabel: {
fontSize: 11,
color: THEME_TEXT_SECONDARY,
fontFamily: 'AliRegular',
marginBottom: 2,
},
infoValue: {
fontSize: 14,
fontWeight: '700',
fontFamily: 'AliBold',
},
percentContainer: {
flexDirection: 'row',
alignItems: 'baseline',
justifyContent: 'center',
},
percentValue: {
fontSize: 24,
fontWeight: '800',
color: THEME_PRIMARY,
fontFamily: 'AliBold',
},
percentSymbol: {
fontSize: 12,
fontWeight: '600',
color: THEME_PRIMARY,
fontFamily: 'AliBold',
marginLeft: 2,
},
trackContainer: {
height: 8,
position: 'relative',
marginBottom: 8,
},
track: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
backgroundColor: '#E8EAF0',
borderRadius: 4,
},
fill: {
position: 'absolute',
left: 0,
top: 0,
bottom: 0,
borderRadius: 4,
overflow: 'hidden',
},
slider: {
position: 'absolute',
top: -8,
width: 24,
height: 24,
borderRadius: 8,
shadowColor: THEME_PRIMARY,
shadowOffset: { width: 0, height: 3 },
shadowOpacity: 0.35,
shadowRadius: 6,
elevation: 6,
},
sliderInner: {
width: '100%',
height: '100%',
borderRadius: 8,
alignItems: 'center',
justifyContent: 'center',
borderWidth: 2.5,
borderColor: THEME_PRIMARY,
},
sliderLine: {
width: 8,
height: 3,
borderRadius: 1.5,
backgroundColor: THEME_PRIMARY,
},
labelRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
labelText: {
fontSize: 11,
color: THEME_TEXT_SECONDARY,
fontFamily: 'AliRegular',
},
targetBadge: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(79, 91, 213, 0.1)',
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: 10,
gap: 4,
},
targetText: {
fontSize: 11,
color: THEME_PRIMARY,
fontWeight: '600',
fontFamily: 'AliBold',
},
});
export default WeightProgressBar;

View File

@@ -1,20 +1,24 @@
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { Ionicons, MaterialCommunityIcons } from '@expo/vector-icons';
import dayjs from 'dayjs';
import 'dayjs/locale/en';
import 'dayjs/locale/zh-cn';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { LinearGradient } from 'expo-linear-gradient';
import React, { useEffect, useMemo, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
ActivityIndicator,
Dimensions,
Modal,
Pressable,
ScrollView,
Share,
StyleSheet,
Text,
TouchableOpacity,
TouchableWithoutFeedback,
View,
} from 'react-native';
import ViewShot, { captureRef } from 'react-native-view-shot';
import { useI18n } from '@/hooks/useI18n';
import {
@@ -26,6 +30,7 @@ import {
WorkoutActivityType,
WorkoutData,
} from '@/utils/health';
import { Toast } from '@/utils/toast.utils';
export interface IntensityBadge {
label: string;
@@ -65,6 +70,8 @@ export function WorkoutDetailModal({
const [isMounted, setIsMounted] = useState(visible);
const [shouldRenderChart, setShouldRenderChart] = useState(visible);
const [showIntensityInfo, setShowIntensityInfo] = useState(false);
const [sharing, setSharing] = useState(false);
const shareContentRef = useRef<ViewShot | null>(null);
const locale = useMemo(() => (i18n.language?.startsWith('en') ? 'en' : 'zh-cn'), [i18n.language]);
@@ -138,6 +145,50 @@ export function WorkoutDetailModal({
}
};
const handleShare = useCallback(async () => {
if (!shareContentRef.current || !workout || sharing) {
return;
}
setSharing(true);
try {
Toast.show({
type: 'info',
text1: t('workoutDetail.share.generating', '正在生成分享卡片…'),
});
const uri = await captureRef(shareContentRef, {
format: 'png',
quality: 0.95,
snapshotContentContainer: true,
});
if (!uri) {
throw new Error('share-capture-failed');
}
const shareUri = uri.startsWith('file://') ? uri : `file://${uri}`;
const shareTitle = t('workoutDetail.share.title', { defaultValue: activityName || t('workoutDetail.title', '锻炼详情') });
const caloriesLabel = metrics?.calories != null
? `${metrics.calories} ${t('workoutDetail.metrics.caloriesUnit')}`
: '--';
const shareMessage = t('workoutDetail.share.message', {
activity: activityName || t('workoutDetail.share.activityFallback', '锻炼'),
duration: metrics?.durationLabel ?? '--',
calories: caloriesLabel,
date: dateInfo.subtitle,
defaultValue: `我的${activityName || '锻炼'}${dateInfo.subtitle},持续${metrics?.durationLabel ?? '--'},消耗${caloriesLabel}`,
});
await Share.share({
title: shareTitle,
message: shareMessage,
url: shareUri,
});
} catch (error) {
console.warn('workout-detail-share-failed', error);
Toast.error(t('workoutDetail.share.failed', '分享失败,请稍后再试'));
} finally {
setSharing(false);
}
}, [activityName, dateInfo.subtitle, metrics?.calories, metrics?.durationLabel, sharing, t, workout]);
if (!isMounted) {
return null;
}
@@ -176,7 +227,48 @@ export function WorkoutDetailModal({
<Text style={styles.headerSubtitle}>{dateInfo.subtitle}</Text>
</View>
<View style={styles.headerSpacer} />
{isLiquidGlassAvailable() ? (
<Pressable
onPress={handleShare}
disabled={loading || sharing || !workout}
style={({ pressed }) => [
styles.headerIconButton,
styles.glassButtonWrapper,
pressed && styles.headerIconPressed,
(loading || sharing || !workout) && styles.headerIconDisabled,
]}
accessibilityRole="button"
accessibilityLabel={t('workoutDetail.share.accessibilityLabel', '分享锻炼记录')}
>
<GlassView glassEffectStyle="regular" tintColor="rgba(255,255,255,0.9)" isInteractive style={styles.glassButton}>
<View style={styles.glassButtonInner}>
<Ionicons name="share-outline" size={20} color="#1E2148" />
</View>
</GlassView>
</Pressable>
) : (
<Pressable
onPress={handleShare}
disabled={loading || sharing || !workout}
style={({ pressed }) => [
styles.headerIconButton,
styles.headerIconFallback,
pressed && styles.headerIconPressed,
(loading || sharing || !workout) && styles.headerIconDisabled,
]}
accessibilityRole="button"
accessibilityLabel={t('workoutDetail.share.accessibilityLabel', '分享锻炼记录')}
>
<LinearGradient
colors={['#EEF2FF', '#E0E7FF']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.glassButtonInner}
>
<Ionicons name="share-outline" size={20} color="#1E2148" />
</LinearGradient>
</Pressable>
)}
</View>
<View style={styles.heroIconWrapper}>
@@ -390,6 +482,237 @@ export function WorkoutDetailModal({
<View style={styles.homeIndicatorSpacer} />
</ScrollView>
</View>
{/* Hidden share capture renders full content height for complete screenshots */}
<ViewShot
ref={shareContentRef}
style={[styles.sheetContainer, styles.shareCaptureContainer]}
options={{ format: 'png', quality: 0.95, snapshotContentContainer: true }}
>
<LinearGradient
colors={['#FFFFFF', '#F3F5FF']}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
style={styles.gradientBackground}
/>
<View style={styles.handleWrapper}>
<View style={styles.handle} />
</View>
<View style={styles.headerRow}>
<View style={styles.headerIconButton} />
<View style={styles.headerTitleWrapper}>
<Text style={styles.headerTitle}>{dateInfo.title}</Text>
<Text style={styles.headerSubtitle}>{dateInfo.subtitle}</Text>
</View>
<View style={styles.headerIconButton} />
</View>
<View style={styles.heroIconWrapper}>
<MaterialCommunityIcons name="run" size={160} color="#E8EAFE" />
</View>
<ScrollView
bounces={false}
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.contentContainer}
>
<View style={[styles.summaryCard, loading ? styles.summaryCardLoading : null]}>
<View style={styles.summaryHeader}>
<Text style={styles.activityName}>{activityName}</Text>
{intensityBadge ? (
<View
style={[
styles.intensityPill,
{ backgroundColor: intensityBadge.background },
]}
>
<Text style={[styles.intensityPillText, { color: intensityBadge.color }]}>
{intensityBadge.label}
</Text>
</View>
) : null}
</View>
<Text style={styles.summarySubtitle}>
{dayjs(workout?.startDate || workout?.endDate)
.locale(locale)
.format(locale === 'en' ? 'dddd, MMM D, YYYY HH:mm' : 'YYYY年M月D日 dddd HH:mm')}
</Text>
{loading ? (
<View style={styles.loadingBlock}>
<ActivityIndicator color="#5C55FF" />
<Text style={styles.loadingLabel}>{t('workoutDetail.loading')}</Text>
</View>
) : metrics ? (
<>
<View style={styles.metricsRow}>
<View style={styles.metricItem}>
<Text style={styles.metricTitle}>{t('workoutDetail.metrics.duration')}</Text>
<Text style={styles.metricValue}>{metrics.durationLabel}</Text>
</View>
<View style={styles.metricItem}>
<Text style={styles.metricTitle}>{t('workoutDetail.metrics.calories')}</Text>
<Text style={styles.metricValue}>
{metrics.calories != null ? `${metrics.calories} ${t('workoutDetail.metrics.caloriesUnit')}` : '--'}
</Text>
</View>
</View>
<View style={styles.metricsRow}>
<View style={styles.metricItem}>
<View style={styles.metricTitleRow}>
<Text style={styles.metricTitle}>{t('workoutDetail.metrics.intensity')}</Text>
<View style={styles.metricInfoButton}>
<MaterialCommunityIcons name="information-outline" size={16} color="#7780AA" />
</View>
</View>
<Text style={styles.metricValue}>
{formatMetsValue(metrics.mets)}
</Text>
</View>
<View style={styles.metricItem}>
<Text style={styles.metricTitle}>{t('workoutDetail.metrics.averageHeartRate')}</Text>
<Text style={styles.metricValue}>
{metrics.averageHeartRate != null ? `${metrics.averageHeartRate} ${t('workoutDetail.metrics.heartRateUnit')}` : '--'}
</Text>
</View>
</View>
{monthOccurrenceText ? (
<Text style={styles.monthOccurrenceText}>{monthOccurrenceText}</Text>
) : null}
</>
) : (
<View style={styles.errorBlock}>
<Text style={styles.errorText}>
{errorMessage || t('workoutDetail.errors.loadFailed')}
</Text>
{onRetry ? (
<View style={[styles.retryButton, styles.retryButtonDisabled]}>
<Text style={styles.retryButtonText}>{t('workoutDetail.retry')}</Text>
</View>
) : null}
</View>
)}
</View>
<View style={[styles.section, loading ? styles.sectionHeartRateLoading : null]}>
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>{t('workoutDetail.sections.heartRateRange')}</Text>
</View>
{loading ? (
<View style={styles.sectionLoading}>
<ActivityIndicator color="#5C55FF" />
</View>
) : metrics ? (
<>
<View style={styles.heartRateSummaryRow}>
<View style={styles.heartRateStat}>
<Text style={styles.statLabel}>{t('workoutDetail.sections.averageHeartRate')}</Text>
<Text style={styles.statValue}>
{metrics.averageHeartRate != null ? `${metrics.averageHeartRate} ${t('workoutDetail.sections.heartRateUnit')}` : '--'}
</Text>
</View>
<View style={styles.heartRateStat}>
<Text style={styles.statLabel}>{t('workoutDetail.sections.maximumHeartRate')}</Text>
<Text style={styles.statValue}>
{metrics.maximumHeartRate != null ? `${metrics.maximumHeartRate} ${t('workoutDetail.sections.heartRateUnit')}` : '--'}
</Text>
</View>
<View style={styles.heartRateStat}>
<Text style={styles.statLabel}>{t('workoutDetail.sections.minimumHeartRate')}</Text>
<Text style={styles.statValue}>
{metrics.minimumHeartRate != null ? `${metrics.minimumHeartRate} ${t('workoutDetail.sections.heartRateUnit')}` : '--'}
</Text>
</View>
</View>
{heartRateChart ? (
LineChart ? (
<View style={styles.chartWrapper}>
{shouldRenderChart ? (
/* @ts-ignore - react-native-chart-kit types are outdated */
<LineChart
data={{
labels: heartRateChart.labels,
datasets: [
{
data: heartRateChart.data,
color: () => '#5C55FF',
strokeWidth: 2,
},
],
}}
width={chartWidth}
height={220}
fromZero={false}
yAxisSuffix={t('workoutDetail.sections.heartRateUnit')}
withInnerLines={false}
bezier
paddingRight={48}
chartConfig={{
backgroundColor: '#FFFFFF',
backgroundGradientFrom: '#FFFFFF',
backgroundGradientTo: '#FFFFFF',
decimalPlaces: 0,
color: (opacity = 1) => `rgba(92, 85, 255, ${opacity})`,
labelColor: (opacity = 1) => `rgba(98, 105, 138, ${opacity})`,
propsForDots: {
r: '3',
strokeWidth: '2',
stroke: '#FFFFFF',
},
fillShadowGradientFromOpacity: 0.1,
fillShadowGradientToOpacity: 0.02,
}}
style={styles.chartStyle}
/>
) : (
<View style={[styles.chartLoading, { width: chartWidth }]}>
<ActivityIndicator color="#5C55FF" />
<Text style={styles.chartLoadingText}>{t('workoutDetail.loading')}</Text>
</View>
)}
</View>
) : (
<View style={styles.chartEmpty}>
<MaterialCommunityIcons name="chart-line-variant" size={32} color="#C5CBE2" />
<Text style={styles.chartEmptyText}>{t('workoutDetail.chart.unavailable')}</Text>
</View>
)
) : (
<View style={styles.chartEmpty}>
<MaterialCommunityIcons name="heart-off-outline" size={32} color="#C5CBE2" />
<Text style={styles.chartEmptyText}>{t('workoutDetail.chart.noData')}</Text>
</View>
)}
</>
) : (
<View style={styles.sectionError}>
<Text style={styles.errorTextSmall}>
{errorMessage || t('workoutDetail.errors.noHeartRateData')}
</Text>
</View>
)}
</View>
<View style={[styles.section, loading ? styles.sectionZonesLoading : null]}>
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>{t('workoutDetail.sections.heartRateZones')}</Text>
</View>
{loading ? (
<View style={styles.sectionLoading}>
<ActivityIndicator color="#5C55FF" />
</View>
) : metrics ? (
metrics.heartRateZones.map((zone) => renderHeartRateZone(zone, t))
) : (
<Text style={styles.errorTextSmall}>{t('workoutDetail.errors.noZoneStats')}</Text>
)}
</View>
<View style={styles.homeIndicatorSpacer} />
</ScrollView>
</ViewShot>
{showIntensityInfo ? (
<Modal
transparent
@@ -634,6 +957,39 @@ const styles = StyleSheet.create({
justifyContent: 'center',
alignItems: 'center',
},
headerIconFallback: {
overflow: 'hidden',
},
headerIconPressed: {
opacity: 0.75,
},
headerIconDisabled: {
opacity: 0.4,
},
glassButtonWrapper: {
overflow: 'hidden',
},
glassButton: {
borderRadius: 20,
width: '100%',
height: '100%',
},
glassButtonInner: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
width: '100%',
height: '100%',
borderRadius: 20,
},
shareCaptureContainer: {
position: 'absolute',
top: -9999,
left: 0,
right: 0,
opacity: 0,
zIndex: -1,
},
headerTitleWrapper: {
flex: 1,
alignItems: 'center',
@@ -761,6 +1117,9 @@ const styles = StyleSheet.create({
backgroundColor: '#5C55FF',
borderRadius: 16,
},
retryButtonDisabled: {
opacity: 0.4,
},
retryButtonText: {
color: '#FFFFFF',
fontWeight: '600',
@@ -970,8 +1329,4 @@ const styles = StyleSheet.create({
intensityHigh: {
color: '#FF6767',
},
headerSpacer: {
width: 40,
height: 40,
},
});

View File

@@ -49,6 +49,8 @@ export const ROUTES = {
FITNESS_RINGS_DETAIL: '/fitness-rings-detail',
SLEEP_DETAIL: '/sleep-detail',
BASAL_METABOLISM_DETAIL: '/basal-metabolism-detail',
HEALTH_PROFILE: '/health/profile',
HEALTH_FAMILY_INVITE: '/health/family-invite',
// 饮水相关路由
WATER_DETAIL: '/water/detail',

View File

@@ -1,6 +1,8 @@
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { ChallengeType } from '@/services/challengesApi';
import { WaterRecordSource } from '@/services/waterRecords';
import { reportChallengeProgress, selectChallengeList } from '@/store/challengesSlice';
import { createWaterRecordAction } from '@/store/waterSlice';
import { deleteWaterIntakeFromHealthKit, getWaterIntakeFromHealthKit, saveWaterIntakeToHealthKit } from '@/utils/health';
import { Toast } from '@/utils/toast.utils';
import { getQuickWaterAmount, getWaterGoalFromStorage, setWaterGoalToStorage } from '@/utils/userPreferences';
@@ -81,6 +83,9 @@ export const useWaterData = () => {
const [waterRecords, setWaterRecords] = useState<{ [date: string]: WaterRecord[] }>({});
const [selectedDate, setSelectedDate] = useState<string>(dayjs().format('YYYY-MM-DD'));
// Redux dispatch
const dispatch = useAppDispatch();
// 获取指定日期的记录
const getWaterRecordsByDate = useCallback(async (date: string, page = 1, limit = 20) => {
setLoading(prev => ({ ...prev, records: true }));
@@ -196,6 +201,15 @@ export const useWaterData = () => {
return false;
}
// 同步到服务端(后台执行,不阻塞 UI
dispatch(createWaterRecordAction({
amount,
recordedAt: recordTime,
source: WaterRecordSource.Manual,
})).catch((err) => {
console.warn('同步饮水记录到服务端失败:', err);
});
// 重新获取当前日期的数据以刷新界面
const updatedRecords = await getWaterRecordsByDate(date);
const totalAmount = updatedRecords.reduce((sum, record) => sum + record.amount, 0);
@@ -225,7 +239,7 @@ export const useWaterData = () => {
return false;
}
},
[dailyWaterGoal, getWaterRecordsByDate, reportWaterChallengeProgress]
[dailyWaterGoal, getWaterRecordsByDate, reportWaterChallengeProgress, dispatch]
);
// 更新喝水记录HealthKit不支持更新只能删除后重新添加
@@ -554,6 +568,7 @@ export const useWaterDataByDate = (targetDate?: string) => {
// 创建喝水记录
const reportWaterChallengeProgress = useWaterChallengeProgressReporter();
const dispatch = useAppDispatch();
const addWaterRecord = useCallback(
async (amount: number, recordedAt?: string) => {
@@ -567,6 +582,15 @@ export const useWaterDataByDate = (targetDate?: string) => {
return false;
}
// 同步到服务端(后台执行,不阻塞 UI
dispatch(createWaterRecordAction({
amount,
recordedAt: recordTime,
source: WaterRecordSource.Manual,
})).catch((err) => {
console.warn('同步饮水记录到服务端失败:', err);
});
// 重新获取当前日期的数据以刷新界面
const updatedRecords = await getWaterRecordsByDate(dateToUse);
const totalAmount = updatedRecords.reduce((sum, record) => sum + record.amount, 0);
@@ -596,7 +620,7 @@ export const useWaterDataByDate = (targetDate?: string) => {
return false;
}
},
[dailyWaterGoal, dateToUse, getWaterRecordsByDate, reportWaterChallengeProgress]
[dailyWaterGoal, dateToUse, getWaterRecordsByDate, reportWaterChallengeProgress, dispatch]
);
// 更新喝水记录

View File

@@ -139,9 +139,32 @@ export const statistics = {
title: 'Sleep',
loading: 'Loading...',
},
sunlight: {
title: 'Sun',
unit: 'min',
compareIncrease: 'Up {{diff}} min vs {{date}}',
compareDecrease: 'Down {{diff}} min vs {{date}}',
compareSame: 'Same as {{date}}',
compareNone: 'No prior data',
last30Days: 'Last 30 days',
syncing: 'Syncing Health data...',
noData: 'No sunlight data yet',
average: '30-day avg',
latest: 'Latest',
},
oxygen: {
title: 'Blood Oxygen',
},
wristTemperature: {
title: 'Wrist Temperature',
last30Days: 'Last 30 days',
syncing: 'Syncing Health data...',
noData: 'No wrist temperature data yet',
baseline: 'Baseline',
average: '30-day avg',
latest: 'Latest',
vsBaseline: 'vs baseline'
},
circumference: {
title: 'Circumference (cm)',
setTitle: 'Set {{label}}',
@@ -176,6 +199,11 @@ export const statistics = {
days: 'days',
range: 'Range',
unit: 'kg',
progress: {
lost: 'Lost',
toGo: 'To go',
},
demo: 'Demo',
bmiModal: {
title: 'BMI Index Explanation',
description: 'BMI (Body Mass Index) is an internationally recognized health indicator for assessing weight relative to height',
@@ -205,13 +233,6 @@ export const statistics = {
},
},
},
tabs: {
health: 'Health',
medications: 'Meds',
fasting: 'Fasting',
challenges: 'Challenges',
personal: 'Me',
},
activityHeatMap: {
subtitle: 'Active {{days}} days in the last 6 months',
activeRate: '{{rate}}%',
@@ -656,6 +677,45 @@ export const workoutDetail = {
},
};
export const sleepNotification = {
// Notification body template
body: 'You slept {{duration}} last night with {{efficiency}}% efficiency. Score: {{score}} 🎯',
// Sleep quality titles - warm and encouraging tone
quality: {
excellent: 'Amazing! You slept great',
good: 'Nice! Good sleep quality',
fair: 'Not bad, tomorrow will be better',
poor: 'Hang in there, rest well tonight',
veryPoor: 'Take care of yourself',
default: 'Sleep analysis complete',
},
// Sleep duration formatting
duration: {
hoursOnly: '{{hours}} hours',
hoursAndMinutes: '{{hours}}h {{minutes}}m',
},
// Sleep tips - encouraging tone
tips: {
excellent: {
keepItUp: 'Keep it up, you\'re doing amazing!',
greatJob: 'Your body thanks you for the great care!',
energized: 'You\'ll be full of energy today!',
proud: 'Give yourself a pat on the back!',
},
suggestions: {
shortSleep: 'Try hitting the pillow earlier - 7-9 hours will boost your energy!',
longSleep: 'Too much sleep can be tiring too - try a consistent wake time!',
lowDeepSleep: 'Put your phone away before bed for deeper rest~',
lowRemSleep: 'A regular schedule helps you dream better!',
lowEfficiency: 'A cozy bedroom environment can work wonders~',
},
general: 'Every night is a fresh start - take care of yourself!',
},
};
export const workoutHistory = {
title: 'Workout Summary',
loading: 'Loading workout records...',
@@ -687,3 +747,127 @@ export const workoutHistory = {
},
monthOccurrence: 'This is your {{index}} {{activity}} in {{month}}.',
};
export const familyGroup = {
joinTitle: 'Join Family Group',
joinDescription: 'Enter the invite code shared by your family member to join health management',
inviteCodePlaceholder: 'Enter invite code',
relationshipLabel: 'Relationship to creator',
relationshipPlaceholder: 'Select relationship',
joinButton: 'Join',
joining: 'Joining...',
cancel: 'Cancel',
errors: {
emptyCode: 'Please enter invite code',
emptyRelationship: 'Please select relationship',
},
success: 'Successfully joined family group',
relationships: {
spouse: 'Spouse',
father: 'Father',
mother: 'Mother',
son: 'Son',
daughter: 'Daughter',
grandfather: 'Grandfather',
grandmother: 'Grandmother',
grandson: 'Grandson',
granddaughter: 'Granddaughter',
brother: 'Brother',
sister: 'Sister',
uncle: 'Uncle',
aunt: 'Aunt',
nephew: 'Nephew',
niece: 'Niece',
cousin: 'Cousin',
other: 'Other',
},
};
export const health = {
tabs: {
health: 'Health',
medications: 'Meds',
fasting: 'Fasting',
challenges: 'Challenges',
personal: 'Me',
healthProfile: {
title: 'Health Profile',
subtitle: 'Invite family to join health management for timely anomaly alerts',
privacyNotice: 'Profile content is visible only to you. We strictly protect your privacy.',
basicInfo: 'Basic Info',
healthHistory: 'History',
medicalRecords: 'Records',
checkupRecords: 'Checkups',
medicineBox: 'Medications',
basicInfoCard: {
title: 'Basic Information',
noData: 'No data',
bmi: 'BMI',
height: 'Height',
heightUnit: 'CM',
weight: 'Weight',
weightUnit: 'KG',
waist: 'Waist',
waistUnit: 'CM',
},
history: {
allergy: 'Allergies',
disease: 'Conditions',
surgery: 'Surgeries',
familyDisease: 'Family History',
pending: 'To be added',
edit: 'Edit',
modal: {
question: 'Do you have {{type}}?',
yes: 'Yes',
no: 'No',
addDetails: 'Add Details',
enterSpecific: 'Enter specific condition...',
recommendations: 'Recommendations',
save: 'Save',
none: 'None',
yesNoDetails: 'Yes (No details)',
diagnosisDate: 'Diagnosis Date',
namePlaceholder: 'Condition Name',
addItem: 'Add Record',
selectDate: 'Select Date'
},
recommendationItems: {
allergy: {
penicillin: 'Penicillin',
sulfonamides: 'Sulfonamides',
peanuts: 'Peanuts',
seafood: 'Seafood',
pollen: 'Pollen',
dustMites: 'Dust Mites',
alcohol: 'Alcohol',
mango: 'Mango'
},
disease: {
hypertension: 'Hypertension',
diabetes: 'Diabetes',
asthma: 'Asthma',
heartDisease: 'Heart Disease',
gastritis: 'Gastritis',
migraine: 'Migraine'
},
surgery: {
appendectomy: 'Appendectomy',
cesareanSection: 'Cesarean Section',
tonsillectomy: 'Tonsillectomy',
fractureRepair: 'Fracture Repair',
none: 'None'
},
familyDisease: {
hypertension: 'Hypertension',
diabetes: 'Diabetes',
cancer: 'Cancer',
heartDisease: 'Heart Disease',
stroke: 'Stroke',
alzheimers: 'Alzheimer\'s'
}
}
}
}
}
};

View File

@@ -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, // 确保通用翻译被正确导出
};

View File

@@ -238,7 +238,7 @@ export const medications = {
periodRange: 'From {{startDate}} to {{endDate}}',
periodLongTerm: 'From {{startDate}} until indefinitely',
expiryStatus: {
notSet: 'Not set',
notSet: 'Set Expiry',
expired: 'Expired',
expiresToday: 'Expires today',
expiresInDays: 'Expires in {{days}} days',

53
i18n/en/menstrual.ts Normal file
View File

@@ -0,0 +1,53 @@
export const menstrual = {
dateFormatShort: 'MMM D',
dateFormats: {
monthTitle: 'MMM',
monthSubtitle: 'YYYY',
},
weekdays: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'],
today: 'Today',
legend: {
period: 'Period',
predictedPeriod: 'Predicted period',
fertile: 'Fertile window',
ovulation: 'Ovulation',
},
actions: {
markPeriod: 'Mark period',
cancelMark: 'Cancel',
},
card: {
title: 'Menstrual cycle',
syncingState: 'Syncing',
syncingDesc: 'Reading menstrual data…',
emptyState: 'Not logged',
emptyDesc: 'Tap to record this period',
periodState: 'Period',
predictedPeriodState: 'Predicted period',
periodEndToday: 'Expected to end today ({{date}})',
periodEndPrefix: 'Ends in ',
periodEndSuffix: ' days ({{date}})',
fertileState: 'Fertile window',
fertileToday: 'Fertile window starts today',
fertileCountdownPrefix: 'Enters fertile window in ',
fertileCountdownSuffix: ' days',
ovulationState: 'Ovulation',
ovulationToday: 'Today is ovulation day',
ovulationCountdownPrefix: 'Ovulation in ',
ovulationCountdownSuffix: ' days',
nextPeriodPrefix: 'Next period in ',
nextPeriodSuffix: ' days',
},
screen: {
header: 'Menstrual Cycle',
tabs: {
cycle: 'Cycle',
analysis: 'Analysis',
},
analysis: {
title: 'Analysis',
description:
'Based on the latest 6 cycles, we will calculate average period and cycle length. Trends and prediction accuracy will be shown here.',
},
},
};

View File

@@ -27,6 +27,11 @@ export const personal = {
validForever: 'No expiry',
dateFormat: 'YYYY-MM-DD',
},
membershipBanner: {
title: 'Unlock Premium Access',
subtitle: 'Get unlimited access to AI features & custom plans',
cta: 'Upgrade Now',
},
sections: {
notifications: 'Notifications',
developer: 'Developer',
@@ -37,6 +42,10 @@ export const personal = {
medicalSources: 'Medical Advice Sources',
customization: 'Customization',
},
healthProfile: {
title: 'Health Profile',
subtitle: 'Manage your personal health data and family profile',
},
versionCheck: {
sectionTitle: 'Updates',
menuTitle: 'Check for updates',
@@ -103,6 +112,30 @@ export const personal = {
},
};
export const statisticsCustomization = {
title: 'Home Content Settings',
sectionTitle: 'Body Metrics Cards',
description: {
text: '• Customize the body metrics modules displayed on the home page\n• Hidden modules will not be shown on the home page, but data will be retained',
},
items: {
mood: 'Mood',
steps: 'Steps',
stress: 'Stress',
sleep: 'Sleep',
sunlight: 'Sun',
fitnessRings: 'Fitness Rings',
water: 'Water Intake',
basalMetabolism: 'Basal Metabolism',
oxygenSaturation: 'Oxygen Saturation',
wristTemperature: 'Wrist Temperature',
menstrualCycle: 'Menstrual Cycle',
weight: 'Weight',
circumference: 'Circumference',
},
vipRequired: 'VIP membership required to customize home layout',
};
export const editProfile = {
title: 'Edit Profile',
fields: {
@@ -385,6 +418,10 @@ export const notificationSettings = {
title: 'Nutrition Record Reminder',
description: 'Receive nutrition record reminders at meal times',
},
hrvReminder: {
title: 'HRV Stress Alert',
description: 'Get guidance when elevated stress is detected from HRV',
},
moodReminder: {
title: 'Mood Record Reminder',
description: 'Receive mood record reminders in the evening',
@@ -406,6 +443,7 @@ export const notificationSettings = {
saveFailed: 'Failed to save settings',
medicationReminderFailed: 'Failed to set medication reminder',
nutritionReminderFailed: 'Failed to set nutrition reminder',
hrvReminderFailed: 'Failed to set HRV reminder',
moodReminderFailed: 'Failed to set mood reminder',
},
notificationsEnabled: {
@@ -420,6 +458,10 @@ export const notificationSettings = {
title: 'Nutrition Reminder Enabled',
body: 'You will receive nutrition record reminders at meal times',
},
hrvReminderEnabled: {
title: 'HRV Reminder Enabled',
body: 'You will get tips when elevated stress is detected from HRV',
},
moodReminderEnabled: {
title: 'Mood Reminder Enabled',
body: 'You will receive mood record reminders in the evening',

View File

@@ -140,9 +140,32 @@ export const statistics = {
title: '睡眠',
loading: '加载中...',
},
sunlight: {
title: '晒太阳',
unit: '分钟',
compareIncrease: '与 {{date}} 相比增加 {{diff}} 分钟',
compareDecrease: '与 {{date}} 相比减少 {{diff}} 分钟',
compareSame: '与 {{date}} 相比无变化',
compareNone: '暂无对比',
last30Days: '最近30天',
syncing: '正在同步健康数据...',
noData: '暂无日照时间数据',
average: '30天均值',
latest: '最新值',
},
oxygen: {
title: '血氧饱和度',
},
wristTemperature: {
title: '手腕温度',
last30Days: '最近30天',
syncing: '正在同步健康数据...',
noData: '暂无手腕温度数据',
baseline: '基线',
average: '30天均值',
latest: '最新值',
vsBaseline: '相对基线'
},
circumference: {
title: '围度 (cm)',
setTitle: '设置{{label}}',
@@ -177,6 +200,11 @@ export const statistics = {
days: '天',
range: '范围',
unit: 'kg',
progress: {
lost: '已减',
toGo: '距目标',
},
demo: '示例数据',
bmiModal: {
title: 'BMI 指数说明',
description: 'BMI身体质量指数是评估体重与身高关系的国际通用健康指标',
@@ -206,13 +234,6 @@ export const statistics = {
},
},
},
tabs: {
health: '健康',
medications: '用药',
fasting: '断食',
challenges: '挑战',
personal: '个人',
},
activityHeatMap: {
subtitle: '最近6个月活跃 {{days}} 天',
activeRate: '{{rate}}%',
@@ -657,6 +678,45 @@ export const workoutDetail = {
},
};
export const sleepNotification = {
// 通知正文模板
body: '昨晚睡了 {{duration}},睡眠效率 {{efficiency}}%,得分 {{score}} 分 🎯',
// 睡眠质量标题 - 更温暖鼓励的语气
quality: {
excellent: '太棒了!睡得真好',
good: '不错哦!睡眠质量良好',
fair: '还行,明天会更好',
poor: '辛苦了,今晚早点休息',
veryPoor: '抱抱,好好照顾自己',
default: '睡眠分析完成啦',
},
// 睡眠时长格式化
duration: {
hoursOnly: '{{hours}} 小时',
hoursAndMinutes: '{{hours}} 小时 {{minutes}} 分钟',
},
// 睡眠建议 - 更鼓励的语气
tips: {
excellent: {
keepItUp: '继续保持,你真的很棒!',
greatJob: '身体一定很感谢你的照顾~',
energized: '今天一定精力满满!',
proud: '为自己的好习惯点赞!',
},
suggestions: {
shortSleep: '试着早点上床吧7-9 小时的睡眠会让你更有活力哦~',
longSleep: '睡太久也会累哦,试试固定起床时间~',
lowDeepSleep: '睡前放下手机,让大脑好好休息~',
lowRemSleep: '规律作息能帮助你做更多好梦~',
lowEfficiency: '调整一下卧室环境,会睡得更香哦~',
},
general: '每一晚都是新的开始,照顾好自己~',
},
};
export const workoutHistory = {
title: '锻炼总结',
loading: '正在加载锻炼记录...',
@@ -688,3 +748,127 @@ export const workoutHistory = {
},
monthOccurrence: '这是你{{month}}的第 {{index}} 次{{activity}}。',
};
export const familyGroup = {
joinTitle: '加入家庭组',
joinDescription: '输入家人分享的邀请码,加入家庭健康管理',
inviteCodePlaceholder: '请输入邀请码',
relationshipLabel: '与创建者的关系',
relationshipPlaceholder: '请选择关系',
joinButton: '加入',
joining: '加入中...',
cancel: '取消',
errors: {
emptyCode: '请输入邀请码',
emptyRelationship: '请选择与创建者的关系',
},
success: '成功加入家庭组',
relationships: {
spouse: '配偶',
father: '父亲',
mother: '母亲',
son: '儿子',
daughter: '女儿',
grandfather: '爷爷/外公',
grandmother: '奶奶/外婆',
grandson: '孙子/外孙',
granddaughter: '孙女/外孙女',
brother: '兄弟',
sister: '姐妹',
uncle: '叔叔/舅舅',
aunt: '阿姨/姑姑',
nephew: '侄子/外甥',
niece: '侄女/外甥女',
cousin: '表/堂兄弟姐妹',
other: '其他',
},
};
export const health = {
tabs: {
health: '健康',
medications: '用药',
fasting: '断食',
challenges: '挑战',
personal: '个人',
healthProfile: {
title: '健康档案',
subtitle: '邀请家人加入家庭健康管理,异常及时提醒',
privacyNotice: '档案内容仅供本人查看,我们将严格保护您的隐私',
basicInfo: '基础信息',
healthHistory: '健康史',
medicalRecords: '就医资料',
checkupRecords: '体检记录',
medicineBox: '药品管理',
basicInfoCard: {
title: '基础信息',
noData: '暂无数据',
bmi: 'BMI',
height: '身高',
heightUnit: 'CM',
weight: '体重',
weightUnit: 'KG',
waist: '腰围',
waistUnit: 'CM',
},
history: {
allergy: '过敏史',
disease: '疾病史',
surgery: '手术史',
familyDisease: '家族疾病史',
pending: '待补充',
edit: '编辑',
modal: {
question: '您是否有{{type}}',
yes: '有',
no: '没有',
addDetails: '添加详情',
enterSpecific: '请输入具体情况...',
recommendations: '推荐选项',
save: '保存',
none: '无',
yesNoDetails: '有 (未填写详情)',
diagnosisDate: '确诊时间',
namePlaceholder: '疾病/手术名称',
addItem: '添加记录',
selectDate: '选择日期'
},
recommendationItems: {
allergy: {
penicillin: '青霉素',
sulfonamides: '磺胺类',
peanuts: '花生',
seafood: '海鲜',
pollen: '花粉',
dustMites: '尘螨',
alcohol: '酒精',
mango: '芒果'
},
disease: {
hypertension: '高血压',
diabetes: '糖尿病',
asthma: '哮喘',
heartDisease: '心脏病',
gastritis: '胃炎',
migraine: '偏头痛'
},
surgery: {
appendectomy: '阑尾切除术',
cesareanSection: '剖腹产',
tonsillectomy: '扁桃体切除术',
fractureRepair: '骨折复位术',
none: '无'
},
familyDisease: {
hypertension: '高血压',
diabetes: '糖尿病',
cancer: '癌症',
heartDisease: '心脏病',
stroke: '中风',
alzheimers: '阿尔茨海默病'
}
}
}
}
}
};

View File

@@ -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, // 确保通用翻译被正确导出
};

View File

@@ -238,7 +238,7 @@ export const medications = {
periodRange: '从 {{startDate}} 至 {{endDate}}',
periodLongTerm: '从 {{startDate}} 至长期',
expiryStatus: {
notSet: '未设置',
notSet: '未设置(过期预警)',
expired: '已过期',
expiresToday: '今天到期',
expiresInDays: '{{days}}天后到期',

52
i18n/zh/menstrual.ts Normal file
View File

@@ -0,0 +1,52 @@
export const menstrual = {
dateFormatShort: 'M月D日',
dateFormats: {
monthTitle: 'M月',
monthSubtitle: 'YYYY年',
},
weekdays: ['一', '二', '三', '四', '五', '六', '日'],
today: '今天',
legend: {
period: '经期',
predictedPeriod: '预测经期',
fertile: '排卵期',
ovulation: '排卵日',
},
actions: {
markPeriod: '标记经期',
cancelMark: '取消标记',
},
card: {
title: '生理周期',
syncingState: '同步中',
syncingDesc: '正在读取经期数据…',
emptyState: '待记录',
emptyDesc: '点击记录本次经期',
periodState: '经期',
predictedPeriodState: '预测经期',
periodEndToday: '预计今日结束({{date}}',
periodEndPrefix: '预计',
periodEndSuffix: '天后结束({{date}}',
fertileState: '排卵期',
fertileToday: '今天进入排卵期',
fertileCountdownPrefix: '还有',
fertileCountdownSuffix: '天进入排卵期',
ovulationState: '排卵日',
ovulationToday: '今天是排卵日',
ovulationCountdownPrefix: '距离排卵日',
ovulationCountdownSuffix: '天',
nextPeriodPrefix: '距离下次月经',
nextPeriodSuffix: '天',
},
screen: {
header: '生理周期',
tabs: {
cycle: '生理周期',
analysis: '分析',
},
analysis: {
title: '分析',
description: '基于最近 6 个周期的记录,计算平均经期和周期长度,后续会展示趋势和预测准确度。',
},
},
};

View File

@@ -27,6 +27,11 @@ export const personal = {
validForever: '长期有效',
dateFormat: 'YYYY年MM月DD日',
},
membershipBanner: {
title: '解锁尊享会员权益',
subtitle: '无限次使用 AI 功能,定制专属健康计划',
cta: '立即升级',
},
sections: {
notifications: '通知',
developer: '开发者',
@@ -37,6 +42,10 @@ export const personal = {
medicalSources: '医学建议来源',
customization: '个性化',
},
healthProfile: {
title: '健康档案',
subtitle: '管理您的个人健康数据与家庭档案',
},
versionCheck: {
sectionTitle: '版本与更新',
menuTitle: '检查更新',
@@ -103,6 +112,30 @@ export const personal = {
},
};
export const statisticsCustomization = {
title: '首页内容设置',
sectionTitle: '身体指标卡片',
description: {
text: '• 自定义首页展示的身体指标模块\n• 关闭的模块将不会在首页显示,但数据仍会保留',
},
items: {
mood: '心情',
steps: '步数',
stress: '压力',
sleep: '睡眠',
sunlight: '晒太阳',
fitnessRings: '健身圆环',
water: '饮水',
basalMetabolism: '基础代谢',
oxygenSaturation: '血氧',
wristTemperature: '手腕温度',
menstrualCycle: '经期',
weight: '体重',
circumference: '围度',
},
vipRequired: '需要开通 VIP 会员才能自定义首页布局',
};
export const editProfile = {
title: '编辑资料',
fields: {
@@ -389,6 +422,10 @@ export const notificationSettings = {
title: '营养记录提醒',
description: '在用餐时间接收营养记录提醒',
},
hrvReminder: {
title: 'HRV 压力提醒',
description: '监测到压力偏高时发送健康建议',
},
moodReminder: {
title: '心情记录提醒',
description: '在晚间接收心情记录提醒',
@@ -410,6 +447,7 @@ export const notificationSettings = {
saveFailed: '保存设置失败',
medicationReminderFailed: '设置药品提醒失败',
nutritionReminderFailed: '设置营养提醒失败',
hrvReminderFailed: '设置 HRV 提醒失败',
moodReminderFailed: '设置心情提醒失败',
},
notificationsEnabled: {
@@ -424,6 +462,10 @@ export const notificationSettings = {
title: '营养提醒已开启',
body: '您将在用餐时间收到营养记录提醒',
},
hrvReminderEnabled: {
title: 'HRV 提醒已开启',
body: '检测到压力升高时将收到健康建议推送',
},
moodReminderEnabled: {
title: '心情提醒已开启',
body: '您将在晚间收到心情记录提醒',

View File

@@ -15,6 +15,7 @@
792C52632EB05B8F002F3F09 /* NativeToastManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 792C52612EB05B8F002F3F09 /* NativeToastManager.swift */; };
794DD5D62ED3E3BB0046E2B4 /* AppStoreReviewManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 794DD5D42ED3E3BB0046E2B4 /* AppStoreReviewManager.m */; };
794DD5D72ED3E3BB0046E2B4 /* AppStoreReviewManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 794DD5D52ED3E3BB0046E2B4 /* AppStoreReviewManager.swift */; };
7981D9922EDFC0B5008D5F2D /* InfoPlist.strings in Sources */ = {isa = PBXBuildFile; fileRef = 7981D9912EDFC0B5008D5F2D /* InfoPlist.strings */; };
79B2CB702E7B954600B51753 /* OutLive-Bridging-Header.h in Sources */ = {isa = PBXBuildFile; fileRef = F11748442D0722820044C1D9 /* OutLive-Bridging-Header.h */; };
79B2CB732E7B954F00B51753 /* HealthKitManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 79B2CB712E7B954F00B51753 /* HealthKitManager.m */; };
79B2CB742E7B954F00B51753 /* HealthKitManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79B2CB722E7B954F00B51753 /* HealthKitManager.swift */; };
@@ -68,6 +69,8 @@
792C52612EB05B8F002F3F09 /* NativeToastManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = NativeToastManager.swift; path = OutLive/NativeToastManager.swift; sourceTree = "<group>"; };
794DD5D42ED3E3BB0046E2B4 /* AppStoreReviewManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = AppStoreReviewManager.m; sourceTree = "<group>"; };
794DD5D52ED3E3BB0046E2B4 /* AppStoreReviewManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStoreReviewManager.swift; sourceTree = "<group>"; };
7981D9902EDFC0B5008D5F2D /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/InfoPlist.strings"; sourceTree = "<group>"; };
7981D9932EDFC0B8008D5F2D /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/InfoPlist.strings; sourceTree = "<group>"; };
79B2CB712E7B954F00B51753 /* HealthKitManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = HealthKitManager.m; path = OutLive/HealthKitManager.m; sourceTree = "<group>"; };
79B2CB722E7B954F00B51753 /* HealthKitManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = HealthKitManager.swift; path = OutLive/HealthKitManager.swift; sourceTree = "<group>"; };
79E80BA22EC5D92A004425BE /* medicineExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = medicineExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -140,6 +143,7 @@
13B07FAE1A68108700A75B9A /* OutLive */ = {
isa = PBXGroup;
children = (
7981D9912EDFC0B5008D5F2D /* InfoPlist.strings */,
F11748412D0307B40044C1D9 /* AppDelegate.swift */,
F11748442D0722820044C1D9 /* OutLive-Bridging-Header.h */,
BB2F792B24A3F905000567C9 /* Supporting */,
@@ -309,10 +313,11 @@
};
buildConfigurationList = 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "OutLive" */;
compatibilityVersion = "Xcode 3.2";
developmentRegion = en;
developmentRegion = "zh-Hans";
hasScannedForEncodings = 0;
knownRegions = (
en,
"zh-Hans",
Base,
);
mainGroup = 83CBB9F61A601CBA00E9B192;
@@ -399,6 +404,7 @@
"${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/ExpoConstants_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/EXNotifications/ExpoNotifications_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/EXTaskManager/ExpoTaskManager_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/EXUpdates/EXUpdates.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/ExpoFileSystem/ExpoFileSystem_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/ExpoLocalization/ExpoLocalization_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/ExpoMediaLibrary/ExpoMediaLibrary_privacy.bundle",
@@ -407,6 +413,7 @@
"${PODS_CONFIGURATION_BUILD_DIR}/RNCAsyncStorage/RNCAsyncStorage_resources.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/RNDeviceInfo/RNDeviceInfoPrivacyInfo.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/RNSVG/RNSVGFilters.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/ReachabilitySwift/ReachabilitySwift.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/React-Core/React-Core_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/React-cxxreact/React-cxxreact_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/RevenueCat/RevenueCat.bundle",
@@ -422,6 +429,7 @@
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoConstants_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoNotifications_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoTaskManager_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXUpdates.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoFileSystem_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoLocalization_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoMediaLibrary_privacy.bundle",
@@ -430,6 +438,7 @@
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNCAsyncStorage_resources.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNDeviceInfoPrivacyInfo.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNSVGFilters.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ReachabilitySwift.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-Core_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-cxxreact_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RevenueCat.bundle",
@@ -500,6 +509,7 @@
79E80BFF2EC5E127004425BE /* AppGroupUserDefaultsManager.m in Sources */,
79E80C002EC5E127004425BE /* WidgetManager.m in Sources */,
79E80C522EC5E500004425BE /* WidgetCenterHelper.swift in Sources */,
7981D9922EDFC0B5008D5F2D /* InfoPlist.strings in Sources */,
792C52632EB05B8F002F3F09 /* NativeToastManager.swift in Sources */,
79B2CB732E7B954F00B51753 /* HealthKitManager.m in Sources */,
79B2CB742E7B954F00B51753 /* HealthKitManager.swift in Sources */,
@@ -530,6 +540,18 @@
};
/* End PBXTargetDependency section */
/* Begin PBXVariantGroup section */
7981D9912EDFC0B5008D5F2D /* InfoPlist.strings */ = {
isa = PBXVariantGroup;
children = (
7981D9902EDFC0B5008D5F2D /* zh-Hans */,
7981D9932EDFC0B8008D5F2D /* en */,
);
name = InfoPlist.strings;
sourceTree = "<group>";
};
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */
13B07F941A680F5B00A75B9A /* Debug */ = {
isa = XCBuildConfiguration;
@@ -620,7 +642,7 @@
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = medicine/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = medicine;
INFOPLIST_KEY_CFBundleDisplayName = "用药计划";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 26.1;
LD_RUNPATH_SEARCH_PATHS = (
@@ -670,7 +692,7 @@
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = medicine/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = medicine;
INFOPLIST_KEY_CFBundleDisplayName = "用药计划";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 26.1;
LD_RUNPATH_SEARCH_PATHS = (

View File

@@ -41,7 +41,7 @@
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
buildConfiguration = "Release"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"

View File

@@ -30,6 +30,14 @@ RCT_EXTERN_METHOD(getAppleStandTime:(NSDictionary *)options
resolver:(RCTPromiseResolveBlock)resolver
rejecter:(RCTPromiseRejectBlock)rejecter)
RCT_EXTERN_METHOD(getTimeInDaylight:(NSDictionary *)options
resolver:(RCTPromiseResolveBlock)resolver
rejecter:(RCTPromiseRejectBlock)rejecter)
RCT_EXTERN_METHOD(getTimeInDaylightSamples:(NSDictionary *)options
resolver:(RCTPromiseResolveBlock)resolver
rejecter:(RCTPromiseRejectBlock)rejecter)
RCT_EXTERN_METHOD(getActivitySummary:(NSDictionary *)options
resolver:(RCTPromiseResolveBlock)resolver
rejecter:(RCTPromiseRejectBlock)rejecter)
@@ -43,6 +51,10 @@ RCT_EXTERN_METHOD(getOxygenSaturationSamples:(NSDictionary *)options
resolver:(RCTPromiseResolveBlock)resolver
rejecter:(RCTPromiseRejectBlock)rejecter)
RCT_EXTERN_METHOD(getWristTemperatureSamples:(NSDictionary *)options
resolver:(RCTPromiseResolveBlock)resolver
rejecter:(RCTPromiseRejectBlock)rejecter)
RCT_EXTERN_METHOD(getHeartRateSamples:(NSDictionary *)options
resolver:(RCTPromiseResolveBlock)resolver
rejecter:(RCTPromiseRejectBlock)rejecter)
@@ -135,4 +147,17 @@ RCT_EXTERN_METHOD(saveWeight:(NSDictionary *)options
resolver:(RCTPromiseResolveBlock)resolver
rejecter:(RCTPromiseRejectBlock)rejecter)
// Menstrual Cycle Methods
RCT_EXTERN_METHOD(getMenstrualFlowSamples:(NSDictionary *)options
resolver:(RCTPromiseResolveBlock)resolver
rejecter:(RCTPromiseRejectBlock)rejecter)
RCT_EXTERN_METHOD(saveMenstrualFlow:(NSDictionary *)options
resolver:(RCTPromiseResolveBlock)resolver
rejecter:(RCTPromiseRejectBlock)rejecter)
RCT_EXTERN_METHOD(deleteMenstrualFlow:(NSDictionary *)options
resolver:(RCTPromiseResolveBlock)resolver
rejecter:(RCTPromiseRejectBlock)rejecter)
@end

View File

@@ -68,6 +68,23 @@ class HealthKitManager: RCTEventEmitter {
static var dateOfBirth: HKCharacteristicType {
return HKObjectType.characteristicType(forIdentifier: .dateOfBirth)!
}
static var menstrualFlow: HKCategoryType? {
return HKObjectType.categoryType(forIdentifier: .menstrualFlow)
}
static var appleSleepingWristTemperature: HKQuantityType? {
if #available(iOS 16.0, *) {
return HKObjectType.quantityType(forIdentifier: .appleSleepingWristTemperature)
} else {
return nil
}
}
static var timeInDaylight: HKQuantityType? {
if #available(iOS 17.0, *) {
return HKObjectType.quantityType(forIdentifier: .timeInDaylight)
} else {
return nil
}
}
static var all: Set<HKObjectType> {
var types: Set<HKObjectType> = [activitySummary, workout, dateOfBirth]
@@ -83,6 +100,9 @@ class HealthKitManager: RCTEventEmitter {
if let dietaryWater = dietaryWater { types.insert(dietaryWater) }
if let height = height { types.insert(height) }
if let bodyMass = bodyMass { types.insert(bodyMass) }
if let menstrualFlow = menstrualFlow { types.insert(menstrualFlow) }
if let appleSleepingWristTemperature = appleSleepingWristTemperature { types.insert(appleSleepingWristTemperature) }
if let timeInDaylight = timeInDaylight { types.insert(timeInDaylight) }
return types
}
@@ -111,6 +131,9 @@ class HealthKitManager: RCTEventEmitter {
static var dietaryCarbohydrates: HKQuantityType? {
return HKObjectType.quantityType(forIdentifier: .dietaryCarbohydrates)
}
static var menstrualFlow: HKCategoryType? {
return HKObjectType.categoryType(forIdentifier: .menstrualFlow)
}
static var all: Set<HKSampleType> {
var types: Set<HKSampleType> = []
@@ -120,6 +143,7 @@ class HealthKitManager: RCTEventEmitter {
if let dietaryProtein = dietaryProtein { types.insert(dietaryProtein) }
if let dietaryFatTotal = dietaryFatTotal { types.insert(dietaryFatTotal) }
if let dietaryCarbohydrates = dietaryCarbohydrates { types.insert(dietaryCarbohydrates) }
if let menstrualFlow = menstrualFlow { types.insert(menstrualFlow) }
return types
}
}
@@ -607,6 +631,151 @@ class HealthKitManager: RCTEventEmitter {
healthStore.execute(query)
}
@objc
func getTimeInDaylight(
_ options: NSDictionary,
resolver: @escaping RCTPromiseResolveBlock,
rejecter: @escaping RCTPromiseRejectBlock
) {
guard HKHealthStore.isHealthDataAvailable() else {
rejecter("HEALTHKIT_NOT_AVAILABLE", "HealthKit is not available on this device", nil)
return
}
guard let daylightType = ReadTypes.timeInDaylight else {
rejecter("TYPE_NOT_AVAILABLE", "Time in daylight type is not available", nil)
return
}
let startDate: Date
if let startString = options["startDate"] as? String, let d = parseDate(from: startString) {
startDate = d
} else {
startDate = Calendar.current.startOfDay(for: Date())
}
let endDate: Date
if let endString = options["endDate"] as? String, let d = parseDate(from: endString) {
endDate = d
} else {
endDate = Date()
}
let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
let query = HKStatisticsQuery(quantityType: daylightType,
quantitySamplePredicate: predicate,
options: .cumulativeSum) { [weak self] (query, statistics, error) in
DispatchQueue.main.async {
if let error = error {
rejecter("QUERY_ERROR", "Failed to query time in daylight: \(error.localizedDescription)", error)
return
}
guard let statistics = statistics else {
resolver([
"totalValue": 0,
"startDate": self?.dateToISOString(startDate) ?? "",
"endDate": self?.dateToISOString(endDate) ?? ""
])
return
}
let totalValue = statistics.sumQuantity()?.doubleValue(for: HKUnit.minute()) ?? 0
let result: [String: Any] = [
"totalValue": totalValue,
"startDate": self?.dateToISOString(startDate) ?? "",
"endDate": self?.dateToISOString(endDate) ?? ""
]
resolver(result)
}
}
healthStore.execute(query)
}
@objc
func getTimeInDaylightSamples(
_ options: NSDictionary,
resolver: @escaping RCTPromiseResolveBlock,
rejecter: @escaping RCTPromiseRejectBlock
) {
guard HKHealthStore.isHealthDataAvailable() else {
rejecter("HEALTHKIT_NOT_AVAILABLE", "HealthKit is not available on this device", nil)
return
}
guard let daylightType = ReadTypes.timeInDaylight else {
rejecter("TYPE_NOT_AVAILABLE", "Time in daylight type is not available", nil)
return
}
let startDate: Date
if let startString = options["startDate"] as? String, let d = parseDate(from: startString) {
startDate = d
} else {
startDate = Calendar.current.startOfDay(for: Date())
}
let endDate: Date
if let endString = options["endDate"] as? String, let d = parseDate(from: endString) {
endDate = d
} else {
endDate = Date()
}
let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
var interval = DateComponents()
interval.day = 1
let anchorDate = Calendar.current.startOfDay(for: startDate)
let query = HKStatisticsCollectionQuery(quantityType: daylightType,
quantitySamplePredicate: predicate,
options: .cumulativeSum,
anchorDate: anchorDate,
intervalComponents: interval)
query.initialResultsHandler = { [weak self] (_, results, error) in
DispatchQueue.main.async {
if let error = error {
rejecter("QUERY_ERROR", "Failed to query time in daylight samples: \(error.localizedDescription)", error)
return
}
guard let results = results else {
resolver([
"data": [],
"count": 0,
"startDate": self?.dateToISOString(startDate) ?? "",
"endDate": self?.dateToISOString(endDate) ?? ""
])
return
}
var data: [[String: Any]] = []
results.enumerateStatistics(from: startDate, to: endDate) { statistics, _ in
let value = statistics.sumQuantity()?.doubleValue(for: HKUnit.minute()) ?? 0
data.append([
"date": self?.dateToISOString(statistics.startDate) ?? "",
"value": value
])
}
let result: [String: Any] = [
"data": data,
"count": data.count,
"startDate": self?.dateToISOString(startDate) ?? "",
"endDate": self?.dateToISOString(endDate) ?? ""
]
resolver(result)
}
}
healthStore.execute(query)
}
@objc
func getActivitySummary(
_ options: NSDictionary,
@@ -852,6 +1021,86 @@ class HealthKitManager: RCTEventEmitter {
healthStore.execute(query)
}
@objc
func getWristTemperatureSamples(
_ options: NSDictionary,
resolver: @escaping RCTPromiseResolveBlock,
rejecter: @escaping RCTPromiseRejectBlock
) {
guard HKHealthStore.isHealthDataAvailable() else {
rejecter("HEALTHKIT_NOT_AVAILABLE", "HealthKit is not available on this device", nil)
return
}
guard let tempType = ReadTypes.appleSleepingWristTemperature else {
rejecter("TYPE_NOT_AVAILABLE", "Wrist temperature type is not available", nil)
return
}
let startDate: Date
if let startString = options["startDate"] as? String, let d = parseDate(from: startString) {
startDate = d
} else {
startDate = Calendar.current.startOfDay(for: Date())
}
let endDate: Date
if let endString = options["endDate"] as? String, let d = parseDate(from: endString) {
endDate = d
} else {
endDate = Date()
}
let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierEndDate, ascending: false)
let limit = options["limit"] as? Int ?? HKObjectQueryNoLimit
let query = HKSampleQuery(sampleType: tempType,
predicate: predicate,
limit: limit,
sortDescriptors: [sortDescriptor]) { [weak self] (query, samples, error) in
DispatchQueue.main.async {
if let error = error {
rejecter("QUERY_ERROR", "Failed to query wrist temperature: \(error.localizedDescription)", error)
return
}
guard let tempSamples = samples as? [HKQuantitySample] else {
resolver([
"data": [],
"count": 0,
"startDate": self?.dateToISOString(startDate) ?? "",
"endDate": self?.dateToISOString(endDate) ?? ""
])
return
}
let tempData = tempSamples.map { sample in
[
"id": sample.uuid.uuidString,
"startDate": self?.dateToISOString(sample.startDate) ?? "",
"endDate": self?.dateToISOString(sample.endDate) ?? "",
"value": sample.quantity.doubleValue(for: HKUnit.degreeCelsius()),
"source": [
"name": sample.sourceRevision.source.name,
"bundleIdentifier": sample.sourceRevision.source.bundleIdentifier
],
"metadata": sample.metadata ?? [:]
] as [String : Any]
}
let result: [String: Any] = [
"data": tempData,
"count": tempData.count,
"startDate": self?.dateToISOString(startDate) ?? "",
"endDate": self?.dateToISOString(endDate) ?? ""
]
resolver(result)
}
}
healthStore.execute(query)
}
@objc
func getHeartRateSamples(
_ options: NSDictionary,
@@ -2548,6 +2797,210 @@ func saveWeight(
}
}
// MARK: - Menstrual Cycle Methods
@objc
func getMenstrualFlowSamples(
_ options: NSDictionary,
resolver: @escaping RCTPromiseResolveBlock,
rejecter: @escaping RCTPromiseRejectBlock
) {
guard HKHealthStore.isHealthDataAvailable() else {
rejecter("HEALTHKIT_NOT_AVAILABLE", "HealthKit is not available on this device", nil)
return
}
guard let menstrualType = ReadTypes.menstrualFlow else {
rejecter("TYPE_NOT_AVAILABLE", "Menstrual flow type is not available", nil)
return
}
let startDate: Date
if let startString = options["startDate"] as? String, let d = parseDate(from: startString) {
startDate = d
} else {
startDate = Calendar.current.date(byAdding: .month, value: -3, to: Date()) ?? Date()
}
let endDate: Date
if let endString = options["endDate"] as? String, let d = parseDate(from: endString) {
endDate = d
} else {
endDate = Date()
}
let limit = options["limit"] as? Int ?? HKObjectQueryNoLimit
let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: true)
let query = HKSampleQuery(sampleType: menstrualType,
predicate: predicate,
limit: limit,
sortDescriptors: [sortDescriptor]) { [weak self] (query, samples, error) in
DispatchQueue.main.async {
if let error = error {
rejecter("QUERY_ERROR", "Failed to query menstrual flow: \(error.localizedDescription)", error)
return
}
guard let flowSamples = samples as? [HKCategorySample] else {
resolver([
"data": [],
"count": 0,
"startDate": self?.dateToISOString(startDate) ?? "",
"endDate": self?.dateToISOString(endDate) ?? ""
])
return
}
let flowData = flowSamples.map { sample in
[
"id": sample.uuid.uuidString,
"startDate": self?.dateToISOString(sample.startDate) ?? "",
"endDate": self?.dateToISOString(sample.endDate) ?? "",
"value": sample.value,
"isStart": sample.metadata?[HKMetadataKeyMenstrualCycleStart] as? Bool ?? false,
"source": [
"name": sample.sourceRevision.source.name,
"bundleIdentifier": sample.sourceRevision.source.bundleIdentifier
],
"metadata": sample.metadata ?? [:]
] as [String : Any]
}
let result: [String: Any] = [
"data": flowData,
"count": flowData.count,
"startDate": self?.dateToISOString(startDate) ?? "",
"endDate": self?.dateToISOString(endDate) ?? ""
]
resolver(result)
}
}
healthStore.execute(query)
}
@objc
func saveMenstrualFlow(
_ options: NSDictionary,
resolver: @escaping RCTPromiseResolveBlock,
rejecter: @escaping RCTPromiseRejectBlock
) {
guard HKHealthStore.isHealthDataAvailable() else {
rejecter("HEALTHKIT_NOT_AVAILABLE", "HealthKit is not available on this device", nil)
return
}
let date: Date
if let dateString = options["date"] as? String, let d = parseDate(from: dateString) {
date = d
} else {
rejecter("INVALID_PARAMETERS", "Date is required", nil)
return
}
// Default to unspecified (1) if not provided.
// HKCategoryValueMenstrualFlow: unspecified=1, light=2, medium=3, heavy=4, none=5
let value = options["value"] as? Int ?? HKCategoryValueMenstrualFlow.unspecified.rawValue
let isStart = options["isStart"] as? Bool ?? false
guard let menstrualType = WriteTypes.menstrualFlow else {
rejecter("TYPE_NOT_AVAILABLE", "Menstrual flow type is not available", nil)
return
}
// Normalize date to start of day and end of day for the sample
let calendar = Calendar.current
let startOfDay = calendar.startOfDay(for: date)
// HealthKit docs suggest menstrual samples should represent the day.
// Often recorded as start of day to next day or specific time.
// Standard practice for cycle tracking is usually per-day samples.
guard let endOfDay = calendar.date(byAdding: .day, value: 1, to: startOfDay) else {
rejecter("DATE_ERROR", "Failed to calculate end of day", nil)
return
}
var metadata: [String: Any] = [:]
// HKMetadataKeyMenstrualCycleStart is REQUIRED for HKCategoryTypeIdentifierMenstrualFlow
// It indicates whether this sample represents the start of a menstrual cycle.
metadata[HKMetadataKeyMenstrualCycleStart] = isStart
metadata[HKMetadataKeyWasUserEntered] = true
let sample = HKCategorySample(
type: menstrualType,
value: value,
start: startOfDay,
end: endOfDay, // Using full day duration
metadata: metadata
)
healthStore.save(sample) { [weak self] (success, error) in
DispatchQueue.main.async {
if let error = error {
rejecter("SAVE_ERROR", "Failed to save menstrual flow: \(error.localizedDescription)", error)
return
}
if success {
resolver(["success": true])
} else {
rejecter("SAVE_FAILED", "Failed to save menstrual flow", nil)
}
}
}
}
@objc
func deleteMenstrualFlow(
_ options: NSDictionary,
resolver: @escaping RCTPromiseResolveBlock,
rejecter: @escaping RCTPromiseRejectBlock
) {
guard HKHealthStore.isHealthDataAvailable() else {
rejecter("HEALTHKIT_NOT_AVAILABLE", "HealthKit is not available on this device", nil)
return
}
let startDate: Date
if let startString = options["startDate"] as? String, let d = parseDate(from: startString) {
startDate = d
} else {
rejecter("INVALID_PARAMETERS", "Start date is required", nil)
return
}
let endDate: Date
if let endString = options["endDate"] as? String, let d = parseDate(from: endString) {
endDate = d
} else {
rejecter("INVALID_PARAMETERS", "End date is required", nil)
return
}
guard let menstrualType = WriteTypes.menstrualFlow else {
rejecter("TYPE_NOT_AVAILABLE", "Menstrual flow type is not available", nil)
return
}
let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate)
healthStore.deleteObjects(of: menstrualType, predicate: predicate) { (success, count, error) in
DispatchQueue.main.async {
if let error = error {
rejecter("DELETE_ERROR", "Failed to delete menstrual flow: \(error.localizedDescription)", error)
return
}
if success {
resolver(["success": true, "count": count])
} else {
rejecter("DELETE_FAILED", "Failed to delete menstrual flow", nil)
}
}
}
}
// MARK: - RCTEventEmitter Overrides
override func supportedEvents() -> [String]! {

View File

@@ -27,7 +27,7 @@
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.1.4</string>
<string>1.1.6</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>

View File

@@ -2,11 +2,15 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>EXUpdatesEnabled</key>
<true/>
<key>EXUpdatesURL</key>
<string>https://pilate.richarjiang.com/api/expo-updates/manifest</string>
<key>EXUpdatesCheckOnLaunch</key>
<string>ALWAYS</string>
<key>EXUpdatesEnabled</key>
<false/>
<key>EXUpdatesLaunchWaitMs</key>
<integer>0</integer>
<key>EXUpdatesRuntimeVersion</key>
<string>1.1.4</string>
</dict>
</plist>

View File

@@ -1,4 +1,6 @@
PODS:
- EASClient (1.0.7):
- ExpoModulesCore
- EXApplication (7.0.7):
- ExpoModulesCore
- EXConstants (18.0.10):
@@ -6,9 +8,12 @@ PODS:
- EXImageLoader (6.0.0):
- ExpoModulesCore
- React-Core
- EXNotifications (0.32.12):
- EXJSONUtils (0.15.0)
- EXManifests (1.0.9):
- ExpoModulesCore
- Expo (54.0.25):
- EXNotifications (0.32.13):
- ExpoModulesCore
- Expo (54.0.26):
- ExpoModulesCore
- hermes-engine
- RCTRequired
@@ -37,7 +42,7 @@ PODS:
- ExpoModulesCore
- ExpoAsset (12.0.10):
- ExpoModulesCore
- ExpoBackgroundTask (1.0.8):
- ExpoBackgroundTask (1.0.9):
- ExpoModulesCore
- ExpoBlur (15.0.7):
- ExpoModulesCore
@@ -47,6 +52,8 @@ PODS:
- ZXingObjC/PDF417
- ExpoClipboard (8.0.7):
- ExpoModulesCore
- ExpoDocumentPicker (14.0.7):
- ExpoModulesCore
- ExpoFileSystem (19.0.19):
- ExpoModulesCore
- ExpoFont (14.0.9):
@@ -55,7 +62,7 @@ PODS:
- ExpoModulesCore
- ExpoHaptics (15.0.7):
- ExpoModulesCore
- ExpoHead (6.0.15):
- ExpoHead (6.0.16):
- ExpoModulesCore
- RNScreens
- ExpoImage (3.0.10):
@@ -78,7 +85,7 @@ PODS:
- ExpoMediaLibrary (18.2.0):
- ExpoModulesCore
- React-Core
- ExpoModulesCore (3.0.26):
- ExpoModulesCore (3.0.27):
- hermes-engine
- RCTRequired
- RCTTypeSafety
@@ -105,7 +112,7 @@ PODS:
- ExpoModulesCore
- ExpoSplashScreen (31.0.11):
- ExpoModulesCore
- ExpoSQLite (16.0.8):
- ExpoSQLite (16.0.9):
- ExpoModulesCore
- ExpoSymbols (1.0.7):
- ExpoModulesCore
@@ -113,11 +120,42 @@ PODS:
- ExpoModulesCore
- ExpoUI (0.2.0-beta.7):
- ExpoModulesCore
- ExpoWebBrowser (15.0.8):
- ExpoWebBrowser (15.0.9):
- ExpoModulesCore
- EXStructuredHeaders (5.0.0)
- EXTaskManager (14.0.8):
- ExpoModulesCore
- UMAppLoader
- EXUpdates (29.0.14):
- EASClient
- EXManifests
- ExpoModulesCore
- EXStructuredHeaders
- EXUpdatesInterface
- hermes-engine
- RCTRequired
- RCTTypeSafety
- ReachabilitySwift
- React-Core
- React-Core-prebuilt
- React-debug
- React-Fabric
- React-featureflags
- React-graphics
- React-ImageManager
- React-jsi
- React-NativeModulesApple
- React-RCTFabric
- React-renderercss
- React-rendererdebug
- React-utils
- ReactCodegen
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- Yoga
- EXUpdatesInterface (2.0.0):
- ExpoModulesCore
- FBLazyVector (0.81.5)
- hermes-engine (0.81.5):
- hermes-engine/Pre-built (= 0.81.5)
@@ -163,14 +201,15 @@ PODS:
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- Yoga
- PurchasesHybridCommon (17.19.1):
- RevenueCat (= 5.48.0)
- PurchasesHybridCommon (17.21.2):
- RevenueCat (= 5.49.2)
- RCTDeprecation (0.81.5)
- RCTRequired (0.81.5)
- RCTTypeSafety (0.81.5):
- FBLazyVector (= 0.81.5)
- RCTRequired (= 0.81.5)
- React-Core (= 0.81.5)
- ReachabilitySwift (5.2.4)
- React (0.81.5):
- React-Core (= 0.81.5)
- React-Core/DevSupport (= 0.81.5)
@@ -1446,7 +1485,7 @@ PODS:
- ReactNativeDependencies
- react-native-render-html (6.3.4):
- React-Core
- react-native-safe-area-context (5.6.1):
- react-native-safe-area-context (5.6.2):
- hermes-engine
- RCTRequired
- RCTTypeSafety
@@ -1458,8 +1497,8 @@ PODS:
- React-graphics
- React-ImageManager
- React-jsi
- react-native-safe-area-context/common (= 5.6.1)
- react-native-safe-area-context/fabric (= 5.6.1)
- react-native-safe-area-context/common (= 5.6.2)
- react-native-safe-area-context/fabric (= 5.6.2)
- React-NativeModulesApple
- React-RCTFabric
- React-renderercss
@@ -1470,7 +1509,7 @@ PODS:
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- Yoga
- react-native-safe-area-context/common (5.6.1):
- react-native-safe-area-context/common (5.6.2):
- hermes-engine
- RCTRequired
- RCTTypeSafety
@@ -1492,7 +1531,7 @@ PODS:
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- Yoga
- react-native-safe-area-context/fabric (5.6.1):
- react-native-safe-area-context/fabric (5.6.2):
- hermes-engine
- RCTRequired
- RCTTypeSafety
@@ -1911,7 +1950,7 @@ PODS:
- React-utils (= 0.81.5)
- ReactNativeDependencies
- ReactNativeDependencies (0.81.5)
- RevenueCat (5.48.0)
- RevenueCat (5.49.2)
- RNCAsyncStorage (2.2.0):
- hermes-engine
- RCTRequired
@@ -2024,10 +2063,10 @@ PODS:
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- Yoga
- RNPurchases (9.6.7):
- PurchasesHybridCommon (= 17.19.1)
- RNPurchases (9.6.9):
- PurchasesHybridCommon (= 17.21.2)
- React-Core
- RNReanimated (4.1.5):
- RNReanimated (4.1.6):
- hermes-engine
- RCTRequired
- RCTTypeSafety
@@ -2049,10 +2088,10 @@ PODS:
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- RNReanimated/reanimated (= 4.1.5)
- RNReanimated/reanimated (= 4.1.6)
- RNWorklets
- Yoga
- RNReanimated/reanimated (4.1.5):
- RNReanimated/reanimated (4.1.6):
- hermes-engine
- RCTRequired
- RCTTypeSafety
@@ -2074,10 +2113,10 @@ PODS:
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- RNReanimated/reanimated/apple (= 4.1.5)
- RNReanimated/reanimated/apple (= 4.1.6)
- RNWorklets
- Yoga
- RNReanimated/reanimated/apple (4.1.5):
- RNReanimated/reanimated/apple (4.1.6):
- hermes-engine
- RCTRequired
- RCTTypeSafety
@@ -2217,7 +2256,7 @@ PODS:
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- Yoga
- RNWorklets (0.6.1):
- RNWorklets (0.7.1):
- hermes-engine
- RCTRequired
- RCTTypeSafety
@@ -2239,9 +2278,9 @@ PODS:
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- RNWorklets/worklets (= 0.6.1)
- RNWorklets/worklets (= 0.7.1)
- Yoga
- RNWorklets/worklets (0.6.1):
- RNWorklets/worklets (0.7.1):
- hermes-engine
- RCTRequired
- RCTTypeSafety
@@ -2263,9 +2302,9 @@ PODS:
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- RNWorklets/worklets/apple (= 0.6.1)
- RNWorklets/worklets/apple (= 0.7.1)
- Yoga
- RNWorklets/worklets/apple (0.6.1):
- RNWorklets/worklets/apple (0.7.1):
- hermes-engine
- RCTRequired
- RCTTypeSafety
@@ -2288,9 +2327,9 @@ PODS:
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- Yoga
- SDWebImage (5.21.4):
- SDWebImage/Core (= 5.21.4)
- SDWebImage/Core (5.21.4)
- SDWebImage (5.21.5):
- SDWebImage/Core (= 5.21.5)
- SDWebImage/Core (5.21.5)
- SDWebImageAVIFCoder (0.11.1):
- libavif/core (>= 0.11.0)
- SDWebImage (~> 5.10)
@@ -2309,9 +2348,12 @@ PODS:
- ZXingObjC/Core
DEPENDENCIES:
- EASClient (from `../node_modules/expo-eas-client/ios`)
- EXApplication (from `../node_modules/expo-application/ios`)
- EXConstants (from `../node_modules/expo-constants/ios`)
- EXImageLoader (from `../node_modules/expo-image-loader/ios`)
- EXJSONUtils (from `../node_modules/expo-json-utils/ios`)
- EXManifests (from `../node_modules/expo-manifests/ios`)
- EXNotifications (from `../node_modules/expo-notifications/ios`)
- Expo (from `../node_modules/expo`)
- ExpoAppleAuthentication (from `../node_modules/expo-apple-authentication/ios`)
@@ -2320,6 +2362,7 @@ DEPENDENCIES:
- ExpoBlur (from `../node_modules/expo-blur/ios`)
- ExpoCamera (from `../node_modules/expo-camera/ios`)
- ExpoClipboard (from `../node_modules/expo-clipboard/ios`)
- ExpoDocumentPicker (from `../node_modules/expo-document-picker/ios`)
- ExpoFileSystem (from `../node_modules/expo-file-system/ios`)
- ExpoFont (from `../node_modules/expo-font/ios`)
- ExpoGlassEffect (from `../node_modules/expo-glass-effect/ios`)
@@ -2340,7 +2383,10 @@ DEPENDENCIES:
- ExpoSystemUI (from `../node_modules/expo-system-ui/ios`)
- "ExpoUI (from `../node_modules/@expo/ui/ios`)"
- ExpoWebBrowser (from `../node_modules/expo-web-browser/ios`)
- EXStructuredHeaders (from `../node_modules/expo-structured-headers/ios`)
- EXTaskManager (from `../node_modules/expo-task-manager/ios`)
- EXUpdates (from `../node_modules/expo-updates/ios`)
- EXUpdatesInterface (from `../node_modules/expo-updates-interface/ios`)
- FBLazyVector (from `../node_modules/react-native/Libraries/FBLazyVector`)
- hermes-engine (from `../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec`)
- lottie-react-native (from `../node_modules/lottie-react-native`)
@@ -2436,6 +2482,7 @@ SPEC REPOS:
- libwebp
- lottie-ios
- PurchasesHybridCommon
- ReachabilitySwift
- RevenueCat
- SDWebImage
- SDWebImageAVIFCoder
@@ -2445,12 +2492,18 @@ SPEC REPOS:
- ZXingObjC
EXTERNAL SOURCES:
EASClient:
:path: "../node_modules/expo-eas-client/ios"
EXApplication:
:path: "../node_modules/expo-application/ios"
EXConstants:
:path: "../node_modules/expo-constants/ios"
EXImageLoader:
:path: "../node_modules/expo-image-loader/ios"
EXJSONUtils:
:path: "../node_modules/expo-json-utils/ios"
EXManifests:
:path: "../node_modules/expo-manifests/ios"
EXNotifications:
:path: "../node_modules/expo-notifications/ios"
Expo:
@@ -2467,6 +2520,8 @@ EXTERNAL SOURCES:
:path: "../node_modules/expo-camera/ios"
ExpoClipboard:
:path: "../node_modules/expo-clipboard/ios"
ExpoDocumentPicker:
:path: "../node_modules/expo-document-picker/ios"
ExpoFileSystem:
:path: "../node_modules/expo-file-system/ios"
ExpoFont:
@@ -2507,8 +2562,14 @@ EXTERNAL SOURCES:
:path: "../node_modules/@expo/ui/ios"
ExpoWebBrowser:
:path: "../node_modules/expo-web-browser/ios"
EXStructuredHeaders:
:path: "../node_modules/expo-structured-headers/ios"
EXTaskManager:
:path: "../node_modules/expo-task-manager/ios"
EXUpdates:
:path: "../node_modules/expo-updates/ios"
EXUpdatesInterface:
:path: "../node_modules/expo-updates-interface/ios"
FBLazyVector:
:path: "../node_modules/react-native/Libraries/FBLazyVector"
hermes-engine:
@@ -2684,22 +2745,26 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native/ReactCommon/yoga"
SPEC CHECKSUMS:
EASClient: 68127f1248d2b25fdc82dbbfb17be95d1c4700be
EXApplication: 296622817d459f46b6c5fe8691f4aac44d2b79e7
EXConstants: fd688cef4e401dcf798a021cfb5d87c890c30ba3
EXImageLoader: 189e3476581efe3ad4d1d3fb4735b7179eb26f05
EXNotifications: 7cff475adb5d7a255a9ea46bbd2589cb3b454506
Expo: 111394d38f32be09385d4c7f70cc96d2da438d0d
EXJSONUtils: 1d3e4590438c3ee593684186007028a14b3686cd
EXManifests: 26e15640538c3d5ef028077ebcaf004b744d4932
EXNotifications: a62e1f8e3edd258dc3b155d3caa49f32920f1c6c
Expo: 7af24402df45b9384900104e88a11896ffc48161
ExpoAppleAuthentication: bc9de6e9ff3340604213ab9031d4c4f7f802623e
ExpoAsset: d839c8eae8124470332408427327e8f88beb2dfd
ExpoBackgroundTask: e0d201d38539c571efc5f9cb661fae8ab36ed61b
ExpoBackgroundTask: c498ce99a10f125d8370a5b2f4405e2583a3c896
ExpoBlur: 2dd8f64aa31f5d405652c21d3deb2d2588b1852f
ExpoCamera: 2a87c210f8955350ea5c70f1d539520b2fc5d940
ExpoClipboard: af650d14765f19c60ce2a1eaf9dfe6445eff7365
ExpoDocumentPicker: 2200eefc2817f19315fa18f0147e0b80ece86926
ExpoFileSystem: 77157a101e03150a4ea4f854b4dd44883c93ae0a
ExpoFont: cf9d90ec1d3b97c4f513211905724c8171f82961
ExpoGlassEffect: 265fa3d75b46bc58262e4dfa513135fa9dfe4aac
ExpoHaptics: 807476b0c39e9d82b7270349d6487928ce32df84
ExpoHead: 95a6ee0be1142320bccf07961d6a1502ded5d6ac
ExpoHead: fc0185d5c2a51ea599aff223aba5d61782301044
ExpoImage: 9c3428921c536ab29e5c6721d001ad5c1f469566
ExpoImagePicker: d251aab45a1b1857e4156fed88511b278b4eee1c
ExpoKeepAwake: 1a2e820692e933c94a565ec3fbbe38ac31658ffe
@@ -2707,15 +2772,18 @@ SPEC CHECKSUMS:
ExpoLinking: 77455aa013e9b6a3601de03ecfab09858ee1b031
ExpoLocalization: b852a5d8ec14c5349c1593eca87896b5b3ebfcca
ExpoMediaLibrary: 641a6952299b395159ccd459bd8f5f6764bf55fe
ExpoModulesCore: e8ec7f8727caf51a49d495598303dd420ca994bf
ExpoModulesCore: bdc95c6daa1639e235a16350134152a0b28e5c72
ExpoQuickActions: 31a70aa6a606128de4416a4830e09cfabfe6667f
ExpoSplashScreen: 268b2f128dc04284c21010540a6c4dd9f95003e3
ExpoSQLite: 7fa091ba5562474093fef09be644161a65e11b3f
ExpoSQLite: b312b02c8b77ab55951396e6cd13992f8db9215f
ExpoSymbols: 1ae04ce686de719b9720453b988d8bc5bf776c68
ExpoSystemUI: 2761aa6875849af83286364811d46e8ed8ea64c7
ExpoUI: b99a1d1ef5352a60bebf4f4fd3a50d2f896ae804
ExpoWebBrowser: d04a0d6247a0bea4519fbc2ea816610019ad83e0
ExpoWebBrowser: b973e1351fdcf5fec0c400997b1851f5a8219ec3
EXStructuredHeaders: c951e77f2d936f88637421e9588c976da5827368
EXTaskManager: cbbb80cbccea6487ccca0631809fbba2ed3e5271
EXUpdates: 9042dc213f17593a02d59ef7dd9d297edf621936
EXUpdatesInterface: 5adf50cb41e079c861da6d9b4b954c3db9a50734
FBLazyVector: e95a291ad2dadb88e42b06e0c5fb8262de53ec12
hermes-engine: 9f4dfe93326146a1c99eb535b1cb0b857a3cd172
libavif: 84bbb62fb232c3018d6f1bab79beea87e35de7b7
@@ -2723,10 +2791,11 @@ SPEC CHECKSUMS:
libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8
lottie-ios: a881093fab623c467d3bce374367755c272bdd59
lottie-react-native: cbe3d931a7c24f7891a8e8032c2bb9b2373c4b9c
PurchasesHybridCommon: a4837eebc889b973668af685d6c23b89a038461d
PurchasesHybridCommon: 71c94158ff8985657d37d5f3be05602881227619
RCTDeprecation: 943572d4be82d480a48f4884f670135ae30bf990
RCTRequired: 8f3cfc90cc25cf6e420ddb3e7caaaabc57df6043
RCTTypeSafety: 16a4144ca3f959583ab019b57d5633df10b5e97c
ReachabilitySwift: 32793e867593cfc1177f5d16491e3a197d2fccda
React: 914f8695f9bf38e6418228c2ffb70021e559f92f
React-callinvoker: 1c0808402aee0c6d4a0d8e7220ce6547af9fba71
React-Core: c61410ef0ca6055e204a963992e363227e0fd1c5
@@ -2758,7 +2827,7 @@ SPEC CHECKSUMS:
React-Mapbuffer: 9050ee10c19f4f7fca8963d0211b2854d624973e
React-microtasksnativemodule: f775db9e991c6f3b8ccbc02bfcde22770f96e23b
react-native-render-html: 5afc4751f1a98621b3009432ef84c47019dcb2bd
react-native-safe-area-context: 42a1b4f8774b577d03b53de7326e3d5757fe9513
react-native-safe-area-context: 37e680fc4cace3c0030ee46e8987d24f5d3bdab2
react-native-view-shot: fb3c0774edb448f42705491802a455beac1502a2
react-native-voice: 908a0eba96c8c3d643e4f98b7232c6557d0a6f9c
react-native-webview: b29007f4723bca10872028067b07abacfa1cb35a
@@ -2793,20 +2862,20 @@ SPEC CHECKSUMS:
ReactCodegen: 7d4593f7591f002d137fe40cef3f6c11f13c88cc
ReactCommon: 08810150b1206cc44aecf5f6ae19af32f29151a8
ReactNativeDependencies: 71ce9c28beb282aa720ea7b46980fff9669f428a
RevenueCat: 1e61140a343a77dc286f171b3ffab99ca09a4b57
RevenueCat: d185cbff8be9425b5835042afd6889389bb756c8
RNCAsyncStorage: 3a4f5e2777dae1688b781a487923a08569e27fe4
RNCMaskedView: d2578d41c59b936db122b2798ba37e4722d21035
RNCPicker: c8a3584b74133464ee926224463fcc54dfdaebca
RNDateTimePicker: 19ffa303c4524ec0a2dfdee2658198451c16b7f1
RNDeviceInfo: bcce8752b5043a623fe3c26789679b473f705d3c
RNGestureHandler: 2914750df066d89bf9d8f48a10ad5f0051108ac3
RNPurchases: 5f3cd4fea5ef2b3914c925b2201dd5cecd31922f
RNReanimated: 1442a577e066e662f0ce1cd1864a65c8e547aee0
RNPurchases: 34da99c0e14ee484ed57e77dc06dcfe8e7cb1cee
RNReanimated: e5c702a3e24cc1c68b2de67671713f35461678f4
RNScreens: d8d6f1792f6e7ac12b0190d33d8d390efc0c1845
RNSentry: 1d7b9fdae7a01ad8f9053335b5d44e75c39a955e
RNSVG: 31d6639663c249b7d5abc9728dde2041eb2a3c34
RNWorklets: 54d8dffb7f645873a58484658ddfd4bd1a9a0bc1
SDWebImage: d0184764be51240d49c761c37f53dd017e1ccaaf
RNWorklets: 9eb6d567fa43984e96b6924a6df504b8a15980cd
SDWebImage: e9c98383c7572d713c1a0d7dd2783b10599b9838
SDWebImageAVIFCoder: afe194a084e851f70228e4be35ef651df0fc5c57
SDWebImageSVGCoder: 15a300a97ec1c8ac958f009c02220ac0402e936c
SDWebImageWebPCoder: e38c0a70396191361d60c092933e22c20d5b1380

Some files were not shown because too many files have changed in this diff Show More