27 Commits

Author SHA1 Message Date
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
richarjiang
5b46104564 feat(ai报告): 新增AI健康报告画廊功能,支持报告生成、保存与分享 2025-12-02 14:40:45 +08:00
richarjiang
be0dd750eb feat(vip): 限制底部栏自定义功能为VIP专享
非VIP用户尝试配置底部栏时将显示会员购买弹窗,
只有VIP会员才能自由开启或关闭导航标签。
包含会员权益说明的国际化支持和存储结构重构。
2025-12-01 16:56:54 +08:00
richarjiang
a47f0fb72e feat(用药管理): 集成AI智能分析功能,提供用药依从性深度洞察和专业健康建议 2025-12-01 10:49:35 +08:00
a309123b35 feat(app): add version check system and enhance internationalization support
Add comprehensive app update checking functionality with:
- New VersionCheckContext for managing update detection and notifications
- VersionUpdateModal UI component for presenting update information
- Version service API integration with platform-specific update URLs
- Version check menu item in personal settings with manual/automatic checking

Enhance internationalization across workout features:
- Complete workout type translations for English and Chinese
- Localized workout detail modal with proper date/time formatting
- Locale-aware date formatting in fitness rings detail
- Workout notification improvements with deep linking to specific workout details

Improve UI/UX with better chart rendering, sizing fixes, and enhanced navigation flow. Update app version to 1.1.3 and include app version in API headers for better tracking.
2025-11-29 20:47:16 +08:00
83b77615cf feat: Enhance Oxygen Saturation Card with health permissions and loading state management
feat(i18n): Add common translations and mood-related strings in English and Chinese

fix(i18n): Update metabolism titles for consistency in health translations

chore: Update Podfile.lock to include SDWebImage 5.21.4 and other dependency versions

refactor(moodCheckins): Improve mood configuration retrieval with optional translation support

refactor(sleepHealthKit): Replace useI18n with direct i18n import for sleep quality descriptions
2025-11-28 23:48:38 +08:00
richarjiang
bca6670390 Add Chinese translations for medication management and personal settings
- Introduced new translation files for medication, personal, and weight management in Chinese.
- Updated the main index file to include the new translation modules.
- Enhanced the medication type definitions to include 'ointment'.
- Refactored workout type labels to utilize i18n for better localization support.
- Improved sleep quality descriptions and recommendations with i18n integration.
2025-11-28 17:29:51 +08:00
richarjiang
fbe0c92f0f feat(i18n): 全面实现应用核心功能模块的国际化支持
- 新增 i18n 翻译资源,覆盖睡眠、饮水、体重、锻炼、用药 AI 识别、步数、健身圆环、基础代谢及设置等核心模块
- 重构相关页面及组件(如 SleepDetail, WaterDetail, WorkoutHistory 等)使用 `useI18n` 钩子替换硬编码文本
- 升级 `utils/date` 工具库与 `DateSelector` 组件,支持基于语言环境的日期格式化与显示
- 完善登录页、注销流程及权限申请弹窗的双语提示信息
- 优化部分页面的 UI 细节与字体样式以适配多语言显示
2025-11-27 17:54:36 +08:00
richarjiang
08adf0f20d feat(i18n): 实现用户语言偏好服务器同步功能
- 添加 UserLanguage 类型定义 ('zh-CN' | 'en-US')
- 在 UpdateUserDto 中新增 language 字段
- 实现语言切换时自动同步到服务器
- 为已登录用户保存语言偏好设置
- 服务器同步失败时降级处理,不影响本地语言切换
2025-11-27 11:17:21 +08:00
richarjiang
18d83091a9 feat(challenges): 添加自定义挑战类型并优化字段验证
- 新增 CUSTOM 挑战类型支持
- 移除 requirementLabel 必填验证,改为可选字段
- 添加挑战类型选择器的编辑模式禁用状态
- 优化日期选择器的多语言支持
- 完善中英文国际化文案
- 修复空 requirementLabel 导致的渲染问题
2025-11-27 11:11:15 +08:00
richarjiang
01388a5c4f style(ui): 为应用组件统一添加自定义字体样式 2025-11-27 09:22:55 +08:00
richarjiang
518282ecb8 feat(challenges): 实现自定义挑战的编辑与删除功能并完善多语言支持
- 新增自定义挑战的编辑模式,支持修改挑战信息
- 在详情页为创建者添加删除(归档)挑战的功能入口
- 全面完善挑战创建页面的国际化(i18n)文案适配
- 优化个人中心页面的字体样式,统一使用 AliBold/Regular
- 更新 Store 逻辑以处理挑战更新、删除及列表数据映射调整
2025-11-26 19:07:19 +08:00
178 changed files with 27572 additions and 11508 deletions

View File

@@ -21,10 +21,11 @@
### 健康数据追踪 ✅ ### 健康数据追踪 ✅
- HealthKit 集成完成支持步数、心率、HRV、睡眠等数据 - HealthKit 集成完成支持步数、心率、HRV、睡眠、手腕温度等数据
- 活动圆环显示(活动卡路里、锻炼分钟、站立小时) - 活动圆环显示(活动卡路里、锻炼分钟、站立小时)
- 实时健康数据监控和历史数据查看 - 实时健康数据监控和历史数据查看
- 健康权限管理系统 - 健康权限管理系统
- 经期跟踪与 HealthKit 同步
### 营养管理 ✅ ### 营养管理 ✅
@@ -95,11 +96,12 @@
### 近期更新 ### 近期更新
1. **多语言支持**: 完善挑战页面的多语言翻译支持,建立翻译最佳实践指南 1. **健康数据**: 新增手腕温度监测功能(支持 Apple Watch 睡眠手腕温度)
2. **性能优化**: 优化健康数据加载和图表渲染性能 2. **健康数据**: 实现经期数据与 HealthKit 的双向同步(读写与删除)
3. **用户体验**: 改进 Liquid Glass 设计效果和交互动画 3. **多语言支持**: 完善挑战页面的多语言翻译支持,建立翻译最佳实践指南
4. **数据同步**: 增强离线功能和数据同步稳定性 4. **用户体验**: 改进 Liquid Glass 设计效果和交互动画
5. **AI 功能**: 扩展 AI 教练对话能力和分析精度 5. **数据同步**: 增强离线功能和数据同步稳定性
6. **AI 功能**: 扩展 AI 教练对话能力和分析精度
### 待解决问题 ### 待解决问题

View File

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

View File

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

View File

@@ -3,17 +3,20 @@
## 核心技术 ## 核心技术
### 前端框架 ### 前端框架
- **React Native**: 0.81.4 - 跨平台移动应用开发框架 - **React Native**: 0.81.4 - 跨平台移动应用开发框架
- **Expo SDK**: 54.0.13 - React Native 开发平台和工具链 - **Expo SDK**: 54.0.13 - React Native 开发平台和工具链
- **Expo Router**: 6.0.12 - 基于文件系统的路由库 - **Expo Router**: 6.0.12 - 基于文件系统的路由库
- **TypeScript**: 5.9.2 - 类型安全的 JavaScript 超集 - **TypeScript**: 5.9.2 - 类型安全的 JavaScript 超集
### 状态管理 ### 状态管理
- **Redux Toolkit**: 2.9.0 - 状态管理解决方案 - **Redux Toolkit**: 2.9.0 - 状态管理解决方案
- **React Redux**: 9.2.0 - React Redux 绑定 - **React Redux**: 9.2.0 - React Redux 绑定
- **Redux Listener Middleware**: 自定义中间件用于自动同步 - **Redux Listener Middleware**: 自定义中间件用于自动同步
### UI 框架和样式 ### UI 框架和样式
- **React Native Elements**: UI 组件库 - **React Native Elements**: UI 组件库
- **Expo UI**: 0.2.0-beta.7 - Expo UI 组件 - **Expo UI**: 0.2.0-beta.7 - Expo UI 组件
- **Expo Glass Effect**: 0.1.4 - Liquid Glass 毛玻璃效果, 优先使用 - **Expo Glass Effect**: 0.1.4 - Liquid Glass 毛玻璃效果, 优先使用
@@ -22,17 +25,20 @@
- **React Native SVG**: 15.12.1 - SVG 图形支持 - **React Native SVG**: 15.12.1 - SVG 图形支持
### 导航 ### 导航
- **Expo Router**: 6.0.12 - 文件系统路由 - **Expo Router**: 6.0.12 - 文件系统路由
- **React Navigation**: 7.x - 导航库 - **React Navigation**: 7.x - 导航库
## 数据和存储 ## 数据和存储
### 本地存储 ### 本地存储
- **Expo SQLite**: 16.0.8 - SQLite 数据库 - **Expo SQLite**: 16.0.8 - SQLite 数据库
- **Expo SQLite KV Store**: 键值存储 - **Expo SQLite KV Store**: 键值存储
- **Async Storage**: 2.2.0 - 异步存储(兼容层) - **Async Storage**: 2.2.0 - 异步存储(兼容层)
### 网络和 API ### 网络和 API
- **Fetch API**: 原生网络请求 - **Fetch API**: 原生网络请求
- **XMLHttpRequest**: 流式请求支持 - **XMLHttpRequest**: 流式请求支持
- **Axios**: HTTP 客户端(可选) - **Axios**: HTTP 客户端(可选)
@@ -40,16 +46,19 @@
## 原生功能集成 ## 原生功能集成
### HealthKit 集成 ### HealthKit 集成
- **自定义 HealthKit Manager**: iOS 原生模块 - **自定义 HealthKit Manager**: iOS 原生模块
- **健康数据类型**: 步数、心率、HRV、睡眠、活动圆环等 - **健康数据类型**: 步数、心率、HRV、睡眠、活动圆环、手腕温度(appleSleepingWristTemperature)
- **权限管理**: 动态权限请求和状态监控 - **权限管理**: 动态权限请求和状态监控
### 通知系统 ### 通知系统
- **Expo Notifications**: 0.32.12 - 本地和推送通知 - **Expo Notifications**: 0.32.12 - 本地和推送通知
- **后台任务**: Expo Task Manager - **后台任务**: Expo Task Manager
- **推送通知**: 远程推送支持 - **推送通知**: 远程推送支持
### 设备功能 ### 设备功能
- **Expo Camera**: 17.0.8 - 相机功能 - **Expo Camera**: 17.0.8 - 相机功能
- **Expo Image Picker**: 17.0.8 - 图片选择 - **Expo Image Picker**: 17.0.8 - 图片选择
- **Expo Haptics**: 15.0.7 - 触觉反馈 - **Expo Haptics**: 15.0.7 - 触觉反馈
@@ -59,17 +68,20 @@
## 开发工具和构建 ## 开发工具和构建
### 构建系统 ### 构建系统
- **Expo Prebuild**: 原生构建生成 - **Expo Prebuild**: 原生构建生成
- **Metro**: JavaScript 打包工具 - **Metro**: JavaScript 打包工具
- **Babel**: JavaScript 编译器 - **Babel**: JavaScript 编译器
### 代码质量 ### 代码质量
- **ESLint**: 9.35.0 - 代码检查 - **ESLint**: 9.35.0 - 代码检查
- **ESLint Config Expo**: 10.0.0 - Expo ESLint 配置 - **ESLint Config Expo**: 10.0.0 - Expo ESLint 配置
- **Prettier**: 代码格式化 - **Prettier**: 代码格式化
- **TypeScript**: 类型检查 - **TypeScript**: 类型检查
### 开发环境 ### 开发环境
- **VS Code**: 主要开发 IDE - **VS Code**: 主要开发 IDE
- **Expo Go**: 开发调试 - **Expo Go**: 开发调试
- **iOS Simulator**: iOS 模拟器 - **iOS Simulator**: iOS 模拟器
@@ -78,21 +90,25 @@
## 第三方服务 ## 第三方服务
### 云存储 ### 云存储
- **腾讯云 COS**: 图片和文件存储 - **腾讯云 COS**: 图片和文件存储
- **上传服务**: 自定义上传实现 - **上传服务**: 自定义上传实现
### AI 服务 ### AI 服务
- **AI 教练**: 自定义 AI 对话服务 - **AI 教练**: 自定义 AI 对话服务
- **图像识别**: 食物识别 - **图像识别**: 食物识别
- **语音识别**: 语音转文字 - **语音识别**: 语音转文字
### 分析和监控 ### 分析和监控
- **Sentry**: 7.2.0 - 错误监控和性能分析 - **Sentry**: 7.2.0 - 错误监控和性能分析
- **崩溃报告**: 自动崩溃收集 - **崩溃报告**: 自动崩溃收集
## UI 组件库 ## UI 组件库
### 基础组件 ### 基础组件
- **ThemedView**: 主题化视图组件 - **ThemedView**: 主题化视图组件
- **ThemedText**: 主题化文本组件 - **ThemedText**: 主题化文本组件
- **IconSymbol**: 图标组件 - **IconSymbol**: 图标组件
@@ -100,6 +116,7 @@
- **AnimatedNumber**: 数字动画组件 - **AnimatedNumber**: 数字动画组件
### 业务组件 ### 业务组件
- **FitnessRingsCard**: 健身圆环卡片 - **FitnessRingsCard**: 健身圆环卡片
- **StepsCard**: 步数卡片 - **StepsCard**: 步数卡片
- **NutritionRadarCard**: 营养雷达图 - **NutritionRadarCard**: 营养雷达图
@@ -109,6 +126,7 @@
- **TaskCard**: 任务卡片 - **TaskCard**: 任务卡片
### 图表组件 ### 图表组件
- **RadarChart**: 雷达图 - **RadarChart**: 雷达图
- **CircularRing**: 圆形进度环 - **CircularRing**: 圆形进度环
- **CalorieRingChart**: 卡路里环形图 - **CalorieRingChart**: 卡路里环形图
@@ -117,17 +135,20 @@
## 开发依赖 ## 开发依赖
### 类型定义 ### 类型定义
- **React Types**: 19.1.13 - **React Types**: 19.1.13
- **React Native Types**: 内置 - **React Native Types**: 内置
- **Expo Types**: 内置 - **Expo Types**: 内置
### 工具库 ### 工具库
- **Day.js**: 1.11.18 - 日期处理 - **Day.js**: 1.11.18 - 日期处理
- **Lodash**: 4.17.21 - 工具函数库 - **Lodash**: 4.17.21 - 工具函数库
- **React Native Chart Kit**: 6.12.0 - 图表库 - **React Native Chart Kit**: 6.12.0 - 图表库
- **Lottie React Native**: 7.3.4 - 动画库 - **Lottie React Native**: 7.3.4 - 动画库
### 音频和媒体 ### 音频和媒体
- **React Native Voice**: 3.2.4 - 语音识别 - **React Native Voice**: 3.2.4 - 语音识别
- **Expo Media Library**: 18.2.0 - 媒体库 - **Expo Media Library**: 18.2.0 - 媒体库
- **Expo Audio**: 音频处理 - **Expo Audio**: 音频处理
@@ -135,12 +156,14 @@
## 平台特定配置 ## 平台特定配置
### iOS 配置 ### iOS 配置
- **最低版本**: iOS 16.0 - **最低版本**: iOS 16.0
- **Bundle ID**: com.anonymous.digitalpilates - **Bundle ID**: com.anonymous.digitalpilates
- **Team ID**: 756WVXJ6MT - **Team ID**: 756WVXJ6MT
- **权限配置**: 相机、相册、麦克风、健康数据、通知等 - **权限配置**: 相机、相册、麦克风、健康数据、通知等
### 构建配置 ### 构建配置
- **New Arch**: 启用 - **New Arch**: 启用
- **JS Engine**: JSC - **JS Engine**: JSC
- **Metro 配置**: 自定义配置 - **Metro 配置**: 自定义配置
@@ -149,18 +172,21 @@
## 性能优化 ## 性能优化
### 渲染优化 ### 渲染优化
- **React.memo**: 组件记忆化 - **React.memo**: 组件记忆化
- **useMemo/useCallback**: 钩子优化 - **useMemo/useCallback**: 钩子优化
- **FlatList**: 大列表优化 - **FlatList**: 大列表优化
- **InteractionManager**: 延迟渲染 - **InteractionManager**: 延迟渲染
### 数据优化 ### 数据优化
- **Redux Toolkit**: 自动优化 - **Redux Toolkit**: 自动优化
- **数据分页**: 分页加载 - **数据分页**: 分页加载
- **缓存策略**: 智能缓存 - **缓存策略**: 智能缓存
- **后台同步**: 异步同步 - **后台同步**: 异步同步
### 资源优化 ### 资源优化
- **图片优化**: WebP 格式 - **图片优化**: WebP 格式
- **Bundle 分割**: 代码分割 - **Bundle 分割**: 代码分割
- **内存管理**: 资源释放 - **内存管理**: 资源释放
@@ -169,12 +195,14 @@
## 安全措施 ## 安全措施
### 数据安全 ### 数据安全
- **HTTPS**: 加密通信 - **HTTPS**: 加密通信
- **Token 管理**: JWT 存储 - **Token 管理**: JWT 存储
- **数据加密**: 本地加密 - **数据加密**: 本地加密
- **权限控制**: 细粒度权限 - **权限控制**: 细粒度权限
### 隐私保护 ### 隐私保护
- **数据脱敏**: 敏感数据处理 - **数据脱敏**: 敏感数据处理
- **权限最小化**: 最小权限原则 - **权限最小化**: 最小权限原则
- **用户控制**: 数据控制权 - **用户控制**: 数据控制权
@@ -183,11 +211,13 @@
## 测试框架 ## 测试框架
### 单元测试 ### 单元测试
- **Jest**: 测试框架 - **Jest**: 测试框架
- **React Native Testing Library**: 组件测试 - **React Native Testing Library**: 组件测试
- **Mock**: 模拟数据和服务 - **Mock**: 模拟数据和服务
### 集成测试 ### 集成测试
- **Detox**: E2E 测试(可选) - **Detox**: E2E 测试(可选)
- **手动测试**: 功能验证 - **手动测试**: 功能验证
- **性能测试**: 性能基准 - **性能测试**: 性能基准
@@ -195,12 +225,14 @@
## 部署和发布 ## 部署和发布
### 构建流程 ### 构建流程
- **Expo EAS Build**: 云端构建 - **Expo EAS Build**: 云端构建
- **App Store Connect**: 应用商店发布 - **App Store Connect**: 应用商店发布
- **OTA 更新**: 热更新 - **OTA 更新**: 热更新
- **版本管理**: 语义化版本 - **版本管理**: 语义化版本
### 持续集成 ### 持续集成
- **GitHub Actions**: 自动化流程 - **GitHub Actions**: 自动化流程
- **代码检查**: 自动化检查 - **代码检查**: 自动化检查
- **测试执行**: 自动化测试 - **测试执行**: 自动化测试
@@ -209,19 +241,22 @@
## 开发规范 ## 开发规范
### 代码规范 ### 代码规范
- **ESLint**: 代码检查 - **ESLint**: 代码检查
- **Prettier**: 代码格式化 - **Prettier**: 代码格式化
- **TypeScript**: 类型安全 - **TypeScript**: 类型安全
- **命名规范**: 统一命名 - **命名规范**: 统一命名
### Git 工作流 ### Git 工作流
- **Conventional Commits**: 提交规范 - **Conventional Commits**: 提交规范
- **分支策略**: Git Flow - **分支策略**: Git Flow
- **代码审查**: PR 流程 - **代码审查**: PR 流程
- **版本标签**: 标签管理 - **版本标签**: 标签管理
### 文档规范 ### 文档规范
- **JSDoc**: 代码注释 - **JSDoc**: 代码注释
- **README**: 项目文档 - **README**: 项目文档
- **API 文档**: 接口文档 - **API 文档**: 接口文档
- **组件文档**: 组件说明 - **组件文档**: 组件说明

View File

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

View File

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

View File

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

View File

@@ -2,6 +2,7 @@ import dayjs from 'dayjs';
import ChallengeProgressCard from '@/components/challenges/ChallengeProgressCard'; import ChallengeProgressCard from '@/components/challenges/ChallengeProgressCard';
import { ConfirmationSheet } from '@/components/ui/ConfirmationSheet'; import { ConfirmationSheet } from '@/components/ui/ConfirmationSheet';
import { Image } from '@/components/ui/Image';
import { Colors } from '@/constants/Colors'; import { Colors } from '@/constants/Colors';
import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useAuthGuard } from '@/hooks/useAuthGuard'; import { useAuthGuard } from '@/hooks/useAuthGuard';
@@ -23,7 +24,6 @@ import {
import { Toast } from '@/utils/toast.utils'; import { Toast } from '@/utils/toast.utils';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect'; import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
@@ -602,14 +602,14 @@ const styles = StyleSheet.create({
marginBottom: 26, marginBottom: 26,
}, },
title: { title: {
fontSize: 32, fontSize: 24,
fontWeight: '700', fontWeight: '700',
letterSpacing: 1, letterSpacing: 1,
fontFamily: 'AliBold' fontFamily: 'AliBold'
}, },
subtitle: { subtitle: {
marginTop: 6, marginTop: 6,
fontSize: 14, fontSize: 12,
fontWeight: '500', fontWeight: '500',
opacity: 0.8, opacity: 0.8,
fontFamily: 'AliRegular' fontFamily: 'AliRegular'
@@ -619,8 +619,8 @@ const styles = StyleSheet.create({
alignItems: 'center', alignItems: 'center',
}, },
joinButtonGlass: { joinButtonGlass: {
paddingHorizontal: 16, paddingHorizontal: 14,
paddingVertical: 10, paddingVertical: 8,
borderRadius: 16, borderRadius: 16,
minWidth: 70, minWidth: 70,
alignItems: 'center', alignItems: 'center',
@@ -629,7 +629,7 @@ const styles = StyleSheet.create({
borderColor: 'rgba(255,255,255,0.45)', borderColor: 'rgba(255,255,255,0.45)',
}, },
joinButtonLabel: { joinButtonLabel: {
fontSize: 14, fontSize: 12,
fontWeight: '700', fontWeight: '700',
color: '#0f1528', color: '#0f1528',
letterSpacing: 0.5, letterSpacing: 0.5,
@@ -639,8 +639,8 @@ const styles = StyleSheet.create({
backgroundColor: 'rgba(255,255,255,0.7)', backgroundColor: 'rgba(255,255,255,0.7)',
}, },
createButton: { createButton: {
width: 40, width: 36,
height: 40, height: 36,
borderRadius: 20, borderRadius: 20,
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',

View File

@@ -4,7 +4,9 @@ import { MedicationCard } from '@/components/medication/MedicationCard';
import { TakenMedicationsStack } from '@/components/medication/TakenMedicationsStack'; import { TakenMedicationsStack } from '@/components/medication/TakenMedicationsStack';
import { ThemedText } from '@/components/ThemedText'; import { ThemedText } from '@/components/ThemedText';
import { IconSymbol } from '@/components/ui/IconSymbol'; import { IconSymbol } from '@/components/ui/IconSymbol';
import { Image } from '@/components/ui/Image';
import { MedicalDisclaimerSheet } from '@/components/ui/MedicalDisclaimerSheet'; import { MedicalDisclaimerSheet } from '@/components/ui/MedicalDisclaimerSheet';
import { MedicationAiSummaryInfoSheet } from '@/components/ui/MedicationAiSummaryInfoSheet';
import { Colors } from '@/constants/Colors'; import { Colors } from '@/constants/Colors';
import { useMembershipModal } from '@/contexts/MembershipModalContext'; import { useMembershipModal } from '@/contexts/MembershipModalContext';
import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useAppDispatch, useAppSelector } from '@/hooks/redux';
@@ -19,7 +21,6 @@ import { useFocusEffect } from '@react-navigation/native';
import dayjs, { Dayjs } from 'dayjs'; import dayjs, { Dayjs } from 'dayjs';
import 'dayjs/locale/zh-cn'; import 'dayjs/locale/zh-cn';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect'; import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import { router } from 'expo-router'; import { router } from 'expo-router';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
@@ -59,6 +60,7 @@ export default function MedicationsScreen() {
const [isCelebrationVisible, setIsCelebrationVisible] = useState(false); const [isCelebrationVisible, setIsCelebrationVisible] = useState(false);
const [disclaimerVisible, setDisclaimerVisible] = useState(false); const [disclaimerVisible, setDisclaimerVisible] = useState(false);
const [pendingAction, setPendingAction] = useState<'manual' | null>(null); const [pendingAction, setPendingAction] = useState<'manual' | null>(null);
const [aiSummaryInfoVisible, setAiSummaryInfoVisible] = useState(false);
// 从 Redux 获取数据 // 从 Redux 获取数据
const selectedKey = selectedDate.format('YYYY-MM-DD'); const selectedKey = selectedDate.format('YYYY-MM-DD');
@@ -115,6 +117,33 @@ export default function MedicationsScreen() {
setPendingAction(null); setPendingAction(null);
}, []); }, []);
const handleOpenAiSummary = useCallback(async () => {
// 先检查登录状态
const isLoggedIn = await ensureLoggedIn();
if (!isLoggedIn) return;
// 检查 VIP 权限
const access = checkServiceAccess();
if (!access.canUseService) {
// 非会员显示介绍弹窗
setAiSummaryInfoVisible(true);
return;
}
// 会员直接跳转到 AI 总结页面
router.push('/medications/ai-summary');
}, [checkServiceAccess, ensureLoggedIn]);
const handleAiSummaryInfoConfirm = useCallback(() => {
setAiSummaryInfoVisible(false);
// 点击"我要订阅"后,弹出会员订阅弹窗
openMembershipModal();
}, [openMembershipModal]);
const handleAiSummaryInfoClose = useCallback(() => {
setAiSummaryInfoVisible(false);
}, []);
const handleOpenMedicationManagement = useCallback(() => { const handleOpenMedicationManagement = useCallback(() => {
router.push('/medications/manage-medications'); router.push('/medications/manage-medications');
}, []); }, []);
@@ -285,31 +314,59 @@ export default function MedicationsScreen() {
</ThemedText> </ThemedText>
</View> </View>
<View style={styles.headerActions}> <View style={styles.headerActions}>
<TouchableOpacity {isLiquidGlassAvailable() ? (
activeOpacity={0.7} <TouchableOpacity
onPress={handleOpenMedicationManagement} activeOpacity={0.7}
> onPress={handleOpenAiSummary}
{isLiquidGlassAvailable() ? ( >
<GlassView <GlassView
style={styles.headerAddButton} style={styles.headerAddButton}
glassEffectStyle="clear" glassEffectStyle="clear"
tintColor="rgba(255, 255, 255, 0.3)" tintColor="rgba(255, 255, 255, 0.36)"
isInteractive={true} isInteractive={true}
> >
<IconSymbol name="pills.fill" size={18} color="#333" /> <IconSymbol name="sparkles" size={18} color="#333" />
</GlassView> </GlassView>
) : ( </TouchableOpacity>
<View style={[styles.headerAddButton, styles.fallbackAddButton]}> ) : (
<IconSymbol name="pills.fill" size={18} color="#333" /> <TouchableOpacity
</View> activeOpacity={0.7}
)} onPress={handleOpenAiSummary}
</TouchableOpacity> style={[styles.headerAddButton, styles.fallbackAddButton]}
>
<IconSymbol name="sparkles" size={18} color="#333" />
</TouchableOpacity>
)}
<TouchableOpacity {isLiquidGlassAvailable() ? (
activeOpacity={0.7} <TouchableOpacity
onPress={handleAddMedication} activeOpacity={0.7}
> onPress={handleOpenMedicationManagement}
{isLiquidGlassAvailable() ? ( >
<GlassView
style={styles.headerAddButton}
glassEffectStyle="clear"
tintColor="rgba(255, 255, 255, 0.3)"
isInteractive={true}
>
<IconSymbol name="pills.fill" size={18} color="#333" />
</GlassView>
</TouchableOpacity>
) : (
<TouchableOpacity
activeOpacity={0.7}
onPress={handleOpenMedicationManagement}
style={[styles.headerAddButton, styles.fallbackAddButton]}
>
<IconSymbol name="pills.fill" size={18} color="#333" />
</TouchableOpacity>
)}
{isLiquidGlassAvailable() ? (
<TouchableOpacity
activeOpacity={0.7}
onPress={handleAddMedication}
>
<GlassView <GlassView
style={styles.headerAddButton} style={styles.headerAddButton}
glassEffectStyle="clear" glassEffectStyle="clear"
@@ -318,12 +375,16 @@ export default function MedicationsScreen() {
> >
<IconSymbol name="plus" size={18} color="#333" /> <IconSymbol name="plus" size={18} color="#333" />
</GlassView> </GlassView>
) : ( </TouchableOpacity>
<View style={[styles.headerAddButton, styles.fallbackAddButton]}> ) : (
<IconSymbol name="plus" size={18} color="#333" /> <TouchableOpacity
</View> activeOpacity={0.7}
)} onPress={handleAddMedication}
</TouchableOpacity> style={[styles.headerAddButton, styles.fallbackAddButton]}
>
<IconSymbol name="plus" size={18} color="#333" />
</TouchableOpacity>
)}
</View> </View>
</View> </View>
@@ -430,6 +491,13 @@ export default function MedicationsScreen() {
onClose={handleDisclaimerClose} onClose={handleDisclaimerClose}
onConfirm={handleDisclaimerConfirm} onConfirm={handleDisclaimerConfirm}
/> />
{/* AI 用药总结介绍弹窗 */}
<MedicationAiSummaryInfoSheet
visible={aiSummaryInfoVisible}
onClose={handleAiSummaryInfoClose}
onConfirm={handleAiSummaryInfoConfirm}
/>
</View> </View>
); );
} }
@@ -498,12 +566,14 @@ const styles = StyleSheet.create({
borderRadius: 30, borderRadius: 30,
}, },
greeting: { greeting: {
fontSize: 24, fontSize: 20,
fontWeight: '600', fontWeight: '600',
fontFamily: 'AliBold',
}, },
welcome: { welcome: {
marginTop: 6, marginTop: 6,
fontSize: 14, fontSize: 14,
fontFamily: 'AliRegular',
}, },
sectionSpacing: { sectionSpacing: {
gap: 16, gap: 16,
@@ -514,10 +584,12 @@ const styles = StyleSheet.create({
sectionTitle: { sectionTitle: {
fontSize: 16, fontSize: 16,
fontWeight: '500', fontWeight: '500',
fontFamily: 'AliBold',
}, },
sectionHeader: { sectionHeader: {
fontSize: 20, fontSize: 20,
fontWeight: '600', fontWeight: '600',
fontFamily: 'AliBold',
}, },
segmentedControl: { segmentedControl: {
flexDirection: 'row', flexDirection: 'row',
@@ -537,6 +609,7 @@ const styles = StyleSheet.create({
segmentLabel: { segmentLabel: {
fontSize: 14, fontSize: 14,
fontWeight: '600', fontWeight: '600',
fontFamily: 'AliBold',
}, },
segmentBadge: { segmentBadge: {
minWidth: 24, minWidth: 24,
@@ -549,6 +622,7 @@ const styles = StyleSheet.create({
segmentBadgeText: { segmentBadgeText: {
fontSize: 12, fontSize: 12,
fontWeight: '600', fontWeight: '600',
fontFamily: 'AliBold',
}, },
emptyState: { emptyState: {
alignItems: 'center', alignItems: 'center',
@@ -566,11 +640,13 @@ const styles = StyleSheet.create({
textAlign: 'center', textAlign: 'center',
fontSize: 18, fontSize: 18,
fontWeight: '600', fontWeight: '600',
fontFamily: 'AliBold',
}, },
emptySubtitle: { emptySubtitle: {
textAlign: 'center', textAlign: 'center',
fontSize: 14, fontSize: 14,
lineHeight: 20, lineHeight: 20,
fontFamily: 'AliRegular',
}, },
primaryButton: { primaryButton: {
marginTop: 8, marginTop: 8,
@@ -584,6 +660,7 @@ const styles = StyleSheet.create({
primaryButtonText: { primaryButtonText: {
fontSize: 16, fontSize: 16,
fontWeight: '600', fontWeight: '600',
fontFamily: 'AliBold',
}, },
cardsWrapper: { cardsWrapper: {
gap: 16, gap: 16,
@@ -597,5 +674,6 @@ const styles = StyleSheet.create({
}, },
loadingText: { loadingText: {
fontSize: 14, fontSize: 14,
fontFamily: 'AliRegular',
}, },
}); });

View File

@@ -1,14 +1,21 @@
import ActivityHeatMap from '@/components/ActivityHeatMap'; import ActivityHeatMap from '@/components/ActivityHeatMap';
import { BadgeShowcaseModal } from '@/components/badges/BadgeShowcaseModal'; import { BadgeShowcaseModal } from '@/components/badges/BadgeShowcaseModal';
import { MembershipBanner } from '@/components/MembershipBanner';
import { PRIVACY_POLICY_URL, USER_AGREEMENT_URL } from '@/constants/Agree'; import { PRIVACY_POLICY_URL, USER_AGREEMENT_URL } from '@/constants/Agree';
import { palette } from '@/constants/Colors'; import { Colors } from '@/constants/Colors';
import { ROUTES } from '@/constants/Routes'; import { ROUTES } from '@/constants/Routes';
import { getTabBarBottomPadding } from '@/constants/TabBar'; import { getTabBarBottomPadding } from '@/constants/TabBar';
import { useMembershipModal } from '@/contexts/MembershipModalContext'; import { useMembershipModal } from '@/contexts/MembershipModalContext';
import { useVersionCheck } from '@/contexts/VersionCheckContext';
import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useAuthGuard } from '@/hooks/useAuthGuard'; import { useAuthGuard } from '@/hooks/useAuthGuard';
import { useColorScheme } from '@/hooks/useColorScheme';
import { Image } from '@/components/ui/Image';
import type { BadgeDto } from '@/services/badges'; import type { BadgeDto } from '@/services/badges';
import { reportBadgeShowcaseDisplayed } from '@/services/badges'; import { reportBadgeShowcaseDisplayed } from '@/services/badges';
import { updateUser, type UserLanguage } from '@/services/users';
import { getCurrentAppVersion } from '@/services/version';
import { fetchAvailableBadges, selectBadgeCounts, selectBadgePreview, selectSortedBadges } from '@/store/badgesSlice'; import { fetchAvailableBadges, selectBadgeCounts, selectBadgePreview, selectSortedBadges } from '@/store/badgesSlice';
import { selectActiveMembershipPlanName } from '@/store/membershipSlice'; import { selectActiveMembershipPlanName } from '@/store/membershipSlice';
import { DEFAULT_MEMBER_NAME, fetchActivityHistory, fetchMyProfile } from '@/store/userSlice'; import { DEFAULT_MEMBER_NAME, fetchActivityHistory, fetchMyProfile } from '@/store/userSlice';
@@ -18,7 +25,6 @@ import { Ionicons } from '@expo/vector-icons';
import { useFocusEffect } from '@react-navigation/native'; import { useFocusEffect } from '@react-navigation/native';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect'; import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
@@ -53,6 +59,8 @@ type LanguageOption = {
}; };
export default function PersonalScreen() { export default function PersonalScreen() {
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme];
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { confirmLogout, confirmDeleteAccount, isLoggedIn, pushIfAuthedElseLogin, ensureLoggedIn } = useAuthGuard(); const { confirmLogout, confirmDeleteAccount, isLoggedIn, pushIfAuthedElseLogin, ensureLoggedIn } = useAuthGuard();
const { openMembershipModal } = useMembershipModal(); const { openMembershipModal } = useMembershipModal();
@@ -65,6 +73,12 @@ export default function PersonalScreen() {
const [languageModalVisible, setLanguageModalVisible] = useState(false); const [languageModalVisible, setLanguageModalVisible] = useState(false);
const [isSwitchingLanguage, setIsSwitchingLanguage] = useState(false); const [isSwitchingLanguage, setIsSwitchingLanguage] = useState(false);
const [refreshing, setRefreshing] = useState(false); 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[]>(() => ([ const languageOptions = useMemo<LanguageOption[]>(() => ([
{ {
@@ -81,6 +95,16 @@ export default function PersonalScreen() {
const activeLanguageCode = getNormalizedLanguage(i18n.language); const activeLanguageCode = getNormalizedLanguage(i18n.language);
const activeLanguageLabel = languageOptions.find((option) => option.code === activeLanguageCode)?.label || ''; const activeLanguageLabel = languageOptions.find((option) => option.code === activeLanguageCode)?.label || '';
const currentAppVersion = useMemo(() => getCurrentAppVersion(), []);
const versionRightText = useMemo(() => {
if (isCheckingVersion) {
return t('personal.versionCheck.checking');
}
if (updateInfo?.needsUpdate) {
return t('personal.versionCheck.updateBadge', { version: updateInfo.latestVersion });
}
return `v${currentAppVersion}`;
}, [currentAppVersion, isCheckingVersion, t, updateInfo?.latestVersion, updateInfo?.needsUpdate]);
const handleLanguageSelect = useCallback(async (language: AppLanguage) => { const handleLanguageSelect = useCallback(async (language: AppLanguage) => {
setLanguageModalVisible(false); setLanguageModalVisible(false);
@@ -89,13 +113,33 @@ export default function PersonalScreen() {
} }
try { try {
setIsSwitchingLanguage(true); setIsSwitchingLanguage(true);
// 将 AppLanguage ('zh' | 'en') 映射到 UserLanguage ('zh-CN' | 'en-US')
const languageMap: Record<AppLanguage, UserLanguage> = {
'zh': 'zh-CN',
'en': 'en-US',
};
const userLanguage = languageMap[language];
// 先切换本地语言
await changeAppLanguage(language); await changeAppLanguage(language);
// 如果用户已登录,同步更新服务器语言设置
if (isLoggedIn) {
try {
await updateUser({ language: userLanguage });
log.info('语言设置已同步到服务器', { language: userLanguage });
} catch (error) {
log.warn('同步语言设置到服务器失败', error);
// 服务器更新失败不影响本地语言切换,静默处理
}
}
} catch (error) { } catch (error) {
log.warn('语言切换失败', error); log.warn('语言切换失败', error);
} finally { } finally {
setIsSwitchingLanguage(false); setIsSwitchingLanguage(false);
} }
}, [activeLanguageCode, isSwitchingLanguage]); }, [activeLanguageCode, isSwitchingLanguage, isLoggedIn]);
// 推送通知设置仅在独立页面管理 // 推送通知设置仅在独立页面管理
@@ -226,25 +270,6 @@ export default function PersonalScreen() {
} }
}; };
// 数据格式化函数
const formatHeight = () => {
if (userProfile.height == null) return '--';
return `${parseFloat(userProfile.height).toFixed(1)}cm`;
};
const formatWeight = () => {
if (userProfile.weight == null) return '--';
return `${parseFloat(userProfile.weight).toFixed(1)}kg`;
};
const formatAge = () => {
if (!userProfile.birthDate) return '--';
const birthDate = new Date(userProfile.birthDate);
const today = new Date();
const age = today.getFullYear() - birthDate.getFullYear();
return `${age}${t('personal.stats.ageSuffix')}`;
};
// 显示名称 // 显示名称
const displayName = (userProfile.name?.trim()) ? userProfile.name : DEFAULT_MEMBER_NAME; const displayName = (userProfile.name?.trim()) ? userProfile.name : DEFAULT_MEMBER_NAME;
const profileActionLabel = isLoggedIn ? t('personal.edit') : t('personal.login'); const profileActionLabel = isLoggedIn ? t('personal.edit') : t('personal.login');
@@ -335,25 +360,6 @@ export default function PersonalScreen() {
</View> </View>
); );
const MembershipBanner = () => (
<View style={styles.sectionContainer}>
<TouchableOpacity
activeOpacity={0.9}
onPress={() => {
void handleMembershipPress();
}}
>
<Image
source={{ uri: 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/banner/vip2.png' }}
style={styles.membershipBannerImage}
contentFit="cover"
transition={200}
cachePolicy="memory-disk"
/>
</TouchableOpacity>
</View>
);
const VipMembershipCard = () => { const VipMembershipCard = () => {
const fallbackProfile = userProfile as Record<string, unknown>; const fallbackProfile = userProfile as Record<string, unknown>;
const fallbackExpire = ['membershipExpiration', 'vipExpiredAt', 'vipExpiresAt', 'vipExpireDate'] const fallbackExpire = ['membershipExpiration', 'vipExpiredAt', 'vipExpiresAt', 'vipExpireDate']
@@ -420,27 +426,33 @@ export default function PersonalScreen() {
); );
}; };
// 数据统计部分 // 健康档案入口组件
const StatsSection = () => ( const HealthProfileEntry = () => (
<View style={styles.sectionContainer}> <View style={styles.sectionContainer}>
<View style={[styles.cardContainer, { <TouchableOpacity
backgroundColor: 'transparent' style={styles.healthProfileCard}
}]}> activeOpacity={0.9}
<View style={styles.statsContainer}> onPress={() => router.push(ROUTES.HEALTH_PROFILE)}
<View style={styles.statItem}> >
<Text style={styles.statValue}>{formatHeight()}</Text> <LinearGradient
<Text style={styles.statLabel}>{t('personal.stats.height')}</Text> colors={['#FFFFFF', '#F0F4FF']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.healthProfileGradient}
>
<View style={styles.healthProfileContent}>
<View style={styles.healthProfileLeft}>
<View style={styles.healthProfileTitleRow}>
<Text style={styles.healthProfileTitle}>{t('personal.healthProfile.title') || '健康档案'}</Text>
</View>
<Text style={styles.healthProfileSubtitle}>{t('personal.healthProfile.subtitle') || '管理您的个人健康数据与家庭档案'}</Text>
</View>
<View style={styles.healthProfileRight}>
<Ionicons name="chevron-forward" size={20} color="#9CA3AF" />
</View>
</View> </View>
<View style={styles.statItem}> </LinearGradient>
<Text style={styles.statValue}>{formatWeight()}</Text> </TouchableOpacity>
<Text style={styles.statLabel}>{t('personal.stats.weight')}</Text>
</View>
<View style={styles.statItem}>
<Text style={styles.statValue}>{formatAge()}</Text>
<Text style={styles.statLabel}>{t('personal.stats.age')}</Text>
</View>
</View>
</View>
</View> </View>
); );
@@ -635,6 +647,19 @@ export default function PersonalScreen() {
}, },
], ],
}, },
{
title: t('personal.versionCheck.sectionTitle'),
items: [
{
icon: 'cloud-download-outline' as React.ComponentProps<typeof Ionicons>['name'],
title: t('personal.versionCheck.menuTitle'),
onPress: () => {
void checkForUpdate({ manual: true });
},
rightText: versionRightText,
},
],
},
// 开发者section需要连续点击三次用户名激活 // 开发者section需要连续点击三次用户名激活
...(showDeveloperSection ? [{ ...(showDeveloperSection ? [{
title: t('personal.sections.developer'), title: t('personal.sections.developer'),
@@ -746,15 +771,13 @@ export default function PersonalScreen() {
); );
return ( return (
<View style={styles.container}> <View style={[styles.container, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}>
<StatusBar barStyle={'dark-content'} backgroundColor="transparent" translucent /> <StatusBar barStyle={theme === 'dark' ? 'light-content' : 'dark-content'} backgroundColor="transparent" translucent />
{/* 背景渐变 */} {/* 背景渐变 */}
<LinearGradient <LinearGradient
colors={[palette.purple[100], '#F5F5F5']} colors={gradientColors}
start={{ x: 1, y: 0 }} style={StyleSheet.absoluteFillObject}
end={{ x: 0.3, y: 0.4 }}
style={styles.gradientBackground}
/> />
<ScrollView <ScrollView
@@ -776,8 +799,8 @@ export default function PersonalScreen() {
} }
> >
<UserHeader /> <UserHeader />
{userProfile.isVip ? <VipMembershipCard /> : <MembershipBanner />} {userProfile.isVip ? <VipMembershipCard /> : <MembershipBanner onPress={() => void handleMembershipPress()} />}
<StatsSection /> <HealthProfileEntry />
<BadgesPreviewSection /> <BadgesPreviewSection />
<View style={styles.fishRecordContainer}> <View style={styles.fishRecordContainer}>
{/* <Image {/* <Image
@@ -808,14 +831,6 @@ export default function PersonalScreen() {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
backgroundColor: '#F5F5F5',
},
gradientBackground: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
height: '60%',
}, },
scrollView: { scrollView: {
flex: 1, flex: 1,
@@ -842,11 +857,6 @@ const styles = StyleSheet.create({
elevation: 2, elevation: 2,
overflow: 'hidden', overflow: 'hidden',
}, },
membershipBannerImage: {
width: '100%',
height: 180,
borderRadius: 16,
},
vipCard: { vipCard: {
borderRadius: 20, borderRadius: 20,
padding: 20, padding: 20,
@@ -981,16 +991,20 @@ const styles = StyleSheet.create({
fontWeight: 'bold', fontWeight: 'bold',
color: '#2C3E50', color: '#2C3E50',
marginBottom: 4, marginBottom: 4,
fontFamily: 'AliBold',
}, },
userRole: { userRole: {
fontSize: 14, fontSize: 14,
color: '#9370DB', color: '#9370DB',
fontWeight: '500', fontWeight: '500',
fontFamily: 'AliBold',
}, },
userMemberNumber: { userMemberNumber: {
fontSize: 10, fontSize: 10,
color: '#6C757D', color: '#6C757D',
marginTop: 4, marginTop: 4,
fontFamily: 'AliRegular',
}, },
aiUsageContainer: { aiUsageContainer: {
flexDirection: 'row', flexDirection: 'row',
@@ -1002,6 +1016,7 @@ const styles = StyleSheet.create({
color: '#9370DB', color: '#9370DB',
marginLeft: 2, marginLeft: 2,
fontWeight: '500', fontWeight: '500',
fontFamily: 'AliRegular',
}, },
editButton: { editButton: {
backgroundColor: '#9370DB', backgroundColor: '#9370DB',
@@ -1020,6 +1035,7 @@ const styles = StyleSheet.create({
color: 'white', color: 'white',
fontSize: 14, fontSize: 14,
fontWeight: '600', fontWeight: '600',
fontFamily: 'AliBold',
}, },
editButtonTextGlass: { editButtonTextGlass: {
color: 'rgba(147, 112, 219, 1)', color: 'rgba(147, 112, 219, 1)',
@@ -1041,11 +1057,13 @@ const styles = StyleSheet.create({
fontWeight: 'bold', fontWeight: 'bold',
color: '#9370DB', color: '#9370DB',
marginBottom: 4, marginBottom: 4,
fontFamily: 'AliBold',
}, },
statLabel: { statLabel: {
fontSize: 12, fontSize: 12,
color: '#6C757D', color: '#6C757D',
fontWeight: '500', fontWeight: '500',
fontFamily: 'AliRegular',
}, },
badgesRowCard: { badgesRowCard: {
flexDirection: 'row', flexDirection: 'row',
@@ -1065,6 +1083,7 @@ const styles = StyleSheet.create({
fontSize: 16, fontSize: 16,
fontWeight: '700', fontWeight: '700',
color: '#111827', color: '#111827',
fontFamily: 'AliBold',
}, },
badgesRowContent: { badgesRowContent: {
flexDirection: 'row', flexDirection: 'row',
@@ -1103,6 +1122,7 @@ const styles = StyleSheet.create({
fontSize: 18, fontSize: 18,
fontWeight: '600', fontWeight: '600',
color: '#475467', color: '#475467',
fontFamily: 'AliBold',
}, },
badgeCompactOverlay: { badgeCompactOverlay: {
...StyleSheet.absoluteFillObject, ...StyleSheet.absoluteFillObject,
@@ -1122,11 +1142,14 @@ const styles = StyleSheet.create({
fontSize: 14, fontSize: 14,
fontWeight: '700', fontWeight: '700',
color: '#5B21B6', color: '#5B21B6',
fontFamily: 'AliRegular',
}, },
badgesRowEmpty: { badgesRowEmpty: {
fontSize: 13, fontSize: 13,
color: '#6B7280', color: '#6B7280',
fontWeight: '500', fontWeight: '500',
fontFamily: 'AliBold',
}, },
// 菜单项 // 菜单项
menuItem: { menuItem: {
@@ -1151,6 +1174,7 @@ const styles = StyleSheet.create({
fontSize: 13, fontSize: 13,
color: '#6C757D', color: '#6C757D',
marginRight: 6, marginRight: 6,
fontFamily: 'AliRegular',
}, },
iconContainer: { iconContainer: {
width: 32, width: 32,
@@ -1179,6 +1203,7 @@ const styles = StyleSheet.create({
fontWeight: 'bold', fontWeight: 'bold',
color: '#2C3E50', color: '#2C3E50',
marginLeft: 4, marginLeft: 4,
fontFamily: 'AliBold',
}, },
languageModalOverlay: { languageModalOverlay: {
flex: 1, flex: 1,
@@ -1204,11 +1229,13 @@ const styles = StyleSheet.create({
fontSize: 18, fontSize: 18,
fontWeight: 'bold', fontWeight: 'bold',
color: '#2C3E50', color: '#2C3E50',
fontFamily: 'AliBold',
}, },
languageModalSubtitle: { languageModalSubtitle: {
fontSize: 13, fontSize: 13,
color: '#6C757D', color: '#6C757D',
marginBottom: 4, marginBottom: 4,
fontFamily: 'AliRegular',
}, },
languageOption: { languageOption: {
flexDirection: 'row', flexDirection: 'row',
@@ -1233,11 +1260,13 @@ const styles = StyleSheet.create({
fontSize: 16, fontSize: 16,
fontWeight: '600', fontWeight: '600',
color: '#2C3E50', color: '#2C3E50',
fontFamily: 'AliBold',
}, },
languageOptionDescription: { languageOptionDescription: {
fontSize: 12, fontSize: 12,
color: '#6C757D', color: '#6C757D',
marginTop: 4, marginTop: 4,
fontFamily: 'AliRegular',
}, },
languageModalClose: { languageModalClose: {
marginTop: 4, marginTop: 4,
@@ -1247,5 +1276,62 @@ const styles = StyleSheet.create({
fontSize: 15, fontSize: 15,
fontWeight: '500', fontWeight: '500',
color: '#9370DB', 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,13 @@
import { BasalMetabolismCard } from '@/components/BasalMetabolismCard'; import { BasalMetabolismCard } from '@/components/BasalMetabolismCard';
import { DateSelector } from '@/components/DateSelector'; import { DateSelector } from '@/components/DateSelector';
import { FitnessRingsCard } from '@/components/FitnessRingsCard'; import { FitnessRingsCard } from '@/components/FitnessRingsCard';
import { MenstrualCycleCard } from '@/components/MenstrualCycleCard';
import { MoodCard } from '@/components/MoodCard'; import { MoodCard } from '@/components/MoodCard';
import { NutritionRadarCard } from '@/components/NutritionRadarCard'; import { NutritionRadarCard } from '@/components/NutritionRadarCard';
import CircumferenceCard from '@/components/statistic/CircumferenceCard'; import CircumferenceCard from '@/components/statistic/CircumferenceCard';
import OxygenSaturationCard from '@/components/statistic/OxygenSaturationCard'; import OxygenSaturationCard from '@/components/statistic/OxygenSaturationCard';
import SleepCard from '@/components/statistic/SleepCard'; import SleepCard from '@/components/statistic/SleepCard';
import WristTemperatureCard from '@/components/statistic/WristTemperatureCard';
import StepsCard from '@/components/StepsCard'; import StepsCard from '@/components/StepsCard';
import { StressMeter } from '@/components/StressMeter'; import { StressMeter } from '@/components/StressMeter';
import WaterIntakeCard from '@/components/WaterIntakeCard'; import WaterIntakeCard from '@/components/WaterIntakeCard';
@@ -14,19 +16,22 @@ import { WorkoutSummaryCard } from '@/components/WorkoutSummaryCard';
import { Colors } from '@/constants/Colors'; import { Colors } from '@/constants/Colors';
import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useAuthGuard } from '@/hooks/useAuthGuard'; import { useAuthGuard } from '@/hooks/useAuthGuard';
import { BackgroundTaskManager } from '@/services/backgroundTaskManagerV2'; import { syncDailyHealthReport, syncHealthKitToServer } from '@/services/healthKitSync';
import { syncHealthKitToServer } from '@/services/healthKitSync';
import { setHealthData } from '@/store/healthSlice'; import { setHealthData } from '@/store/healthSlice';
import { fetchDailyMoodCheckins, selectLatestMoodRecordByDate } from '@/store/moodSlice'; import { fetchDailyMoodCheckins, selectLatestMoodRecordByDate } from '@/store/moodSlice';
import { updateUserProfile } from '@/store/userSlice'; import { updateUserProfile } from '@/store/userSlice';
import { fetchTodayWaterStats } from '@/store/waterSlice'; import { fetchTodayWaterStats } from '@/store/waterSlice';
import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date'; import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date';
import { fetchHealthDataForDate, testHRVDataFetch } from '@/utils/health'; import { fetchHealthDataForDate } from '@/utils/health';
import { logger } from '@/utils/logger'; import { logger } from '@/utils/logger';
import { DEFAULT_CARD_ORDER, getStatisticsCardOrder, getStatisticsCardsVisibility, StatisticsCardsVisibility } from '@/utils/userPreferences';
import { Ionicons } from '@expo/vector-icons';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import { useFocusEffect, useRouter } from 'expo-router';
import { debounce } from 'lodash'; import { debounce } from 'lodash';
import React, { useEffect, useMemo, useRef, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
AppState, AppState,
@@ -62,9 +67,11 @@ export default function ExploreScreen() {
const { t } = useTranslation(); const { t } = useTranslation();
const stepGoal = useAppSelector((s) => s.user.profile?.dailyStepsGoal) ?? 2000; const stepGoal = useAppSelector((s) => s.user.profile?.dailyStepsGoal) ?? 2000;
const userProfile = useAppSelector((s) => s.user.profile); const userProfile = useAppSelector((s) => s.user.profile);
const todayWaterStats = useAppSelector((s) => s.water.todayStats);
const { pushIfAuthedElseLogin, isLoggedIn } = useAuthGuard();
const { pushIfAuthedElseLogin, isLoggedIn, ensureLoggedIn } = useAuthGuard();
const router = useRouter();
// 使用 dayjs当月日期与默认选中"今天" // 使用 dayjs当月日期与默认选中"今天"
const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth()); const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth());
@@ -80,11 +87,56 @@ export default function ExploreScreen() {
return dayjs(currentSelectedDate).format('YYYY-MM-DD'); return dayjs(currentSelectedDate).format('YYYY-MM-DD');
}, [currentSelectedDate]); }, [currentSelectedDate]);
const handleOpenGallery = React.useCallback(async () => {
const ok = await ensureLoggedIn();
if (!ok) return;
router.push('/gallery');
}, [ensureLoggedIn, router]);
const handleOpenCustomization = React.useCallback(() => {
router.push('/statistics-customization');
}, [router]);
// 用于触发动画重置的 token当日期或数据变化时更新 // 用于触发动画重置的 token当日期或数据变化时更新
const [animToken, setAnimToken] = useState(0); const [animToken, setAnimToken] = useState(0);
// 首页卡片显示设置
const [cardVisibility, setCardVisibility] = useState<StatisticsCardsVisibility>({
showMood: true,
showSteps: true,
showStress: true,
showSleep: true,
showFitnessRings: true,
showWater: true,
showBasalMetabolism: true,
showOxygenSaturation: true,
showWristTemperature: true,
showMenstrualCycle: true,
showWeight: true,
showCircumference: true,
});
const [cardOrder, setCardOrder] = useState<string[]>(DEFAULT_CARD_ORDER);
// 加载卡片设置
const loadSettings = useCallback(async () => {
try {
const [visibility, order] = await Promise.all([
getStatisticsCardsVisibility(),
getStatisticsCardOrder(),
]);
setCardVisibility(visibility);
setCardOrder(order);
} catch (error) {
console.error('Failed to load card settings:', error);
}
}, []);
// 页面聚焦时加载设置
useFocusEffect(
useCallback(() => {
loadSettings();
}, [loadSettings])
);
// 心情相关状态 // 心情相关状态
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
@@ -287,6 +339,7 @@ export default function ExploreScreen() {
try { try {
logger.info('开始同步 HealthKit 个人健康数据到服务端...'); logger.info('开始同步 HealthKit 个人健康数据到服务端...');
// 1. 同步个人资料 (身高、体重、出生日期)
// 传入当前用户资料,用于 diff 比较 // 传入当前用户资料,用于 diff 比较
const success = await syncHealthKitToServer( const success = await syncHealthKitToServer(
async (data) => { async (data) => {
@@ -296,20 +349,36 @@ export default function ExploreScreen() {
); );
if (success) { if (success) {
logger.info('HealthKit 数据同步到服务端成功'); logger.info('HealthKit 个人资料同步到服务端成功');
} else { } else {
logger.info('HealthKit 数据同步到服务端跳过(无变化)或失败'); logger.info('HealthKit 个人资料同步到服务端跳过(无变化)或失败');
} }
// 2. 同步每日健康数据报表 (活动、睡眠、心率等)
// 传入今日饮水量
const waterIntake = todayWaterStats?.totalAmount;
logger.info('开始同步每日健康数据报表...', { waterIntake });
const reportSuccess = await syncDailyHealthReport(waterIntake);
if (reportSuccess) {
logger.info('每日健康数据报表同步成功');
} else {
logger.info('每日健康数据报表同步跳过(无变化)或失败');
}
} catch (error) { } catch (error) {
logger.error('同步 HealthKit 数据到服务端失败:', error); logger.error('同步 HealthKit 数据到服务端失败:', error);
} }
}, [isLoggedIn, dispatch, userProfile]); }, [isLoggedIn, dispatch, userProfile, todayWaterStats]);
// 初始加载时执行数据加载和同步 // 初始加载时执行数据加载和同步
useEffect(() => { useEffect(() => {
loadAllData(currentSelectedDate); loadAllData(currentSelectedDate);
// 延迟1秒后执行同步避免影响初始加载性能 // 延迟1秒后执行同步避免影响初始加载性能
// 如果 todayWaterStats 还未加载完成,可能会导致第一次同步时 waterIntake 为 undefined
// 但 waterSlice.fetchTodayWaterStats 会在 loadAllData 中被调用
const syncTimer = setTimeout(() => { const syncTimer = setTimeout(() => {
syncHealthDataToServer(); syncHealthDataToServer();
}, 1000); }, 1000);
@@ -376,7 +445,7 @@ export default function ExploreScreen() {
style={styles.scrollView} style={styles.scrollView}
contentContainerStyle={{ contentContainerStyle={{
paddingTop: insets.top, paddingTop: insets.top,
paddingBottom: 60, paddingBottom: 100,
paddingHorizontal: 20 paddingHorizontal: 20
}} }}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
@@ -384,42 +453,61 @@ export default function ExploreScreen() {
{/* 顶部信息栏 */} {/* 顶部信息栏 */}
<View style={styles.headerContainer}> <View style={styles.headerContainer}>
<View style={styles.headerContent}> <View style={styles.headerContent}>
{/* 左边logo */} <View style={styles.headerLeft}>
<Image <Image
source={require('@/assets/machine.png')} source={require('@/assets/machine.png')}
style={styles.logoImage} style={styles.logoImage}
resizeMode="cover" resizeMode="cover"
/> />
{/* 右边文字区域 */} {/* 右边文字区域 */}
<View style={styles.headerTextContainer}> <View style={styles.headerTextContainer}>
<Text style={styles.headerTitle}>{t('statistics.title')}</Text> <Text style={styles.headerTitle}>{t('statistics.title')}</Text>
</View>
</View> </View>
{/* 开发环境调试按钮 */} <View style={styles.headerActions}>
{__DEV__ && ( <TouchableOpacity
<View style={styles.debugButtonsContainer}> activeOpacity={0.85}
<TouchableOpacity onPress={handleOpenCustomization}
style={styles.debugButton} >
onPress={async () => { {isLiquidGlassAvailable() ? (
console.log('🔧 Manual background task test...'); <GlassView
await BackgroundTaskManager.getInstance().triggerTaskForTesting(); style={styles.liquidGlassButton}
}} glassEffectStyle="regular"
> tintColor="rgba(255, 255, 255, 0.3)"
<Text style={styles.debugButtonText}>🔧</Text> isInteractive={true}
</TouchableOpacity> >
<Ionicons name="options-outline" size={20} color="#0F172A" />
</GlassView>
) : (
<View style={[styles.liquidGlassButton, styles.liquidGlassFallback]}>
<Ionicons name="options-outline" size={20} color="#0F172A" />
</View>
)}
</TouchableOpacity>
<TouchableOpacity <TouchableOpacity
style={[styles.debugButton, styles.hrvTestButton]} activeOpacity={0.85}
onPress={async () => { onPress={handleOpenGallery}
console.log('🫀 Testing HRV data fetch...'); >
await testHRVDataFetch(); {isLiquidGlassAvailable() ? (
}} <GlassView
> style={styles.liquidGlassButton}
<Text style={styles.debugButtonText}>🫀</Text> glassEffectStyle="regular"
</TouchableOpacity> tintColor="rgba(255, 255, 255, 0.3)"
</View> isInteractive={true}
)} >
<Ionicons name="sparkles-outline" size={18} color="#0F172A" />
</GlassView>
) : (
<View style={[styles.liquidGlassButton, styles.liquidGlassFallback]}>
<Ionicons name="sparkles-outline" size={18} color="#0F172A" />
</View>
)}
</TouchableOpacity>
</View>
</View> </View>
</View> </View>
@@ -452,90 +540,185 @@ export default function ExploreScreen() {
<Text style={styles.sectionTitle}>{t('statistics.sections.bodyMetrics')}</Text> <Text style={styles.sectionTitle}>{t('statistics.sections.bodyMetrics')}</Text>
</View> </View>
{/* 真正瀑布流布局 */} {/* 动态布局:支持混合瀑布流和全宽卡片 */}
<View style={styles.masonryContainer}> <View style={styles.layoutContainer}>
{/* 左列 */} {(() => {
<View style={styles.masonryColumn}> // 定义所有卡片及其显示状态
{/* 心情卡片 */} const allCardsMap: Record<string, any> = {
<FloatingCard style={styles.masonryCard}> mood: {
<MoodCard visible: cardVisibility.showMood,
moodCheckin={currentMoodCheckin} component: (
onPress={() => pushIfAuthedElseLogin('/mood/calendar')} <MoodCard
isLoading={isMoodLoading} moodCheckin={currentMoodCheckin}
/> onPress={() => pushIfAuthedElseLogin('/mood/calendar')}
</FloatingCard> isLoading={isMoodLoading}
/>
)
},
steps: {
visible: cardVisibility.showSteps,
component: (
<StepsCard
curDate={currentSelectedDate}
stepGoal={stepGoal}
style={styles.stepsCardOverride}
/>
)
},
stress: {
visible: cardVisibility.showStress,
component: (
<StressMeter
curDate={currentSelectedDate}
/>
)
},
sleep: {
visible: cardVisibility.showSleep,
component: (
<SleepCard
selectedDate={currentSelectedDate}
/>
)
},
fitness: {
visible: cardVisibility.showFitnessRings,
component: (
<FitnessRingsCard
selectedDate={currentSelectedDate}
resetToken={animToken}
/>
)
},
water: {
visible: cardVisibility.showWater,
component: (
<WaterIntakeCard
selectedDate={currentSelectedDateString}
style={styles.waterCardOverride}
/>
)
},
metabolism: {
visible: cardVisibility.showBasalMetabolism,
component: (
<BasalMetabolismCard
selectedDate={currentSelectedDate}
style={styles.basalMetabolismCardOverride}
/>
)
},
oxygen: {
visible: cardVisibility.showOxygenSaturation,
component: (
<OxygenSaturationCard
selectedDate={currentSelectedDate}
style={styles.basalMetabolismCardOverride}
/>
)
},
temperature: {
visible: cardVisibility.showWristTemperature,
component: (
<WristTemperatureCard
selectedDate={currentSelectedDate}
style={styles.basalMetabolismCardOverride}
/>
)
},
menstrual: {
visible: cardVisibility.showMenstrualCycle,
component: (
<MenstrualCycleCard
onPress={() => pushIfAuthedElseLogin('/menstrual-cycle')}
/>
)
},
weight: {
visible: cardVisibility.showWeight,
isFullWidth: true,
component: (
<WeightHistoryCard />
)
},
circumference: {
visible: cardVisibility.showCircumference,
isFullWidth: true,
component: (
<CircumferenceCard style={{ marginBottom: 0, marginTop: 16 }} />
)
}
};
<FloatingCard style={styles.masonryCard}> const allKeys = Object.keys(allCardsMap);
<StepsCard const sortedKeys = Array.from(new Set([...cardOrder, ...allKeys]))
curDate={currentSelectedDate} .filter(key => allCardsMap[key]);
stepGoal={stepGoal}
style={styles.stepsCardOverride}
/>
</FloatingCard>
const visibleCards = sortedKeys
.map(key => ({ id: key, ...allCardsMap[key] }))
.filter(card => card.visible);
<FloatingCard style={styles.masonryCard}> // 分组逻辑:将连续的瀑布流卡片聚合,全宽卡片单独作为一组
<StressMeter const blocks: any[] = [];
curDate={currentSelectedDate} let currentMasonryBlock: any[] = [];
/>
</FloatingCard>
{/* 心率卡片 */} visibleCards.forEach(card => {
{/* <FloatingCard style={styles.masonryCard} delay={2000}> if (card.isFullWidth) {
<HeartRateCard // 如果有未处理的瀑布流卡片,先结算
resetToken={animToken} if (currentMasonryBlock.length > 0) {
style={styles.basalMetabolismCardOverride} blocks.push({ type: 'masonry', items: [...currentMasonryBlock] });
heartRate={heartRate} currentMasonryBlock = [];
/> }
</FloatingCard> */} // 添加全宽卡片
blocks.push({ type: 'full', item: card });
} else {
// 添加到当前瀑布流组
currentMasonryBlock.push(card);
}
});
<FloatingCard style={styles.masonryCard}> // 结算剩余的瀑布流卡片
<SleepCard if (currentMasonryBlock.length > 0) {
selectedDate={currentSelectedDate} blocks.push({ type: 'masonry', items: [...currentMasonryBlock] });
/> }
</FloatingCard>
</View>
{/* 右列 */} return blocks.map((block, blockIndex) => {
<View style={styles.masonryColumn}> if (block.type === 'full') {
<FloatingCard style={styles.masonryCard}> return (
<FitnessRingsCard <View key={`block-${blockIndex}-${block.item.id}`}>
selectedDate={currentSelectedDate} {block.item.component}
resetToken={animToken} </View>
/> );
</FloatingCard> } else {
{/* 饮水记录卡片 */} // 渲染瀑布流块
<FloatingCard style={styles.masonryCard}> const leftColumnCards = block.items.filter((_: any, index: number) => index % 2 === 0);
<WaterIntakeCard const rightColumnCards = block.items.filter((_: any, index: number) => index % 2 !== 0);
selectedDate={currentSelectedDateString}
style={styles.waterCardOverride}
/>
</FloatingCard>
return (
{/* 基础代谢卡片 */} <View key={`block-${blockIndex}-masonry`} style={styles.masonryContainer}>
<FloatingCard style={styles.masonryCard}> <View style={styles.masonryColumn}>
<BasalMetabolismCard {leftColumnCards.map((card: any) => (
selectedDate={currentSelectedDate} <FloatingCard key={card.id} style={styles.masonryCard}>
style={styles.basalMetabolismCardOverride} {card.component}
/> </FloatingCard>
</FloatingCard> ))}
</View>
{/* 血氧饱和度卡片 */} <View style={styles.masonryColumn}>
<FloatingCard style={styles.masonryCard}> {rightColumnCards.map((card: any) => (
<OxygenSaturationCard <FloatingCard key={card.id} style={styles.masonryCard}>
style={styles.basalMetabolismCardOverride} {card.component}
/> </FloatingCard>
</FloatingCard> ))}
</View>
</View>
</View> );
}
});
})()}
</View> </View>
<WeightHistoryCard />
{/* 围度数据卡片 - 占满底部一行 */}
<CircumferenceCard style={styles.circumferenceCard} />
</ScrollView> </ScrollView>
</View> </View>
); );
} }
@@ -584,6 +767,13 @@ const styles = StyleSheet.create({
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between', justifyContent: 'space-between',
gap: 12,
},
headerLeft: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
minWidth: 0,
}, },
logoImage: { logoImage: {
width: 28, width: 28,
@@ -598,7 +788,7 @@ const styles = StyleSheet.create({
fontSize: 16, fontSize: 16,
fontWeight: '700', fontWeight: '700',
color: '#192126', color: '#192126',
fontFamily: 'AliRegular' fontFamily: 'AliBold'
}, },
debugButtonsContainer: { debugButtonsContainer: {
flexDirection: 'row', flexDirection: 'row',
@@ -620,11 +810,9 @@ const styles = StyleSheet.create({
shadowRadius: 4, shadowRadius: 4,
elevation: 3, elevation: 3,
}, },
hrvTestButton: {
backgroundColor: '#8B5CF6',
},
debugButtonText: { debugButtonText: {
fontSize: 12, fontSize: 12,
fontFamily: 'AliRegular',
}, },
metricsRow: { metricsRow: {
flexDirection: 'row', flexDirection: 'row',
@@ -658,13 +846,15 @@ const styles = StyleSheet.create({
fontSize: 18, fontSize: 18,
lineHeight: 18, lineHeight: 18,
fontWeight: '600', fontWeight: '600',
textAlignVertical: 'bottom' textAlignVertical: 'bottom',
fontFamily: 'AliBold'
}, },
caloriesUnit: { caloriesUnit: {
color: '#515558ff', color: '#515558ff',
fontSize: 12, fontSize: 12,
marginLeft: 4, marginLeft: 4,
lineHeight: 18, lineHeight: 18,
fontFamily: 'AliRegular',
}, },
trainingContent: { trainingContent: {
marginTop: 8, marginTop: 8,
@@ -698,6 +888,7 @@ const styles = StyleSheet.create({
fontSize: 18, fontSize: 18,
fontWeight: '800', fontWeight: '800',
color: '#8B74F3', color: '#8B74F3',
fontFamily: 'AliBold',
}, },
cyclingHeader: { cyclingHeader: {
flexDirection: 'row', flexDirection: 'row',
@@ -717,6 +908,7 @@ const styles = StyleSheet.create({
color: '#FFFFFF', color: '#FFFFFF',
fontSize: 20, fontSize: 20,
fontWeight: '800', fontWeight: '800',
fontFamily: 'AliBold',
}, },
mapArea: { mapArea: {
backgroundColor: 'rgba(255,255,255,0.08)', backgroundColor: 'rgba(255,255,255,0.08)',
@@ -756,6 +948,7 @@ const styles = StyleSheet.create({
cardTitle: { cardTitle: {
fontSize: 14, fontSize: 14,
color: '#192126', color: '#192126',
fontFamily: 'AliBold',
}, },
heartCard: { heartCard: {
backgroundColor: '#FFE5E5', backgroundColor: '#FFE5E5',
@@ -776,12 +969,14 @@ const styles = StyleSheet.create({
alignSelf: 'flex-end', alignSelf: 'flex-end',
color: '#5B5B5B', color: '#5B5B5B',
fontWeight: '600', fontWeight: '600',
fontFamily: 'AliBold',
}, },
stepsValue: { stepsValue: {
fontSize: 14, fontSize: 14,
color: '#7A6A42', color: '#7A6A42',
fontWeight: '700', fontWeight: '700',
marginBottom: 8, marginBottom: 8,
fontFamily: 'AliBold',
}, },
errorContainer: { errorContainer: {
flexDirection: 'row', flexDirection: 'row',
@@ -797,6 +992,7 @@ const styles = StyleSheet.create({
fontWeight: '600', fontWeight: '600',
marginLeft: 8, marginLeft: 8,
flex: 1, flex: 1,
fontFamily: 'AliRegular',
}, },
retryButton: { retryButton: {
padding: 4, padding: 4,
@@ -811,11 +1007,13 @@ const styles = StyleSheet.create({
viewMoreText: { viewMoreText: {
fontSize: 14, fontSize: 14,
color: '#192126', color: '#192126',
fontFamily: 'AliRegular',
}, },
viewMoreIcon: { viewMoreIcon: {
fontSize: 16, fontSize: 16,
color: '#192126', color: '#192126',
marginLeft: 4, marginLeft: 4,
fontFamily: 'AliRegular',
}, },
stressCardRow: { stressCardRow: {
flexDirection: 'row', flexDirection: 'row',
@@ -827,6 +1025,9 @@ const styles = StyleSheet.create({
justifyContent: 'space-between', justifyContent: 'space-between',
marginBottom: 16, marginBottom: 16,
}, },
layoutContainer: {
flex: 1,
},
masonryContainer: { masonryContainer: {
flexDirection: 'row', flexDirection: 'row',
gap: 16, gap: 16,
@@ -886,6 +1087,7 @@ const styles = StyleSheet.create({
color: '#0369A1', color: '#0369A1',
fontWeight: '800', fontWeight: '800',
marginTop: 8, marginTop: 8,
fontFamily: 'AliBold',
}, },
addWeightButton: { addWeightButton: {
position: 'absolute', position: 'absolute',
@@ -906,6 +1108,54 @@ const styles = StyleSheet.create({
fontWeight: '700', fontWeight: '700',
color: '#192126', color: '#192126',
textAlign: 'left', textAlign: 'left',
fontFamily: 'AliBold',
},
headerActions: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
},
reportButton: {
height: 36,
borderRadius: 18,
paddingHorizontal: 12,
backgroundColor: '#F6F7FB',
borderWidth: 1,
borderColor: '#E5E7EB',
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
reportIconWrapper: {
width: 28,
height: 28,
borderRadius: 14,
alignItems: 'center',
justifyContent: 'center',
},
reportButtonLabel: {
fontSize: 14,
fontFamily: 'AliBold',
color: '#0F172A',
},
// Liquid Glass 风格按钮
liquidGlassButton: {
height: 40,
width: 40,
borderRadius: 20,
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
},
liquidGlassFallback: {
backgroundColor: 'rgba(255, 255, 255, 0.6)',
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.8)',
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 4,
}, },

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 PrivacyConsentModal from '@/components/PrivacyConsentModal';
import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useQuickActions } from '@/hooks/useQuickActions'; import { useQuickActions } from '@/hooks/useQuickActions';
import '@/i18n';
import { hrvMonitorService } from '@/services/hrvMonitor'; import { hrvMonitorService } from '@/services/hrvMonitor';
import { cleanupLegacyMedicationNotifications } from '@/services/medicationNotificationCleanup'; import { cleanupLegacyMedicationNotifications } from '@/services/medicationNotificationCleanup';
import { clearBadgeCount, notificationService } from '@/services/notifications'; import { clearBadgeCount, notificationService } from '@/services/notifications';
@@ -26,12 +19,19 @@ import { initializeHealthPermissions } from '@/utils/health';
import { MoodNotificationHelpers, NutritionNotificationHelpers, WaterNotificationHelpers } from '@/utils/notificationHelpers'; import { MoodNotificationHelpers, NutritionNotificationHelpers, WaterNotificationHelpers } from '@/utils/notificationHelpers';
import { getMoodReminderEnabled, getNutritionReminderEnabled, getWaterReminderSettings } from '@/utils/userPreferences'; import { getMoodReminderEnabled, getNutritionReminderEnabled, getWaterReminderSettings } from '@/utils/userPreferences';
import { clearPendingWaterRecords, syncPendingWidgetChanges } from '@/utils/widgetDataSync'; import { clearPendingWaterRecords, syncPendingWidgetChanges } from '@/utils/widgetDataSync';
import { DefaultTheme, ThemeProvider } from '@react-navigation/native';
import { useFonts } from 'expo-font';
import { Stack, useRouter } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import { AppState, AppStateStatus } from 'react-native'; import { AppState, AppStateStatus } from 'react-native';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import 'react-native-reanimated';
import { DialogProvider } from '@/components/ui/DialogProvider'; import { DialogProvider } from '@/components/ui/DialogProvider';
import { MembershipModalProvider } from '@/contexts/MembershipModalContext'; import { MembershipModalProvider } from '@/contexts/MembershipModalContext';
import { ToastProvider } from '@/contexts/ToastContext'; import { ToastProvider } from '@/contexts/ToastContext';
import { VersionCheckProvider } from '@/contexts/VersionCheckContext';
import { useAuthGuard } from '@/hooks/useAuthGuard'; import { useAuthGuard } from '@/hooks/useAuthGuard';
import { STORAGE_KEYS, setUnauthorizedHandler } from '@/services/api'; import { STORAGE_KEYS, setUnauthorizedHandler } from '@/services/api';
import { BackgroundTaskManager } from '@/services/backgroundTaskManagerV2'; import { BackgroundTaskManager } from '@/services/backgroundTaskManagerV2';
@@ -484,6 +484,51 @@ function Bootstrapper({ children }: { children: React.ReactNode }) {
getPrivacyAgreed(); getPrivacyAgreed();
}, []); }, []);
// 使用 ref 确保更新检查只执行一次
// const updateCheckRequestedRef = React.useRef(false);
// useEffect(() => {
// // 如果已经执行过更新检查,直接返回
// if (updateCheckRequestedRef.current) {
// return;
// }
// updateCheckRequestedRef.current = true;
// async function checkUpdate() {
// try {
// logger.info(`Checking for updates..., env: ${__DEV__}, Updates.isEnabled: ${Updates.isEnabled}`);
// // 只有在 expo-updates 启用时才检查更新
// // 开发环境或 Updates 未启用时跳过
// if (__DEV__ || !Updates.isEnabled) {
// logger.info('Skipping update check: dev mode or updates not enabled');
// return;
// }
// const update = await Updates.checkForUpdateAsync();
// logger.info("Update check:", update);
// if (update.isAvailable) {
// logger.info("Update available, fetching...");
// const result = await Updates.fetchUpdateAsync();
// logger.info("Fetch result:", result);
// if (result.isNew) {
// logger.info("Reloading app to apply update...");
// await Updates.reloadAsync();
// }
// }
// } catch (e) {
// logger.error("Update error:", e);
// }
// }
// setTimeout(() => {
// checkUpdate();
// }, 5000);
// }, []);
const handlePrivacyAgree = () => { const handlePrivacyAgree = () => {
dispatch(setPrivacyAgreed()); dispatch(setPrivacyAgreed());
setShowPrivacyModal(false); setShowPrivacyModal(false);
@@ -524,30 +569,32 @@ export default function RootLayout() {
<Provider store={store}> <Provider store={store}>
<Bootstrapper> <Bootstrapper>
<ToastProvider> <ToastProvider>
<ThemeProvider value={DefaultTheme}> <VersionCheckProvider>
<Stack screenOptions={{ headerShown: false }}> <ThemeProvider value={DefaultTheme}>
<Stack.Screen name="onboarding" /> <Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="(tabs)" /> <Stack.Screen name="onboarding" />
<Stack.Screen name="profile/edit" /> <Stack.Screen name="(tabs)" />
<Stack.Screen name="fasting/[planId]" options={{ headerShown: false }} /> <Stack.Screen name="profile/edit" />
<Stack.Screen name="fasting/[planId]" options={{ headerShown: false }} />
<Stack.Screen name="auth/login" options={{ headerShown: false }} /> <Stack.Screen name="auth/login" options={{ headerShown: false }} />
<Stack.Screen name="legal/user-agreement" options={{ headerShown: true, title: '用户协议' }} /> <Stack.Screen name="legal/user-agreement" options={{ headerShown: true, title: '用户协议' }} />
<Stack.Screen name="legal/privacy-policy" options={{ headerShown: true, title: '隐私政策' }} /> <Stack.Screen name="legal/privacy-policy" options={{ headerShown: true, title: '隐私政策' }} />
<Stack.Screen name="article/[id]" options={{ headerShown: false }} /> <Stack.Screen name="article/[id]" options={{ headerShown: false }} />
<Stack.Screen name="workout/notification-settings" options={{ headerShown: false }} /> <Stack.Screen name="workout/notification-settings" options={{ headerShown: false }} />
<Stack.Screen <Stack.Screen
name="health-data-permissions" name="health-data-permissions"
options={{ headerShown: false }} options={{ headerShown: false }}
/> />
<Stack.Screen name="medications/ai-camera" options={{ headerShown: false }} /> <Stack.Screen name="medications/ai-camera" options={{ headerShown: false }} />
<Stack.Screen name="medications/ai-progress" options={{ headerShown: false }} /> <Stack.Screen name="medications/ai-progress" options={{ headerShown: false }} />
<Stack.Screen name="badges/index" options={{ headerShown: false }} /> <Stack.Screen name="badges/index" options={{ headerShown: false }} />
<Stack.Screen name="settings/tab-bar-config" options={{ headerShown: false }} /> <Stack.Screen name="settings/tab-bar-config" options={{ headerShown: false }} />
<Stack.Screen name="+not-found" /> <Stack.Screen name="+not-found" />
</Stack> </Stack>
<StatusBar style="dark" /> <StatusBar style="dark" />
</ThemeProvider> </ThemeProvider>
</VersionCheckProvider>
</ToastProvider> </ToastProvider>
</Bootstrapper> </Bootstrapper>
</Provider> </Provider>

View File

@@ -13,6 +13,7 @@ import { PRIVACY_POLICY_URL, USER_AGREEMENT_URL } from '@/constants/Agree';
import { Colors } from '@/constants/Colors'; import { Colors } from '@/constants/Colors';
import { useAppDispatch } from '@/hooks/redux'; import { useAppDispatch } from '@/hooks/redux';
import { useColorScheme } from '@/hooks/useColorScheme'; import { useColorScheme } from '@/hooks/useColorScheme';
import { useI18n } from '@/hooks/useI18n';
import { fetchMyProfile, login } from '@/store/userSlice'; import { fetchMyProfile, login } from '@/store/userSlice';
import Toast from 'react-native-toast-message'; import Toast from 'react-native-toast-message';
@@ -23,6 +24,7 @@ export default function LoginScreen() {
const color = Colors[scheme]; const color = Colors[scheme];
const pageBackground = scheme === 'light' ? color.pageBackgroundEmphasis : color.background; const pageBackground = scheme === 'light' ? color.pageBackgroundEmphasis : color.background;
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { t } = useI18n();
const AnimatedLinear = useMemo(() => Animated.createAnimatedComponent(LinearGradient), []); const AnimatedLinear = useMemo(() => Animated.createAnimatedComponent(LinearGradient), []);
// 背景动效:轻微平移/旋转与呼吸动画 // 背景动效:轻微平移/旋转与呼吸动画
@@ -79,12 +81,12 @@ export default function LoginScreen() {
const guardAgreement = useCallback((action: () => void) => { const guardAgreement = useCallback((action: () => void) => {
if (!hasAgreed) { if (!hasAgreed) {
Alert.alert( Alert.alert(
'请先阅读并同意', t('login.agreement.alert.title'),
'继续登录前,请阅读并勾选《隐私政策》和《用户协议》。点击“同意并继续”将默认勾选并继续登录。', t('login.agreement.alert.message'),
[ [
{ text: '取消', style: 'cancel' }, { text: t('login.agreement.alert.cancel'), style: 'cancel' },
{ {
text: '同意并继续', text: t('login.agreement.alert.confirm'),
onPress: () => { onPress: () => {
setHasAgreed(true); setHasAgreed(true);
setTimeout(() => action(), 0); setTimeout(() => action(), 0);
@@ -96,7 +98,7 @@ export default function LoginScreen() {
return; return;
} }
action(); action();
}, [hasAgreed]); }, [hasAgreed, t]);
const onAppleLogin = useCallback(async () => { const onAppleLogin = useCallback(async () => {
if (!appleAvailable) return; if (!appleAvailable) return;
@@ -110,7 +112,7 @@ export default function LoginScreen() {
}); });
const identityToken = (credential as any)?.identityToken; const identityToken = (credential as any)?.identityToken;
if (!identityToken || typeof identityToken !== 'string') { if (!identityToken || typeof identityToken !== 'string') {
throw new Error('未获取到 Apple 身份令牌'); throw new Error(t('login.errors.appleIdentityTokenMissing'));
} }
await dispatch(login({ appleIdentityToken: identityToken })).unwrap(); await dispatch(login({ appleIdentityToken: identityToken })).unwrap();
@@ -118,7 +120,7 @@ export default function LoginScreen() {
await dispatch(fetchMyProfile()) await dispatch(fetchMyProfile())
Toast.show({ Toast.show({
text1: '登录成功', text1: t('login.success.loginSuccess'),
type: 'success', type: 'success',
}); });
// 登录成功后处理重定向 // 登录成功后处理重定向
@@ -145,12 +147,12 @@ export default function LoginScreen() {
console.log('err.code', err.code); console.log('err.code', err.code);
if (err?.code === 'ERR_CANCELED' || err?.code === 'ERR_REQUEST_CANCELED') return; if (err?.code === 'ERR_CANCELED' || err?.code === 'ERR_REQUEST_CANCELED') return;
const message = err?.message || '登录失败,请稍后再试'; const message = err?.message || t('login.errors.loginFailed');
Alert.alert('登录失败', message); Alert.alert(t('login.errors.loginFailedTitle'), message);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [appleAvailable, router, searchParams?.redirectParams, searchParams?.redirectTo]); }, [appleAvailable, router, searchParams?.redirectParams, searchParams?.redirectTo, dispatch, t]);
// 登录按钮不再因未勾选协议而禁用,仅在加载中禁用 // 登录按钮不再因未勾选协议而禁用,仅在加载中禁用
@@ -244,14 +246,14 @@ export default function LoginScreen() {
<Ionicons name="chevron-back" size={24} color={scheme === 'dark' ? '#ECEDEE' : '#192126'} /> <Ionicons name="chevron-back" size={24} color={scheme === 'dark' ? '#ECEDEE' : '#192126'} />
</TouchableOpacity> </TouchableOpacity>
)} )}
<Text style={[styles.headerTitle, { color: color.text }]}></Text> <Text style={[styles.headerTitle, { color: color.text }]}>{t('login.title')}</Text>
<View style={{ width: 32 }} /> <View style={{ width: 32 }} />
</View> </View>
<ScrollView contentContainerStyle={styles.content} showsVerticalScrollIndicator={false}> <ScrollView contentContainerStyle={styles.content} showsVerticalScrollIndicator={false}>
<View style={styles.headerWrap}> <View style={styles.headerWrap}>
<ThemedText style={[styles.title, { color: color.text }]}>Out Live</ThemedText> <ThemedText style={[styles.title, { color: color.text }]}>Out Live</ThemedText>
<ThemedText style={[styles.subtitle, { color: color.textMuted }]}></ThemedText> <ThemedText style={[styles.subtitle, { color: color.textMuted }]}>{t('login.subtitle')}</ThemedText>
</View> </View>
{/* Apple 登录 */} {/* Apple 登录 */}
@@ -276,12 +278,12 @@ export default function LoginScreen() {
color="#FFFFFF" color="#FFFFFF"
style={{ marginRight: 10 }} style={{ marginRight: 10 }}
/> />
<Text style={styles.appleText}>...</Text> <Text style={styles.appleText}>{t('login.loggingIn')}</Text>
</> </>
) : ( ) : (
<> <>
<Ionicons name="logo-apple" size={22} color="#FFFFFF" style={{ marginRight: 10 }} /> <Ionicons name="logo-apple" size={22} color="#FFFFFF" style={{ marginRight: 10 }} />
<Text style={styles.appleText}>使 Apple </Text> <Text style={styles.appleText}>{t('login.appleLogin')}</Text>
</> </>
)} )}
</GlassView> </GlassView>
@@ -294,12 +296,12 @@ export default function LoginScreen() {
color="#FFFFFF" color="#FFFFFF"
style={{ marginRight: 10 }} style={{ marginRight: 10 }}
/> />
<Text style={styles.appleText}>...</Text> <Text style={styles.appleText}>{t('login.loggingIn')}</Text>
</> </>
) : ( ) : (
<> <>
<Ionicons name="logo-apple" size={22} color="#FFFFFF" style={{ marginRight: 10 }} /> <Ionicons name="logo-apple" size={22} color="#FFFFFF" style={{ marginRight: 10 }} />
<Text style={styles.appleText}>使 Apple </Text> <Text style={styles.appleText}>{t('login.appleLogin')}</Text>
</> </>
)} )}
</View> </View>
@@ -319,13 +321,13 @@ export default function LoginScreen() {
{hasAgreed && <Ionicons name="checkmark" size={14} color={color.onPrimary} />} {hasAgreed && <Ionicons name="checkmark" size={14} color={color.onPrimary} />}
</View> </View>
</Pressable> </Pressable>
<Text style={[styles.agreementText, { color: color.textMuted }]}></Text> <Text style={[styles.agreementText, { color: color.textMuted }]}>{t('login.agreement.readAndAgree')}</Text>
<Pressable onPress={() => Linking.openURL(PRIVACY_POLICY_URL)}> <Pressable onPress={() => Linking.openURL(PRIVACY_POLICY_URL)}>
<Text style={[styles.link, { color: color.primary }]}></Text> <Text style={[styles.link, { color: color.primary }]}>{t('login.agreement.privacyPolicy')}</Text>
</Pressable> </Pressable>
<Text style={[styles.agreementText, { color: color.textMuted }]}></Text> <Text style={[styles.agreementText, { color: color.textMuted }]}>{t('login.agreement.and')}</Text>
<Pressable onPress={() => Linking.openURL(USER_AGREEMENT_URL)}> <Pressable onPress={() => Linking.openURL(USER_AGREEMENT_URL)}>
<Text style={[styles.link, { color: color.primary }]}></Text> <Text style={[styles.link, { color: color.primary }]}>{t('login.agreement.userAgreement')}</Text>
</Pressable> </Pressable>
</View> </View>

View File

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

View File

@@ -2,9 +2,10 @@ import { DateSelector } from '@/components/DateSelector';
import { HeaderBar } from '@/components/ui/HeaderBar'; import { HeaderBar } from '@/components/ui/HeaderBar';
import { Colors } from '@/constants/Colors'; import { Colors } from '@/constants/Colors';
import { useAppSelector } from '@/hooks/redux'; import { useAppSelector } from '@/hooks/redux';
import { useI18n } from '@/hooks/useI18n';
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding'; import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
import { selectUserAge, selectUserProfile } from '@/store/userSlice'; import { selectUserAge, selectUserProfile } from '@/store/userSlice';
import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date'; import { getLocalizedDateFormat, getMonthDays, getTodayIndexInMonth } from '@/utils/date';
import { fetchBasalEnergyBurned } from '@/utils/health'; import { fetchBasalEnergyBurned } from '@/utils/health';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
@@ -24,6 +25,7 @@ type BasalMetabolismData = {
}; };
export default function BasalMetabolismDetailScreen() { export default function BasalMetabolismDetailScreen() {
const { t, i18n } = useI18n();
const userProfile = useAppSelector(selectUserProfile); const userProfile = useAppSelector(selectUserProfile);
const userAge = useAppSelector(selectUserAge); const userAge = useAppSelector(selectUserAge);
const safeAreaTop = useSafeAreaTop() const safeAreaTop = useSafeAreaTop()
@@ -140,9 +142,9 @@ export default function BasalMetabolismDetailScreen() {
// 获取当前选中日期 // 获取当前选中日期
const currentSelectedDate = useMemo(() => { const currentSelectedDate = useMemo(() => {
const days = getMonthDaysZh(); const days = getMonthDays(undefined, i18n.language as 'zh' | 'en');
return days[selectedIndex]?.date?.toDate() ?? new Date(); return days[selectedIndex]?.date?.toDate() ?? new Date();
}, [selectedIndex]); }, [selectedIndex, i18n.language]);
// 计算BMR范围 // 计算BMR范围
@@ -203,7 +205,7 @@ export default function BasalMetabolismDetailScreen() {
setSelectedIndex(index); setSelectedIndex(index);
// 获取选中日期 // 获取选中日期
const days = getMonthDaysZh(); const days = getMonthDays(undefined, i18n.language as 'zh' | 'en');
const selectedDate = days[index]?.date?.toDate(); const selectedDate = days[index]?.date?.toDate();
if (selectedDate) { if (selectedDate) {
@@ -247,7 +249,7 @@ export default function BasalMetabolismDetailScreen() {
} }
} catch (err) { } catch (err) {
if (!isCancelled) { if (!isCancelled) {
setError(err instanceof Error ? err.message : '获取数据失败'); setError(err instanceof Error ? err.message : t('basalMetabolismDetail.chart.error.fetchFailed'));
} }
} finally { } finally {
if (!isCancelled) { if (!isCancelled) {
@@ -280,7 +282,8 @@ export default function BasalMetabolismDetailScreen() {
// 显示周数 // 显示周数
const weekOfYear = dayjs(item.date).week(); const weekOfYear = dayjs(item.date).week();
const firstWeekOfYear = dayjs(item.date).startOf('year').week(); const firstWeekOfYear = dayjs(item.date).startOf('year').week();
return `${weekOfYear - firstWeekOfYear + 1}`; const weekNumber = weekOfYear - firstWeekOfYear + 1;
return t('basalMetabolismDetail.chart.weekLabel', { week: weekNumber });
default: default:
return dayjs(item.date).format('MM-DD'); return dayjs(item.date).format('MM-DD');
} }
@@ -319,7 +322,7 @@ export default function BasalMetabolismDetailScreen() {
{/* 头部导航 */} {/* 头部导航 */}
<HeaderBar <HeaderBar
title="基础代谢" title={t('basalMetabolismDetail.title')}
transparent transparent
right={ right={
<TouchableOpacity <TouchableOpacity
@@ -355,7 +358,9 @@ export default function BasalMetabolismDetailScreen() {
{/* 当前日期基础代谢显示 */} {/* 当前日期基础代谢显示 */}
<View style={styles.currentDataCard}> <View style={styles.currentDataCard}>
<Text style={styles.currentDataTitle}> <Text style={styles.currentDataTitle}>
{dayjs(currentSelectedDate).format('M月D日')} {t('basalMetabolismDetail.currentData.title', {
date: getLocalizedDateFormat(dayjs(currentSelectedDate), i18n.language as 'zh' | 'en')
})}
</Text> </Text>
<View style={styles.currentValueContainer}> <View style={styles.currentValueContainer}>
<Text style={styles.currentValue}> <Text style={styles.currentValue}>
@@ -366,21 +371,24 @@ export default function BasalMetabolismDetailScreen() {
if (selectedDateData?.value) { if (selectedDateData?.value) {
return Math.round(selectedDateData.value).toString(); return Math.round(selectedDateData.value).toString();
} }
return '--'; return t('basalMetabolismDetail.currentData.noData');
})()} })()}
</Text> </Text>
<Text style={styles.currentUnit}></Text> <Text style={styles.currentUnit}>{t('basalMetabolismDetail.currentData.unit')}</Text>
</View> </View>
{bmrRange && ( {bmrRange && (
<Text style={styles.rangeText}> <Text style={styles.rangeText}>
: {bmrRange.min}-{bmrRange.max} {t('basalMetabolismDetail.currentData.normalRange', {
min: bmrRange.min,
max: bmrRange.max
})}
</Text> </Text>
)} )}
</View> </View>
{/* 基础代谢统计 */} {/* 基础代谢统计 */}
<View style={styles.statsCard}> <View style={styles.statsCard}>
<Text style={styles.statsTitle}></Text> <Text style={styles.statsTitle}>{t('basalMetabolismDetail.stats.title')}</Text>
{/* Tab 切换 */} {/* Tab 切换 */}
<View style={styles.tabContainer}> <View style={styles.tabContainer}>
@@ -390,7 +398,7 @@ export default function BasalMetabolismDetailScreen() {
activeOpacity={0.7} activeOpacity={0.7}
> >
<Text style={[styles.tabText, activeTab === 'week' && styles.activeTabText]}> <Text style={[styles.tabText, activeTab === 'week' && styles.activeTabText]}>
{t('basalMetabolismDetail.stats.tabs.week')}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity
@@ -399,7 +407,7 @@ export default function BasalMetabolismDetailScreen() {
activeOpacity={0.7} activeOpacity={0.7}
> >
<Text style={[styles.tabText, activeTab === 'month' && styles.activeTabText]}> <Text style={[styles.tabText, activeTab === 'month' && styles.activeTabText]}>
{t('basalMetabolismDetail.stats.tabs.month')}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
@@ -408,28 +416,30 @@ export default function BasalMetabolismDetailScreen() {
{isLoading ? ( {isLoading ? (
<View style={styles.loadingChart}> <View style={styles.loadingChart}>
<ActivityIndicator size="large" color="#4ECDC4" /> <ActivityIndicator size="large" color="#4ECDC4" />
<Text style={styles.loadingText}>...</Text> <Text style={styles.loadingText}>{t('basalMetabolismDetail.chart.loadingText')}</Text>
</View> </View>
) : error ? ( ) : error ? (
<View style={styles.errorChart}> <View style={styles.errorChart}>
<Text style={styles.errorText}>: {error}</Text> <Text style={styles.errorText}>
{t('basalMetabolismDetail.chart.error.text', { error })}
</Text>
<TouchableOpacity <TouchableOpacity
style={styles.retryButton} style={styles.retryButton}
onPress={() => { onPress={() => {
// 重新加载数据 // {t('basalMetabolismDetail.comments.reloadData')}
setIsLoading(true); setIsLoading(true);
setError(null); setError(null);
fetchBasalMetabolismData(activeTab).then(data => { fetchBasalMetabolismData(activeTab).then(data => {
setChartData(data); setChartData(data);
setIsLoading(false); setIsLoading(false);
}).catch(err => { }).catch(err => {
setError(err instanceof Error ? err.message : '获取数据失败'); setError(err instanceof Error ? err.message : t('basalMetabolismDetail.chart.error.fetchFailed'));
setIsLoading(false); setIsLoading(false);
}); });
}} }}
activeOpacity={0.7} activeOpacity={0.7}
> >
<Text style={styles.retryText}></Text> <Text style={styles.retryText}>{t('basalMetabolismDetail.chart.error.retry')}</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
) : processedChartData.datasets.length > 0 && processedChartData.datasets[0].data.length > 0 ? ( ) : processedChartData.datasets.length > 0 && processedChartData.datasets[0].data.length > 0 ? (
@@ -441,7 +451,7 @@ export default function BasalMetabolismDetailScreen() {
width={Dimensions.get('window').width - 80} width={Dimensions.get('window').width - 80}
height={220} height={220}
yAxisLabel="" yAxisLabel=""
yAxisSuffix="千卡" yAxisSuffix={t('basalMetabolismDetail.chart.yAxisSuffix')}
chartConfig={{ chartConfig={{
backgroundColor: '#ffffff', backgroundColor: '#ffffff',
backgroundGradientFrom: '#ffffff', backgroundGradientFrom: '#ffffff',
@@ -470,7 +480,7 @@ export default function BasalMetabolismDetailScreen() {
/> />
) : ( ) : (
<View style={styles.emptyChart}> <View style={styles.emptyChart}>
<Text style={styles.emptyChartText}></Text> <Text style={styles.emptyChartText}>{t('basalMetabolismDetail.chart.empty')}</Text>
</View> </View>
)} )}
</View> </View>
@@ -490,56 +500,66 @@ export default function BasalMetabolismDetailScreen() {
style={styles.closeButton} style={styles.closeButton}
onPress={() => setInfoModalVisible(false)} onPress={() => setInfoModalVisible(false)}
> >
<Text style={styles.closeButtonText}>×</Text> <Text style={styles.closeButtonText}>{t('basalMetabolismDetail.modal.closeButton')}</Text>
</TouchableOpacity> </TouchableOpacity>
{/* 标题 */} {/* 标题 */}
<Text style={styles.modalTitle}></Text> <Text style={styles.modalTitle}>{t('basalMetabolismDetail.modal.title')}</Text>
{/* 基础代谢定义 */} {/* 基础代谢定义 */}
<Text style={styles.modalDescription}> <Text style={styles.modalDescription}>
BMR {t('basalMetabolismDetail.modal.description')}
</Text> </Text>
{/* 为什么重要 */} {/* 为什么重要 */}
<Text style={styles.sectionTitle}></Text> <Text style={styles.sectionTitle}>{t('basalMetabolismDetail.modal.sections.importance.title')}</Text>
<Text style={styles.sectionContent}> <Text style={styles.sectionContent}>
60-75% {t('basalMetabolismDetail.modal.sections.importance.content')}
</Text> </Text>
{/* 正常范围 */} {/* 正常范围 */}
<Text style={styles.sectionTitle}></Text> <Text style={styles.sectionTitle}>{t('basalMetabolismDetail.modal.sections.normalRange.title')}</Text>
<Text style={styles.formulaText}> <Text style={styles.formulaText}>
- BMR = 10 × (kg) + 6.25 × (cm) - 5 × + 5 - {t('basalMetabolismDetail.modal.sections.normalRange.formulas.male')}
</Text> </Text>
<Text style={styles.formulaText}> <Text style={styles.formulaText}>
- BMR = 10 × (kg) + 6.25 × (cm) - 5 × - 161 - {t('basalMetabolismDetail.modal.sections.normalRange.formulas.female')}
</Text> </Text>
{bmrRange ? ( {bmrRange ? (
<> <>
<Text style={styles.rangeText}>{bmrRange.min}-{bmrRange.max}/</Text> <Text style={styles.rangeText}>
{t('basalMetabolismDetail.modal.sections.normalRange.userRange', {
min: bmrRange.min,
max: bmrRange.max
})}
</Text>
<Text style={styles.rangeNote}> <Text style={styles.rangeNote}>
(15%) {t('basalMetabolismDetail.modal.sections.normalRange.rangeNote')}
</Text> </Text>
<Text style={styles.userInfoText}> <Text style={styles.userInfoText}>
{userProfile.gender === 'male' ? '男性' : '女性'}{userAge}{userProfile.height}cm{userProfile.weight}kg {t('basalMetabolismDetail.modal.sections.normalRange.userInfo', {
gender: t(`basalMetabolismDetail.gender.${userProfile.gender === 'male' ? 'male' : 'female'}`),
age: userAge,
height: userProfile.height,
weight: userProfile.weight
})}
</Text> </Text>
</> </>
) : ( ) : (
<Text style={styles.rangeText}></Text> <Text style={styles.rangeText}>
{t('basalMetabolismDetail.modal.sections.normalRange.incompleteInfo')}
</Text>
)} )}
{/* 提高代谢率的策略 */} {/* 提高代谢率的策略 */}
<Text style={styles.sectionTitle}></Text> <Text style={styles.sectionTitle}>{t('basalMetabolismDetail.modal.sections.strategies.title')}</Text>
<Text style={styles.strategyText}></Text> <Text style={styles.strategyText}>{t('basalMetabolismDetail.modal.sections.strategies.subtitle')}</Text>
<View style={styles.strategyList}> <View style={styles.strategyList}>
<Text style={styles.strategyItem}>1. (2-3)</Text> {(t('basalMetabolismDetail.modal.sections.strategies.items', { returnObjects: true }) as string[]).map((item: string, index: number) => (
<Text style={styles.strategyItem}>2. (HIIT)</Text> <Text key={index} style={styles.strategyItem}>{item}</Text>
<Text style={styles.strategyItem}>3. (1.6-2.2g)</Text> ))}
<Text style={styles.strategyItem}>4. (7-9/)</Text>
<Text style={styles.strategyItem}>5. (BMR的80%)</Text>
</View> </View>
</View> </View>
</View> </View>

View File

@@ -1,17 +1,22 @@
import ChallengeProgressCard from '@/components/challenges/ChallengeProgressCard'; import ChallengeProgressCard from '@/components/challenges/ChallengeProgressCard';
import { ChallengeRankingItem } from '@/components/challenges/ChallengeRankingItem'; import { ChallengeRankingItem } from '@/components/challenges/ChallengeRankingItem';
import { HeaderBar } from '@/components/ui/HeaderBar'; import { HeaderBar } from '@/components/ui/HeaderBar';
import { Image } from '@/components/ui/Image';
import { Colors } from '@/constants/Colors'; import { Colors } from '@/constants/Colors';
import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useAuthGuard } from '@/hooks/useAuthGuard'; import { useAuthGuard } from '@/hooks/useAuthGuard';
import { useColorScheme } from '@/hooks/useColorScheme'; import { useColorScheme } from '@/hooks/useColorScheme';
import { ChallengeSource } from '@/services/challengesApi'; import { ChallengeSource } from '@/services/challengesApi';
import { import {
archiveCustomChallengeThunk,
fetchChallengeDetail, fetchChallengeDetail,
fetchChallengeRankings, fetchChallengeRankings,
fetchChallenges,
joinChallenge, joinChallenge,
leaveChallenge, leaveChallenge,
reportChallengeProgress, reportChallengeProgress,
selectArchiveError,
selectArchiveStatus,
selectChallengeById, selectChallengeById,
selectChallengeDetailError, selectChallengeDetailError,
selectChallengeDetailStatus, selectChallengeDetailStatus,
@@ -29,7 +34,6 @@ import { BlurView } from 'expo-blur';
import * as Clipboard from 'expo-clipboard'; import * as Clipboard from 'expo-clipboard';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect'; import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import * as Haptics from 'expo-haptics'; import * as Haptics from 'expo-haptics';
import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import { useLocalSearchParams, useRouter } from 'expo-router'; import { useLocalSearchParams, useRouter } from 'expo-router';
import LottieView from 'lottie-react-native'; import LottieView from 'lottie-react-native';
@@ -117,6 +121,10 @@ export default function ChallengeDetailScreen() {
const leaveStatus = useAppSelector((state) => (leaveStatusSelector ? leaveStatusSelector(state) : 'idle')); const leaveStatus = useAppSelector((state) => (leaveStatusSelector ? leaveStatusSelector(state) : 'idle'));
const leaveErrorSelector = useMemo(() => (id ? selectLeaveError(id) : undefined), [id]); const leaveErrorSelector = useMemo(() => (id ? selectLeaveError(id) : undefined), [id]);
const leaveError = useAppSelector((state) => (leaveErrorSelector ? leaveErrorSelector(state) : undefined)); const leaveError = useAppSelector((state) => (leaveErrorSelector ? leaveErrorSelector(state) : undefined));
const archiveStatusSelector = useMemo(() => (id ? selectArchiveStatus(id) : undefined), [id]);
const archiveStatus = useAppSelector((state) => (archiveStatusSelector ? archiveStatusSelector(state) : 'idle'));
const archiveErrorSelector = useMemo(() => (id ? selectArchiveError(id) : undefined), [id]);
const archiveError = useAppSelector((state) => (archiveErrorSelector ? archiveErrorSelector(state) : undefined));
const progressStatusSelector = useMemo(() => (id ? selectProgressStatus(id) : undefined), [id]); const progressStatusSelector = useMemo(() => (id ? selectProgressStatus(id) : undefined), [id]);
const progressStatus = useAppSelector((state) => (progressStatusSelector ? progressStatusSelector(state) : 'idle')); const progressStatus = useAppSelector((state) => (progressStatusSelector ? progressStatusSelector(state) : 'idle'));
@@ -160,9 +168,13 @@ export default function ChallengeDetailScreen() {
}; };
}, [showCelebration]); }, [showCelebration]);
const progress = challenge?.progress; const progress = challenge?.progress;
const isJoined = challenge?.isJoined ?? false; const isJoined = challenge?.isJoined ?? false;
const isCustomChallenge = challenge?.source === ChallengeSource.CUSTOM; const isCustomChallenge = challenge?.source === ChallengeSource.CUSTOM;
const isCreator = challenge?.isCreator ?? false;
const isCustomCreator = isCustomChallenge && isCreator;
const canEdit = isCustomChallenge && isCreator;
const lastProgressAt = useMemo(() => { const lastProgressAt = useMemo(() => {
const progressRecord = challenge?.progress as { lastProgressAt?: string; last_progress_at?: string } | undefined; const progressRecord = challenge?.progress as { lastProgressAt?: string; last_progress_at?: string } | undefined;
return progressRecord?.lastProgressAt ?? progressRecord?.last_progress_at; return progressRecord?.lastProgressAt ?? progressRecord?.last_progress_at;
@@ -275,6 +287,20 @@ export default function ChallengeDetailScreen() {
} }
}; };
const handleArchive = async () => {
if (!id || archiveStatus === 'loading') {
return;
}
try {
await dispatch(archiveCustomChallengeThunk(id)).unwrap();
Toast.success(t('challengeDetail.alert.archiveSuccess'));
await dispatch(fetchChallenges());
router.back();
} catch (error) {
Toast.error(t('challengeDetail.alert.archiveFailed'));
}
};
const handleLeaveConfirm = () => { const handleLeaveConfirm = () => {
if (!id || leaveStatus === 'loading') { if (!id || leaveStatus === 'loading') {
return; return;
@@ -295,6 +321,26 @@ export default function ChallengeDetailScreen() {
); );
}; };
const handleArchiveConfirm = () => {
if (!id || archiveStatus === 'loading') {
return;
}
Alert.alert(
t('challengeDetail.alert.archiveConfirm.title'),
t('challengeDetail.alert.archiveConfirm.message'),
[
{ text: t('challengeDetail.alert.archiveConfirm.cancel'), style: 'cancel' },
{
text: t('challengeDetail.alert.archiveConfirm.confirm'),
style: 'destructive',
onPress: () => {
void handleArchive();
},
},
]
);
};
const handleProgressReport = async () => { const handleProgressReport = async () => {
if (!id || progressStatus === 'loading') { if (!id || progressStatus === 'loading') {
return; return;
@@ -391,6 +437,9 @@ export default function ChallengeDetailScreen() {
const joinCtaLabel = joinStatus === 'loading' ? t('challengeDetail.cta.joining') : challenge.ctaLabel ?? t('challengeDetail.cta.join'); const joinCtaLabel = joinStatus === 'loading' ? t('challengeDetail.cta.joining') : challenge.ctaLabel ?? t('challengeDetail.cta.join');
const isUpcoming = challenge.status === 'upcoming'; const isUpcoming = challenge.status === 'upcoming';
const isExpired = challenge.status === 'expired'; const isExpired = challenge.status === 'expired';
const deleteCtaLabel = archiveStatus === 'loading'
? t('challengeDetail.cta.deleting')
: t('challengeDetail.cta.delete');
const upcomingStartLabel = formatMonthDay(challenge.startAt); const upcomingStartLabel = formatMonthDay(challenge.startAt);
const upcomingHighlightTitle = t('challengeDetail.highlight.upcoming.title'); const upcomingHighlightTitle = t('challengeDetail.highlight.upcoming.title');
const upcomingHighlightSubtitle = upcomingStartLabel const upcomingHighlightSubtitle = upcomingStartLabel
@@ -420,10 +469,17 @@ export default function ChallengeDetailScreen() {
? `分享码 ${challenge?.shareCode ?? ''}` ? `分享码 ${challenge?.shareCode ?? ''}`
: leaveHighlightTitle; : leaveHighlightTitle;
floatingHighlightSubtitle = showShareCode ? '' : leaveHighlightSubtitle; floatingHighlightSubtitle = showShareCode ? '' : leaveHighlightSubtitle;
floatingCtaLabel = leaveCtaLabel; if (isCustomCreator) {
floatingOnPress = handleLeaveConfirm; floatingCtaLabel = deleteCtaLabel;
floatingDisabled = leaveStatus === 'loading'; floatingOnPress = handleArchiveConfirm;
floatingError = leaveError; floatingDisabled = archiveStatus === 'loading';
floatingError = archiveError;
} else {
floatingCtaLabel = leaveCtaLabel;
floatingOnPress = handleLeaveConfirm;
floatingDisabled = leaveStatus === 'loading';
floatingError = leaveError;
}
} }
if (isUpcoming) { if (isUpcoming) {
@@ -539,7 +595,7 @@ export default function ChallengeDetailScreen() {
<Ionicons name="flag-outline" size={20} color="#5E8BFF" /> <Ionicons name="flag-outline" size={20} color="#5E8BFF" />
</View> </View>
<View style={styles.shareInfoTextWrapper}> <View style={styles.shareInfoTextWrapper}>
<Text style={styles.shareInfoLabel}>{challenge.requirementLabel}</Text> {challenge.requirementLabel ? <Text style={styles.shareInfoLabel}>{challenge.requirementLabel}</Text> : null}
<Text style={styles.shareInfoMeta}>{t('challengeDetail.shareCard.info.checkInDaily')}</Text> <Text style={styles.shareInfoMeta}>{t('challengeDetail.shareCard.info.checkInDaily')}</Text>
</View> </View>
</View> </View>
@@ -573,29 +629,63 @@ export default function ChallengeDetailScreen() {
transparent transparent
withSafeTop={false} withSafeTop={false}
right={ right={
isLiquidGlassAvailable() ? ( <View style={styles.headerButtons}>
<TouchableOpacity {canEdit && (
onPress={handleShare} isLiquidGlassAvailable() ? (
activeOpacity={0.7} <TouchableOpacity
> onPress={() => router.push({
<GlassView pathname: '/challenges/create-custom',
style={styles.shareButton} params: { id, mode: 'edit' }
glassEffectStyle="clear" })}
tintColor="rgba(255, 255, 255, 0.3)" activeOpacity={0.7}
isInteractive={true} style={styles.editButton}
>
<GlassView
style={styles.editButtonGlass}
glassEffectStyle="clear"
tintColor="rgba(255, 255, 255, 0.3)"
isInteractive={true}
>
<Ionicons name="create-outline" size={20} color="#ffffff" />
</GlassView>
</TouchableOpacity>
) : (
<TouchableOpacity
onPress={() => router.push({
pathname: '/challenges/create-custom',
params: { id, mode: 'edit' }
})}
activeOpacity={0.7}
style={[styles.editButton, styles.fallbackEditButton]}
>
<Ionicons name="create-outline" size={20} color="#ffffff" />
</TouchableOpacity>
)
)}
{isLiquidGlassAvailable() ? (
<TouchableOpacity
onPress={handleShare}
activeOpacity={0.7}
>
<GlassView
style={styles.shareButton}
glassEffectStyle="clear"
tintColor="rgba(255, 255, 255, 0.3)"
isInteractive={true}
>
<Ionicons name="share-social-outline" size={20} color="#ffffff" />
</GlassView>
</TouchableOpacity>
) : (
<TouchableOpacity
onPress={handleShare}
style={[styles.shareButton, styles.fallbackShareButton]}
activeOpacity={0.7}
> >
<Ionicons name="share-social-outline" size={20} color="#ffffff" /> <Ionicons name="share-social-outline" size={20} color="#ffffff" />
</GlassView> </TouchableOpacity>
</TouchableOpacity> )}
) : ( </View>
<TouchableOpacity
onPress={handleShare}
style={[styles.shareButton, styles.fallbackShareButton]}
activeOpacity={0.7}
>
<Ionicons name="share-social-outline" size={20} color="#ffffff" />
</TouchableOpacity>
)
} }
/> />
</View> </View>
@@ -653,7 +743,7 @@ export default function ChallengeDetailScreen() {
<Ionicons name="flag-outline" size={20} color="#4F5BD5" /> <Ionicons name="flag-outline" size={20} color="#4F5BD5" />
</View> </View>
<View style={styles.detailTextWrapper}> <View style={styles.detailTextWrapper}>
<Text style={styles.detailLabel}>{challenge.requirementLabel}</Text> {challenge.requirementLabel ? <Text style={styles.detailLabel}>{challenge.requirementLabel}</Text> : null}
<Text style={styles.detailMeta}>{t('challengeDetail.detail.requirement')}</Text> <Text style={styles.detailMeta}>{t('challengeDetail.detail.requirement')}</Text>
</View> </View>
</View> </View>
@@ -746,52 +836,129 @@ export default function ChallengeDetailScreen() {
</View> </View>
</ScrollView> </ScrollView>
<View pointerEvents="box-none" style={[styles.floatingCTAContainer, { paddingBottom: insets.bottom }]}> <View pointerEvents="box-none" style={[styles.floatingCTAContainer, { paddingBottom: insets.bottom || 20 }]}>
<BlurView intensity={10} tint="light" style={styles.floatingCTABlur}> {isLiquidGlassAvailable() ? (
<View style={styles.floatingCTAContent}> <View style={styles.glassWrapper}>
{showShareCode ? ( {/* 顶部高光线条 */}
<View style={[styles.highlightCopy, styles.highlightCopyCompact]}> <LinearGradient
<View style={styles.shareCodeRow}> colors={['rgba(255,255,255,0.9)', 'rgba(255,255,255,0.2)', 'transparent']}
<Text style={styles.highlightTitle}>{floatingHighlightTitle}</Text> start={{ x: 0.5, y: 0 }}
<TouchableOpacity end={{ x: 0.5, y: 1 }}
activeOpacity={0.85} style={styles.glassHighlight}
style={styles.shareCodeIconButton} />
onPress={handleCopyShareCode} <GlassView
> style={styles.glassContainer}
<Ionicons name="copy-outline" size={18} color="#4F5BD5" /> glassEffectStyle="regular"
</TouchableOpacity> tintColor="rgba(243, 244, 251, 0.55)"
</View> isInteractive={true}
{floatingHighlightSubtitle ? (
<Text style={styles.highlightSubtitle}>{floatingHighlightSubtitle}</Text>
) : null}
{floatingError ? <Text style={styles.ctaErrorText}>{floatingError}</Text> : null}
</View>
) : (
<View style={styles.highlightCopy}>
<Text style={styles.highlightTitle}>{floatingHighlightTitle}</Text>
<Text style={styles.highlightSubtitle}>{floatingHighlightSubtitle}</Text>
{floatingError ? <Text style={styles.ctaErrorText}>{floatingError}</Text> : null}
</View>
)}
<TouchableOpacity
style={styles.highlightButton}
activeOpacity={0.9}
onPress={floatingOnPress}
disabled={floatingDisabled}
> >
{/* 内部微光渐变 */}
<LinearGradient <LinearGradient
colors={floatingGradientColors} colors={['rgba(255,255,255,0.6)', 'rgba(255,255,255,0.0)']}
start={{ x: 0, y: 0 }} start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }} end={{ x: 0, y: 0.6 }}
style={styles.highlightButtonBackground} style={StyleSheet.absoluteFill}
> pointerEvents="none"
<Text style={[styles.highlightButtonLabel, isDisabledButtonState && styles.highlightButtonLabelDisabled]}> />
{floatingCtaLabel} <View style={styles.floatingCTAContent}>
</Text> {showShareCode ? (
</LinearGradient> <View style={[styles.highlightCopy, styles.highlightCopyCompact]}>
</TouchableOpacity> <View style={styles.shareCodeRow}>
<Text style={styles.highlightTitle}>{floatingHighlightTitle}</Text>
<TouchableOpacity
activeOpacity={0.7}
style={styles.shareCodeIconButton}
onPress={handleCopyShareCode}
>
<Ionicons name="copy-outline" size={18} color="#4F5BD5" />
</TouchableOpacity>
</View>
{floatingHighlightSubtitle ? (
<Text style={styles.highlightSubtitle}>{floatingHighlightSubtitle}</Text>
) : null}
{floatingError ? <Text style={styles.ctaErrorText}>{floatingError}</Text> : null}
</View>
) : (
<View style={styles.highlightCopy}>
<Text style={styles.highlightTitle}>{floatingHighlightTitle}</Text>
<Text style={styles.highlightSubtitle}>{floatingHighlightSubtitle}</Text>
{floatingError ? <Text style={styles.ctaErrorText}>{floatingError}</Text> : null}
</View>
)}
<TouchableOpacity
style={styles.highlightButton}
activeOpacity={0.85}
onPress={floatingOnPress}
disabled={floatingDisabled}
>
<LinearGradient
colors={floatingGradientColors}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.highlightButtonBackground}
>
{/* 按钮内部高光 */}
<LinearGradient
colors={['rgba(255,255,255,0.4)', 'transparent']}
start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 0.5 }}
style={StyleSheet.absoluteFill}
/>
<Text style={[styles.highlightButtonLabel, isDisabledButtonState && styles.highlightButtonLabelDisabled]}>
{floatingCtaLabel}
</Text>
</LinearGradient>
</TouchableOpacity>
</View>
</GlassView>
</View> </View>
</BlurView> ) : (
<BlurView intensity={20} tint="light" style={styles.floatingCTABlur}>
<View style={styles.floatingCTAContent}>
{showShareCode ? (
<View style={[styles.highlightCopy, styles.highlightCopyCompact]}>
<View style={styles.shareCodeRow}>
<Text style={styles.highlightTitle}>{floatingHighlightTitle}</Text>
<TouchableOpacity
activeOpacity={0.85}
style={styles.shareCodeIconButton}
onPress={handleCopyShareCode}
>
<Ionicons name="copy-outline" size={18} color="#4F5BD5" />
</TouchableOpacity>
</View>
{floatingHighlightSubtitle ? (
<Text style={styles.highlightSubtitle}>{floatingHighlightSubtitle}</Text>
) : null}
{floatingError ? <Text style={styles.ctaErrorText}>{floatingError}</Text> : null}
</View>
) : (
<View style={styles.highlightCopy}>
<Text style={styles.highlightTitle}>{floatingHighlightTitle}</Text>
<Text style={styles.highlightSubtitle}>{floatingHighlightSubtitle}</Text>
{floatingError ? <Text style={styles.ctaErrorText}>{floatingError}</Text> : null}
</View>
)}
<TouchableOpacity
style={styles.highlightButton}
activeOpacity={0.9}
onPress={floatingOnPress}
disabled={floatingDisabled}
>
<LinearGradient
colors={floatingGradientColors}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.highlightButtonBackground}
>
<Text style={[styles.highlightButtonLabel, isDisabledButtonState && styles.highlightButtonLabelDisabled]}>
{floatingCtaLabel}
</Text>
</LinearGradient>
</TouchableOpacity>
</View>
</BlurView>
)}
</View> </View>
</View> </View>
{showCelebration && ( {showCelebration && (
@@ -850,13 +1017,47 @@ const styles = StyleSheet.create({
right: 0, right: 0,
bottom: 0, bottom: 0,
paddingHorizontal: 20, paddingHorizontal: 20,
zIndex: 100,
}, },
floatingCTABlur: { floatingCTABlur: {
borderRadius: 24, borderRadius: 24,
overflow: 'hidden', overflow: 'hidden',
borderWidth: 1, borderWidth: 1,
borderColor: 'rgba(255,255,255,0.6)', borderColor: 'rgba(255,255,255,0.6)',
backgroundColor: 'rgba(243, 244, 251, 0.85)', backgroundColor: 'rgba(243, 244, 251, 0.9)',
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.1,
shadowRadius: 12,
elevation: 5,
},
glassWrapper: {
borderRadius: 24,
overflow: 'hidden',
backgroundColor: 'rgba(255, 255, 255, 0.05)',
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.3)',
shadowColor: '#5E8BFF',
shadowOffset: {
width: 0,
height: 8,
},
shadowOpacity: 0.18,
shadowRadius: 20,
elevation: 10,
},
glassHighlight: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: 1,
zIndex: 2,
opacity: 0.9,
},
glassContainer: {
borderRadius: 24,
overflow: 'hidden',
}, },
floatingCTAContent: { floatingCTAContent: {
flexDirection: 'row', flexDirection: 'row',
@@ -890,6 +1091,7 @@ const styles = StyleSheet.create({
fontSize: 14, fontSize: 14,
color: '#596095', color: '#596095',
letterSpacing: 0.2, letterSpacing: 0.2,
fontFamily: 'AliRegular',
}, },
title: { title: {
marginTop: 10, marginTop: 10,
@@ -897,6 +1099,7 @@ const styles = StyleSheet.create({
fontWeight: '800', fontWeight: '800',
color: '#1c1f3a', color: '#1c1f3a',
textAlign: 'center', textAlign: 'center',
fontFamily: 'AliBold'
}, },
summary: { summary: {
marginTop: 12, marginTop: 12,
@@ -904,6 +1107,7 @@ const styles = StyleSheet.create({
lineHeight: 20, lineHeight: 20,
color: '#7080b4', color: '#7080b4',
textAlign: 'center', textAlign: 'center',
fontFamily: 'AliRegular',
}, },
inlineError: { inlineError: {
marginTop: 12, marginTop: 12,
@@ -950,11 +1154,13 @@ const styles = StyleSheet.create({
fontSize: 15, fontSize: 15,
fontWeight: '600', fontWeight: '600',
color: '#1c1f3a', color: '#1c1f3a',
fontFamily: 'AliBold',
}, },
detailMeta: { detailMeta: {
marginTop: 4, marginTop: 4,
fontSize: 12, fontSize: 12,
color: '#6f7ba7', color: '#6f7ba7',
fontFamily: 'AliRegular',
}, },
avatarRow: { avatarRow: {
flexDirection: 'row', flexDirection: 'row',
@@ -982,6 +1188,7 @@ const styles = StyleSheet.create({
fontSize: 12, fontSize: 12,
color: '#4F5BD5', color: '#4F5BD5',
fontWeight: '600', fontWeight: '600',
fontFamily: 'AliRegular',
}, },
checkInCard: { checkInCard: {
marginTop: 4, marginTop: 4,
@@ -999,12 +1206,14 @@ const styles = StyleSheet.create({
fontSize: 14, fontSize: 14,
fontWeight: '700', fontWeight: '700',
color: '#1c1f3a', color: '#1c1f3a',
fontFamily: 'AliBold',
}, },
checkInSubtitle: { checkInSubtitle: {
marginTop: 4, marginTop: 4,
fontSize: 12, fontSize: 12,
color: '#6f7ba7', color: '#6f7ba7',
lineHeight: 18, lineHeight: 18,
fontFamily: 'AliRegular',
}, },
checkInButton: { checkInButton: {
borderRadius: 18, borderRadius: 18,
@@ -1022,6 +1231,7 @@ const styles = StyleSheet.create({
fontSize: 13, fontSize: 13,
fontWeight: '700', fontWeight: '700',
color: '#ffffff', color: '#ffffff',
fontFamily: 'AliBold',
}, },
checkInButtonLabelDisabled: { checkInButtonLabelDisabled: {
color: '#6f7799', color: '#6f7799',
@@ -1037,11 +1247,13 @@ const styles = StyleSheet.create({
fontSize: 18, fontSize: 18,
fontWeight: '700', fontWeight: '700',
color: '#1c1f3a', color: '#1c1f3a',
fontFamily: 'AliBold',
}, },
sectionAction: { sectionAction: {
fontSize: 13, fontSize: 13,
fontWeight: '600', fontWeight: '600',
color: '#5F6BF0', color: '#5F6BF0',
fontFamily: 'AliBold',
}, },
sectionSubtitle: { sectionSubtitle: {
marginTop: 8, marginTop: 8,
@@ -1049,6 +1261,7 @@ const styles = StyleSheet.create({
fontSize: 13, fontSize: 13,
color: '#6f7ba7', color: '#6f7ba7',
lineHeight: 18, lineHeight: 18,
fontFamily: 'AliRegular',
}, },
rankingCard: { rankingCard: {
marginTop: 20, marginTop: 20,
@@ -1069,17 +1282,20 @@ const styles = StyleSheet.create({
emptyRankingText: { emptyRankingText: {
fontSize: 14, fontSize: 14,
color: '#6f7ba7', color: '#6f7ba7',
fontFamily: 'AliRegular',
}, },
highlightTitle: { highlightTitle: {
fontSize: 16, fontSize: 16,
fontWeight: '700', fontWeight: '700',
color: '#1c1f3a', color: '#1c1f3a',
fontFamily: 'AliBold',
}, },
highlightSubtitle: { highlightSubtitle: {
marginTop: 4, marginTop: 4,
fontSize: 12, fontSize: 12,
color: '#5f6a97', color: '#5f6a97',
lineHeight: 18, lineHeight: 18,
fontFamily: 'AliRegular',
}, },
shareCodeIconButton: { shareCodeIconButton: {
paddingHorizontal: 4, paddingHorizontal: 4,
@@ -1105,10 +1321,38 @@ const styles = StyleSheet.create({
fontSize: 14, fontSize: 14,
fontWeight: '700', fontWeight: '700',
color: '#ffffff', color: '#ffffff',
fontFamily: 'AliBold',
}, },
highlightButtonLabelDisabled: { highlightButtonLabelDisabled: {
color: '#6f7799', color: '#6f7799',
}, },
headerButtons: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
editButton: {
width: 40,
height: 40,
borderRadius: 20,
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
},
editButtonGlass: {
width: 40,
height: 40,
borderRadius: 20,
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
},
fallbackEditButton: {
backgroundColor: 'rgba(255, 255, 255, 0.24)',
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.45)',
},
shareButton: { shareButton: {
width: 40, width: 40,
height: 40, height: 40,
@@ -1131,6 +1375,7 @@ const styles = StyleSheet.create({
missingText: { missingText: {
fontSize: 16, fontSize: 16,
textAlign: 'center', textAlign: 'center',
fontFamily: 'AliRegular',
}, },
retryButton: { retryButton: {
marginTop: 18, marginTop: 18,
@@ -1142,6 +1387,7 @@ const styles = StyleSheet.create({
retryText: { retryText: {
fontSize: 14, fontSize: 14,
fontWeight: '600', fontWeight: '600',
fontFamily: 'AliBold',
}, },
celebrationOverlay: { celebrationOverlay: {
...StyleSheet.absoluteFillObject, ...StyleSheet.absoluteFillObject,
@@ -1185,6 +1431,7 @@ const styles = StyleSheet.create({
textShadowColor: 'rgba(0, 0, 0, 0.3)', textShadowColor: 'rgba(0, 0, 0, 0.3)',
textShadowOffset: { width: 0, height: 2 }, textShadowOffset: { width: 0, height: 2 },
textShadowRadius: 4, textShadowRadius: 4,
fontFamily: 'AliBold',
}, },
shareCardSummary: { shareCardSummary: {
fontSize: 15, fontSize: 15,
@@ -1195,6 +1442,7 @@ const styles = StyleSheet.create({
textShadowColor: 'rgba(0, 0, 0, 0.25)', textShadowColor: 'rgba(0, 0, 0, 0.25)',
textShadowOffset: { width: 0, height: 1 }, textShadowOffset: { width: 0, height: 1 },
textShadowRadius: 3, textShadowRadius: 3,
fontFamily: 'AliRegular',
}, },
shareProgressContainer: { shareProgressContainer: {
backgroundColor: 'rgba(255, 255, 255, 0.95)', backgroundColor: 'rgba(255, 255, 255, 0.95)',
@@ -1229,11 +1477,13 @@ const styles = StyleSheet.create({
fontSize: 14, fontSize: 14,
fontWeight: '600', fontWeight: '600',
color: '#1c1f3a', color: '#1c1f3a',
fontFamily: 'AliBold',
}, },
shareInfoMeta: { shareInfoMeta: {
fontSize: 12, fontSize: 12,
color: '#707baf', color: '#707baf',
marginTop: 2, marginTop: 2,
fontFamily: 'AliRegular',
}, },
shareProgressHeader: { shareProgressHeader: {
flexDirection: 'row', flexDirection: 'row',
@@ -1245,11 +1495,13 @@ const styles = StyleSheet.create({
fontSize: 14, fontSize: 14,
fontWeight: '600', fontWeight: '600',
color: '#1c1f3a', color: '#1c1f3a',
fontFamily: 'AliBold',
}, },
shareProgressValue: { shareProgressValue: {
fontSize: 18, fontSize: 18,
fontWeight: '800', fontWeight: '800',
color: '#5E8BFF', color: '#5E8BFF',
fontFamily: 'AliBold',
}, },
shareProgressTrack: { shareProgressTrack: {
height: 8, height: 8,
@@ -1268,6 +1520,7 @@ const styles = StyleSheet.create({
marginTop: 12, marginTop: 12,
textAlign: 'center', textAlign: 'center',
fontWeight: '500', fontWeight: '500',
fontFamily: 'AliRegular',
}, },
shareCardFooter: { shareCardFooter: {
alignItems: 'center', alignItems: 'center',
@@ -1278,5 +1531,6 @@ const styles = StyleSheet.create({
color: '#ffffff', color: '#ffffff',
opacity: 0.8, opacity: 0.8,
fontWeight: '600', fontWeight: '600',
fontFamily: 'AliBold',
}, },
}); });

View File

@@ -4,6 +4,7 @@ import { HeaderBar } from '@/components/ui/HeaderBar';
import { Colors } from '@/constants/Colors'; import { Colors } from '@/constants/Colors';
import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useColorScheme } from '@/hooks/useColorScheme'; import { useColorScheme } from '@/hooks/useColorScheme';
import { useI18n } from '@/hooks/useI18n';
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding'; import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
import { import {
fetchChallengeDetail, fetchChallengeDetail,
@@ -37,6 +38,7 @@ export default function ChallengeLeaderboardScreen() {
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark'; const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme]; const colorTokens = Colors[theme];
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { t } = useI18n();
const challengeSelector = useMemo(() => (id ? selectChallengeById(id) : undefined), [id]); const challengeSelector = useMemo(() => (id ? selectChallengeById(id) : undefined), [id]);
const challenge = useAppSelector((state) => (challengeSelector ? challengeSelector(state) : undefined)); const challenge = useAppSelector((state) => (challengeSelector ? challengeSelector(state) : undefined));
@@ -75,12 +77,12 @@ export default function ChallengeLeaderboardScreen() {
if (!id) { if (!id) {
return ( return (
<View style={[styles.safeArea, { backgroundColor: colorTokens.background }]}> <View style={[styles.safeArea, { backgroundColor: colorTokens.background }]}>
<HeaderBar title="排行榜" onBack={() => router.back()} withSafeTop /> <HeaderBar title={t('challengeDetail.leaderboard.title')} onBack={() => router.back()} withSafeTop />
<View style={{ <View style={{
paddingTop: safeAreaTop paddingTop: safeAreaTop
}} /> }} />
<View style={styles.missingContainer}> <View style={styles.missingContainer}>
<Text style={[styles.missingText, { color: colorTokens.textSecondary }]}></Text> <Text style={[styles.missingText, { color: colorTokens.textSecondary }]}>{t('challengeDetail.leaderboard.notFound')}</Text>
</View> </View>
</View> </View>
); );
@@ -89,10 +91,10 @@ export default function ChallengeLeaderboardScreen() {
if (detailStatus === 'loading' && !challenge) { if (detailStatus === 'loading' && !challenge) {
return ( return (
<View style={[styles.safeArea, { backgroundColor: '#f3f4fb' }]}> <View style={[styles.safeArea, { backgroundColor: '#f3f4fb' }]}>
<HeaderBar title="排行榜" onBack={() => router.back()} withSafeTop /> <HeaderBar title={t('challengeDetail.leaderboard.title')} onBack={() => router.back()} withSafeTop />
<View style={styles.loadingContainer}> <View style={styles.loadingContainer}>
<ActivityIndicator color={colorTokens.primary} /> <ActivityIndicator color={colorTokens.primary} />
<Text style={[styles.loadingText, { color: colorTokens.textSecondary }]}></Text> <Text style={[styles.loadingText, { color: colorTokens.textSecondary }]}>{t('challengeDetail.leaderboard.loading')}</Text>
</View> </View>
</View> </View>
); );
@@ -131,10 +133,10 @@ export default function ChallengeLeaderboardScreen() {
if (!challenge) { if (!challenge) {
return ( return (
<View style={[styles.safeArea, { backgroundColor: '#f3f4fb' }]}> <View style={[styles.safeArea, { backgroundColor: '#f3f4fb' }]}>
<HeaderBar title="排行榜" onBack={() => router.back()} withSafeTop /> <HeaderBar title={t('challengeDetail.leaderboard.title')} onBack={() => router.back()} withSafeTop />
<View style={styles.missingContainer}> <View style={styles.missingContainer}>
<Text style={[styles.missingText, { color: colorTokens.textSecondary }]}> <Text style={[styles.missingText, { color: colorTokens.textSecondary }]}>
{detailError ?? '暂时无法加载榜单,请稍后再试。'} {detailError ?? t('challengeDetail.leaderboard.loadFailed')}
</Text> </Text>
</View> </View>
</View> </View>
@@ -146,7 +148,7 @@ export default function ChallengeLeaderboardScreen() {
return ( return (
<View style={[styles.safeArea, { backgroundColor: '#f3f4fb' }]}> <View style={[styles.safeArea, { backgroundColor: '#f3f4fb' }]}>
<HeaderBar title="排行榜" onBack={() => router.back()} withSafeTop /> <HeaderBar title={t('challengeDetail.leaderboard.title')} onBack={() => router.back()} withSafeTop />
<ScrollView <ScrollView
style={styles.scrollView} style={styles.scrollView}
contentContainerStyle={{ paddingBottom: insets.bottom + 40, paddingTop: safeAreaTop }} contentContainerStyle={{ paddingBottom: insets.bottom + 40, paddingTop: safeAreaTop }}
@@ -178,7 +180,7 @@ export default function ChallengeLeaderboardScreen() {
{showInitialRankingLoading ? ( {showInitialRankingLoading ? (
<View style={styles.rankingLoading}> <View style={styles.rankingLoading}>
<ActivityIndicator color={colorTokens.primary} /> <ActivityIndicator color={colorTokens.primary} />
<Text style={[styles.loadingText, { color: colorTokens.textSecondary }]}></Text> <Text style={[styles.loadingText, { color: colorTokens.textSecondary }]}>{t('challengeDetail.leaderboard.loading')}</Text>
</View> </View>
) : rankingData.length ? ( ) : rankingData.length ? (
rankingData.map((item, index) => ( rankingData.map((item, index) => (
@@ -196,18 +198,18 @@ export default function ChallengeLeaderboardScreen() {
</View> </View>
) : ( ) : (
<View style={styles.emptyRanking}> <View style={styles.emptyRanking}>
<Text style={styles.emptyRankingText}></Text> <Text style={styles.emptyRankingText}>{t('challengeDetail.leaderboard.empty')}</Text>
</View> </View>
)} )}
{isLoadingMore ? ( {isLoadingMore ? (
<View style={styles.loadMoreIndicator}> <View style={styles.loadMoreIndicator}>
<ActivityIndicator color={colorTokens.primary} size="small" /> <ActivityIndicator color={colorTokens.primary} size="small" />
<Text style={[styles.loadingText, { color: colorTokens.textSecondary, marginTop: 8 }]}></Text> <Text style={[styles.loadingText, { color: colorTokens.textSecondary, marginTop: 8 }]}>{t('challengeDetail.leaderboard.loadMore')}</Text>
</View> </View>
) : null} ) : null}
{rankingLoadMoreStatus === 'failed' ? ( {rankingLoadMoreStatus === 'failed' ? (
<View style={styles.loadMoreIndicator}> <View style={styles.loadMoreIndicator}>
<Text style={styles.loadMoreErrorText}></Text> <Text style={styles.loadMoreErrorText}>{t('challengeDetail.leaderboard.loadMoreFailed')}</Text>
</View> </View>
) : null} ) : null}
</View> </View>

View File

@@ -1,10 +1,11 @@
import { Image } from '@/components/ui/Image';
import i18n from '@/i18n';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { BlurView } from 'expo-blur'; import { BlurView } from 'expo-blur';
import * as Clipboard from 'expo-clipboard'; import * as Clipboard from 'expo-clipboard';
import { Image } from 'expo-image';
import * as ImagePicker from 'expo-image-picker'; import * as ImagePicker from 'expo-image-picker';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import { useRouter } from 'expo-router'; import { useLocalSearchParams, useRouter } from 'expo-router';
import React, { useCallback, useEffect, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { import {
Alert, Alert,
@@ -27,22 +28,33 @@ import { Colors } from '@/constants/Colors';
import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useColorScheme } from '@/hooks/useColorScheme'; import { useColorScheme } from '@/hooks/useColorScheme';
import { useCosUpload } from '@/hooks/useCosUpload'; import { useCosUpload } from '@/hooks/useCosUpload';
import { ChallengeType, type CreateCustomChallengePayload } from '@/services/challengesApi'; import { useI18n } from '@/hooks/useI18n';
import {
ChallengeType,
type CreateCustomChallengePayload,
type UpdateCustomChallengePayload,
} from '@/services/challengesApi';
import { store } from '@/store';
import { import {
createCustomChallengeThunk, createCustomChallengeThunk,
fetchChallenges, fetchChallenges,
selectChallengeById,
selectCreateChallengeError, selectCreateChallengeError,
selectCreateChallengeStatus, selectCreateChallengeStatus,
selectUpdateChallengeError,
selectUpdateChallengeStatus,
updateCustomChallengeThunk
} from '@/store/challengesSlice'; } from '@/store/challengesSlice';
import { Toast } from '@/utils/toast.utils'; import { Toast } from '@/utils/toast.utils';
const typeOptions: { value: ChallengeType; label: string; accent: string }[] = [ const getTypeOptions = (t: (key: string) => string): { value: ChallengeType; label: string; accent: string }[] => [
{ value: ChallengeType.WATER, label: '喝水', accent: '#5E8BFF' }, { value: ChallengeType.WATER, label: t('challenges.createCustom.typeLabels.water'), accent: '#5E8BFF' },
{ value: ChallengeType.EXERCISE, label: '运动', accent: '#6B6CFF' }, { value: ChallengeType.EXERCISE, label: t('challenges.createCustom.typeLabels.exercise'), accent: '#6B6CFF' },
{ value: ChallengeType.DIET, label: '饮食', accent: '#38BDF8' }, { value: ChallengeType.DIET, label: t('challenges.createCustom.typeLabels.diet'), accent: '#38BDF8' },
{ value: ChallengeType.SLEEP, label: '睡眠', accent: '#7C3AED' }, { value: ChallengeType.SLEEP, label: t('challenges.createCustom.typeLabels.sleep'), accent: '#7C3AED' },
{ value: ChallengeType.MOOD, label: '心情', accent: '#F97316' }, { value: ChallengeType.MOOD, label: t('challenges.createCustom.typeLabels.mood'), accent: '#F97316' },
{ value: ChallengeType.WEIGHT, label: '体重', accent: '#22C55E' }, { value: ChallengeType.WEIGHT, label: t('challenges.createCustom.typeLabels.weight'), accent: '#22C55E' },
{ value: ChallengeType.CUSTOM, label: t('challenges.createCustom.typeLabels.custom'), accent: '#8B5CF6' },
]; ];
const FALLBACK_IMAGE = const FALLBACK_IMAGE =
@@ -51,6 +63,9 @@ const FALLBACK_IMAGE =
type PickerType = 'start' | 'end' | null; type PickerType = 'start' | 'end' | null;
export default function CreateCustomChallengeScreen() { export default function CreateCustomChallengeScreen() {
const { id, mode } = useLocalSearchParams<{ id?: string; mode?: 'edit' }>();
const isEditMode = mode === 'edit' && !!id;
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark'; const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme]; const colorTokens = Colors[theme];
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
@@ -58,7 +73,14 @@ export default function CreateCustomChallengeScreen() {
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const createStatus = useAppSelector(selectCreateChallengeStatus); const createStatus = useAppSelector(selectCreateChallengeStatus);
const createError = useAppSelector(selectCreateChallengeError); const createError = useAppSelector(selectCreateChallengeError);
const updateError = useAppSelector(selectUpdateChallengeError);
const updateStatus = useAppSelector(selectUpdateChallengeStatus);
const inlineError = isEditMode ? updateError : createError;
const isCreating = createStatus === 'loading'; const isCreating = createStatus === 'loading';
const isUpdating = updateStatus === 'loading';
const { t } = useI18n();
const typeOptions = useMemo(() => getTypeOptions(t), [t]);
const today = useMemo(() => dayjs().startOf('day').toDate(), []); const today = useMemo(() => dayjs().startOf('day').toDate(), []);
const defaultEnd = useMemo(() => dayjs().add(21, 'day').startOf('day').toDate(), []); const defaultEnd = useMemo(() => dayjs().add(21, 'day').startOf('day').toDate(), []);
@@ -74,10 +96,10 @@ export default function CreateCustomChallengeScreen() {
const [minimumCheckInDays, setMinimumCheckInDays] = useState(''); const [minimumCheckInDays, setMinimumCheckInDays] = useState('');
const [requirementLabel, setRequirementLabel] = useState(''); const [requirementLabel, setRequirementLabel] = useState('');
const [summary, setSummary] = useState(''); const [summary, setSummary] = useState('');
const [progressUnit] = useState(''); const [progressUnit, setProgressUnit] = useState('');
const [periodLabel, setPeriodLabel] = useState(''); const [periodLabel, setPeriodLabel] = useState('');
const [periodEdited, setPeriodEdited] = useState(false); const [periodEdited, setPeriodEdited] = useState(false);
const [rankingDescription] = useState('连续打卡榜'); const [rankingDescription] = useState(t('challenges.createCustom.rankingDescription'));
const [isPublic, setIsPublic] = useState(true); const [isPublic, setIsPublic] = useState(true);
const [maxParticipants, setMaxParticipants] = useState('100'); const [maxParticipants, setMaxParticipants] = useState('100');
const [minimumEdited, setMinimumEdited] = useState(false); const [minimumEdited, setMinimumEdited] = useState(false);
@@ -88,6 +110,28 @@ export default function CreateCustomChallengeScreen() {
const [pickerType, setPickerType] = useState<PickerType>(null); const [pickerType, setPickerType] = useState<PickerType>(null);
// 编辑模式下预填充数据
useEffect(() => {
if (isEditMode && id) {
const challengeSelector = selectChallengeById(id);
const challenge = challengeSelector(store.getState());
if (challenge) {
setTitle(challenge.title || '');
setImage(challenge.image);
setType(challenge.type);
setStartDate(new Date(challenge.startAt || Date.now()));
setEndDate(new Date(challenge.endAt || Date.now()));
setTargetValue(String(challenge.progress?.target || ''));
setMinimumCheckInDays(String(challenge.minimumCheckInDays || ''));
setSummary(challenge.summary || '');
setProgressUnit(challenge.unit || '');
setPeriodLabel(challenge.periodLabel || '');
setIsPublic(challenge.isPublic ?? true);
setMaxParticipants(challenge.maxParticipants?.toString() || '100');
}
}
}, [isEditMode, id]);
const durationDays = useMemo( const durationDays = useMemo(
() => () =>
Math.max( Math.max(
@@ -96,16 +140,16 @@ export default function CreateCustomChallengeScreen() {
), ),
[startDate, endDate] [startDate, endDate]
); );
const durationLabel = useMemo(() => `持续${durationDays}`, [durationDays]); const durationLabel = useMemo(() => t('challenges.createCustom.durationDays', { days: durationDays }), [durationDays, t]);
useEffect(() => { useEffect(() => {
if (!periodEdited) { if (!periodEdited) {
setPeriodLabel(`${durationDays}天挑战`); setPeriodLabel(t('challenges.createCustom.durationDaysChallenge', { days: durationDays }));
} }
if (!minimumEdited) { if (!minimumEdited) {
setMinimumCheckInDays(String(durationDays)); setMinimumCheckInDays(String(durationDays));
} }
}, [durationDays, minimumEdited, periodEdited]); }, [durationDays, minimumEdited, periodEdited, t]);
const handleConfirmDate = (date: Date) => { const handleConfirmDate = (date: Date) => {
if (!pickerType) return; if (!pickerType) return;
@@ -128,47 +172,43 @@ export default function CreateCustomChallengeScreen() {
}; };
const handleSubmit = async () => { const handleSubmit = async () => {
if (isCreating) return; if (isCreating || isUpdating) return;
if (!title.trim()) { if (!title.trim()) {
Toast.warning('请填写挑战标题'); Toast.warning(t('challenges.createCustom.alerts.titleRequired'));
return; return;
} }
if (!requirementLabel.trim()) {
Toast.warning('请填写挑战要求说明');
return;
}
const startTimestamp = dayjs(startDate).valueOf(); const startTimestamp = dayjs(startDate).valueOf();
const endTimestamp = dayjs(endDate).valueOf(); const endTimestamp = dayjs(endDate).valueOf();
if (endTimestamp <= startTimestamp) { if (endTimestamp <= startTimestamp) {
Toast.warning('结束时间需要晚于开始时间'); Toast.warning(t('challenges.createCustom.alerts.endTimeError'));
return; return;
} }
const target = Number(targetValue); const target = Number(targetValue);
if (!Number.isFinite(target) || target < 1 || target > 1000) { if (!Number.isFinite(target) || target < 1 || target > 1000) {
Toast.warning('每日目标值需在 1-1000 之间'); Toast.warning(t('challenges.createCustom.alerts.targetValueError'));
return; return;
} }
const minDays = Number(minimumCheckInDays) || durationDays; const minDays = Number(minimumCheckInDays) || durationDays;
if (!Number.isFinite(minDays) || minDays < 1 || minDays > 365) { if (!Number.isFinite(minDays) || minDays < 1 || minDays > 365) {
Toast.warning('最少打卡天数需在 1-365 之间'); Toast.warning(t('challenges.createCustom.alerts.minimumDaysError'));
return; return;
} }
if (minDays > durationDays) { if (minDays > durationDays) {
Toast.warning('最少打卡天数不能超过持续天数'); Toast.warning(t('challenges.createCustom.alerts.minimumDaysExceedError'));
return; return;
} }
const maxP = maxParticipants ? Number(maxParticipants) : null; const maxP = maxParticipants ? Number(maxParticipants) : null;
if (maxP !== null && (!Number.isFinite(maxP) || maxP < 2 || maxP > 10000)) { if (maxP !== null && (!Number.isFinite(maxP) || maxP < 2 || maxP > 10000)) {
Toast.warning('参与人数需在 2-10000 之间,或留空表示无限制'); Toast.warning(t('challenges.createCustom.alerts.participantsError'));
return; return;
} }
const safeTitle = title.trim() || '自定义挑战'; const safeTitle = title.trim() || t('challenges.createCustom.defaultTitle');
const payload: CreateCustomChallengePayload = { const payload: CreateCustomChallengePayload = {
title: safeTitle, title: safeTitle,
type, type,
@@ -178,24 +218,39 @@ export default function CreateCustomChallengeScreen() {
targetValue: target, targetValue: target,
minimumCheckInDays: minDays, minimumCheckInDays: minDays,
durationLabel, durationLabel,
requirementLabel: requirementLabel.trim() || '请填写挑战要求', requirementLabel: '',
summary: summary.trim() || undefined, summary: summary.trim() || undefined,
progressUnit: progressUnit.trim() || '天', progressUnit: progressUnit.trim(),
periodLabel: periodLabel.trim() || undefined, periodLabel: periodLabel.trim() || undefined,
rankingDescription: rankingDescription.trim() || undefined, rankingDescription: rankingDescription.trim() || undefined,
isPublic, isPublic,
maxParticipants: maxP, maxParticipants: maxP,
}; };
const updatePayload: UpdateCustomChallengePayload = {
title: safeTitle,
image: image?.trim() || undefined,
summary: summary.trim() || undefined,
isPublic,
maxParticipants: maxP ?? undefined,
};
try { try {
if (isEditMode && id) {
await dispatch(updateCustomChallengeThunk({ id, payload: updatePayload })).unwrap();
Toast.success(t('challenges.createCustom.alerts.updateSuccess'));
dispatch(fetchChallenges());
return;
}
const created = await dispatch(createCustomChallengeThunk(payload)).unwrap(); const created = await dispatch(createCustomChallengeThunk(payload)).unwrap();
setShareCode(created.shareCode ?? null); setShareCode(created.shareCode ?? null);
setCreatedChallengeId(created.id); setCreatedChallengeId(created.id);
setShareModalVisible(true); setShareModalVisible(true);
Toast.success('自定义挑战已创建'); Toast.success(t('challenges.createCustom.alerts.createSuccess'));
dispatch(fetchChallenges()); dispatch(fetchChallenges());
} catch (error) { } catch (error) {
const message = typeof error === 'string' ? error : '创建失败,请稍后再试'; const message = typeof error === 'string' ? error : t('challenges.createCustom.alerts.createFailed');
Toast.error(message); Toast.error(message);
} }
}; };
@@ -203,7 +258,7 @@ export default function CreateCustomChallengeScreen() {
const handleCopyShareCode = async () => { const handleCopyShareCode = async () => {
if (!shareCode) return; if (!shareCode) return;
await Clipboard.setStringAsync(shareCode); await Clipboard.setStringAsync(shareCode);
Toast.success('邀请码已复制'); Toast.success(t('challenges.createCustom.shareModal.copyCode'));
}; };
const handleTargetInputChange = (value: string) => { const handleTargetInputChange = (value: string) => {
@@ -235,16 +290,16 @@ export default function CreateCustomChallengeScreen() {
const handlePickImage = useCallback(() => { const handlePickImage = useCallback(() => {
Alert.alert( Alert.alert(
'选择封面图', t('challenges.createCustom.imageUpload.selectSource'),
'请选择封面来源', t('challenges.createCustom.imageUpload.selectMessage'),
[ [
{ {
text: '拍照', text: t('challenges.createCustom.imageUpload.camera'),
onPress: async () => { onPress: async () => {
try { try {
const permission = await ImagePicker.requestCameraPermissionsAsync(); const permission = await ImagePicker.requestCameraPermissionsAsync();
if (permission.status !== 'granted') { if (permission.status !== 'granted') {
Alert.alert('权限不足', '需要相机权限以拍摄封面'); Alert.alert(t('challenges.createCustom.imageUpload.cameraPermission'), t('challenges.createCustom.imageUpload.cameraPermissionMessage'));
return; return;
} }
const result = await ImagePicker.launchCameraAsync({ const result = await ImagePicker.launchCameraAsync({
@@ -269,21 +324,21 @@ export default function CreateCustomChallengeScreen() {
setImagePreview(null); setImagePreview(null);
} catch (error) { } catch (error) {
console.error('[CHALLENGE] 封面上传失败', error); console.error('[CHALLENGE] 封面上传失败', error);
Alert.alert('上传失败', '封面上传失败,请稍后重试'); Alert.alert(t('challenges.createCustom.imageUpload.uploadFailed'), t('challenges.createCustom.imageUpload.uploadFailedMessage'));
} }
} catch (error) { } catch (error) {
console.error('[CHALLENGE] 拍照失败', error); console.error('[CHALLENGE] 拍照失败', error);
Alert.alert('拍照失败', '无法打开相机,请稍后再试'); Alert.alert(t('challenges.createCustom.imageUpload.cameraFailed'), t('challenges.createCustom.imageUpload.cameraFailedMessage'));
} }
}, },
}, },
{ {
text: '从相册选择', text: t('challenges.createCustom.imageUpload.album'),
onPress: async () => { onPress: async () => {
try { try {
const permission = await ImagePicker.requestMediaLibraryPermissionsAsync(); const permission = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (permission.status !== 'granted') { if (permission.status !== 'granted') {
Alert.alert('权限不足', '需要相册权限以选择封面'); Alert.alert(t('challenges.createCustom.imageUpload.cameraPermission'), t('challenges.createCustom.imageUpload.albumPermissionMessage'));
return; return;
} }
const result = await ImagePicker.launchImageLibraryAsync({ const result = await ImagePicker.launchImageLibraryAsync({
@@ -307,19 +362,19 @@ export default function CreateCustomChallengeScreen() {
setImagePreview(null); setImagePreview(null);
} catch (error) { } catch (error) {
console.error('[CHALLENGE] 封面上传失败', error); console.error('[CHALLENGE] 封面上传失败', error);
Alert.alert('上传失败', '封面上传失败,请稍后重试'); Alert.alert(t('challenges.createCustom.imageUpload.uploadFailed'), t('challenges.createCustom.imageUpload.uploadFailedMessage'));
} }
} catch (error) { } catch (error) {
console.error('[CHALLENGE] 选择封面失败', error); console.error('[CHALLENGE] 选择封面失败', error);
Alert.alert('选择失败', '无法打开相册,请稍后再试'); Alert.alert(t('challenges.createCustom.imageUpload.selectFailed'), t('challenges.createCustom.imageUpload.selectFailedMessage'));
} }
}, },
}, },
{ text: '取消', style: 'cancel' }, { text: t('challenges.createCustom.imageUpload.cancel'), style: 'cancel' },
], ],
{ cancelable: true } { cancelable: true }
); );
}, [upload]); }, [upload, t]);
const handleViewChallenge = () => { const handleViewChallenge = () => {
setShareModalVisible(false); setShareModalVisible(false);
@@ -370,7 +425,7 @@ export default function CreateCustomChallengeScreen() {
</View> </View>
); );
const progressMeta = `${durationDays} · ${progressUnit || ''}`; const progressMeta = `${durationDays} ${t('challenges.createCustom.dayUnit')}${progressUnit ? ` · ${progressUnit}` : ''}`;
const heroImageSource = imagePreview || image || FALLBACK_IMAGE; const heroImageSource = imagePreview || image || FALLBACK_IMAGE;
return ( return (
@@ -379,7 +434,7 @@ export default function CreateCustomChallengeScreen() {
colors={[colorTokens.backgroundGradientStart, colorTokens.backgroundGradientEnd]} colors={[colorTokens.backgroundGradientStart, colorTokens.backgroundGradientEnd]}
style={StyleSheet.absoluteFillObject} style={StyleSheet.absoluteFillObject}
/> />
<HeaderBar title="新建挑战" transparent /> <HeaderBar title={isEditMode ? t('challenges.createCustom.editTitle') : t('challenges.createCustom.title')} transparent />
<KeyboardAvoidingView <KeyboardAvoidingView
style={{ flex: 1 }} style={{ flex: 1 }}
behavior={Platform.OS === 'ios' ? 'padding' : undefined} behavior={Platform.OS === 'ios' ? 'padding' : undefined}
@@ -403,20 +458,20 @@ export default function CreateCustomChallengeScreen() {
style={StyleSheet.absoluteFillObject} style={StyleSheet.absoluteFillObject}
/> />
<View style={styles.heroOverlay}> <View style={styles.heroOverlay}>
<Text style={styles.heroKicker}></Text> <Text style={styles.heroKicker}>{t('challenges.customChallenges')}</Text>
<Text style={styles.heroTitle}>{title || '你的专属挑战'}</Text> <Text style={styles.heroTitle}>{title || t('challenges.createCustom.yourChallenge')}</Text>
<Text style={styles.heroMeta}>{progressMeta}</Text> <Text style={styles.heroMeta}>{progressMeta}</Text>
</View> </View>
</View> </View>
<View style={styles.formCard}> <View style={styles.formCard}>
<View style={styles.formHeader}> <View style={styles.formHeader}>
<Text style={styles.sectionTitle}></Text> <Text style={styles.sectionTitle}>{t('challenges.createCustom.basicInfo')}</Text>
{createError ? <Text style={styles.inlineError}>{createError}</Text> : null} {inlineError ? <Text style={styles.inlineError}>{inlineError}</Text> : null}
</View> </View>
{renderField('标题', title, setTitle, '挑战标题最多100字')} {renderField(t('challenges.createCustom.fields.title'), title, setTitle, t('challenges.createCustom.fields.titlePlaceholder'))}
<View style={styles.fieldBlock}> <View style={styles.fieldBlock}>
<Text style={styles.fieldLabel}></Text> <Text style={styles.fieldLabel}>{t('challenges.createCustom.fields.coverImage')}</Text>
<View style={styles.uploadRow}> <View style={styles.uploadRow}>
<TouchableOpacity <TouchableOpacity
activeOpacity={0.9} activeOpacity={0.9}
@@ -424,7 +479,7 @@ export default function CreateCustomChallengeScreen() {
onPress={handlePickImage} onPress={handlePickImage}
disabled={uploading} disabled={uploading}
> >
<Text style={styles.uploadButtonLabel}>{uploading ? '上传中…' : '上传封面'}</Text> <Text style={styles.uploadButtonLabel}>{uploading ? t('challenges.createCustom.imageUpload.uploading') : t('challenges.createCustom.fields.uploadCover')}</Text>
</TouchableOpacity> </TouchableOpacity>
{image || imagePreview ? ( {image || imagePreview ? (
<TouchableOpacity <TouchableOpacity
@@ -434,20 +489,20 @@ export default function CreateCustomChallengeScreen() {
setImage(undefined); setImage(undefined);
}} }}
> >
<Text style={styles.clearUpload}></Text> <Text style={styles.clearUpload}>{t('challenges.createCustom.imageUpload.clear')}</Text>
</TouchableOpacity> </TouchableOpacity>
) : null} ) : null}
</View> </View>
<Text style={styles.helperText}> 16:9</Text> <Text style={styles.helperText}>{t('challenges.createCustom.imageUpload.helper')}</Text>
</View> </View>
{renderTextarea('挑战说明', summary, setSummary, '简单介绍这个挑战的目标与要求')} {renderTextarea(t('challenges.createCustom.fields.challengeDescription'), summary, setSummary, t('challenges.createCustom.fields.descriptionPlaceholder'))}
</View> </View>
<View style={styles.formCard}> <View style={styles.formCard}>
<Text style={styles.sectionTitle}></Text> <Text style={styles.sectionTitle}>{t('challenges.createCustom.challengeSettings')}</Text>
<View style={styles.fieldBlock}> <View style={styles.fieldBlock}>
<Text style={styles.fieldLabel}></Text> <Text style={styles.fieldLabel}>{t('challenges.createCustom.fields.challengeType')}</Text>
<View style={styles.chipRow}> <View style={styles.chipRow}>
{typeOptions.map((option) => { {typeOptions.map((option) => {
const active = option.value === type; const active = option.value === type;
@@ -455,16 +510,19 @@ export default function CreateCustomChallengeScreen() {
<TouchableOpacity <TouchableOpacity
key={option.value} key={option.value}
activeOpacity={0.9} activeOpacity={0.9}
onPress={() => setType(option.value)} onPress={() => !isEditMode && setType(option.value)}
disabled={isEditMode}
style={[ style={[
styles.chip, styles.chip,
active && { backgroundColor: `${option.accent}1A`, borderColor: option.accent }, active && { backgroundColor: `${option.accent}1A`, borderColor: option.accent },
isEditMode && styles.chipDisabled,
]} ]}
> >
<Text <Text
style={[ style={[
styles.chipLabel, styles.chipLabel,
active && { color: option.accent, fontWeight: '700' }, active && { color: option.accent, fontWeight: '700' },
isEditMode && styles.chipLabelDisabled,
]} ]}
> >
{option.label} {option.label}
@@ -473,17 +531,18 @@ export default function CreateCustomChallengeScreen() {
); );
})} })}
</View> </View>
<Text style={styles.helperText}>{t('challenges.createCustom.fields.challengeTypeHelper')}</Text>
</View> </View>
<View style={styles.fieldBlock}> <View style={styles.fieldBlock}>
<Text style={styles.fieldLabel}></Text> <Text style={styles.fieldLabel}>{t('challenges.createCustom.fields.timeRange')}</Text>
<View style={styles.dateRow}> <View style={styles.dateRow}>
<TouchableOpacity <TouchableOpacity
activeOpacity={0.9} activeOpacity={0.9}
style={styles.datePill} style={styles.datePill}
onPress={() => setPickerType('start')} onPress={() => setPickerType('start')}
> >
<Text style={styles.dateLabel}></Text> <Text style={styles.dateLabel}>{t('challenges.createCustom.fields.start')}</Text>
<Text style={styles.dateValue}>{dayjs(startDate).format('YYYY.MM.DD')}</Text> <Text style={styles.dateValue}>{dayjs(startDate).format('YYYY.MM.DD')}</Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity
@@ -491,7 +550,7 @@ export default function CreateCustomChallengeScreen() {
style={styles.datePill} style={styles.datePill}
onPress={() => setPickerType('end')} onPress={() => setPickerType('end')}
> >
<Text style={styles.dateLabel}></Text> <Text style={styles.dateLabel}>{t('challenges.createCustom.fields.end')}</Text>
<Text style={styles.dateValue}>{dayjs(endDate).format('YYYY.MM.DD')}</Text> <Text style={styles.dateValue}>{dayjs(endDate).format('YYYY.MM.DD')}</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
@@ -499,48 +558,59 @@ export default function CreateCustomChallengeScreen() {
<View style={styles.inlineFields}> <View style={styles.inlineFields}>
<View style={styles.fieldBlock}> <View style={styles.fieldBlock}>
<Text style={styles.fieldLabel}></Text> <Text style={styles.fieldLabel}>{t('challenges.createCustom.fields.duration')}</Text>
<View style={styles.readonlyPill}> <View style={styles.readonlyPill}>
<Text style={styles.readonlyText}>{durationLabel}</Text> <Text style={styles.readonlyText}>{durationLabel}</Text>
</View> </View>
</View> </View>
{renderField('周期标签', periodLabel, (v) => { {renderField(t('challenges.createCustom.fields.periodLabel'), periodLabel, (v) => {
setPeriodEdited(true); setPeriodEdited(true);
setPeriodLabel(v); setPeriodLabel(v);
}, '如21天挑战')} }, t('challenges.createCustom.fields.periodLabelPlaceholder'))}
</View> </View>
<View style={styles.inlineFields}> <View style={styles.fieldBlock}>
{renderField('每日目标值', targetValue, handleTargetInputChange, '如8', 'numeric')} <Text style={styles.fieldLabel}>{t('challenges.createCustom.fields.dailyTargetAndUnit')}</Text>
<View style={styles.fieldBlock}> <View style={styles.targetUnitRow}>
<Text style={styles.fieldLabel}></Text> <TextInput
<View style={styles.readonlyPill}> value={targetValue}
<Text style={styles.readonlyText}>{progressUnit}</Text> onChangeText={handleTargetInputChange}
</View> placeholder={t('challenges.createCustom.fields.dailyTargetPlaceholder')}
placeholderTextColor="#9ca3af"
style={[styles.input, styles.targetInput]}
keyboardType="numeric"
/>
<TextInput
value={progressUnit}
onChangeText={setProgressUnit}
placeholder={t('challenges.createCustom.fields.unitPlaceholder')}
placeholderTextColor="#9ca3af"
style={[styles.input, styles.unitInput]}
/>
</View> </View>
<Text style={styles.helperText}>{t('challenges.createCustom.fields.unitHelper')}</Text>
</View> </View>
{renderField('最少打卡天数', minimumCheckInDays, handleMinimumDaysChange, '至少1天', 'numeric')} {renderField(t('challenges.createCustom.fields.minimumCheckInDays'), minimumCheckInDays, handleMinimumDaysChange, t('challenges.createCustom.fields.minimumCheckInDaysPlaceholder'), 'numeric')}
{renderField('挑战要求说明', requirementLabel, setRequirementLabel, '例如:每日完成 30 分钟运动')}
</View> </View>
<View style={styles.formCard}> <View style={styles.formCard}>
<Text style={styles.sectionTitle}>&</Text> <Text style={styles.sectionTitle}>{t('challenges.createCustom.displayInteraction')}</Text>
<View style={styles.inlineFields}> <View style={styles.inlineFields}>
{renderField('参与人数上限', maxParticipants, (v) => { {renderField(t('challenges.createCustom.fields.maxParticipants'), maxParticipants, (v) => {
const digits = v.replace(/\D/g, ''); const digits = v.replace(/\D/g, '');
if (!digits) { if (!digits) {
setMaxParticipants(''); setMaxParticipants('');
return; return;
} }
setMaxParticipants(String(parseInt(digits, 10))); setMaxParticipants(String(parseInt(digits, 10)));
}, '留空表示无限制', 'numeric')} }, t('challenges.createCustom.fields.noLimit'), 'numeric')}
</View> </View>
<View style={styles.switchRow}> <View style={styles.switchRow}>
<View> <View>
<Text style={styles.fieldLabel}></Text> <Text style={styles.fieldLabel}>{t('challenges.createCustom.fields.isPublic')}</Text>
<Text style={styles.switchHint}></Text> <Text style={styles.switchHint}>{t('challenges.createCustom.fields.publicDescription')}</Text>
</View> </View>
<Switch <Switch
value={isPublic} value={isPublic}
@@ -557,14 +627,24 @@ export default function CreateCustomChallengeScreen() {
<BlurView intensity={14} tint="light" style={styles.floatingBlur}> <BlurView intensity={14} tint="light" style={styles.floatingBlur}>
<View style={styles.floatingContent}> <View style={styles.floatingContent}>
<View style={styles.floatingCopy}> <View style={styles.floatingCopy}>
<Text style={styles.floatingTitle}></Text> <Text style={styles.floatingTitle}>
<Text style={styles.floatingSubtitle}></Text> {isEditMode
? t('challenges.createCustom.floatingCTA.editTitle')
: t('challenges.createCustom.floatingCTA.title')
}
</Text>
<Text style={styles.floatingSubtitle}>
{isEditMode
? t('challenges.createCustom.floatingCTA.editSubtitle')
: t('challenges.createCustom.floatingCTA.subtitle')
}
</Text>
</View> </View>
<TouchableOpacity <TouchableOpacity
activeOpacity={0.9} activeOpacity={0.9}
style={styles.floatingButton} style={styles.floatingButton}
onPress={handleSubmit} onPress={handleSubmit}
disabled={isCreating} disabled={isCreating || isUpdating}
> >
<LinearGradient <LinearGradient
colors={['#5E8BFF', '#6B6CFF']} colors={['#5E8BFF', '#6B6CFF']}
@@ -573,7 +653,14 @@ export default function CreateCustomChallengeScreen() {
style={styles.floatingButtonBackground} style={styles.floatingButtonBackground}
> >
<Text style={styles.floatingButtonLabel}> <Text style={styles.floatingButtonLabel}>
{isCreating ? '创建中…' : '创建并生成邀请码'} {isCreating
? t('challenges.createCustom.buttons.creating')
: isUpdating
? t('challenges.createCustom.buttons.updating')
: isEditMode
? t('challenges.createCustom.buttons.updateAndSave')
: t('challenges.createCustom.buttons.createAndGenerateCode')
}
</Text> </Text>
</LinearGradient> </LinearGradient>
</TouchableOpacity> </TouchableOpacity>
@@ -588,6 +675,9 @@ export default function CreateCustomChallengeScreen() {
minimumDate={pickerType === 'end' ? dayjs(startDate).add(1, 'day').toDate() : dayjs().add(1, 'day').toDate()} minimumDate={pickerType === 'end' ? dayjs(startDate).add(1, 'day').toDate() : dayjs().add(1, 'day').toDate()}
onConfirm={handleConfirmDate} onConfirm={handleConfirmDate}
onCancel={() => setPickerType(null)} onCancel={() => setPickerType(null)}
locale={i18n.language}
confirmTextIOS={t('challenges.createCustom.datePicker.confirm')}
cancelTextIOS={t('challenges.createCustom.datePicker.cancel')}
/> />
<Modal <Modal
@@ -598,10 +688,10 @@ export default function CreateCustomChallengeScreen() {
> >
<View style={styles.modalOverlay}> <View style={styles.modalOverlay}>
<View style={styles.shareCard}> <View style={styles.shareCard}>
<Text style={styles.shareTitle}></Text> <Text style={styles.shareTitle}>{t('challenges.createCustom.shareModal.title')}</Text>
<Text style={styles.shareSubtitle}></Text> <Text style={styles.shareSubtitle}>{t('challenges.createCustom.shareModal.subtitle')}</Text>
<View style={styles.shareCodeBadge}> <View style={styles.shareCodeBadge}>
<Text style={styles.shareCode}>{shareCode ?? '获取中…'}</Text> <Text style={styles.shareCode}>{shareCode ?? t('challenges.createCustom.shareModal.generatingCode')}</Text>
</View> </View>
<View style={styles.shareActions}> <View style={styles.shareActions}>
<TouchableOpacity <TouchableOpacity
@@ -610,7 +700,7 @@ export default function CreateCustomChallengeScreen() {
onPress={handleCopyShareCode} onPress={handleCopyShareCode}
disabled={!shareCode} disabled={!shareCode}
> >
<Text style={styles.shareButtonGhostLabel}></Text> <Text style={styles.shareButtonGhostLabel}>{t('challenges.createCustom.shareModal.copyCode')}</Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity
activeOpacity={0.9} activeOpacity={0.9}
@@ -623,7 +713,7 @@ export default function CreateCustomChallengeScreen() {
end={{ x: 1, y: 1 }} end={{ x: 1, y: 1 }}
style={styles.shareButtonPrimary} style={styles.shareButtonPrimary}
> >
<Text style={styles.shareButtonPrimaryLabel}></Text> <Text style={styles.shareButtonPrimaryLabel}>{t('challenges.createCustom.shareModal.viewChallenge')}</Text>
</LinearGradient> </LinearGradient>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
@@ -632,7 +722,7 @@ export default function CreateCustomChallengeScreen() {
activeOpacity={0.8} activeOpacity={0.8}
onPress={() => setShareModalVisible(false)} onPress={() => setShareModalVisible(false)}
> >
<Text style={styles.shareCloseLabel}></Text> <Text style={styles.shareCloseLabel}>{t('challenges.createCustom.shareModal.later')}</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</View> </View>
@@ -720,6 +810,16 @@ const styles = StyleSheet.create({
fontSize: 15, fontSize: 15,
color: '#111827', color: '#111827',
}, },
targetUnitRow: {
flexDirection: 'row',
gap: 12,
},
targetInput: {
flex: 1,
},
unitInput: {
flex: 1,
},
textarea: { textarea: {
minHeight: 90, minHeight: 90,
}, },
@@ -736,10 +836,17 @@ const styles = StyleSheet.create({
borderWidth: 1, borderWidth: 1,
borderColor: '#e5e7eb', borderColor: '#e5e7eb',
}, },
chipDisabled: {
opacity: 0.5,
backgroundColor: '#f1f5f9',
},
chipLabel: { chipLabel: {
fontSize: 13, fontSize: 13,
color: '#334155', color: '#334155',
}, },
chipLabelDisabled: {
color: '#94a3b8',
},
uploadRow: { uploadRow: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',

View File

@@ -25,6 +25,7 @@ const CIRCUMFERENCE_TYPES = [
{ key: 'calfCircumference', label: '小腿围', color: '#DDA0DD' }, { key: 'calfCircumference', label: '小腿围', color: '#DDA0DD' },
]; ];
import { useI18n } from '@/hooks/useI18n';
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding'; import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
import { CircumferencePeriod } from '@/services/circumferenceAnalysis'; import { CircumferencePeriod } from '@/services/circumferenceAnalysis';
@@ -35,6 +36,7 @@ export default function CircumferenceDetailScreen() {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const userProfile = useAppSelector(selectUserProfile); const userProfile = useAppSelector(selectUserProfile);
const { ensureLoggedIn } = useAuthGuard(); const { ensureLoggedIn } = useAuthGuard();
const { t } = useI18n();
// 日期相关状态 // 日期相关状态
const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth()); const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth());
@@ -78,37 +80,37 @@ export default function CircumferenceDetailScreen() {
const measurements = [ const measurements = [
{ {
key: 'chestCircumference', key: 'chestCircumference',
label: '胸围', label: t('circumferenceDetail.measurements.chest'),
value: userProfile?.chestCircumference, value: userProfile?.chestCircumference,
color: '#FF6B6B', color: '#FF6B6B',
}, },
{ {
key: 'waistCircumference', key: 'waistCircumference',
label: '腰围', label: t('circumferenceDetail.measurements.waist'),
value: userProfile?.waistCircumference, value: userProfile?.waistCircumference,
color: '#4ECDC4', color: '#4ECDC4',
}, },
{ {
key: 'upperHipCircumference', key: 'upperHipCircumference',
label: '上臀围', label: t('circumferenceDetail.measurements.upperHip'),
value: userProfile?.upperHipCircumference, value: userProfile?.upperHipCircumference,
color: '#45B7D1', color: '#45B7D1',
}, },
{ {
key: 'armCircumference', key: 'armCircumference',
label: '臂围', label: t('circumferenceDetail.measurements.arm'),
value: userProfile?.armCircumference, value: userProfile?.armCircumference,
color: '#96CEB4', color: '#96CEB4',
}, },
{ {
key: 'thighCircumference', key: 'thighCircumference',
label: '大腿围', label: t('circumferenceDetail.measurements.thigh'),
value: userProfile?.thighCircumference, value: userProfile?.thighCircumference,
color: '#FFEAA7', color: '#FFEAA7',
}, },
{ {
key: 'calfCircumference', key: 'calfCircumference',
label: '小腿围', label: t('circumferenceDetail.measurements.calf'),
value: userProfile?.calfCircumference, value: userProfile?.calfCircumference,
color: '#DDA0DD', color: '#DDA0DD',
}, },
@@ -243,10 +245,10 @@ export default function CircumferenceDetailScreen() {
// 将YYYY-MM-DD格式转换为第几周 // 将YYYY-MM-DD格式转换为第几周
const weekOfYear = dayjs(item.label).week(); const weekOfYear = dayjs(item.label).week();
const firstWeekOfMonth = dayjs(item.label).startOf('month').week(); const firstWeekOfMonth = dayjs(item.label).startOf('month').week();
return `${weekOfYear - firstWeekOfMonth + 1}`; return t('circumferenceDetail.chart.weekLabel', { week: weekOfYear - firstWeekOfMonth + 1 });
case 'year': case 'year':
// 将YYYY-MM格式转换为月份 // 将YYYY-MM格式转换为月份
return dayjs(item.label).format('M'); return t('circumferenceDetail.chart.monthLabel', { month: dayjs(item.label).format('M') });
default: default:
return item.label; return item.label;
} }
@@ -287,7 +289,7 @@ export default function CircumferenceDetailScreen() {
{/* 头部导航 */} {/* 头部导航 */}
<HeaderBar <HeaderBar
title="围度统计" title={t('circumferenceDetail.title')}
transparent transparent
/> />
@@ -338,7 +340,7 @@ export default function CircumferenceDetailScreen() {
{/* 围度统计 */} {/* 围度统计 */}
<View style={styles.statsCard}> <View style={styles.statsCard}>
<Text style={styles.statsTitle}></Text> <Text style={styles.statsTitle}>{t('circumferenceDetail.title')}</Text>
{/* Tab 切换 */} {/* Tab 切换 */}
<View style={styles.tabContainer}> <View style={styles.tabContainer}>
@@ -348,7 +350,7 @@ export default function CircumferenceDetailScreen() {
activeOpacity={0.7} activeOpacity={0.7}
> >
<Text style={[styles.tabText, activeTab === 'week' && styles.activeTabText]}> <Text style={[styles.tabText, activeTab === 'week' && styles.activeTabText]}>
{t('circumferenceDetail.tabs.week')}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity
@@ -357,7 +359,7 @@ export default function CircumferenceDetailScreen() {
activeOpacity={0.7} activeOpacity={0.7}
> >
<Text style={[styles.tabText, activeTab === 'month' && styles.activeTabText]}> <Text style={[styles.tabText, activeTab === 'month' && styles.activeTabText]}>
{t('circumferenceDetail.tabs.month')}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity
@@ -366,7 +368,7 @@ export default function CircumferenceDetailScreen() {
activeOpacity={0.7} activeOpacity={0.7}
> >
<Text style={[styles.tabText, activeTab === 'year' && styles.activeTabText]}> <Text style={[styles.tabText, activeTab === 'year' && styles.activeTabText]}>
{t('circumferenceDetail.tabs.year')}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
@@ -390,7 +392,7 @@ export default function CircumferenceDetailScreen() {
styles.legendText, styles.legendText,
!isVisible && styles.legendTextHidden !isVisible && styles.legendTextHidden
]}> ]}>
{type.label} {t(`circumferenceDetail.measurements.${type.key.replace('Circumference', '')}`)}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
); );
@@ -401,17 +403,17 @@ export default function CircumferenceDetailScreen() {
{isLoading ? ( {isLoading ? (
<View style={styles.loadingChart}> <View style={styles.loadingChart}>
<ActivityIndicator size="large" color="#4ECDC4" /> <ActivityIndicator size="large" color="#4ECDC4" />
<Text style={styles.loadingText}>...</Text> <Text style={styles.loadingText}>{t('circumferenceDetail.loading')}</Text>
</View> </View>
) : error ? ( ) : error ? (
<View style={styles.errorChart}> <View style={styles.errorChart}>
<Text style={styles.errorText}>: {error}</Text> <Text style={styles.errorText}>{t('circumferenceDetail.error')}: {error}</Text>
<TouchableOpacity <TouchableOpacity
style={styles.retryButton} style={styles.retryButton}
onPress={() => dispatch(fetchCircumferenceAnalysis(activeTab))} onPress={() => dispatch(fetchCircumferenceAnalysis(activeTab))}
activeOpacity={0.7} activeOpacity={0.7}
> >
<Text style={styles.retryText}></Text> <Text style={styles.retryText}>{t('circumferenceDetail.retry')}</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
) : processedChartData.datasets.length > 0 ? ( ) : processedChartData.datasets.length > 0 ? (
@@ -453,8 +455,8 @@ export default function CircumferenceDetailScreen() {
<View style={styles.emptyChart}> <View style={styles.emptyChart}>
<Text style={styles.emptyChartText}> <Text style={styles.emptyChartText}>
{processedChartData.datasets.length === 0 && !isLoading && !error {processedChartData.datasets.length === 0 && !isLoading && !error
? '暂无数据' ? t('circumferenceDetail.chart.empty')
: '请选择要显示的围度数据' : t('circumferenceDetail.chart.noSelection')
} }
</Text> </Text>
</View> </View>
@@ -469,12 +471,12 @@ export default function CircumferenceDetailScreen() {
setModalVisible(false); setModalVisible(false);
setSelectedMeasurement(null); setSelectedMeasurement(null);
}} }}
title={selectedMeasurement ? `设置${selectedMeasurement.label}` : '设置围度'} title={selectedMeasurement ? t('circumferenceDetail.modal.title', { label: selectedMeasurement.label }) : t('circumferenceDetail.modal.defaultTitle')}
items={circumferenceOptions} items={circumferenceOptions}
selectedValue={selectedMeasurement?.currentValue} selectedValue={selectedMeasurement?.currentValue}
onValueChange={() => { }} // Real-time update not needed onValueChange={() => { }} // Real-time update not needed
onConfirm={handleUpdateMeasurement} onConfirm={handleUpdateMeasurement}
confirmButtonText="确认" confirmButtonText={t('circumferenceDetail.modal.confirm')}
pickerHeight={180} pickerHeight={180}
/> />
</View> </View>

View File

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

View File

@@ -3,6 +3,7 @@ import { ThemedView } from '@/components/ThemedView';
import { HeaderBar } from '@/components/ui/HeaderBar'; import { HeaderBar } from '@/components/ui/HeaderBar';
import { Colors } from '@/constants/Colors'; import { Colors } from '@/constants/Colors';
import { useColorScheme } from '@/hooks/useColorScheme'; import { useColorScheme } from '@/hooks/useColorScheme';
import { useI18n } from '@/hooks/useI18n';
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding'; import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
import { import {
fetchActivityRingsForDate, fetchActivityRingsForDate,
@@ -34,6 +35,8 @@ import {
TouchableOpacity, TouchableOpacity,
View View
} from 'react-native'; } from 'react-native';
import 'dayjs/locale/en';
import 'dayjs/locale/zh-cn';
// 配置 dayjs 插件 // 配置 dayjs 插件
dayjs.extend(utc); dayjs.extend(utc);
@@ -51,7 +54,8 @@ type WeekData = {
}; };
export default function FitnessRingsDetailScreen() { export default function FitnessRingsDetailScreen() {
const safeAreaTop = useSafeAreaTop() const { t, i18n } = useI18n();
const safeAreaTop = useSafeAreaTop();
const colorScheme = useColorScheme(); const colorScheme = useColorScheme();
const [weekData, setWeekData] = useState<WeekData[]>([]); const [weekData, setWeekData] = useState<WeekData[]>([]);
const [selectedDate, setSelectedDate] = useState<Date>(new Date()); const [selectedDate, setSelectedDate] = useState<Date>(new Date());
@@ -82,7 +86,7 @@ export default function FitnessRingsDetailScreen() {
exerciseInfoAnim.setValue(0); exerciseInfoAnim.setValue(0);
} }
} catch (error) { } catch (error) {
console.error('加载锻炼分钟说明偏好失败:', error); console.error(t('fitnessRingsDetail.errors.loadExerciseInfoPreference'), error);
} }
}; };
@@ -98,7 +102,15 @@ export default function FitnessRingsDetailScreen() {
for (let i = 0; i < 7; i++) { for (let i = 0; i < 7; i++) {
const currentDay = startOfWeek.add(i, 'day'); const currentDay = startOfWeek.add(i, 'day');
const isToday = currentDay.isSame(today, 'day'); const isToday = currentDay.isSame(today, 'day');
const dayNames = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']; const dayNames = [
t('fitnessRingsDetail.weekDays.monday'),
t('fitnessRingsDetail.weekDays.tuesday'),
t('fitnessRingsDetail.weekDays.wednesday'),
t('fitnessRingsDetail.weekDays.thursday'),
t('fitnessRingsDetail.weekDays.friday'),
t('fitnessRingsDetail.weekDays.saturday'),
t('fitnessRingsDetail.weekDays.sunday')
];
try { try {
const activityRingsData = await fetchActivityRingsForDate(currentDay.toDate()); const activityRingsData = await fetchActivityRingsForDate(currentDay.toDate());
@@ -164,8 +176,9 @@ export default function FitnessRingsDetailScreen() {
// 格式化头部显示的日期 // 格式化头部显示的日期
const formatHeaderDate = (date: Date) => { const formatHeaderDate = (date: Date) => {
const dayJsDate = dayjs(date).tz('Asia/Shanghai'); const dayJsDate = dayjs(date).tz('Asia/Shanghai').locale(i18n.language === 'zh' ? 'zh-cn' : 'en');
return `${dayJsDate.format('YYYY年MM月DD日')}`; const dateFormat = t('fitnessRingsDetail.dateFormats.header', { defaultValue: 'YYYY年MM月DD日' });
return dayJsDate.format(dateFormat);
}; };
const renderWeekRingItem = (item: WeekData, index: number) => { const renderWeekRingItem = (item: WeekData, index: number) => {
@@ -303,7 +316,7 @@ export default function FitnessRingsDetailScreen() {
setShowExerciseInfo(false); setShowExerciseInfo(false);
}); });
} catch (error) { } catch (error) {
console.error('保存锻炼分钟说明偏好失败:', error); console.error(t('fitnessRingsDetail.errors.saveExerciseInfoPreference'), error);
} }
}; };
@@ -380,7 +393,7 @@ export default function FitnessRingsDetailScreen() {
{/* 活动热量卡片 */} {/* 活动热量卡片 */}
<View style={styles.metricCard}> <View style={styles.metricCard}>
<View style={styles.cardHeader}> <View style={styles.cardHeader}>
<Text style={styles.cardTitle}></Text> <Text style={styles.cardTitle}>{t('fitnessRingsDetail.cards.activeCalories.title')}</Text>
<TouchableOpacity style={styles.helpButton}> <TouchableOpacity style={styles.helpButton}>
<Text style={styles.helpIcon}>?</Text> <Text style={styles.helpIcon}>?</Text>
</TouchableOpacity> </TouchableOpacity>
@@ -390,25 +403,25 @@ export default function FitnessRingsDetailScreen() {
<Text style={[styles.valueText, { color: '#FF3B30' }]}> <Text style={[styles.valueText, { color: '#FF3B30' }]}>
{Math.round(activeEnergyBurned)}/{activeEnergyBurnedGoal} {Math.round(activeEnergyBurned)}/{activeEnergyBurnedGoal}
</Text> </Text>
<Text style={styles.unitText}></Text> <Text style={styles.unitText}>{t('fitnessRingsDetail.cards.activeCalories.unit')}</Text>
</View> </View>
<Text style={styles.cardSubtext}> <Text style={styles.cardSubtext}>
{Math.round(activeEnergyBurned)} {Math.round(activeEnergyBurned)}{t('fitnessRingsDetail.cards.activeCalories.unit')}
</Text> </Text>
{renderBarChart( {renderBarChart(
hourlyCaloriesData.map(h => h.calories), hourlyCaloriesData.map(h => h.calories),
Math.max(activeEnergyBurnedGoal / 24, 1), Math.max(activeEnergyBurnedGoal / 24, 1),
'#FF3B30', '#FF3B30',
'千卡' t('fitnessRingsDetail.cards.activeCalories.unit')
)} )}
</View> </View>
{/* 锻炼分钟卡片 */} {/* 锻炼分钟卡片 */}
<View style={styles.metricCard}> <View style={styles.metricCard}>
<View style={styles.cardHeader}> <View style={styles.cardHeader}>
<Text style={styles.cardTitle}></Text> <Text style={styles.cardTitle}>{t('fitnessRingsDetail.cards.exerciseMinutes.title')}</Text>
<TouchableOpacity style={styles.helpButton}> <TouchableOpacity style={styles.helpButton}>
<Text style={styles.helpIcon}>?</Text> <Text style={styles.helpIcon}>?</Text>
</TouchableOpacity> </TouchableOpacity>
@@ -418,18 +431,18 @@ export default function FitnessRingsDetailScreen() {
<Text style={[styles.valueText, { color: '#FF9500' }]}> <Text style={[styles.valueText, { color: '#FF9500' }]}>
{Math.round(appleExerciseTime)}/{appleExerciseTimeGoal} {Math.round(appleExerciseTime)}/{appleExerciseTimeGoal}
</Text> </Text>
<Text style={styles.unitText}></Text> <Text style={styles.unitText}>{t('fitnessRingsDetail.cards.exerciseMinutes.unit')}</Text>
</View> </View>
<Text style={styles.cardSubtext}> <Text style={styles.cardSubtext}>
{Math.round(appleExerciseTime)} {Math.round(appleExerciseTime)}{t('fitnessRingsDetail.cards.exerciseMinutes.unit')}
</Text> </Text>
{renderBarChart( {renderBarChart(
hourlyExerciseData.map(h => h.minutes), hourlyExerciseData.map(h => h.minutes),
Math.max(appleExerciseTimeGoal / 8, 1), Math.max(appleExerciseTimeGoal / 8, 1),
'#FF9500', '#FF9500',
'分钟' t('fitnessRingsDetail.cards.exerciseMinutes.unit')
)} )}
{/* 锻炼分钟说明 */} {/* 锻炼分钟说明 */}
@@ -450,15 +463,15 @@ export default function FitnessRingsDetailScreen() {
} }
]} ]}
> >
<Text style={styles.exerciseTitle}>:</Text> <Text style={styles.exerciseTitle}>{t('fitnessRingsDetail.cards.exerciseMinutes.info.title')}</Text>
<Text style={styles.exerciseDesc}> <Text style={styles.exerciseDesc}>
"快走" {t('fitnessRingsDetail.cards.exerciseMinutes.info.description')}
</Text> </Text>
<Text style={styles.exerciseRecommendation}> <Text style={styles.exerciseRecommendation}>
30 {t('fitnessRingsDetail.cards.exerciseMinutes.info.recommendation')}
</Text> </Text>
<TouchableOpacity style={styles.knowButton} onPress={handleKnowButtonPress}> <TouchableOpacity style={styles.knowButton} onPress={handleKnowButtonPress}>
<Text style={styles.knowButtonText}></Text> <Text style={styles.knowButtonText}>{t('fitnessRingsDetail.cards.exerciseMinutes.info.knowButton')}</Text>
</TouchableOpacity> </TouchableOpacity>
</Animated.View> </Animated.View>
)} )}
@@ -467,7 +480,7 @@ export default function FitnessRingsDetailScreen() {
{/* 活动小时数卡片 */} {/* 活动小时数卡片 */}
<View style={styles.metricCard}> <View style={styles.metricCard}>
<View style={styles.cardHeader}> <View style={styles.cardHeader}>
<Text style={styles.cardTitle}></Text> <Text style={styles.cardTitle}>{t('fitnessRingsDetail.cards.standHours.title')}</Text>
<TouchableOpacity style={styles.helpButton}> <TouchableOpacity style={styles.helpButton}>
<Text style={styles.helpIcon}>?</Text> <Text style={styles.helpIcon}>?</Text>
</TouchableOpacity> </TouchableOpacity>
@@ -477,18 +490,18 @@ export default function FitnessRingsDetailScreen() {
<Text style={[styles.valueText, { color: '#007AFF' }]}> <Text style={[styles.valueText, { color: '#007AFF' }]}>
{Math.round(appleStandHours)}/{appleStandHoursGoal} {Math.round(appleStandHours)}/{appleStandHoursGoal}
</Text> </Text>
<Text style={styles.unitText}></Text> <Text style={styles.unitText}>{t('fitnessRingsDetail.cards.standHours.unit')}</Text>
</View> </View>
<Text style={styles.cardSubtext}> <Text style={styles.cardSubtext}>
{Math.round(appleStandHours)} {Math.round(appleStandHours)}{t('fitnessRingsDetail.cards.standHours.unit')}
</Text> </Text>
{renderBarChart( {renderBarChart(
hourlyStandData.map(h => h.hasStood), hourlyStandData.map(h => h.hasStood),
1, 1,
'#007AFF', '#007AFF',
'小时' t('fitnessRingsDetail.cards.standHours.unit')
)} )}
</View> </View>
</View> </View>
@@ -536,9 +549,9 @@ export default function FitnessRingsDetailScreen() {
{/* 周闭环天数统计 */} {/* 周闭环天数统计 */}
<View style={styles.statsContainer}> <View style={styles.statsContainer}>
<View style={styles.statRow}> <View style={styles.statRow}>
<Text style={[styles.statLabel, { color: Colors[colorScheme ?? 'light'].text }]}></Text> <Text style={[styles.statLabel, { color: Colors[colorScheme ?? 'light'].text }]}>{t('fitnessRingsDetail.stats.weeklyClosedRings')}</Text>
<View style={styles.statValue}> <View style={styles.statValue}>
<Text style={[styles.statNumber, { color: Colors[colorScheme ?? 'light'].text }]}>{getClosedRingCount()}</Text> <Text style={[styles.statNumber, { color: Colors[colorScheme ?? 'light'].text }]}>{getClosedRingCount()}{t('fitnessRingsDetail.stats.daysUnit')}</Text>
</View> </View>
</View> </View>
</View> </View>
@@ -559,7 +572,7 @@ export default function FitnessRingsDetailScreen() {
display={Platform.OS === 'ios' ? 'inline' : 'calendar'} display={Platform.OS === 'ios' ? 'inline' : 'calendar'}
minimumDate={new Date(2020, 0, 1)} minimumDate={new Date(2020, 0, 1)}
maximumDate={new Date()} maximumDate={new Date()}
{...(Platform.OS === 'ios' ? { locale: 'zh-CN' } : {})} {...(Platform.OS === 'ios' ? { locale: i18n.language === 'zh' ? 'zh-CN' : 'en-US' } : {})}
onChange={(event, date) => { onChange={(event, date) => {
if (Platform.OS === 'ios') { if (Platform.OS === 'ios') {
if (date) setPickerDate(date); if (date) setPickerDate(date);
@@ -575,12 +588,12 @@ export default function FitnessRingsDetailScreen() {
{Platform.OS === 'ios' && ( {Platform.OS === 'ios' && (
<View style={styles.modalActions}> <View style={styles.modalActions}>
<Pressable onPress={closeDatePicker} style={[styles.modalBtn]}> <Pressable onPress={closeDatePicker} style={[styles.modalBtn]}>
<Text style={styles.modalBtnText}></Text> <Text style={styles.modalBtnText}>{t('fitnessRingsDetail.datePicker.cancel')}</Text>
</Pressable> </Pressable>
<Pressable onPress={() => { <Pressable onPress={() => {
onConfirmDate(pickerDate); onConfirmDate(pickerDate);
}} style={[styles.modalBtn, styles.modalBtnPrimary]}> }} style={[styles.modalBtn, styles.modalBtnPrimary]}>
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary]}></Text> <Text style={[styles.modalBtnText, styles.modalBtnTextPrimary]}>{t('fitnessRingsDetail.datePicker.confirm')}</Text>
</Pressable> </Pressable>
</View> </View>
)} )}
@@ -874,4 +887,4 @@ const styles = StyleSheet.create({
color: '#FFFFFF', color: '#FFFFFF',
fontWeight: '700', fontWeight: '700',
}, },
}); });

File diff suppressed because it is too large Load Diff

View File

@@ -1,14 +1,15 @@
import { CircularRing } from '@/components/CircularRing'; import { CircularRing } from '@/components/CircularRing';
import { HeaderBar } from '@/components/ui/HeaderBar'; import { HeaderBar } from '@/components/ui/HeaderBar';
import { Image } from '@/components/ui/Image';
import { Colors } from '@/constants/Colors'; import { Colors } from '@/constants/Colors';
import { ROUTES } from '@/constants/Routes'; import { ROUTES } from '@/constants/Routes';
import { useAppSelector } from '@/hooks/redux'; import { useAppSelector } from '@/hooks/redux';
import { useI18n } from '@/hooks/useI18n';
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding'; import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
import { addDietRecord, type CreateDietRecordDto, type MealType } from '@/services/dietRecords'; import { addDietRecord, type CreateDietRecordDto, type MealType } from '@/services/dietRecords';
import { selectFoodRecognitionResult } from '@/store/foodRecognitionSlice'; import { selectFoodRecognitionResult } from '@/store/foodRecognitionSlice';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import { useLocalSearchParams, useRouter } from 'expo-router'; import { useLocalSearchParams, useRouter } from 'expo-router';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
@@ -65,15 +66,8 @@ const mockFoodItems = [
} }
]; ];
// 餐次映射
const MEAL_TYPE_MAP = {
breakfast: '早餐',
lunch: '午餐',
dinner: '晚餐',
snack: '加餐'
};
export default function FoodAnalysisResultScreen() { export default function FoodAnalysisResultScreen() {
const { t } = useI18n();
const safeAreaTop = useSafeAreaTop() const safeAreaTop = useSafeAreaTop()
const router = useRouter(); const router = useRouter();
const params = useLocalSearchParams<{ const params = useLocalSearchParams<{
@@ -190,6 +184,15 @@ export default function FoodAnalysisResultScreen() {
} }
}; };
// 餐次映射
const MEAL_TYPE_MAP = {
breakfast: t('nutritionRecords.mealTypes.breakfast'),
lunch: t('nutritionRecords.mealTypes.lunch'),
dinner: t('nutritionRecords.mealTypes.dinner'),
snack: t('nutritionRecords.mealTypes.snack'),
other: t('nutritionRecords.mealTypes.other'),
};
// 计算所有食物的总营养数据 // 计算所有食物的总营养数据
const totalCalories = foodItems.reduce((sum, item) => sum + item.calories, 0); const totalCalories = foodItems.reduce((sum, item) => sum + item.calories, 0);
const totalProtein = foodItems.reduce((sum, item) => sum + item.protein, 0); const totalProtein = foodItems.reduce((sum, item) => sum + item.protein, 0);
@@ -253,24 +256,24 @@ export default function FoodAnalysisResultScreen() {
// 餐次选择选项 // 餐次选择选项
const mealOptions = [ const mealOptions = [
{ key: 'breakfast' as const, label: '早餐', color: '#FF6B35' }, { key: 'breakfast' as const, label: t('nutritionRecords.mealTypes.breakfast'), color: '#FF6B35' },
{ key: 'lunch' as const, label: '午餐', color: '#4CAF50' }, { key: 'lunch' as const, label: t('nutritionRecords.mealTypes.lunch'), color: '#4CAF50' },
{ key: 'dinner' as const, label: '晚餐', color: '#2196F3' }, { key: 'dinner' as const, label: t('nutritionRecords.mealTypes.dinner'), color: '#2196F3' },
{ key: 'snack' as const, label: '加餐', color: '#FF9800' }, { key: 'snack' as const, label: t('nutritionRecords.mealTypes.snack'), color: '#FF9800' },
]; ];
if (!imageUri && !recognitionResult) { if (!imageUri && !recognitionResult) {
return ( return (
<View style={styles.container}> <View style={styles.container}>
<HeaderBar <HeaderBar
title="分析结果" title={t('foodAnalysisResult.title')}
onBack={() => router.back()} onBack={() => router.back()}
/> />
<View style={{ <View style={{
paddingTop: safeAreaTop paddingTop: safeAreaTop
}} /> }} />
<View style={styles.errorContainer}> <View style={styles.errorContainer}>
<Text style={styles.errorText}></Text> <Text style={styles.errorText}>{t('foodAnalysisResult.error.notFound')}</Text>
</View> </View>
</View> </View>
); );
@@ -287,7 +290,7 @@ export default function FoodAnalysisResultScreen() {
/> />
<HeaderBar <HeaderBar
title="分析结果" title={t('foodAnalysisResult.title')}
onBack={() => router.back()} onBack={() => router.back()}
transparent={true} transparent={true}
/> />
@@ -316,7 +319,7 @@ export default function FoodAnalysisResultScreen() {
<View style={styles.placeholderContainer}> <View style={styles.placeholderContainer}>
<View style={styles.placeholderContent}> <View style={styles.placeholderContent}>
<Ionicons name="restaurant-outline" size={48} color="#666" /> <Ionicons name="restaurant-outline" size={48} color="#666" />
<Text style={styles.placeholderText}></Text> <Text style={styles.placeholderText}>{t('foodAnalysisResult.placeholder')}</Text>
</View> </View>
</View> </View>
)} )}
@@ -325,8 +328,8 @@ export default function FoodAnalysisResultScreen() {
<View style={styles.descriptionBubble}> <View style={styles.descriptionBubble}>
<Text style={styles.descriptionText}> <Text style={styles.descriptionText}>
{recognitionResult ? {recognitionResult ?
`置信度: ${recognitionResult.confidence}%` : t('foodAnalysisResult.confidence', { value: recognitionResult.confidence }) :
dayjs().format('YYYY年M月D日') dayjs().format(t('foodAnalysisResult.dateFormats.today'))
} }
</Text> </Text>
</View> </View>
@@ -337,31 +340,31 @@ export default function FoodAnalysisResultScreen() {
{/* 卡路里 */} {/* 卡路里 */}
<View style={styles.calorieSection}> <View style={styles.calorieSection}>
<Text style={styles.calorieValue}>{totalCalories}</Text> <Text style={styles.calorieValue}>{totalCalories}</Text>
<Text style={styles.calorieUnit}></Text> <Text style={styles.calorieUnit}>{t('foodAnalysisResult.nutrients.caloriesUnit')}</Text>
</View> </View>
{/* 营养圆环图 */} {/* 营养圆环图 */}
<View style={styles.nutritionRings}> <View style={styles.nutritionRings}>
<NutritionRing <NutritionRing
label="蛋白质" label={t('foodAnalysisResult.nutrients.protein')}
value={totalProtein.toFixed(1)} value={totalProtein.toFixed(1)}
unit="克" unit={t('foodAnalysisResult.nutrients.unit')}
percentage={Math.min(100, proteinPercentage)} percentage={Math.min(100, proteinPercentage)}
color="#4CAF50" color="#4CAF50"
resetToken={animationTrigger} resetToken={animationTrigger}
/> />
<NutritionRing <NutritionRing
label="脂肪" label={t('foodAnalysisResult.nutrients.fat')}
value={totalFat.toFixed(1)} value={totalFat.toFixed(1)}
unit="克" unit={t('foodAnalysisResult.nutrients.unit')}
percentage={Math.min(100, fatPercentage)} percentage={Math.min(100, fatPercentage)}
color="#FF9800" color="#FF9800"
resetToken={animationTrigger} resetToken={animationTrigger}
/> />
<NutritionRing <NutritionRing
label="碳水" label={t('foodAnalysisResult.nutrients.carbs')}
value={totalCarbohydrate.toFixed(1)} value={totalCarbohydrate.toFixed(1)}
unit="克" unit={t('foodAnalysisResult.nutrients.unit')}
percentage={Math.min(100, carbohydratePercentage)} percentage={Math.min(100, carbohydratePercentage)}
color="#2196F3" color="#2196F3"
resetToken={animationTrigger} resetToken={animationTrigger}
@@ -372,7 +375,7 @@ export default function FoodAnalysisResultScreen() {
{/* 食物摄入部分 */} {/* 食物摄入部分 */}
<View style={styles.foodIntakeSection}> <View style={styles.foodIntakeSection}>
<Text style={styles.foodIntakeTitle}> <Text style={styles.foodIntakeTitle}>
{recognitionResult ? '识别结果' : '食物摄入'} {recognitionResult ? t('foodAnalysisResult.sections.recognitionResult') : t('foodAnalysisResult.sections.foodIntake')}
</Text> </Text>
{recognitionResult && recognitionResult.analysisText && ( {recognitionResult && recognitionResult.analysisText && (
<Text style={styles.analysisText}>{recognitionResult.analysisText}</Text> <Text style={styles.analysisText}>{recognitionResult.analysisText}</Text>
@@ -384,15 +387,15 @@ export default function FoodAnalysisResultScreen() {
<View style={styles.nonFoodIcon}> <View style={styles.nonFoodIcon}>
<Ionicons name="alert-circle-outline" size={48} color="#FF9800" /> <Ionicons name="alert-circle-outline" size={48} color="#FF9800" />
</View> </View>
<Text style={styles.nonFoodTitle}></Text> <Text style={styles.nonFoodTitle}>{t('foodAnalysisResult.nonFood.title')}</Text>
<Text style={styles.nonFoodMessage}> <Text style={styles.nonFoodMessage}>
{recognitionResult.nonFoodMessage || recognitionResult.analysisText} {recognitionResult.nonFoodMessage || recognitionResult.analysisText}
</Text> </Text>
<View style={styles.nonFoodSuggestions}> <View style={styles.nonFoodSuggestions}>
<Text style={styles.nonFoodSuggestionsTitle}></Text> <Text style={styles.nonFoodSuggestionsTitle}>{t('foodAnalysisResult.nonFood.suggestions.title')}</Text>
<Text style={styles.nonFoodSuggestionItem}> </Text> <Text style={styles.nonFoodSuggestionItem}>{t('foodAnalysisResult.nonFood.suggestions.item1')}</Text>
<Text style={styles.nonFoodSuggestionItem}> </Text> <Text style={styles.nonFoodSuggestionItem}>{t('foodAnalysisResult.nonFood.suggestions.item2')}</Text>
<Text style={styles.nonFoodSuggestionItem}> 线</Text> <Text style={styles.nonFoodSuggestionItem}>{t('foodAnalysisResult.nonFood.suggestions.item3')}</Text>
</View> </View>
</View> </View>
)} )}
@@ -411,7 +414,7 @@ export default function FoodAnalysisResultScreen() {
</View> </View>
<View style={styles.foodIntakeCalories}> <View style={styles.foodIntakeCalories}>
<Text style={styles.foodIntakeCaloriesValue}>{item.calories}</Text> <Text style={styles.foodIntakeCaloriesValue}>{item.calories}{t('foodAnalysisResult.nutrients.caloriesUnit')}</Text>
{shouldHideRecordBar ? null : <TouchableOpacity {shouldHideRecordBar ? null : <TouchableOpacity
style={styles.editButton} style={styles.editButton}
onPress={() => handleEditFood(item)} onPress={() => handleEditFood(item)}
@@ -442,7 +445,7 @@ export default function FoodAnalysisResultScreen() {
activeOpacity={0.8} activeOpacity={0.8}
> >
<Ionicons name="camera-outline" size={20} color={Colors.light.onPrimary} style={{ marginRight: 8 }} /> <Ionicons name="camera-outline" size={20} color={Colors.light.onPrimary} style={{ marginRight: 8 }} />
<Text style={styles.retakePhotoButtonText}></Text> <Text style={styles.retakePhotoButtonText}>{t('foodAnalysisResult.actions.retake')}</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
) : ( ) : (
@@ -471,7 +474,7 @@ export default function FoodAnalysisResultScreen() {
{isRecording ? ( {isRecording ? (
<ActivityIndicator size="small" color="#FFF" /> <ActivityIndicator size="small" color="#FFF" />
) : ( ) : (
<Text style={styles.recordButtonText}></Text> <Text style={styles.recordButtonText}>{t('foodAnalysisResult.actions.record')}</Text>
)} )}
</TouchableOpacity> </TouchableOpacity>
</View> </View>
@@ -492,7 +495,7 @@ export default function FoodAnalysisResultScreen() {
/> />
<View style={styles.mealSelectorModal}> <View style={styles.mealSelectorModal}>
<View style={styles.mealSelectorHeader}> <View style={styles.mealSelectorHeader}>
<Text style={styles.mealSelectorTitle}></Text> <Text style={styles.mealSelectorTitle}>{t('foodAnalysisResult.mealSelector.title')}</Text>
<TouchableOpacity onPress={() => setShowMealSelector(false)}> <TouchableOpacity onPress={() => setShowMealSelector(false)}>
<Ionicons name="close" size={24} color="#666" /> <Ionicons name="close" size={24} color="#666" />
</TouchableOpacity> </TouchableOpacity>
@@ -539,8 +542,8 @@ export default function FoodAnalysisResultScreen() {
<View style={styles.imageViewerHeader}> <View style={styles.imageViewerHeader}>
<Text style={styles.imageViewerHeaderText}> <Text style={styles.imageViewerHeaderText}>
{recognitionResult ? {recognitionResult ?
`置信度: ${recognitionResult.confidence}%` : t('foodAnalysisResult.confidence', { value: recognitionResult.confidence }) :
dayjs().format('YYYY年M月D日 HH:mm') dayjs().format(t('foodAnalysisResult.dateFormats.full'))
} }
</Text> </Text>
</View> </View>
@@ -551,7 +554,7 @@ export default function FoodAnalysisResultScreen() {
style={styles.imageViewerFooterButton} style={styles.imageViewerFooterButton}
onPress={() => setShowImagePreview(false)} onPress={() => setShowImagePreview(false)}
> >
<Text style={styles.imageViewerFooterButtonText}></Text> <Text style={styles.imageViewerFooterButtonText}>{t('foodAnalysisResult.actions.close')}</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
)} )}
@@ -587,6 +590,8 @@ function FoodEditModal({
onFormDataChange({ ...formData, [field]: value }); onFormDataChange({ ...formData, [field]: value });
}; };
const { t } = useI18n();
return ( return (
<Modal <Modal
visible={visible} visible={visible}
@@ -598,14 +603,14 @@ function FoodEditModal({
<View style={styles.editModalSheet}> <View style={styles.editModalSheet}>
<View style={styles.modalHandle} /> <View style={styles.modalHandle} />
<Text style={styles.modalTitle}></Text> <Text style={styles.modalTitle}>{t('foodAnalysisResult.editModal.title')}</Text>
{/* 食物名称 */} {/* 食物名称 */}
<View style={styles.editFieldContainer}> <View style={styles.editFieldContainer}>
<Text style={styles.editFieldLabel}></Text> <Text style={styles.editFieldLabel}>{t('foodAnalysisResult.editModal.fields.name')}</Text>
<TextInput <TextInput
style={styles.editInput} style={styles.editInput}
placeholder="输入食物名称" placeholder={t('foodAnalysisResult.editModal.fields.namePlaceholder')}
placeholderTextColor="#999" placeholderTextColor="#999"
value={formData.name} value={formData.name}
onChangeText={(value) => handleFieldChange('name', value)} onChangeText={(value) => handleFieldChange('name', value)}
@@ -615,10 +620,10 @@ function FoodEditModal({
{/* 重量/数量 */} {/* 重量/数量 */}
<View style={styles.editFieldContainer}> <View style={styles.editFieldContainer}>
<Text style={styles.editFieldLabel}> ()</Text> <Text style={styles.editFieldLabel}>{t('foodAnalysisResult.editModal.fields.amount')}</Text>
<TextInput <TextInput
style={styles.editInput} style={styles.editInput}
placeholder="输入重量" placeholder={t('foodAnalysisResult.editModal.fields.amountPlaceholder')}
placeholderTextColor="#999" placeholderTextColor="#999"
value={formData.amount} value={formData.amount}
onChangeText={(value) => handleFieldChange('amount', value)} onChangeText={(value) => handleFieldChange('amount', value)}
@@ -628,10 +633,10 @@ function FoodEditModal({
{/* 卡路里 */} {/* 卡路里 */}
<View style={styles.editFieldContainer}> <View style={styles.editFieldContainer}>
<Text style={styles.editFieldLabel}> ()</Text> <Text style={styles.editFieldLabel}>{t('foodAnalysisResult.editModal.fields.calories')}</Text>
<TextInput <TextInput
style={styles.editInput} style={styles.editInput}
placeholder="输入卡路里" placeholder={t('foodAnalysisResult.editModal.fields.caloriesPlaceholder')}
placeholderTextColor="#999" placeholderTextColor="#999"
value={formData.calories} value={formData.calories}
onChangeText={(value) => handleFieldChange('calories', value)} onChangeText={(value) => handleFieldChange('calories', value)}
@@ -645,13 +650,13 @@ function FoodEditModal({
onPress={onClose} onPress={onClose}
style={styles.modalCancelBtn} style={styles.modalCancelBtn}
> >
<Text style={styles.modalCancelText}></Text> <Text style={styles.modalCancelText}>{t('foodAnalysisResult.editModal.actions.cancel')}</Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity
onPress={onSave} onPress={onSave}
style={[styles.modalSaveBtn, { backgroundColor: Colors.light.primary }]} style={[styles.modalSaveBtn, { backgroundColor: Colors.light.primary }]}
> >
<Text style={[styles.modalSaveText, { color: Colors.light.onPrimary }]}></Text> <Text style={[styles.modalSaveText, { color: Colors.light.onPrimary }]}>{t('foodAnalysisResult.editModal.actions.save')}</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</View> </View>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,7 @@
import { HeaderBar } from '@/components/ui/HeaderBar'; import { HeaderBar } from '@/components/ui/HeaderBar';
import { Image } from '@/components/ui/Image';
import { Colors } from '@/constants/Colors'; import { Colors } from '@/constants/Colors';
import { useI18n } from '@/hooks/useI18n';
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding'; import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
import { import {
deleteNutritionAnalysisRecord, deleteNutritionAnalysisRecord,
@@ -12,7 +14,6 @@ import { triggerLightHaptic } from '@/utils/haptics';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect'; import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import React, { useCallback, useEffect, useState } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
@@ -30,6 +31,7 @@ import {
import ImageViewing from 'react-native-image-viewing'; import ImageViewing from 'react-native-image-viewing';
export default function NutritionAnalysisHistoryScreen() { export default function NutritionAnalysisHistoryScreen() {
const { t } = useI18n();
const safeAreaTop = useSafeAreaTop(); const safeAreaTop = useSafeAreaTop();
const router = useRouter(); const router = useRouter();
@@ -95,15 +97,15 @@ export default function NutritionAnalysisHistoryScreen() {
setHasMore(page < response.data.totalPages); setHasMore(page < response.data.totalPages);
setCurrentPage(page); setCurrentPage(page);
} else { } else {
const errorMessage = response.message || '获取历史记录失败'; const errorMessage = response.message || t('nutritionAnalysisHistory.errors.fetchFailed');
setError(errorMessage); setError(errorMessage);
Alert.alert('错误', errorMessage); Alert.alert(t('nutritionAnalysisHistory.errors.error'), errorMessage);
} }
} catch (error) { } catch (error) {
console.error('[HISTORY] 获取历史记录失败:', error); console.error('[HISTORY] 获取历史记录失败:', error);
const errorMessage = '获取历史记录失败,请稍后重试'; const errorMessage = t('nutritionAnalysisHistory.errors.fetchFailedRetry');
setError(errorMessage); setError(errorMessage);
Alert.alert('错误', errorMessage); Alert.alert(t('nutritionAnalysisHistory.errors.error'), errorMessage);
} finally { } finally {
setLoading(false); setLoading(false);
setRefreshing(false); setRefreshing(false);
@@ -173,13 +175,13 @@ export default function NutritionAnalysisHistoryScreen() {
const getStatusText = (status: string) => { const getStatusText = (status: string) => {
switch (status) { switch (status) {
case 'success': case 'success':
return '成功'; return t('nutritionAnalysisHistory.status.success');
case 'failed': case 'failed':
return '失败'; return t('nutritionAnalysisHistory.status.failed');
case 'processing': case 'processing':
return '处理中'; return t('nutritionAnalysisHistory.status.processing');
default: default:
return '未知'; return t('nutritionAnalysisHistory.status.unknown');
} }
}; };
@@ -208,15 +210,15 @@ export default function NutritionAnalysisHistoryScreen() {
// 处理删除记录 // 处理删除记录
const handleDeleteRecord = useCallback((recordId: number) => { const handleDeleteRecord = useCallback((recordId: number) => {
Alert.alert( Alert.alert(
'确认删除', t('nutritionAnalysisHistory.delete.confirmTitle'),
'确定要删除这条营养分析记录吗?此操作无法撤销。', t('nutritionAnalysisHistory.delete.confirmMessage'),
[ [
{ {
text: '取消', text: t('nutritionAnalysisHistory.delete.cancel'),
style: 'cancel', style: 'cancel',
}, },
{ {
text: '删除', text: t('nutritionAnalysisHistory.delete.delete'),
style: 'destructive', style: 'destructive',
onPress: async () => { onPress: async () => {
try { try {
@@ -231,10 +233,10 @@ export default function NutritionAnalysisHistoryScreen() {
triggerLightHaptic(); triggerLightHaptic();
// 显示成功提示 // 显示成功提示
Alert.alert('成功', '记录已删除'); Alert.alert(t('nutritionAnalysisHistory.delete.successTitle'), t('nutritionAnalysisHistory.delete.successMessage'));
} catch (error) { } catch (error) {
console.error('[HISTORY] 删除记录失败:', error); console.error('[HISTORY] 删除记录失败:', error);
Alert.alert('错误', '删除失败,请稍后重试'); Alert.alert(t('nutritionAnalysisHistory.errors.error'), t('nutritionAnalysisHistory.errors.deleteFailed'));
} finally { } finally {
setDeletingId(null); setDeletingId(null);
} }
@@ -256,11 +258,11 @@ export default function NutritionAnalysisHistoryScreen() {
<View style={styles.recordInfo}> <View style={styles.recordInfo}>
{isSuccess && ( {isSuccess && (
<Text style={styles.recordTitle}> <Text style={styles.recordTitle}>
{item.nutritionCount} {t('nutritionAnalysisHistory.recognized', { count: item.nutritionCount })}
</Text> </Text>
)} )}
<Text style={styles.recordDate}> <Text style={styles.recordDate}>
{dayjs(item.createdAt).format('YYYY年M月D日 HH:mm')} {dayjs(item.createdAt).format(t('nutritionAnalysisHistory.dateFormat'))}
</Text> </Text>
<View style={[styles.statusBadge, { backgroundColor: getStatusColor(item.status) }]}> <View style={[styles.statusBadge, { backgroundColor: getStatusColor(item.status) }]}>
<Text style={styles.statusText}>{getStatusText(item.status)}</Text> <Text style={styles.statusText}>{getStatusText(item.status)}</Text>
@@ -327,25 +329,25 @@ export default function NutritionAnalysisHistoryScreen() {
<> <>
{mainNutrients.energy && ( {mainNutrients.energy && (
<View style={styles.nutritionItem}> <View style={styles.nutritionItem}>
<Text style={styles.nutritionLabel}></Text> <Text style={styles.nutritionLabel}>{t('nutritionAnalysisHistory.nutrients.energy')}</Text>
<Text style={styles.nutritionValue}>{mainNutrients.energy}</Text> <Text style={styles.nutritionValue}>{mainNutrients.energy}</Text>
</View> </View>
)} )}
{mainNutrients.protein && ( {mainNutrients.protein && (
<View style={styles.nutritionItem}> <View style={styles.nutritionItem}>
<Text style={styles.nutritionLabel}></Text> <Text style={styles.nutritionLabel}>{t('nutritionAnalysisHistory.nutrients.protein')}</Text>
<Text style={styles.nutritionValue}>{mainNutrients.protein}</Text> <Text style={styles.nutritionValue}>{mainNutrients.protein}</Text>
</View> </View>
)} )}
{mainNutrients.carbs && ( {mainNutrients.carbs && (
<View style={styles.nutritionItem}> <View style={styles.nutritionItem}>
<Text style={styles.nutritionLabel}></Text> <Text style={styles.nutritionLabel}>{t('nutritionAnalysisHistory.nutrients.carbs')}</Text>
<Text style={styles.nutritionValue}>{mainNutrients.carbs}</Text> <Text style={styles.nutritionValue}>{mainNutrients.carbs}</Text>
</View> </View>
)} )}
{mainNutrients.fat && ( {mainNutrients.fat && (
<View style={styles.nutritionItem}> <View style={styles.nutritionItem}>
<Text style={styles.nutritionLabel}></Text> <Text style={styles.nutritionLabel}>{t('nutritionAnalysisHistory.nutrients.fat')}</Text>
<Text style={styles.nutritionValue}>{mainNutrients.fat}</Text> <Text style={styles.nutritionValue}>{mainNutrients.fat}</Text>
</View> </View>
)} )}
@@ -371,7 +373,7 @@ export default function NutritionAnalysisHistoryScreen() {
activeOpacity={0.7} activeOpacity={0.7}
> >
<Text style={styles.expandButtonText}> <Text style={styles.expandButtonText}>
{isExpanded ? '收起详情' : '展开详情'} {isExpanded ? t('nutritionAnalysisHistory.actions.collapse') : t('nutritionAnalysisHistory.actions.expand')}
</Text> </Text>
<Ionicons <Ionicons
name={isExpanded ? 'chevron-up-outline' : 'chevron-down-outline'} name={isExpanded ? 'chevron-up-outline' : 'chevron-down-outline'}
@@ -383,7 +385,7 @@ export default function NutritionAnalysisHistoryScreen() {
{/* 详细信息 */} {/* 详细信息 */}
{isExpanded && isSuccess && item.analysisResult && item.analysisResult.data && ( {isExpanded && isSuccess && item.analysisResult && item.analysisResult.data && (
<View style={styles.detailsContainer}> <View style={styles.detailsContainer}>
<Text style={styles.detailsTitle}></Text> <Text style={styles.detailsTitle}>{t('nutritionAnalysisHistory.details.title')}</Text>
{item.analysisResult.data.map((nutritionItem: NutritionItem) => ( {item.analysisResult.data.map((nutritionItem: NutritionItem) => (
<View key={nutritionItem.key} style={styles.detailItem}> <View key={nutritionItem.key} style={styles.detailItem}>
<View style={styles.nutritionInfo}> <View style={styles.nutritionInfo}>
@@ -397,8 +399,8 @@ export default function NutritionAnalysisHistoryScreen() {
))} ))}
<View style={styles.metaInfo}> <View style={styles.metaInfo}>
<Text style={styles.metaText}>AI : {item.aiModel}</Text> <Text style={styles.metaText}>{t('nutritionAnalysisHistory.details.aiModel')}: {item.aiModel}</Text>
<Text style={styles.metaText}>: {item.aiProvider}</Text> <Text style={styles.metaText}>{t('nutritionAnalysisHistory.details.provider')}: {item.aiProvider}</Text>
</View> </View>
</View> </View>
)} )}
@@ -410,8 +412,8 @@ export default function NutritionAnalysisHistoryScreen() {
const renderEmptyState = () => ( const renderEmptyState = () => (
<View style={styles.emptyState}> <View style={styles.emptyState}>
<Ionicons name="document-text-outline" size={64} color="#CCC" /> <Ionicons name="document-text-outline" size={64} color="#CCC" />
<Text style={styles.emptyStateText}></Text> <Text style={styles.emptyStateText}>{t('nutritionAnalysisHistory.empty.title')}</Text>
<Text style={styles.emptyStateSubtext}></Text> <Text style={styles.emptyStateSubtext}>{t('nutritionAnalysisHistory.empty.subtitle')}</Text>
</View> </View>
); );
@@ -419,8 +421,8 @@ export default function NutritionAnalysisHistoryScreen() {
const renderErrorState = () => ( const renderErrorState = () => (
<View style={styles.errorState}> <View style={styles.errorState}>
<Ionicons name="alert-circle-outline" size={64} color="#F44336" /> <Ionicons name="alert-circle-outline" size={64} color="#F44336" />
<Text style={styles.errorStateText}></Text> <Text style={styles.errorStateText}>{t('nutritionAnalysisHistory.errors.loadFailed')}</Text>
<Text style={styles.errorStateSubtext}>{error || '未知错误'}</Text> <Text style={styles.errorStateSubtext}>{error || t('nutritionAnalysisHistory.errors.unknownError')}</Text>
<TouchableOpacity <TouchableOpacity
style={styles.retryButton} style={styles.retryButton}
onPress={() => { onPress={() => {
@@ -428,7 +430,7 @@ export default function NutritionAnalysisHistoryScreen() {
fetchRecords(1, true); fetchRecords(1, true);
}} }}
> >
<Text style={styles.retryButtonText}></Text> <Text style={styles.retryButtonText}>{t('nutritionAnalysisHistory.actions.retry')}</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
); );
@@ -440,7 +442,7 @@ export default function NutritionAnalysisHistoryScreen() {
return ( return (
<View style={styles.loadingFooter}> <View style={styles.loadingFooter}>
<ActivityIndicator size="small" color={Colors.light.primary} /> <ActivityIndicator size="small" color={Colors.light.primary} />
<Text style={styles.loadingFooterText}>...</Text> <Text style={styles.loadingFooterText}>{t('nutritionAnalysisHistory.loadingMore')}</Text>
</View> </View>
); );
}; };
@@ -456,7 +458,7 @@ export default function NutritionAnalysisHistoryScreen() {
/> />
<HeaderBar <HeaderBar
title="历史记录" title={t('nutritionAnalysisHistory.title')}
onBack={() => router.back()} onBack={() => router.back()}
transparent={true} transparent={true}
/> />
@@ -477,7 +479,7 @@ export default function NutritionAnalysisHistoryScreen() {
activeOpacity={0.7} activeOpacity={0.7}
> >
<Text style={[styles.filterButtonText, !statusFilter && styles.filterButtonTextActive]}> <Text style={[styles.filterButtonText, !statusFilter && styles.filterButtonTextActive]}>
{t('nutritionAnalysisHistory.filter.all')}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity
@@ -494,7 +496,7 @@ export default function NutritionAnalysisHistoryScreen() {
activeOpacity={0.7} activeOpacity={0.7}
> >
<Text style={[styles.filterButtonText, statusFilter === 'success' && styles.filterButtonTextActive]}> <Text style={[styles.filterButtonText, statusFilter === 'success' && styles.filterButtonTextActive]}>
{t('nutritionAnalysisHistory.status.success')}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity
@@ -511,7 +513,7 @@ export default function NutritionAnalysisHistoryScreen() {
activeOpacity={0.7} activeOpacity={0.7}
> >
<Text style={[styles.filterButtonText, statusFilter === 'failed' && styles.filterButtonTextActive]}> <Text style={[styles.filterButtonText, statusFilter === 'failed' && styles.filterButtonTextActive]}>
{t('nutritionAnalysisHistory.status.failed')}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
@@ -520,7 +522,7 @@ export default function NutritionAnalysisHistoryScreen() {
{loading ? ( {loading ? (
<View style={styles.loadingContainer}> <View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={Colors.light.primary} /> <ActivityIndicator size="large" color={Colors.light.primary} />
<Text style={styles.loadingText}>...</Text> <Text style={styles.loadingText}>{t('nutritionAnalysisHistory.loading')}</Text>
</View> </View>
) : ( ) : (
<FlatList <FlatList
@@ -555,7 +557,7 @@ export default function NutritionAnalysisHistoryScreen() {
HeaderComponent={() => ( HeaderComponent={() => (
<View style={styles.imageViewerHeader}> <View style={styles.imageViewerHeader}>
<Text style={styles.imageViewerHeaderText}> <Text style={styles.imageViewerHeaderText}>
{dayjs().format('YYYY年M月D日 HH:mm')} {dayjs().format(t('nutritionAnalysisHistory.dateFormat'))}
</Text> </Text>
</View> </View>
)} )}
@@ -565,7 +567,7 @@ export default function NutritionAnalysisHistoryScreen() {
style={styles.imageViewerFooterButton} style={styles.imageViewerFooterButton}
onPress={() => setShowImagePreview(false)} onPress={() => setShowImagePreview(false)}
> >
<Text style={styles.imageViewerFooterButtonText}></Text> <Text style={styles.imageViewerFooterButtonText}>{t('nutritionLabelAnalysis.actions.close')}</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
)} )}

View File

@@ -1,7 +1,9 @@
import { HeaderBar } from '@/components/ui/HeaderBar'; import { HeaderBar } from '@/components/ui/HeaderBar';
import { Image } from '@/components/ui/Image';
import { Colors } from '@/constants/Colors'; import { Colors } from '@/constants/Colors';
import { useAuthGuard } from '@/hooks/useAuthGuard'; import { useAuthGuard } from '@/hooks/useAuthGuard';
import { useCosUpload } from '@/hooks/useCosUpload'; import { useCosUpload } from '@/hooks/useCosUpload';
import { useI18n } from '@/hooks/useI18n';
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding'; import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
import { import {
analyzeNutritionImage, analyzeNutritionImage,
@@ -11,7 +13,6 @@ import { triggerLightHaptic } from '@/utils/haptics';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect'; import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { Image } from 'expo-image';
import * as ImagePicker from 'expo-image-picker'; import * as ImagePicker from 'expo-image-picker';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
@@ -29,6 +30,7 @@ import {
import ImageViewing from 'react-native-image-viewing'; import ImageViewing from 'react-native-image-viewing';
export default function NutritionLabelAnalysisScreen() { export default function NutritionLabelAnalysisScreen() {
const { t } = useI18n();
const safeAreaTop = useSafeAreaTop(); const safeAreaTop = useSafeAreaTop();
const router = useRouter(); const router = useRouter();
const { pushIfAuthedElseLogin, ensureLoggedIn } = useAuthGuard(); const { pushIfAuthedElseLogin, ensureLoggedIn } = useAuthGuard();
@@ -77,7 +79,7 @@ export default function NutritionLabelAnalysisScreen() {
const requestCameraPermission = async () => { const requestCameraPermission = async () => {
const { status } = await ImagePicker.requestCameraPermissionsAsync(); const { status } = await ImagePicker.requestCameraPermissionsAsync();
if (status !== 'granted') { if (status !== 'granted') {
Alert.alert('权限不足', '需要相机权限才能拍摄成分表'); Alert.alert(t('nutritionLabelAnalysis.camera.permissionDenied'), t('nutritionLabelAnalysis.camera.permissionMessage'));
return false; return false;
} }
return true; return true;
@@ -153,7 +155,7 @@ export default function NutritionLabelAnalysisScreen() {
// 直接使用服务端返回的数据,不做任何转换 // 直接使用服务端返回的数据,不做任何转换
setNewAnalysisResult(analysisResponse); setNewAnalysisResult(analysisResponse);
} else { } else {
throw new Error(analysisResponse.message || '分析失败'); throw new Error(analysisResponse.message || t('nutritionLabelAnalysis.errors.analysisFailed.defaultMessage'));
} }
} catch (error: any) { } catch (error: any) {
console.error('[NUTRITION_ANALYSIS] 新API分析失败:', error); console.error('[NUTRITION_ANALYSIS] 新API分析失败:', error);
@@ -162,8 +164,8 @@ export default function NutritionLabelAnalysisScreen() {
// 显示错误提示 // 显示错误提示
Alert.alert( Alert.alert(
'分析失败', t('nutritionLabelAnalysis.errors.analysisFailed.title'),
error.message || '无法识别成分表,请尝试拍摄更清晰的照片' error.message || t('nutritionLabelAnalysis.errors.analysisFailed.message')
); );
} finally { } finally {
setIsUploading(false); setIsUploading(false);
@@ -182,7 +184,7 @@ export default function NutritionLabelAnalysisScreen() {
/> />
<HeaderBar <HeaderBar
title="成分表分析" title={t('nutritionLabelAnalysis.title')}
onBack={() => router.back()} onBack={() => router.back()}
transparent={true} transparent={true}
right={ right={
@@ -253,7 +255,7 @@ export default function NutritionLabelAnalysisScreen() {
activeOpacity={0.8} activeOpacity={0.8}
> >
<Ionicons name="search-outline" size={20} color="#FFF" /> <Ionicons name="search-outline" size={20} color="#FFF" />
<Text style={styles.analyzeButtonText}></Text> <Text style={styles.analyzeButtonText}>{t('nutritionLabelAnalysis.actions.startAnalysis')}</Text>
</TouchableOpacity> </TouchableOpacity>
)} )}
@@ -274,7 +276,7 @@ export default function NutritionLabelAnalysisScreen() {
<View style={styles.placeholderContainer}> <View style={styles.placeholderContainer}>
<View style={styles.placeholderContent}> <View style={styles.placeholderContent}>
<Ionicons name="document-text-outline" size={48} color="#666" /> <Ionicons name="document-text-outline" size={48} color="#666" />
<Text style={styles.placeholderText}></Text> <Text style={styles.placeholderText}>{t('nutritionLabelAnalysis.placeholder.text')}</Text>
</View> </View>
{/* 操作按钮区域 */} {/* 操作按钮区域 */}
<View style={styles.imageActionButtonsContainer}> <View style={styles.imageActionButtonsContainer}>
@@ -284,7 +286,7 @@ export default function NutritionLabelAnalysisScreen() {
activeOpacity={0.8} activeOpacity={0.8}
> >
<Ionicons name="camera-outline" size={20} color={Colors.light.onPrimary} /> <Ionicons name="camera-outline" size={20} color={Colors.light.onPrimary} />
<Text style={styles.imageActionButtonText}></Text> <Text style={styles.imageActionButtonText}>{t('nutritionLabelAnalysis.actions.takePhoto')}</Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity
style={[styles.imageActionButton, styles.imageActionButtonSecondary]} style={[styles.imageActionButton, styles.imageActionButtonSecondary]}
@@ -292,7 +294,7 @@ export default function NutritionLabelAnalysisScreen() {
activeOpacity={0.8} activeOpacity={0.8}
> >
<Ionicons name="image-outline" size={20} color={Colors.light.primary} /> <Ionicons name="image-outline" size={20} color={Colors.light.primary} />
<Text style={[styles.imageActionButtonText, { color: Colors.light.primary }]}></Text> <Text style={[styles.imageActionButtonText, { color: Colors.light.primary }]}>{t('nutritionLabelAnalysis.actions.selectFromAlbum')}</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</View> </View>
@@ -307,7 +309,7 @@ export default function NutritionLabelAnalysisScreen() {
<View style={styles.analysisSectionHeaderIcon}> <View style={styles.analysisSectionHeaderIcon}>
<Ionicons name="document-text-outline" size={18} color="#6B6ED6" /> <Ionicons name="document-text-outline" size={18} color="#6B6ED6" />
</View> </View>
<Text style={styles.analysisSectionTitle}></Text> <Text style={styles.analysisSectionTitle}>{t('nutritionLabelAnalysis.results.title')}</Text>
</View> </View>
<View style={styles.analysisCardsWrapper}> <View style={styles.analysisCardsWrapper}>
{newAnalysisResult.data.map((item, index) => ( {newAnalysisResult.data.map((item, index) => (
@@ -352,7 +354,7 @@ export default function NutritionLabelAnalysisScreen() {
<View style={styles.loadingContainer}> <View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={Colors.light.primary} /> <ActivityIndicator size="large" color={Colors.light.primary} />
<Text style={styles.loadingText}> <Text style={styles.loadingText}>
... {uploadProgress > 0 ? `${Math.round(uploadProgress)}%` : ''} {t('nutritionLabelAnalysis.status.uploading')} {uploadProgress > 0 ? `${Math.round(uploadProgress)}%` : ''}
</Text> </Text>
</View> </View>
)} )}
@@ -361,7 +363,7 @@ export default function NutritionLabelAnalysisScreen() {
{isAnalyzing && !newAnalysisResult && !isUploading && ( {isAnalyzing && !newAnalysisResult && !isUploading && (
<View style={styles.loadingContainer}> <View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={Colors.light.primary} /> <ActivityIndicator size="large" color={Colors.light.primary} />
<Text style={styles.loadingText}>...</Text> <Text style={styles.loadingText}>{t('nutritionLabelAnalysis.status.analyzing')}</Text>
</View> </View>
)} )}
</ScrollView> </ScrollView>
@@ -377,7 +379,7 @@ export default function NutritionLabelAnalysisScreen() {
HeaderComponent={() => ( HeaderComponent={() => (
<View style={styles.imageViewerHeader}> <View style={styles.imageViewerHeader}>
<Text style={styles.imageViewerHeaderText}> <Text style={styles.imageViewerHeaderText}>
{dayjs().format('YYYY年M月D日 HH:mm')} {dayjs().format(t('nutritionLabelAnalysis.imageViewer.dateFormat'))}
</Text> </Text>
</View> </View>
)} )}
@@ -387,7 +389,7 @@ export default function NutritionLabelAnalysisScreen() {
style={styles.imageViewerFooterButton} style={styles.imageViewerFooterButton}
onPress={() => setShowImagePreview(false)} onPress={() => setShowImagePreview(false)}
> >
<Text style={styles.imageViewerFooterButtonText}></Text> <Text style={styles.imageViewerFooterButtonText}>{t('nutritionLabelAnalysis.actions.close')}</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
)} )}
@@ -514,7 +516,7 @@ const styles = StyleSheet.create({
}, },
imageActionButtonText: { imageActionButtonText: {
color: Colors.light.onPrimary, color: Colors.light.onPrimary,
fontSize: 14, fontSize: 12,
fontWeight: '600', fontWeight: '600',
marginLeft: 6, marginLeft: 6,
}, },

687
app/gallery/index.tsx Normal file
View File

@@ -0,0 +1,687 @@
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';
import { AiReportRecord, generateAiReport, getAiReportHistory } from '@/services/aiReport';
import { getAuthToken } from '@/services/api';
import { Toast } from '@/utils/toast.utils';
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 { LinearGradient } from 'expo-linear-gradient';
import * as MediaLibrary from 'expo-media-library';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
ActivityIndicator,
Animated,
Platform,
Pressable,
RefreshControl,
ScrollView,
Share,
StatusBar,
StyleSheet,
Text,
TouchableOpacity,
View,
useWindowDimensions
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
export default function GalleryScreen() {
const { t, i18n } = useTranslation();
const insets = useSafeAreaInsets();
const { ensureLoggedIn } = useAuthGuard();
const { checkServiceAccess } = useVipService();
const { openMembershipModal } = useMembershipModal();
const { width: screenWidth, height: screenHeight } = useWindowDimensions();
// 报告历史列表
const [reports, setReports] = useState<AiReportRecord[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isRefreshing, setIsRefreshing] = useState(false);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [isGeneratingReport, setIsGeneratingReport] = useState(false);
const [reportImageUrl, setReportImageUrl] = useState<string | null>(null);
const [reportLocalUri, setReportLocalUri] = useState<string | null>(null);
const [reportModalVisible, setReportModalVisible] = useState(false);
const [isSavingReport, setIsSavingReport] = useState(false);
const [isSharingReport, setIsSharingReport] = useState(false);
const reportSpinAnim = useRef(new Animated.Value(0)).current;
const reportIconSpin = reportSpinAnim.interpolate({
inputRange: [0, 1],
outputRange: ['0deg', '360deg']
});
const emptyImageHeight = useMemo(() => screenHeight / 1.5, [screenHeight]);
const todayString = useMemo(() => dayjs().format('YYYY-MM-DD'), []);
const reportImageSize = useMemo(() => {
const maxWidth = Math.min(screenWidth - 40, 440);
const maxHeight = screenHeight - 240;
let width = maxWidth;
let height = (maxWidth * 16) / 9;
if (height > maxHeight) {
height = maxHeight;
width = (maxHeight * 9) / 16;
}
return { width, height };
}, [screenHeight, screenWidth]);
// 加载报告历史
const loadReports = useCallback(async (pageNum: number, refresh = false) => {
try {
const response = await getAiReportHistory({
page: pageNum,
pageSize: 10,
status: 'success',
});
if (refresh) {
setReports(response.records);
} else {
setReports(prev => [...prev, ...response.records]);
}
setHasMore(pageNum < response.totalPages);
setPage(pageNum);
} catch (error: any) {
console.error('load-ai-report-history-failed', error);
if (refresh) {
Toast.error(t('statistics.aiReport.loadFailed', '加载报告历史失败'));
}
}
}, [t]);
// 初始加载
useEffect(() => {
const init = async () => {
setIsLoading(true);
await loadReports(1, true);
setIsLoading(false);
};
init();
}, [loadReports]);
// 下拉刷新
const handleRefresh = useCallback(async () => {
setIsRefreshing(true);
await loadReports(1, true);
setIsRefreshing(false);
}, [loadReports]);
// 加载更多
const handleLoadMore = useCallback(async () => {
if (isLoadingMore || !hasMore) return;
setIsLoadingMore(true);
await loadReports(page + 1, false);
setIsLoadingMore(false);
}, [isLoadingMore, hasMore, page, loadReports]);
useEffect(() => {
if (!isGeneratingReport) {
reportSpinAnim.stopAnimation();
return;
}
reportSpinAnim.setValue(0);
const loop = Animated.loop(
Animated.timing(reportSpinAnim, {
toValue: 1,
duration: 1400,
useNativeDriver: true,
})
);
loop.start();
return () => loop.stop();
}, [isGeneratingReport, reportSpinAnim]);
const handleGenerateReport = useCallback(async () => {
const ok = await ensureLoggedIn();
if (!ok || isGeneratingReport) return;
// 检查 VIP 权限
const access = checkServiceAccess();
if (!access.canUseService) {
openMembershipModal({
onPurchaseSuccess: () => {
// 购买成功后自动触发生成
handleGenerateReport();
},
});
return;
}
setIsGeneratingReport(true);
setReportLocalUri(null);
Toast.info(t('statistics.aiReport.generating', '正在生成健康报告,预计 1030 秒…'));
try {
const response = await generateAiReport({ date: todayString });
const imageUrl = (response as any)?.imageUrl ?? (response as any)?.url ?? (response as any)?.image_url;
if (!imageUrl) {
throw new Error(t('statistics.aiReport.missing', '未获取到报告图片,请稍后重试'));
}
setReportImageUrl(imageUrl);
setReportModalVisible(true);
Toast.success(t('statistics.aiReport.success', '报告已生成'));
// 生成成功后刷新列表
handleRefresh();
} catch (error: any) {
console.error('generate-ai-report-failed', error);
Toast.error(error?.message ?? t('statistics.aiReport.failed', '生成报告失败,请稍后重试'));
} finally {
setIsGeneratingReport(false);
}
}, [ensureLoggedIn, isGeneratingReport, checkServiceAccess, openMembershipModal, t, todayString, handleRefresh]);
const prepareLocalReportImage = useCallback(async () => {
if (!reportImageUrl) {
throw new Error(t('statistics.aiReport.missing', '未获取到报告图片,请稍后重试'));
}
if (reportLocalUri) {
return reportLocalUri;
}
const fileUri = `${FileSystem.cacheDirectory}ai-report-${Date.now()}.jpg`;
const token = await getAuthToken();
const download = await FileSystem.downloadAsync(
reportImageUrl,
fileUri,
token ? { headers: { Authorization: `Bearer ${token}` } } : undefined,
);
if (!download?.uri) {
throw new Error(t('statistics.aiReport.missing', '未获取到报告图片,请稍后重试'));
}
setReportLocalUri(download.uri);
return download.uri;
}, [reportImageUrl, reportLocalUri, t]);
const handleSaveReport = useCallback(async () => {
if (isSavingReport) return;
try {
setIsSavingReport(true);
const permission = await MediaLibrary.requestPermissionsAsync();
if (permission.status !== 'granted') {
Toast.warning(t('statistics.aiReport.permission', '需要相册权限才能保存图片'));
return;
}
const localUri = await prepareLocalReportImage();
await MediaLibrary.saveToLibraryAsync(localUri);
Toast.success(t('statistics.aiReport.saved', '已保存到相册'));
} catch (error: any) {
console.error('save-ai-report-failed', error);
Toast.error(error?.message ?? t('statistics.aiReport.saveFailed', '保存失败,请稍后重试'));
} finally {
setIsSavingReport(false);
}
}, [isSavingReport, prepareLocalReportImage, t]);
const handleShareReport = useCallback(async () => {
if (isSharingReport) return;
try {
setIsSharingReport(true);
const localUri = await prepareLocalReportImage();
await Share.share({
message: t('statistics.aiReport.shareMessage', '这是我的 AI 健康报告,分享给你看看!'),
url: Platform.OS === 'ios' ? localUri : `file://${localUri}`,
title: t('statistics.aiReport.shareTitle', 'AI 健康报告')
});
} catch (error: any) {
console.error('share-ai-report-failed', error);
Toast.error(error?.message ?? t('statistics.aiReport.shareFailed', '分享失败,请稍后重试'));
} finally {
setIsSharingReport(false);
}
}, [isSharingReport, prepareLocalReportImage, t]);
// 点击卡片查看报告
const handleCardPress = useCallback((report: AiReportRecord) => {
if (!report.imageUrl) return;
setReportImageUrl(report.imageUrl);
setReportLocalUri(null);
setReportModalVisible(true);
}, []);
// 滚动到底部加载更多
const handleScroll = useCallback((event: any) => {
const { layoutMeasurement, contentOffset, contentSize } = event.nativeEvent;
const paddingToBottom = 100;
if (layoutMeasurement.height + contentOffset.y >= contentSize.height - paddingToBottom) {
handleLoadMore();
}
}, [handleLoadMore]);
const headerRight = isLiquidGlassAvailable() ? (
<TouchableOpacity
activeOpacity={0.7}
onPress={handleGenerateReport}
disabled={isGeneratingReport}
>
<GlassView
style={styles.reportButton}
glassEffectStyle="clear"
isInteractive
>
<Animated.View style={[styles.reportIconWrapper, isGeneratingReport && { transform: [{ rotate: reportIconSpin }] }]}>
<Ionicons name="sparkles-outline" size={18} color="#0F172A" />
</Animated.View>
</GlassView>
</TouchableOpacity>
) : (
<TouchableOpacity
activeOpacity={0.9}
onPress={handleGenerateReport}
style={[styles.reportButton, styles.reportButtonFallback]}
disabled={isGeneratingReport}
>
<Animated.View style={[styles.reportIconWrapper, isGeneratingReport && { transform: [{ rotate: reportIconSpin }] }]}>
<Ionicons name="sparkles-outline" size={18} color="#0F172A" />
</Animated.View>
</TouchableOpacity>
);
const headerTitle = (
<View style={styles.headerCenter}>
<Text style={styles.headerTitle}>{t('statistics.aiReport.galleryTitle', 'AI 报告画廊')}</Text>
<Text style={styles.headerSubtitle}>{t('statistics.aiReport.gallerySubtitle', '沉浸式浏览你的健康报告')}</Text>
</View>
);
return (
<View style={styles.container}>
<StatusBar barStyle="dark-content" />
<LinearGradient
colors={['#f0f4ff', '#fdf8ff', '#f6f8fa']}
style={StyleSheet.absoluteFill}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
/>
<HeaderBar
title={headerTitle}
right={headerRight}
tone="light"
transparent
/>
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={{
paddingTop: insets.top + 56,
paddingBottom: 40,
paddingHorizontal: 16,
...(reports.length === 0 && !isLoading ? { flexGrow: 1, justifyContent: 'center' } : {})
}}
showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl
refreshing={isRefreshing}
onRefresh={handleRefresh}
tintColor="#6B7280"
/>
}
onScroll={handleScroll}
scrollEventThrottle={400}
>
{isLoading ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color="#3B82F6" />
</View>
) : reports.length === 0 ? (
<View style={styles.emptyContainer}>
<Pressable
style={styles.emptyImageCard}
onPress={() => {
const imageUrl = i18n.language?.startsWith('en')
? 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/gallary/empty_en.jpg'
: 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/gallary/empty_zh.jpg';
setReportImageUrl(imageUrl);
setReportLocalUri(null);
setReportModalVisible(true);
}}
>
<ExpoImage
source={{
uri: i18n.language?.startsWith('en')
? 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/gallary/empty_en.jpg'
: 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/gallary/empty_zh.jpg'
}}
style={[styles.emptyImage, { height: emptyImageHeight }]}
contentFit="contain"
transition={300}
/>
<View style={styles.emptyImageOverlay}>
<View style={styles.previewHint}>
<Ionicons name="expand-outline" size={14} color="#fff" />
<Text style={styles.previewHintText}>{t('statistics.aiReport.clickToPreview', '点击预览模板')}</Text>
</View>
</View>
</Pressable>
<View style={styles.emptyContent}>
<Text style={styles.emptyTitle}>{t('statistics.aiReport.emptyHistory', '暂无报告记录')}</Text>
<Text style={styles.emptySubtitle}>{t('statistics.aiReport.emptyHistoryHint', '点击右上方按钮生成你的第一份报告')}</Text>
</View>
</View>
) : (
<View style={styles.galleryGrid}>
{reports.map((report) => (
<Pressable
key={report.id}
style={({ pressed }) => [styles.card, pressed && styles.cardPressed]}
onPress={() => handleCardPress(report)}
>
<ExpoImage
source={{ uri: report.imageUrl }}
style={styles.cardImage}
contentFit="cover"
transition={250}
/>
<View style={styles.cardBody}>
<Text numberOfLines={1} style={styles.cardTitle}>
{dayjs(report.reportDate).format('YYYY年M月D日')}
</Text>
<Text style={styles.cardSubtitle}>
{dayjs(report.createdAt).format('HH:mm')} {t('statistics.aiReport.generated', '生成')}
</Text>
</View>
</Pressable>
))}
{isLoadingMore && (
<View style={styles.loadingMoreContainer}>
<ActivityIndicator size="small" color="#6B7280" />
</View>
)}
</View>
)}
</ScrollView>
{reportModalVisible && (
<View style={styles.modalOverlay}>
<Pressable style={StyleSheet.absoluteFill} onPress={() => setReportModalVisible(false)} />
<View style={styles.modalCard}>
{reportImageUrl ? (
<ExpoImage
source={{ uri: reportImageUrl }}
style={[styles.reportImage, { width: reportImageSize.width, height: reportImageSize.height }]}
contentFit="cover"
/>
) : (
<View style={[styles.reportImageFallback, { width: reportImageSize.width, height: reportImageSize.height }]}>
<Text style={styles.reportFallbackText}>{t('statistics.aiReport.missing', '未获取到报告图片,请稍后重试')}</Text>
</View>
)}
<View style={styles.modalActions}>
<TouchableOpacity
style={[styles.modalButton, isSavingReport && styles.modalButtonDisabled]}
onPress={handleSaveReport}
disabled={isSavingReport}
>
<Ionicons name="download-outline" size={18} color="#0F172A" />
<Text style={styles.modalButtonText}>
{isSavingReport ? t('statistics.aiReport.saving', '保存中…') : t('statistics.aiReport.save', '保存')}
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.modalButton, isSharingReport && styles.modalButtonDisabled]}
onPress={handleShareReport}
disabled={isSharingReport}
>
<Ionicons name="share-social-outline" size={18} color="#0F172A" />
<Text style={styles.modalButtonText}>
{isSharingReport ? t('statistics.aiReport.sharing', '分享中…') : t('statistics.aiReport.share', '分享')}
</Text>
</TouchableOpacity>
</View>
<Pressable style={styles.closeRow} onPress={() => setReportModalVisible(false)}>
<Ionicons name="close" size={18} color="#4B5563" />
<Text style={styles.closeLabel}>{t('statistics.aiReport.close', '收起')}</Text>
</Pressable>
</View>
</View>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f7f8fb',
},
headerCenter: {
flex: 1,
minWidth: 0,
},
headerTitle: {
fontSize: 18,
fontFamily: 'AliBold',
color: '#0F172A',
textAlign: 'center',
},
headerSubtitle: {
marginTop: 2,
color: '#6B7280',
fontSize: 12,
fontFamily: 'AliRegular',
textAlign: 'center',
},
reportButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
width: 36,
height: 36,
borderRadius: 18,
overflow: 'hidden',
},
reportButtonFallback: {
backgroundColor: 'rgba(255, 255, 255, 0.5)',
borderWidth: 1,
borderColor: '#E5E7EB',
},
reportIconWrapper: {
width: 28,
height: 28,
borderRadius: 14,
backgroundColor: '#E0F2FE',
alignItems: 'center',
justifyContent: 'center',
},
loadingContainer: {
flex: 1,
paddingTop: 100,
alignItems: 'center',
justifyContent: 'center',
},
emptyContainer: {
alignItems: 'center',
gap: 24,
},
emptyImageCard: {
width: '100%',
borderRadius: 20,
overflow: 'hidden',
shadowColor: '#000',
shadowOpacity: 0.1,
shadowRadius: 16,
shadowOffset: { width: 0, height: 8 },
elevation: 6,
},
emptyImage: {
width: '100%',
height: 380,
},
emptyImageOverlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0, 0, 0, 0.15)',
borderRadius: 20,
},
previewHint: {
position: 'absolute',
top: 12,
right: 12,
flexDirection: 'row',
alignItems: 'center',
gap: 4,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
paddingHorizontal: 10,
paddingVertical: 6,
borderRadius: 14,
},
previewHintText: {
fontSize: 12,
fontFamily: 'AliRegular',
color: '#fff',
},
emptyContent: {
alignItems: 'center',
gap: 12,
paddingHorizontal: 20,
},
emptyTitle: {
fontSize: 18,
fontFamily: 'AliBold',
color: '#1F2937',
textAlign: 'center',
},
emptySubtitle: {
fontSize: 14,
fontFamily: 'AliRegular',
color: '#6B7280',
textAlign: 'center',
lineHeight: 20,
},
emptyButton: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
paddingHorizontal: 28,
paddingVertical: 14,
backgroundColor: '#3B82F6',
borderRadius: 28,
marginTop: 8,
shadowColor: '#3B82F6',
shadowOpacity: 0.3,
shadowRadius: 12,
shadowOffset: { width: 0, height: 6 },
elevation: 4,
},
emptyButtonText: {
fontSize: 16,
fontFamily: 'AliBold',
color: '#fff',
},
loadingMoreContainer: {
paddingVertical: 20,
alignItems: 'center',
},
galleryGrid: {
gap: 18,
},
card: {
backgroundColor: '#fff',
borderRadius: 22,
overflow: 'hidden',
shadowColor: '#000',
shadowOpacity: 0.08,
shadowRadius: 12,
shadowOffset: { width: 0, height: 8 },
elevation: 6,
},
cardPressed: {
transform: [{ scale: 0.99 }],
},
cardImage: {
width: '100%',
height: 360,
},
cardBody: {
paddingHorizontal: 16,
paddingVertical: 14,
gap: 4,
},
cardTitle: {
fontSize: 16,
fontFamily: 'AliBold',
color: '#111827',
},
cardSubtitle: {
fontSize: 12,
fontFamily: 'AliRegular',
color: '#9CA3AF',
},
modalOverlay: {
position: 'absolute',
inset: 0,
backgroundColor: 'rgba(12, 18, 27, 0.78)',
alignItems: 'center',
justifyContent: 'center',
padding: 16,
},
modalCard: {
backgroundColor: '#FDFDFE',
borderRadius: 20,
padding: 14,
alignItems: 'center',
gap: 12,
shadowColor: '#000',
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.28,
shadowRadius: 18,
elevation: 16,
},
reportImage: {
borderRadius: 14,
overflow: 'hidden',
},
reportImageFallback: {
borderRadius: 14,
backgroundColor: '#F3F4F6',
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 20,
},
reportFallbackText: {
textAlign: 'center',
color: '#111827',
fontFamily: 'AliRegular',
},
modalActions: {
flexDirection: 'row',
gap: 10,
},
modalButton: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
paddingHorizontal: 12,
paddingVertical: 10,
backgroundColor: '#E0F2FE',
borderRadius: 12,
borderWidth: 1,
borderColor: '#BAE6FD',
},
modalButtonDisabled: {
opacity: 0.6,
},
modalButtonText: {
fontSize: 14,
color: '#0F172A',
fontFamily: 'AliBold',
},
closeRow: {
marginTop: 4,
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingHorizontal: 8,
paddingVertical: 6,
},
closeLabel: {
fontSize: 14,
color: '#4B5563',
fontFamily: 'AliRegular',
},
});

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 { ThemedText } from '@/components/ThemedText';
import { ConfirmationSheet } from '@/components/ui/ConfirmationSheet'; import { ConfirmationSheet } from '@/components/ui/ConfirmationSheet';
import { HeaderBar } from '@/components/ui/HeaderBar'; import { HeaderBar } from '@/components/ui/HeaderBar';
import { Image } from '@/components/ui/Image';
import InfoCard from '@/components/ui/InfoCard'; import InfoCard from '@/components/ui/InfoCard';
import { Colors } from '@/constants/Colors'; import { Colors } from '@/constants/Colors';
import { DOSAGE_UNITS, DOSAGE_VALUES, FORM_OPTIONS } from '@/constants/Medication'; import { DOSAGE_UNITS, DOSAGE_VALUES, FORM_OPTIONS } from '@/constants/Medication';
@@ -37,7 +38,6 @@ import { Picker } from '@react-native-picker/picker';
import Voice from '@react-native-voice/voice'; import Voice from '@react-native-voice/voice';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect'; import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { Image } from 'expo-image';
import * as ImagePicker from 'expo-image-picker'; import * as ImagePicker from 'expo-image-picker';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import { useLocalSearchParams, useRouter } from 'expo-router'; import { useLocalSearchParams, useRouter } from 'expo-router';
@@ -559,19 +559,17 @@ export default function MedicationDetailScreen() {
const formLabel = medication ? t(`medications.manage.formLabels.${medication.form}`) : ''; const formLabel = medication ? t(`medications.manage.formLabels.${medication.form}`) : '';
const dosageLabel = medication ? `${medication.dosageValue} ${medication.dosageUnit}` : '--'; const dosageLabel = medication ? `${medication.dosageValue} ${medication.dosageUnit}` : '--';
const startDateLabel = medication
? dayjs(medication.startDate).format('YYYY年M月D日')
: '--';
// 计算服药周期显示 // 计算服药周期显示
const medicationPeriodLabel = useMemo(() => { const medicationPeriodLabel = useMemo(() => {
if (!medication) return '--'; if (!medication) return '--';
const startDate = dayjs(medication.startDate).format('YYYY年M月D日'); const format = t('medications.detail.plan.dateFormat', { defaultValue: 'YYYY年M月D日' });
const startDate = dayjs(medication.startDate).format(format);
if (medication.endDate) { if (medication.endDate) {
// 有结束日期,显示开始日期到结束日期 // 有结束日期,显示开始日期到结束日期
const endDate = dayjs(medication.endDate).format('YYYY年M月D日'); const endDate = dayjs(medication.endDate).format(format);
return `${startDate} - ${endDate}`; return `${startDate} - ${endDate}`;
} else { } else {
// 没有结束日期,显示长期 // 没有结束日期,显示长期
@@ -581,22 +579,23 @@ export default function MedicationDetailScreen() {
// 计算有效期显示 // 计算有效期显示
const expiryDateLabel = useMemo(() => { const expiryDateLabel = useMemo(() => {
if (!medication?.expiryDate) return '未设置'; if (!medication?.expiryDate) return t('medications.detail.plan.expiryStatus.notSet');
const expiry = dayjs(medication.expiryDate); const expiry = dayjs(medication.expiryDate);
const today = dayjs(); const today = dayjs();
const daysUntilExpiry = expiry.diff(today, 'day'); const daysUntilExpiry = expiry.diff(today, 'day');
const format = t('medications.detail.plan.dateFormat', { defaultValue: 'YYYY年M月D日' });
if (daysUntilExpiry < 0) { if (daysUntilExpiry < 0) {
return `${expiry.format('YYYY年M月D日')} (已过期)`; return `${expiry.format(format)} (${t('medications.detail.plan.expiryStatus.expired')})`;
} else if (daysUntilExpiry === 0) { } else if (daysUntilExpiry === 0) {
return `${expiry.format('YYYY年M月D日')} (今天到期)`; return `${expiry.format(format)} (${t('medications.detail.plan.expiryStatus.expiresToday')})`;
} else if (daysUntilExpiry <= 30) { } else if (daysUntilExpiry <= 30) {
return `${expiry.format('YYYY年M月D日')} (${daysUntilExpiry}天后到期)`; return `${expiry.format(format)} (${t('medications.detail.plan.expiryStatus.expiresInDays', { days: daysUntilExpiry })})`;
} else { } else {
return expiry.format('YYYY年M月D日'); return expiry.format(format);
} }
}, [medication?.expiryDate]); }, [medication?.expiryDate, t]);
const reminderTimes = medication?.medicationTimes?.length const reminderTimes = medication?.medicationTimes?.length
? medication.medicationTimes.join('、') ? medication.medicationTimes.join('、')
@@ -617,8 +616,8 @@ export default function MedicationDetailScreen() {
const aiActionLabel = aiAnalysisLoading const aiActionLabel = aiAnalysisLoading
? t('medications.detail.aiAnalysis.analyzingButton') ? t('medications.detail.aiAnalysis.analyzingButton')
: hasAiAnalysis : hasAiAnalysis
? '重新分析' ? t('medications.detail.aiAnalysis.reanalyzeButton')
: '获取 AI 分析'; : t('medications.detail.aiAnalysis.getAnalysisButton');
const handleOpenNoteModal = useCallback(() => { const handleOpenNoteModal = useCallback(() => {
setNoteDraft(medication?.note ?? ''); setNoteDraft(medication?.note ?? '');
@@ -645,15 +644,15 @@ export default function MedicationDetailScreen() {
const trimmed = nameDraft.trim(); const trimmed = nameDraft.trim();
if (!trimmed) { if (!trimmed) {
Alert.alert( Alert.alert(
'提示', t('common.hint'),
'药物名称不能为空' t('medications.detail.name.errors.empty')
); );
return; return;
} }
if (Array.from(trimmed).length > 10) { if (Array.from(trimmed).length > 10) {
Alert.alert( Alert.alert(
'提示', t('common.hint'),
'药物名称不能超过10个字' t('medications.detail.name.errors.tooLong')
); );
return; return;
} }
@@ -675,8 +674,8 @@ export default function MedicationDetailScreen() {
} catch (err) { } catch (err) {
console.error('更新药物名称失败', err); console.error('更新药物名称失败', err);
Alert.alert( Alert.alert(
'提示', t('common.hint'),
'名称更新失败,请稍后再试' t('medications.detail.name.errors.updateFailed')
); );
} finally { } finally {
setNameSaving(false); setNameSaving(false);
@@ -908,16 +907,17 @@ export default function MedicationDetailScreen() {
const handleStartDatePress = useCallback(() => { const handleStartDatePress = useCallback(() => {
if (!medication) return; if (!medication) return;
const startDate = dayjs(medication.startDate).format('YYYY年M月D日'); const format = t('medications.detail.plan.dateFormat', { defaultValue: 'YYYY年M月D日' });
const startDate = dayjs(medication.startDate).format(format);
let message; let message;
if (medication.endDate) { if (medication.endDate) {
const endDate = dayjs(medication.endDate).format('YYYY年M月D日'); const endDate = dayjs(medication.endDate).format(format);
message = `${startDate}${endDate}`; message = t('medications.detail.plan.periodRange', { startDate, endDate, defaultValue: `${startDate}${endDate}` });
} else { } else {
message = `${startDate} 至长期`; message = t('medications.detail.plan.periodLongTerm', { startDate, defaultValue: `${startDate} 至长期` });
} }
Alert.alert('服药周期', message); Alert.alert(t('medications.detail.plan.period'), message);
}, [medication, t]); }, [medication, t]);
const handleTimePress = useCallback(() => { const handleTimePress = useCallback(() => {
@@ -990,7 +990,7 @@ export default function MedicationDetailScreen() {
} catch (err) { } catch (err) {
console.error('更新有效期失败', err); console.error('更新有效期失败', err);
Alert.alert('更新失败', '有效期更新失败,请稍后重试'); Alert.alert(t('medications.detail.updateErrors.expiryDate'), t('medications.detail.updateErrors.expiryDateMessage'));
} finally { } finally {
setUpdatePending(false); setUpdatePending(false);
} }
@@ -1185,7 +1185,7 @@ export default function MedicationDetailScreen() {
}); });
} catch (err: any) { } catch (err: any) {
console.error('[MEDICATION_DETAIL] AI 草稿保存失败', err); console.error('[MEDICATION_DETAIL] AI 草稿保存失败', err);
Alert.alert('保存失败', err?.message || '请稍后再试'); Alert.alert(t('medications.detail.aiDraft.saveError.title'), err?.message || t('medications.detail.aiDraft.saveError.message'));
} finally { } finally {
setAiDraftSaving(false); setAiDraftSaving(false);
} }
@@ -1297,7 +1297,7 @@ export default function MedicationDetailScreen() {
<View style={styles.photoUploadingIndicator}> <View style={styles.photoUploadingIndicator}>
<ActivityIndicator color={colors.primary} size="small" /> <ActivityIndicator color={colors.primary} size="small" />
<Text style={[styles.uploadingText, { color: '#FFF' }]}> <Text style={[styles.uploadingText, { color: '#FFF' }]}>
... {t('medications.detail.photo.uploading')}
</Text> </Text>
</View> </View>
)} )}
@@ -1415,12 +1415,12 @@ export default function MedicationDetailScreen() {
</TouchableOpacity> </TouchableOpacity>
</Section> </Section>
<Section title="AI 分析" color={colors.text}> <Section title={t('medications.detail.sections.aiAnalysis')} color={colors.text}>
<View style={[styles.aiCardContainer, { backgroundColor: colors.surface }]}> <View style={[styles.aiCardContainer, { backgroundColor: colors.surface }]}>
<View style={styles.aiHeaderRow}> <View style={styles.aiHeaderRow}>
<View style={styles.aiHeaderLeft}> <View style={styles.aiHeaderLeft}>
<Ionicons name="sparkles-outline" size={18} color={colors.primary} /> <Ionicons name="sparkles-outline" size={18} color={colors.primary} />
<Text style={[styles.aiHeaderTitle, { color: colors.text }]}></Text> <Text style={[styles.aiHeaderTitle, { color: colors.text }]}>{t('medications.detail.aiAnalysis.title')}</Text>
</View> </View>
<View <View
style={[ style={[
@@ -1439,7 +1439,7 @@ export default function MedicationDetailScreen() {
{ color: hasAiAnalysis ? '#16a34a' : aiAnalysisLocked ? '#ef4444' : colors.primary }, { color: hasAiAnalysis ? '#16a34a' : aiAnalysisLocked ? '#ef4444' : colors.primary },
]} ]}
> >
{hasAiAnalysis ? '已生成' : aiAnalysisLocked ? '会员专享' : '待生成'} {hasAiAnalysis ? t('medications.detail.aiAnalysis.status.generated') : aiAnalysisLocked ? t('medications.detail.aiAnalysis.status.memberExclusive') : t('medications.detail.aiAnalysis.status.pending')}
</Text> </Text>
</View> </View>
</View> </View>
@@ -1467,7 +1467,7 @@ export default function MedicationDetailScreen() {
style={styles.aiScoreBadge} style={styles.aiScoreBadge}
> >
<Ionicons name="thumbs-up-outline" size={14} color="#fff" /> <Ionicons name="thumbs-up-outline" size={14} color="#fff" />
<Text style={styles.aiScoreBadgeText}>AI </Text> <Text style={styles.aiScoreBadgeText}>{t('medications.detail.aiAnalysis.recommendation')}</Text>
</LinearGradient> </LinearGradient>
)} )}
</View> </View>
@@ -1476,7 +1476,7 @@ export default function MedicationDetailScreen() {
{medication.name} {medication.name}
</Text> </Text>
<Text style={[styles.aiHeroSubtitle, { color: colors.textSecondary }]} numberOfLines={3}> <Text style={[styles.aiHeroSubtitle, { color: colors.textSecondary }]} numberOfLines={3}>
{aiAnalysisResult?.mainUsage || '获取 AI 分析,快速了解适用人群、成分安全与使用建议。'} {aiAnalysisResult?.mainUsage || t('medications.detail.aiAnalysis.placeholder')}
</Text> </Text>
<View style={styles.aiChipRow}> <View style={styles.aiChipRow}>
<View style={styles.aiChip}> <View style={styles.aiChip}>
@@ -1527,7 +1527,7 @@ export default function MedicationDetailScreen() {
<View style={styles.aiColumns}> <View style={styles.aiColumns}>
<View style={[styles.aiBubbleCard, { backgroundColor: '#ECFEFF', borderColor: '#BAF2F4' }]}> <View style={[styles.aiBubbleCard, { backgroundColor: '#ECFEFF', borderColor: '#BAF2F4' }]}>
<View style={styles.aiBubbleHeader}> <View style={styles.aiBubbleHeader}>
<Text style={[styles.aiBubbleTitle, { color: '#0284c7' }]}></Text> <Text style={[styles.aiBubbleTitle, { color: '#0284c7' }]}>{t('medications.detail.aiAnalysis.categories.suitableFor')}</Text>
<Ionicons name="checkmark-circle" size={16} color="#0ea5e9" /> <Ionicons name="checkmark-circle" size={16} color="#0ea5e9" />
</View> </View>
{aiAnalysisResult.suitableFor.map((item, idx) => ( {aiAnalysisResult.suitableFor.map((item, idx) => (
@@ -1540,7 +1540,7 @@ export default function MedicationDetailScreen() {
<View style={[styles.aiBubbleCard, { backgroundColor: '#FEF2F2', borderColor: '#FEE2E2' }]}> <View style={[styles.aiBubbleCard, { backgroundColor: '#FEF2F2', borderColor: '#FEE2E2' }]}>
<View style={styles.aiBubbleHeader}> <View style={styles.aiBubbleHeader}>
<Text style={[styles.aiBubbleTitle, { color: '#ef4444' }]}></Text> <Text style={[styles.aiBubbleTitle, { color: '#ef4444' }]}>{t('medications.detail.aiAnalysis.categories.unsuitableFor')}</Text>
<Ionicons name="alert-circle" size={16} color="#ef4444" /> <Ionicons name="alert-circle" size={16} color="#ef4444" />
</View> </View>
{aiAnalysisResult.unsuitableFor.map((item, idx) => ( {aiAnalysisResult.unsuitableFor.map((item, idx) => (
@@ -1552,9 +1552,9 @@ export default function MedicationDetailScreen() {
</View> </View>
</View> </View>
{renderAdviceCard('可能的副作用', aiAnalysisResult.sideEffects, 'warning-outline', '#f59e0b', '#FFFBEB')} {renderAdviceCard(t('medications.detail.aiAnalysis.categories.sideEffects'), aiAnalysisResult.sideEffects, 'warning-outline', '#f59e0b', '#FFFBEB')}
{renderAdviceCard('储存建议', aiAnalysisResult.storageAdvice, 'cube-outline', '#10b981', '#ECFDF3')} {renderAdviceCard(t('medications.detail.aiAnalysis.categories.storageAdvice'), aiAnalysisResult.storageAdvice, 'cube-outline', '#10b981', '#ECFDF3')}
{renderAdviceCard('健康/使用建议', aiAnalysisResult.healthAdvice, 'sparkles-outline', '#6366f1', '#EEF2FF')} {renderAdviceCard(t('medications.detail.aiAnalysis.categories.healthAdvice'), aiAnalysisResult.healthAdvice, 'sparkles-outline', '#6366f1', '#EEF2FF')}
</> </>
) : null} ) : null}
@@ -1580,8 +1580,8 @@ export default function MedicationDetailScreen() {
<View style={styles.aiMembershipLeft}> <View style={styles.aiMembershipLeft}>
<Ionicons name="diamond-outline" size={18} color="#f59e0b" /> <Ionicons name="diamond-outline" size={18} color="#f59e0b" />
<View> <View>
<Text style={styles.aiMembershipTitle}> AI </Text> <Text style={styles.aiMembershipTitle}>{t('medications.detail.aiAnalysis.membershipCard.title')}</Text>
<Text style={styles.aiMembershipSub}>使</Text> <Text style={styles.aiMembershipSub}>{t('medications.detail.aiAnalysis.membershipCard.subtitle')}</Text>
</View> </View>
</View> </View>
<Ionicons name="chevron-forward" size={18} color="#f59e0b" /> <Ionicons name="chevron-forward" size={18} color="#f59e0b" />
@@ -1622,22 +1622,67 @@ export default function MedicationDetailScreen() {
{isAiDraft ? ( {isAiDraft ? (
<View style={styles.footerButtonContainer}> <View style={styles.footerButtonContainer}>
<TouchableOpacity <TouchableOpacity
style={styles.secondaryFooterBtn}
activeOpacity={0.9} activeOpacity={0.9}
onPress={() => router.replace('/medications/ai-camera')} onPress={() => router.replace('/medications/ai-camera')}
> >
<Text style={styles.secondaryFooterText}></Text> {isLiquidGlassAvailable() ? (
<GlassView
style={[styles.secondaryFooterBtn, { borderWidth: 0, overflow: 'hidden', backgroundColor: 'transparent' }]}
glassEffectStyle="regular"
isInteractive={true}
>
<Text style={styles.secondaryFooterText}>{t('medications.detail.aiDraft.reshoot')}</Text>
</GlassView>
) : (
<View style={styles.secondaryFooterBtn}>
<Text style={styles.secondaryFooterText}>{t('medications.detail.aiDraft.reshoot')}</Text>
</View>
)}
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity
style={[styles.primaryFooterBtn, { backgroundColor: colors.primary }]} style={{ flex: 1 }}
activeOpacity={0.9} activeOpacity={0.9}
onPress={handleAiDraftSave} onPress={handleAiDraftSave}
disabled={aiDraftSaving} disabled={aiDraftSaving}
> >
{aiDraftSaving ? ( {isLiquidGlassAvailable() ? (
<ActivityIndicator color={colors.onPrimary} /> <GlassView
style={[
styles.primaryFooterBtn,
{
width: '100%',
shadowOpacity: 0,
backgroundColor: 'transparent',
overflow: 'hidden',
},
]}
glassEffectStyle="regular"
tintColor={colors.primary}
isInteractive={true}
>
{aiDraftSaving ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={[styles.primaryFooterText, { color: '#fff' }]}>
{t('medications.detail.aiDraft.saveAndCreate')}
</Text>
)}
</GlassView>
) : ( ) : (
<Text style={[styles.primaryFooterText, { color: colors.onPrimary }]}></Text> <View
style={[
styles.primaryFooterBtn,
{ width: '100%', backgroundColor: colors.primary },
]}
>
{aiDraftSaving ? (
<ActivityIndicator color={colors.onPrimary} />
) : (
<Text style={[styles.primaryFooterText, { color: colors.onPrimary }]}>
{t('medications.detail.aiDraft.saveAndCreate')}
</Text>
)}
</View>
)} )}
</TouchableOpacity> </TouchableOpacity>
</View> </View>
@@ -1726,7 +1771,7 @@ export default function MedicationDetailScreen() {
<View style={styles.modalHandle} /> <View style={styles.modalHandle} />
<View style={styles.modalHeader}> <View style={styles.modalHeader}>
<Text style={[styles.modalTitle, { color: colors.text }]}> <Text style={[styles.modalTitle, { color: colors.text }]}>
{t('medications.detail.name.edit')}
</Text> </Text>
<TouchableOpacity onPress={handleCloseNameModal} hitSlop={12}> <TouchableOpacity onPress={handleCloseNameModal} hitSlop={12}>
<Ionicons name="close" size={20} color={colors.textSecondary} /> <Ionicons name="close" size={20} color={colors.textSecondary} />
@@ -1744,7 +1789,7 @@ export default function MedicationDetailScreen() {
<TextInput <TextInput
value={nameDraft} value={nameDraft}
onChangeText={handleNameChange} onChangeText={handleNameChange}
placeholder="请输入药物名称" placeholder={t('medications.detail.name.placeholder')}
placeholderTextColor={colors.textMuted} placeholderTextColor={colors.textMuted}
style={[styles.nameInput, { color: colors.text }]} style={[styles.nameInput, { color: colors.text }]}
autoFocus autoFocus
@@ -1777,7 +1822,7 @@ export default function MedicationDetailScreen() {
<ActivityIndicator color={colors.onPrimary} size="small" /> <ActivityIndicator color={colors.onPrimary} size="small" />
) : ( ) : (
<Text style={[styles.modalActionPrimaryText, { color: colors.onPrimary }]}> <Text style={[styles.modalActionPrimaryText, { color: colors.onPrimary }]}>
{t('common.save')}
</Text> </Text>
)} )}
</TouchableOpacity> </TouchableOpacity>
@@ -2599,7 +2644,7 @@ const styles = StyleSheet.create({
elevation: 4, elevation: 4,
}, },
aiScoreBadgeText: { aiScoreBadgeText: {
fontSize: 12, fontSize: 10,
fontWeight: '700', fontWeight: '700',
color: '#fff', color: '#fff',
}, },

View File

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

View File

@@ -1,15 +1,16 @@
import { MedicationPhotoGuideModal } from '@/components/medications/MedicationPhotoGuideModal'; import { MedicationPhotoGuideModal } from '@/components/medications/MedicationPhotoGuideModal';
import { HeaderBar } from '@/components/ui/HeaderBar'; import { HeaderBar } from '@/components/ui/HeaderBar';
import { Image } from '@/components/ui/Image';
import { Colors } from '@/constants/Colors'; import { Colors } from '@/constants/Colors';
import { useAuthGuard } from '@/hooks/useAuthGuard'; import { useAuthGuard } from '@/hooks/useAuthGuard';
import { useColorScheme } from '@/hooks/useColorScheme'; import { useColorScheme } from '@/hooks/useColorScheme';
import { useCosUpload } from '@/hooks/useCosUpload'; import { useCosUpload } from '@/hooks/useCosUpload';
import { useI18n } from '@/hooks/useI18n';
import { createMedicationRecognitionTask } from '@/services/medications'; import { createMedicationRecognitionTask } from '@/services/medications';
import { getItem, setItem } from '@/utils/kvStore'; import { getItem, setItem } from '@/utils/kvStore';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { CameraView, useCameraPermissions } from 'expo-camera'; import { CameraView, useCameraPermissions } from 'expo-camera';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect'; import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { Image } from 'expo-image';
import * as ImagePicker from 'expo-image-picker'; import * as ImagePicker from 'expo-image-picker';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import { router } from 'expo-router'; import { router } from 'expo-router';
@@ -39,9 +40,9 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
const MEDICATION_GUIDE_SEEN_KEY = 'medication_ai_camera_guide_seen'; const MEDICATION_GUIDE_SEEN_KEY = 'medication_ai_camera_guide_seen';
const captureSteps = [ const captureSteps = [
{ key: 'front', title: '正面', subtitle: '保证药品名称清晰可见', mandatory: true }, { key: 'front', mandatory: true },
{ key: 'side', title: '背面', subtitle: '包含规格、成分等信息', mandatory: true }, { key: 'side', mandatory: true },
{ key: 'aux', title: '侧面', subtitle: '补充更多细节提升准确率', mandatory: false }, { key: 'aux', mandatory: false },
] as const; ] as const;
type CaptureKey = (typeof captureSteps)[number]['key']; type CaptureKey = (typeof captureSteps)[number]['key'];
@@ -51,6 +52,7 @@ type Shot = {
}; };
export default function MedicationAiCameraScreen() { export default function MedicationAiCameraScreen() {
const { t } = useI18n();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const scheme = (useColorScheme() ?? 'light') as keyof typeof Colors; const scheme = (useColorScheme() ?? 'light') as keyof typeof Colors;
const colors = Colors[scheme]; const colors = Colors[scheme];
@@ -113,7 +115,14 @@ export default function MedicationAiCameraScreen() {
} }
}, [allRequiredCaptured]); }, [allRequiredCaptured]);
const stepTitle = useMemo(() => `步骤 ${currentStepIndex + 1} / ${captureSteps.length}`, [currentStepIndex]); const stepTitle = useMemo(
() =>
t('medications.aiCamera.steps.stepProgress', {
current: currentStepIndex + 1,
total: captureSteps.length,
}),
[currentStepIndex, t]
);
// 计算固定的相机高度,不受按钮状态影响,避免布局跳动 // 计算固定的相机高度,不受按钮状态影响,避免布局跳动
const cameraHeight = useMemo(() => { const cameraHeight = useMemo(() => {
@@ -149,7 +158,7 @@ export default function MedicationAiCameraScreen() {
if (!result.canceled && result.assets?.length) { if (!result.canceled && result.assets?.length) {
const asset = result.assets[0]; const asset = result.assets[0];
setShots((prev) => ({ ...prev, [currentStep.key]: { uri: asset.uri } })); setShots((prev) => ({ ...prev, [currentStep.key]: { uri: asset.uri } }));
// 拍摄完成后自动进入下一步(如果还有下一步) // 拍摄完成后自动进入下一步(如果还有下一步)
if (currentStepIndex < captureSteps.length - 1) { if (currentStepIndex < captureSteps.length - 1) {
setTimeout(() => { setTimeout(() => {
@@ -159,7 +168,10 @@ export default function MedicationAiCameraScreen() {
} }
} catch (error) { } catch (error) {
console.error('[MEDICATION_AI] pick image failed', error); console.error('[MEDICATION_AI] pick image failed', error);
Alert.alert('选择失败', '请重试或更换图片'); Alert.alert(
t('medications.aiCamera.alerts.pickFailed.title'),
t('medications.aiCamera.alerts.pickFailed.message')
);
} }
}; };
@@ -169,7 +181,7 @@ export default function MedicationAiCameraScreen() {
const photo = await cameraRef.current.takePictureAsync({ quality: 0.85 }); const photo = await cameraRef.current.takePictureAsync({ quality: 0.85 });
if (photo?.uri) { if (photo?.uri) {
setShots((prev) => ({ ...prev, [currentStep.key]: { uri: photo.uri } })); setShots((prev) => ({ ...prev, [currentStep.key]: { uri: photo.uri } }));
// 拍摄完成后自动进入下一步(如果还有下一步) // 拍摄完成后自动进入下一步(如果还有下一步)
if (currentStepIndex < captureSteps.length - 1) { if (currentStepIndex < captureSteps.length - 1) {
setTimeout(() => { setTimeout(() => {
@@ -179,7 +191,10 @@ export default function MedicationAiCameraScreen() {
} }
} catch (error) { } catch (error) {
console.error('[MEDICATION_AI] take picture failed', error); console.error('[MEDICATION_AI] take picture failed', error);
Alert.alert('拍摄失败', '请重试'); Alert.alert(
t('medications.aiCamera.alerts.captureFailed.title'),
t('medications.aiCamera.alerts.captureFailed.message')
);
} }
}; };
@@ -192,7 +207,10 @@ export default function MedicationAiCameraScreen() {
const handleStartRecognition = async () => { const handleStartRecognition = async () => {
// 检查必需照片是否完成 // 检查必需照片是否完成
if (!allRequiredCaptured) { if (!allRequiredCaptured) {
Alert.alert('照片不足', '请至少完成正面和背面拍摄'); Alert.alert(
t('medications.aiCamera.alerts.insufficientPhotos.title'),
t('medications.aiCamera.alerts.insufficientPhotos.message')
);
return; return;
} }
@@ -209,7 +227,9 @@ export default function MedicationAiCameraScreen() {
const [frontUpload, sideUpload, auxUpload] = await Promise.all([ const [frontUpload, sideUpload, auxUpload] = await Promise.all([
upload({ uri: shots.front.uri, name: `front-${Date.now()}.jpg`, type: 'image/jpeg' }), upload({ uri: shots.front.uri, name: `front-${Date.now()}.jpg`, type: 'image/jpeg' }),
upload({ uri: shots.side.uri, name: `side-${Date.now()}.jpg`, type: 'image/jpeg' }), upload({ uri: shots.side.uri, name: `side-${Date.now()}.jpg`, type: 'image/jpeg' }),
shots.aux ? upload({ uri: shots.aux.uri, name: `aux-${Date.now()}.jpg`, type: 'image/jpeg' }) : Promise.resolve(null), shots.aux
? upload({ uri: shots.aux.uri, name: `aux-${Date.now()}.jpg`, type: 'image/jpeg' })
: Promise.resolve(null),
]); ]);
const task = await createMedicationRecognitionTask({ const task = await createMedicationRecognitionTask({
@@ -227,7 +247,10 @@ export default function MedicationAiCameraScreen() {
}); });
} catch (error: any) { } catch (error: any) {
console.error('[MEDICATION_AI] recognize failed', error); console.error('[MEDICATION_AI] recognize failed', error);
Alert.alert('创建任务失败', error?.message || '请检查网络后重试'); Alert.alert(
t('medications.aiCamera.alerts.taskFailed.title'),
error?.message || t('medications.aiCamera.alerts.taskFailed.defaultMessage')
);
} finally { } finally {
setCreatingTask(false); setCreatingTask(false);
} }
@@ -278,12 +301,16 @@ export default function MedicationAiCameraScreen() {
isInteractive={true} isInteractive={true}
> >
<Ionicons name="camera-reverse-outline" size={20} color="#0f172a" /> <Ionicons name="camera-reverse-outline" size={20} color="#0f172a" />
<Text style={styles.secondaryBtnText}></Text> <Text style={styles.secondaryBtnText}>
{t('medications.aiCamera.buttons.flip')}
</Text>
</GlassView> </GlassView>
) : ( ) : (
<View style={[styles.secondaryBtn, styles.fallbackSecondaryBtn]}> <View style={[styles.secondaryBtn, styles.fallbackSecondaryBtn]}>
<Ionicons name="camera-reverse-outline" size={20} color="#0f172a" /> <Ionicons name="camera-reverse-outline" size={20} color="#0f172a" />
<Text style={styles.secondaryBtnText}></Text> <Text style={styles.secondaryBtnText}>
{t('medications.aiCamera.buttons.flip')}
</Text>
</View> </View>
)} )}
</TouchableOpacity> </TouchableOpacity>
@@ -440,12 +467,16 @@ export default function MedicationAiCameraScreen() {
isInteractive={true} isInteractive={true}
> >
<Ionicons name="camera" size={20} color="#0ea5e9" /> <Ionicons name="camera" size={20} color="#0ea5e9" />
<Text style={styles.splitButtonLabel}></Text> <Text style={styles.splitButtonLabel}>
{t('medications.aiCamera.buttons.capture')}
</Text>
</GlassView> </GlassView>
) : ( ) : (
<View style={[styles.splitButton, styles.fallbackSplitButton]}> <View style={[styles.splitButton, styles.fallbackSplitButton]}>
<Ionicons name="camera" size={20} color="#0ea5e9" /> <Ionicons name="camera" size={20} color="#0ea5e9" />
<Text style={styles.splitButtonLabel}></Text> <Text style={styles.splitButtonLabel}>
{t('medications.aiCamera.buttons.capture')}
</Text>
</View> </View>
)} )}
</TouchableOpacity> </TouchableOpacity>
@@ -470,7 +501,9 @@ export default function MedicationAiCameraScreen() {
) : ( ) : (
<> <>
<Ionicons name="checkmark-circle" size={20} color="#10b981" /> <Ionicons name="checkmark-circle" size={20} color="#10b981" />
<Text style={styles.splitButtonLabel}></Text> <Text style={styles.splitButtonLabel}>
{t('medications.aiCamera.buttons.complete')}
</Text>
</> </>
)} )}
</GlassView> </GlassView>
@@ -481,7 +514,9 @@ export default function MedicationAiCameraScreen() {
) : ( ) : (
<> <>
<Ionicons name="checkmark-circle" size={20} color="#10b981" /> <Ionicons name="checkmark-circle" size={20} color="#10b981" />
<Text style={styles.splitButtonLabel}></Text> <Text style={styles.splitButtonLabel}>
{t('medications.aiCamera.buttons.complete')}
</Text>
</> </>
)} )}
</View> </View>
@@ -501,12 +536,25 @@ export default function MedicationAiCameraScreen() {
if (!permission.granted) { if (!permission.granted) {
return ( return (
<View style={[styles.container, { backgroundColor: '#f8fafc' }]}> <View style={[styles.container, { backgroundColor: '#f8fafc' }]}>
<HeaderBar title="AI 用药识别" onBack={() => router.back()} transparent /> <HeaderBar
title={t('medications.aiCamera.title')}
onBack={() => router.back()}
transparent
/>
<View style={[styles.permissionCard, { marginTop: insets.top + 60 }]}> <View style={[styles.permissionCard, { marginTop: insets.top + 60 }]}>
<Text style={styles.permissionTitle}></Text> <Text style={styles.permissionTitle}>
<Text style={styles.permissionTip}></Text> {t('medications.aiCamera.permission.title')}
<TouchableOpacity style={[styles.permissionBtn, { backgroundColor: colors.primary }]} onPress={requestPermission}> </Text>
<Text style={styles.permissionBtnText}>访</Text> <Text style={styles.permissionTip}>
{t('medications.aiCamera.permission.description')}
</Text>
<TouchableOpacity
style={[styles.permissionBtn, { backgroundColor: colors.primary }]}
onPress={requestPermission}
>
<Text style={styles.permissionBtnText}>
{t('medications.aiCamera.permission.button')}
</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</View> </View>
@@ -524,14 +572,14 @@ export default function MedicationAiCameraScreen() {
<View style={styles.container}> <View style={styles.container}>
<LinearGradient colors={['#fefefe', '#f4f7fb']} style={StyleSheet.absoluteFill} /> <LinearGradient colors={['#fefefe', '#f4f7fb']} style={StyleSheet.absoluteFill} />
<HeaderBar <HeaderBar
title="AI 用药识别" title={t('medications.aiCamera.title')}
onBack={() => router.back()} onBack={() => router.back()}
transparent transparent
right={ right={
<TouchableOpacity <TouchableOpacity
onPress={() => setShowGuideModal(true)} onPress={() => setShowGuideModal(true)}
activeOpacity={0.7} activeOpacity={0.7}
accessibilityLabel="查看拍摄说明" accessibilityLabel={t('medications.aiCamera.guideModal.title')}
> >
{isLiquidGlassAvailable() ? ( {isLiquidGlassAvailable() ? (
<GlassView <GlassView
@@ -556,8 +604,12 @@ export default function MedicationAiCameraScreen() {
<View style={styles.metaBadge}> <View style={styles.metaBadge}>
<Text style={styles.metaBadgeText}>{stepTitle}</Text> <Text style={styles.metaBadgeText}>{stepTitle}</Text>
</View> </View>
<Text style={styles.metaTitle}>{currentStep.title}</Text> <Text style={styles.metaTitle}>
<Text style={styles.metaSubtitle}>{currentStep.subtitle}</Text> {t(`medications.aiCamera.steps.${currentStep.key}.title`)}
</Text>
<Text style={styles.metaSubtitle}>
{t(`medications.aiCamera.steps.${currentStep.key}.subtitle`)}
</Text>
</View> </View>
<View style={styles.cameraCard}> <View style={styles.cameraCard}>
@@ -587,14 +639,22 @@ export default function MedicationAiCameraScreen() {
style={[styles.shotCard, active && styles.shotCardActive]} style={[styles.shotCard, active && styles.shotCardActive]}
> >
<Text style={[styles.shotLabel, active && styles.shotLabelActive]}> <Text style={[styles.shotLabel, active && styles.shotLabelActive]}>
{step.title} {t(`medications.aiCamera.steps.${step.key}.title`)}
{!step.mandatory ? '(可选)' : ''} {!step.mandatory
? ` ${t('medications.aiCamera.steps.optional')}`
: ''}
</Text> </Text>
{shot ? ( {shot ? (
<Image source={{ uri: shot.uri }} style={styles.shotThumb} contentFit="cover" /> <Image
source={{ uri: shot.uri }}
style={styles.shotThumb}
contentFit="cover"
/>
) : ( ) : (
<View style={styles.shotPlaceholder}> <View style={styles.shotPlaceholder}>
<Text style={styles.shotPlaceholderText}></Text> <Text style={styles.shotPlaceholderText}>
{t('medications.aiCamera.steps.notTaken')}
</Text>
</View> </View>
)} )}
</TouchableOpacity> </TouchableOpacity>
@@ -617,12 +677,16 @@ export default function MedicationAiCameraScreen() {
isInteractive={true} isInteractive={true}
> >
<Ionicons name="images-outline" size={20} color="#0f172a" /> <Ionicons name="images-outline" size={20} color="#0f172a" />
<Text style={styles.secondaryBtnText}></Text> <Text style={styles.secondaryBtnText}>
{t('medications.aiCamera.buttons.album')}
</Text>
</GlassView> </GlassView>
) : ( ) : (
<View style={[styles.secondaryBtn, styles.fallbackSecondaryBtn]}> <View style={[styles.secondaryBtn, styles.fallbackSecondaryBtn]}>
<Ionicons name="images-outline" size={20} color="#0f172a" /> <Ionicons name="images-outline" size={20} color="#0f172a" />
<Text style={styles.secondaryBtnText}></Text> <Text style={styles.secondaryBtnText}>
{t('medications.aiCamera.buttons.album')}
</Text>
</View> </View>
)} )}
</TouchableOpacity> </TouchableOpacity>

View File

@@ -1,10 +1,11 @@
import { HeaderBar } from '@/components/ui/HeaderBar'; import { HeaderBar } from '@/components/ui/HeaderBar';
import { Colors } from '@/constants/Colors'; import { Image } from '@/components/ui/Image';
import { Colors, palette } from '@/constants/Colors';
import { useI18n } from '@/hooks/useI18n';
import { getMedicationRecognitionStatus } from '@/services/medications'; import { getMedicationRecognitionStatus } from '@/services/medications';
import { MedicationRecognitionTask } from '@/types/medication'; import { MedicationRecognitionTask } from '@/types/medication';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect'; import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import { router, useLocalSearchParams } from 'expo-router'; import { router, useLocalSearchParams } from 'expo-router';
import React, { useEffect, useMemo, useRef, useState } from 'react'; import React, { useEffect, useMemo, useRef, useState } from 'react';
@@ -13,14 +14,15 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
const { width: SCREEN_WIDTH } = Dimensions.get('window'); const { width: SCREEN_WIDTH } = Dimensions.get('window');
const STATUS_STEPS: { key: MedicationRecognitionTask['status']; label: string }[] = [ const STEP_KEYS: MedicationRecognitionTask['status'][] = [
{ key: 'analyzing_product', label: '正在进行产品分析...' }, 'analyzing_product',
{ key: 'analyzing_suitability', label: '正在检测适宜人群...' }, 'analyzing_suitability',
{ key: 'analyzing_ingredients', label: '正在评估成分信息...' }, 'analyzing_ingredients',
{ key: 'analyzing_effects', label: '正在生成安全建议...' }, 'analyzing_effects',
]; ];
export default function MedicationAiProgressScreen() { export default function MedicationAiProgressScreen() {
const { t } = useI18n();
const { taskId, cover } = useLocalSearchParams<{ taskId?: string; cover?: string }>(); const { taskId, cover } = useLocalSearchParams<{ taskId?: string; cover?: string }>();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const [task, setTask] = useState<MedicationRecognitionTask | null>(null); const [task, setTask] = useState<MedicationRecognitionTask | null>(null);
@@ -35,11 +37,16 @@ export default function MedicationAiProgressScreen() {
const floatAnim = useRef(new Animated.Value(0)).current; const floatAnim = useRef(new Animated.Value(0)).current;
const opacityAnim = useRef(new Animated.Value(0.3)).current; const opacityAnim = useRef(new Animated.Value(0.3)).current;
const steps = useMemo(() => STEP_KEYS.map(key => ({
key,
label: t(`medications.aiProgress.steps.${key}`)
})), [t]);
const currentStepIndex = useMemo(() => { const currentStepIndex = useMemo(() => {
if (!task) return 0; if (!task) return 0;
const idx = STATUS_STEPS.findIndex((step) => step.key === task.status); const idx = STEP_KEYS.indexOf(task.status as any);
if (idx >= 0) return idx; if (idx >= 0) return idx;
if (task.status === 'completed') return STATUS_STEPS.length; if (task.status === 'completed') return STEP_KEYS.length;
return 0; return 0;
}, [task]); }, [task]);
@@ -77,12 +84,12 @@ export default function MedicationAiProgressScreen() {
pollingTimerRef.current = null; pollingTimerRef.current = null;
} }
// 显示错误提示弹窗 // 显示错误提示弹窗
setErrorMessage(data.errorMessage || '识别失败,请重新拍摄'); setErrorMessage(data.errorMessage || t('medications.aiProgress.errors.default'));
setShowErrorModal(true); setShowErrorModal(true);
} }
} catch (err: any) { } catch (err: any) {
console.error('[MEDICATION_AI] status failed', err); console.error('[MEDICATION_AI] status failed', err);
setError(err?.message || '查询失败,请稍后再试'); setError(err?.message || t('medications.aiProgress.errors.queryFailed'));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -148,12 +155,12 @@ export default function MedicationAiProgressScreen() {
}; };
}, []); }, []);
const progress = task?.progress ?? Math.min(100, (currentStepIndex / STATUS_STEPS.length) * 100 + 10); const progress = task?.progress ?? Math.min(100, (currentStepIndex / steps.length) * 100 + 10);
return ( return (
<SafeAreaView style={styles.container}> <SafeAreaView style={styles.container}>
<LinearGradient colors={['#fdfdfd', '#f3f6fb']} style={StyleSheet.absoluteFill} /> <LinearGradient colors={[palette.gray[25], palette.gray[50]]} style={StyleSheet.absoluteFill} />
<HeaderBar title="识别中" onBack={() => router.back()} transparent /> <HeaderBar title={t('medications.aiProgress.title')} onBack={() => router.back()} transparent />
<View style={{ height: insets.top }} /> <View style={{ height: insets.top }} />
<View style={styles.heroCard}> <View style={styles.heroCard}>
@@ -172,7 +179,7 @@ export default function MedicationAiProgressScreen() {
{/* 渐变蒙版边框,增加视觉层次 */} {/* 渐变蒙版边框,增加视觉层次 */}
<LinearGradient <LinearGradient
colors={['rgba(14, 165, 233, 0.3)', 'rgba(6, 182, 212, 0.2)', 'transparent']} colors={[Colors.light.primary + '4D', Colors.light.accentPurple + '33', 'transparent']}
style={styles.gradientBorder} style={styles.gradientBorder}
start={{ x: 0, y: 0 }} start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }} end={{ x: 1, y: 1 }}
@@ -206,7 +213,7 @@ export default function MedicationAiProgressScreen() {
</View> </View>
<View style={styles.stepList}> <View style={styles.stepList}>
{STATUS_STEPS.map((step, index) => { {steps.map((step, index) => {
const active = index === currentStepIndex; const active = index === currentStepIndex;
const done = index < currentStepIndex; const done = index < currentStepIndex;
return ( return (
@@ -221,7 +228,7 @@ export default function MedicationAiProgressScreen() {
{task?.status === 'completed' && ( {task?.status === 'completed' && (
<View style={styles.stepRow}> <View style={styles.stepRow}>
<View style={[styles.bullet, styles.bulletDone]} /> <View style={[styles.bullet, styles.bulletDone]} />
<Text style={[styles.stepLabel, styles.stepLabelDone]}>...</Text> <Text style={[styles.stepLabel, styles.stepLabelDone]}>{t('medications.aiProgress.steps.completed')}</Text>
</View> </View>
)} )}
</View> </View>
@@ -251,7 +258,7 @@ export default function MedicationAiProgressScreen() {
<View style={styles.errorModalContent}> <View style={styles.errorModalContent}>
{/* 标题 */} {/* 标题 */}
<Text style={styles.errorModalTitle}></Text> <Text style={styles.errorModalTitle}>{t('medications.aiProgress.modal.title')}</Text>
{/* 提示信息 */} {/* 提示信息 */}
<View style={styles.errorMessageBox}> <View style={styles.errorMessageBox}>
@@ -268,29 +275,29 @@ export default function MedicationAiProgressScreen() {
<GlassView <GlassView
style={styles.retryButton} style={styles.retryButton}
glassEffectStyle="regular" glassEffectStyle="regular"
tintColor="rgba(14, 165, 233, 0.9)" tintColor={Colors.light.primary}
isInteractive={true} isInteractive={true}
> >
<LinearGradient <LinearGradient
colors={['rgba(14, 165, 233, 0.95)', 'rgba(6, 182, 212, 0.95)']} colors={[Colors.light.primary, Colors.light.accentPurple]}
start={{ x: 0, y: 0 }} start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }} end={{ x: 1, y: 0 }}
style={styles.retryButtonGradient} style={styles.retryButtonGradient}
> >
<Ionicons name="camera" size={20} color="#FFFFFF" style={{ marginRight: 8 }} /> <Ionicons name="camera" size={20} color="#FFFFFF" style={{ marginRight: 8 }} />
<Text style={styles.retryButtonText}></Text> <Text style={styles.retryButtonText}>{t('medications.aiProgress.modal.retry')}</Text>
</LinearGradient> </LinearGradient>
</GlassView> </GlassView>
) : ( ) : (
<View style={styles.retryButton}> <View style={styles.retryButton}>
<LinearGradient <LinearGradient
colors={['#0ea5e9', '#06b6d4']} colors={[Colors.light.primary, Colors.light.accentPurple]}
start={{ x: 0, y: 0 }} start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }} end={{ x: 1, y: 0 }}
style={styles.retryButtonGradient} style={styles.retryButtonGradient}
> >
<Ionicons name="camera" size={20} color="#FFFFFF" style={{ marginRight: 8 }} /> <Ionicons name="camera" size={20} color="#FFFFFF" style={{ marginRight: 8 }} />
<Text style={styles.retryButtonText}></Text> <Text style={styles.retryButtonText}>{t('medications.aiProgress.modal.retry')}</Text>
</LinearGradient> </LinearGradient>
</View> </View>
)} )}
@@ -311,9 +318,9 @@ const styles = StyleSheet.create({
marginHorizontal: 20, marginHorizontal: 20,
marginTop: 24, marginTop: 24,
borderRadius: 24, borderRadius: 24,
backgroundColor: '#fff', backgroundColor: Colors.light.card,
padding: 16, padding: 16,
shadowColor: '#0f172a', shadowColor: Colors.light.text,
shadowOpacity: 0.08, shadowOpacity: 0.08,
shadowRadius: 18, shadowRadius: 18,
shadowOffset: { width: 0, height: 10 }, shadowOffset: { width: 0, height: 10 },
@@ -322,7 +329,7 @@ const styles = StyleSheet.create({
height: 230, height: 230,
borderRadius: 18, borderRadius: 18,
overflow: 'hidden', overflow: 'hidden',
backgroundColor: '#e2e8f0', backgroundColor: palette.gray[50],
}, },
heroImage: { heroImage: {
width: '100%', width: '100%',
@@ -330,7 +337,7 @@ const styles = StyleSheet.create({
}, },
heroPlaceholder: { heroPlaceholder: {
flex: 1, flex: 1,
backgroundColor: '#e2e8f0', backgroundColor: palette.gray[50],
}, },
// 深色蒙版层,让点阵更清晰可见 // 深色蒙版层,让点阵更清晰可见
overlayMask: { overlayMask: {
@@ -368,15 +375,15 @@ const styles = StyleSheet.create({
width: 5, width: 5,
height: 5, height: 5,
borderRadius: 2.5, borderRadius: 2.5,
backgroundColor: '#FFFFFF', backgroundColor: Colors.light.background,
shadowColor: '#0ea5e9', shadowColor: Colors.light.primary,
shadowOpacity: 0.9, shadowOpacity: 0.9,
shadowRadius: 6, shadowRadius: 6,
shadowOffset: { width: 0, height: 0 }, shadowOffset: { width: 0, height: 0 },
}, },
progressRow: { progressRow: {
height: 8, height: 8,
backgroundColor: '#f1f5f9', backgroundColor: palette.gray[50],
borderRadius: 10, borderRadius: 10,
marginTop: 14, marginTop: 14,
overflow: 'hidden', overflow: 'hidden',
@@ -384,13 +391,13 @@ const styles = StyleSheet.create({
progressBar: { progressBar: {
height: '100%', height: '100%',
borderRadius: 10, borderRadius: 10,
backgroundColor: '#0ea5e9', backgroundColor: Colors.light.primary,
}, },
progressText: { progressText: {
marginTop: 8, marginTop: 8,
fontSize: 14, fontSize: 14,
fontWeight: '700', fontWeight: '700',
color: '#0f172a', color: Colors.light.text,
textAlign: 'right', textAlign: 'right',
}, },
stepList: { stepList: {
@@ -407,24 +414,24 @@ const styles = StyleSheet.create({
width: 14, width: 14,
height: 14, height: 14,
borderRadius: 7, borderRadius: 7,
backgroundColor: '#e2e8f0', backgroundColor: palette.gray[50],
}, },
bulletActive: { bulletActive: {
backgroundColor: '#0ea5e9', backgroundColor: Colors.light.primary,
}, },
bulletDone: { bulletDone: {
backgroundColor: '#22c55e', backgroundColor: Colors.light.success,
}, },
stepLabel: { stepLabel: {
fontSize: 15, fontSize: 15,
color: '#94a3b8', color: Colors.light.textMuted,
}, },
stepLabelActive: { stepLabelActive: {
color: '#0f172a', color: Colors.light.text,
fontWeight: '700', fontWeight: '700',
}, },
stepLabelDone: { stepLabelDone: {
color: '#16a34a', color: Colors.light.successDark,
fontWeight: '700', fontWeight: '700',
}, },
loadingBox: { loadingBox: {
@@ -433,7 +440,7 @@ const styles = StyleSheet.create({
gap: 12, gap: 12,
}, },
errorText: { errorText: {
color: '#ef4444', color: Colors.light.danger,
fontSize: 14, fontSize: 14,
}, },
// Modal 样式 // Modal 样式
@@ -445,10 +452,10 @@ const styles = StyleSheet.create({
}, },
errorModalContainer: { errorModalContainer: {
width: SCREEN_WIDTH - 48, width: SCREEN_WIDTH - 48,
backgroundColor: '#FFFFFF', backgroundColor: Colors.light.card,
borderRadius: 28, borderRadius: 28,
overflow: 'hidden', overflow: 'hidden',
shadowColor: '#0ea5e9', shadowColor: Colors.light.primary,
shadowOpacity: 0.15, shadowOpacity: 0.15,
shadowRadius: 24, shadowRadius: 24,
shadowOffset: { width: 0, height: 8 }, shadowOffset: { width: 0, height: 8 },
@@ -465,36 +472,36 @@ const styles = StyleSheet.create({
width: 96, width: 96,
height: 96, height: 96,
borderRadius: 48, borderRadius: 48,
backgroundColor: 'rgba(14, 165, 233, 0.08)', backgroundColor: palette.purple[50],
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
}, },
errorModalTitle: { errorModalTitle: {
fontSize: 22, fontSize: 22,
fontWeight: '700', fontWeight: '700',
color: '#0f172a', color: Colors.light.text,
marginBottom: 16, marginBottom: 16,
textAlign: 'center', textAlign: 'center',
}, },
errorMessageBox: { errorMessageBox: {
backgroundColor: '#f0f9ff', backgroundColor: palette.purple[25],
borderRadius: 16, borderRadius: 16,
padding: 20, padding: 20,
marginBottom: 28, marginBottom: 28,
width: '100%', width: '100%',
borderWidth: 1, borderWidth: 1,
borderColor: 'rgba(14, 165, 233, 0.2)', borderColor: palette.purple[200],
}, },
errorMessageText: { errorMessageText: {
fontSize: 15, fontSize: 15,
lineHeight: 24, lineHeight: 24,
color: '#475569', color: Colors.light.textSecondary,
textAlign: 'center', textAlign: 'center',
}, },
retryButton: { retryButton: {
borderRadius: 16, borderRadius: 16,
overflow: 'hidden', overflow: 'hidden',
shadowColor: '#0ea5e9', shadowColor: Colors.light.primary,
shadowOpacity: 0.25, shadowOpacity: 0.25,
shadowRadius: 12, shadowRadius: 12,
shadowOffset: { width: 0, height: 6 }, shadowOffset: { width: 0, height: 6 },
@@ -509,6 +516,6 @@ const styles = StyleSheet.create({
retryButtonText: { retryButtonText: {
fontSize: 18, fontSize: 18,
fontWeight: '700', fontWeight: '700',
color: '#FFFFFF', color: Colors.light.onPrimary,
}, },
}); });

View File

@@ -0,0 +1,886 @@
import { ThemedText } from '@/components/ThemedText';
import { HeaderBar } from '@/components/ui/HeaderBar';
import { IconSymbol } from '@/components/ui/IconSymbol';
import { getMedicationAiSummary } from '@/services/medications';
import { type MedicationAiSummary, type MedicationAiSummaryItem } from '@/types/medication';
import { useFocusEffect } from '@react-navigation/native';
import dayjs from 'dayjs';
import { LinearGradient } from 'expo-linear-gradient';
import React, { useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
ActivityIndicator,
Modal,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
export default function MedicationAiSummaryScreen() {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const [summary, setSummary] = useState<MedicationAiSummary | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [lastUpdated, setLastUpdated] = useState<string>('');
const [showInfoModal, setShowInfoModal] = useState(false);
const [showCompletionInfoModal, setShowCompletionInfoModal] = useState(false);
const fetchSummary = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await getMedicationAiSummary();
setSummary(data);
setLastUpdated(dayjs().format('YYYY.MM.DD HH:mm'));
} catch (err: any) {
const status = err?.status;
if (status === 403) {
setError(t('medications.aiSummary.error403'));
} else {
setError(err?.message || t('medications.aiSummary.genericError'));
}
setSummary(null);
} finally {
setLoading(false);
}
}, [t]);
useFocusEffect(
useCallback(() => {
fetchSummary();
}, [fetchSummary])
);
const handleExplainRefresh = useCallback(() => {
setShowInfoModal(true);
}, []);
const handleExplainCompletion = useCallback(() => {
setShowCompletionInfoModal(true);
}, []);
const medicationItems = summary?.medicationAnalysis ?? [];
const isEmpty = !loading && !error && medicationItems.length === 0;
const stats = useMemo(() => {
const plannedDoses = medicationItems.reduce((acc, item) => acc + (item.plannedDoses || 0), 0);
const takenDoses = medicationItems.reduce((acc, item) => acc + (item.takenDoses || 0), 0);
const completion = plannedDoses > 0 ? takenDoses / plannedDoses : 0;
const avgCompletion =
medicationItems.length > 0
? medicationItems.reduce((acc, item) => acc + (item.completionRate || 0), 0) /
medicationItems.length
: 0;
const plannedDays = medicationItems.reduce((acc, item) => acc + (item.plannedDays || 0), 0);
return {
plannedDoses,
takenDoses,
completion,
avgCompletion,
plannedDays,
activePlans: medicationItems.length,
};
}, [medicationItems]);
const completionPercent = Math.min(100, Math.round(stats.completion * 100));
const renderMedicationCard = (item: MedicationAiSummaryItem) => {
const percent = Math.min(100, Math.round((item.completionRate || 0) * 100));
return (
<View key={item.id} style={styles.planCard}>
<View style={styles.planHeader}>
<View style={{ flex: 1 }}>
<ThemedText style={styles.planName}>{item.name}</ThemedText>
<ThemedText style={styles.planMeta}>
{t('medications.aiSummary.daysLabel', {
days: item.plannedDays,
times: item.timesPerDay,
})}
</ThemedText>
</View>
<View style={styles.planChip}>
<IconSymbol name="sparkles" size={14} color="#d6b37f" />
<ThemedText style={styles.planChipText}>
{t('medications.aiSummary.badges.adherence')}
</ThemedText>
</View>
</View>
<View style={styles.progressRow}>
<View style={styles.progressTrack}>
<View style={[styles.progressFill, { width: `${percent}%` }]} />
</View>
<ThemedText style={styles.progressValue}>
{t('medications.aiSummary.completionLabel', { value: percent })}
</ThemedText>
</View>
<View style={styles.planFooter}>
<ThemedText style={styles.planStat}>
{t('medications.aiSummary.doseSummary', {
taken: item.takenDoses,
planned: item.plannedDoses,
})}
</ThemedText>
<ThemedText style={styles.planDate}>
{dayjs(item.startDate).format('YYYY.MM.DD')}
</ThemedText>
</View>
</View>
);
};
const headerTitle = (
<View style={styles.headerTitle}>
<ThemedText style={styles.title}>{t('medications.aiSummary.title')}</ThemedText>
<ThemedText style={styles.subtitle}>{t('medications.aiSummary.subtitle')}</ThemedText>
</View>
);
return (
<View style={styles.container}>
<LinearGradient
colors={['#0a0e16', '#0b101a', '#0b0f16']}
style={StyleSheet.absoluteFill}
/>
<View style={styles.glowTop} />
<View style={styles.glowBottom} />
<HeaderBar
title={headerTitle}
tone="dark"
transparent
variant="minimal"
right={
<TouchableOpacity
style={styles.iconButton}
onPress={handleExplainRefresh}
activeOpacity={0.8}
>
<IconSymbol name="info.circle" size={20} color="#dfe8ff" />
</TouchableOpacity>
}
/>
<ScrollView
contentContainerStyle={[
styles.scrollContent,
{ paddingBottom: insets.bottom + 32, paddingTop: insets.top + 80 },
]}
showsVerticalScrollIndicator={false}
>
<LinearGradient
colors={['#131a28', '#0f1623']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.heroCard}
>
<View style={styles.heroHeader}>
<ThemedText style={styles.heroLabel}>
{t('medications.aiSummary.overviewTitle')}
</ThemedText>
<ThemedText style={styles.updatedAt}>
{lastUpdated ? t('medications.aiSummary.updatedAt', { time: lastUpdated }) : ' '}
</ThemedText>
</View>
<View style={styles.heroMainRow}>
<View style={styles.heroLeft}>
<ThemedText style={styles.heroValue}>{completionPercent}%</ThemedText>
<ThemedText style={styles.heroCaption}>
{t('medications.aiSummary.doseSummary', {
taken: stats.takenDoses,
planned: stats.plannedDoses,
})}
</ThemedText>
<View style={styles.heroProgressTrack}>
<View style={[styles.heroProgressFill, { width: `${completionPercent}%` }]} />
</View>
</View>
<View style={styles.heroChip}>
<ThemedText style={styles.heroChipLabel}>
{t('medications.aiSummary.badges.safety')}
</ThemedText>
<ThemedText style={styles.heroChipValue}>{stats.activePlans}</ThemedText>
<ThemedText style={styles.heroChipHint}>
{t('medications.aiSummary.stats.activePlans')}
</ThemedText>
</View>
</View>
<View style={styles.heroStatsRow}>
<View style={styles.heroStatItem}>
<ThemedText style={styles.heroStatLabel}>
{t('medications.aiSummary.stats.avgCompletion')}
</ThemedText>
<ThemedText style={styles.heroStatValue}>
{Math.round(stats.avgCompletion * 100)}%
</ThemedText>
</View>
<View style={styles.heroStatItem}>
<ThemedText style={styles.heroStatLabel}>
{t('medications.aiSummary.stats.activeDays')}
</ThemedText>
<ThemedText style={styles.heroStatValue}>{stats.plannedDays}</ThemedText>
</View>
<View style={styles.heroStatItem}>
<ThemedText style={styles.heroStatLabel}>
{t('medications.aiSummary.stats.takenDoses')}
</ThemedText>
<ThemedText style={styles.heroStatValue}>{stats.takenDoses}</ThemedText>
</View>
</View>
</LinearGradient>
{error ? (
<View style={styles.errorCard}>
<ThemedText style={styles.errorTitle}>{error}</ThemedText>
<TouchableOpacity style={styles.retryButton} onPress={fetchSummary} activeOpacity={0.85}>
<ThemedText style={styles.retryText}>{t('medications.aiSummary.retry')}</ThemedText>
</TouchableOpacity>
</View>
) : (
<>
<View style={styles.sectionCard}>
<View style={styles.sectionHeader}>
<ThemedText style={styles.sectionTitle}>
{t('medications.aiSummary.keyInsights')}
</ThemedText>
<View style={styles.pillChip}>
<IconSymbol name="sparkles" size={14} color="#0b0f16" />
<ThemedText style={styles.pillChipText}>
{t('medications.aiSummary.pillChip')}
</ThemedText>
</View>
</View>
<ThemedText style={styles.insightText}>
{summary?.keyInsights || t('medications.aiSummary.keyInsightPlaceholder')}
</ThemedText>
</View>
<View style={styles.sectionCard}>
<View style={styles.sectionHeader}>
<ThemedText style={styles.sectionTitle}>
{t('medications.aiSummary.listTitle')}
</ThemedText>
<TouchableOpacity
style={styles.infoIconButton}
onPress={handleExplainCompletion}
activeOpacity={0.8}
>
<IconSymbol name="info.circle" size={16} color="#8b94a8" />
</TouchableOpacity>
</View>
{loading ? (
<View style={styles.loadingRow}>
<ActivityIndicator color="#d6b37f" />
<ThemedText style={styles.loadingText}>
{t('medications.aiSummary.refresh')}
</ThemedText>
</View>
) : isEmpty ? (
<View style={styles.emptyState}>
<ThemedText style={styles.emptyTitle}>
{t('medications.aiSummary.emptyTitle')}
</ThemedText>
<ThemedText style={styles.emptySubtitle}>
{t('medications.aiSummary.emptyDescription')}
</ThemedText>
</View>
) : (
<View style={styles.planList}>{medicationItems.map(renderMedicationCard)}</View>
)}
</View>
</>
)}
</ScrollView>
<Modal
visible={showInfoModal}
transparent
animationType="fade"
onRequestClose={() => setShowInfoModal(false)}
>
<TouchableOpacity
style={styles.infoOverlay}
activeOpacity={1}
onPress={() => setShowInfoModal(false)}
>
<TouchableOpacity
activeOpacity={1}
onPress={(e) => e.stopPropagation()}
style={styles.infoModal}
>
<LinearGradient
colors={['#111827', '#0b1220']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.infoGradient}
>
<View style={styles.infoHeader}>
<ThemedText style={styles.infoBadge}>{t('medications.aiSummary.infoModal.badge')}</ThemedText>
<ThemedText style={styles.infoTitle}>{t('medications.aiSummary.infoModal.title')}</ThemedText>
<TouchableOpacity
onPress={() => setShowInfoModal(false)}
style={styles.infoClose}
accessibilityLabel="close"
>
<IconSymbol name="xmark" size={18} color="#e5e7eb" />
</TouchableOpacity>
</View>
<View style={styles.infoContent}>
<Text style={styles.infoText}>
{t('medications.aiSummary.infoModal.point1')}
</Text>
<Text style={styles.infoText}>
{t('medications.aiSummary.infoModal.point2')}
</Text>
<Text style={styles.infoText}>
{t('medications.aiSummary.infoModal.point3')}
</Text>
<Text style={styles.infoText}>
{t('medications.aiSummary.infoModal.point4')}
</Text>
</View>
<View style={styles.infoButtonContainer}>
<TouchableOpacity
onPress={() => setShowInfoModal(false)}
>
<LinearGradient
colors={['#d6b37f', '#c59b63']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.infoButton}
>
<Text style={styles.infoButtonText}>{t('medications.aiSummary.infoModal.button')}</Text>
</LinearGradient>
</TouchableOpacity>
</View>
</LinearGradient>
</TouchableOpacity>
</TouchableOpacity>
</Modal>
<Modal
visible={showCompletionInfoModal}
transparent
animationType="fade"
onRequestClose={() => setShowCompletionInfoModal(false)}
>
<TouchableOpacity
style={styles.infoOverlay}
activeOpacity={1}
onPress={() => setShowCompletionInfoModal(false)}
>
<TouchableOpacity
activeOpacity={1}
onPress={(e) => e.stopPropagation()}
style={styles.infoModal}
>
<LinearGradient
colors={['#111827', '#0b1220']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.infoGradient}
>
<View style={styles.infoHeader}>
<ThemedText style={styles.infoBadge}>{t('medications.aiSummary.completionInfoModal.badge')}</ThemedText>
<ThemedText style={styles.infoTitle}>{t('medications.aiSummary.completionInfoModal.title')}</ThemedText>
<TouchableOpacity
onPress={() => setShowCompletionInfoModal(false)}
style={styles.infoClose}
accessibilityLabel="close"
>
<IconSymbol name="xmark" size={18} color="#e5e7eb" />
</TouchableOpacity>
</View>
<View style={styles.infoContent}>
<Text style={styles.infoText}>
{t('medications.aiSummary.completionInfoModal.point1')}
</Text>
<Text style={styles.infoText}>
{t('medications.aiSummary.completionInfoModal.point2')}
</Text>
<Text style={styles.infoText}>
{t('medications.aiSummary.completionInfoModal.point3')}
</Text>
<Text style={styles.infoText}>
{t('medications.aiSummary.completionInfoModal.point4')}
</Text>
<Text style={styles.infoText}>
{t('medications.aiSummary.completionInfoModal.point5')}
</Text>
</View>
<View style={styles.infoButtonContainer}>
<TouchableOpacity
onPress={() => setShowCompletionInfoModal(false)}
>
<LinearGradient
colors={['#d6b37f', '#c59b63']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.infoButton}
>
<Text style={styles.infoButtonText}>{t('medications.aiSummary.completionInfoModal.button')}</Text>
</LinearGradient>
</TouchableOpacity>
</View>
</LinearGradient>
</TouchableOpacity>
</TouchableOpacity>
</Modal>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#0b0f16',
},
scrollContent: {
paddingHorizontal: 20,
gap: 20,
},
glowTop: {
position: 'absolute',
top: -80,
left: -40,
width: 200,
height: 200,
backgroundColor: '#1b2a44',
opacity: 0.35,
borderRadius: 140,
},
glowBottom: {
position: 'absolute',
bottom: -120,
right: -60,
width: 240,
height: 240,
backgroundColor: '#123125',
opacity: 0.25,
borderRadius: 200,
},
iconButton: {
width: 40,
height: 40,
borderRadius: 14,
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.08)',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(255,255,255,0.04)',
},
headerTitle: {
alignItems: 'center',
flex: 1,
gap: 6,
},
badge: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingHorizontal: 10,
paddingVertical: 6,
borderRadius: 999,
backgroundColor: '#d6b37f',
},
badgeText: {
color: '#0b0f16',
fontSize: 12,
fontWeight: '700',
fontFamily: 'AliBold',
},
title: {
color: '#f6f7fb',
fontSize: 22,
fontFamily: 'AliBold',
},
subtitle: {
color: '#b9c2d3',
fontSize: 14,
fontFamily: 'AliRegular',
},
heroCard: {
borderRadius: 24,
padding: 18,
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.06)',
shadowColor: '#000',
shadowOpacity: 0.25,
shadowRadius: 16,
gap: 14,
},
heroHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
heroLabel: {
color: '#f5f6fb',
fontSize: 16,
fontFamily: 'AliBold',
},
updatedAt: {
color: '#8b94a8',
fontSize: 12,
fontFamily: 'AliRegular',
},
heroMainRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
gap: 16,
},
heroLeft: {
flex: 1,
minWidth: 0,
},
heroValue: {
color: '#36d0a5',
fontSize: 38,
lineHeight: 42,
fontFamily: 'AliBold',
letterSpacing: 0.5,
flexShrink: 1,
},
heroCaption: {
color: '#c2ccdf',
fontSize: 13,
fontFamily: 'AliRegular',
marginTop: 4,
},
heroProgressTrack: {
marginTop: 12,
height: 10,
borderRadius: 10,
backgroundColor: 'rgba(255,255,255,0.08)',
overflow: 'hidden',
},
heroProgressFill: {
height: '100%',
borderRadius: 10,
backgroundColor: '#36d0a5',
},
heroChip: {
paddingHorizontal: 14,
paddingVertical: 12,
borderRadius: 18,
backgroundColor: 'rgba(214, 179, 127, 0.12)',
borderWidth: 1,
borderColor: 'rgba(214, 179, 127, 0.3)',
minWidth: 120,
alignItems: 'flex-start',
gap: 4,
},
heroChipLabel: {
color: '#d6b37f',
fontSize: 12,
fontFamily: 'AliRegular',
},
heroChipValue: {
color: '#f6f7fb',
fontSize: 20,
fontFamily: 'AliBold',
lineHeight: 24,
},
heroChipHint: {
color: '#b9c2d3',
fontSize: 12,
fontFamily: 'AliRegular',
},
heroStatsRow: {
flexDirection: 'row',
gap: 12,
justifyContent: 'space-between',
},
heroStatItem: {
flex: 1,
padding: 12,
borderRadius: 14,
backgroundColor: 'rgba(255,255,255,0.04)',
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.04)',
},
heroStatLabel: {
color: '#9dabc4',
fontSize: 12,
fontFamily: 'AliRegular',
},
heroStatValue: {
color: '#f6f7fb',
fontSize: 18,
marginTop: 6,
fontFamily: 'AliBold',
},
sectionCard: {
borderRadius: 20,
padding: 16,
backgroundColor: 'rgba(255,255,255,0.03)',
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.05)',
gap: 12,
},
sectionHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
sectionTitle: {
color: '#f5f6fb',
fontSize: 16,
fontFamily: 'AliBold',
},
pillChip: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
backgroundColor: '#d6b37f',
paddingHorizontal: 10,
paddingVertical: 6,
borderRadius: 999,
},
pillChipText: {
color: '#0b0f16',
fontSize: 12,
fontFamily: 'AliBold',
},
insightText: {
color: '#d9e2f2',
fontSize: 15,
lineHeight: 22,
fontFamily: 'AliRegular',
},
planList: {
gap: 12,
},
planCard: {
borderRadius: 16,
padding: 14,
backgroundColor: 'rgba(255,255,255,0.04)',
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.06)',
gap: 10,
},
planHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
},
planName: {
color: '#f6f7fb',
fontSize: 16,
fontFamily: 'AliBold',
},
planMeta: {
color: '#9dabc4',
fontSize: 12,
fontFamily: 'AliRegular',
marginTop: 2,
},
planChip: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingHorizontal: 10,
paddingVertical: 6,
borderRadius: 999,
backgroundColor: 'rgba(214, 179, 127, 0.15)',
borderWidth: 1,
borderColor: 'rgba(214, 179, 127, 0.35)',
},
planChipText: {
color: '#d6b37f',
fontSize: 12,
fontFamily: 'AliBold',
},
progressRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
},
progressTrack: {
flex: 1,
height: 10,
borderRadius: 10,
backgroundColor: 'rgba(255,255,255,0.08)',
overflow: 'hidden',
},
progressFill: {
height: '100%',
backgroundColor: '#36d0a5',
borderRadius: 10,
},
progressValue: {
color: '#f6f7fb',
fontSize: 12,
fontFamily: 'AliBold',
},
planFooter: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
planStat: {
color: '#c7d1e4',
fontSize: 13,
fontFamily: 'AliRegular',
},
planDate: {
color: '#7f8aa4',
fontSize: 12,
fontFamily: 'AliRegular',
},
errorCard: {
padding: 16,
borderRadius: 16,
backgroundColor: 'rgba(255, 86, 86, 0.08)',
borderWidth: 1,
borderColor: 'rgba(255, 86, 86, 0.3)',
alignItems: 'center',
gap: 12,
},
errorTitle: {
color: '#ff9c9c',
fontSize: 14,
textAlign: 'center',
fontFamily: 'AliBold',
},
retryButton: {
paddingHorizontal: 16,
paddingVertical: 10,
borderRadius: 999,
backgroundColor: '#ff9c9c',
},
retryText: {
color: '#0b0f16',
fontSize: 13,
fontFamily: 'AliBold',
},
loadingRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
paddingVertical: 12,
},
loadingText: {
color: '#c7d1e4',
fontSize: 13,
fontFamily: 'AliRegular',
},
emptyState: {
paddingVertical: 12,
gap: 6,
},
emptyTitle: {
color: '#f6f7fb',
fontSize: 15,
fontFamily: 'AliBold',
},
emptySubtitle: {
color: '#9dabc4',
fontSize: 13,
fontFamily: 'AliRegular',
lineHeight: 20,
},
infoOverlay: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.6)',
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: 20,
},
infoModal: {
width: '100%',
maxWidth: 400,
borderRadius: 24,
overflow: 'hidden',
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.1)',
},
infoGradient: {
padding: 24,
gap: 20,
},
infoHeader: {
alignItems: 'center',
justifyContent: 'center',
marginBottom: 4,
},
infoBadge: {
color: '#d6b37f',
fontSize: 24,
lineHeight: 28,
fontFamily: 'AliBold',
marginBottom: 10,
letterSpacing: 0.5,
},
infoTitle: {
color: '#f6f7fb',
fontSize: 16,
fontFamily: 'AliBold',
textAlign: 'center',
},
infoClose: {
position: 'absolute',
right: -4,
top: -4,
padding: 8,
width: 36,
height: 36,
alignItems: 'center',
justifyContent: 'center',
borderRadius: 18,
backgroundColor: 'rgba(255,255,255,0.05)',
},
infoContent: {
gap: 14,
},
infoText: {
color: '#d9e2f2',
fontSize: 14,
lineHeight: 18,
fontFamily: 'AliRegular',
},
infoButtonContainer: {
marginTop: 12,
alignItems: 'center',
},
infoButtonWrapper: {
// minWidth: 120,
// maxWidth: 180,
},
infoButton: {
borderRadius: 12,
paddingVertical: 10,
paddingHorizontal: 28,
alignItems: 'center',
overflow: 'hidden',
},
infoButtonGlass: {
paddingVertical: 10,
paddingHorizontal: 28,
alignItems: 'center',
},
infoButtonText: {
color: '#0b0f16',
fontSize: 15,
fontFamily: 'AliBold',
letterSpacing: 0.2,
},
infoIconButton: {
width: 28,
height: 28,
borderRadius: 14,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(139, 148, 168, 0.1)',
},
});

View File

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

554
app/menstrual-cycle.tsx Normal file
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

@@ -1,5 +1,6 @@
import { HeaderBar } from '@/components/ui/HeaderBar'; import { HeaderBar } from '@/components/ui/HeaderBar';
import { useAppSelector } from '@/hooks/redux'; import { useAppSelector } from '@/hooks/redux';
import { useI18n } from '@/hooks/useI18n';
import { useMoodData } from '@/hooks/useMoodData'; import { useMoodData } from '@/hooks/useMoodData';
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding'; import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
import { getMoodOptions } from '@/services/moodCheckins'; import { getMoodOptions } from '@/services/moodCheckins';
@@ -61,6 +62,7 @@ const generateCalendarData = (targetDate: Date) => {
}; };
export default function MoodCalendarScreen() { export default function MoodCalendarScreen() {
const { t } = useI18n();
const safeAreaTop = useSafeAreaTop() const safeAreaTop = useSafeAreaTop()
const params = useLocalSearchParams(); const params = useLocalSearchParams();
const { fetchMoodRecords, fetchMoodHistoryRecords } = useMoodData(); const { fetchMoodRecords, fetchMoodHistoryRecords } = useMoodData();
@@ -89,9 +91,30 @@ export default function MoodCalendarScreen() {
return selectLatestMoodRecordByDate(selectedDateString)(state); return selectLatestMoodRecordByDate(selectedDateString)(state);
}); });
const moodOptions = getMoodOptions(); const moodOptions = getMoodOptions(t);
const weekDays = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']; const weekDays = [
const monthNames = ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月']; t('mood.calendar.weekDays.monday'),
t('mood.calendar.weekDays.tuesday'),
t('mood.calendar.weekDays.wednesday'),
t('mood.calendar.weekDays.thursday'),
t('mood.calendar.weekDays.friday'),
t('mood.calendar.weekDays.saturday'),
t('mood.calendar.weekDays.sunday'),
];
const monthNames = [
t('mood.calendar.months.january'),
t('mood.calendar.months.february'),
t('mood.calendar.months.march'),
t('mood.calendar.months.april'),
t('mood.calendar.months.may'),
t('mood.calendar.months.june'),
t('mood.calendar.months.july'),
t('mood.calendar.months.august'),
t('mood.calendar.months.september'),
t('mood.calendar.months.october'),
t('mood.calendar.months.november'),
t('mood.calendar.months.december'),
];
// 生成当前月份的日历数据 // 生成当前月份的日历数据
const { calendar, today, month, year } = generateCalendarData(currentMonth); const { calendar, today, month, year } = generateCalendarData(currentMonth);
@@ -103,7 +126,7 @@ export default function MoodCalendarScreen() {
const endDate = dayjs(targetMonth).endOf('month').format('YYYY-MM-DD'); const endDate = dayjs(targetMonth).endOf('month').format('YYYY-MM-DD');
await fetchMoodHistoryRecordsRef.current({ startDate, endDate }); await fetchMoodHistoryRecordsRef.current({ startDate, endDate });
} catch (error) { } catch (error) {
console.error('加载月份心情数据失败:', error); console.error(t('mood.calendar.errors.loadMonthDataFailed'), error);
} }
}, []); }, []);
@@ -112,7 +135,7 @@ export default function MoodCalendarScreen() {
try { try {
await fetchMoodRecordsRef.current(dateString); await fetchMoodRecordsRef.current(dateString);
} catch (error) { } catch (error) {
console.error('加载心情记录失败:', error); console.error(t('mood.calendar.errors.loadDailyDataFailed'), error);
} }
}, []); }, []);
@@ -235,7 +258,7 @@ export default function MoodCalendarScreen() {
<View style={styles.safeArea}> <View style={styles.safeArea}>
<HeaderBar <HeaderBar
title="心情日历" title={t('mood.calendar.title')}
onBack={() => router.back()} onBack={() => router.back()}
withSafeTop={false} withSafeTop={false}
transparent={true} transparent={true}
@@ -255,7 +278,7 @@ export default function MoodCalendarScreen() {
> >
<Text style={styles.navButtonText}></Text> <Text style={styles.navButtonText}></Text>
</TouchableOpacity> </TouchableOpacity>
<Text style={styles.monthTitle}>{year}{monthNames[month - 1]}</Text> <Text style={styles.monthTitle}>{year} {monthNames[month - 1]}</Text>
<TouchableOpacity <TouchableOpacity
style={styles.navButton} style={styles.navButton}
onPress={goToNextMonth} onPress={goToNextMonth}
@@ -315,13 +338,13 @@ export default function MoodCalendarScreen() {
<View style={styles.selectedDateSection}> <View style={styles.selectedDateSection}>
<View style={styles.selectedDateHeader}> <View style={styles.selectedDateHeader}>
<Text style={styles.selectedDateTitle}> <Text style={styles.selectedDateTitle}>
{selectedDay ? dayjs(currentMonth).date(selectedDay).format('YYYY年M月D日') : '请选择日期'} {selectedDay ? dayjs(currentMonth).date(selectedDay).format(t('mood.calendar.selectedDate.dateFormat')) : t('mood.calendar.selectedDate.selectDate')}
</Text> </Text>
<TouchableOpacity <TouchableOpacity
style={styles.addMoodButton} style={styles.addMoodButton}
onPress={openMoodEdit} onPress={openMoodEdit}
> >
<Text style={styles.addMoodButtonText}></Text> <Text style={styles.addMoodButtonText}>{t('mood.calendar.selectedDate.record')}</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
@@ -343,7 +366,7 @@ export default function MoodCalendarScreen() {
<Text style={styles.recordMood}> <Text style={styles.recordMood}>
{moodOptions.find(m => m.type === selectedDateMood.moodType)?.label} {moodOptions.find(m => m.type === selectedDateMood.moodType)?.label}
</Text> </Text>
<Text style={styles.recordIntensity}>: {selectedDateMood.intensity}</Text> <Text style={styles.recordIntensity}>{t('mood.calendar.selectedDate.intensity')}: {selectedDateMood.intensity}</Text>
{selectedDateMood.description && ( {selectedDateMood.description && (
<Text style={styles.recordDescription}>{selectedDateMood.description}</Text> <Text style={styles.recordDescription}>{selectedDateMood.description}</Text>
)} )}
@@ -355,14 +378,14 @@ export default function MoodCalendarScreen() {
</TouchableOpacity> </TouchableOpacity>
) : ( ) : (
<View style={styles.emptyRecord}> <View style={styles.emptyRecord}>
<Text style={styles.emptyRecordText}></Text> <Text style={styles.emptyRecordText}>{t('mood.calendar.selectedDate.noRecord')}</Text>
<Text style={styles.emptyRecordSubtext}>"记录"</Text> <Text style={styles.emptyRecordSubtext}>{t('mood.calendar.selectedDate.noRecordHint')}</Text>
</View> </View>
) )
) : ( ) : (
<View style={styles.emptyRecord}> <View style={styles.emptyRecord}>
<Text style={styles.emptyRecordText}></Text> <Text style={styles.emptyRecordText}>{t('mood.calendar.selectedDate.noDateSelected')}</Text>
<Text style={styles.emptyRecordSubtext}>"记录"</Text> <Text style={styles.emptyRecordSubtext}>{t('mood.calendar.selectedDate.noDateSelectedHint')}</Text>
</View> </View>
)} )}
</View> </View>

View File

@@ -3,6 +3,7 @@ import { HeaderBar } from '@/components/ui/HeaderBar';
import { Colors } from '@/constants/Colors'; import { Colors } from '@/constants/Colors';
import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useColorScheme } from '@/hooks/useColorScheme'; import { useColorScheme } from '@/hooks/useColorScheme';
import { useI18n } from '@/hooks/useI18n';
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding'; import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
import { getMoodOptions, MoodType } from '@/services/moodCheckins'; import { getMoodOptions, MoodType } from '@/services/moodCheckins';
import { import {
@@ -31,6 +32,7 @@ import {
} from 'react-native'; } from 'react-native';
export default function MoodEditScreen() { export default function MoodEditScreen() {
const { t } = useI18n();
const safeAreaTop = useSafeAreaTop() const safeAreaTop = useSafeAreaTop()
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark'; const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
@@ -51,7 +53,7 @@ export default function MoodEditScreen() {
const scrollViewRef = useRef<ScrollView>(null); const scrollViewRef = useRef<ScrollView>(null);
const textInputRef = useRef<TextInput>(null); const textInputRef = useRef<TextInput>(null);
const moodOptions = getMoodOptions(); const moodOptions = getMoodOptions(t);
// 从 Redux 获取数据 // 从 Redux 获取数据
const moodRecords = useAppSelector(selectMoodRecordsByDate(selectedDate)); const moodRecords = useAppSelector(selectMoodRecordsByDate(selectedDate));
@@ -95,7 +97,7 @@ export default function MoodEditScreen() {
const handleSave = async () => { const handleSave = async () => {
if (!selectedMood) { if (!selectedMood) {
Alert.alert('提示', '请选择心情'); Alert.alert(t('common.alert'), t('mood.edit.alerts.selectMood'));
return; return;
} }
@@ -120,12 +122,12 @@ export default function MoodEditScreen() {
})).unwrap(); })).unwrap();
} }
Alert.alert('成功', existingMood ? '心情记录已更新' : '心情记录已保存', [ Alert.alert(t('common.success'), existingMood ? t('mood.edit.alerts.updateSuccess') : t('mood.edit.alerts.saveSuccess'), [
{ text: '确定', onPress: () => router.back() } { text: t('common.confirm'), onPress: () => router.back() }
]); ]);
} catch (error) { } catch (error) {
console.error('保存心情失败:', error); console.error('保存心情失败:', error);
Alert.alert('错误', '保存心情失败,请重试'); Alert.alert(t('common.error'), t('mood.edit.alerts.saveError'));
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
@@ -135,24 +137,24 @@ export default function MoodEditScreen() {
if (!existingMood) return; if (!existingMood) return;
Alert.alert( Alert.alert(
'确认删除', t('mood.edit.alerts.confirmDeleteTitle'),
'确定要删除这条心情记录吗?', t('mood.edit.alerts.confirmDelete'),
[ [
{ text: '取消', style: 'cancel' }, { text: t('common.cancel'), style: 'cancel' },
{ {
text: '删除', text: t('common.delete'),
style: 'destructive', style: 'destructive',
onPress: async () => { onPress: async () => {
try { try {
setIsDeleting(true); setIsDeleting(true);
await dispatch(deleteMoodRecord({ id: existingMood.id })).unwrap(); await dispatch(deleteMoodRecord({ id: existingMood.id })).unwrap();
Alert.alert('成功', '心情记录已删除', [ Alert.alert(t('common.success'), t('mood.edit.alerts.deleteSuccess'), [
{ text: '确定', onPress: () => router.back() } { text: t('common.confirm'), onPress: () => router.back() }
]); ]);
} catch (error) { } catch (error) {
console.error('删除心情失败:', error); console.error('删除心情失败:', error);
Alert.alert('错误', '删除心情失败,请重试'); Alert.alert(t('common.error'), t('mood.edit.alerts.deleteError'));
} finally { } finally {
setIsDeleting(false); setIsDeleting(false);
} }
@@ -183,7 +185,7 @@ export default function MoodEditScreen() {
<View style={styles.decorativeCircle2} /> <View style={styles.decorativeCircle2} />
<View style={styles.safeArea} > <View style={styles.safeArea} >
<HeaderBar <HeaderBar
title={existingMood ? '编辑心情' : '记录心情'} title={existingMood ? t('mood.edit.editTitle') : t('mood.edit.title')}
onBack={() => router.back()} onBack={() => router.back()}
withSafeTop={false} withSafeTop={false}
transparent={true} transparent={true}
@@ -207,13 +209,13 @@ export default function MoodEditScreen() {
{/* 日期显示 */} {/* 日期显示 */}
<View style={styles.dateSection}> <View style={styles.dateSection}>
<Text style={styles.dateTitle}> <Text style={styles.dateTitle}>
{dayjs(selectedDate).format('YYYY年M月D日')} {dayjs(selectedDate).format(t('mood.edit.dateFormat'))}
</Text> </Text>
</View> </View>
{/* 心情选择 */} {/* 心情选择 */}
<View style={styles.moodSection}> <View style={styles.moodSection}>
<Text style={styles.sectionTitle}></Text> <Text style={styles.sectionTitle}>{t('mood.edit.selectMood')}</Text>
<View style={styles.moodOptions}> <View style={styles.moodOptions}>
{moodOptions.map((mood, index) => ( {moodOptions.map((mood, index) => (
<TouchableOpacity <TouchableOpacity
@@ -233,7 +235,7 @@ export default function MoodEditScreen() {
{/* 心情强度选择 */} {/* 心情强度选择 */}
<View style={styles.intensitySection}> <View style={styles.intensitySection}>
<Text style={styles.sectionTitle}></Text> <Text style={styles.sectionTitle}>{t('mood.edit.intensity')}</Text>
<MoodIntensitySlider <MoodIntensitySlider
value={intensity} value={intensity}
onValueChange={handleIntensityChange} onValueChange={handleIntensityChange}
@@ -248,18 +250,12 @@ export default function MoodEditScreen() {
{/* 心情描述 */} {/* 心情描述 */}
<View style={styles.descriptionSection}> <View style={styles.descriptionSection}>
<Text style={styles.sectionTitle}></Text> <Text style={styles.sectionTitle}>{t('mood.edit.diary')}</Text>
<Text style={styles.diarySubtitle}></Text> <Text style={styles.diarySubtitle}>{t('mood.edit.diarySubtitle')}</Text>
<TextInput <TextInput
ref={textInputRef} ref={textInputRef}
style={styles.descriptionInput} style={styles.descriptionInput}
placeholder={`今天的心情如何? placeholder={t('mood.edit.placeholder')}
你经历过什么特别的事情吗?
有什么让你开心的事?
或者,有什么让你感到困扰?
写下你的感受,让这些时刻成为你珍贵的记忆...`}
placeholderTextColor="#a8a8a8" placeholderTextColor="#a8a8a8"
value={description} value={description}
onChangeText={setDescription} onChangeText={setDescription}
@@ -289,7 +285,7 @@ export default function MoodEditScreen() {
disabled={!selectedMood || isLoading} disabled={!selectedMood || isLoading}
> >
<Text style={styles.saveButtonText}> <Text style={styles.saveButtonText}>
{isLoading ? '保存中...' : existingMood ? '更新心情' : '保存心情'} {isLoading ? t('mood.edit.saving') : existingMood ? t('mood.edit.update') : t('mood.edit.save')}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
{existingMood && ( {existingMood && (

View File

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

View File

@@ -3,10 +3,12 @@ import { DateSelector } from '@/components/DateSelector';
import { FloatingFoodOverlay } from '@/components/FloatingFoodOverlay'; import { FloatingFoodOverlay } from '@/components/FloatingFoodOverlay';
import { NutritionRecordCard } from '@/components/NutritionRecordCard'; import { NutritionRecordCard } from '@/components/NutritionRecordCard';
import { HeaderBar } from '@/components/ui/HeaderBar'; import { HeaderBar } from '@/components/ui/HeaderBar';
import { Image } from '@/components/ui/Image';
import { Colors } from '@/constants/Colors'; import { Colors } from '@/constants/Colors';
import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useAuthGuard } from '@/hooks/useAuthGuard'; import { useAuthGuard } from '@/hooks/useAuthGuard';
import { useColorScheme } from '@/hooks/useColorScheme'; import { useColorScheme } from '@/hooks/useColorScheme';
import { useI18n } from '@/hooks/useI18n';
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding'; import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
import { DietRecord } from '@/services/dietRecords'; import { DietRecord } from '@/services/dietRecords';
import { type FoodRecognitionResponse } from '@/services/foodRecognition'; import { type FoodRecognitionResponse } from '@/services/foodRecognition';
@@ -20,16 +22,19 @@ import {
selectNutritionRecordsByDate, selectNutritionRecordsByDate,
selectNutritionSummaryByDate selectNutritionSummaryByDate
} from '@/store/nutritionSlice'; } from '@/store/nutritionSlice';
import { getMonthDaysZh, getMonthTitleZh, getTodayIndexInMonth } from '@/utils/date'; import { getTodayIndexInMonth } from '@/utils/date';
import { fetchBasalEnergyBurned } from '@/utils/health'; import { fetchBasalEnergyBurned } from '@/utils/health';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { useFocusEffect } from '@react-navigation/native'; import { useFocusEffect } from '@react-navigation/native';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { LinearGradient } from 'expo-linear-gradient';
import { router } from 'expo-router'; import { router } from 'expo-router';
import React, { useCallback, useEffect, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import { import {
FlatList, FlatList,
RefreshControl, RefreshControl,
StatusBar,
StyleSheet, StyleSheet,
Text, Text,
TouchableOpacity, TouchableOpacity,
@@ -39,26 +44,21 @@ import {
type ViewMode = 'daily' | 'all'; type ViewMode = 'daily' | 'all';
export default function NutritionRecordsScreen() { export default function NutritionRecordsScreen() {
const safeAreaTop = useSafeAreaTop() const { t } = useI18n();
const safeAreaTop = useSafeAreaTop();
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark'; const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme]; const colorTokens = Colors[theme];
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const isGlassAvailable = isLiquidGlassAvailable();
const { isLoggedIn } = useAuthGuard() const { isLoggedIn } = useAuthGuard();
// 日期相关状态 - 使用与统计页面相同的日期逻辑 // 日期相关状态
const days = getMonthDaysZh();
const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth()); const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth());
const monthTitle = getMonthTitleZh(); // 直接使用 state 管理当前选中日期,而不是从 days 数组派生,以支持 DateSelector 内部月份切换
const [currentSelectedDate, setCurrentSelectedDate] = useState<Date>(new Date());
// 获取当前选中日期 - 使用 useMemo 避免每次渲染都创建新对象 const currentSelectedDateString = dayjs(currentSelectedDate).format('YYYY-MM-DD');
const currentSelectedDate = useMemo(() => {
return days[selectedIndex]?.date?.toDate() ?? new Date();
}, [selectedIndex, days]);
const currentSelectedDateString = useMemo(() => {
return dayjs(currentSelectedDate).format('YYYY-MM-DD');
}, [currentSelectedDate]);
// 从 Redux 获取数据 // 从 Redux 获取数据
const healthData = useAppSelector(selectHealthDataByDate(currentSelectedDateString)); const healthData = useAppSelector(selectHealthDataByDate(currentSelectedDateString));
@@ -89,7 +89,6 @@ export default function NutritionRecordsScreen() {
const displayRecords = viewMode === 'daily' ? nutritionRecords : allRecords; const displayRecords = viewMode === 'daily' ? nutritionRecords : allRecords;
const loading = viewMode === 'daily' ? nutritionLoading.records : allRecordsLoading; const loading = viewMode === 'daily' ? nutritionLoading.records : allRecordsLoading;
// 页面聚焦时自动刷新数据 // 页面聚焦时自动刷新数据
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {
@@ -123,7 +122,7 @@ export default function NutritionRecordsScreen() {
loadAllRecords(); loadAllRecords();
} }
}, [viewMode, currentSelectedDateString, dispatch]) }, [viewMode, currentSelectedDateString, dispatch, isLoggedIn])
); );
// 当选中日期或视图模式变化时重新加载数据 // 当选中日期或视图模式变化时重新加载数据
@@ -327,71 +326,6 @@ export default function NutritionRecordsScreen() {
}); });
}; };
// 渲染日期选择器(仅在按天查看模式下显示)
const renderDateSelector = () => {
if (viewMode !== 'daily') return null;
return (
<DateSelector
selectedIndex={selectedIndex}
onDateSelect={(index, date) => setSelectedIndex(index)}
showMonthTitle={true}
disableFutureDates={true}
showCalendarIcon={true}
containerStyle={{
paddingHorizontal: 16
}}
/>
);
};
const renderEmptyState = () => (
<View style={styles.emptyContainer}>
<View style={styles.emptyContent}>
<Ionicons name="restaurant-outline" size={48} color={colorTokens.textSecondary} />
<Text style={[styles.emptyTitle, { color: colorTokens.text }]}>
{viewMode === 'daily' ? '今天还没有记录' : '暂无营养记录'}
</Text>
<Text style={[styles.emptySubtitle, { color: colorTokens.textSecondary }]}>
{viewMode === 'daily' ? '开始记录今日营养摄入' : '开始记录你的营养摄入吧'}
</Text>
</View>
</View>
);
const renderRecord = ({ item, index }: { item: DietRecord; index: number }) => (
<NutritionRecordCard
record={item}
onPress={() => handleRecordPress(item)}
onDelete={() => handleDeleteRecord(item.id)}
/>
);
const renderFooter = () => {
if (!hasMoreData) {
return (
<View style={styles.footerContainer}>
<Text style={[styles.footerText, { color: colorTokens.textSecondary }]}>
</Text>
</View>
);
}
if (viewMode === 'all' && displayRecords.length > 0) {
return (
<TouchableOpacity style={styles.loadMoreButton} onPress={loadMoreRecords}>
<Text style={[styles.loadMoreText, { color: colorTokens.primary }]}>
</Text>
</TouchableOpacity>
);
}
return null;
};
// 根据当前时间智能判断餐次类型 // 根据当前时间智能判断餐次类型
const getCurrentMealType = (): 'breakfast' | 'lunch' | 'dinner' | 'snack' => { const getCurrentMealType = (): 'breakfast' | 'lunch' | 'dinner' | 'snack' => {
const hour = new Date().getHours(); const hour = new Date().getHours();
@@ -415,68 +349,160 @@ export default function NutritionRecordsScreen() {
// 渲染右侧添加按钮 // 渲染右侧添加按钮
const renderRightButton = () => ( const renderRightButton = () => (
<TouchableOpacity <TouchableOpacity
style={[styles.addButton, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}
onPress={handleAddFood} onPress={handleAddFood}
activeOpacity={0.7} activeOpacity={0.7}
> >
<Ionicons name="add" size={20} color={colorTokens.primary} /> {isGlassAvailable ? (
<GlassView
style={styles.glassAddButton}
glassEffectStyle="regular"
tintColor="rgba(255, 255, 255, 0.4)"
isInteractive={true}
>
<Ionicons name="add" size={24} color={colorTokens.primary} />
</GlassView>
) : (
<View style={[styles.fallbackAddButton, { backgroundColor: 'rgba(255,255,255,0.8)' }]}>
<Ionicons name="add" size={24} color={colorTokens.primary} />
</View>
)}
</TouchableOpacity> </TouchableOpacity>
); );
return ( const renderEmptyState = () => (
<View style={[styles.container, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}> <View style={styles.emptySimpleContainer}>
<HeaderBar <Image
title="营养记录" source={require('@/assets/images/icons/icon-yingyang.png')}
onBack={() => router.back()} style={styles.emptySimpleImage}
right={renderRightButton()} contentFit="contain"
/>
<View style={{
paddingTop: safeAreaTop
}}>
{/* {renderViewModeToggle()} */}
{renderDateSelector()}
{/* Calorie Ring Chart */}
<CalorieRingChart
metabolism={basalMetabolism}
exercise={healthData?.activeEnergyBurned || 0}
consumed={nutritionSummary?.totalCalories || 0}
protein={nutritionSummary?.totalProtein || 0}
fat={nutritionSummary?.totalFat || 0}
carbs={nutritionSummary?.totalCarbohydrate || 0}
proteinGoal={nutritionGoals.proteinGoal}
fatGoal={nutritionGoals.fatGoal}
carbsGoal={nutritionGoals.carbsGoal}
/> />
<Text style={styles.emptySimpleText}>
{t('nutritionRecords.empty.title')}
</Text>
<TouchableOpacity onPress={handleAddFood}>
<Text style={[styles.emptyActionText, { color: colorTokens.primary }]}>
{t('nutritionRecords.empty.action')}
</Text>
</TouchableOpacity>
</View>
);
{( const renderRecord = ({ item }: { item: DietRecord }) => (
<FlatList <NutritionRecordCard
data={displayRecords} record={item}
renderItem={({ item, index }) => renderRecord({ item, index })} onPress={() => handleRecordPress(item)}
keyExtractor={(item) => item.id.toString()} onDelete={() => handleDeleteRecord(item.id)}
contentContainerStyle={[ />
styles.listContainer, );
{ paddingBottom: 40, paddingTop: 16 }
]} const renderFooter = () => {
showsVerticalScrollIndicator={false} if (!hasMoreData) {
refreshControl={ if (displayRecords.length === 0) return null;
<RefreshControl return (
refreshing={refreshing} <View style={styles.footerContainer}>
onRefresh={onRefresh} <Text style={[styles.footerText, { color: colorTokens.textSecondary }]}>
tintColor={colorTokens.primary} {t('nutritionRecords.footer.end')}
colors={[colorTokens.primary]} </Text>
/> </View>
} );
ListEmptyComponent={renderEmptyState} }
ListFooterComponent={renderFooter}
onEndReached={viewMode === 'all' ? loadMoreRecords : undefined} if (viewMode === 'all' && displayRecords.length > 0) {
onEndReachedThreshold={0.1} return (
<TouchableOpacity style={styles.loadMoreButton} onPress={loadMoreRecords}>
<Text style={[styles.loadMoreText, { color: colorTokens.primary }]}>
{t('nutritionRecords.footer.loadMore')}
</Text>
</TouchableOpacity>
);
}
return null;
};
const ListHeader = () => (
<View>
<View style={styles.headerContent}>
{viewMode === 'daily' && (
<DateSelector
selectedIndex={selectedIndex}
onDateSelect={(index, date) => {
setSelectedIndex(index);
setCurrentSelectedDate(date);
}}
showMonthTitle={true}
disableFutureDates={true}
showCalendarIcon={true}
containerStyle={styles.dateSelectorContainer}
/> />
)} )}
<View style={styles.chartWrapper}>
<CalorieRingChart
metabolism={basalMetabolism}
exercise={healthData?.activeEnergyBurned || 0}
consumed={nutritionSummary?.totalCalories || 0}
protein={nutritionSummary?.totalProtein || 0}
fat={nutritionSummary?.totalFat || 0}
carbs={nutritionSummary?.totalCarbohydrate || 0}
proteinGoal={nutritionGoals.proteinGoal}
fatGoal={nutritionGoals.fatGoal}
carbsGoal={nutritionGoals.carbsGoal}
/>
</View>
<View style={styles.listTitleContainer}>
<Text style={styles.listTitle}>{t('nutritionRecords.listTitle')}</Text>
{displayRecords.length > 0 && (
<Text style={styles.listSubtitle}>{t('nutritionRecords.recordCount', { count: displayRecords.length })}</Text>
)}
</View>
</View> </View>
</View>
);
return (
<View style={[styles.container, { backgroundColor: '#f3f4fb' }]}>
<StatusBar barStyle="dark-content" />
{/* 顶部柔和渐变背景 */}
<LinearGradient
colors={['rgba(255, 243, 224, 0.8)', 'rgba(243, 244, 251, 0)']}
style={styles.topGradient}
start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 1 }}
/>
<HeaderBar
title={t('nutritionRecords.title')}
onBack={() => router.back()}
right={renderRightButton()}
transparent={true}
/>
<FlatList
data={displayRecords}
renderItem={renderRecord}
keyExtractor={(item) => item.id.toString()}
contentContainerStyle={[
styles.listContainer,
{ paddingTop: safeAreaTop }
]}
showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
tintColor={colorTokens.primary}
colors={[colorTokens.primary]}
/>
}
ListHeaderComponent={ListHeader}
ListEmptyComponent={renderEmptyState}
ListFooterComponent={renderFooter}
onEndReached={viewMode === 'all' ? loadMoreRecords : undefined}
onEndReachedThreshold={0.1}
/>
{/* 食物添加悬浮窗 */} {/* 食物添加悬浮窗 */}
<FloatingFoodOverlay <FloatingFoodOverlay
@@ -492,130 +518,105 @@ const styles = StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
}, },
viewModeContainer: { topGradient: {
flexDirection: 'row', position: 'absolute',
justifyContent: 'space-between', left: 0,
alignItems: 'center', right: 0,
paddingHorizontal: 16, top: 0,
paddingVertical: 12, height: 320,
marginBottom: 8,
},
monthTitle: {
fontSize: 22,
fontWeight: '800',
},
toggleContainer: {
flexDirection: 'row',
borderRadius: 20,
padding: 2,
},
toggleButton: {
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 18,
minWidth: 80,
alignItems: 'center',
},
toggleText: {
fontSize: 14,
fontWeight: '600',
},
daysContainer: {
marginBottom: 12,
},
daysScrollContainer: {
paddingHorizontal: 16,
paddingVertical: 8,
},
dayPill: {
width: 48,
height: 48,
borderRadius: 34,
marginRight: 12,
alignItems: 'center',
justifyContent: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.08,
shadowRadius: 4,
elevation: 3,
},
dayNumber: {
fontSize: 18,
textAlign: 'center',
},
dayLabel: {
fontSize: 12,
marginTop: 2,
textAlign: 'center',
},
addButton: {
width: 32,
height: 32,
borderRadius: 16,
alignItems: 'center',
justifyContent: 'center',
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 2,
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
loadingText: {
marginTop: 12,
fontSize: 16,
fontWeight: '500',
}, },
listContainer: { listContainer: {
paddingBottom: 100, // 留出底部空间防止遮挡
},
headerContent: {
marginBottom: 16,
},
dateSelectorContainer: {
paddingHorizontal: 16, paddingHorizontal: 16,
paddingTop: 8, marginBottom: 16,
}, },
emptyContainer: { chartWrapper: {
flex: 1, marginBottom: 24,
justifyContent: 'center', shadowColor: 'rgba(30, 41, 59, 0.05)',
alignItems: 'center', shadowOffset: { width: 0, height: 8 },
paddingVertical: 60, shadowOpacity: 0.1,
paddingHorizontal: 16, shadowRadius: 12,
elevation: 4,
}, },
emptyContent: { listTitleContainer: {
alignItems: 'center', flexDirection: 'row',
maxWidth: 320, alignItems: 'baseline',
paddingHorizontal: 24,
marginBottom: 12,
gap: 8,
}, },
emptyTitle: { listTitle: {
fontSize: 18, fontSize: 18,
fontWeight: '700', fontWeight: '700',
marginTop: 16, color: '#1c1f3a',
marginBottom: 8, fontFamily: 'AliBold',
textAlign: 'center',
}, },
emptySubtitle: { listSubtitle: {
fontSize: 13,
color: '#6f7ba7',
fontFamily: 'AliRegular',
},
glassAddButton: {
width: 40,
height: 40,
alignItems: 'center',
justifyContent: 'center',
borderRadius: 20,
overflow: 'hidden',
},
fallbackAddButton: {
width: 40,
height: 40,
alignItems: 'center',
justifyContent: 'center',
borderRadius: 20,
borderWidth: 1,
borderColor: 'rgba(0,0,0,0.05)',
},
emptySimpleContainer: {
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 60,
},
emptySimpleImage: {
width: 48,
height: 48,
opacity: 0.4,
marginBottom: 12,
},
emptySimpleText: {
fontSize: 14, fontSize: 14,
fontWeight: '500', color: '#94A3B8',
textAlign: 'center', fontFamily: 'AliRegular',
lineHeight: 20, marginBottom: 8,
},
emptyActionText: {
fontSize: 14,
fontWeight: '600',
fontFamily: 'AliBold',
}, },
footerContainer: { footerContainer: {
paddingVertical: 20, paddingVertical: 24,
alignItems: 'center', alignItems: 'center',
}, },
footerText: { footerText: {
fontSize: 14, fontSize: 12,
fontWeight: '500', fontWeight: '500',
opacity: 0.6,
fontFamily: 'AliRegular',
}, },
loadMoreButton: { loadMoreButton: {
paddingVertical: 16, paddingVertical: 16,
alignItems: 'center', alignItems: 'center',
}, },
loadMoreText: { loadMoreText: {
fontSize: 16, fontSize: 14,
fontWeight: '600', fontWeight: '600',
fontFamily: 'AliBold',
}, },
}); });

View File

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

View File

@@ -1,5 +1,6 @@
import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding'; import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
import { useVipService } from '@/hooks/useVipService';
import { import {
resetToDefault, resetToDefault,
selectTabBarConfigs, selectTabBarConfigs,
@@ -7,11 +8,9 @@ import {
type TabConfig, type TabConfig,
} from '@/store/tabBarConfigSlice'; } from '@/store/tabBarConfigSlice';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { isLiquidGlassAvailable } from 'expo-glass-effect';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import React, { useCallback } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { import {
Alert, Alert,
ScrollView, ScrollView,
@@ -22,26 +21,50 @@ import {
View View
} from 'react-native'; } from 'react-native';
import { MembershipModal } from '@/components/model/MembershipModal';
import { HeaderBar } from '@/components/ui/HeaderBar'; import { HeaderBar } from '@/components/ui/HeaderBar';
import { IconSymbol } from '@/components/ui/IconSymbol'; import { IconSymbol } from '@/components/ui/IconSymbol';
import { palette } from '@/constants/Colors'; import { palette } from '@/constants/Colors';
import { useI18n } from '@/hooks/useI18n';
export default function TabBarConfigScreen() { export default function TabBarConfigScreen() {
const { t } = useTranslation(); const { t } = useI18n();
const router = useRouter(); const router = useRouter();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const safeAreaTop = useSafeAreaTop(60); const safeAreaTop = useSafeAreaTop(60);
const configs = useAppSelector(selectTabBarConfigs); const configs = useAppSelector(selectTabBarConfigs);
const isGlassAvailable = isLiquidGlassAvailable(); const { isVip } = useVipService();
const [showMembershipModal, setShowMembershipModal] = useState(false);
// 处理开关切换 // 处理开关切换
const handleToggle = useCallback( const handleToggle = useCallback(
(tabId: string) => { (tabId: string) => {
dispatch(toggleTabEnabled(tabId)); // 直接检查用户是否是 VIP底部栏配置不是权益类功能而是基础功能
if (isVip) {
// VIP 用户可以正常切换
dispatch(toggleTabEnabled(tabId));
} else {
// 非 VIP 用户显示购买弹窗
setShowMembershipModal(true);
}
}, },
[dispatch] [dispatch, isVip]
); );
// 页面加载时检查 VIP 状态
useEffect(() => {
if (!isVip) {
// 非 VIP 用户进入页面时立即显示购买弹窗
setShowMembershipModal(true);
}
}, [isVip]);
// 购买成功回调
const handlePurchaseSuccess = useCallback(() => {
// 购买成功后可以执行一些操作,比如刷新用户信息
console.log('会员购买成功');
}, []);
// 恢复默认设置 // 恢复默认设置
const handleReset = useCallback(() => { const handleReset = useCallback(() => {
Alert.alert( Alert.alert(
@@ -82,6 +105,11 @@ export default function TabBarConfigScreen() {
{t('personal.tabBarConfig.cannotDisable')} {t('personal.tabBarConfig.cannotDisable')}
</Text> </Text>
)} )}
{item.canBeDisabled && !isVip && (
<Text style={styles.vipSubtitle}>
{t('personal.tabBarConfig.vipOnly')}
</Text>
)}
</View> </View>
</View> </View>
@@ -89,7 +117,7 @@ export default function TabBarConfigScreen() {
<Switch <Switch
value={item.enabled} value={item.enabled}
onValueChange={() => handleToggle(item.id)} onValueChange={() => handleToggle(item.id)}
disabled={!item.canBeDisabled} disabled={!item.canBeDisabled || !isVip}
trackColor={{ false: '#E5E5E5', true: '#9370DB' }} trackColor={{ false: '#E5E5E5', true: '#9370DB' }}
thumbColor="#FFFFFF" thumbColor="#FFFFFF"
style={styles.switch} style={styles.switch}
@@ -155,6 +183,13 @@ export default function TabBarConfigScreen() {
</View> </View>
</ScrollView> </ScrollView>
{/* 会员购买弹窗 */}
<MembershipModal
visible={showMembershipModal}
onClose={() => setShowMembershipModal(false)}
onPurchaseSuccess={handlePurchaseSuccess}
/>
</View> </View>
); );
} }
@@ -255,6 +290,10 @@ const styles = StyleSheet.create({
fontSize: 12, fontSize: 12,
color: '#9370DB', color: '#9370DB',
}, },
vipSubtitle: {
fontSize: 12,
color: '#FF6B6B',
},
switch: { switch: {
transform: [{ scaleX: 0.9 }, { scaleY: 0.9 }], transform: [{ scaleX: 0.9 }, { scaleY: 0.9 }],
}, },

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,358 @@
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' },
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,5 +1,6 @@
import { DateSelector } from '@/components/DateSelector'; import { DateSelector } from '@/components/DateSelector';
import { HeaderBar } from '@/components/ui/HeaderBar'; import { HeaderBar } from '@/components/ui/HeaderBar';
import { useI18n } from '@/hooks/useI18n';
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding'; import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date'; import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date';
import { fetchHourlyStepSamples, fetchStepCount, HourlyStepData } from '@/utils/health'; import { fetchHourlyStepSamples, fetchStepCount, HourlyStepData } from '@/utils/health';
@@ -17,6 +18,7 @@ import {
} from 'react-native'; } from 'react-native';
export default function StepsDetailScreen() { export default function StepsDetailScreen() {
const { t } = useI18n();
const safeAreaTop = useSafeAreaTop() const safeAreaTop = useSafeAreaTop()
// 获取路由参数 // 获取路由参数
@@ -169,11 +171,11 @@ export default function StepsDetailScreen() {
// 活动等级配置 // 活动等级配置
const activityLevels = useMemo(() => [ const activityLevels = useMemo(() => [
{ key: 'inactive', label: '不怎么动', minSteps: 0, maxSteps: 3000, color: '#B8C8D6' }, { key: 'inactive', label: t('stepsDetail.activityLevel.levels.inactive'), minSteps: 0, maxSteps: 3000, color: '#B8C8D6' },
{ key: 'light', label: '轻度活跃', minSteps: 3000, maxSteps: 7500, color: '#93C5FD' }, { key: 'light', label: t('stepsDetail.activityLevel.levels.light'), minSteps: 3000, maxSteps: 7500, color: '#93C5FD' },
{ key: 'moderate', label: '中等活跃', minSteps: 7500, maxSteps: 10000, color: '#FCD34D' }, { key: 'moderate', label: t('stepsDetail.activityLevel.levels.moderate'), minSteps: 7500, maxSteps: 10000, color: '#FCD34D' },
{ key: 'very_active', label: '非常活跃', minSteps: 10000, maxSteps: Infinity, color: '#FB923C' } { key: 'very_active', label: t('stepsDetail.activityLevel.levels.very_active'), minSteps: 10000, maxSteps: Infinity, color: '#FB923C' }
], []); ], [t]);
// 计算当前活动等级 // 计算当前活动等级
const currentActivityLevel = useMemo(() => { const currentActivityLevel = useMemo(() => {
@@ -211,7 +213,7 @@ export default function StepsDetailScreen() {
/> />
<HeaderBar <HeaderBar
title="步数详情" title={t('stepsDetail.title')}
/> />
<ScrollView <ScrollView
@@ -233,23 +235,23 @@ export default function StepsDetailScreen() {
<View style={styles.statsCard}> <View style={styles.statsCard}>
{isLoading ? ( {isLoading ? (
<View style={styles.loadingContainer}> <View style={styles.loadingContainer}>
<Text style={styles.loadingText}>...</Text> <Text style={styles.loadingText}>{t('stepsDetail.loading')}</Text>
</View> </View>
) : ( ) : (
<View style={styles.statsRow}> <View style={styles.statsRow}>
<View style={styles.statItem}> <View style={styles.statItem}>
<Text style={styles.statValue}>{totalSteps.toLocaleString()}</Text> <Text style={styles.statValue}>{totalSteps.toLocaleString()}</Text>
<Text style={styles.statLabel}></Text> <Text style={styles.statLabel}>{t('stepsDetail.stats.totalSteps')}</Text>
</View> </View>
<View style={styles.statItem}> <View style={styles.statItem}>
<Text style={styles.statValue}>{averageHourlySteps}</Text> <Text style={styles.statValue}>{averageHourlySteps}</Text>
<Text style={styles.statLabel}></Text> <Text style={styles.statLabel}>{t('stepsDetail.stats.averagePerHour')}</Text>
</View> </View>
<View style={styles.statItem}> <View style={styles.statItem}>
<Text style={styles.statValue}> <Text style={styles.statValue}>
{mostActiveHour ? `${mostActiveHour.hour}:00` : '--'} {mostActiveHour ? `${mostActiveHour.hour}:00` : '--'}
</Text> </Text>
<Text style={styles.statLabel}></Text> <Text style={styles.statLabel}>{t('stepsDetail.stats.mostActiveTime')}</Text>
</View> </View>
</View> </View>
)} )}
@@ -258,7 +260,7 @@ export default function StepsDetailScreen() {
{/* 详细柱状图卡片 */} {/* 详细柱状图卡片 */}
<View style={styles.chartCard}> <View style={styles.chartCard}>
<View style={styles.chartHeader}> <View style={styles.chartHeader}>
<Text style={styles.chartTitle}></Text> <Text style={styles.chartTitle}>{t('stepsDetail.chart.title')}</Text>
<Text style={styles.chartSubtitle}> <Text style={styles.chartSubtitle}>
{dayjs(currentSelectedDate).format('YYYY年MM月DD日')} {dayjs(currentSelectedDate).format('YYYY年MM月DD日')}
</Text> </Text>
@@ -290,7 +292,7 @@ export default function StepsDetailScreen() {
))} ))}
</View> </View>
<Text style={styles.averageLineLabel}> <Text style={styles.averageLineLabel}>
{averageHourlySteps} {t('stepsDetail.chart.averageLabel', { steps: averageHourlySteps })}
</Text> </Text>
</View> </View>
)} )}
@@ -354,9 +356,9 @@ export default function StepsDetailScreen() {
{/* 底部时间轴标签 */} {/* 底部时间轴标签 */}
<View style={styles.timeLabels}> <View style={styles.timeLabels}>
<Text style={styles.timeLabel}>0:00</Text> <Text style={styles.timeLabel}>{t('stepsDetail.timeLabels.midnight')}</Text>
<Text style={styles.timeLabel}>12:00</Text> <Text style={styles.timeLabel}>{t('stepsDetail.timeLabels.noon')}</Text>
<Text style={styles.timeLabel}>24:00</Text> <Text style={styles.timeLabel}>{t('stepsDetail.timeLabels.nextDay')}</Text>
</View> </View>
</View> </View>
</View> </View>
@@ -366,7 +368,7 @@ export default function StepsDetailScreen() {
{/* 活动级别文本 */} {/* 活动级别文本 */}
<Text style={styles.activityMainText}></Text> <Text style={styles.activityMainText}>{t('stepsDetail.activityLevel.currentActivity')}</Text>
<Text style={styles.activityLevelText}>{currentActivityLevel.label}</Text> <Text style={styles.activityLevelText}>{currentActivityLevel.label}</Text>
{/* 进度条 */} {/* 进度条 */}
@@ -388,14 +390,14 @@ export default function StepsDetailScreen() {
<View style={styles.stepsInfoContainer}> <View style={styles.stepsInfoContainer}>
<View style={styles.currentStepsInfo}> <View style={styles.currentStepsInfo}>
<Text style={styles.stepsValue}>{totalSteps.toLocaleString()} </Text> <Text style={styles.stepsValue}>{totalSteps.toLocaleString()} </Text>
<Text style={styles.stepsLabel}></Text> <Text style={styles.stepsLabel}>{t('stepsDetail.activityLevel.progress.current')}</Text>
</View> </View>
<View style={styles.nextStepsInfo}> <View style={styles.nextStepsInfo}>
<Text style={styles.stepsValue}> <Text style={styles.stepsValue}>
{nextActivityLevel ? `${nextActivityLevel.minSteps.toLocaleString()}` : '--'} {nextActivityLevel ? `${nextActivityLevel.minSteps.toLocaleString()}` : '--'}
</Text> </Text>
<Text style={styles.stepsLabel}> <Text style={styles.stepsLabel}>
{nextActivityLevel ? `下一级: ${nextActivityLevel.label}` : '已达最高级'} {nextActivityLevel ? t('stepsDetail.activityLevel.progress.nextLevel', { level: nextActivityLevel.label }) : t('stepsDetail.activityLevel.progress.highestLevel')}
</Text> </Text>
</View> </View>
</View> </View>

View File

@@ -3,6 +3,7 @@ import { Colors } from '@/constants/Colors';
import { useAppDispatch } from '@/hooks/redux'; import { useAppDispatch } from '@/hooks/redux';
import { useAuthGuard } from '@/hooks/useAuthGuard'; import { useAuthGuard } from '@/hooks/useAuthGuard';
import { useColorScheme } from '@/hooks/useColorScheme'; import { useColorScheme } from '@/hooks/useColorScheme';
import { useI18n } from '@/hooks/useI18n';
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding'; import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
import { analyzeFoodFromText } from '@/services/foodRecognition'; import { analyzeFoodFromText } from '@/services/foodRecognition';
import { saveRecognitionResult, setError, setLoading } from '@/store/foodRecognitionSlice'; import { saveRecognitionResult, setError, setLoading } from '@/store/foodRecognitionSlice';
@@ -24,6 +25,7 @@ import {
type VoiceRecordState = 'idle' | 'listening' | 'processing' | 'result' | 'analyzing'; type VoiceRecordState = 'idle' | 'listening' | 'processing' | 'result' | 'analyzing';
export default function VoiceRecordScreen() { export default function VoiceRecordScreen() {
const { t } = useI18n();
const safeAreaTop = useSafeAreaTop() const safeAreaTop = useSafeAreaTop()
const theme = useColorScheme() ?? 'light'; const theme = useColorScheme() ?? 'light';
const colorTokens = Colors[theme]; const colorTokens = Colors[theme];
@@ -118,7 +120,7 @@ export default function VoiceRecordScreen() {
// 语音识别回调 - 使用 useCallback 避免每次渲染重新创建 // 语音识别回调 - 使用 useCallback 避免每次渲染重新创建
const onSpeechStart = useCallback(() => { const onSpeechStart = useCallback(() => {
console.log('语音开始'); console.log('Voice started');
if (!isMountedRef.current) return; if (!isMountedRef.current) return;
setIsListening(true); setIsListening(true);
@@ -128,11 +130,11 @@ export default function VoiceRecordScreen() {
}, []); }, []);
const onSpeechRecognized = useCallback(() => { const onSpeechRecognized = useCallback(() => {
console.log('语音识别中...'); console.log('Voice recognition in progress...');
}, []); }, []);
const onSpeechEnd = useCallback(() => { const onSpeechEnd = useCallback(() => {
console.log('语音结束'); console.log('Voice ended');
if (!isMountedRef.current) return; if (!isMountedRef.current) return;
setIsListening(false); setIsListening(false);
@@ -141,7 +143,7 @@ export default function VoiceRecordScreen() {
}, []); }, []);
const onSpeechError = useCallback((error: any) => { const onSpeechError = useCallback((error: any) => {
console.log('语音识别错误:', error); console.log('Voice recognition error:', error);
if (!isMountedRef.current) return; if (!isMountedRef.current) return;
setIsListening(false); setIsListening(false);
@@ -150,16 +152,16 @@ export default function VoiceRecordScreen() {
// 显示更友好的错误信息 // 显示更友好的错误信息
if (error.error?.code === '7') { if (error.error?.code === '7') {
Alert.alert('提示', '没有检测到语音输入,请重试'); Alert.alert(t('voiceRecord.alerts.noVoiceInput'), t('voiceRecord.alerts.noVoiceInput'));
} else if (error.error?.code === '2') { } else if (error.error?.code === '2') {
Alert.alert('提示', '网络连接异常,请检查网络后重试'); Alert.alert(t('voiceRecord.alerts.networkError'), t('voiceRecord.alerts.networkError'));
} else { } else {
Alert.alert('提示', '语音识别出现问题,请重试'); Alert.alert(t('voiceRecord.alerts.voiceError'), t('voiceRecord.alerts.voiceError'));
} }
}, []); }, []);
const onSpeechResults = useCallback((event: any) => { const onSpeechResults = useCallback((event: any) => {
console.log('语音识别结果:', event); console.log('Voice recognition result:', event);
if (!isMountedRef.current) return; if (!isMountedRef.current) return;
const text = event.value?.[0] || ''; const text = event.value?.[0] || '';
@@ -168,7 +170,7 @@ export default function VoiceRecordScreen() {
setRecordState('result'); setRecordState('result');
} else { } else {
setRecordState('idle'); setRecordState('idle');
Alert.alert('提示', '未识别到有效内容,请重新录音'); Alert.alert(t('voiceRecord.alerts.noValidContent'), t('voiceRecord.alerts.noValidContent'));
} }
stopAnimations(); stopAnimations();
}, []); }, []);
@@ -215,7 +217,7 @@ export default function VoiceRecordScreen() {
await Voice.destroy(); await Voice.destroy();
Voice.removeAllListeners(); Voice.removeAllListeners();
} catch (error) { } catch (error) {
console.log('清理语音识别资源失败:', error); console.log('Failed to clean up voice recognition resources:', error);
} }
}; };
cleanup(); cleanup();
@@ -246,22 +248,22 @@ export default function VoiceRecordScreen() {
await Voice.start('zh-CN'); await Voice.start('zh-CN');
} catch (error) { } catch (error) {
console.log('启动语音识别失败:', error); console.log('Failed to start voice recognition:', error);
setRecordState('idle'); setRecordState('idle');
setIsListening(false); setIsListening(false);
Alert.alert('录音失败', '无法启动语音识别,请检查麦克风权限设置'); Alert.alert(t('voiceRecord.alerts.recordingFailed'), t('voiceRecord.alerts.recordingPermissionError'));
} }
}; };
// 停止录音 // 停止录音
const stopRecording = async () => { const stopRecording = async () => {
try { try {
console.log('停止录音'); console.log('Stop recording');
setIsListening(false); setIsListening(false);
await Voice.stop(); await Voice.stop();
triggerHapticFeedback('impactLight'); triggerHapticFeedback('impactLight');
} catch (error) { } catch (error) {
console.log('停止语音识别失败:', error); console.log('Failed to stop voice recognition:', error);
setIsListening(false); setIsListening(false);
setRecordState('idle'); setRecordState('idle');
} }
@@ -287,7 +289,7 @@ export default function VoiceRecordScreen() {
startRecording(); startRecording();
}, 200); }, 200);
} catch (error) { } catch (error) {
console.log('重新录音失败:', error); console.log('Failed to retry recording:', error);
setRecordState('idle'); setRecordState('idle');
setIsListening(false); setIsListening(false);
} }
@@ -296,7 +298,7 @@ export default function VoiceRecordScreen() {
// 确认并分析食物文本 // 确认并分析食物文本
const confirmResult = async () => { const confirmResult = async () => {
if (!recognizedText.trim()) { if (!recognizedText.trim()) {
Alert.alert('提示', '请先进行语音识别'); Alert.alert(t('voiceRecord.alerts.pleaseRecordFirst'), t('voiceRecord.alerts.pleaseRecordFirst'));
return; return;
} }
@@ -382,7 +384,7 @@ export default function VoiceRecordScreen() {
const errorMessage = error instanceof Error ? error.message : '分析失败,请重试'; const errorMessage = error instanceof Error ? error.message : '分析失败,请重试';
dispatch(setError(errorMessage)); dispatch(setError(errorMessage));
Alert.alert('分析失败', errorMessage); Alert.alert(t('voiceRecord.alerts.analysisFailed'), errorMessage);
} }
}; };
@@ -401,7 +403,7 @@ export default function VoiceRecordScreen() {
router.back(); router.back();
} catch (error) { } catch (error) {
console.log('返回时清理资源失败:', error); console.log('Failed to clean up resources when returning:', error);
router.back(); router.back();
} }
}; };
@@ -410,15 +412,15 @@ export default function VoiceRecordScreen() {
const getStatusText = () => { const getStatusText = () => {
switch (recordState) { switch (recordState) {
case 'idle': case 'idle':
return '轻触麦克风开始录音'; return t('voiceRecord.status.idle');
case 'listening': case 'listening':
return '正在聆听中,请开始说话...'; return t('voiceRecord.status.listening');
case 'processing': case 'processing':
return 'AI正在处理语音内容...'; return t('voiceRecord.status.processing');
case 'analyzing': case 'analyzing':
return 'AI大模型深度分析营养成分中...'; return t('voiceRecord.status.analyzing');
case 'result': case 'result':
return '语音识别完成,请确认结果'; return t('voiceRecord.status.result');
default: default:
return ''; return '';
} }
@@ -470,7 +472,7 @@ export default function VoiceRecordScreen() {
return ( return (
<View style={[styles.container, { backgroundColor: colorTokens.background }]}> <View style={[styles.container, { backgroundColor: colorTokens.background }]}>
<HeaderBar <HeaderBar
title="一句话记录" title={t('voiceRecord.title')}
onBack={handleBack} onBack={handleBack}
tone={theme} tone={theme}
variant="elevated" variant="elevated"
@@ -485,7 +487,7 @@ export default function VoiceRecordScreen() {
<View style={styles.topSection}> <View style={styles.topSection}>
<View style={styles.introContainer}> <View style={styles.introContainer}>
<Text style={[styles.introDescription, { color: colorTokens.textSecondary }]}> <Text style={[styles.introDescription, { color: colorTokens.textSecondary }]}>
AI将智能分析营养成分和卡路里 {t('voiceRecord.intro.description')}
</Text> </Text>
</View> </View>
</View> </View>
@@ -605,7 +607,7 @@ export default function VoiceRecordScreen() {
{recordState === 'listening' && ( {recordState === 'listening' && (
<Text style={[styles.hintText, { color: colorTokens.textSecondary }]}> <Text style={[styles.hintText, { color: colorTokens.textSecondary }]}>
{t('voiceRecord.hints.listening')}
</Text> </Text>
)} )}
@@ -614,18 +616,18 @@ export default function VoiceRecordScreen() {
<BlurView intensity={20} tint={theme} style={styles.examplesContainer}> <BlurView intensity={20} tint={theme} style={styles.examplesContainer}>
<View style={styles.examplesContent}> <View style={styles.examplesContent}>
<Text style={[styles.examplesTitle, { color: colorTokens.text }]}> <Text style={[styles.examplesTitle, { color: colorTokens.text }]}>
{t('voiceRecord.examples.title')}
</Text> </Text>
<View style={styles.examplesList}> <View style={styles.examplesList}>
<Text style={[styles.exampleText, { color: colorTokens.textSecondary }]}> {[
&ldquo;&rdquo; t('voiceRecord.examples.items.0'),
</Text> t('voiceRecord.examples.items.1'),
<Text style={[styles.exampleText, { color: colorTokens.textSecondary }]}> t('voiceRecord.examples.items.2')
&ldquo;150&rdquo; ].map((example: string, index: number) => (
</Text> <Text key={index} style={[styles.exampleText, { color: colorTokens.textSecondary }]}>
<Text style={[styles.exampleText, { color: colorTokens.textSecondary }]}> &ldquo;{example}&rdquo;
&ldquo;&rdquo; </Text>
</Text> ))}
</View> </View>
</View> </View>
</BlurView> </BlurView>
@@ -634,7 +636,7 @@ export default function VoiceRecordScreen() {
{recordState === 'analyzing' && ( {recordState === 'analyzing' && (
<View style={styles.analysisProgressContainer}> <View style={styles.analysisProgressContainer}>
<Text style={[styles.progressText, { color: colorTokens.text }]}> <Text style={[styles.progressText, { color: colorTokens.text }]}>
: {Math.round(analysisProgress)}% {t('voiceRecord.analysis.progress', { progress: Math.round(analysisProgress) })}
</Text> </Text>
<View style={styles.progressBarContainer}> <View style={styles.progressBarContainer}>
<Animated.View <Animated.View
@@ -650,7 +652,7 @@ export default function VoiceRecordScreen() {
/> />
</View> </View>
<Text style={[styles.analysisHint, { color: colorTokens.textSecondary }]}> <Text style={[styles.analysisHint, { color: colorTokens.textSecondary }]}>
AI正在深度分析您的食物描述... {t('voiceRecord.analysis.hint')}
</Text> </Text>
</View> </View>
)} )}
@@ -662,7 +664,7 @@ export default function VoiceRecordScreen() {
<BlurView intensity={20} tint={theme} style={styles.resultContainer}> <BlurView intensity={20} tint={theme} style={styles.resultContainer}>
<View style={styles.resultContent}> <View style={styles.resultContent}>
<Text style={[styles.resultLabel, { color: colorTokens.textSecondary }]}> <Text style={[styles.resultLabel, { color: colorTokens.textSecondary }]}>
: {t('voiceRecord.result.label')}
</Text> </Text>
<Text style={[styles.resultText, { color: colorTokens.text }]}> <Text style={[styles.resultText, { color: colorTokens.text }]}>
{recognizedText} {recognizedText}
@@ -675,7 +677,7 @@ export default function VoiceRecordScreen() {
onPress={retryRecording} onPress={retryRecording}
> >
<Ionicons name="refresh" size={16} color="#7B68EE" /> <Ionicons name="refresh" size={16} color="#7B68EE" />
<Text style={styles.retryButtonText}></Text> <Text style={styles.retryButtonText}>{t('voiceRecord.actions.retry')}</Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity
@@ -683,7 +685,7 @@ export default function VoiceRecordScreen() {
onPress={confirmResult} onPress={confirmResult}
> >
<Ionicons name="checkmark" size={16} color="white" /> <Ionicons name="checkmark" size={16} color="white" />
<Text style={styles.confirmButtonText}>使</Text> <Text style={styles.confirmButtonText}>{t('voiceRecord.actions.confirm')}</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
)} )}

View File

@@ -1,9 +1,10 @@
import { Image } from '@/components/ui/Image';
import { Colors } from '@/constants/Colors'; import { Colors } from '@/constants/Colors';
import { useColorScheme } from '@/hooks/useColorScheme'; import { useColorScheme } from '@/hooks/useColorScheme';
import { useWaterDataByDate } from '@/hooks/useWaterData'; import { useWaterDataByDate } from '@/hooks/useWaterData';
import { getQuickWaterAmount } from '@/utils/userPreferences'; import { getQuickWaterAmount } from '@/utils/userPreferences';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { Image } from 'expo-image'; import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import { router, useLocalSearchParams } from 'expo-router'; import { router, useLocalSearchParams } from 'expo-router';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
@@ -20,6 +21,7 @@ import {
import { Swipeable } from 'react-native-gesture-handler'; import { Swipeable } from 'react-native-gesture-handler';
import { HeaderBar } from '@/components/ui/HeaderBar'; import { HeaderBar } from '@/components/ui/HeaderBar';
import { useI18n } from '@/hooks/useI18n';
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding'; import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
@@ -28,6 +30,7 @@ interface WaterDetailProps {
} }
const WaterDetail: React.FC<WaterDetailProps> = () => { const WaterDetail: React.FC<WaterDetailProps> = () => {
const { t } = useI18n();
const safeAreaTop = useSafeAreaTop() const safeAreaTop = useSafeAreaTop()
const { selectedDate } = useLocalSearchParams<{ selectedDate?: string }>(); const { selectedDate } = useLocalSearchParams<{ selectedDate?: string }>();
@@ -37,22 +40,14 @@ const WaterDetail: React.FC<WaterDetailProps> = () => {
const [dailyGoal, setDailyGoal] = useState<string>('2000'); const [dailyGoal, setDailyGoal] = useState<string>('2000');
const [quickAddAmount, setQuickAddAmount] = useState<string>('250'); const [quickAddAmount, setQuickAddAmount] = useState<string>('250');
// Remove modal states as they are now in separate settings page
// 使用新的 hook 来处理指定日期的饮水数据 // 使用新的 hook 来处理指定日期的饮水数据
const { waterRecords, dailyWaterGoal, updateWaterGoal, removeWaterRecord } = useWaterDataByDate(selectedDate); const { waterRecords, dailyWaterGoal, updateWaterGoal, removeWaterRecord } = useWaterDataByDate(selectedDate);
// 处理设置按钮点击 - 跳转到设置页面 // 处理设置按钮点击 - 跳转到设置页面
const handleSettingsPress = () => { const handleSettingsPress = () => {
router.push('/water/settings'); router.push('/water/settings');
}; };
// Remove all modal-related functions as they are now in separate settings page
// 删除饮水记录 // 删除饮水记录
const handleDeleteRecord = async (recordId: string) => { const handleDeleteRecord = async (recordId: string) => {
await removeWaterRecord(recordId); await removeWaterRecord(recordId);
@@ -70,13 +65,17 @@ const WaterDetail: React.FC<WaterDetailProps> = () => {
setDailyGoal(dailyWaterGoal.toString()); setDailyGoal(dailyWaterGoal.toString());
} }
} catch (error) { } catch (error) {
console.error('加载用户偏好设置失败:', error); console.error(t('waterDetail.loadingUserPreferences'), error);
} }
}; };
loadUserPreferences(); loadUserPreferences();
}, [dailyWaterGoal]); }, [dailyWaterGoal]);
const totalAmount = waterRecords?.reduce((sum, record) => sum + record.amount, 0) || 0;
const currentGoal = dailyWaterGoal || 2000;
const progress = Math.min(100, (totalAmount / currentGoal) * 100);
// 新增:饮水记录卡片组件 // 新增:饮水记录卡片组件
const WaterRecordCard = ({ record, onDelete }: { record: any; onDelete: () => void }) => { const WaterRecordCard = ({ record, onDelete }: { record: any; onDelete: () => void }) => {
const swipeableRef = React.useRef<Swipeable>(null); const swipeableRef = React.useRef<Swipeable>(null);
@@ -84,15 +83,15 @@ const WaterDetail: React.FC<WaterDetailProps> = () => {
// 处理删除操作 // 处理删除操作
const handleDelete = () => { const handleDelete = () => {
Alert.alert( Alert.alert(
'确认删除', t('waterDetail.deleteConfirm.title'),
'确定要删除这条饮水记录吗?此操作无法撤销。', t('waterDetail.deleteConfirm.message'),
[ [
{ {
text: '取消', text: t('waterDetail.deleteConfirm.cancel'),
style: 'cancel', style: 'cancel',
}, },
{ {
text: '删除', text: t('waterDetail.deleteConfirm.confirm'),
style: 'destructive', style: 'destructive',
onPress: () => { onPress: () => {
onDelete(); onDelete();
@@ -112,7 +111,6 @@ const WaterDetail: React.FC<WaterDetailProps> = () => {
activeOpacity={0.8} activeOpacity={0.8}
> >
<Ionicons name="trash" size={20} color="#FFFFFF" /> <Ionicons name="trash" size={20} color="#FFFFFF" />
<Text style={styles.deleteSwipeButtonText}></Text>
</TouchableOpacity> </TouchableOpacity>
); );
}; };
@@ -125,29 +123,29 @@ const WaterDetail: React.FC<WaterDetailProps> = () => {
rightThreshold={40} rightThreshold={40}
overshootRight={false} overshootRight={false}
> >
<View style={[styles.recordCard, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}> <View style={styles.recordCard}>
<View style={styles.recordMainContent}> <View style={styles.recordMainContent}>
<View style={[styles.recordIconContainer, { backgroundColor: colorTokens.background }]}> <View style={styles.recordIconContainer}>
<Image <Image
source={require('@/assets/images/icons/IconGlass.png')} source={require('@/assets/images/icons/IconGlass.png')}
style={styles.recordIcon} style={styles.recordIcon}
/> />
</View> </View>
<View style={styles.recordInfo}> <View style={styles.recordInfo}>
<Text style={[styles.recordLabel, { color: colorTokens.text }]}></Text> <Text style={styles.recordLabel}>{t('waterDetail.water')}</Text>
<View style={styles.recordTimeContainer}> <View style={styles.recordTimeContainer}>
<Ionicons name="time-outline" size={14} color={colorTokens.textSecondary} /> <Ionicons name="time-outline" size={14} color="#6f7ba7" />
<Text style={[styles.recordTimeText, { color: colorTokens.textSecondary }]}> <Text style={styles.recordTimeText}>
{dayjs(record.recordedAt || record.createdAt).format('HH:mm')} {dayjs(record.recordedAt || record.createdAt).format('HH:mm')}
</Text> </Text>
</View> </View>
</View> </View>
<View style={styles.recordAmountContainer}> <View style={styles.recordAmountContainer}>
<Text style={[styles.recordAmount, { color: colorTokens.text }]}>{record.amount}ml</Text> <Text style={styles.recordAmount}>{record.amount}ml</Text>
</View> </View>
</View> </View>
{record.note && ( {record.note && (
<Text style={[styles.recordNote, { color: colorTokens.textSecondary }]}>{record.note}</Text> <Text style={styles.recordNote}>{record.note}</Text>
)} )}
</View> </View>
</Swipeable> </Swipeable>
@@ -157,32 +155,47 @@ const WaterDetail: React.FC<WaterDetailProps> = () => {
return ( return (
<View style={styles.container}> <View style={styles.container}>
{/* 背景渐变 */} {/* 背景 */}
<LinearGradient <LinearGradient
colors={['#f5e5fbff', '#e5fcfeff', '#eefdffff', '#e6f6fcff']} colors={['#f3f4fb', '#f3f4fb']}
style={styles.gradientBackground} style={StyleSheet.absoluteFillObject}
start={{ x: 0, y: 0 }} />
end={{ x: 0, y: 1 }} {/* 顶部装饰性渐变 - 模仿挑战页面的柔和背景感 */}
<LinearGradient
colors={['rgba(229, 252, 254, 0.8)', 'rgba(243, 244, 251, 0)']}
style={styles.topGradient}
start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 1 }}
/> />
{/* 装饰性圆圈 */}
<View style={styles.decorativeCircle1} />
<View style={styles.decorativeCircle2} />
<HeaderBar <HeaderBar
title="饮水详情" title={t('waterDetail.title')}
onBack={() => { onBack={() => router.back()}
// 这里会通过路由自动处理返回
router.back();
}}
right={ right={
<TouchableOpacity isLiquidGlassAvailable() ? (
style={styles.settingsButton} <TouchableOpacity
onPress={handleSettingsPress} onPress={handleSettingsPress}
activeOpacity={0.7} activeOpacity={0.7}
> style={styles.settingsButtonWrapper}
<Ionicons name="settings-outline" size={24} color={colorTokens.text} /> >
</TouchableOpacity> <GlassView
style={styles.settingsButtonGlass}
glassEffectStyle="regular"
tintColor="rgba(255, 255, 255, 0.4)"
isInteractive={true}
>
<Ionicons name="settings-outline" size={22} color="#1c1f3a" />
</GlassView>
</TouchableOpacity>
) : (
<TouchableOpacity
style={styles.settingsButtonFallback}
onPress={handleSettingsPress}
activeOpacity={0.7}
>
<Ionicons name="settings-outline" size={22} color="#1c1f3a" />
</TouchableOpacity>
)
} }
/> />
@@ -197,13 +210,37 @@ const WaterDetail: React.FC<WaterDetailProps> = () => {
}]} }]}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
> >
<View style={styles.headerBlock}>
{/* 第二部分:饮水记录 */} <Text style={styles.pageTitle}>
<View style={styles.section}> {selectedDate ? dayjs(selectedDate).format('MM-DD') : t('waterDetail.today')}
<Text style={[styles.sectionTitle, { color: colorTokens.text }]}>
{selectedDate ? dayjs(selectedDate).format('MM月DD日') : '今日'}
</Text> </Text>
<Text style={styles.pageSubtitle}>{t('waterDetail.waterRecord')}</Text>
</View>
{/* 进度卡片 */}
<View style={styles.progressCard}>
<View style={styles.progressInfo}>
<View>
<Text style={styles.progressLabel}>{t('waterDetail.total')}</Text>
<Text style={styles.progressValue}>{totalAmount}<Text style={styles.progressUnit}>ml</Text></Text>
</View>
<View style={{ alignItems: 'flex-end' }}>
<Text style={styles.progressLabel}>{t('waterDetail.goal')}</Text>
<Text style={styles.progressGoalValue}>{currentGoal}<Text style={styles.progressUnit}>ml</Text></Text>
</View>
</View>
<View style={styles.progressBarBg}>
<LinearGradient
colors={['#4F5BD5', '#6B6CFF']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={[styles.progressBarFill, { width: `${progress}%` }]}
/>
</View>
</View>
{/* 记录列表 */}
<View style={styles.section}>
{waterRecords && waterRecords.length > 0 ? ( {waterRecords && waterRecords.length > 0 ? (
<View style={styles.recordsList}> <View style={styles.recordsList}>
{waterRecords.map((record) => ( {waterRecords.map((record) => (
@@ -213,29 +250,20 @@ const WaterDetail: React.FC<WaterDetailProps> = () => {
onDelete={() => handleDeleteRecord(record.id)} onDelete={() => handleDeleteRecord(record.id)}
/> />
))} ))}
{/* 总计显示 */}
<View style={[styles.recordsSummary, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}>
<Text style={[styles.summaryText, { color: colorTokens.text }]}>
{waterRecords.reduce((sum, record) => sum + record.amount, 0)}ml
</Text>
<Text style={[styles.summaryGoal, { color: colorTokens.textSecondary }]}>
{dailyWaterGoal}ml
</Text>
</View>
</View> </View>
) : ( ) : (
<View style={styles.noRecordsContainer}> <View style={styles.noRecordsContainer}>
<Ionicons name="water-outline" size={48} color={colorTokens.textSecondary} /> <Image
<Text style={[styles.noRecordsText, { color: colorTokens.textSecondary }]}></Text> source={require('@/assets/images/icons/IconGlass.png')}
<Text style={[styles.noRecordsSubText, { color: colorTokens.textSecondary }]}>&quot;&quot;</Text> style={{ width: 60, height: 60, opacity: 0.5, marginBottom: 16 }}
/>
<Text style={styles.noRecordsText}>{t('waterDetail.noRecords')}</Text>
<Text style={styles.noRecordsSubText}>{t('waterDetail.noRecordsSubtitle')}</Text>
</View> </View>
)} )}
</View> </View>
</ScrollView> </ScrollView>
</KeyboardAvoidingView> </KeyboardAvoidingView>
{/* All modals have been moved to the separate water-settings page */}
</View> </View>
); );
}; };
@@ -245,32 +273,12 @@ const styles = StyleSheet.create({
flex: 1, flex: 1,
backgroundColor: '#f3f4fb', backgroundColor: '#f3f4fb',
}, },
gradientBackground: { topGradient: {
position: 'absolute', position: 'absolute',
left: 0, left: 0,
right: 0, right: 0,
top: 0, top: 0,
bottom: 0, height: 300,
},
decorativeCircle1: {
position: 'absolute',
top: 80,
right: 30,
width: 80,
height: 80,
borderRadius: 40,
backgroundColor: '#4F5BD5',
opacity: 0.08,
},
decorativeCircle2: {
position: 'absolute',
bottom: 100,
left: -20,
width: 60,
height: 60,
borderRadius: 30,
backgroundColor: '#4F5BD5',
opacity: 0.06,
}, },
keyboardAvoidingView: { keyboardAvoidingView: {
flex: 1, flex: 1,
@@ -279,54 +287,107 @@ const styles = StyleSheet.create({
flex: 1, flex: 1,
}, },
scrollContent: { scrollContent: {
paddingBottom: 40,
},
headerBlock: {
paddingHorizontal: 24, paddingHorizontal: 24,
paddingTop: 20, marginTop: 10,
marginBottom: 24,
}, },
section: { pageTitle: {
marginBottom: 36, fontSize: 28,
fontWeight: '800',
color: '#1c1f3a',
fontFamily: 'AliBold',
marginBottom: 4,
}, },
sectionTitle: { pageSubtitle: {
fontSize: 16,
color: '#6f7ba7',
fontFamily: 'AliRegular',
},
// 进度卡片
progressCard: {
marginHorizontal: 24,
marginBottom: 32,
padding: 24,
borderRadius: 28,
backgroundColor: '#ffffff',
shadowColor: 'rgba(30, 41, 59, 0.1)',
shadowOffset: { width: 0, height: 10 },
shadowOpacity: 0.18,
shadowRadius: 20,
elevation: 8,
},
progressInfo: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-end',
marginBottom: 16,
},
progressLabel: {
fontSize: 14,
color: '#6f7ba7',
marginBottom: 6,
fontFamily: 'AliRegular',
},
progressValue: {
fontSize: 28,
fontWeight: '800',
color: '#4F5BD5',
fontFamily: 'AliBold',
lineHeight: 32,
},
progressGoalValue: {
fontSize: 20, fontSize: 20,
fontWeight: '700', fontWeight: '700',
marginBottom: 24,
letterSpacing: -0.5,
color: '#1c1f3a', color: '#1c1f3a',
fontFamily: 'AliBold',
lineHeight: 32,
}, },
subsectionTitle: { progressUnit: {
fontSize: 16, fontSize: 16,
fontWeight: '600', fontWeight: '600',
marginBottom: 16,
letterSpacing: -0.3,
color: '#1c1f3a',
},
sectionSubtitle: {
fontSize: 14,
fontWeight: '500',
lineHeight: 20,
color: '#6f7ba7', color: '#6f7ba7',
marginLeft: 2,
fontFamily: 'AliRegular',
}, },
// 饮水记录相关样式 progressBarBg: {
height: 12,
backgroundColor: '#F0F2F5',
borderRadius: 6,
overflow: 'hidden',
},
progressBarFill: {
height: '100%',
borderRadius: 6,
},
section: {
paddingHorizontal: 24,
},
// 记录列表样式
recordsList: { recordsList: {
gap: 16, gap: 16,
}, },
recordCardContainer: { recordCardContainer: {
// iOS 阴影效果 - 增强阴影效果 shadowColor: 'rgba(30, 41, 59, 0.08)',
shadowColor: 'rgba(30, 41, 59, 0.18)',
shadowOffset: { width: 0, height: 8 }, shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.16, shadowOpacity: 0.12,
shadowRadius: 16, shadowRadius: 12,
// Android 阴影效果 elevation: 4,
elevation: 6, marginBottom: 2,
}, },
recordCard: { recordCard: {
borderRadius: 20, borderRadius: 24,
padding: 18, padding: 18,
backgroundColor: '#ffffff', backgroundColor: '#ffffff',
}, },
recordMainContent: { recordMainContent: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between',
}, },
recordIconContainer: { recordIconContainer: {
width: 48, width: 48,
@@ -334,7 +395,7 @@ const styles = StyleSheet.create({
borderRadius: 16, borderRadius: 16,
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
backgroundColor: 'rgba(79, 91, 213, 0.08)', backgroundColor: '#f5f6ff',
}, },
recordIcon: { recordIcon: {
width: 24, width: 24,
@@ -345,15 +406,21 @@ const styles = StyleSheet.create({
marginLeft: 16, marginLeft: 16,
}, },
recordLabel: { recordLabel: {
fontSize: 17, fontSize: 16,
fontWeight: '700', fontWeight: '700',
color: '#1c1f3a', color: '#1c1f3a',
marginBottom: 6, marginBottom: 4,
fontFamily: 'AliBold',
}, },
recordTimeContainer: { recordTimeContainer: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
gap: 6, gap: 4,
},
recordTimeText: {
fontSize: 13,
color: '#6f7ba7',
fontFamily: 'AliRegular',
}, },
recordAmountContainer: { recordAmountContainer: {
alignItems: 'flex-end', alignItems: 'flex-end',
@@ -362,364 +429,74 @@ const styles = StyleSheet.create({
fontSize: 18, fontSize: 18,
fontWeight: '700', fontWeight: '700',
color: '#4F5BD5', color: '#4F5BD5',
}, fontFamily: 'AliBold',
deleteSwipeButton: {
backgroundColor: '#EF4444',
justifyContent: 'center',
alignItems: 'center',
width: 80,
borderRadius: 16,
marginLeft: 12,
},
deleteSwipeButtonText: {
color: '#FFFFFF',
fontSize: 12,
fontWeight: '600',
marginTop: 4,
},
recordTimeText: {
fontSize: 13,
fontWeight: '500',
color: '#6f7ba7',
}, },
recordNote: { recordNote: {
marginTop: 12, marginTop: 14,
padding: 12, padding: 12,
backgroundColor: 'rgba(79, 91, 213, 0.04)', backgroundColor: '#F8F9FC',
borderRadius: 12, borderRadius: 12,
fontSize: 14, fontSize: 13,
fontStyle: 'normal', lineHeight: 18,
lineHeight: 20,
color: '#5f6a97', color: '#5f6a97',
fontFamily: 'AliRegular',
}, },
recordsSummary: { deleteSwipeButton: {
marginTop: 24, backgroundColor: '#FF6B6B',
padding: 20, justifyContent: 'center',
borderRadius: 20,
backgroundColor: '#ffffff',
shadowColor: 'rgba(30, 41, 59, 0.12)',
shadowOpacity: 0.16,
shadowRadius: 18,
shadowOffset: { width: 0, height: 10 },
elevation: 6,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center', alignItems: 'center',
width: 70,
height: '100%',
borderRadius: 24,
marginLeft: 12,
}, },
summaryText: {
fontSize: 16,
fontWeight: '700',
color: '#1c1f3a',
},
summaryGoal: {
fontSize: 14,
fontWeight: '600',
color: '#6f7ba7',
},
noRecordsContainer: { noRecordsContainer: {
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
paddingVertical: 60, paddingVertical: 60,
gap: 20, backgroundColor: '#ffffff',
borderRadius: 28,
shadowColor: 'rgba(30, 41, 59, 0.06)',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.1,
shadowRadius: 12,
}, },
noRecordsText: { noRecordsText: {
fontSize: 17, fontSize: 16,
fontWeight: '600', fontWeight: '600',
lineHeight: 24, color: '#1c1f3a',
color: '#6f7ba7', marginBottom: 8,
fontFamily: 'AliBold',
}, },
noRecordsSubText: { noRecordsSubText: {
fontSize: 14, fontSize: 14,
textAlign: 'center',
lineHeight: 20,
color: '#9ba3c7', color: '#9ba3c7',
fontFamily: 'AliRegular',
}, },
modalBackdrop: {
...StyleSheet.absoluteFillObject, // Settings Button
backgroundColor: 'rgba(0,0,0,0.4)', settingsButtonWrapper: {
width: 40,
height: 40,
borderRadius: 20,
overflow: 'hidden',
}, },
modalSheet: { settingsButtonGlass: {
position: 'absolute', width: 40,
left: 0, height: 40,
right: 0,
bottom: 0,
padding: 16,
backgroundColor: '#FFFFFF',
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
// iOS 阴影效果
shadowColor: '#000000',
shadowOffset: { width: 0, height: -2 },
shadowOpacity: 0.1,
shadowRadius: 8,
// Android 阴影效果
elevation: 16,
},
modalHandle: {
width: 36,
height: 4,
backgroundColor: '#E0E0E0',
borderRadius: 2,
alignSelf: 'center',
marginBottom: 20,
},
modalTitle: {
fontSize: 20,
fontWeight: '600',
textAlign: 'center',
marginBottom: 20,
},
pickerContainer: {
height: 200,
marginBottom: 20,
},
picker: {
height: 200,
},
modalActions: {
flexDirection: 'row',
justifyContent: 'flex-end',
gap: 12,
},
modalBtn: {
paddingHorizontal: 14,
paddingVertical: 10,
borderRadius: 10,
minWidth: 80,
alignItems: 'center', alignItems: 'center',
justifyContent: 'center',
}, },
modalBtnPrimary: { settingsButtonFallback: {
// backgroundColor will be set dynamically
},
modalBtnText: {
fontSize: 16,
fontWeight: '600',
},
modalBtnTextPrimary: {
// color will be set dynamically
},
settingsButton: {
width: 40, width: 40,
height: 40, height: 40,
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
borderRadius: 20, borderRadius: 20,
backgroundColor: 'rgba(255, 255, 255, 0.24)', backgroundColor: '#ffffff',
borderWidth: 1, borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.45)', borderColor: 'rgba(0,0,0,0.05)',
},
settingsModalSheet: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
padding: 16,
backgroundColor: '#FFFFFF',
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
shadowColor: '#000000',
shadowOffset: { width: 0, height: -2 },
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 16,
},
settingsModalTitle: {
fontSize: 18,
fontWeight: '600',
textAlign: 'center',
marginBottom: 20,
},
settingsMenuContainer: {
backgroundColor: '#FFFFFF',
borderRadius: 12,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 4,
elevation: 2,
overflow: 'hidden',
},
settingsMenuItem: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 14,
paddingHorizontal: 16,
borderBottomWidth: 1,
borderBottomColor: '#F1F3F4',
},
settingsMenuItemLeft: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
},
settingsIconContainer: {
width: 32,
height: 32,
borderRadius: 6,
alignItems: 'center',
justifyContent: 'center',
marginRight: 12,
},
settingsMenuItemContent: {
flex: 1,
},
settingsMenuItemTitle: {
fontSize: 15,
fontWeight: '500',
marginBottom: 2,
},
settingsMenuItemSubtitle: {
fontSize: 12,
marginBottom: 4,
},
settingsMenuItemValue: {
fontSize: 14,
},
// 喝水提醒配置弹窗样式
waterReminderModalSheet: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
padding: 16,
backgroundColor: '#FFFFFF',
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
maxHeight: '80%',
shadowColor: '#000000',
shadowOffset: { width: 0, height: -2 },
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 16,
},
waterReminderContent: {
flex: 1,
marginBottom: 20,
},
waterReminderSection: {
marginBottom: 24,
},
waterReminderSectionHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 8,
},
waterReminderSectionTitleContainer: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
waterReminderSectionTitle: {
fontSize: 16,
fontWeight: '600',
},
waterReminderSectionDesc: {
fontSize: 14,
lineHeight: 20,
marginTop: 4,
},
timeRangeContainer: {
flexDirection: 'row',
gap: 16,
marginTop: 16,
},
timePickerContainer: {
flex: 1,
},
timeLabel: {
fontSize: 14,
fontWeight: '500',
marginBottom: 8,
},
timePicker: {
paddingVertical: 12,
paddingHorizontal: 16,
borderRadius: 8,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
},
timePickerText: {
fontSize: 16,
fontWeight: '500',
},
timePickerIcon: {
opacity: 0.6,
},
intervalContainer: {
marginTop: 16,
},
intervalPickerContainer: {
backgroundColor: '#F8F9FA',
borderRadius: 8,
overflow: 'hidden',
},
intervalPicker: {
height: 120,
},
// 时间选择器弹窗样式
timePickerModalSheet: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
padding: 16,
backgroundColor: '#FFFFFF',
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
maxHeight: '60%',
shadowColor: '#000000',
shadowOffset: { width: 0, height: -2 },
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 16,
},
timePickerContent: {
flex: 1,
marginBottom: 20,
},
timePickerSection: {
marginBottom: 20,
},
timePickerLabel: {
fontSize: 16,
fontWeight: '600',
marginBottom: 12,
textAlign: 'center',
},
hourPickerContainer: {
backgroundColor: '#F8F9FA',
borderRadius: 8,
overflow: 'hidden',
},
hourPicker: {
height: 160,
},
timeRangePreview: {
backgroundColor: '#F0F8FF',
borderRadius: 8,
padding: 16,
marginTop: 16,
alignItems: 'center',
},
timeRangePreviewLabel: {
fontSize: 12,
fontWeight: '500',
marginBottom: 4,
},
timeRangePreviewText: {
fontSize: 18,
fontWeight: '600',
marginBottom: 8,
},
timeRangeWarning: {
fontSize: 12,
color: '#FF6B6B',
textAlign: 'center',
lineHeight: 18,
}, },
}); });

View File

@@ -22,9 +22,11 @@ import {
} from 'react-native'; } from 'react-native';
import { HeaderBar } from '@/components/ui/HeaderBar'; import { HeaderBar } from '@/components/ui/HeaderBar';
import { useI18n } from '@/hooks/useI18n';
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding'; import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
const WaterReminderSettings: React.FC = () => { const WaterReminderSettings: React.FC = () => {
const { t } = useI18n();
const safeAreaTop = useSafeAreaTop() const safeAreaTop = useSafeAreaTop()
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark'; const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme]; const colorTokens = Colors[theme];
@@ -71,9 +73,9 @@ const WaterReminderSettings: React.FC = () => {
setStartTimePickerVisible(false); setStartTimePickerVisible(false);
} else { } else {
Alert.alert( Alert.alert(
'时间设置提示', t('waterReminderSettings.alerts.timeValidation.title'),
'开始时间不能晚于或等于结束时间,请重新选择', t('waterReminderSettings.alerts.timeValidation.startTimeInvalid'),
[{ text: '确定' }] [{ text: t('waterReminderSettings.buttons.confirm') }]
); );
} }
}; };
@@ -91,9 +93,9 @@ const WaterReminderSettings: React.FC = () => {
setEndTimePickerVisible(false); setEndTimePickerVisible(false);
} else { } else {
Alert.alert( Alert.alert(
'时间设置提示', t('waterReminderSettings.alerts.timeValidation.title'),
'结束时间不能早于或等于开始时间,请重新选择', t('waterReminderSettings.alerts.timeValidation.endTimeInvalid'),
[{ text: '确定' }] [{ text: t('waterReminderSettings.buttons.confirm') }]
); );
} }
}; };
@@ -125,18 +127,28 @@ const WaterReminderSettings: React.FC = () => {
if (waterReminderSettings.enabled) { if (waterReminderSettings.enabled) {
const timeInfo = `${waterReminderSettings.startTime}-${waterReminderSettings.endTime}`; const timeInfo = `${waterReminderSettings.startTime}-${waterReminderSettings.endTime}`;
const intervalInfo = `${waterReminderSettings.interval}分钟`; const intervalInfo = `${waterReminderSettings.interval}${t('waterReminderSettings.labels.minutes')}`;
Alert.alert( Alert.alert(
'设置成功', t('waterReminderSettings.alerts.success.enabled'),
`喝水提醒已开启\n\n时间段${timeInfo}\n提醒间隔${intervalInfo}\n\n我们将在指定时间段内定期提醒您喝水`, t('waterReminderSettings.alerts.success.enabledMessage', {
[{ text: '确定', onPress: () => router.back() }] timeRange: timeInfo,
interval: intervalInfo
}),
[{ text: t('waterReminderSettings.buttons.confirm'), onPress: () => router.back() }]
); );
} else { } else {
Alert.alert('设置成功', '喝水提醒已关闭', [{ text: '确定', onPress: () => router.back() }]); Alert.alert(
t('waterReminderSettings.alerts.success.disabled'),
t('waterReminderSettings.alerts.success.disabledMessage'),
[{ text: t('waterReminderSettings.buttons.confirm'), onPress: () => router.back() }]
);
} }
} catch (error) { } catch (error) {
console.error('保存喝水提醒设置失败:', error); console.error('保存喝水提醒设置失败:', error);
Alert.alert('保存失败', '无法保存喝水提醒设置,请重试'); Alert.alert(
t('waterReminderSettings.alerts.error.title'),
t('waterReminderSettings.alerts.error.message')
);
} }
}; };
@@ -176,7 +188,7 @@ const WaterReminderSettings: React.FC = () => {
<View style={styles.decorativeCircle2} /> <View style={styles.decorativeCircle2} />
<HeaderBar <HeaderBar
title="喝水提醒" title={t('waterReminderSettings.title')}
onBack={() => { onBack={() => {
router.back(); router.back();
}} }}
@@ -198,7 +210,7 @@ const WaterReminderSettings: React.FC = () => {
<View style={styles.waterReminderSectionHeader}> <View style={styles.waterReminderSectionHeader}>
<View style={styles.waterReminderSectionTitleContainer}> <View style={styles.waterReminderSectionTitleContainer}>
<Ionicons name="notifications-outline" size={20} color={colorTokens.text} /> <Ionicons name="notifications-outline" size={20} color={colorTokens.text} />
<Text style={[styles.waterReminderSectionTitle, { color: colorTokens.text }]}></Text> <Text style={[styles.waterReminderSectionTitle, { color: colorTokens.text }]}>{t('waterReminderSettings.sections.notifications')}</Text>
</View> </View>
<Switch <Switch
value={waterReminderSettings.enabled} value={waterReminderSettings.enabled}
@@ -208,7 +220,7 @@ const WaterReminderSettings: React.FC = () => {
/> />
</View> </View>
<Text style={[styles.waterReminderSectionDesc, { color: colorTokens.textSecondary }]}> <Text style={[styles.waterReminderSectionDesc, { color: colorTokens.textSecondary }]}>
{t('waterReminderSettings.descriptions.notifications')}
</Text> </Text>
</View> </View>
@@ -216,15 +228,15 @@ const WaterReminderSettings: React.FC = () => {
{waterReminderSettings.enabled && ( {waterReminderSettings.enabled && (
<> <>
<View style={styles.waterReminderSection}> <View style={styles.waterReminderSection}>
<Text style={[styles.waterReminderSectionTitle, { color: colorTokens.text }]}></Text> <Text style={[styles.waterReminderSectionTitle, { color: colorTokens.text }]}>{t('waterReminderSettings.sections.timeRange')}</Text>
<Text style={[styles.waterReminderSectionDesc, { color: colorTokens.textSecondary }]}> <Text style={[styles.waterReminderSectionDesc, { color: colorTokens.textSecondary }]}>
{t('waterReminderSettings.descriptions.timeRange')}
</Text> </Text>
<View style={styles.timeRangeContainer}> <View style={styles.timeRangeContainer}>
{/* 开始时间 */} {/* 开始时间 */}
<View style={styles.timePickerContainer}> <View style={styles.timePickerContainer}>
<Text style={[styles.timeLabel, { color: colorTokens.text }]}></Text> <Text style={[styles.timeLabel, { color: colorTokens.text }]}>{t('waterReminderSettings.labels.startTime')}</Text>
<Pressable <Pressable
style={[styles.timePicker, { backgroundColor: 'white' }]} style={[styles.timePicker, { backgroundColor: 'white' }]}
onPress={openStartTimePicker} onPress={openStartTimePicker}
@@ -236,7 +248,7 @@ const WaterReminderSettings: React.FC = () => {
{/* 结束时间 */} {/* 结束时间 */}
<View style={styles.timePickerContainer}> <View style={styles.timePickerContainer}>
<Text style={[styles.timeLabel, { color: colorTokens.text }]}></Text> <Text style={[styles.timeLabel, { color: colorTokens.text }]}>{t('waterReminderSettings.labels.endTime')}</Text>
<Pressable <Pressable
style={[styles.timePicker, { backgroundColor: 'white' }]} style={[styles.timePicker, { backgroundColor: 'white' }]}
onPress={openEndTimePicker} onPress={openEndTimePicker}
@@ -250,9 +262,9 @@ const WaterReminderSettings: React.FC = () => {
{/* 提醒间隔设置 */} {/* 提醒间隔设置 */}
<View style={styles.waterReminderSection}> <View style={styles.waterReminderSection}>
<Text style={[styles.waterReminderSectionTitle, { color: colorTokens.text }]}></Text> <Text style={[styles.waterReminderSectionTitle, { color: colorTokens.text }]}>{t('waterReminderSettings.sections.interval')}</Text>
<Text style={[styles.waterReminderSectionDesc, { color: colorTokens.textSecondary }]}> <Text style={[styles.waterReminderSectionDesc, { color: colorTokens.textSecondary }]}>
30-120 {t('waterReminderSettings.descriptions.interval')}
</Text> </Text>
<View style={styles.intervalContainer}> <View style={styles.intervalContainer}>
@@ -263,7 +275,7 @@ const WaterReminderSettings: React.FC = () => {
style={styles.intervalPicker} style={styles.intervalPicker}
> >
{[30, 45, 60, 90, 120, 150, 180].map(interval => ( {[30, 45, 60, 90, 120, 150, 180].map(interval => (
<Picker.Item key={interval} label={`${interval}分钟`} value={interval} /> <Picker.Item key={interval} label={`${interval}${t('waterReminderSettings.labels.minutes')}`} value={interval} />
))} ))}
</Picker> </Picker>
</View> </View>
@@ -279,7 +291,7 @@ const WaterReminderSettings: React.FC = () => {
onPress={handleWaterReminderSave} onPress={handleWaterReminderSave}
activeOpacity={0.8} activeOpacity={0.8}
> >
<Text style={[styles.saveButtonText, { color: colorTokens.onPrimary }]}></Text> <Text style={[styles.saveButtonText, { color: colorTokens.onPrimary }]}>{t('waterReminderSettings.labels.saveSettings')}</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</ScrollView> </ScrollView>
@@ -295,11 +307,11 @@ const WaterReminderSettings: React.FC = () => {
<Pressable style={styles.modalBackdrop} onPress={() => setStartTimePickerVisible(false)} /> <Pressable style={styles.modalBackdrop} onPress={() => setStartTimePickerVisible(false)} />
<View style={styles.timePickerModalSheet}> <View style={styles.timePickerModalSheet}>
<View style={styles.modalHandle} /> <View style={styles.modalHandle} />
<Text style={[styles.modalTitle, { color: colorTokens.text }]}></Text> <Text style={[styles.modalTitle, { color: colorTokens.text }]}>{t('waterReminderSettings.labels.startTime')}</Text>
<View style={styles.timePickerContent}> <View style={styles.timePickerContent}>
<View style={styles.timePickerSection}> <View style={styles.timePickerSection}>
<Text style={[styles.timePickerLabel, { color: colorTokens.text }]}></Text> <Text style={[styles.timePickerLabel, { color: colorTokens.text }]}>{t('waterReminderSettings.labels.hours')}</Text>
<View style={styles.hourPickerContainer}> <View style={styles.hourPickerContainer}>
<Picker <Picker
selectedValue={tempStartHour} selectedValue={tempStartHour}
@@ -314,12 +326,12 @@ const WaterReminderSettings: React.FC = () => {
</View> </View>
<View style={styles.timeRangePreview}> <View style={styles.timeRangePreview}>
<Text style={[styles.timeRangePreviewLabel, { color: colorTokens.textSecondary }]}></Text> <Text style={[styles.timeRangePreviewLabel, { color: colorTokens.textSecondary }]}>{t('waterReminderSettings.labels.timeRangePreview')}</Text>
<Text style={[styles.timeRangePreviewText, { color: colorTokens.text }]}> <Text style={[styles.timeRangePreviewText, { color: colorTokens.text }]}>
{String(tempStartHour).padStart(2, '0')}:00 - {waterReminderSettings.endTime} {String(tempStartHour).padStart(2, '0')}:00 - {waterReminderSettings.endTime}
</Text> </Text>
{!isValidTimeRange(`${String(tempStartHour).padStart(2, '0')}:00`, waterReminderSettings.endTime) && ( {!isValidTimeRange(`${String(tempStartHour).padStart(2, '0')}:00`, waterReminderSettings.endTime) && (
<Text style={styles.timeRangeWarning}> </Text> <Text style={styles.timeRangeWarning}> {t('waterReminderSettings.alerts.timeValidation.startTimeInvalid')}</Text>
)} )}
</View> </View>
</View> </View>
@@ -329,13 +341,13 @@ const WaterReminderSettings: React.FC = () => {
onPress={() => setStartTimePickerVisible(false)} onPress={() => setStartTimePickerVisible(false)}
style={[styles.modalBtn, { backgroundColor: 'white' }]} style={[styles.modalBtn, { backgroundColor: 'white' }]}
> >
<Text style={[styles.modalBtnText, { color: colorTokens.text }]}></Text> <Text style={[styles.modalBtnText, { color: colorTokens.text }]}>{t('waterReminderSettings.buttons.cancel')}</Text>
</Pressable> </Pressable>
<Pressable <Pressable
onPress={confirmStartTime} onPress={confirmStartTime}
style={[styles.modalBtn, styles.modalBtnPrimary, { backgroundColor: colorTokens.primary }]} style={[styles.modalBtn, styles.modalBtnPrimary, { backgroundColor: colorTokens.primary }]}
> >
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary, { color: colorTokens.onPrimary }]}></Text> <Text style={[styles.modalBtnText, styles.modalBtnTextPrimary, { color: colorTokens.onPrimary }]}>{t('waterReminderSettings.buttons.confirm')}</Text>
</Pressable> </Pressable>
</View> </View>
</View> </View>
@@ -351,11 +363,11 @@ const WaterReminderSettings: React.FC = () => {
<Pressable style={styles.modalBackdrop} onPress={() => setEndTimePickerVisible(false)} /> <Pressable style={styles.modalBackdrop} onPress={() => setEndTimePickerVisible(false)} />
<View style={styles.timePickerModalSheet}> <View style={styles.timePickerModalSheet}>
<View style={styles.modalHandle} /> <View style={styles.modalHandle} />
<Text style={[styles.modalTitle, { color: colorTokens.text }]}></Text> <Text style={[styles.modalTitle, { color: colorTokens.text }]}>{t('waterReminderSettings.labels.endTime')}</Text>
<View style={styles.timePickerContent}> <View style={styles.timePickerContent}>
<View style={styles.timePickerSection}> <View style={styles.timePickerSection}>
<Text style={[styles.timePickerLabel, { color: colorTokens.text }]}></Text> <Text style={[styles.timePickerLabel, { color: colorTokens.text }]}>{t('waterReminderSettings.labels.hours')}</Text>
<View style={styles.hourPickerContainer}> <View style={styles.hourPickerContainer}>
<Picker <Picker
selectedValue={tempEndHour} selectedValue={tempEndHour}
@@ -370,12 +382,12 @@ const WaterReminderSettings: React.FC = () => {
</View> </View>
<View style={styles.timeRangePreview}> <View style={styles.timeRangePreview}>
<Text style={[styles.timeRangePreviewLabel, { color: colorTokens.textSecondary }]}></Text> <Text style={[styles.timeRangePreviewLabel, { color: colorTokens.textSecondary }]}>{t('waterReminderSettings.labels.timeRangePreview')}</Text>
<Text style={[styles.timeRangePreviewText, { color: colorTokens.text }]}> <Text style={[styles.timeRangePreviewText, { color: colorTokens.text }]}>
{waterReminderSettings.startTime} - {String(tempEndHour).padStart(2, '0')}:00 {waterReminderSettings.startTime} - {String(tempEndHour).padStart(2, '0')}:00
</Text> </Text>
{!isValidTimeRange(waterReminderSettings.startTime, `${String(tempEndHour).padStart(2, '0')}:00`) && ( {!isValidTimeRange(waterReminderSettings.startTime, `${String(tempEndHour).padStart(2, '0')}:00`) && (
<Text style={styles.timeRangeWarning}> </Text> <Text style={styles.timeRangeWarning}> {t('waterReminderSettings.alerts.timeValidation.endTimeInvalid')}</Text>
)} )}
</View> </View>
</View> </View>
@@ -385,13 +397,13 @@ const WaterReminderSettings: React.FC = () => {
onPress={() => setEndTimePickerVisible(false)} onPress={() => setEndTimePickerVisible(false)}
style={[styles.modalBtn, { backgroundColor: 'white' }]} style={[styles.modalBtn, { backgroundColor: 'white' }]}
> >
<Text style={[styles.modalBtnText, { color: colorTokens.text }]}></Text> <Text style={[styles.modalBtnText, { color: colorTokens.text }]}>{t('waterReminderSettings.buttons.cancel')}</Text>
</Pressable> </Pressable>
<Pressable <Pressable
onPress={confirmEndTime} onPress={confirmEndTime}
style={[styles.modalBtn, styles.modalBtnPrimary, { backgroundColor: colorTokens.primary }]} style={[styles.modalBtn, styles.modalBtnPrimary, { backgroundColor: colorTokens.primary }]}
> >
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary, { color: colorTokens.onPrimary }]}></Text> <Text style={[styles.modalBtnText, styles.modalBtnTextPrimary, { color: colorTokens.onPrimary }]}>{t('waterReminderSettings.buttons.confirm')}</Text>
</Pressable> </Pressable>
</View> </View>
</View> </View>

View File

@@ -21,9 +21,11 @@ import {
} from 'react-native'; } from 'react-native';
import { HeaderBar } from '@/components/ui/HeaderBar'; import { HeaderBar } from '@/components/ui/HeaderBar';
import { useI18n } from '@/hooks/useI18n';
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding'; import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
const WaterSettings: React.FC = () => { const WaterSettings: React.FC = () => {
const { t } = useI18n();
const safeAreaTop = useSafeAreaTop() const safeAreaTop = useSafeAreaTop()
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark'; const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme]; const colorTokens = Colors[theme];
@@ -74,7 +76,10 @@ const WaterSettings: React.FC = () => {
setGoalModalVisible(false); setGoalModalVisible(false);
// 这里可以添加保存到本地存储或发送到后端的逻辑 // 这里可以添加保存到本地存储或发送到后端的逻辑
Alert.alert('设置成功', `每日饮水目标已设置为 ${tempGoal}ml`); Alert.alert(
t('waterSettings.alerts.goalSuccess.title'),
t('waterSettings.alerts.goalSuccess.message', { amount: tempGoal })
);
}; };
// 处理快速添加默认值确认 // 处理快速添加默认值确认
@@ -84,9 +89,15 @@ const WaterSettings: React.FC = () => {
try { try {
await setQuickWaterAmount(tempQuickAdd); await setQuickWaterAmount(tempQuickAdd);
Alert.alert('设置成功', `快速添加默认值已设置为 ${tempQuickAdd}ml`); Alert.alert(
t('waterSettings.alerts.quickAddSuccess.title'),
t('waterSettings.alerts.quickAddSuccess.message', { amount: tempQuickAdd })
);
} catch { } catch {
Alert.alert('设置失败', '无法保存快速添加默认值,请重试'); Alert.alert(
t('waterSettings.alerts.quickAddFailed.title'),
t('waterSettings.alerts.quickAddFailed.message')
);
} }
}; };
@@ -101,7 +112,7 @@ const WaterSettings: React.FC = () => {
const reminderSettings = await getWaterReminderSettings(); const reminderSettings = await getWaterReminderSettings();
setWaterReminderSettings(reminderSettings); setWaterReminderSettings(reminderSettings);
} catch (error) { } catch (error) {
console.error('加载用户偏好设置失败:', error); console.error('Failed to load user preferences:', error);
} }
}, []); }, []);
@@ -132,7 +143,7 @@ const WaterSettings: React.FC = () => {
<View style={styles.decorativeCircle2} /> <View style={styles.decorativeCircle2} />
<HeaderBar <HeaderBar
title="饮水设置" title={t('waterSettings.title')}
onBack={() => { onBack={() => {
router.back(); router.back();
}} }}
@@ -156,8 +167,8 @@ const WaterSettings: React.FC = () => {
<Ionicons name="flag-outline" size={20} color="#9370DB" /> <Ionicons name="flag-outline" size={20} color="#9370DB" />
</View> </View>
<View style={styles.settingsMenuItemContent}> <View style={styles.settingsMenuItemContent}>
<Text style={[styles.settingsMenuItemTitle, { color: colorTokens.text }]}></Text> <Text style={[styles.settingsMenuItemTitle, { color: colorTokens.text }]}>{t('waterSettings.sections.dailyGoal')}</Text>
<Text style={[styles.settingsMenuItemValue, { color: colorTokens.textSecondary }]}>{currentWaterGoal}ml</Text> <Text style={[styles.settingsMenuItemValue, { color: colorTokens.textSecondary }]}>{currentWaterGoal}{t('waterSettings.labels.ml')}</Text>
</View> </View>
</View> </View>
<Ionicons name="chevron-forward" size={20} color="#CCCCCC" /> <Ionicons name="chevron-forward" size={20} color="#CCCCCC" />
@@ -169,11 +180,11 @@ const WaterSettings: React.FC = () => {
<Ionicons name="add-outline" size={20} color="#9370DB" /> <Ionicons name="add-outline" size={20} color="#9370DB" />
</View> </View>
<View style={styles.settingsMenuItemContent}> <View style={styles.settingsMenuItemContent}>
<Text style={[styles.settingsMenuItemTitle, { color: colorTokens.text }]}></Text> <Text style={[styles.settingsMenuItemTitle, { color: colorTokens.text }]}>{t('waterSettings.sections.quickAdd')}</Text>
<Text style={[styles.settingsMenuItemSubtitle, { color: colorTokens.textSecondary }]}> <Text style={[styles.settingsMenuItemSubtitle, { color: colorTokens.textSecondary }]}>
"+" {t('waterSettings.descriptions.quickAdd')}
</Text> </Text>
<Text style={[styles.settingsMenuItemValue, { color: colorTokens.textSecondary }]}>{quickAddAmount}ml</Text> <Text style={[styles.settingsMenuItemValue, { color: colorTokens.textSecondary }]}>{quickAddAmount}{t('waterSettings.labels.ml')}</Text>
</View> </View>
</View> </View>
<Ionicons name="chevron-forward" size={20} color="#CCCCCC" /> <Ionicons name="chevron-forward" size={20} color="#CCCCCC" />
@@ -185,12 +196,19 @@ const WaterSettings: React.FC = () => {
<Ionicons name="notifications-outline" size={20} color="#3498DB" /> <Ionicons name="notifications-outline" size={20} color="#3498DB" />
</View> </View>
<View style={styles.settingsMenuItemContent}> <View style={styles.settingsMenuItemContent}>
<Text style={[styles.settingsMenuItemTitle, { color: colorTokens.text }]}></Text> <Text style={[styles.settingsMenuItemTitle, { color: colorTokens.text }]}>{t('waterSettings.sections.reminder')}</Text>
<Text style={[styles.settingsMenuItemSubtitle, { color: colorTokens.textSecondary }]}> <Text style={[styles.settingsMenuItemSubtitle, { color: colorTokens.textSecondary }]}>
{t('waterSettings.descriptions.reminder')}
</Text> </Text>
<Text style={[styles.settingsMenuItemValue, { color: colorTokens.textSecondary }]}> <Text style={[styles.settingsMenuItemValue, { color: colorTokens.textSecondary }]}>
{waterReminderSettings.enabled ? `${waterReminderSettings.startTime}-${waterReminderSettings.endTime}, 每${waterReminderSettings.interval}分钟` : '已关闭'} {waterReminderSettings.enabled
? t('waterSettings.status.reminderEnabled', {
startTime: waterReminderSettings.startTime,
endTime: waterReminderSettings.endTime,
interval: waterReminderSettings.interval
})
: t('waterSettings.labels.disabled')
}
</Text> </Text>
</View> </View>
</View> </View>
@@ -211,7 +229,7 @@ const WaterSettings: React.FC = () => {
<Pressable style={styles.modalBackdrop} onPress={() => setGoalModalVisible(false)} /> <Pressable style={styles.modalBackdrop} onPress={() => setGoalModalVisible(false)} />
<View style={styles.modalSheet}> <View style={styles.modalSheet}>
<View style={styles.modalHandle} /> <View style={styles.modalHandle} />
<Text style={[styles.modalTitle, { color: colorTokens.text }]}></Text> <Text style={[styles.modalTitle, { color: colorTokens.text }]}>{t('waterSettings.sections.dailyGoal')}</Text>
<View style={styles.pickerContainer}> <View style={styles.pickerContainer}>
<Picker <Picker
selectedValue={tempGoal} selectedValue={tempGoal}
@@ -219,7 +237,7 @@ const WaterSettings: React.FC = () => {
style={styles.picker} style={styles.picker}
> >
{Array.from({ length: 96 }, (_, i) => 500 + i * 100).map(goal => ( {Array.from({ length: 96 }, (_, i) => 500 + i * 100).map(goal => (
<Picker.Item key={goal} label={`${goal}ml`} value={goal} /> <Picker.Item key={goal} label={`${goal}${t('waterSettings.labels.ml')}`} value={goal} />
))} ))}
</Picker> </Picker>
</View> </View>
@@ -228,13 +246,13 @@ const WaterSettings: React.FC = () => {
onPress={() => setGoalModalVisible(false)} onPress={() => setGoalModalVisible(false)}
style={[styles.modalBtn, { backgroundColor: colorTokens.pageBackgroundEmphasis }]} style={[styles.modalBtn, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}
> >
<Text style={[styles.modalBtnText, { color: colorTokens.text }]}></Text> <Text style={[styles.modalBtnText, { color: colorTokens.text }]}>{t('waterSettings.buttons.cancel')}</Text>
</Pressable> </Pressable>
<Pressable <Pressable
onPress={handleGoalConfirm} onPress={handleGoalConfirm}
style={[styles.modalBtn, styles.modalBtnPrimary, { backgroundColor: colorTokens.primary }]} style={[styles.modalBtn, styles.modalBtnPrimary, { backgroundColor: colorTokens.primary }]}
> >
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary, { color: colorTokens.onPrimary }]}></Text> <Text style={[styles.modalBtnText, styles.modalBtnTextPrimary, { color: colorTokens.onPrimary }]}>{t('waterSettings.buttons.confirm')}</Text>
</Pressable> </Pressable>
</View> </View>
</View> </View>
@@ -250,7 +268,7 @@ const WaterSettings: React.FC = () => {
<Pressable style={styles.modalBackdrop} onPress={() => setQuickAddModalVisible(false)} /> <Pressable style={styles.modalBackdrop} onPress={() => setQuickAddModalVisible(false)} />
<View style={styles.modalSheet}> <View style={styles.modalSheet}>
<View style={styles.modalHandle} /> <View style={styles.modalHandle} />
<Text style={[styles.modalTitle, { color: colorTokens.text }]}></Text> <Text style={[styles.modalTitle, { color: colorTokens.text }]}>{t('waterSettings.sections.quickAdd')}</Text>
<View style={styles.pickerContainer}> <View style={styles.pickerContainer}>
<Picker <Picker
selectedValue={tempQuickAdd} selectedValue={tempQuickAdd}
@@ -258,7 +276,7 @@ const WaterSettings: React.FC = () => {
style={styles.picker} style={styles.picker}
> >
{Array.from({ length: 41 }, (_, i) => 50 + i * 10).map(amount => ( {Array.from({ length: 41 }, (_, i) => 50 + i * 10).map(amount => (
<Picker.Item key={amount} label={`${amount}ml`} value={amount} /> <Picker.Item key={amount} label={`${amount}${t('waterSettings.labels.ml')}`} value={amount} />
))} ))}
</Picker> </Picker>
</View> </View>
@@ -267,13 +285,13 @@ const WaterSettings: React.FC = () => {
onPress={() => setQuickAddModalVisible(false)} onPress={() => setQuickAddModalVisible(false)}
style={[styles.modalBtn, { backgroundColor: colorTokens.pageBackgroundEmphasis }]} style={[styles.modalBtn, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}
> >
<Text style={[styles.modalBtnText, { color: colorTokens.text }]}></Text> <Text style={[styles.modalBtnText, { color: colorTokens.text }]}>{t('waterSettings.buttons.cancel')}</Text>
</Pressable> </Pressable>
<Pressable <Pressable
onPress={handleQuickAddConfirm} onPress={handleQuickAddConfirm}
style={[styles.modalBtn, styles.modalBtnPrimary, { backgroundColor: colorTokens.primary }]} style={[styles.modalBtn, styles.modalBtnPrimary, { backgroundColor: colorTokens.primary }]}
> >
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary, { color: colorTokens.onPrimary }]}></Text> <Text style={[styles.modalBtnText, styles.modalBtnTextPrimary, { color: colorTokens.onPrimary }]}>{t('waterSettings.buttons.confirm')}</Text>
</Pressable> </Pressable>
</View> </View>
</View> </View>

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,7 @@ import { useFocusEffect } from '@react-navigation/native';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import isBetween from 'dayjs/plugin/isBetween'; import isBetween from 'dayjs/plugin/isBetween';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import { useLocalSearchParams } from 'expo-router';
import React, { useCallback, useMemo, useState } from 'react'; import React, { useCallback, useMemo, useState } from 'react';
import { import {
ActivityIndicator, ActivityIndicator,
@@ -16,6 +17,7 @@ import {
import { HeaderBar } from '@/components/ui/HeaderBar'; import { HeaderBar } from '@/components/ui/HeaderBar';
import { IntensityBadge, WorkoutDetailModal } from '@/components/workout/WorkoutDetailModal'; import { IntensityBadge, WorkoutDetailModal } from '@/components/workout/WorkoutDetailModal';
import { useI18n } from '@/hooks/useI18n';
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding'; import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
import { getWorkoutDetailMetrics, WorkoutDetailMetrics } from '@/services/workoutDetail'; import { getWorkoutDetailMetrics, WorkoutDetailMetrics } from '@/services/workoutDetail';
import { import {
@@ -233,23 +235,23 @@ function computeMonthlyStats(workouts: WorkoutData[]): MonthlyStatsInfo | null {
}; };
} }
function getIntensityBadge(totalCalories?: number, durationInSeconds?: number) { function getIntensityBadge(t: (key: string, options?: any) => string, totalCalories?: number, durationInSeconds?: number): { label: string; color: string; background: string } {
if (!totalCalories || !durationInSeconds) { if (!totalCalories || !durationInSeconds) {
return { label: '低强度', color: '#7C85A3', background: '#E4E7F2' }; return { label: t('workoutHistory.intensity.low'), color: '#7C85A3', background: '#E4E7F2' };
} }
const minutes = Math.max(durationInSeconds / 60, 1); const minutes = Math.max(durationInSeconds / 60, 1);
const caloriesPerMinute = totalCalories / minutes; const caloriesPerMinute = totalCalories / minutes;
if (caloriesPerMinute >= 9) { if (caloriesPerMinute >= 9) {
return { label: '高强度', color: '#F85959', background: '#FFE6E6' }; return { label: t('workoutHistory.intensity.high'), color: '#F85959', background: '#FFE6E6' };
} }
if (caloriesPerMinute >= 5) { if (caloriesPerMinute >= 5) {
return { label: '中强度', color: '#0EAF71', background: '#E4F6EF' }; return { label: t('workoutHistory.intensity.medium'), color: '#0EAF71', background: '#E4F6EF' };
} }
return { label: '低强度', color: '#5966FF', background: '#E7EBFF' }; return { label: t('workoutHistory.intensity.low'), color: '#5966FF', background: '#E7EBFF' };
} }
function groupWorkouts(workouts: WorkoutData[]): WorkoutSection[] { function groupWorkouts(workouts: WorkoutData[]): WorkoutSection[] {
@@ -265,13 +267,15 @@ function groupWorkouts(workouts: WorkoutData[]): WorkoutSection[] {
return Object.keys(grouped) return Object.keys(grouped)
.sort((a, b) => dayjs(b).valueOf() - dayjs(a).valueOf()) .sort((a, b) => dayjs(b).valueOf() - dayjs(a).valueOf())
.map((dateKey) => ({ .map((dateKey) => ({
title: dayjs(dateKey).format('M月D日'), title: dayjs(dateKey).format('M月D日'), // 保持中文格式,因为这是日期格式
data: grouped[dateKey] data: grouped[dateKey]
.sort((a, b) => dayjs(b.startDate || b.endDate).valueOf() - dayjs(a.startDate || a.endDate).valueOf()), .sort((a, b) => dayjs(b.startDate || b.endDate).valueOf() - dayjs(a.startDate || a.endDate).valueOf()),
})); }));
} }
export default function WorkoutHistoryScreen() { export default function WorkoutHistoryScreen() {
const { t } = useI18n();
const { workoutId: workoutIdParam } = useLocalSearchParams<{ workoutId?: string | string[] }>();
const [sections, setSections] = useState<WorkoutSection[]>([]); const [sections, setSections] = useState<WorkoutSection[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -283,8 +287,19 @@ export default function WorkoutHistoryScreen() {
const [selectedIntensity, setSelectedIntensity] = useState<IntensityBadge | null>(null); const [selectedIntensity, setSelectedIntensity] = useState<IntensityBadge | null>(null);
const [monthOccurrenceText, setMonthOccurrenceText] = useState<string | null>(null); const [monthOccurrenceText, setMonthOccurrenceText] = useState<string | null>(null);
const [monthlyStats, setMonthlyStats] = useState<MonthlyStatsInfo | null>(null); const [monthlyStats, setMonthlyStats] = useState<MonthlyStatsInfo | null>(null);
const [pendingWorkoutId, setPendingWorkoutId] = useState<string | null>(null);
const safeAreaTop = useSafeAreaTop() const safeAreaTop = useSafeAreaTop();
React.useEffect(() => {
if (!workoutIdParam) {
return;
}
const idParam = Array.isArray(workoutIdParam) ? workoutIdParam[0] : workoutIdParam;
if (idParam) {
setPendingWorkoutId(idParam);
}
}, [workoutIdParam]);
const loadHistory = useCallback(async () => { const loadHistory = useCallback(async () => {
setIsLoading(true); setIsLoading(true);
@@ -302,7 +317,7 @@ export default function WorkoutHistoryScreen() {
if (!hasPermission) { if (!hasPermission) {
setSections([]); setSections([]);
setError('尚未授予健康数据权限'); setError(t('workoutHistory.error.permissionDenied'));
setMonthlyStats(null); setMonthlyStats(null);
return; return;
} }
@@ -315,8 +330,8 @@ export default function WorkoutHistoryScreen() {
setMonthlyStats(computeMonthlyStats(filteredWorkouts)); setMonthlyStats(computeMonthlyStats(filteredWorkouts));
setSections(groupWorkouts(filteredWorkouts)); setSections(groupWorkouts(filteredWorkouts));
} catch (err) { } catch (err) {
console.error('加载锻炼历史失败:', err); console.error('Failed to load workout history:', err);
setError('加载锻炼记录失败,请稍后再试'); setError(t('workoutHistory.error.loadFailed'));
setSections([]); setSections([]);
setMonthlyStats(null); setMonthlyStats(null);
} finally { } finally {
@@ -350,9 +365,9 @@ export default function WorkoutHistoryScreen() {
? dayjs(monthlyStats.snapshotDate).format('M月D日') ? dayjs(monthlyStats.snapshotDate).format('M月D日')
: dayjs().format('M月D日'); : dayjs().format('M月D日');
const overviewText = monthlyStats const overviewText = monthlyStats
? `截至${snapshotLabel},你已完成${monthlyStats.totalCount}次锻炼,累计${formatDurationShort(monthlyStats.totalDuration)}` ? t('workoutHistory.monthlyStats.overviewWithStats', { date: snapshotLabel, count: monthlyStats.totalCount, duration: formatDurationShort(monthlyStats.totalDuration) })
: '本月还没有锻炼记录,动起来收集第一条吧!'; : t('workoutHistory.monthlyStats.overviewEmpty');
const periodText = `统计周期1日 - ${monthEndDay}日(本月)`; const periodText = t('workoutHistory.monthlyStats.periodText', { day: monthEndDay });
const maxDuration = statsItems[0]?.duration || 1; const maxDuration = statsItems[0]?.duration || 1;
return ( return (
@@ -369,7 +384,7 @@ export default function WorkoutHistoryScreen() {
end={{ x: 1, y: 1 }} end={{ x: 1, y: 1 }}
style={styles.monthlyStatsCard} style={styles.monthlyStatsCard}
> >
<Text style={styles.statSectionLabel}></Text> <Text style={styles.statSectionLabel}>{t('workoutHistory.monthlyStats.title')}</Text>
<Text style={styles.statPeriodText}>{periodText}</Text> <Text style={styles.statPeriodText}>{periodText}</Text>
<Text style={styles.statDescription}>{overviewText}</Text> <Text style={styles.statDescription}>{overviewText}</Text>
@@ -403,7 +418,7 @@ export default function WorkoutHistoryScreen() {
) : ( ) : (
<View style={styles.statEmptyState}> <View style={styles.statEmptyState}>
<MaterialCommunityIcons name="calendar-blank" size={20} color="#7C85A3" /> <MaterialCommunityIcons name="calendar-blank" size={20} color="#7C85A3" />
<Text style={styles.statEmptyText}></Text> <Text style={styles.statEmptyText}>{t('workoutHistory.monthlyStats.emptyData')}</Text>
</View> </View>
)} )}
</LinearGradient> </LinearGradient>
@@ -416,8 +431,8 @@ export default function WorkoutHistoryScreen() {
const emptyComponent = useMemo(() => ( const emptyComponent = useMemo(() => (
<View style={styles.emptyContainer}> <View style={styles.emptyContainer}>
<MaterialCommunityIcons name="calendar-blank" size={40} color="#9AA4C4" /> <MaterialCommunityIcons name="calendar-blank" size={40} color="#9AA4C4" />
<Text style={styles.emptyText}></Text> <Text style={styles.emptyText}>{t('workoutHistory.empty.title')}</Text>
<Text style={styles.emptySubText}></Text> <Text style={styles.emptySubText}>{t('workoutHistory.empty.subtitle')}</Text>
</View> </View>
), []); ), []);
@@ -453,7 +468,7 @@ export default function WorkoutHistoryScreen() {
} }
const activityLabel = getWorkoutTypeDisplayName(workout.workoutActivityType); const activityLabel = getWorkoutTypeDisplayName(workout.workoutActivityType);
return `这是你${workoutDate.format('M月')}的第 ${index + 1}${activityLabel}`; return t('workoutHistory.monthOccurrence', { month: workoutDate.format('M月'), index: index + 1, activity: activityLabel });
}, [sections]); }, [sections]);
const loadWorkoutDetail = useCallback(async (workout: WorkoutData) => { const loadWorkoutDetail = useCallback(async (workout: WorkoutData) => {
@@ -463,16 +478,16 @@ export default function WorkoutHistoryScreen() {
const metrics = await getWorkoutDetailMetrics(workout); const metrics = await getWorkoutDetailMetrics(workout);
setDetailMetrics(metrics); setDetailMetrics(metrics);
} catch (err) { } catch (err) {
console.error('加载锻炼详情失败:', err); console.error('Failed to load workout details:', err);
setDetailMetrics(null); setDetailMetrics(null);
setDetailError('加载锻炼详情失败,请稍后再试'); setDetailError(t('workoutHistory.error.detailLoadFailed'));
} finally { } finally {
setDetailLoading(false); setDetailLoading(false);
} }
}, []); }, []);
const handleWorkoutPress = useCallback((workout: WorkoutData) => { const handleWorkoutPress = useCallback((workout: WorkoutData) => {
const intensity = getIntensityBadge(workout.totalEnergyBurned, workout.duration || 0); const intensity = getIntensityBadge(t, workout.totalEnergyBurned, workout.duration || 0);
setSelectedIntensity(intensity); setSelectedIntensity(intensity);
setSelectedWorkout(workout); setSelectedWorkout(workout);
setDetailMetrics(null); setDetailMetrics(null);
@@ -482,6 +497,22 @@ export default function WorkoutHistoryScreen() {
loadWorkoutDetail(workout); loadWorkoutDetail(workout);
}, [computeMonthlyOccurrenceText, loadWorkoutDetail]); }, [computeMonthlyOccurrenceText, loadWorkoutDetail]);
React.useEffect(() => {
if (!pendingWorkoutId || isLoading) {
return;
}
const allWorkouts = sections.flatMap((section) => section.data);
const targetWorkout = allWorkouts.find((workout) => workout.id === pendingWorkoutId);
if (targetWorkout) {
handleWorkoutPress(targetWorkout);
}
// 清理待处理状态,避免重复触发
setPendingWorkoutId(null);
}, [pendingWorkoutId, isLoading, sections, handleWorkoutPress]);
const handleRetryDetail = useCallback(() => { const handleRetryDetail = useCallback(() => {
if (selectedWorkout) { if (selectedWorkout) {
loadWorkoutDetail(selectedWorkout); loadWorkoutDetail(selectedWorkout);
@@ -495,7 +526,7 @@ export default function WorkoutHistoryScreen() {
const renderItem = useCallback(({ item }: { item: WorkoutData }) => { const renderItem = useCallback(({ item }: { item: WorkoutData }) => {
const calories = Math.round(item.totalEnergyBurned || 0); const calories = Math.round(item.totalEnergyBurned || 0);
const minutes = Math.max(Math.round((item.duration || 0) / 60), 1); const minutes = Math.max(Math.round((item.duration || 0) / 60), 1);
const intensity = getIntensityBadge(item.totalEnergyBurned, item.duration || 0); const intensity = getIntensityBadge(t, item.totalEnergyBurned, item.duration || 0);
const iconName = ICON_MAP[item.workoutActivityType as WorkoutActivityType] || 'arm-flex'; const iconName = ICON_MAP[item.workoutActivityType as WorkoutActivityType] || 'arm-flex';
const time = dayjs(item.startDate || item.endDate).format('HH:mm'); const time = dayjs(item.startDate || item.endDate).format('HH:mm');
const activityLabel = getWorkoutTypeDisplayName(item.workoutActivityType); const activityLabel = getWorkoutTypeDisplayName(item.workoutActivityType);
@@ -512,12 +543,12 @@ export default function WorkoutHistoryScreen() {
<View style={styles.cardContent}> <View style={styles.cardContent}>
<View style={styles.cardTitleRow}> <View style={styles.cardTitleRow}>
<Text style={styles.cardTitle}>{calories} · {minutes}</Text> <Text style={styles.cardTitle}>{t('workoutHistory.historyCard.calories', { calories, minutes })}</Text>
<View style={[styles.intensityBadge, { backgroundColor: intensity.background }]}> <View style={[styles.intensityBadge, { backgroundColor: intensity.background }]}>
<Text style={[styles.intensityText, { color: intensity.color }]}>{intensity.label}</Text> <Text style={[styles.intensityText, { color: intensity.color }]}>{intensity.label}</Text>
</View> </View>
</View> </View>
<Text style={styles.cardSubtitle}>{activityLabel}{time}</Text> <Text style={styles.cardSubtitle}>{t('workoutHistory.historyCard.activityTime', { activity: activityLabel, time })}</Text>
</View> </View>
{/* <Ionicons name="chevron-forward" size={20} color="#9AA4C4" /> */} {/* <Ionicons name="chevron-forward" size={20} color="#9AA4C4" /> */}
@@ -535,11 +566,11 @@ export default function WorkoutHistoryScreen() {
colors={["#F3F5FF", "#FFFFFF"]} colors={["#F3F5FF", "#FFFFFF"]}
style={StyleSheet.absoluteFill} style={StyleSheet.absoluteFill}
/> />
<HeaderBar title="锻炼总结" variant="minimal" transparent={true} /> <HeaderBar title={t('workoutHistory.title')} variant="minimal" transparent={true} />
{isLoading ? ( {isLoading ? (
<View style={styles.loadingContainer}> <View style={styles.loadingContainer}>
<ActivityIndicator size="large" color="#5C55FF" /> <ActivityIndicator size="large" color="#5C55FF" />
<Text style={styles.loadingText}>...</Text> <Text style={styles.loadingText}>{t('workoutHistory.loading')}</Text>
</View> </View>
) : ( ) : (
<SectionList <SectionList
@@ -556,7 +587,7 @@ export default function WorkoutHistoryScreen() {
<MaterialCommunityIcons name="alert-circle" size={40} color="#F85959" /> <MaterialCommunityIcons name="alert-circle" size={40} color="#F85959" />
<Text style={[styles.emptyText, { color: '#F85959' }]}>{error}</Text> <Text style={[styles.emptyText, { color: '#F85959' }]}>{error}</Text>
<TouchableOpacity style={styles.retryButton} onPress={loadHistory}> <TouchableOpacity style={styles.retryButton} onPress={loadHistory}>
<Text style={styles.retryText}></Text> <Text style={styles.retryText}>{t('workoutHistory.retry')}</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
) : emptyComponent} ) : emptyComponent}

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

View File

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

View File

@@ -1,9 +1,9 @@
import { Image } from '@/components/ui/Image';
import { ROUTES } from '@/constants/Routes'; import { ROUTES } from '@/constants/Routes';
import { useAppSelector } from '@/hooks/redux'; import { useAppSelector } from '@/hooks/redux';
import { selectUserAge, selectUserProfile } from '@/store/userSlice'; import { selectUserAge, selectUserProfile } from '@/store/userSlice';
import { fetchBasalEnergyBurned } from '@/utils/health'; import { fetchBasalEnergyBurned } from '@/utils/health';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { Image } from 'expo-image';
import { router } from 'expo-router'; import { router } from 'expo-router';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -242,6 +242,7 @@ const styles = StyleSheet.create({
fontSize: 14, fontSize: 14,
color: '#0F172A', color: '#0F172A',
fontWeight: '600', fontWeight: '600',
fontFamily: 'AliBold',
}, },
titleIcon: { titleIcon: {
width: 16, width: 16,
@@ -256,6 +257,7 @@ const styles = StyleSheet.create({
statusText: { statusText: {
fontSize: 11, fontSize: 11,
fontWeight: '600', fontWeight: '600',
fontFamily: 'AliBold',
}, },
valueSection: { valueSection: {
flexDirection: 'row', flexDirection: 'row',
@@ -267,10 +269,12 @@ const styles = StyleSheet.create({
fontWeight: '600', fontWeight: '600',
color: '#0F172A', color: '#0F172A',
lineHeight: 28, lineHeight: 28,
fontFamily: 'AliBold',
}, },
unit: { unit: {
fontSize: 12, fontSize: 12,
color: '#64748B', color: '#64748B',
marginLeft: 6, marginLeft: 6,
fontFamily: 'AliRegular',
}, },
}); });

View File

@@ -1,9 +1,9 @@
import { ThemedText } from '@/components/ThemedText'; import { ThemedText } from '@/components/ThemedText';
import { useI18n } from '@/hooks/useI18n';
import { useThemeColor } from '@/hooks/useThemeColor'; import { useThemeColor } from '@/hooks/useThemeColor';
import React, { useEffect, useRef } from 'react'; import React, { useEffect, useRef } from 'react';
import { Animated, StyleSheet, View } from 'react-native'; import { Animated, StyleSheet, View } from 'react-native';
import Svg, { Circle } from 'react-native-svg'; import Svg, { Circle, Defs, Stop, LinearGradient as SvgLinearGradient } from 'react-native-svg';
const AnimatedCircle = Animated.createAnimatedComponent(Circle); const AnimatedCircle = Animated.createAnimatedComponent(Circle);
@@ -26,12 +26,8 @@ export function CalorieRingChart({
protein, protein,
fat, fat,
carbs, carbs,
proteinGoal,
fatGoal,
carbsGoal,
}: CalorieRingChartProps) { }: CalorieRingChartProps) {
const surfaceColor = useThemeColor({}, 'surface'); const { t } = useI18n();
const textColor = useThemeColor({}, 'text'); const textColor = useThemeColor({}, 'text');
const textSecondaryColor = useThemeColor({}, 'textSecondary'); const textSecondaryColor = useThemeColor({}, 'textSecondary');
@@ -46,9 +42,9 @@ export function CalorieRingChart({
const totalAvailable = metabolism + exercise; const totalAvailable = metabolism + exercise;
const progressPercentage = totalAvailable > 0 ? Math.min((consumed / totalAvailable) * 100, 100) : 0; const progressPercentage = totalAvailable > 0 ? Math.min((consumed / totalAvailable) * 100, 100) : 0;
// 圆环参数 - 小尺寸以优化空间占用 // 圆环参数 - 小尺寸
const radius = 48; const radius = 42;
const strokeWidth = 8; // 增加圆环厚度 const strokeWidth = 8;
const center = radius + strokeWidth; const center = radius + strokeWidth;
const circumference = 2 * Math.PI * radius; const circumference = 2 * Math.PI * radius;
const strokeDasharray = circumference; const strokeDasharray = circumference;
@@ -70,34 +66,32 @@ export function CalorieRingChart({
}); });
return ( return (
<View style={[styles.container, { backgroundColor: surfaceColor }]}> <View style={styles.container}>
{/* 左上角公式展示 */}
<View style={styles.formulaContainer}>
<ThemedText style={[styles.formulaText, { color: textSecondaryColor }]}>
= + -
</ThemedText>
</View>
{/* 主要内容区域 */}
<View style={styles.mainContent}> <View style={styles.mainContent}>
{/* 左侧圆环图 */} {/* 左侧圆环图 */}
<View style={styles.chartContainer}> <View style={styles.chartContainer}>
<Svg width={center * 2} height={center * 2}> <Svg width={center * 2} height={center * 2}>
<Defs>
<SvgLinearGradient id="progressGradient" x1="0" y1="0" x2="1" y2="1">
<Stop offset="0" stopColor={progressPercentage > 80 ? "#FF9966" : "#4facfe"} stopOpacity="1" />
<Stop offset="1" stopColor={progressPercentage > 80 ? "#FF5E62" : "#00f2fe"} stopOpacity="1" />
</SvgLinearGradient>
</Defs>
{/* 背景圆环 */} {/* 背景圆环 */}
<Circle <Circle
cx={center} cx={center}
cy={center} cy={center}
r={radius} r={radius}
stroke="#F0F0F0" stroke="#F5F7FA"
strokeWidth={strokeWidth} strokeWidth={strokeWidth}
fill="none" fill="none"
/> />
{/* 进度圆环 - 保持固定颜色 */} {/* 进度圆环 */}
<AnimatedCircle <AnimatedCircle
cx={center} cx={center}
cy={center} cy={center}
r={radius} r={radius}
stroke={progressPercentage > 80 ? "#FF6B6B" : "#4ECDC4"} stroke="url(#progressGradient)"
strokeWidth={strokeWidth} strokeWidth={strokeWidth}
fill="none" fill="none"
strokeDasharray={`${strokeDasharray}`} strokeDasharray={`${strokeDasharray}`}
@@ -109,68 +103,68 @@ export function CalorieRingChart({
{/* 中心内容 */} {/* 中心内容 */}
<View style={styles.centerContent}> <View style={styles.centerContent}>
<ThemedText style={[styles.centerLabel, { color: textSecondaryColor }]}> <ThemedText style={styles.centerLabel}>
{t('nutritionRecords.chart.remaining')}
</ThemedText> </ThemedText>
<ThemedText style={[styles.centerValue, { color: textColor }]}> <ThemedText style={styles.centerValue}>
{Math.round(canEat)} {Math.round(canEat)}
</ThemedText>
<ThemedText style={styles.centerUnit}>
{t('nutritionRecords.nutrients.caloriesUnit')}
</ThemedText> </ThemedText>
</View> </View>
</View> </View>
{/* 右侧数据展示 */} {/* 右侧数据展示 - 优化布局 */}
<View style={styles.dataContainer}> <View style={styles.dataContainer}>
<View style={styles.dataBackground}> {/* 公式 */}
{/* 左右两列布局 */} <View style={styles.formulaContainer}>
<View style={styles.dataColumns}> <ThemedText style={styles.formulaText}>
{/* 左列:卡路里数据 */} {t('nutritionRecords.chart.formula')}
<View style={styles.dataColumn}> </ThemedText>
<View style={styles.dataItem}> </View>
<ThemedText style={[styles.dataLabel, { color: textSecondaryColor }]}></ThemedText>
<ThemedText style={[styles.dataValue, { color: textColor }]}>
{Math.round(metabolism)}
</ThemedText>
</View>
<View style={styles.dataItem}> {/* 代谢 & 运动 & 饮食 */}
<ThemedText style={[styles.dataLabel, { color: textSecondaryColor }]}></ThemedText> <View style={styles.statsGroup}>
<ThemedText style={[styles.dataValue, { color: textColor }]}> <View style={styles.statRowCompact}>
{Math.round(exercise)} <View style={styles.labelWithDot}>
</ThemedText> <View style={styles.dotMetabolism} />
</View> <ThemedText style={styles.statLabel}>{t('nutritionRecords.chart.metabolism')}</ThemedText>
</View>
<View style={styles.dataItem}> <ThemedText style={styles.statValue}>{Math.round(metabolism)}</ThemedText>
<ThemedText style={[styles.dataLabel, { color: textSecondaryColor }]}></ThemedText>
<ThemedText style={[styles.dataValue, { color: textColor }]}>
{Math.round(consumed)}
</ThemedText>
</View>
</View>
{/* 右列:营养数据 */}
<View style={styles.dataColumn}>
<View style={styles.dataItem}>
<ThemedText style={[styles.dataLabel, { color: textSecondaryColor }]}></ThemedText>
<ThemedText style={[styles.dataValue, { color: textColor }]}>
{Math.round(protein)}g
</ThemedText>
</View>
<View style={styles.dataItem}>
<ThemedText style={[styles.dataLabel, { color: textSecondaryColor }]}></ThemedText>
<ThemedText style={[styles.dataValue, { color: textColor }]}>
{Math.round(fat)}g
</ThemedText>
</View>
<View style={styles.dataItem}>
<ThemedText style={[styles.dataLabel, { color: textSecondaryColor }]}></ThemedText>
<ThemedText style={[styles.dataValue, { color: textColor }]}>
{Math.round(carbs)}g
</ThemedText>
</View>
</View>
</View> </View>
<View style={styles.statRowCompact}>
<View style={styles.labelWithDot}>
<View style={styles.dotExercise} />
<ThemedText style={styles.statLabel}>{t('nutritionRecords.chart.exercise')}</ThemedText>
</View>
<ThemedText style={styles.statValue}>{Math.round(exercise)}</ThemedText>
</View>
<View style={styles.statRowCompact}>
<View style={styles.labelWithDot}>
<View style={styles.dotConsumed} />
<ThemedText style={styles.statLabel}>{t('nutritionRecords.chart.diet')}</ThemedText>
</View>
<ThemedText style={styles.statValue}>{Math.round(consumed)}</ThemedText>
</View>
</View>
<View style={styles.divider} />
{/* 营养素 - 水平排布 */}
<View style={styles.nutritionRow}>
<View style={styles.nutritionItem}>
<ThemedText style={styles.statValueSmall}>{Math.round(protein)}{t('nutritionRecords.nutrients.unit')}</ThemedText>
<ThemedText style={styles.statLabelSmall}>{t('nutritionRecords.nutrients.protein')}</ThemedText>
</View>
<View style={styles.nutritionItem}>
<ThemedText style={styles.statValueSmall}>{Math.round(fat)}{t('nutritionRecords.nutrients.unit')}</ThemedText>
<ThemedText style={styles.statLabelSmall}>{t('nutritionRecords.nutrients.fat')}</ThemedText>
</View>
<View style={styles.nutritionItem}>
<ThemedText style={styles.statValueSmall}>{Math.round(carbs)}{t('nutritionRecords.nutrients.unit')}</ThemedText>
<ThemedText style={styles.statLabelSmall}>{t('nutritionRecords.nutrients.carbs')}</ThemedText>
</View>
</View> </View>
</View> </View>
</View> </View>
@@ -181,40 +175,35 @@ export function CalorieRingChart({
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
backgroundColor: '#FFFFFF', backgroundColor: '#FFFFFF',
borderRadius: 16, borderRadius: 24,
padding: 16, padding: 16,
marginHorizontal: 16, marginHorizontal: 20,
marginBottom: 8, shadowColor: 'rgba(30, 41, 59, 0.08)',
shadowColor: '#000000', shadowOffset: { width: 0, height: 8 },
shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.12,
shadowOpacity: 0.04, shadowRadius: 16,
shadowRadius: 8, elevation: 6,
elevation: 2,
}, },
formulaContainer: { formulaContainer: {
alignItems: 'flex-start',
marginBottom: 12, marginBottom: 12,
}, },
formulaText: { formulaText: {
fontSize: 12, fontSize: 10,
fontWeight: '500', fontWeight: '500',
color: '#999999', color: '#94A3B8',
lineHeight: 16, fontFamily: 'AliRegular',
}, },
mainContent: { mainContent: {
width: '100%',
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'flex-start',
justifyContent: 'space-between',
marginBottom: 0, // 移除底部间距,因为不再有底部营养容器
paddingHorizontal: 8,
}, },
chartContainer: { chartContainer: {
position: 'relative', position: 'relative',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
width: 112, // 减少宽度以匹配更小的圆环 (48*2 + 8*2) width: 100,
flexShrink: 0, height: 100,
marginTop: 8,
}, },
centerContent: { centerContent: {
position: 'absolute', position: 'absolute',
@@ -222,71 +211,95 @@ const styles = StyleSheet.create({
justifyContent: 'center', justifyContent: 'center',
}, },
centerLabel: { centerLabel: {
fontSize: 11, fontSize: 10,
fontWeight: '500', fontWeight: '500',
color: '#999999', color: '#94A3B8',
marginBottom: 2, marginBottom: 1,
fontFamily: 'AliRegular',
}, },
centerValue: { centerValue: {
fontSize: 14, fontSize: 20,
fontWeight: '600', fontWeight: '800',
color: '#333333', color: '#1E293B',
marginBottom: 1, lineHeight: 24,
fontFamily: 'AliBold',
}, },
centerPercentage: { centerUnit: {
fontSize: 11, fontSize: 10,
fontWeight: '500', fontWeight: '600',
color: '#999999', color: '#64748B',
fontFamily: 'AliRegular',
}, },
dataContainer: { dataContainer: {
flex: 1, flex: 1,
marginLeft: 16, marginLeft: 20,
}, },
dataBackground: { statsGroup: {
backgroundColor: 'rgba(248, 250, 252, 0.8)', // 毛玻璃背景色 gap: 6,
borderRadius: 12,
padding: 12,
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 1,
},
shadowOpacity: 0.06,
shadowRadius: 3,
elevation: 1,
// 添加边框增强毛玻璃效果
borderWidth: 0.5,
borderColor: 'rgba(255, 255, 255, 0.8)',
gap: 4,
}, },
dataItem: { statRowCompact: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
gap: 4, justifyContent: 'space-between',
}, },
dataIcon: { labelWithDot: {
width: 6, flexDirection: 'row',
height: 6, alignItems: 'center',
borderRadius: 3,
}, },
dataLabel: { dotMetabolism: {
fontSize: 11, width: 6,
fontWeight: '500', height: 6,
color: '#999999', borderRadius: 3,
minWidth: 28, backgroundColor: '#94A3B8',
marginRight: 6,
}, },
dataValue: { dotExercise: {
fontSize: 11, width: 6,
height: 6,
borderRadius: 3,
backgroundColor: '#4facfe',
marginRight: 6,
},
dotConsumed: {
width: 6,
height: 6,
borderRadius: 3,
backgroundColor: '#FF9966',
marginRight: 6,
},
statLabel: {
fontSize: 12,
color: '#64748B',
fontFamily: 'AliRegular',
},
statValue: {
fontSize: 13,
fontWeight: '600', fontWeight: '600',
color: '#333333', color: '#334155',
fontFamily: 'AliBold',
}, },
dataColumns: { divider: {
height: 1,
backgroundColor: '#F1F5F9',
marginVertical: 10,
},
nutritionRow: {
flexDirection: 'row', flexDirection: 'row',
justifyContent: 'space-between', justifyContent: 'space-between',
gap: 12,
}, },
dataColumn: { nutritionItem: {
flex: 1, alignItems: 'center',
gap: 4, },
statLabelSmall: {
fontSize: 10,
color: '#94A3B8',
marginTop: 2,
fontFamily: 'AliRegular',
},
statValueSmall: {
fontSize: 13,
fontWeight: '600',
color: '#475569',
fontFamily: 'AliBold',
}, },
}); });

View File

@@ -1,4 +1,5 @@
import { getMonthDaysZh, getMonthTitleZh, getTodayIndexInMonth } from '@/utils/date'; import { useI18n } from '@/hooks/useI18n';
import { getMonthDays, getMonthTitle, getTodayIndexInMonth } from '@/utils/date';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import DateTimePicker from '@react-native-community/datetimepicker'; import DateTimePicker from '@react-native-community/datetimepicker';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
@@ -50,6 +51,8 @@ export const DateSelector: React.FC<DateSelectorProps> = ({
autoScrollToSelected = true, autoScrollToSelected = true,
showCalendarIcon = true, showCalendarIcon = true,
}) => { }) => {
const { t, i18n } = useI18n();
// 内部状态管理 // 内部状态管理
const [internalSelectedIndex, setInternalSelectedIndex] = useState(getTodayIndexInMonth()); const [internalSelectedIndex, setInternalSelectedIndex] = useState(getTodayIndexInMonth());
const [currentMonth, setCurrentMonth] = useState(dayjs()); // 当前显示的月份 const [currentMonth, setCurrentMonth] = useState(dayjs()); // 当前显示的月份
@@ -59,8 +62,8 @@ export const DateSelector: React.FC<DateSelectorProps> = ({
const isGlassAvailable = isLiquidGlassAvailable(); const isGlassAvailable = isLiquidGlassAvailable();
// 获取日期数据 // 获取日期数据
const days = getMonthDaysZh(currentMonth); const days = getMonthDays(currentMonth, i18n.language as 'zh' | 'en');
const monthTitle = externalMonthTitle ?? getMonthTitleZh(currentMonth); const monthTitle = externalMonthTitle ?? getMonthTitle(currentMonth, i18n.language as 'zh' | 'en');
// 判断当前选中的日期是否是今天 // 判断当前选中的日期是否是今天
const isSelectedDateToday = () => { const isSelectedDateToday = () => {
@@ -201,7 +204,7 @@ export const DateSelector: React.FC<DateSelectorProps> = ({
setCurrentMonth(selectedMonth); setCurrentMonth(selectedMonth);
// 计算选中日期在新月份中的索引 // 计算选中日期在新月份中的索引
const newMonthDays = getMonthDaysZh(selectedMonth); const newMonthDays = getMonthDays(selectedMonth, i18n.language as 'zh' | 'en');
const selectedDay = selectedMonth.date(); const selectedDay = selectedMonth.date();
const newSelectedIndex = newMonthDays.findIndex(day => day.dayOfMonth === selectedDay); const newSelectedIndex = newMonthDays.findIndex(day => day.dayOfMonth === selectedDay);
@@ -219,7 +222,7 @@ export const DateSelector: React.FC<DateSelectorProps> = ({
const handleGoToday = () => { const handleGoToday = () => {
const today = dayjs(); const today = dayjs();
setCurrentMonth(today); setCurrentMonth(today);
const todayDays = getMonthDaysZh(today); const todayDays = getMonthDays(today, i18n.language as 'zh' | 'en');
const newSelectedIndex = todayDays.findIndex(day => day.dayOfMonth === today.date()); const newSelectedIndex = todayDays.findIndex(day => day.dayOfMonth === today.date());
if (newSelectedIndex !== -1) { if (newSelectedIndex !== -1) {
@@ -250,11 +253,11 @@ export const DateSelector: React.FC<DateSelectorProps> = ({
tintColor="rgba(124, 58, 237, 0.08)" tintColor="rgba(124, 58, 237, 0.08)"
isInteractive={true} isInteractive={true}
> >
<Text style={styles.todayButtonText}></Text> <Text style={styles.todayButtonText}>{t('dateSelector.backToToday')}</Text>
</GlassView> </GlassView>
) : ( ) : (
<View style={[styles.todayButton, styles.todayButtonFallback]}> <View style={[styles.todayButton, styles.todayButtonFallback]}>
<Text style={styles.todayButtonText}></Text> <Text style={styles.todayButtonText}>{t('dateSelector.backToToday')}</Text>
</View> </View>
)} )}
</TouchableOpacity> </TouchableOpacity>
@@ -379,7 +382,7 @@ export const DateSelector: React.FC<DateSelectorProps> = ({
display={Platform.OS === 'ios' ? 'inline' : 'calendar'} display={Platform.OS === 'ios' ? 'inline' : 'calendar'}
minimumDate={dayjs().subtract(6, 'month').toDate()} minimumDate={dayjs().subtract(6, 'month').toDate()}
maximumDate={disableFutureDates ? new Date() : undefined} maximumDate={disableFutureDates ? new Date() : undefined}
{...(Platform.OS === 'ios' ? { locale: 'zh-CN' } : {})} {...(Platform.OS === 'ios' ? { locale: i18n.language === 'zh' ? 'zh-CN' : 'en-US' } : {})}
onChange={(event, date) => { onChange={(event, date) => {
if (Platform.OS === 'ios') { if (Platform.OS === 'ios') {
if (date) setPickerDate(date); if (date) setPickerDate(date);
@@ -395,12 +398,12 @@ export const DateSelector: React.FC<DateSelectorProps> = ({
{Platform.OS === 'ios' && ( {Platform.OS === 'ios' && (
<View style={styles.modalActions}> <View style={styles.modalActions}>
<TouchableOpacity onPress={closeDatePicker} style={[styles.modalBtn]}> <TouchableOpacity onPress={closeDatePicker} style={[styles.modalBtn]}>
<Text style={styles.modalBtnText}></Text> <Text style={styles.modalBtnText}>{t('dateSelector.cancel')}</Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity onPress={() => { <TouchableOpacity onPress={() => {
onConfirmDate(pickerDate); onConfirmDate(pickerDate);
}} style={[styles.modalBtn, styles.modalBtnPrimary]}> }} style={[styles.modalBtn, styles.modalBtnPrimary]}>
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary]}></Text> <Text style={[styles.modalBtnText, styles.modalBtnTextPrimary]}>{t('dateSelector.confirm')}</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
)} )}
@@ -413,7 +416,7 @@ export const DateSelector: React.FC<DateSelectorProps> = ({
display={Platform.OS === 'ios' ? 'inline' : 'calendar'} display={Platform.OS === 'ios' ? 'inline' : 'calendar'}
minimumDate={dayjs().subtract(6, 'month').toDate()} minimumDate={dayjs().subtract(6, 'month').toDate()}
maximumDate={disableFutureDates ? new Date() : undefined} maximumDate={disableFutureDates ? new Date() : undefined}
{...(Platform.OS === 'ios' ? { locale: 'zh-CN' } : {})} {...(Platform.OS === 'ios' ? { locale: i18n.language === 'zh' ? 'zh-CN' : 'en-US' } : {})}
onChange={(event, date) => { onChange={(event, date) => {
if (Platform.OS === 'ios') { if (Platform.OS === 'ios') {
if (date) setPickerDate(date); if (date) setPickerDate(date);
@@ -429,12 +432,12 @@ export const DateSelector: React.FC<DateSelectorProps> = ({
{Platform.OS === 'ios' && ( {Platform.OS === 'ios' && (
<View style={styles.modalActions}> <View style={styles.modalActions}>
<TouchableOpacity onPress={closeDatePicker} style={[styles.modalBtn]}> <TouchableOpacity onPress={closeDatePicker} style={[styles.modalBtn]}>
<Text style={styles.modalBtnText}></Text> <Text style={styles.modalBtnText}>{t('dateSelector.cancel')}</Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity onPress={() => { <TouchableOpacity onPress={() => {
onConfirmDate(pickerDate); onConfirmDate(pickerDate);
}} style={[styles.modalBtn, styles.modalBtnPrimary]}> }} style={[styles.modalBtn, styles.modalBtnPrimary]}>
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary]}></Text> <Text style={[styles.modalBtnText, styles.modalBtnTextPrimary]}>{t('dateSelector.confirm')}</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
)} )}
@@ -460,15 +463,16 @@ const styles = StyleSheet.create({
alignItems: 'center', alignItems: 'center',
}, },
monthTitle: { monthTitle: {
fontSize: 22, fontSize: 26,
fontWeight: '800', fontWeight: '800',
color: '#1a1a1a', color: '#1c1f3a',
letterSpacing: -0.5, fontFamily: 'AliBold',
marginLeft: 8,
}, },
calendarIconButton: { calendarIconButton: {
padding: 4, padding: 6,
borderRadius: 6, borderRadius: 12,
marginLeft: 4, marginLeft: 8,
overflow: 'hidden', overflow: 'hidden',
}, },
calendarIconFallback: { calendarIconFallback: {
@@ -477,22 +481,20 @@ const styles = StyleSheet.create({
borderColor: 'rgba(255, 255, 255, 0.3)', borderColor: 'rgba(255, 255, 255, 0.3)',
}, },
todayButton: { todayButton: {
paddingHorizontal: 12, paddingHorizontal: 14,
paddingVertical: 6, paddingVertical: 8,
borderRadius: 12, borderRadius: 16,
marginRight: 8, marginRight: 4,
overflow: 'hidden', overflow: 'hidden',
}, },
todayButtonFallback: { todayButtonFallback: {
backgroundColor: '#EEF2FF', backgroundColor: '#EEF2FF',
borderWidth: 1,
borderColor: 'rgba(124, 58, 237, 0.2)',
}, },
todayButtonText: { todayButtonText: {
fontSize: 12, fontSize: 12,
fontWeight: '700', fontWeight: '700',
color: '#7c3aed', color: '#5F6BF0',
letterSpacing: 0.2, fontFamily: 'AliBold',
}, },
daysContainer: { daysContainer: {
paddingBottom: 8, paddingBottom: 8,
@@ -503,8 +505,8 @@ const styles = StyleSheet.create({
marginRight: 8, marginRight: 8,
}, },
dayPill: { dayPill: {
width: 40, width: 48,
height: 60, height: 68,
borderRadius: 24, borderRadius: 24,
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
@@ -518,14 +520,12 @@ const styles = StyleSheet.create({
transform: [{ scale: 0.96 }], transform: [{ scale: 0.96 }],
}, },
dayPillSelectedFallback: { dayPillSelectedFallback: {
backgroundColor: '#FFFFFF', backgroundColor: '#5F6BF0',
shadowColor: '#000', shadowColor: 'rgba(95, 107, 240, 0.3)',
shadowOffset: { width: 0, height: 2 }, shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.1, shadowOpacity: 0.3,
shadowRadius: 4, shadowRadius: 8,
elevation: 3, elevation: 4,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.5)',
}, },
dayPillDisabled: { dayPillDisabled: {
backgroundColor: 'transparent', backgroundColor: 'transparent',
@@ -533,27 +533,31 @@ const styles = StyleSheet.create({
}, },
dayLabel: { dayLabel: {
fontSize: 11, fontSize: 11,
fontWeight: '700', fontWeight: '600',
color: '#8e8e93', color: '#94A3B8',
marginBottom: 2, marginBottom: 4,
letterSpacing: 0.1, fontFamily: 'AliRegular',
}, },
dayLabelSelected: { dayLabelSelected: {
color: '#1a1a1a', color: '#1a1a1a',
fontWeight: '800', fontWeight: '700',
fontFamily: 'AliBold',
opacity: 0.9,
}, },
dayLabelDisabled: { dayLabelDisabled: {
color: '#c7c7cc', color: '#c7c7cc',
}, },
dayDate: { dayDate: {
fontSize: 13, fontSize: 15,
fontWeight: '700', fontWeight: '700',
color: '#8e8e93', color: '#64748B',
letterSpacing: -0.2, fontFamily: 'AliBold',
}, },
dayDateSelected: { dayDateSelected: {
color: '#1a1a1a', color: '#1a1a1a',
fontWeight: '800', fontWeight: '800',
fontSize: 16,
fontFamily: 'AliBold',
}, },
dayDateDisabled: { dayDateDisabled: {
color: '#c7c7cc', color: '#c7c7cc',
@@ -607,11 +611,13 @@ const styles = StyleSheet.create({
fontWeight: '700', fontWeight: '700',
fontSize: 14, fontSize: 14,
letterSpacing: 0.1, letterSpacing: 0.1,
fontFamily: 'AliBold',
}, },
modalBtnTextPrimary: { modalBtnTextPrimary: {
color: '#FFFFFF', color: '#FFFFFF',
fontWeight: '700', fontWeight: '700',
fontSize: 14, fontSize: 14,
letterSpacing: 0.1, letterSpacing: 0.1,
fontFamily: 'AliBold',
}, },
}); });

View File

@@ -1,5 +1,6 @@
import { ROUTES } from '@/constants/Routes'; import { ROUTES } from '@/constants/Routes';
import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useI18n } from '@/hooks/useI18n';
import { ChallengeType } from '@/services/challengesApi'; import { ChallengeType } from '@/services/challengesApi';
import { reportChallengeProgress, selectChallengeList } from '@/store/challengesSlice'; import { reportChallengeProgress, selectChallengeList } from '@/store/challengesSlice';
import { ActivityRingsData, fetchActivityRingsForDate } from '@/utils/health'; import { ActivityRingsData, fetchActivityRingsForDate } from '@/utils/health';
@@ -26,6 +27,7 @@ export function FitnessRingsCard({
selectedDate, selectedDate,
resetToken, resetToken,
}: FitnessRingsCardProps) { }: FitnessRingsCardProps) {
const { t } = useI18n();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const challenges = useAppSelector(selectChallengeList); const challenges = useAppSelector(selectChallengeList);
const [activityData, setActivityData] = useState<ActivityRingsData | null>(null); const [activityData, setActivityData] = useState<ActivityRingsData | null>(null);
@@ -135,6 +137,24 @@ export function FitnessRingsCard({
const exerciseProgress = Math.min(1, Math.max(0, exerciseMinutes / exerciseMinutesGoal)); const exerciseProgress = Math.min(1, Math.max(0, exerciseMinutes / exerciseMinutesGoal));
const standProgress = Math.min(1, Math.max(0, standHours / standHoursGoal)); const standProgress = Math.min(1, Math.max(0, standHours / standHoursGoal));
const units = useMemo(
() => ({
kcal: t('statistics.components.fitness.kcal'),
minutes: t('statistics.components.fitness.minutes'),
hours: t('statistics.components.fitness.hours'),
}),
[t]
);
const fitnessRows = useMemo(
() => [
{ key: 'active', value: Math.round(activeCalories), goal: activeCaloriesGoal, unit: units.kcal },
{ key: 'exercise', value: Math.round(exerciseMinutes), goal: exerciseMinutesGoal, unit: units.minutes },
{ key: 'stand', value: Math.round(standHours), goal: standHoursGoal, unit: units.hours },
],
[activeCalories, activeCaloriesGoal, exerciseMinutes, exerciseMinutesGoal, standHours, standHoursGoal, units]
);
const handlePress = () => { const handlePress = () => {
router.push(ROUTES.FITNESS_RINGS_DETAIL); router.push(ROUTES.FITNESS_RINGS_DETAIL);
}; };
@@ -191,47 +211,23 @@ export function FitnessRingsCard({
{/* 右侧数据显示 */} {/* 右侧数据显示 */}
<View style={styles.dataContainer}> <View style={styles.dataContainer}>
<View style={styles.dataRow}> {fitnessRows.map((row) => (
<Text style={styles.dataText}> <View key={row.key} style={styles.dataRow}>
{loading ? ( <Text style={styles.dataText}>
<Text style={styles.dataValue}>--</Text> {loading ? (
) : ( <Text style={styles.dataValue}>--</Text>
<> ) : (
<Text style={styles.dataValue}>{Math.round(activeCalories)}</Text> <>
<Text style={styles.dataGoal}>/{activeCaloriesGoal}</Text> <Text style={styles.dataValue}>{row.value}</Text>
</> <Text style={styles.dataGoal}>
)} {t('statistics.components.fitnessRings.goal', { goal: row.goal })}
</Text> </Text>
<Text style={styles.dataUnit}></Text> </>
</View> )}
</Text>
<View style={styles.dataRow}> <Text style={styles.dataUnit}>{row.unit}</Text>
<Text style={styles.dataText}> </View>
{loading ? ( ))}
<Text style={styles.dataValue}>--</Text>
) : (
<>
<Text style={styles.dataValue}>{Math.round(exerciseMinutes)}</Text>
<Text style={styles.dataGoal}>/{exerciseMinutesGoal}</Text>
</>
)}
</Text>
<Text style={styles.dataUnit}></Text>
</View>
<View style={styles.dataRow}>
<Text style={styles.dataText}>
{loading ? (
<Text style={styles.dataValue}>--</Text>
) : (
<>
<Text style={styles.dataValue}>{Math.round(standHours)}</Text>
<Text style={styles.dataGoal}>/{standHoursGoal}</Text>
</>
)}
</Text>
<Text style={styles.dataUnit}></Text>
</View>
</View> </View>
</View> </View>
</TouchableOpacity> </TouchableOpacity>
@@ -285,6 +281,7 @@ const styles = StyleSheet.create({
fontSize: 12, fontSize: 12,
fontWeight: '700', fontWeight: '700',
flex: 1, flex: 1,
fontFamily: 'AliBold',
}, },
dataValue: { dataValue: {
color: '#192126', color: '#192126',
@@ -298,5 +295,6 @@ const styles = StyleSheet.create({
fontWeight: '500', fontWeight: '500',
minWidth: 25, minWidth: 25,
textAlign: 'right', textAlign: 'right',
fontFamily: 'AliRegular',
}, },
}); });

View File

@@ -1,5 +1,6 @@
import { ROUTES } from '@/constants/Routes'; import { ROUTES } from '@/constants/Routes';
import { useAuthGuard } from '@/hooks/useAuthGuard'; import { useAuthGuard } from '@/hooks/useAuthGuard';
import { useI18n } from '@/hooks/useI18n';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { BlurView } from 'expo-blur'; import { BlurView } from 'expo-blur';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
@@ -20,6 +21,7 @@ interface FloatingFoodOverlayProps {
export function FloatingFoodOverlay({ visible, onClose, mealType = 'dinner' }: FloatingFoodOverlayProps) { export function FloatingFoodOverlay({ visible, onClose, mealType = 'dinner' }: FloatingFoodOverlayProps) {
const router = useRouter(); const router = useRouter();
const { t } = useI18n();
const { pushIfAuthedElseLogin } = useAuthGuard() const { pushIfAuthedElseLogin } = useAuthGuard()
@@ -41,21 +43,21 @@ export function FloatingFoodOverlay({ visible, onClose, mealType = 'dinner' }: F
const menuItems = [ const menuItems = [
{ {
id: 'scan', id: 'scan',
title: 'AI识别', title: t('nutritionRecords.overlay.scan'),
icon: '📷', icon: '📷',
backgroundColor: '#4FC3F7', backgroundColor: '#4FC3F7',
onPress: handlePhotoRecognition, onPress: handlePhotoRecognition,
}, },
{ {
id: 'food-library', id: 'food-library',
title: '食物库', title: t('nutritionRecords.overlay.foodLibrary'),
icon: '🍎', icon: '🍎',
backgroundColor: '#FF9500', backgroundColor: '#FF9500',
onPress: handleFoodLibrary, onPress: handleFoodLibrary,
}, },
{ {
id: 'voice-record', id: 'voice-record',
title: '一句话记录', title: t('nutritionRecords.overlay.voiceRecord'),
icon: '🎤', icon: '🎤',
backgroundColor: '#7B68EE', backgroundColor: '#7B68EE',
onPress: handleVoiceRecord, onPress: handleVoiceRecord,
@@ -81,7 +83,7 @@ export function FloatingFoodOverlay({ visible, onClose, mealType = 'dinner' }: F
<View style={styles.container}> <View style={styles.container}>
<BlurView intensity={80} tint="light" style={styles.blurContainer}> <BlurView intensity={80} tint="light" style={styles.blurContainer}>
<View style={styles.header}> <View style={styles.header}>
<Text style={styles.title}></Text> <Text style={styles.title}>{t('nutritionRecords.overlay.title')}</Text>
</View> </View>
<View style={styles.menuGrid}> <View style={styles.menuGrid}>

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

@@ -13,7 +13,7 @@ interface MoodCardProps {
export function MoodCard({ moodCheckin, onPress }: MoodCardProps) { export function MoodCard({ moodCheckin, onPress }: MoodCardProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const moodConfig = moodCheckin ? getMoodConfig(moodCheckin.moodType) : null; const moodConfig = moodCheckin ? getMoodConfig(moodCheckin.moodType, t) : null;
const animationRef = useRef<LottieView>(null); const animationRef = useRef<LottieView>(null);
useEffect(() => { useEffect(() => {
@@ -82,7 +82,8 @@ const styles = StyleSheet.create({
cardTitle: { cardTitle: {
fontSize: 14, fontSize: 14,
color: '#192126', color: '#192126',
fontWeight: '600' fontWeight: '600',
fontFamily: 'AliBold',
}, },
lottieAnimation: { lottieAnimation: {
@@ -100,21 +101,25 @@ const styles = StyleSheet.create({
fontSize: 14, fontSize: 14,
color: '#059669', color: '#059669',
fontWeight: '600', fontWeight: '600',
fontFamily: 'AliBold',
}, },
moodPreviewTime: { moodPreviewTime: {
fontSize: 12, fontSize: 12,
color: '#6B7280', color: '#6B7280',
fontFamily: 'AliRegular',
}, },
moodEmptyText: { moodEmptyText: {
fontSize: 12, fontSize: 12,
color: '#9CA3AF', color: '#9CA3AF',
fontStyle: 'italic', fontStyle: 'italic',
marginTop: 22, marginTop: 22,
fontFamily: 'AliRegular',
}, },
moodLoadingText: { moodLoadingText: {
fontSize: 12, fontSize: 12,
color: '#9CA3AF', color: '#9CA3AF',
fontStyle: 'italic', fontStyle: 'italic',
marginTop: 22, marginTop: 22,
fontFamily: 'AliRegular',
}, },
}); });

View File

@@ -1,3 +1,4 @@
import { useI18n } from '@/hooks/useI18n';
import { MoodCheckin, getMoodConfig } from '@/services/moodCheckins'; import { MoodCheckin, getMoodConfig } from '@/services/moodCheckins';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import React from 'react'; import React from 'react';
@@ -8,7 +9,9 @@ interface MoodHistoryCardProps {
title?: string; title?: string;
} }
export function MoodHistoryCard({ moodCheckins, title = '心情记录' }: MoodHistoryCardProps) { export function MoodHistoryCard({ moodCheckins, title }: MoodHistoryCardProps) {
const { t } = useI18n();
const defaultTitle = t('mood.history.title');
// 计算心情统计 // 计算心情统计
const moodStats = React.useMemo(() => { const moodStats = React.useMemo(() => {
const stats = { const stats = {
@@ -26,7 +29,7 @@ export function MoodHistoryCard({ moodCheckins, title = '心情记录' }: MoodHi
// 计算心情分布 // 计算心情分布
moodCheckins.forEach(checkin => { moodCheckins.forEach(checkin => {
const moodLabel = getMoodConfig(checkin.moodType)?.label || checkin.moodType; const moodLabel = getMoodConfig(checkin.moodType, t)?.label || checkin.moodType;
stats.moodDistribution[moodLabel] = (stats.moodDistribution[moodLabel] || 0) + 1; stats.moodDistribution[moodLabel] = (stats.moodDistribution[moodLabel] || 0) + 1;
}); });
@@ -45,11 +48,11 @@ export function MoodHistoryCard({ moodCheckins, title = '心情记录' }: MoodHi
return ( return (
<View style={styles.container}> <View style={styles.container}>
<Text style={styles.title}>{title}</Text> <Text style={styles.title}>{title || defaultTitle}</Text>
{moodCheckins.length === 0 ? ( {moodCheckins.length === 0 ? (
<View style={styles.emptyState}> <View style={styles.emptyState}>
<Text style={styles.emptyText}></Text> <Text style={styles.emptyText}>{t('mood.history.noRecords')}</Text>
</View> </View>
) : ( ) : (
<> <>
@@ -57,36 +60,36 @@ export function MoodHistoryCard({ moodCheckins, title = '心情记录' }: MoodHi
<View style={styles.statsContainer}> <View style={styles.statsContainer}>
<View style={styles.statItem}> <View style={styles.statItem}>
<Text style={styles.statValue}>{moodStats.total}</Text> <Text style={styles.statValue}>{moodStats.total}</Text>
<Text style={styles.statLabel}></Text> <Text style={styles.statLabel}>{t('mood.history.totalRecords')}</Text>
</View> </View>
<View style={styles.statItem}> <View style={styles.statItem}>
<Text style={styles.statValue}>{moodStats.averageIntensity}</Text> <Text style={styles.statValue}>{moodStats.averageIntensity}</Text>
<Text style={styles.statLabel}></Text> <Text style={styles.statLabel}>{t('mood.history.averageIntensity')}</Text>
</View> </View>
<View style={styles.statItem}> <View style={styles.statItem}>
<Text style={styles.statValue}>{moodStats.mostFrequentMood}</Text> <Text style={styles.statValue}>{moodStats.mostFrequentMood}</Text>
<Text style={styles.statLabel}></Text> <Text style={styles.statLabel}>{t('mood.history.mostFrequent')}</Text>
</View> </View>
</View> </View>
{/* 最近记录 */} {/* 最近记录 */}
<View style={styles.recentContainer}> <View style={styles.recentContainer}>
<Text style={styles.sectionTitle}></Text> <Text style={styles.sectionTitle}>{t('mood.history.recentRecords')}</Text>
{recentMoods.map((checkin, index) => { {recentMoods.map((checkin, index) => {
const moodConfig = getMoodConfig(checkin.moodType); const moodConfig = getMoodConfig(checkin.moodType, t);
return ( return (
<View key={checkin.id} style={styles.moodItem}> <View key={checkin.id} style={styles.moodItem}>
<View style={styles.moodInfo}> <View style={styles.moodInfo}>
<Text style={styles.moodEmoji}>{moodConfig?.emoji}</Text> <Text style={styles.moodEmoji}>😊</Text>
<View style={styles.moodDetails}> <View style={styles.moodDetails}>
<Text style={styles.moodLabel}>{moodConfig?.label}</Text> <Text style={styles.moodLabel}>{moodConfig?.label}</Text>
<Text style={styles.moodDate}> <Text style={styles.moodDate}>
{dayjs(checkin.createdAt).format('MM月DD日 HH:mm')} {dayjs(checkin.createdAt).format(t('mood.history.dateTimeFormat'))}
</Text> </Text>
</View> </View>
</View> </View>
<View style={styles.moodIntensity}> <View style={styles.moodIntensity}>
<Text style={styles.intensityText}> {checkin.intensity}</Text> <Text style={styles.intensityText}>{t('mood.history.intensity')} {checkin.intensity}</Text>
</View> </View>
</View> </View>
); );

View File

@@ -6,6 +6,7 @@ import {
Text, Text,
View, View,
} from 'react-native'; } from 'react-native';
import { useTranslation } from 'react-i18next';
import { import {
Gesture, Gesture,
GestureDetector, GestureDetector,
@@ -38,6 +39,7 @@ export default function MoodIntensitySlider({
width = 320, width = 320,
height = 16, // 更粗的进度条 height = 16, // 更粗的进度条
}: MoodIntensitySliderProps) { }: MoodIntensitySliderProps) {
const { t } = useTranslation();
const thumbSize = 32; // 合适的触摸区域 const thumbSize = 32; // 合适的触摸区域
const translateX = useSharedValue(0); const translateX = useSharedValue(0);
const isDragging = useSharedValue(0); const isDragging = useSharedValue(0);
@@ -175,8 +177,8 @@ export default function MoodIntensitySlider({
{/* 标签 */} {/* 标签 */}
<View style={[styles.labelsContainer, { width: width }]}> <View style={[styles.labelsContainer, { width: width }]}>
<Text style={styles.labelText}></Text> <Text style={styles.labelText}>{t('mood.edit.intensityLow')}</Text>
<Text style={styles.labelText}></Text> <Text style={styles.labelText}>{t('mood.edit.intensityHigh')}</Text>
</View> </View>
{/* 刻度 */} {/* 刻度 */}

View File

@@ -6,11 +6,13 @@ import {
TouchableOpacity, TouchableOpacity,
View View
} from 'react-native'; } from 'react-native';
import { useI18n } from '../hooks/useI18n';
import { useNotifications } from '../hooks/useNotifications'; import { useNotifications } from '../hooks/useNotifications';
import { ThemedText } from './ThemedText'; import { ThemedText } from './ThemedText';
import { ThemedView } from './ThemedView'; import { ThemedView } from './ThemedView';
export const NotificationTest: React.FC = () => { export const NotificationTest: React.FC = () => {
const { t } = useI18n();
const { const {
isInitialized, isInitialized,
permissionStatus, permissionStatus,
@@ -95,8 +97,8 @@ export const NotificationTest: React.FC = () => {
const handleSendMoodCheckinReminder = async () => { const handleSendMoodCheckinReminder = async () => {
try { try {
await sendMoodCheckinReminder('心情打卡', '记得记录今天的心情状态哦'); await sendMoodCheckinReminder(t('notifications.moodReminder.title'), t('notifications.moodReminder.body'));
Alert.alert('成功', '心情打卡提醒已发送'); Alert.alert(t('common.success'), t('notifications.moodReminder.sent'));
} catch (error) { } catch (error) {
Alert.alert('错误', '发送心情打卡提醒失败'); Alert.alert('错误', '发送心情打卡提醒失败');
} }

View File

@@ -82,10 +82,10 @@ const SimpleRingProgress = ({
/> />
</Svg> </Svg>
<View style={{ position: 'absolute', alignItems: 'center', justifyContent: 'center', top: 0, left: 0, right: 0, bottom: 0 }}> <View style={{ position: 'absolute', alignItems: 'center', justifyContent: 'center', top: 0, left: 0, right: 0, bottom: 0 }}>
<Text style={{ fontSize: 12, fontWeight: '600', color: '#192126' }}> <Text style={{ fontSize: 12, fontWeight: '600', color: '#192126', fontFamily: 'AliBold' }}>
{Math.round(remainingCalories)} {Math.round(remainingCalories)}
</Text> </Text>
<Text style={{ fontSize: 8, color: '#9AA3AE' }}>{t('statistics.components.diet.remaining')}</Text> <Text style={{ fontSize: 8, color: '#9AA3AE', fontFamily: 'AliRegular' }}>{t('statistics.components.diet.remaining')}</Text>
</View> </View>
</View> </View>
); );
@@ -361,12 +361,14 @@ const styles = StyleSheet.create({
cardTitle: { cardTitle: {
fontSize: 14, fontSize: 14,
color: '#192126', color: '#192126',
fontWeight: '600' fontWeight: '600',
fontFamily: 'AliBold',
}, },
cardSubtitle: { cardSubtitle: {
fontSize: 10, fontSize: 10,
color: '#9AA3AE', color: '#9AA3AE',
fontWeight: '600', fontWeight: '600',
fontFamily: 'AliRegular',
}, },
contentContainer: { contentContainer: {
flexDirection: 'row', flexDirection: 'row',
@@ -419,11 +421,13 @@ const styles = StyleSheet.create({
fontSize: 10, fontSize: 10,
color: '#9AA3AE', color: '#9AA3AE',
flex: 1, flex: 1,
fontFamily: 'AliRegular',
}, },
statValue: { statValue: {
fontSize: 12, fontSize: 12,
color: '#192126', color: '#192126',
fontWeight: '600', fontWeight: '600',
fontFamily: 'AliBold',
}, },
// 卡路里相关样式 // 卡路里相关样式
calorieSection: { calorieSection: {
@@ -442,6 +446,7 @@ const styles = StyleSheet.create({
fontSize: 16, fontSize: 16,
fontWeight: '800', fontWeight: '800',
color: '#192126', color: '#192126',
fontFamily: 'AliBold',
}, },
calorieContent: { calorieContent: {
}, },
@@ -450,6 +455,7 @@ const styles = StyleSheet.create({
color: '#64748B', color: '#64748B',
fontWeight: '600', fontWeight: '600',
marginRight: 4, marginRight: 4,
fontFamily: 'AliRegular',
}, },
calculationRow: { calculationRow: {
flexDirection: 'row', flexDirection: 'row',
@@ -461,11 +467,13 @@ const styles = StyleSheet.create({
fontSize: 14, fontSize: 14,
fontWeight: '600', fontWeight: '600',
color: '#192126', color: '#192126',
fontFamily: 'AliBold',
}, },
calculationText: { calculationText: {
fontSize: 10, fontSize: 10,
fontWeight: '600', fontWeight: '600',
color: '#64748B', color: '#64748B',
fontFamily: 'AliRegular',
}, },
calculationItem: { calculationItem: {
flexDirection: 'row', flexDirection: 'row',
@@ -476,11 +484,13 @@ const styles = StyleSheet.create({
fontSize: 9, fontSize: 9,
color: '#64748B', color: '#64748B',
fontWeight: '500', fontWeight: '500',
fontFamily: 'AliRegular',
}, },
calculationValue: { calculationValue: {
fontSize: 11, fontSize: 11,
fontWeight: '700', fontWeight: '700',
color: '#192126', color: '#192126',
fontFamily: 'AliBold',
}, },
remainingCaloriesContainer: { remainingCaloriesContainer: {
flexDirection: 'row', flexDirection: 'row',
@@ -491,6 +501,7 @@ const styles = StyleSheet.create({
fontSize: 10, fontSize: 10,
color: '#64748B', color: '#64748B',
fontWeight: '500', fontWeight: '500',
fontFamily: 'AliRegular',
}, },
mealsContainer: { mealsContainer: {
flexDirection: 'row', flexDirection: 'row',
@@ -514,6 +525,7 @@ const styles = StyleSheet.create({
fontSize: 10, fontSize: 10,
color: '#64748B', color: '#64748B',
fontWeight: '600', fontWeight: '600',
fontFamily: 'AliRegular',
}, },
// 食物选项样式 // 食物选项样式
foodOptionsContainer: { foodOptionsContainer: {
@@ -559,5 +571,6 @@ const styles = StyleSheet.create({
fontWeight: '500', fontWeight: '500',
color: '#192126', color: '#192126',
textAlign: 'center', textAlign: 'center',
fontFamily: 'AliRegular',
}, },
}); });

View File

@@ -1,9 +1,10 @@
import { ThemedText } from '@/components/ThemedText'; import { ThemedText } from '@/components/ThemedText';
import { Image } from '@/components/ui/Image';
import { useI18n } from '@/hooks/useI18n';
import { useThemeColor } from '@/hooks/useThemeColor'; import { useThemeColor } from '@/hooks/useThemeColor';
import { DietRecord } from '@/services/dietRecords'; import { DietRecord } from '@/services/dietRecords';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { Image } from 'expo-image';
import React, { useMemo, useRef, useState } from 'react'; import React, { useMemo, useRef, useState } from 'react';
import { Alert, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { Alert, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { RectButton, Swipeable } from 'react-native-gesture-handler'; import { RectButton, Swipeable } from 'react-native-gesture-handler';
@@ -15,14 +16,6 @@ export type NutritionRecordCardProps = {
onDelete?: () => void; onDelete?: () => void;
}; };
const MEAL_TYPE_LABELS = {
breakfast: '早餐',
lunch: '午餐',
dinner: '晚餐',
snack: '加餐',
other: '其他',
} as const;
const MEAL_TYPE_ICONS = { const MEAL_TYPE_ICONS = {
breakfast: 'sunny-outline', breakfast: 'sunny-outline',
lunch: 'partly-sunny-outline', lunch: 'partly-sunny-outline',
@@ -44,46 +37,40 @@ export function NutritionRecordCard({
onPress, onPress,
onDelete onDelete
}: NutritionRecordCardProps) { }: NutritionRecordCardProps) {
const surfaceColor = useThemeColor({}, 'surface'); const { t } = useI18n();
const textColor = useThemeColor({}, 'text'); const textColor = useThemeColor({}, 'text');
const textSecondaryColor = useThemeColor({}, 'textSecondary'); const textSecondaryColor = useThemeColor({}, 'textSecondary');
// Popover 状态管理
const [showPopover, setShowPopover] = useState(false);
const popoverRef = useRef<any>(null);
// 左滑删除相关 // 左滑删除相关
const swipeableRef = useRef<Swipeable>(null); const swipeableRef = useRef<Swipeable>(null);
// 添加滑动状态管理,防止滑动时触发点击事件
const [isSwiping, setIsSwiping] = useState(false); const [isSwiping, setIsSwiping] = useState(false);
// 营养数据统计 // 营养数据统计
const nutritionStats = useMemo(() => { const nutritionStats = useMemo(() => {
return [ return [
{ {
label: '蛋白质', label: t('nutritionRecords.nutrients.protein'),
value: record.proteinGrams ? `${record.proteinGrams.toFixed(1)}g` : '-', value: record.proteinGrams ? `${Math.round(record.proteinGrams)}` : '-',
icon: '🥩', unit: t('nutritionRecords.nutrients.unit'),
color: '#FF6B6B' color: '#64748B'
}, },
{ {
label: '脂肪', label: t('nutritionRecords.nutrients.fat'),
value: record.fatGrams ? `${record.fatGrams.toFixed(1)}g` : '-', value: record.fatGrams ? `${Math.round(record.fatGrams)}` : '-',
icon: '🥑', unit: t('nutritionRecords.nutrients.unit'),
color: '#FFB366' color: '#64748B'
}, },
{ {
label: '碳水', label: t('nutritionRecords.nutrients.carbs'),
value: record.carbohydrateGrams ? `${record.carbohydrateGrams.toFixed(1)}g` : '-', value: record.carbohydrateGrams ? `${Math.round(record.carbohydrateGrams)}` : '-',
icon: '🍞', unit: t('nutritionRecords.nutrients.unit'),
color: '#4ECDC4' color: '#64748B'
}, },
]; ];
}, [record]); }, [record, t]);
const mealTypeColor = MEAL_TYPE_COLORS[record.mealType]; const mealTypeColor = MEAL_TYPE_COLORS[record.mealType];
const mealTypeLabel = MEAL_TYPE_LABELS[record.mealType]; const mealTypeLabel = t(`nutritionRecords.mealTypes.${record.mealType}`);
// 处理点击事件,只有在非滑动状态下才触发 // 处理点击事件,只有在非滑动状态下才触发
const handlePress = () => { const handlePress = () => {
@@ -92,31 +79,17 @@ export function NutritionRecordCard({
} }
}; };
// 处理滑动开始 const handleSwipeableWillOpen = () => setIsSwiping(true);
const handleSwipeableWillOpen = () => { const handleSwipeableClose = () => setTimeout(() => setIsSwiping(false), 100);
setIsSwiping(true);
};
// 处理滑动结束
const handleSwipeableClose = () => {
// 延迟重置滑动状态,防止滑动结束时立即触发点击
setTimeout(() => {
setIsSwiping(false);
}, 100);
};
// 处理删除操作
const handleDelete = () => { const handleDelete = () => {
Alert.alert( Alert.alert(
'确认删除', t('nutritionRecords.delete.title'),
`确定要删除这条营养记录吗?此操作无法撤销。`, t('nutritionRecords.delete.message'),
[ [
{ text: t('nutritionRecords.delete.cancel'), style: 'cancel' },
{ {
text: '取消', text: t('nutritionRecords.delete.confirm'),
style: 'cancel',
},
{
text: '删除',
style: 'destructive', style: 'destructive',
onPress: () => { onPress: () => {
onDelete?.(); onDelete?.();
@@ -127,7 +100,6 @@ export function NutritionRecordCard({
); );
}; };
// 渲染删除按钮
const renderRightActions = () => { const renderRightActions = () => {
return ( return (
<TouchableOpacity <TouchableOpacity
@@ -136,7 +108,6 @@ export function NutritionRecordCard({
activeOpacity={0.8} activeOpacity={0.8}
> >
<Ionicons name="trash" size={20} color="#FFFFFF" /> <Ionicons name="trash" size={20} color="#FFFFFF" />
<Text style={styles.deleteButtonText}></Text>
</TouchableOpacity> </TouchableOpacity>
); );
}; };
@@ -152,239 +123,228 @@ export function NutritionRecordCard({
onSwipeableClose={handleSwipeableClose} onSwipeableClose={handleSwipeableClose}
> >
<RectButton <RectButton
style={[ style={styles.card}
styles.card,
]}
onPress={handlePress} onPress={handlePress}
// activeOpacity={0.7}
> >
{/* 主要内容区域 - 水平布局 */}
<View style={styles.mainContent}> <View style={styles.mainContent}>
{/* 左侧:食物图片 */} {/* 左侧:时间线和图标 */}
<View style={[styles.foodImageContainer, !record.imageUrl && styles.foodImagePlaceholder]}> <View style={styles.leftSection}>
{record.imageUrl ? ( <View style={styles.mealIconContainer}>
<Image <Image
source={{ uri: record.imageUrl }} source={require('@/assets/images/icons/icon-food.png')}
style={styles.foodImage} style={styles.mealIcon}
cachePolicy={'memory-disk'} />
/> </View>
) : (
<Ionicons name="restaurant" size={28} color={textSecondaryColor} />
)}
</View> </View>
{/* 中间:食物信息 */} {/* 中间:主要信息 */}
<View style={styles.foodInfoContainer}> <View style={styles.centerSection}>
{/* 食物名称 */} <View style={styles.titleRow}>
<ThemedText style={[styles.foodName, { color: textColor }]}> <ThemedText style={styles.foodName} numberOfLines={1}>
{record.foodName} {record.foodName}
</ThemedText> </ThemedText>
<View style={[styles.mealTag, { backgroundColor: `${mealTypeColor}15` }]}>
{/* 时间 */} <Text style={[styles.mealTagText, { color: mealTypeColor }]}>{mealTypeLabel}</Text>
<ThemedText style={[styles.mealTime, { color: textSecondaryColor }]}> </View>
{record.mealTime ? dayjs(record.mealTime).format('HH:mm') : '--:--'} </View>
</ThemedText>
<View style={styles.metaRow}>
{/* 营养信息 - 水平排列 */} <Ionicons name="time-outline" size={12} color="#94A3B8" />
<View style={styles.nutritionContainer}> <Text style={styles.timeText}>
{record.mealTime ? dayjs(record.mealTime).format('HH:mm') : '--:--'}
</Text>
{record.portionDescription && (
<>
<Text style={styles.dotSeparator}>·</Text>
<Text style={styles.portionText} numberOfLines={1}>{record.portionDescription}</Text>
</>
)}
</View>
{/* 营养微缩信息 */}
<View style={styles.nutritionRow}>
{nutritionStats.map((stat, index) => ( {nutritionStats.map((stat, index) => (
<View key={stat.label} style={styles.nutritionItem}> <View key={index} style={styles.nutritionItem}>
<ThemedText style={styles.nutritionIcon}>{stat.icon}</ThemedText> <Text style={styles.nutritionValue}>{stat.value}<Text style={styles.nutritionUnit}>{stat.unit}</Text></Text>
<ThemedText style={[styles.nutritionValue, { color: textColor }]}> <Text style={styles.nutritionLabel}>{stat.label}</Text>
{stat.value} </View>
</ThemedText>
</View>
))} ))}
</View> </View>
</View> </View>
{/* 右侧:热量和餐次标签 */} {/* 右侧:热量 */}
<View style={styles.rightSection}> <View style={styles.rightSection}>
{/* 热量显示 */} <Text style={styles.caloriesValue}>
<View style={styles.caloriesContainer}> {record.estimatedCalories ? Math.round(record.estimatedCalories) : '-'}
<ThemedText style={[styles.caloriesText]}> </Text>
{record.estimatedCalories ? `${Math.round(record.estimatedCalories)} kcal` : '- kcal'} <Text style={styles.caloriesUnit}>{t('nutritionRecords.nutrients.caloriesUnit')}</Text>
</ThemedText>
</View>
{/* 餐次标签 */}
<View style={[styles.mealTypeBadge]}>
<ThemedText style={[styles.mealTypeText, { color: mealTypeColor }]}>
{mealTypeLabel}
</ThemedText>
</View>
</View> </View>
</View> </View>
{/* 如果有图片,显示图片缩略图 */}
{record.imageUrl && (
<View style={styles.imageSection}>
<Image
source={{ uri: record.imageUrl }}
style={styles.foodImage}
contentFit="cover"
transition={200}
/>
</View>
)}
</RectButton> </RectButton>
</Swipeable> </Swipeable>
</View> </View>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
marginBottom: 16, marginBottom: 12,
// iOS 阴影效果 - 更自然的阴影 marginHorizontal: 24,
shadowColor: '#000000', shadowColor: 'rgba(30, 41, 59, 0.05)',
shadowOffset: { width: 0, height: 2 }, shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.1, shadowOpacity: 0.1,
shadowRadius: 8, shadowRadius: 12,
// Android 阴影效果 elevation: 2,
elevation: 3,
}, },
card: { card: {
flex: 1,
minHeight: 100,
backgroundColor: '#FFFFFF', backgroundColor: '#FFFFFF',
borderRadius: 16, borderRadius: 24,
paddingHorizontal: 16, padding: 16,
paddingVertical: 14,
}, },
mainContent: { mainContent: {
flex: 1,
flexDirection: 'row', flexDirection: 'row',
},
leftSection: {
marginRight: 12,
alignItems: 'center', alignItems: 'center',
}, },
foodImageContainer: { mealIconContainer: {
width: 48, width: 40,
height: 48, height: 40,
borderRadius: 12, borderRadius: 14,
marginRight: 16, backgroundColor: '#F8FAFC',
alignItems: 'center',
justifyContent: 'center',
},
mealIcon: {
width: 20,
height: 20,
opacity: 0.8,
},
centerSection: {
flex: 1,
marginRight: 12,
},
titleRow: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 4,
gap: 8,
},
foodName: {
fontSize: 16,
fontWeight: '700',
color: '#1E293B',
fontFamily: 'AliBold',
flexShrink: 1,
},
mealTag: {
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: 6,
},
mealTagText: {
fontSize: 10,
fontWeight: '600',
fontFamily: 'AliBold',
},
metaRow: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 10,
},
timeText: {
fontSize: 12,
color: '#94A3B8',
marginLeft: 4,
fontFamily: 'AliRegular',
},
dotSeparator: {
marginHorizontal: 4,
color: '#CBD5E1',
},
portionText: {
fontSize: 12,
color: '#64748B',
fontFamily: 'AliRegular',
flex: 1,
},
nutritionRow: {
flexDirection: 'row',
gap: 12,
},
nutritionItem: {
flexDirection: 'row',
alignItems: 'baseline',
gap: 2,
},
nutritionValue: {
fontSize: 13,
fontWeight: '600',
color: '#475569',
fontFamily: 'AliBold',
},
nutritionUnit: {
fontSize: 10,
fontWeight: '500',
color: '#94A3B8',
marginLeft: 1,
},
nutritionLabel: {
fontSize: 10,
color: '#94A3B8',
marginLeft: 2,
fontFamily: 'AliRegular',
},
rightSection: {
alignItems: 'flex-end',
justifyContent: 'flex-start',
paddingTop: 2,
},
caloriesValue: {
fontSize: 18,
fontWeight: '800',
color: '#1E293B',
fontFamily: 'AliBold',
lineHeight: 22,
},
caloriesUnit: {
fontSize: 10,
color: '#94A3B8',
fontWeight: '500',
fontFamily: 'AliRegular',
},
imageSection: {
marginTop: 12,
height: 120,
width: '100%',
borderRadius: 16,
overflow: 'hidden', overflow: 'hidden',
backgroundColor: '#F1F5F9',
}, },
foodImage: { foodImage: {
width: '100%', width: '100%',
height: '100%', height: '100%',
borderRadius: 8,
},
foodImagePlaceholder: {
backgroundColor: '#F8F9FA',
justifyContent: 'center',
alignItems: 'center',
},
foodInfoContainer: {
flex: 1,
justifyContent: 'center',
gap: 4,
},
foodName: {
fontSize: 16,
fontWeight: '600',
color: '#333333',
lineHeight: 20,
},
mealTime: {
fontSize: 12,
fontWeight: '400',
color: '#999999',
lineHeight: 16,
},
nutritionContainer: {
flexDirection: 'row',
alignItems: 'center',
gap: 16,
marginTop: 2,
},
nutritionItem: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
},
nutritionIcon: {
fontSize: 14,
},
nutritionValue: {
fontSize: 13,
fontWeight: '500',
color: '#666666',
},
rightSection: {
alignItems: 'flex-end',
justifyContent: 'center',
gap: 8,
minHeight: 60,
},
caloriesContainer: {
flexDirection: 'row',
alignItems: 'center',
},
caloriesText: {
fontSize: 14,
color: '#333333',
fontWeight: '600',
},
mealTypeBadge: {
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 12,
backgroundColor: 'rgba(0,0,0,0.05)',
},
mealTypeText: {
fontSize: 12,
fontWeight: '600',
},
moreButton: {
padding: 2,
},
notesSection: {
marginTop: 8,
paddingTop: 12,
borderTopWidth: 1,
borderTopColor: 'rgba(0,0,0,0.06)',
},
notesText: {
fontSize: 13,
fontWeight: '500',
lineHeight: 18,
fontStyle: 'italic',
},
popoverContainer: {
borderRadius: 12,
backgroundColor: '#FFFFFF',
// iOS 阴影效果
shadowColor: '#000000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.15,
shadowRadius: 12,
// Android 阴影效果
elevation: 8,
// 添加边框
borderWidth: 0.5,
borderColor: 'rgba(0, 0, 0, 0.08)',
},
popoverBackground: {
backgroundColor: 'rgba(0, 0, 0, 0.3)',
},
popoverContent: {
minWidth: 140,
paddingVertical: 8,
},
popoverItem: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 12,
gap: 12,
},
popoverText: {
fontSize: 16,
fontWeight: '500',
}, },
deleteButton: { deleteButton: {
backgroundColor: '#EF4444', backgroundColor: '#FF6B6B',
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
width: 80, width: 70,
borderRadius: 12, height: '100%',
marginLeft: 8, borderRadius: 24,
}, marginLeft: 12,
deleteButtonText: {
color: '#FFFFFF',
fontSize: 12,
fontWeight: '600',
marginTop: 4,
}, },
}); });

View File

@@ -9,10 +9,13 @@ import {
ViewStyle ViewStyle
} from 'react-native'; } 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 { fetchHourlyStepSamples, fetchStepCount, HourlyStepData } from '@/utils/health';
import { logger } from '@/utils/logger'; import { logger } from '@/utils/logger';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { Image } from 'expo-image';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { AnimatedNumber } from './AnimatedNumber'; import { AnimatedNumber } from './AnimatedNumber';
@@ -20,8 +23,8 @@ import { AnimatedNumber } from './AnimatedNumber';
// import Svg, { Rect } from 'react-native-svg'; // import Svg, { Rect } from 'react-native-svg';
interface StepsCardProps { interface StepsCardProps {
curDate: Date curDate: Date;
stepGoal: number; stepGoal?: number;
style?: ViewStyle; style?: ViewStyle;
} }
@@ -31,9 +34,20 @@ const StepsCard: React.FC<StepsCardProps> = ({
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const router = useRouter(); const router = useRouter();
const dispatch = useAppDispatch();
const challenges = useAppSelector(selectChallengeList);
const [stepCount, setStepCount] = useState(0) const [stepCount, setStepCount] = useState(0);
const [hourlySteps, setHourSteps] = useState<HourlyStepData[]>([]) const [hourlySteps, setHourSteps] = useState<HourlyStepData[]>([]);
// 过滤出已参加的步数挑战
const joinedStepsChallenges = useMemo(
() => challenges.filter((challenge) => challenge.type === ChallengeType.STEP && challenge.isJoined && challenge.status === 'ongoing'),
[challenges]
);
// 跟踪上次上报的记录,避免重复上报
const lastReportedRef = useRef<{ date: string; value: number } | null>(null);
const getStepData = useCallback(async (date: Date) => { const getStepData = useCallback(async (date: Date) => {
@@ -59,6 +73,42 @@ const StepsCard: React.FC<StepsCardProps> = ({
} }
}, [curDate]); }, [curDate]);
// 步数挑战进度上报逻辑
useEffect(() => {
if (!curDate || !stepCount || !joinedStepsChallenges.length) {
return;
}
// 如果当前日期不是今天,不上报
if (!dayjs(curDate).isSame(dayjs(), 'day')) {
return;
}
const dateKey = dayjs(curDate).format('YYYY-MM-DD');
const lastReport = lastReportedRef.current;
if (lastReport && lastReport.date === dateKey && lastReport.value === stepCount) {
return;
}
const reportProgress = async () => {
const stepsChallenge = joinedStepsChallenges.find((c) => c.type === ChallengeType.STEP);
if (!stepsChallenge) {
return;
}
try {
await dispatch(reportChallengeProgress({ id: stepsChallenge.id, value: stepCount })).unwrap();
} catch (error) {
logger.warn('StepsCard: Challenge progress report failed', { error, challengeId: stepsChallenge.id });
}
lastReportedRef.current = { date: dateKey, value: stepCount };
};
reportProgress();
}, [dispatch, joinedStepsChallenges, curDate, stepCount]);
// 优化:减少动画值数量,只为有数据的小时创建动画 // 优化:减少动画值数量,只为有数据的小时创建动画
const animatedValues = useRef<Map<number, Animated.Value>>(new Map()).current; const animatedValues = useRef<Map<number, Animated.Value>>(new Map()).current;
@@ -244,7 +294,8 @@ const styles = StyleSheet.create({
title: { title: {
fontSize: 14, fontSize: 14,
color: '#192126', color: '#192126',
fontWeight: '600' fontWeight: '600',
fontFamily: 'AliBold',
}, },
footprintIcons: { footprintIcons: {
flexDirection: 'row', flexDirection: 'row',
@@ -290,6 +341,7 @@ const styles = StyleSheet.create({
fontSize: 18, fontSize: 18,
fontWeight: '600', fontWeight: '600',
color: '#192126', color: '#192126',
fontFamily: 'AliBold',
}, },
}); });

View File

@@ -1,323 +0,0 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Animated,
StyleSheet,
Text,
TouchableOpacity,
View,
ViewStyle,
InteractionManager
} from 'react-native';
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 { AnimatedNumber } from './AnimatedNumber';
interface StepsCardProps {
curDate: Date
stepGoal: number;
style?: ViewStyle;
}
const StepsCardOptimized: React.FC<StepsCardProps> = ({
curDate,
style,
}) => {
const router = useRouter();
const [stepCount, setStepCount] = useState(0)
const [hourlySteps, setHourSteps] = useState<HourlyStepData[]>([])
const [isLoading, setIsLoading] = useState(false)
// 优化使用debounce减少频繁的数据获取
const debounceTimer = useRef<NodeJS.Timeout | null>(null);
const getStepData = useCallback(async (date: Date) => {
try {
setIsLoading(true);
logger.info('获取步数数据...');
// 先获取步数立即更新UI
const steps = await fetchStepCount(date);
setStepCount(steps);
// 清除之前的定时器
if (debounceTimer.current) {
clearTimeout(debounceTimer.current);
}
// 使用 InteractionManager 在空闲时获取更复杂的小时数据
InteractionManager.runAfterInteractions(async () => {
try {
const hourly = await fetchHourlyStepSamples(date);
setHourSteps(hourly);
} catch (error) {
logger.error('获取小时步数数据失败:', error);
} finally {
setIsLoading(false);
}
});
} catch (error) {
logger.error('获取步数数据失败:', error);
setIsLoading(false);
}
}, []);
useEffect(() => {
if (curDate) {
getStepData(curDate);
}
}, [curDate, getStepData]);
// 优化:减少动画值数量,只为有数据的小时创建动画
const animatedValues = useRef<Map<number, Animated.Value>>(new Map()).current;
// 优化:简化柱状图数据计算,减少计算量
const chartData = useMemo(() => {
if (!hourlySteps || hourlySteps.length === 0) {
return Array.from({ length: 24 }, (_, i) => ({ hour: i, steps: 0, height: 0 }));
}
// 优化:只计算有数据的小时的最大步数
const activeSteps = hourlySteps.filter(data => data.steps > 0);
if (activeSteps.length === 0) {
return Array.from({ length: 24 }, (_, i) => ({ hour: i, steps: 0, height: 0 }));
}
const maxSteps = Math.max(...activeSteps.map(data => data.steps));
const maxHeight = 20;
return hourlySteps.map(data => ({
...data,
height: data.steps > 0 ? (data.steps / maxSteps) * maxHeight : 0
}));
}, [hourlySteps]);
// 获取当前小时
const currentHour = new Date().getHours();
// 优化延迟执行动画减少UI阻塞
useEffect(() => {
const hasData = chartData && chartData.length > 0 && chartData.some(data => data.steps > 0);
if (hasData && !isLoading) {
// 使用 InteractionManager 确保动画不会阻塞用户交互
InteractionManager.runAfterInteractions(() => {
// 只为有数据的小时创建和执行动画
const animations = chartData
.map((data, index) => {
if (data.steps > 0) {
// 懒创建动画值
if (!animatedValues.has(index)) {
animatedValues.set(index, new Animated.Value(0));
}
const animValue = animatedValues.get(index)!;
animValue.setValue(0);
// 使用更高性能的timing动画替代spring
return Animated.timing(animValue, {
toValue: 1,
duration: 200, // 减少动画时长
useNativeDriver: false,
});
}
return null;
})
.filter(Boolean) as Animated.CompositeAnimation[];
// 批量执行动画,提高性能
if (animations.length > 0) {
Animated.stagger(50, animations).start();
}
});
}
}, [chartData, animatedValues, isLoading]);
// 优化使用React.memo包装复杂的渲染组件
const ChartBars = useMemo(() => {
return chartData.map((data, index) => {
// 判断是否是当前小时或者有活动的小时
const isActive = data.steps > 0;
const isCurrent = index <= currentHour;
// 优化:只为有数据的柱体创建动画插值
const animValue = animatedValues.get(index);
let animatedScale: Animated.AnimatedInterpolation<number> | undefined;
let animatedOpacity: Animated.AnimatedInterpolation<number> | undefined;
if (animValue && isActive) {
animatedScale = animValue.interpolate({
inputRange: [0, 1],
outputRange: [0, 1],
});
animatedOpacity = animValue.interpolate({
inputRange: [0, 1],
outputRange: [0, 1],
});
}
return (
<View key={`bar-container-${index}`} style={styles.barContainer}>
{/* 背景柱体 - 始终显示,使用相似色系的淡色 */}
<View
style={[
styles.chartBar,
{
height: 20, // 背景柱体占满整个高度
backgroundColor: isCurrent ? '#FFF4E6' : '#FFF8F0', // 更淡的相似色系
}
]}
/>
{/* 数据柱体 - 只有当有数据时才显示并执行动画 */}
{isActive && (
<Animated.View
style={[
styles.chartBar,
{
height: data.height,
backgroundColor: isCurrent ? '#FFC365' : '#FFEBCB',
transform: animatedScale ? [{ scaleY: animatedScale }] : undefined,
opacity: animatedOpacity || 1,
}
]}
/>
)}
</View>
);
});
}, [chartData, currentHour, animatedValues]);
const CardContent = () => (
<>
{/* 标题和步数显示 */}
<View style={styles.header}>
<Image
source={require('@/assets/images/icons/icon-step.png')}
style={styles.titleIcon}
/>
<Text style={styles.title}></Text>
{isLoading && <Text style={styles.loadingText}>...</Text>}
</View>
{/* 柱状图 */}
<View style={styles.chartContainer}>
<View style={styles.chartWrapper}>
<View style={styles.chartArea}>
{ChartBars}
</View>
</View>
</View>
{/* 步数和目标显示 */}
<View style={styles.statsContainer}>
<AnimatedNumber
value={stepCount || 0}
style={styles.stepCount}
format={(v) => stepCount !== null ? `${Math.round(v)}` : '——'}
resetToken={stepCount}
/>
</View>
</>
);
return (
<TouchableOpacity
style={[styles.container, style]}
onPress={() => {
// 传递当前日期参数到详情页
const dateParam = dayjs(curDate).format('YYYY-MM-DD');
router.push(`/steps/detail?date=${dateParam}`);
}}
activeOpacity={0.8}
>
<CardContent />
</TouchableOpacity>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'space-between',
borderRadius: 20,
padding: 16,
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 4,
},
shadowOpacity: 0.08,
shadowRadius: 20,
elevation: 8,
},
header: {
flexDirection: 'row',
justifyContent: 'flex-start',
alignItems: 'center',
},
titleIcon: {
width: 16,
height: 16,
marginRight: 6,
resizeMode: 'contain',
},
title: {
fontSize: 14,
color: '#192126',
fontWeight: '600'
},
loadingText: {
fontSize: 10,
color: '#666',
marginLeft: 8,
},
chartContainer: {
flex: 1,
justifyContent: 'center',
marginTop: 6
},
chartWrapper: {
width: '100%',
alignItems: 'center',
},
chartArea: {
flexDirection: 'row',
alignItems: 'flex-end',
height: 20,
width: '100%',
maxWidth: 240,
justifyContent: 'space-between',
paddingHorizontal: 4,
},
barContainer: {
width: 4,
height: 20,
alignItems: 'center',
justifyContent: 'flex-end',
position: 'relative',
},
chartBar: {
width: 4,
borderRadius: 1,
position: 'absolute',
bottom: 0,
},
statsContainer: {
alignItems: 'flex-start',
marginTop: 6
},
stepCount: {
fontSize: 18,
fontWeight: '600',
color: '#192126',
},
});
export default StepsCardOptimized;

View File

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

View File

@@ -1,6 +1,6 @@
import { Image } from '@/components/ui/Image';
import { fetchHRVWithStatus } from '@/utils/health'; import { fetchHRVWithStatus } from '@/utils/health';
import { convertHrvToStressIndex } from '@/utils/stress'; import { convertHrvToStressIndex } from '@/utils/stress';
import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -15,6 +15,7 @@ export function StressMeter({ curDate }: StressMeterProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [hrvValue, setHrvValue] = useState(0) const [hrvValue, setHrvValue] = useState(0)
const [updateTime, setUpdateTime] = useState<Date>(new Date())
useEffect(() => { useEffect(() => {
@@ -32,6 +33,9 @@ export function StressMeter({ curDate }: StressMeterProps) {
if (result.hrvData) { if (result.hrvData) {
setHrvValue(Math.round(result.hrvData.value)); setHrvValue(Math.round(result.hrvData.value));
if (result.hrvData.recordedAt) {
setUpdateTime(new Date(result.hrvData.recordedAt));
}
console.log(`StressMeter: Using ${result.message}, HRV value: ${result.hrvData.value}ms`); console.log(`StressMeter: Using ${result.message}, HRV value: ${result.hrvData.value}ms`);
} else { } else {
console.log('StressMeter: No HRV data obtained'); console.log('StressMeter: No HRV data obtained');
@@ -92,7 +96,7 @@ export function StressMeter({ curDate }: StressMeterProps) {
{/* 渐变背景进度条 */} {/* 渐变背景进度条 */}
<View style={[styles.progressBar, { width: '100%' }]}> <View style={[styles.progressBar, { width: '100%' }]}>
<LinearGradient <LinearGradient
colors={['#EF4444', '#FCD34D', '#10B981']} colors={['#10B981', '#FCD34D', '#EF4444']}
start={{ x: 0, y: 0 }} start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }} end={{ x: 1, y: 0 }}
style={styles.gradientBar} style={styles.gradientBar}
@@ -110,7 +114,7 @@ export function StressMeter({ curDate }: StressMeterProps) {
visible={showStressModal} visible={showStressModal}
onClose={() => setShowStressModal(false)} onClose={() => setShowStressModal(false)}
hrvValue={hrvValue} hrvValue={hrvValue}
updateTime={new Date()} updateTime={updateTime}
/> />
</> </>
); );
@@ -158,7 +162,8 @@ const styles = StyleSheet.create({
title: { title: {
fontSize: 14, fontSize: 14,
color: '#192126', color: '#192126',
fontWeight: '600' fontWeight: '600',
fontFamily: 'AliBold',
}, },
valueSection: { valueSection: {
flexDirection: 'row', flexDirection: 'row',
@@ -171,12 +176,14 @@ const styles = StyleSheet.create({
color: '#192126', color: '#192126',
lineHeight: 20, lineHeight: 20,
marginTop: 2, marginTop: 2,
fontFamily: 'AliBold',
}, },
unit: { unit: {
fontSize: 12, fontSize: 12,
fontWeight: '500', fontWeight: '500',
color: '#9AA3AE', color: '#9AA3AE',
marginLeft: 4, marginLeft: 4,
fontFamily: 'AliRegular',
}, },
progressContainer: { progressContainer: {
height: 6, height: 6,

View File

@@ -0,0 +1,343 @@
import type { VersionInfo } from '@/services/version';
import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import React, { useMemo } from 'react';
import {
Modal,
Pressable,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native';
type VersionUpdateModalProps = {
visible: boolean;
info: VersionInfo | null;
currentVersion: string;
onClose: () => void;
onUpdate: () => void;
strings: {
title: string;
tag: string;
currentVersionLabel: string;
latestVersionLabel: string;
updatesTitle: string;
fallbackNote: string;
remindLater: string;
updateCta: string;
};
};
export function VersionUpdateModal({
visible,
info,
currentVersion,
onClose,
onUpdate,
strings,
}: VersionUpdateModalProps) {
const notes = useMemo(() => {
if (!info) return [];
if (info.releaseNotes && info.releaseNotes.trim().length > 0) {
return info.releaseNotes
.split(/\r?\n+/)
.map((line) => line.trim())
.filter(Boolean);
}
if (info.updateMessage && info.updateMessage.trim().length > 0) {
return [info.updateMessage.trim()];
}
return [];
}, [info]);
if (!info) return null;
return (
<Modal
animationType="fade"
transparent
visible={visible}
onRequestClose={onClose}
>
<View style={styles.overlay}>
<Pressable style={StyleSheet.absoluteFill} onPress={onClose} />
<View style={styles.cardShadow}>
<LinearGradient
colors={['#0F1B61', '#0F274A', '#0A1A3A']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.card}
>
<LinearGradient
colors={['rgba(255,255,255,0.18)', 'rgba(255,255,255,0.03)']}
style={styles.glowOrb}
/>
<LinearGradient
colors={['rgba(255,255,255,0.08)', 'transparent']}
style={styles.ribbon}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
<View style={styles.headerRow}>
<View style={styles.tag}>
<Ionicons name="sparkles" size={14} color="#0F1B61" />
<Text style={styles.tagText}>{strings.tag}</Text>
</View>
<TouchableOpacity onPress={onClose} style={styles.closeButton}>
<Ionicons name="close" size={18} color="#E5E7EB" />
</TouchableOpacity>
</View>
<View style={styles.titleBlock}>
<Text style={styles.title}>{strings.title}</Text>
<Text style={styles.subtitle}>
{info.latestVersion ? `v${info.latestVersion}` : ''}
</Text>
</View>
<View style={styles.metaRow}>
<View style={styles.metaChip}>
<Ionicons name="time-outline" size={14} color="#C7D2FE" />
<Text style={styles.metaText}>
{strings.currentVersionLabel} v{currentVersion}
</Text>
</View>
<View style={styles.metaChip}>
<Ionicons name="arrow-up-circle-outline" size={14} color="#C7D2FE" />
<Text style={styles.metaText}>
{strings.latestVersionLabel} v{info.latestVersion}
</Text>
</View>
</View>
<View style={styles.noteCard}>
<Text style={styles.noteTitle}>{strings.updatesTitle}</Text>
{notes.length > 0 ? (
notes.map((line, idx) => (
<View key={`${idx}-${line}`} style={styles.noteItem}>
<View style={styles.bullet}>
<Ionicons name="ellipse" size={6} color="#6EE7B7" />
</View>
<Text style={styles.noteText}>{line}</Text>
</View>
))
) : (
<Text style={styles.noteText}>{strings.fallbackNote}</Text>
)}
</View>
<View style={styles.actions}>
<TouchableOpacity
activeOpacity={0.85}
onPress={onClose}
style={styles.secondaryButton}
>
<Text style={styles.secondaryText}>{strings.remindLater}</Text>
</TouchableOpacity>
<TouchableOpacity
activeOpacity={0.9}
onPress={onUpdate}
style={styles.primaryButtonShadow}
>
<LinearGradient
colors={['#6EE7B7', '#3B82F6']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.primaryButton}
>
<Ionicons name="cloud-download-outline" size={18} color="#0B1236" />
<Text style={styles.primaryText}>{strings.updateCta}</Text>
</LinearGradient>
</TouchableOpacity>
</View>
</LinearGradient>
</View>
</View>
</Modal>
);
}
const styles = StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: 'rgba(7, 11, 34, 0.65)',
alignItems: 'center',
justifyContent: 'center',
padding: 20,
},
cardShadow: {
width: '100%',
maxWidth: 420,
shadowColor: '#0B1236',
shadowOpacity: 0.35,
shadowOffset: { width: 0, height: 16 },
shadowRadius: 30,
elevation: 8,
},
card: {
borderRadius: 24,
padding: 20,
overflow: 'hidden',
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.08)',
},
glowOrb: {
position: 'absolute',
width: 220,
height: 220,
borderRadius: 110,
right: -60,
top: -80,
opacity: 0.8,
},
ribbon: {
position: 'absolute',
left: -120,
bottom: -120,
width: 260,
height: 260,
transform: [{ rotate: '-8deg' }],
opacity: 0.6,
},
headerRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
tag: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 10,
paddingVertical: 6,
borderRadius: 14,
backgroundColor: '#A5B4FC',
},
tagText: {
color: '#0F1B61',
fontWeight: '700',
marginLeft: 6,
fontSize: 12,
letterSpacing: 0.3,
},
closeButton: {
width: 32,
height: 32,
borderRadius: 16,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(255,255,255,0.06)',
},
titleBlock: {
marginTop: 14,
marginBottom: 8,
},
title: {
fontSize: 24,
fontWeight: '800',
color: '#F9FAFB',
letterSpacing: 0.2,
},
subtitle: {
color: '#C7D2FE',
marginTop: 6,
fontSize: 15,
},
metaRow: {
flexDirection: 'row',
marginTop: 10,
gap: 8,
flexWrap: 'wrap',
},
metaChip: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(255,255,255,0.08)',
borderRadius: 12,
paddingHorizontal: 10,
paddingVertical: 8,
},
metaText: {
color: '#E5E7EB',
marginLeft: 6,
fontSize: 12,
},
noteCard: {
marginTop: 16,
borderRadius: 16,
padding: 14,
backgroundColor: 'rgba(255,255,255,0.06)',
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.04)',
},
noteTitle: {
color: '#F9FAFB',
fontWeight: '700',
fontSize: 15,
marginBottom: 8,
},
noteItem: {
flexDirection: 'row',
alignItems: 'flex-start',
marginTop: 8,
},
bullet: {
width: 18,
alignItems: 'center',
marginTop: 6,
},
noteText: {
flex: 1,
color: '#E5E7EB',
fontSize: 14,
lineHeight: 20,
},
actions: {
marginTop: 18,
flexDirection: 'row',
gap: 10,
},
secondaryButton: {
flex: 1,
height: 48,
borderRadius: 14,
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.16)',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(255,255,255,0.08)',
},
secondaryText: {
color: '#E5E7EB',
fontWeight: '600',
fontSize: 14,
},
primaryButtonShadow: {
flex: 1,
height: 48,
borderRadius: 14,
overflow: 'hidden',
shadowColor: '#1E40AF',
shadowOpacity: 0.4,
shadowOffset: { width: 0, height: 12 },
shadowRadius: 14,
elevation: 6,
},
primaryButton: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
},
primaryText: {
color: '#0B1236',
fontWeight: '800',
fontSize: 15,
},
});
export default VersionUpdateModal;

View File

@@ -1,10 +1,10 @@
import { Image } from '@/components/ui/Image';
import { useWaterDataByDate } from '@/hooks/useWaterData'; import { useWaterDataByDate } from '@/hooks/useWaterData';
import { appStoreReviewService } from '@/services/appStoreReview'; import { appStoreReviewService } from '@/services/appStoreReview';
import { getQuickWaterAmount } from '@/utils/userPreferences'; import { getQuickWaterAmount } from '@/utils/userPreferences';
import { useFocusEffect } from '@react-navigation/native'; import { useFocusEffect } from '@react-navigation/native';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import * as Haptics from 'expo-haptics'; import * as Haptics from 'expo-haptics';
import { Image } from 'expo-image';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import LottieView from 'lottie-react-native'; import LottieView from 'lottie-react-native';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
@@ -309,6 +309,7 @@ const styles = StyleSheet.create({
fontSize: 14, fontSize: 14,
color: '#192126', color: '#192126',
fontWeight: '600', fontWeight: '600',
fontFamily: 'AliBold',
}, },
addButton: { addButton: {
borderRadius: 16, borderRadius: 16,
@@ -323,6 +324,7 @@ const styles = StyleSheet.create({
color: '#6366F1', color: '#6366F1',
fontWeight: '700', fontWeight: '700',
lineHeight: 10, lineHeight: 10,
fontFamily: 'AliBold',
}, },
chartContainer: { chartContainer: {
flex: 1, flex: 1,
@@ -363,11 +365,13 @@ const styles = StyleSheet.create({
fontSize: 14, fontSize: 14,
fontWeight: '600', fontWeight: '600',
color: '#192126', color: '#192126',
fontFamily: 'AliBold',
}, },
targetIntake: { targetIntake: {
fontSize: 12, fontSize: 12,
color: '#6B7280', color: '#6B7280',
marginLeft: 4, marginLeft: 4,
fontFamily: 'AliRegular',
}, },
}); });

View File

@@ -1,6 +1,6 @@
import { Image } from '@/components/ui/Image';
import { MaterialCommunityIcons } from '@expo/vector-icons'; import { MaterialCommunityIcons } from '@expo/vector-icons';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { Image } from 'expo-image';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -269,6 +269,7 @@ const styles = StyleSheet.create({
fontSize: 16, fontSize: 16,
color: '#1F2355', color: '#1F2355',
fontWeight: '600', fontWeight: '600',
fontFamily: 'AliBold',
}, },
addButton: { addButton: {
width: 28, width: 28,
@@ -287,6 +288,7 @@ const styles = StyleSheet.create({
fontSize: 20, fontSize: 20,
color: '#7A8FFF', color: '#7A8FFF',
marginTop: -2, marginTop: -2,
fontFamily: 'AliBold',
}, },
metricsRow: { metricsRow: {
flexDirection: 'row', flexDirection: 'row',
@@ -310,12 +312,14 @@ const styles = StyleSheet.create({
fontSize: 24, fontSize: 24,
fontWeight: '700', fontWeight: '700',
color: '#1F2355', color: '#1F2355',
fontFamily: 'AliBold',
}, },
metricLabel: { metricLabel: {
fontSize: 12, fontSize: 12,
color: '#4A5677', color: '#4A5677',
fontWeight: '500', fontWeight: '500',
marginBottom: 2, marginBottom: 2,
fontFamily: 'AliRegular',
}, },
detailsRow: { detailsRow: {
flexDirection: 'row', flexDirection: 'row',
@@ -331,14 +335,17 @@ const styles = StyleSheet.create({
fontSize: 13, fontSize: 13,
color: '#1F2355', color: '#1F2355',
fontWeight: '500', fontWeight: '500',
fontFamily: 'AliRegular',
}, },
lastWorkoutTime: { lastWorkoutTime: {
fontSize: 12, fontSize: 12,
color: '#7C85A3', color: '#7C85A3',
fontFamily: 'AliRegular',
}, },
sourceText: { sourceText: {
fontSize: 11, fontSize: 11,
color: '#9AA3C0', color: '#9AA3C0',
fontFamily: 'AliRegular',
}, },
badgesRow: { badgesRow: {
flexDirection: 'row', flexDirection: 'row',

View File

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

View File

@@ -1,6 +1,7 @@
import { Image } from '@/components/ui/Image';
import { useI18n } from '@/hooks/useI18n';
import type { RankingItem } from '@/store/challengesSlice'; import type { RankingItem } from '@/store/challengesSlice';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { Image } from 'expo-image';
import React from 'react'; import React from 'react';
import { StyleSheet, Text, View } from 'react-native'; import { StyleSheet, Text, View } from 'react-native';
@@ -18,34 +19,34 @@ const formatNumber = (value: number): string => {
return value.toFixed(2).replace(/0+$/, '').replace(/\.$/, ''); return value.toFixed(2).replace(/0+$/, '').replace(/\.$/, '');
}; };
const formatMinutes = (value: number): string => {
const safeValue = Math.max(0, Math.round(value));
const hours = safeValue / 60;
return `${hours.toFixed(1)} 小时`;
};
const formatValueWithUnit = (value: number | undefined, unit?: string): string | undefined => {
if (typeof value !== 'number' || Number.isNaN(value)) {
return undefined;
}
if (unit === 'min') {
return formatMinutes(value);
}
const formatted = formatNumber(value);
return unit ? `${formatted} ${unit}` : formatted;
};
export function ChallengeRankingItem({ item, index, showDivider = false, unit }: ChallengeRankingItemProps) { export function ChallengeRankingItem({ item, index, showDivider = false, unit }: ChallengeRankingItemProps) {
console.log('unit', unit); const { t } = useI18n();
const formatMinutes = (value: number): string => {
const safeValue = Math.max(0, Math.round(value));
const hours = safeValue / 60;
return `${hours.toFixed(1)} ${t('challengeDetail.ranking.hour')}`;
};
const formatValueWithUnit = (value: number | undefined, unit?: string): string | undefined => {
if (typeof value !== 'number' || Number.isNaN(value)) {
return undefined;
}
if (unit === 'min') {
return formatMinutes(value);
}
const formatted = formatNumber(value);
return unit ? `${formatted} ${unit}` : formatted;
};
const reportedLabel = formatValueWithUnit(item.todayReportedValue, unit); const reportedLabel = formatValueWithUnit(item.todayReportedValue, unit);
const targetLabel = formatValueWithUnit(item.todayTargetValue, unit); const targetLabel = formatValueWithUnit(item.todayTargetValue, unit);
const progressLabel = reportedLabel && targetLabel const progressLabel = reportedLabel && targetLabel
? `今日 ${reportedLabel} / ${targetLabel}` ? `${t('challengeDetail.ranking.today')} ${reportedLabel} / ${targetLabel}`
: reportedLabel : reportedLabel
? `今日 ${reportedLabel}` ? `${t('challengeDetail.ranking.today')} ${reportedLabel}`
: targetLabel : targetLabel
? `今日目标 ${targetLabel}` ? `${t('challengeDetail.ranking.todayGoal')} ${targetLabel}`
: undefined; : undefined;
return ( return (

View File

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

View File

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

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 { Ionicons } from '@expo/vector-icons';
import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import { Animated, Modal, Pressable, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { Animated, Modal, Pressable, StyleSheet, Text, TouchableOpacity, View } from 'react-native';

View File

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

View File

@@ -1,6 +1,7 @@
import { Image } from '@/components/ui/Image';
import { useI18n } from '@/hooks/useI18n';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect'; import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import React from 'react'; import React from 'react';
import { import {
@@ -25,6 +26,8 @@ interface MedicationPhotoGuideModalProps {
* 展示如何正确拍摄药品照片的说明和示例 * 展示如何正确拍摄药品照片的说明和示例
*/ */
export function MedicationPhotoGuideModal({ visible, onClose }: MedicationPhotoGuideModalProps) { export function MedicationPhotoGuideModal({ visible, onClose }: MedicationPhotoGuideModalProps) {
const { t } = useI18n();
return ( return (
<Modal <Modal
visible={visible} visible={visible}
@@ -48,8 +51,12 @@ export function MedicationPhotoGuideModal({ visible, onClose }: MedicationPhotoG
> >
{/* 标题部分 */} {/* 标题部分 */}
<View style={styles.guideHeader}> <View style={styles.guideHeader}>
<Text style={styles.guideStepBadge}></Text> <Text style={styles.guideStepBadge}>
<Text style={styles.guideTitle}></Text> {t('medications.aiCamera.guideModal.badge')}
</Text>
<Text style={styles.guideTitle}>
{t('medications.aiCamera.guideModal.title')}
</Text>
</View> </View>
{/* 示例图片 */} {/* 示例图片 */}
@@ -99,10 +106,10 @@ export function MedicationPhotoGuideModal({ visible, onClose }: MedicationPhotoG
{/* 说明文字 */} {/* 说明文字 */}
<View style={styles.guideDescription}> <View style={styles.guideDescription}>
<Text style={styles.guideDescriptionText}> <Text style={styles.guideDescriptionText}>
\\ {t('medications.aiCamera.guideModal.description1')}
</Text> </Text>
<Text style={styles.guideDescriptionText}> <Text style={styles.guideDescriptionText}>
线 {t('medications.aiCamera.guideModal.description2')}
</Text> </Text>
</View> </View>
@@ -124,7 +131,9 @@ export function MedicationPhotoGuideModal({ visible, onClose }: MedicationPhotoG
end={{ x: 1, y: 0 }} end={{ x: 1, y: 0 }}
style={styles.guideConfirmButtonGradient} style={styles.guideConfirmButtonGradient}
> >
<Text style={styles.guideConfirmButtonText}></Text> <Text style={styles.guideConfirmButtonText}>
{t('medications.aiCamera.guideModal.button')}
</Text>
</LinearGradient> </LinearGradient>
</GlassView> </GlassView>
) : ( ) : (
@@ -135,7 +144,9 @@ export function MedicationPhotoGuideModal({ visible, onClose }: MedicationPhotoG
end={{ x: 1, y: 0 }} end={{ x: 1, y: 0 }}
style={styles.guideConfirmButtonGradient} style={styles.guideConfirmButtonGradient}
> >
<Text style={styles.guideConfirmButtonText}></Text> <Text style={styles.guideConfirmButtonText}>
{t('medications.aiCamera.guideModal.button')}
</Text>
</LinearGradient> </LinearGradient>
</View> </View>
)} )}

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

@@ -2,6 +2,7 @@
import CustomCheckBox from '@/components/ui/CheckBox'; import CustomCheckBox from '@/components/ui/CheckBox';
import { USER_AGREEMENT_URL } from '@/constants/Agree'; import { USER_AGREEMENT_URL } from '@/constants/Agree';
import { useAppDispatch } from '@/hooks/redux'; import { useAppDispatch } from '@/hooks/redux';
import { useI18n } from '@/hooks/useI18n';
import { import {
MEMBERSHIP_PLAN_META, MEMBERSHIP_PLAN_META,
extractMembershipProductsFromOfferings, extractMembershipProductsFromOfferings,
@@ -65,51 +66,6 @@ interface BenefitItem {
regular: PermissionConfig; regular: PermissionConfig;
} }
// 权益对比配置
const BENEFIT_COMPARISON: BenefitItem[] = [
{
title: 'AI拍照记录热量',
description: '通过拍照识别食物并自动记录热量',
vip: {
type: 'unlimited',
text: '无限次使用',
vipText: '无限次使用'
},
regular: {
type: 'limited',
text: '有限次使用',
vipText: '每日3次'
}
},
{
title: 'AI拍照识别包装',
description: '识别食品包装上的营养成分信息',
vip: {
type: 'unlimited',
text: '无限次使用',
vipText: '无限次使用'
},
regular: {
type: 'limited',
text: '有限次使用',
vipText: '每日5次'
}
},
{
title: '每日健康提醒',
description: '根据个人目标提供个性化健康提醒',
vip: {
type: 'unlimited',
text: '完全支持',
vipText: '智能提醒'
},
regular: {
type: 'unlimited',
text: '基础提醒',
vipText: '基础提醒'
}
},
];
const PLAN_STYLE_CONFIG: Record<MembershipPlanType, { gradient: readonly [string, string]; accent: string }> = { const PLAN_STYLE_CONFIG: Record<MembershipPlanType, { gradient: readonly [string, string]; accent: string }> = {
lifetime: { lifetime: {
@@ -151,6 +107,7 @@ const getPermissionIcon = (type: PermissionType, isVip: boolean) => {
}; };
export function MembershipModal({ visible, onClose, onPurchaseSuccess }: MembershipModalProps) { export function MembershipModal({ visible, onClose, onPurchaseSuccess }: MembershipModalProps) {
const { t } = useI18n();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const [selectedProduct, setSelectedProduct] = useState<PurchasesStoreProduct | null>(null); const [selectedProduct, setSelectedProduct] = useState<PurchasesStoreProduct | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -165,6 +122,94 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
// 保存监听器引用,用于移除监听器 // 保存监听器引用,用于移除监听器
const purchaseListenerRef = useRef<((customerInfo: CustomerInfo) => void) | null>(null); const purchaseListenerRef = useRef<((customerInfo: CustomerInfo) => void) | null>(null);
// 权益对比配置 - Move inside component to use t function
const benefitComparison: BenefitItem[] = [
{
title: t('membershipModal.benefits.items.aiCalories.title'),
description: t('membershipModal.benefits.items.aiCalories.description'),
vip: {
type: 'unlimited',
text: t('membershipModal.benefits.permissions.unlimited'),
vipText: t('membershipModal.benefits.permissions.unlimited')
},
regular: {
type: 'limited',
text: t('membershipModal.benefits.permissions.limited'),
vipText: t('membershipModal.benefits.permissions.dailyLimit', { count: 3 })
}
},
{
title: t('membershipModal.benefits.items.aiNutrition.title'),
description: t('membershipModal.benefits.items.aiNutrition.description'),
vip: {
type: 'unlimited',
text: t('membershipModal.benefits.permissions.unlimited'),
vipText: t('membershipModal.benefits.permissions.unlimited')
},
regular: {
type: 'limited',
text: t('membershipModal.benefits.permissions.limited'),
vipText: t('membershipModal.benefits.permissions.dailyLimit', { count: 5 })
}
},
{
title: t('membershipModal.benefits.items.healthReminder.title'),
description: t('membershipModal.benefits.items.healthReminder.description'),
vip: {
type: 'unlimited',
text: t('membershipModal.benefits.permissions.fullSupport'),
vipText: t('membershipModal.benefits.permissions.smartReminder')
},
regular: {
type: 'unlimited',
text: t('membershipModal.benefits.permissions.basicSupport'),
vipText: t('membershipModal.benefits.permissions.basicSupport')
}
},
{
title: t('membershipModal.benefits.items.aiMedication.title'),
description: t('membershipModal.benefits.items.aiMedication.description'),
vip: {
type: 'exclusive',
text: t('membershipModal.benefits.permissions.fullAnalysis'),
vipText: t('membershipModal.benefits.permissions.fullAnalysis')
},
regular: {
type: 'exclusive',
text: t('membershipModal.benefits.permissions.notSupported'),
vipText: t('membershipModal.benefits.permissions.notSupported')
}
},
{
title: t('membershipModal.benefits.items.customChallenge.title'),
description: t('membershipModal.benefits.items.customChallenge.description'),
vip: {
type: 'exclusive',
text: t('membershipModal.benefits.permissions.createUnlimited'),
vipText: t('membershipModal.benefits.permissions.createUnlimited')
},
regular: {
type: 'exclusive',
text: t('membershipModal.benefits.permissions.notSupported'),
vipText: t('membershipModal.benefits.permissions.notSupported')
}
},
{
title: t('membershipModal.benefits.items.tabBarCustomization.title'),
description: t('membershipModal.benefits.items.tabBarCustomization.description'),
vip: {
type: 'exclusive',
text: t('membershipModal.benefits.permissions.fullSupport'),
vipText: t('membershipModal.benefits.permissions.unlimited')
},
regular: {
type: 'exclusive',
text: t('membershipModal.benefits.permissions.notSupported'),
vipText: t('membershipModal.benefits.permissions.notSupported')
}
},
];
// 根据选中的产品生成tips内容 // 根据选中的产品生成tips内容
const getTipsContent = (product: PurchasesStoreProduct | null): string => { const getTipsContent = (product: PurchasesStoreProduct | null): string => {
if (!product) return ''; if (!product) return '';
@@ -176,11 +221,11 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
switch (plan.type) { switch (plan.type) {
case 'lifetime': case 'lifetime':
return '终身陪伴,见证您的每一次健康蜕变'; return t('membershipModal.plans.lifetime.subtitle');
case 'quarterly': case 'quarterly':
return '3个月科学计划让健康成为生活习惯'; return t('membershipModal.plans.quarterly.subtitle');
case 'weekly': case 'weekly':
return '7天体验期感受专业健康指导的力量'; return t('membershipModal.plans.weekly.subtitle');
default: default:
return ''; return '';
} }
@@ -326,7 +371,7 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
// 显示成功提示 // 显示成功提示
GlobalToast.show({ GlobalToast.show({
message: '会员开通成功', message: t('membershipModal.success.purchase'),
}); });
}, 1000); }, 1000);
} }
@@ -492,11 +537,11 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
// 验证是否已同意协议 // 验证是否已同意协议
if (!agreementAccepted) { if (!agreementAccepted) {
Alert.alert( Alert.alert(
'请阅读并同意相关协议', t('membershipModal.agreements.alert.title'),
'购买前需要同意用户协议、会员协议和自动续费协议', t('membershipModal.agreements.alert.message'),
[ [
{ {
text: '确定', text: t('membershipModal.agreements.alert.confirm'),
style: 'default', style: 'default',
} }
] ]
@@ -517,11 +562,11 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
// 验证是否选择了产品 // 验证是否选择了产品
if (!selectedProduct) { if (!selectedProduct) {
Alert.alert( Alert.alert(
'请选择会员套餐', t('membershipModal.errors.selectPlan'),
'', '',
[ [
{ {
text: '确定', text: t('membershipModal.agreements.alert.confirm'),
style: 'default', style: 'default',
} }
] ]
@@ -579,32 +624,32 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
if (error.userCancelled || error.code === Purchases.PURCHASES_ERROR_CODE.PURCHASE_CANCELLED_ERROR) { if (error.userCancelled || error.code === Purchases.PURCHASES_ERROR_CODE.PURCHASE_CANCELLED_ERROR) {
// 用户取消购买 // 用户取消购买
GlobalToast.show({ GlobalToast.show({
message: '购买已取消', message: t('membershipModal.errors.purchaseCancelled'),
}); });
} else if (error.code === Purchases.PURCHASES_ERROR_CODE.PRODUCT_ALREADY_PURCHASED_ERROR) { } else if (error.code === Purchases.PURCHASES_ERROR_CODE.PRODUCT_ALREADY_PURCHASED_ERROR) {
// 商品已拥有 // 商品已拥有
GlobalToast.show({ GlobalToast.show({
message: '您已拥有此商品', message: t('membershipModal.errors.alreadyPurchased'),
}); });
} else if (error.code === Purchases.PURCHASES_ERROR_CODE.NETWORK_ERROR) { } else if (error.code === Purchases.PURCHASES_ERROR_CODE.NETWORK_ERROR) {
// 网络错误 // 网络错误
GlobalToast.show({ GlobalToast.show({
message: '网络连接失败', message: t('membershipModal.errors.networkError'),
}); });
} else if (error.code === Purchases.PURCHASES_ERROR_CODE.PAYMENT_PENDING_ERROR) { } else if (error.code === Purchases.PURCHASES_ERROR_CODE.PAYMENT_PENDING_ERROR) {
// 支付待处理 // 支付待处理
GlobalToast.show({ GlobalToast.show({
message: '支付正在处理中', message: t('membershipModal.errors.paymentPending'),
}); });
} else if (error.code === Purchases.PURCHASES_ERROR_CODE.INVALID_CREDENTIALS_ERROR) { } else if (error.code === Purchases.PURCHASES_ERROR_CODE.INVALID_CREDENTIALS_ERROR) {
// 凭据无效 // 凭据无效
GlobalToast.show({ GlobalToast.show({
message: '账户验证失败', message: t('membershipModal.errors.invalidCredentials'),
}); });
} else { } else {
// 其他错误 // 其他错误
GlobalToast.show({ GlobalToast.show({
message: '购买失败', message: t('membershipModal.errors.purchaseFailed'),
}); });
} }
} finally { } finally {
@@ -701,7 +746,7 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
onClose?.(); onClose?.();
GlobalToast.show({ GlobalToast.show({
message: '恢复购买成功', message: t('membershipModal.errors.restoreSuccess'),
}); });
} catch (apiError: any) { } catch (apiError: any) {
@@ -720,7 +765,7 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
// 即使后台接口失败,也显示恢复成功(因为 RevenueCat 已经确认有购买记录) // 即使后台接口失败,也显示恢复成功(因为 RevenueCat 已经确认有购买记录)
// 但不关闭弹窗,让用户知道可能需要重试 // 但不关闭弹窗,让用户知道可能需要重试
GlobalToast.show({ GlobalToast.show({
message: '恢复购买部分失败', message: t('membershipModal.errors.restorePartialFailed'),
}); });
} }
@@ -734,7 +779,7 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
activeSubscriptionsCount: activeSubscriptionIds.length activeSubscriptionsCount: activeSubscriptionIds.length
}); });
GlobalToast.show({ GlobalToast.show({
message: '没有找到购买记录', message: t('membershipModal.errors.noPurchasesFound'),
}); });
} }
} catch (error: any) { } catch (error: any) {
@@ -754,19 +799,19 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
// 处理特定的恢复购买错误 // 处理特定的恢复购买错误
if (error.userCancelled || error.code === Purchases.PURCHASES_ERROR_CODE.PURCHASE_CANCELLED_ERROR) { if (error.userCancelled || error.code === Purchases.PURCHASES_ERROR_CODE.PURCHASE_CANCELLED_ERROR) {
GlobalToast.show({ GlobalToast.show({
message: '恢复购买已取消', message: t('membershipModal.errors.restoreCancelled'),
}); });
} else if (error.code === Purchases.PURCHASES_ERROR_CODE.NETWORK_ERROR) { } else if (error.code === Purchases.PURCHASES_ERROR_CODE.NETWORK_ERROR) {
GlobalToast.show({ GlobalToast.show({
message: '网络错误', message: t('membershipModal.errors.networkError'),
}); });
} else if (error.code === Purchases.PURCHASES_ERROR_CODE.INVALID_CREDENTIALS_ERROR) { } else if (error.code === Purchases.PURCHASES_ERROR_CODE.INVALID_CREDENTIALS_ERROR) {
GlobalToast.show({ GlobalToast.show({
message: '账户验证失败', message: t('membershipModal.errors.invalidCredentials'),
}); });
} else { } else {
GlobalToast.show({ GlobalToast.show({
message: '恢复购买失败', message: t('membershipModal.errors.restoreFailed'),
}); });
} }
} finally { } finally {
@@ -780,7 +825,19 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
const renderPlanCard = (product: PurchasesStoreProduct) => { const renderPlanCard = (product: PurchasesStoreProduct) => {
const planMeta = getPlanMetaById(product.identifier); const planMeta = getPlanMetaById(product.identifier);
const isSelected = selectedProduct === product; const isSelected = selectedProduct === product;
const displayTitle = resolvePlanDisplayName(product, planMeta);
// 优先使用翻译的标题,如果找不到 meta 则回退到产品标题
let displayTitle = product.title;
let displaySubtitle = planMeta?.subtitle ?? '';
if (planMeta) {
displayTitle = t(`membershipModal.plans.${planMeta.type}.title`);
displaySubtitle = t(`membershipModal.plans.${planMeta.type}.subtitle`);
} else {
// 如果没有 meta尝试使用 resolvePlanDisplayName (虽然这里主要依赖 meta)
displayTitle = resolvePlanDisplayName(product, planMeta);
}
const priceLabel = product.priceString || ''; const priceLabel = product.priceString || '';
const styleConfig = planMeta ? PLAN_STYLE_CONFIG[planMeta.type] : undefined; const styleConfig = planMeta ? PLAN_STYLE_CONFIG[planMeta.type] : undefined;
@@ -797,7 +854,7 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
activeOpacity={loading ? 1 : 0.8} activeOpacity={loading ? 1 : 0.8}
accessible={true} accessible={true}
accessibilityLabel={`${displayTitle} ${priceLabel}`} accessibilityLabel={`${displayTitle} ${priceLabel}`}
accessibilityHint={loading ? '购买进行中,无法切换套餐' : `选择${displayTitle}套餐`} accessibilityHint={loading ? t('membershipModal.loading.purchase') : t('membershipModal.actions.selectPlan', { plan: displayTitle })}
accessibilityState={{ disabled: loading, selected: isSelected }} accessibilityState={{ disabled: loading, selected: isSelected }}
> >
<LinearGradient <LinearGradient
@@ -809,7 +866,7 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
<View style={styles.planCardTopSection}> <View style={styles.planCardTopSection}>
{planMeta?.tag && ( {planMeta?.tag && (
<View style={styles.planTag}> <View style={styles.planTag}>
<Text style={styles.planTagText}>{planMeta.tag}</Text> <Text style={styles.planTagText}>{t('membershipModal.plans.tag')}</Text>
</View> </View>
)} )}
<Text style={styles.planCardTitle}>{displayTitle}</Text> <Text style={styles.planCardTitle}>{displayTitle}</Text>
@@ -825,7 +882,7 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
</View> </View>
<View style={styles.planCardBottomSection}> <View style={styles.planCardBottomSection}>
<Text style={styles.planCardDescription}>{planMeta?.subtitle ?? ''}</Text> <Text style={styles.planCardDescription}>{displaySubtitle}</Text>
</View> </View>
</LinearGradient> </LinearGradient>
</TouchableOpacity> </TouchableOpacity>
@@ -854,8 +911,8 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
onPress={onClose} onPress={onClose}
activeOpacity={0.7} activeOpacity={0.7}
accessible={true} accessible={true}
accessibilityLabel="返回" accessibilityLabel={t('membershipModal.actions.back')}
accessibilityHint="关闭会员购买弹窗" accessibilityHint={t('membershipModal.actions.close')}
style={styles.floatingBackButtonContainer} style={styles.floatingBackButtonContainer}
> >
{isLiquidGlassAvailable() ? ( {isLiquidGlassAvailable() ? (
@@ -887,14 +944,14 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
<View style={styles.sectionTitleBadge}> <View style={styles.sectionTitleBadge}>
<Ionicons name="star" size={16} color="#7B2CBF" /> <Ionicons name="star" size={16} color="#7B2CBF" />
</View> </View>
<Text style={styles.sectionTitle}></Text> <Text style={styles.sectionTitle}>{t('membershipModal.sectionTitle.plans')}</Text>
</View> </View>
<Text style={styles.sectionSubtitle}></Text> <Text style={styles.sectionSubtitle}>{t('membershipModal.sectionTitle.plansSubtitle')}</Text>
{products.length === 0 ? ( {products.length === 0 ? (
<View style={styles.configurationNotice}> <View style={styles.configurationNotice}>
<Text style={styles.configurationText}> <Text style={styles.configurationText}>
RevenueCat iOS Offering {t('membershipModal.errors.noProducts')}
</Text> </Text>
</View> </View>
) : ( ) : (
@@ -917,17 +974,17 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
<View style={styles.sectionTitleBadge}> <View style={styles.sectionTitleBadge}>
<Ionicons name="checkbox" size={16} color="#FF9F0A" /> <Ionicons name="checkbox" size={16} color="#FF9F0A" />
</View> </View>
<Text style={styles.sectionTitle}></Text> <Text style={styles.sectionTitle}>{t('membershipModal.benefits.title')}</Text>
</View> </View>
<Text style={styles.sectionSubtitle}></Text> <Text style={styles.sectionSubtitle}>{t('membershipModal.benefits.subtitle')}</Text>
<View style={styles.comparisonTable}> <View style={styles.comparisonTable}>
<View style={[styles.tableRow, styles.tableHeader]}> <View style={[styles.tableRow, styles.tableHeader]}>
<Text style={[styles.tableHeaderText, styles.tableTitleCell]}></Text> <Text style={[styles.tableHeaderText, styles.tableTitleCell]}>{t('membershipModal.benefits.table.benefit')}</Text>
<Text style={[styles.tableHeaderText, styles.tableVipCell]}>VIP</Text> <Text style={[styles.tableHeaderText, styles.tableVipCell]}>{t('membershipModal.benefits.table.vip')}</Text>
<Text style={[styles.tableHeaderText, styles.tableNormalCell]}></Text> <Text style={[styles.tableHeaderText, styles.tableNormalCell]}>{t('membershipModal.benefits.table.regular')}</Text>
</View> </View>
{BENEFIT_COMPARISON.map((row, index) => ( {benefitComparison.map((row, index) => (
<View <View
key={row.title} key={row.title}
style={[ style={[
@@ -963,39 +1020,46 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
</View> </View>
<View style={styles.bottomSection}> <View style={styles.bottomSection}>
<View style={styles.agreementRow}> <View style={styles.agreementContainer}>
<CustomCheckBox <View style={styles.checkboxWrapper}>
checked={agreementAccepted} <CustomCheckBox
onCheckedChange={setAgreementAccepted} checked={agreementAccepted}
size={16} onCheckedChange={setAgreementAccepted}
checkedColor="#E91E63" size={16}
uncheckedColor="#999" checkedColor="#E91E63"
/> uncheckedColor="#999"
<Text style={styles.agreementPrefix}></Text> />
<TouchableOpacity </View>
onPress={() => { <Text style={styles.agreementText}>
Linking.openURL(USER_AGREEMENT_URL); {t('membershipModal.agreements.prefix')}
captureMessage('click user agreement'); <Text
}} style={styles.agreementLink}
> onPress={() => {
<Text style={styles.agreementLink}></Text> Linking.openURL(USER_AGREEMENT_URL);
</TouchableOpacity> captureMessage('click user agreement');
<Text style={styles.agreementSeparator}>|</Text> }}
<TouchableOpacity >
onPress={() => { {t('membershipModal.agreements.userAgreement')}
captureMessage('click membership agreement'); </Text>
}} <Text style={styles.agreementSeparator}> | </Text>
> <Text
<Text style={styles.agreementLink}></Text> style={styles.agreementLink}
</TouchableOpacity> onPress={() => {
<Text style={styles.agreementSeparator}>|</Text> captureMessage('click membership agreement');
<TouchableOpacity }}
onPress={() => { >
captureMessage('click auto renewal agreement'); {t('membershipModal.agreements.membershipAgreement')}
}} </Text>
> <Text style={styles.agreementSeparator}> | </Text>
<Text style={styles.agreementLink}></Text> <Text
</TouchableOpacity> style={styles.agreementLink}
onPress={() => {
captureMessage('click auto renewal agreement');
}}
>
{t('membershipModal.agreements.autoRenewalAgreement')}
</Text>
</Text>
</View> </View>
<TouchableOpacity <TouchableOpacity
@@ -1006,10 +1070,10 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
{restoring ? ( {restoring ? (
<View style={styles.restoreButtonContent}> <View style={styles.restoreButtonContent}>
<ActivityIndicator size="small" color="#666" style={styles.restoreButtonLoader} /> <ActivityIndicator size="small" color="#666" style={styles.restoreButtonLoader} />
<Text style={styles.restoreButtonText}>...</Text> <Text style={styles.restoreButtonText}>{t('membershipModal.actions.restoring')}</Text>
</View> </View>
) : ( ) : (
<Text style={styles.restoreButtonText}></Text> <Text style={styles.restoreButtonText}>{t('membershipModal.actions.restore')}</Text>
)} )}
</TouchableOpacity> </TouchableOpacity>
</View> </View>
@@ -1031,15 +1095,15 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
onPress={handlePurchase} onPress={handlePurchase}
disabled={loading || products.length === 0} disabled={loading || products.length === 0}
accessible={true} accessible={true}
accessibilityLabel={loading ? '正在处理购买' : '购买会员'} accessibilityLabel={loading ? t('membershipModal.actions.processing') : t('membershipModal.actions.subscribe')}
accessibilityHint={ accessibilityHint={
loading loading
? '购买正在进行中,请稍候' ? t('membershipModal.loading.purchase')
: products.length === 0 : products.length === 0
? '正在加载会员套餐,请稍候' ? t('membershipModal.loading.products')
: !selectedProduct : !selectedProduct
? '请选择会员套餐后再进行购买' ? t('membershipModal.errors.selectPlan')
: `点击购买${selectedProduct.title || '已选'}会员套餐` : t('membershipModal.actions.purchaseHint', { plan: selectedProduct.title || '已选' })
} }
accessibilityState={{ disabled: loading || products.length === 0 }} accessibilityState={{ disabled: loading || products.length === 0 }}
style={styles.purchaseButtonContent} style={styles.purchaseButtonContent}
@@ -1047,10 +1111,10 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
{loading ? ( {loading ? (
<View style={styles.loadingContainer}> <View style={styles.loadingContainer}>
<ActivityIndicator size="small" color="white" style={styles.loadingSpinner} /> <ActivityIndicator size="small" color="white" style={styles.loadingSpinner} />
<Text style={styles.purchaseButtonText}>...</Text> <Text style={styles.purchaseButtonText}>{t('membershipModal.actions.processing')}</Text>
</View> </View>
) : ( ) : (
<Text style={styles.purchaseButtonText}></Text> <Text style={styles.purchaseButtonText}>{t('membershipModal.actions.subscribe')}</Text>
)} )}
</TouchableOpacity> </TouchableOpacity>
</GlassView> </GlassView>
@@ -1066,15 +1130,15 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
onPress={handlePurchase} onPress={handlePurchase}
disabled={loading || products.length === 0} disabled={loading || products.length === 0}
accessible={true} accessible={true}
accessibilityLabel={loading ? '正在处理购买' : '购买会员'} accessibilityLabel={loading ? t('membershipModal.actions.processing') : t('membershipModal.actions.subscribe')}
accessibilityHint={ accessibilityHint={
loading loading
? '购买正在进行中,请稍候' ? t('membershipModal.loading.purchase')
: products.length === 0 : products.length === 0
? '正在加载会员套餐,请稍候' ? t('membershipModal.loading.products')
: !selectedProduct : !selectedProduct
? '请选择会员套餐后再进行购买' ? t('membershipModal.errors.selectPlan')
: `点击购买${selectedProduct.title || '已选'}会员套餐` : t('membershipModal.actions.purchaseHint', { plan: selectedProduct.title || '已选' })
} }
accessibilityState={{ disabled: loading || products.length === 0 }} accessibilityState={{ disabled: loading || products.length === 0 }}
style={styles.purchaseButtonContent} style={styles.purchaseButtonContent}
@@ -1082,10 +1146,10 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
{loading ? ( {loading ? (
<View style={styles.loadingContainer}> <View style={styles.loadingContainer}>
<ActivityIndicator size="small" color="white" style={styles.loadingSpinner} /> <ActivityIndicator size="small" color="white" style={styles.loadingSpinner} />
<Text style={styles.purchaseButtonText}>...</Text> <Text style={styles.purchaseButtonText}>{t('membershipModal.actions.processing')}</Text>
</View> </View>
) : ( ) : (
<Text style={styles.purchaseButtonText}></Text> <Text style={styles.purchaseButtonText}>{t('membershipModal.actions.subscribe')}</Text>
)} )}
</TouchableOpacity> </TouchableOpacity>
</View> </View>
@@ -1168,12 +1232,14 @@ const styles = StyleSheet.create({
fontSize: 18, fontSize: 18,
fontWeight: '700', fontWeight: '700',
color: '#2B2B2E', color: '#2B2B2E',
fontFamily: 'AliBold',
}, },
sectionSubtitle: { sectionSubtitle: {
fontSize: 13, fontSize: 13,
color: '#6B6B73', color: '#6B6B73',
marginTop: 6, marginTop: 6,
marginBottom: 16, marginBottom: 16,
fontFamily: 'AliRegular',
}, },
configurationNotice: { configurationNotice: {
borderRadius: 16, borderRadius: 16,
@@ -1185,6 +1251,7 @@ const styles = StyleSheet.create({
color: '#B86A04', color: '#B86A04',
textAlign: 'center', textAlign: 'center',
lineHeight: 20, lineHeight: 20,
fontFamily: 'AliRegular',
}, },
plansContainer: { plansContainer: {
flexDirection: 'row', flexDirection: 'row',
@@ -1217,35 +1284,40 @@ const styles = StyleSheet.create({
alignSelf: 'flex-start', alignSelf: 'flex-start',
backgroundColor: '#2F2F36', backgroundColor: '#2F2F36',
borderRadius: 14, borderRadius: 14,
paddingHorizontal: 12, paddingHorizontal: 6,
paddingVertical: 4, paddingVertical: 6,
marginBottom: 12, marginBottom: 12,
}, },
planTagText: { planTagText: {
color: '#FFFFFF', color: '#FFFFFF',
fontSize: 11, fontSize: 10,
fontWeight: '600', fontWeight: '600',
fontFamily: 'AliBold',
}, },
planCardTitle: { planCardTitle: {
fontSize: 18,
fontWeight: '700',
color: '#241F1F',
},
planCardPrice: {
fontSize: 16, fontSize: 16,
fontWeight: '700', fontWeight: '700',
color: '#241F1F',
fontFamily: 'AliBold',
},
planCardPrice: {
fontSize: 14,
fontWeight: '700',
marginTop: 12, marginTop: 12,
fontFamily: 'AliBold',
}, },
planCardOriginalPrice: { planCardOriginalPrice: {
fontSize: 13, fontSize: 13,
color: '#8E8EA1', color: '#8E8EA1',
textDecorationLine: 'line-through', textDecorationLine: 'line-through',
marginTop: 2, marginTop: 2,
fontFamily: 'AliRegular',
}, },
planCardDescription: { planCardDescription: {
fontSize: 12, fontSize: 12,
color: '#6C6C77', color: '#6C6C77',
lineHeight: 17, lineHeight: 17,
fontFamily: 'AliRegular',
}, },
planCardTopSection: { planCardTopSection: {
flex: 1, flex: 1,
@@ -1275,6 +1347,7 @@ const styles = StyleSheet.create({
color: '#9B6200', color: '#9B6200',
marginLeft: 6, marginLeft: 6,
lineHeight: 16, lineHeight: 16,
fontFamily: 'AliRegular',
}, },
comparisonTable: { comparisonTable: {
borderRadius: 16, borderRadius: 16,
@@ -1298,10 +1371,12 @@ const styles = StyleSheet.create({
color: '#575764', color: '#575764',
textTransform: 'uppercase', textTransform: 'uppercase',
letterSpacing: 0.4, letterSpacing: 0.4,
fontFamily: 'AliBold',
}, },
tableCellText: { tableCellText: {
fontSize: 13, fontSize: 13,
color: '#3E3E44', color: '#3E3E44',
fontFamily: 'AliRegular',
}, },
tableTitleCell: { tableTitleCell: {
flex: 1.5, flex: 1.5,
@@ -1361,6 +1436,7 @@ const styles = StyleSheet.create({
color: '#FFFFFF', color: '#FFFFFF',
fontSize: 18, fontSize: 18,
fontWeight: '700', fontWeight: '700',
fontFamily: 'AliBold',
}, },
loadingContainer: { loadingContainer: {
flexDirection: 'row', flexDirection: 'row',
@@ -1369,29 +1445,34 @@ const styles = StyleSheet.create({
loadingSpinner: { loadingSpinner: {
marginRight: 8, marginRight: 8,
}, },
agreementRow: { agreementContainer: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'flex-start',
justifyContent: 'center', justifyContent: 'center',
flexWrap: 'nowrap', marginBottom: 20,
marginBottom: 16, paddingHorizontal: 4,
}, },
agreementPrefix: { checkboxWrapper: {
fontSize: 10, marginTop: 2, // Align with text line-height
marginRight: 8,
},
agreementText: {
flex: 1,
fontSize: 11,
lineHeight: 16,
color: '#666672', color: '#666672',
marginRight: 4, fontFamily: 'AliRegular',
}, },
agreementLink: { agreementLink: {
fontSize: 10, fontSize: 11,
color: '#E91E63', color: '#E91E63',
textDecorationLine: 'underline',
fontWeight: '500', fontWeight: '500',
marginHorizontal: 2, textDecorationLine: 'underline',
fontFamily: 'AliBold',
}, },
agreementSeparator: { agreementSeparator: {
fontSize: 10, fontSize: 11,
color: '#A0A0B0', color: '#A0A0B0',
marginHorizontal: 2,
}, },
restoreButton: { restoreButton: {
alignSelf: 'center', alignSelf: 'center',
@@ -1401,6 +1482,7 @@ const styles = StyleSheet.create({
color: '#6F6F7A', color: '#6F6F7A',
fontSize: 12, fontSize: 12,
fontWeight: '500', fontWeight: '500',
fontFamily: 'AliBold',
}, },
disabledRestoreButton: { disabledRestoreButton: {
opacity: 0.5, opacity: 0.5,
@@ -1422,6 +1504,7 @@ const styles = StyleSheet.create({
color: '#8E8E93', color: '#8E8E93',
marginTop: 2, marginTop: 2,
lineHeight: 14, lineHeight: 14,
fontFamily: 'AliRegular',
}, },
permissionContainer: { permissionContainer: {
alignItems: 'center', alignItems: 'center',
@@ -1435,5 +1518,6 @@ const styles = StyleSheet.create({
marginTop: 4, marginTop: 4,
textAlign: 'center', textAlign: 'center',
lineHeight: 12, lineHeight: 12,
fontFamily: 'AliRegular',
}, },
}); });

View File

@@ -1,6 +1,11 @@
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 { Ionicons } from '@expo/vector-icons';
import { Image } from 'expo-image'; import { BlurView } from 'expo-blur';
import * as ImagePicker from 'expo-image-picker'; import * as ImagePicker from 'expo-image-picker';
import { LinearGradient } from 'expo-linear-gradient';
import React, { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { import {
ActivityIndicator, ActivityIndicator,
@@ -15,11 +20,11 @@ import {
Text, Text,
TextInput, TextInput,
TouchableOpacity, TouchableOpacity,
View View,
} from 'react-native'; } from 'react-native';
import { useAppSelector } from '@/hooks/redux';
import { useCosUpload } from '@/hooks/useCosUpload'; const CTA_GRADIENT: [string, string] = ['#5E8BFF', '#6B6CFF'];
import { Colors } from '@/constants/Colors'; const CTA_DISABLED_GRADIENT: [string, string] = ['#d3d7e8', '#c1c6da'];
export interface CreateCustomFoodModalProps { export interface CreateCustomFoodModalProps {
visible: boolean; visible: boolean;
@@ -43,9 +48,10 @@ export function CreateCustomFoodModal({
onClose, onClose,
onSave onSave
}: CreateCustomFoodModalProps) { }: CreateCustomFoodModalProps) {
const { t } = useI18n();
const [foodName, setFoodName] = useState(''); const [foodName, setFoodName] = useState('');
const [defaultAmount, setDefaultAmount] = useState('100'); const [defaultAmount, setDefaultAmount] = useState('100');
const [caloriesUnit, setCaloriesUnit] = useState('千卡'); const [caloriesUnit, setCaloriesUnit] = useState(t('createCustomFood.units.kcal'));
const [calories, setCalories] = useState('100'); const [calories, setCalories] = useState('100');
const [imageUrl, setImageUrl] = useState<string>(''); const [imageUrl, setImageUrl] = useState<string>('');
const [protein, setProtein] = useState('0'); const [protein, setProtein] = useState('0');
@@ -93,7 +99,7 @@ export function CreateCustomFoodModal({
if (visible) { if (visible) {
setFoodName(''); setFoodName('');
setDefaultAmount('100'); setDefaultAmount('100');
setCaloriesUnit('千卡'); setCaloriesUnit(t('createCustomFood.units.kcal'));
setCalories('100'); setCalories('100');
setImageUrl(''); setImageUrl('');
setProtein('0'); setProtein('0');
@@ -102,16 +108,16 @@ export function CreateCustomFoodModal({
} }
}, [visible]); }, [visible]);
// 选择热量单位
// 选择图片 // 选择图片
const handleSelectImage = async () => { const handleSelectImage = async () => {
try { try {
const resp = await ImagePicker.requestMediaLibraryPermissionsAsync(); const resp = await ImagePicker.requestMediaLibraryPermissionsAsync();
const libGranted = resp.status === 'granted' || (resp as any).accessPrivileges === 'limited'; const libGranted = resp.status === 'granted' || (resp as any).accessPrivileges === 'limited';
if (!libGranted) { if (!libGranted) {
Alert.alert('权限不足', '需要相册权限以选择照片'); Alert.alert(
t('createCustomFood.alerts.permissionDenied.title'),
t('createCustomFood.alerts.permissionDenied.message')
);
return; return;
} }
@@ -137,11 +143,17 @@ export function CreateCustomFoodModal({
setImageUrl(url); setImageUrl(url);
} catch (e) { } catch (e) {
console.warn('上传照片失败', e); console.warn('上传照片失败', e);
Alert.alert('上传失败', '照片上传失败,请重试'); Alert.alert(
t('createCustomFood.alerts.uploadFailed.title'),
t('createCustomFood.alerts.uploadFailed.message')
);
} }
} }
} catch (e) { } catch (e) {
Alert.alert('发生错误', '选择照片失败,请重试'); Alert.alert(
t('createCustomFood.alerts.error.title'),
t('createCustomFood.alerts.error.message')
);
} }
}; };
@@ -151,12 +163,18 @@ export function CreateCustomFoodModal({
// 保存自定义食物 // 保存自定义食物
const handleSave = () => { const handleSave = () => {
if (!foodName.trim()) { if (!foodName.trim()) {
Alert.alert('提示', '请输入食物名称'); Alert.alert(
t('createCustomFood.alerts.validation.title'),
t('createCustomFood.alerts.validation.nameRequired')
);
return; return;
} }
if (!calories.trim() || parseFloat(calories) <= 0) { if (!calories.trim() || parseFloat(calories) <= 0) {
Alert.alert('提示', '请输入有效的热量值'); Alert.alert(
t('createCustomFood.alerts.validation.title'),
t('createCustomFood.alerts.validation.caloriesRequired')
);
return; return;
} }
@@ -175,75 +193,99 @@ export function CreateCustomFoodModal({
onClose(); onClose();
}; };
const isSaveDisabled = !foodName.trim() || !calories.trim();
return ( return (
<Modal <Modal
visible={visible} visible={visible}
animationType="fade" animationType="slide"
transparent={true} transparent={true}
onRequestClose={onClose} onRequestClose={onClose}
presentationStyle="overFullScreen" presentationStyle="overFullScreen"
> >
<View style={styles.overlay}> <BlurView intensity={20} tint="dark" style={styles.overlay}>
<KeyboardAvoidingView <KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'} behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.keyboardAvoidingView} style={styles.keyboardAvoidingView}
> >
<View style={[ <TouchableOpacity activeOpacity={1} onPress={onClose} style={styles.dismissArea} />
styles.modalContainer, <View
keyboardHeight > 0 && { style={[
height: screenHeight - keyboardHeight, styles.modalContainer,
maxHeight: screenHeight - keyboardHeight, keyboardHeight > 0 && {
} height: screenHeight - keyboardHeight - 60,
]}> maxHeight: screenHeight - keyboardHeight - 60,
},
]}
>
<View style={styles.modalHeaderBar}>
<View style={styles.dragIndicator} />
</View>
<ScrollView <ScrollView
style={styles.scrollView} style={styles.scrollView}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled" keyboardShouldPersistTaps="handled"
contentContainerStyle={{ contentContainerStyle={{
flexGrow: 1, flexGrow: 1,
paddingBottom: keyboardHeight > 0 ? 20 : 0 paddingBottom: keyboardHeight > 0 ? 20 : 40,
}} }}
> >
{/* 头部 */} {/* 头部 */}
<View style={styles.header}> <View style={styles.header}>
<TouchableOpacity onPress={onClose} style={styles.backButton}> <TouchableOpacity onPress={onClose} style={styles.backButton} activeOpacity={0.7}>
<Ionicons name="chevron-back" size={24} color="#333" /> <Ionicons name="close-circle" size={32} color="#E2E8F0" />
</TouchableOpacity> </TouchableOpacity>
<Text style={styles.headerTitle}></Text> <Text style={styles.headerTitle}>{t('createCustomFood.title')}</Text>
<TouchableOpacity <TouchableOpacity
style={[ style={[styles.saveButton, isSaveDisabled && styles.saveButtonDisabled]}
styles.saveButton,
(!foodName.trim() || !calories.trim()) && styles.saveButtonDisabled
]}
onPress={handleSave} onPress={handleSave}
disabled={!foodName.trim() || !calories.trim()} disabled={isSaveDisabled}
activeOpacity={0.8}
> >
<Text style={[ <LinearGradient
styles.saveButtonText, colors={isSaveDisabled ? CTA_DISABLED_GRADIENT : CTA_GRADIENT}
(!foodName.trim() || !calories.trim()) && styles.saveButtonTextDisabled start={{ x: 0, y: 0 }}
]}></Text> end={{ x: 1, y: 1 }}
style={styles.saveButtonGradient}
>
<Text style={styles.saveButtonText}>{t('createCustomFood.save')}</Text>
</LinearGradient>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
{/* 效果预览区域 */} {/* 效果预览区域 */}
<View style={styles.previewSection}> <View style={styles.previewSection}>
<Text style={styles.sectionTitle}></Text>
<View style={styles.previewCard}> <View style={styles.previewCard}>
<LinearGradient
colors={['#ffffff', '#F8F9FF']}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
style={StyleSheet.absoluteFill}
/>
<View style={styles.previewHeader}>
<Text style={styles.sectionTitle}>{t('createCustomFood.preview.title')}</Text>
</View>
<View style={styles.previewContent}> <View style={styles.previewContent}>
{imageUrl ? ( <View style={styles.imageWrapper}>
<Image style={styles.previewImage} source={{ uri: imageUrl }} /> {imageUrl ? (
) : ( <Image style={styles.previewImage} source={{ uri: imageUrl }} />
<View style={styles.previewImagePlaceholder}> ) : (
<Ionicons name="restaurant" size={20} color="#999" /> <View style={styles.previewImagePlaceholder}>
</View> <Ionicons name="restaurant" size={24} color="#94A3B8" />
)} </View>
)}
</View>
<View style={styles.previewInfo}> <View style={styles.previewInfo}>
<Text style={styles.previewName}> <Text style={styles.previewName} numberOfLines={1}>
{foodName || '食物名称'} {foodName || t('createCustomFood.preview.defaultName')}
</Text>
<Text style={styles.previewCalories}>
{actualCalories}{caloriesUnit}/{defaultAmount}g
</Text> </Text>
<View style={styles.previewBadge}>
<Ionicons name="flame" size={14} color="#F59E0B" />
<Text style={styles.previewCalories}>
{actualCalories} {caloriesUnit} / {defaultAmount}
{t('createCustomFood.units.g')}
</Text>
</View>
</View> </View>
</View> </View>
</View> </View>
@@ -252,21 +294,21 @@ export function CreateCustomFoodModal({
{/* 基本信息 */} {/* 基本信息 */}
<View style={styles.section}> <View style={styles.section}>
<View style={styles.sectionHeader}> <View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}></Text> <Text style={styles.sectionTitle}>{t('createCustomFood.basicInfo.title')}</Text>
<Text style={styles.requiredIndicator}>*</Text> <Text style={styles.requiredIndicator}>*</Text>
</View> </View>
<View style={styles.sectionCard}> <View style={styles.sectionCard}>
{/* 食物名称和单位 */} {/* 食物名称 */}
<View style={styles.inputRowContainer}> <View style={styles.inputRowContainer}>
<Text style={styles.inputRowLabel}></Text> <Text style={styles.inputRowLabel}>{t('createCustomFood.basicInfo.name')}</Text>
<View style={styles.inputRowContent}> <View style={styles.inputRowContent}>
<View style={styles.numberInputContainer}> <View style={styles.modernInputContainer}>
<TextInput <TextInput
style={styles.modernNumberInput} style={styles.modernNumberInput}
value={foodName} value={foodName}
onChangeText={setFoodName} onChangeText={setFoodName}
placeholder="例如,汉堡" placeholder={t('createCustomFood.basicInfo.namePlaceholder')}
placeholderTextColor="#A0A0A0" placeholderTextColor="#94A3B8"
/> />
</View> </View>
</View> </View>
@@ -274,36 +316,36 @@ export function CreateCustomFoodModal({
{/* 默认数量 */} {/* 默认数量 */}
<View style={styles.inputRowContainer}> <View style={styles.inputRowContainer}>
<Text style={styles.inputRowLabel}></Text> <Text style={styles.inputRowLabel}>{t('createCustomFood.basicInfo.defaultAmount')}</Text>
<View style={styles.inputRowContent}> <View style={styles.inputRowContent}>
<View style={styles.numberInputContainer}> <View style={styles.modernInputContainer}>
<TextInput <TextInput
style={styles.modernNumberInput} style={styles.modernNumberInput}
value={defaultAmount} value={defaultAmount}
onChangeText={setDefaultAmount} onChangeText={setDefaultAmount}
keyboardType="numeric" keyboardType="numeric"
placeholder="100" placeholder="100"
placeholderTextColor="#A0A0A0" placeholderTextColor="#94A3B8"
/> />
<Text style={styles.unitText}>g</Text> <Text style={styles.unitText}>{t('createCustomFood.units.g')}</Text>
</View> </View>
</View> </View>
</View> </View>
{/* 食物热量 */} {/* 食物热量 */}
<View style={[styles.inputRowContainer, { marginBottom: 0 }]}> <View style={[styles.inputRowContainer, { marginBottom: 0 }]}>
<Text style={styles.inputRowLabel}></Text> <Text style={styles.inputRowLabel}>{t('createCustomFood.basicInfo.calories')}</Text>
<View style={styles.inputRowContent}> <View style={styles.inputRowContent}>
<View style={styles.numberInputContainer}> <View style={styles.modernInputContainer}>
<TextInput <TextInput
style={styles.modernNumberInput} style={styles.modernNumberInput}
value={calories} value={calories}
onChangeText={setCalories} onChangeText={setCalories}
keyboardType="numeric" keyboardType="numeric"
placeholder="100" placeholder="0"
placeholderTextColor="#A0A0A0" placeholderTextColor="#94A3B8"
/> />
<Text style={styles.unitText}></Text> <Text style={styles.unitText}>{t('createCustomFood.units.kcal')}</Text>
</View> </View>
</View> </View>
</View> </View>
@@ -312,23 +354,26 @@ export function CreateCustomFoodModal({
{/* 可选信息 */} {/* 可选信息 */}
<View style={styles.section}> <View style={styles.section}>
<Text style={styles.sectionTitle}></Text> <Text style={styles.sectionTitle}>{t('createCustomFood.optionalInfo.title')}</Text>
<View style={styles.sectionCard}> <View style={styles.sectionCard}>
{/* 照片 */} {/* 照片 */}
<View style={styles.inputRowContainer}> <View style={styles.inputRowContainer}>
<Text style={styles.inputRowLabel}></Text> <Text style={styles.inputRowLabel}>{t('createCustomFood.optionalInfo.photo')}</Text>
<View style={styles.inputRowContent}> <View style={styles.inputRowContent}>
<TouchableOpacity <TouchableOpacity
style={styles.modernImageSelector} style={styles.modernImageSelector}
onPress={handleSelectImage} onPress={handleSelectImage}
disabled={uploading} disabled={uploading}
activeOpacity={0.8}
> >
{imageUrl ? ( {imageUrl ? (
<Image style={styles.selectedImage} source={{ uri: imageUrl }} /> <Image style={styles.selectedImage} source={{ uri: imageUrl }} />
) : ( ) : (
<View style={styles.modernImagePlaceholder}> <View style={styles.modernImagePlaceholder}>
<Ionicons name="camera" size={28} color="#A0A0A0" /> <Ionicons name="camera-outline" size={28} color="#94A3B8" />
<Text style={styles.imagePlaceholderText}></Text> <Text style={styles.imagePlaceholderText}>
{t('createCustomFood.optionalInfo.addPhoto')}
</Text>
</View> </View>
)} )}
{uploading && ( {uploading && (
@@ -342,54 +387,56 @@ export function CreateCustomFoodModal({
{/* 蛋白质 */} {/* 蛋白质 */}
<View style={styles.inputRowContainer}> <View style={styles.inputRowContainer}>
<Text style={styles.inputRowLabel}></Text> <Text style={styles.inputRowLabel}>{t('createCustomFood.optionalInfo.protein')}</Text>
<View style={styles.inputRowContent}> <View style={styles.inputRowContent}>
<View style={styles.numberInputContainer}> <View style={styles.modernInputContainer}>
<TextInput <TextInput
style={styles.modernNumberInput} style={styles.modernNumberInput}
value={protein} value={protein}
onChangeText={setProtein} onChangeText={setProtein}
keyboardType="numeric" keyboardType="numeric"
placeholder="0" placeholder="0"
placeholderTextColor="#A0A0A0" placeholderTextColor="#94A3B8"
/> />
<Text style={styles.unitText}></Text> <Text style={styles.unitText}>{t('createCustomFood.units.gram')}</Text>
</View> </View>
</View> </View>
</View> </View>
{/* 脂肪 */} {/* 脂肪 */}
<View style={styles.inputRowContainer}> <View style={styles.inputRowContainer}>
<Text style={styles.inputRowLabel}></Text> <Text style={styles.inputRowLabel}>{t('createCustomFood.optionalInfo.fat')}</Text>
<View style={styles.inputRowContent}> <View style={styles.inputRowContent}>
<View style={styles.numberInputContainer}> <View style={styles.modernInputContainer}>
<TextInput <TextInput
style={styles.modernNumberInput} style={styles.modernNumberInput}
value={fat} value={fat}
onChangeText={setFat} onChangeText={setFat}
keyboardType="numeric" keyboardType="numeric"
placeholder="0" placeholder="0"
placeholderTextColor="#A0A0A0" placeholderTextColor="#94A3B8"
/> />
<Text style={styles.unitText}></Text> <Text style={styles.unitText}>{t('createCustomFood.units.gram')}</Text>
</View> </View>
</View> </View>
</View> </View>
{/* 碳水化合物 */} {/* 碳水化合物 */}
<View style={[styles.inputRowContainer, { marginBottom: 0 }]}> <View style={[styles.inputRowContainer, { marginBottom: 0 }]}>
<Text style={styles.inputRowLabel}></Text> <Text style={styles.inputRowLabel}>
{t('createCustomFood.optionalInfo.carbohydrate')}
</Text>
<View style={styles.inputRowContent}> <View style={styles.inputRowContent}>
<View style={styles.numberInputContainer}> <View style={styles.modernInputContainer}>
<TextInput <TextInput
style={styles.modernNumberInput} style={styles.modernNumberInput}
value={carbohydrate} value={carbohydrate}
onChangeText={setCarbohydrate} onChangeText={setCarbohydrate}
keyboardType="numeric" keyboardType="numeric"
placeholder="0" placeholder="0"
placeholderTextColor="#A0A0A0" placeholderTextColor="#94A3B8"
/> />
<Text style={styles.unitText}></Text> <Text style={styles.unitText}>{t('createCustomFood.units.gram')}</Text>
</View> </View>
</View> </View>
</View> </View>
@@ -398,7 +445,7 @@ export function CreateCustomFoodModal({
</ScrollView> </ScrollView>
</View> </View>
</KeyboardAvoidingView> </KeyboardAvoidingView>
</View> </BlurView>
</Modal> </Modal>
); );
} }
@@ -408,331 +455,272 @@ const { height: screenHeight } = Dimensions.get('window');
const styles = StyleSheet.create({ const styles = StyleSheet.create({
overlay: { overlay: {
flex: 1, flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
}, },
keyboardAvoidingView: { keyboardAvoidingView: {
flex: 1, flex: 1,
justifyContent: 'flex-end',
},
dismissArea: {
flex: 1,
}, },
modalContainer: { modalContainer: {
flex: 1, backgroundColor: '#F1F5F9', // Slate 100
backgroundColor: '#FFFFFF', borderTopLeftRadius: 32,
marginTop: 50, borderTopRightRadius: 32,
borderTopLeftRadius: 20, height: '90%',
borderTopRightRadius: 20, maxHeight: '90%',
shadowColor: '#000',
shadowOffset: {
width: 0,
height: -4,
},
shadowOpacity: 0.1,
shadowRadius: 12,
elevation: 20,
overflow: 'hidden',
},
modalHeaderBar: {
width: '100%',
height: 24,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#F1F5F9',
},
dragIndicator: {
width: 40,
height: 4,
backgroundColor: '#CBD5E1',
borderRadius: 2,
}, },
scrollView: { scrollView: {
flex: 1, flex: 1,
backgroundColor: '#F1F5F9',
}, },
header: { header: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between', justifyContent: 'space-between',
paddingHorizontal: 16, paddingHorizontal: 20,
paddingVertical: 16, paddingBottom: 20,
}, },
backButton: { backButton: {
padding: 4, padding: 4,
marginLeft: -8,
}, },
headerTitle: { headerTitle: {
fontSize: 18, fontSize: 20,
fontWeight: '600', fontWeight: '800',
color: '#333', color: '#1E293B',
flex: 1,
textAlign: 'center', textAlign: 'center',
marginHorizontal: 20, fontFamily: 'AliBold',
}, },
saveButton: { saveButton: {
paddingHorizontal: 12, borderRadius: 20,
paddingVertical: 6, overflow: 'hidden',
borderRadius: 16,
}, },
saveButtonDisabled: { saveButtonDisabled: {
opacity: 0.5, opacity: 0.6,
},
saveButtonGradient: {
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 20,
}, },
saveButtonText: { saveButtonText: {
fontSize: 16, fontSize: 14,
color: Colors.light.primary, color: '#FFFFFF',
fontWeight: '500', fontWeight: '700',
}, fontFamily: 'AliBold',
saveButtonTextDisabled: {
color: Colors.light.textMuted,
}, },
previewSection: { previewSection: {
paddingHorizontal: 16, paddingHorizontal: 20,
paddingBottom: 16, marginBottom: 24,
}, },
previewCard: { previewCard: {
backgroundColor: '#F8F9FA', borderRadius: 24,
borderRadius: 12, padding: 20,
padding: 16, overflow: 'hidden',
marginTop: 8, backgroundColor: '#FFFFFF',
shadowColor: 'rgba(30, 41, 59, 0.08)',
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.4,
shadowRadius: 12,
elevation: 4,
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.6)',
},
previewHeader: {
marginBottom: 16,
}, },
previewContent: { previewContent: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
}, },
imageWrapper: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 4,
},
previewImage: { previewImage: {
width: 32, width: 56,
height: 32, height: 56,
borderRadius: 4, borderRadius: 16,
backgroundColor: '#F8FAFC',
}, },
previewImagePlaceholder: { previewImagePlaceholder: {
width: 32, width: 56,
height: 32, height: 56,
borderRadius: 4, borderRadius: 16,
backgroundColor: '#E5E5E5', backgroundColor: '#F1F5F9',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
borderWidth: 1,
borderColor: '#E2E8F0',
}, },
previewInfo: { previewInfo: {
flex: 1, flex: 1,
marginLeft: 12, marginLeft: 16,
justifyContent: 'center',
}, },
previewName: { previewName: {
fontSize: 16, fontSize: 18,
fontWeight: '500', fontWeight: '700',
color: '#333', color: '#1E293B',
marginBottom: 2, marginBottom: 6,
fontFamily: 'AliBold',
},
previewBadge: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#FFFBEB',
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 8,
alignSelf: 'flex-start',
gap: 4,
}, },
previewCalories: { previewCalories: {
fontSize: 14, fontSize: 13,
color: '#666', color: '#D97706',
fontWeight: '600',
fontFamily: 'AliRegular',
}, },
section: { section: {
paddingHorizontal: 16, paddingHorizontal: 20,
paddingVertical: 12, marginBottom: 24,
}, },
sectionHeader: { sectionHeader: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
marginBottom: 4, marginBottom: 12,
paddingHorizontal: 4,
}, },
sectionTitle: { sectionTitle: {
fontSize: 14, fontSize: 14,
color: '#333', fontWeight: '700',
marginLeft: 8 color: '#64748B',
fontFamily: 'AliBold',
textTransform: 'uppercase',
letterSpacing: 0.5,
}, },
requiredIndicator: { requiredIndicator: {
fontSize: 16, fontSize: 14,
color: '#FF4444', color: '#EF4444',
marginLeft: 4, marginLeft: 4,
}, fontWeight: '700',
inputGroup: {
marginBottom: 20,
},
inputRowGroup: {
flexDirection: 'row',
gap: 12,
marginBottom: 20,
},
inputRowItem: {
flex: 1,
},
inputLabel: {
fontSize: 14,
color: '#666',
fontWeight: '500',
},
modernTextInput: {
flex: 1,
borderRadius: 12,
paddingHorizontal: 12,
paddingVertical: 8,
fontSize: 16,
marginLeft: 20,
color: '#333',
backgroundColor: '#FFFFFF',
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
elevation: 1,
},
numberInputContainer: {
flexDirection: 'row',
alignItems: 'center',
borderRadius: 12,
backgroundColor: '#FFFFFF',
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
elevation: 1,
},
modernNumberInput: {
flex: 1,
paddingHorizontal: 12,
paddingVertical: 8,
fontSize: 16,
color: '#333',
textAlign: 'right',
},
unitText: {
fontSize: 14,
color: '#666',
paddingRight: 16,
minWidth: 40,
textAlign: 'center',
},
modernSelectButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
borderWidth: 1.5,
borderColor: '#E8E8E8',
borderRadius: 12,
paddingHorizontal: 16,
paddingVertical: 14,
backgroundColor: '#FFFFFF',
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
elevation: 1,
},
selectButtonText: {
fontSize: 14,
color: 'gray',
fontWeight: '500',
},
modernImageSelector: {
alignSelf: 'flex-end',
borderRadius: 16,
overflow: 'hidden',
},
selectedImage: {
width: 80,
height: 80,
borderRadius: 16,
},
modernImagePlaceholder: {
width: 80,
height: 80,
borderRadius: 16,
backgroundColor: '#F8F8F8',
alignItems: 'center',
justifyContent: 'center',
borderWidth: 1.5,
borderColor: '#E8E8E8',
borderStyle: 'dashed',
},
imagePlaceholderText: {
fontSize: 12,
color: '#A0A0A0',
marginTop: 4,
fontWeight: '500',
},
nutritionRow: {
flexDirection: 'row',
gap: 12,
marginBottom: 20,
},
nutritionItem: {
flex: 1,
},
// 保留旧样式以防兼容性问题
textInput: {
borderWidth: 1,
borderColor: '#E5E5E5',
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 12,
fontSize: 16,
color: '#333',
backgroundColor: '#FFFFFF',
},
numberInput: {
flex: 1,
borderWidth: 1,
borderColor: '#E5E5E5',
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 12,
fontSize: 16,
color: '#333',
backgroundColor: '#FFFFFF',
textAlign: 'right',
},
inputWithUnit: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
inputUnit: {
fontSize: 16,
color: '#666',
minWidth: 30,
},
selectButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
borderWidth: 1,
borderColor: '#E5E5E5',
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 12,
backgroundColor: '#FFFFFF',
},
imageSelector: {
alignSelf: 'flex-end',
borderRadius: 12,
overflow: 'hidden',
},
imagePlaceholder: {
width: 60,
height: 60,
borderRadius: 12,
backgroundColor: '#F0F0F0',
alignItems: 'center',
justifyContent: 'center',
borderWidth: 1,
borderColor: '#E5E5E5',
},
disclaimer: {
paddingHorizontal: 16,
paddingVertical: 20,
paddingBottom: 40,
},
disclaimerText: {
fontSize: 12,
color: '#999',
lineHeight: 18,
}, },
sectionCard: { sectionCard: {
backgroundColor: '#F8F9FA', backgroundColor: '#FFFFFF',
borderRadius: 12, borderRadius: 24,
padding: 16, padding: 20,
marginTop: 8, shadowColor: 'rgba(30, 41, 59, 0.05)',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.5,
shadowRadius: 10,
elevation: 2,
}, },
// 新增行布局样式
inputRowContainer: { inputRowContainer: {
flexDirection: 'row', flexDirection: 'row',
justifyContent: 'space-between', justifyContent: 'space-between',
alignItems: 'center', alignItems: 'center',
marginBottom: 20, marginBottom: 16,
}, },
inputRowLabel: { inputRowLabel: {
fontSize: 14, fontSize: 15,
color: '#666', color: '#475569',
fontWeight: '500', fontWeight: '600',
width: 80, width: 90,
marginRight: 12, marginRight: 12,
fontFamily: 'AliRegular',
}, },
inputRowContent: { inputRowContent: {
flex: 1, flex: 1,
}, },
imageLoadingOverlay: { modernInputContainer: {
position: 'absolute', flexDirection: 'row',
left: 0, alignItems: 'center',
right: 0, borderRadius: 16,
top: 0, backgroundColor: '#F8FAFC',
bottom: 0, borderWidth: 1,
borderColor: '#E2E8F0',
overflow: 'hidden',
},
modernNumberInput: {
flex: 1,
paddingHorizontal: 16,
paddingVertical: 12,
fontSize: 16,
color: '#1E293B',
textAlign: 'right',
fontFamily: 'AliRegular',
},
unitText: {
fontSize: 14,
color: '#94A3B8',
paddingRight: 16,
minWidth: 40,
textAlign: 'center',
fontWeight: '500',
},
modernImageSelector: {
alignSelf: 'flex-end',
borderRadius: 20,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.05,
shadowRadius: 4,
elevation: 2,
},
selectedImage: {
width: 72,
height: 72,
borderRadius: 20,
},
modernImagePlaceholder: {
width: 72,
height: 72,
borderRadius: 20,
backgroundColor: '#F8FAFC',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
backgroundColor: 'rgba(0,0,0,0.5)', borderWidth: 1,
borderRadius: 16, borderColor: '#E2E8F0',
borderStyle: 'dashed',
},
imagePlaceholderText: {
fontSize: 11,
color: '#94A3B8',
marginTop: 4,
fontWeight: '600',
textAlign: 'center',
},
imageLoadingOverlay: {
...StyleSheet.absoluteFillObject,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(0,0,0,0.3)',
borderRadius: 20,
}, },
}); });

View File

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

View File

@@ -11,6 +11,7 @@ import {
import { Colors } from '@/constants/Colors'; import { Colors } from '@/constants/Colors';
import { useColorScheme } from '@/hooks/useColorScheme'; import { useColorScheme } from '@/hooks/useColorScheme';
import { useI18n } from '@/hooks/useI18n';
// 睡眠详情数据类型 // 睡眠详情数据类型
export type SleepDetailData = { export type SleepDetailData = {
@@ -41,15 +42,22 @@ const SleepGradeCard = ({
range: string; range: string;
isActive?: boolean; isActive?: boolean;
}) => { }) => {
const { t } = useI18n();
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark'; const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme]; const colorTokens = Colors[theme];
const getGradeColor = (grade: string) => { const getGradeColor = (grade: string) => {
switch (grade) { switch (grade) {
case '低': case '较差': return { bg: '#FECACA', text: '#DC2626' }; case t('sleepDetail.sleepGrades.low'):
case '正常': case '一般': return { bg: '#D1FAE5', text: '#065F46' }; case t('sleepDetail.sleepGrades.poor'):
case '良好': return { bg: '#D1FAE5', text: '#065F46' }; return { bg: '#FECACA', text: '#DC2626' };
case '优秀': return { bg: '#FEF3C7', text: '#92400E' }; case t('sleepDetail.sleepGrades.normal'):
case t('sleepDetail.sleepGrades.fair'):
return { bg: '#D1FAE5', text: '#065F46' };
case t('sleepDetail.sleepGrades.good'):
return { bg: '#D1FAE5', text: '#065F46' };
case t('sleepDetail.sleepGrades.excellent'):
return { bg: '#FEF3C7', text: '#92400E' };
default: return { bg: colorTokens.pageBackgroundEmphasis, text: colorTokens.textSecondary }; default: return { bg: colorTokens.pageBackgroundEmphasis, text: colorTokens.textSecondary };
} }
}; };
@@ -97,6 +105,7 @@ export const InfoModal = ({
type: 'sleep-time' | 'sleep-quality'; type: 'sleep-time' | 'sleep-quality';
sleepData: SleepDetailData; sleepData: SleepDetailData;
}) => { }) => {
const { t } = useI18n();
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark'; const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme]; const colorTokens = Colors[theme];
const slideAnim = useState(new Animated.Value(0))[0]; const slideAnim = useState(new Animated.Value(0))[0];
@@ -153,26 +162,26 @@ export const InfoModal = ({
const currentSleepQualityGrade = getSleepQualityGrade(sleepData.sleepQualityPercentage || 94); // 默认94% const currentSleepQualityGrade = getSleepQualityGrade(sleepData.sleepQualityPercentage || 94); // 默认94%
const sleepTimeGrades = [ const sleepTimeGrades = [
{ icon: 'alert-circle-outline', grade: '低', range: '< 6h', isActive: currentSleepTimeGrade === 0 }, { icon: 'alert-circle-outline', grade: t('sleepDetail.sleepGrades.low'), range: '< 6h', isActive: currentSleepTimeGrade === 0 },
{ icon: 'checkmark-circle-outline', grade: '正常', range: '6h - 7h or > 9h', isActive: currentSleepTimeGrade === 1 }, { icon: 'checkmark-circle-outline', grade: t('sleepDetail.sleepGrades.normal'), range: '6h - 7h or > 9h', isActive: currentSleepTimeGrade === 1 },
{ icon: 'checkmark-circle', grade: '良好', range: '7h - 8h', isActive: currentSleepTimeGrade === 2 }, { icon: 'checkmark-circle', grade: t('sleepDetail.sleepGrades.good'), range: '7h - 8h', isActive: currentSleepTimeGrade === 2 },
{ icon: 'star', grade: '优秀', range: '8h - 9h', isActive: currentSleepTimeGrade === 3 }, { icon: 'star', grade: t('sleepDetail.sleepGrades.excellent'), range: '8h - 9h', isActive: currentSleepTimeGrade === 3 },
]; ];
const sleepQualityGrades = [ const sleepQualityGrades = [
{ icon: 'alert-circle-outline', grade: '较差', range: '< 55%', isActive: currentSleepQualityGrade === 0 }, { icon: 'alert-circle-outline', grade: t('sleepDetail.sleepGrades.poor'), range: '< 55%', isActive: currentSleepQualityGrade === 0 },
{ icon: 'checkmark-circle-outline', grade: '一般', range: '55% - 69%', isActive: currentSleepQualityGrade === 1 }, { icon: 'checkmark-circle-outline', grade: t('sleepDetail.sleepGrades.fair'), range: '55% - 69%', isActive: currentSleepQualityGrade === 1 },
{ icon: 'checkmark-circle', grade: '良好', range: '70% - 84%', isActive: currentSleepQualityGrade === 2 }, { icon: 'checkmark-circle', grade: t('sleepDetail.sleepGrades.good'), range: '70% - 84%', isActive: currentSleepQualityGrade === 2 },
{ icon: 'star', grade: '优秀', range: '85% - 100%', isActive: currentSleepQualityGrade === 3 }, { icon: 'star', grade: t('sleepDetail.sleepGrades.excellent'), range: '85% - 100%', isActive: currentSleepQualityGrade === 3 },
]; ];
const currentGrades = type === 'sleep-time' ? sleepTimeGrades : sleepQualityGrades; const currentGrades = type === 'sleep-time' ? sleepTimeGrades : sleepQualityGrades;
const getDescription = () => { const getDescription = () => {
if (type === 'sleep-time') { if (type === 'sleep-time') {
return '睡眠最重要 - 它占据了你睡眠得分的一半以上。长时间的睡眠可以减少睡眠债务,但是规律的睡眠时间对于高质量的休息至关重要。'; return t('sleepDetail.sleepTimeDescription');
} else { } else {
return '睡眠质量综合评估您的睡眠效率、深度睡眠时长、REM睡眠比例等多个指标。高质量的睡眠不仅仅取决于时长还包括睡眠的连续性和各睡眠阶段的平衡。'; return t('sleepDetail.sleepQualityDescription');
} }
}; };

View File

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

View File

@@ -13,6 +13,7 @@ import {
import { Colors } from '@/constants/Colors'; import { Colors } from '@/constants/Colors';
import { useColorScheme } from '@/hooks/useColorScheme'; import { useColorScheme } from '@/hooks/useColorScheme';
import { useI18n } from '@/hooks/useI18n';
// Sleep Stages Info Modal 组件 // Sleep Stages Info Modal 组件
export const SleepStagesInfoModal = ({ export const SleepStagesInfoModal = ({
@@ -22,6 +23,7 @@ export const SleepStagesInfoModal = ({
visible: boolean; visible: boolean;
onClose: () => void; onClose: () => void;
}) => { }) => {
const { t } = useI18n();
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark'; const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme]; const colorTokens = Colors[theme];
const slideAnim = useState(new Animated.Value(0))[0]; const slideAnim = useState(new Animated.Value(0))[0];
@@ -82,7 +84,7 @@ export const SleepStagesInfoModal = ({
<View style={styles.sleepStagesModalHeader}> <View style={styles.sleepStagesModalHeader}>
<Text style={[styles.sleepStagesModalTitle, { color: colorTokens.text }]}> <Text style={[styles.sleepStagesModalTitle, { color: colorTokens.text }]}>
{t('sleepDetail.sleepStagesInfo.title')}
</Text> </Text>
<TouchableOpacity onPress={onClose} style={styles.infoModalCloseButton}> <TouchableOpacity onPress={onClose} style={styles.infoModalCloseButton}>
<Ionicons name="close" size={24} color={colorTokens.textSecondary} /> <Ionicons name="close" size={24} color={colorTokens.textSecondary} />
@@ -97,7 +99,7 @@ export const SleepStagesInfoModal = ({
scrollEnabled={true} scrollEnabled={true}
> >
<Text style={[styles.sleepStagesDescription, { color: colorTokens.textSecondary }]}> <Text style={[styles.sleepStagesDescription, { color: colorTokens.textSecondary }]}>
{t('sleepDetail.sleepStagesInfo.description')}
</Text> </Text>
{/* 清醒时间 */} {/* 清醒时间 */}
@@ -105,11 +107,11 @@ export const SleepStagesInfoModal = ({
<View style={[styles.sleepStageInfoHeader, { borderBottomColor: colorTokens.border }]}> <View style={[styles.sleepStageInfoHeader, { borderBottomColor: colorTokens.border }]}>
<View style={styles.sleepStageInfoTitleContainer}> <View style={styles.sleepStageInfoTitleContainer}>
<View style={[styles.sleepStageDot, { backgroundColor: '#F59E0B' }]} /> <View style={[styles.sleepStageDot, { backgroundColor: '#F59E0B' }]} />
<Text style={[styles.sleepStageInfoTitle, { color: colorTokens.text }]}></Text> <Text style={[styles.sleepStageInfoTitle, { color: colorTokens.text }]}>{t('sleepDetail.sleepStagesInfo.awake.title')}</Text>
</View> </View>
</View> </View>
<Text style={[styles.sleepStageInfoContent, { color: colorTokens.textSecondary }]}> <Text style={[styles.sleepStageInfoContent, { color: colorTokens.textSecondary }]}>
{t('sleepDetail.sleepStagesInfo.awake.description')}
</Text> </Text>
</View> </View>
@@ -118,11 +120,11 @@ export const SleepStagesInfoModal = ({
<View style={[styles.sleepStageInfoHeader, { borderBottomColor: colorTokens.border }]}> <View style={[styles.sleepStageInfoHeader, { borderBottomColor: colorTokens.border }]}>
<View style={styles.sleepStageInfoTitleContainer}> <View style={styles.sleepStageInfoTitleContainer}>
<View style={[styles.sleepStageDot, { backgroundColor: '#EC4899' }]} /> <View style={[styles.sleepStageDot, { backgroundColor: '#EC4899' }]} />
<Text style={[styles.sleepStageInfoTitle, { color: colorTokens.text }]}></Text> <Text style={[styles.sleepStageInfoTitle, { color: colorTokens.text }]}>{t('sleepDetail.sleepStagesInfo.rem.title')}</Text>
</View> </View>
</View> </View>
<Text style={[styles.sleepStageInfoContent, { color: colorTokens.textSecondary }]}> <Text style={[styles.sleepStageInfoContent, { color: colorTokens.textSecondary }]}>
{t('sleepDetail.sleepStagesInfo.rem.description')}
</Text> </Text>
</View> </View>
@@ -131,11 +133,11 @@ export const SleepStagesInfoModal = ({
<View style={[styles.sleepStageInfoHeader, { borderBottomColor: colorTokens.border }]}> <View style={[styles.sleepStageInfoHeader, { borderBottomColor: colorTokens.border }]}>
<View style={styles.sleepStageInfoTitleContainer}> <View style={styles.sleepStageInfoTitleContainer}>
<View style={[styles.sleepStageDot, { backgroundColor: '#8B5CF6' }]} /> <View style={[styles.sleepStageDot, { backgroundColor: '#8B5CF6' }]} />
<Text style={[styles.sleepStageInfoTitle, { color: colorTokens.text }]}></Text> <Text style={[styles.sleepStageInfoTitle, { color: colorTokens.text }]}>{t('sleepDetail.sleepStagesInfo.core.title')}</Text>
</View> </View>
</View> </View>
<Text style={[styles.sleepStageInfoContent, { color: colorTokens.textSecondary }]}> <Text style={[styles.sleepStageInfoContent, { color: colorTokens.textSecondary }]}>
{t('sleepDetail.sleepStagesInfo.core.description')}
</Text> </Text>
</View> </View>
@@ -144,11 +146,11 @@ export const SleepStagesInfoModal = ({
<View style={[styles.sleepStageInfoHeader, { borderBottomColor: colorTokens.border }]}> <View style={[styles.sleepStageInfoHeader, { borderBottomColor: colorTokens.border }]}>
<View style={styles.sleepStageInfoTitleContainer}> <View style={styles.sleepStageInfoTitleContainer}>
<View style={[styles.sleepStageDot, { backgroundColor: '#3B82F6' }]} /> <View style={[styles.sleepStageDot, { backgroundColor: '#3B82F6' }]} />
<Text style={[styles.sleepStageInfoTitle, { color: colorTokens.text }]}></Text> <Text style={[styles.sleepStageInfoTitle, { color: colorTokens.text }]}>{t('sleepDetail.sleepStagesInfo.deep.title')}</Text>
</View> </View>
</View> </View>
<Text style={[styles.sleepStageInfoContent, { color: colorTokens.textSecondary }]}> <Text style={[styles.sleepStageInfoContent, { color: colorTokens.textSecondary }]}>
{t('sleepDetail.sleepStagesInfo.deep.description')}
</Text> </Text>
</View> </View>
</ScrollView> </ScrollView>

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