30 Commits

Author SHA1 Message Date
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
richarjiang
39671ed70f feat(challenges): 添加自定义挑战功能和多语言支持
- 新增自定义挑战创建页面,支持设置挑战类型、时间范围、目标值等
- 实现挑战邀请码系统,支持通过邀请码加入自定义挑战
- 完善挑战详情页面的多语言翻译支持
- 优化用户认证状态检查逻辑,使用token作为主要判断依据
- 添加阿里字体文件支持,提升UI显示效果
- 改进确认弹窗组件,支持Liquid Glass效果和自定义内容
- 优化应用启动流程,直接读取onboarding状态而非预加载用户数据
2025-11-26 16:39:01 +08:00
richarjiang
3ad0e08d58 perf(app): 添加登录状态检查并优化性能
- 在多个页面添加 isLoggedIn 检查,防止未登录时进行不必要的数据获取
- 使用 React.memo 和 useMemo 优化个人页面徽章渲染性能
- 为 badges API 添加节流机制,避免频繁请求
- 优化图片缓存策略和字符串处理
- 移除调试日志并改进推送通知的认证检查
2025-11-25 15:35:30 +08:00
richarjiang
6f2b7eb45e feat(medications): 简化药品添加流程并优化AI相机交互体验
- 移除药品添加选项底部抽屉,直接跳转至AI识别相机
- 优化AI相机拍摄完成后的按钮交互,展开为"拍照"和"完成"两个按钮
- 添加相机引导提示本地存储,避免重复显示
- 修复相机页面布局跳动问题,固定相机高度
- 为医疗免责声明组件添加触觉反馈错误处理
- 实现活动热力图的国际化支持,包括月份标签和统计文本
2025-11-25 14:09:24 +08:00
richarjiang
3db2d39a58 perf(store): 优化 selector 性能并移除未使用代码
- 使用 createSelector 和 useMemo 优化 medications 和 tabBarConfig 的 selector,避免不必要的重渲染
- 添加空数组常量 EMPTY_RECORDS_ARRAY,减少对象创建开销
- 移除 _layout.tsx 中未使用的路由配置
- 删除过时的通知实现文档
- 移除 pushNotificationManager 中未使用的 token 刷新监听器
- 禁用开发环境的后台任务调试工具初始化
2025-11-24 11:11:29 +08:00
richarjiang
c1c9f22111 feat(review): 集成iOS应用内评分功能
- 新增iOS原生模块AppStoreReviewManager,封装StoreKit评分请求
- 实现appStoreReviewService服务层,管理评分请求时间间隔(14天)
- 在关键用户操作后触发评分请求:完成挑战、记录服药、记录体重、记录饮水
- 优化通知设置页面UI,改进设置项布局和视觉层次
- 调整用药卡片样式,优化状态显示和文字大小
- 新增配置检查脚本check-app-review-setup.sh
- 修改喝水提醒默认状态为关闭

评分请求策略:
- 仅iOS 14.0+支持
- 自动控制请求频率,避免过度打扰用户
- 延迟1秒执行,不阻塞主业务流程
- 所有评分请求均做错误处理,确保不影响核心功能
2025-11-24 10:06:18 +08:00
8cbf6be50a feat: add nutrition and mood reminder settings
- Implemented nutrition and mood reminder toggles in notification settings screen.
- Added corresponding utility functions for managing nutrition and mood reminder preferences.
- Updated user preferences interface to include nutrition and mood reminder states.
- Enhanced localization for new reminder settings and alerts.
- Incremented iOS app version to 1.0.30.
2025-11-23 22:47:54 +08:00
richarjiang
bcb910140e feat(medications): 添加AI智能识别药品功能和有效期管理
- 新增AI药品识别流程,支持多角度拍摄和实时进度显示
- 添加药品有效期字段,支持在添加和编辑药品时设置有效期
- 新增MedicationAddOptionsSheet选择录入方式(AI识别/手动录入)
- 新增ai-camera和ai-progress两个独立页面处理AI识别流程
- 新增ExpiryDatePickerModal和MedicationPhotoGuideModal组件
- 移除本地通知系统,迁移到服务端推送通知
- 添加medicationNotificationCleanup服务清理旧的本地通知
- 更新药品详情页支持AI草稿模式和有效期显示
- 优化药品表单,支持有效期选择和AI识别结果确认
- 更新i18n资源,添加有效期相关翻译

BREAKING CHANGE: 药品通知系统从本地通知迁移到服务端推送,旧版本的本地通知将被清理
2025-11-21 17:32:44 +08:00
richarjiang
29942feee9 feat(ui): 添加底部标签栏自定义配置功能和药物堆叠展示
- 新增底部标签栏配置页面,支持切换标签显示/隐藏和恢复默认设置
- 实现已服用药物的堆叠卡片展示,优化药物列表视觉层次
- 集成Redux状态管理底部标签栏配置,支持本地持久化
- 优化个人中心页面背景渐变效果,移除装饰性圆圈元素
- 更新启动页和应用图标为新的品牌视觉
- 药物详情页AI分析加载动画替换为Lottie动画
- 调整药物卡片圆角半径提升视觉一致性
- 新增多语言支持(中英文)用于标签栏配置界面

主要改进:
1. 用户可以自定义底部导航栏显示内容
2. 已完成的药物以堆叠形式展示,节省空间
3. 配置数据通过AsyncStorage持久化保存
4. 支持默认配置恢复功能
2025-11-20 17:55:17 +08:00
richarjiang
84abfa2506 feat(medication): 重构AI分析为结构化展示并支持喝水提醒个性化配置
- 将药品AI分析从Markdown流式输出重构为结构化数据展示(V2)
- 新增适合人群、不适合人群、主要成分、副作用等分类卡片展示
- 优化AI分析UI布局,采用卡片式设计提升可读性
- 新增药品跳过功能,支持用户标记本次用药为已跳过
- 修复喝水提醒逻辑,支持用户开关控制和自定义时间段配置
- 优化个人资料编辑页面键盘适配,避免输入框被遮挡
- 统一API响应码处理,兼容200和0两种成功状态码
- 更新版本号至1.0.28

BREAKING CHANGE: 药品AI分析接口从流式Markdown输出改为结构化JSON格式,旧版本分析结果将不再显示
2025-11-20 10:10:53 +08:00
188 changed files with 31409 additions and 12669 deletions

View File

@@ -1,6 +1,7 @@
# 项目当前状态
## 应用基本信息
- **应用名称**: Out Live超越生命
- **版本**: 1.0.19
- **Bundle ID**: com.anonymous.digitalpilates
@@ -9,8 +10,9 @@
- **架构**: Expo Prebuild 后的 React Native 应用
## 当前开发状态
- **开发阶段**: 生产就绪版本
- **最后更新**: 2025年10
- **最后更新**: 2025 年 11
- **主要功能**: 已完成核心健康数据追踪、AI 教练、目标管理、轻断食等功能
- **状态管理**: 使用 Redux Toolkit 进行状态管理
- **数据存储**: 本地使用 expo-sqlite/kv-store远程 API 集成
@@ -18,41 +20,48 @@
## 核心功能实现状态
### 健康数据追踪 ✅
- HealthKit 集成完成支持步数、心率、HRV、睡眠等数据
- 活动圆环显示(活动卡路里、锻炼分钟、站立小时)
- 实时健康数据监控和历史数据查看
- 健康权限管理系统
### 营养管理 ✅
- 饮食记录功能(文字、语音、拍照识别)
- 营养成分分析和卡路里计算
- 食物库和自定义食物功能
- 营养标签识别
### 目标与习惯管理 ✅
- 目标创建、编辑、删除功能
- 任务分解和进度追踪
- 智能提醒系统
- 目标完成统计和分析
### 轻断食功能 ✅
- 多种预设断食方案16:8、18:6等
- 多种预设断食方案16:8、18:6 等)
- 实时断食进度显示
- 断食提醒和通知
- 断食历史记录
### AI 教练系统 ✅
- AI 对话功能(流式响应)
- 体态评估(照片分析)
- 个性化健康建议
- 情绪分析(基于 HRV
### 社区与挑战 ✅
- 挑战赛参与和排行榜
- 成就系统
- 社交分享功能
### 训练计划 ✅
- 个性化训练计划生成
- 运动库和动作指导
- 训练进度记录
@@ -60,6 +69,7 @@
## 技术架构状态
### 前端架构 ✅
- React Native 0.81.4 + Expo 54
- TypeScript 全面覆盖
- Expo Router 6.0 用于路由管理
@@ -67,12 +77,14 @@
- Liquid Glass 设计风格实现
### 后端集成 ✅
- RESTful API 集成API 基础地址https://pilate.richarjiang.com
- 用户认证和授权
- 数据同步和备份
- 推送通知服务
### 原生功能 ✅
- HealthKit 深度集成
- 推送通知(本地和远程)
- 快捷动作Quick Actions
@@ -82,32 +94,39 @@
## 当前开发重点
### 近期更新
1. **性能优化**: 优化健康数据加载和图表渲染性能
2. **用户体验**: 改进 Liquid Glass 设计效果和交互动画
3. **数据同步**: 增强离线功能和数据同步稳定性
4. **AI 功能**: 扩展 AI 教练对话能力和分析精度
1. **多语言支持**: 完善挑战页面的多语言翻译支持,建立翻译最佳实践指南
2. **性能优化**: 优化健康数据加载和图表渲染性能
3. **用户体验**: 改进 Liquid Glass 设计效果和交互动画
4. **数据同步**: 增强离线功能和数据同步稳定性
5. **AI 功能**: 扩展 AI 教练对话能力和分析精度
### 待解决问题
1. **测试覆盖**: 自动化测试覆盖率需要提升
2. **错误监控**: 需要集成更完善的错误监控和分析
3. **性能监控**: 应用性能监控和分析工具集成
4. **文档完善**: API 文档和组件文档需要进一步完善
1. **多语言覆盖**: 其他页面的多语言翻译支持需要逐步完善
2. **测试覆盖**: 自动化测试覆盖率需要提升
3. **错误监控**: 需要集成更完善的错误监控和分析
4. **性能监控**: 应用性能监控和分析工具集成
5. **文档完善**: API 文档和组件文档需要进一步完善
## 代码质量状态
### 代码规范 ✅
- ESLint 配置完善eslint-config-expo
- Prettier 代码格式化
- TypeScript 严格模式
- 组件和函数命名规范
### 项目结构 ✅
- 清晰的目录结构app/、components/、services/、store/、utils/
- 功能模块化组织
- 类型定义完整
- 常量和配置集中管理
### 状态管理 ✅
- Redux Toolkit 标准实现
- 异步操作处理规范
- 数据持久化策略
@@ -116,12 +135,14 @@
## 部署和发布
### 构建配置 ✅
- Expo Prebuild 配置
- iOS 证书和配置文件
- App Store 发布配置
- 自动化构建流程
### 发布状态 ✅
- App Store 已发布版本
- 支持 OTA 更新
- 崩溃监控和分析
@@ -130,12 +151,14 @@
## 团队协作
### 开发工具 ✅
- Git 版本控制
- VS Code 开发环境
- Expo 开发者工具
- iOS 模拟器和真机调试
### 文档状态 🔄
- API 文档部分完成
- 组件文档需要补充
- 部署文档完善
@@ -143,20 +166,24 @@
## 下一步计划
### 短期目标1-2个月
1. 完善自动化测试覆盖
2. 优化应用启动性能
3. 增强错误监控和分析
4. 改进用户引导流程
### 短期目标1-2 个月)
1. 完善所有核心页面的多语言翻译支持
2. 完善自动化测试覆盖
3. 优化应用启动性能
4. 增强错误监控和分析
5. 改进用户引导流程
### 中期目标3-6 个月)
### 中期目标3-6个月
1. 扩展 AI 教练功能
2. 增加更多健康指标追踪
3. 优化数据同步策略
4. 增强社交功能
### 长期目标6个月以上
### 长期目标6 个月以上)
1. 支持 Apple Watch 应用
2. 集成更多第三方健康设备
3. 开发 Web 端管理界面
4. 扩展企业健康解决方案
4. 扩展企业健康解决方案

View File

@@ -5,26 +5,31 @@
**最后更新**: 2025-10-24
### 重要规则
**项目中不允许使用 MaterialIcons**,所有图标必须使用 Ionicons 以保持图标库的一致性。
### 问题描述
在项目中发现使用 MaterialIcons 的情况,需要将所有 MaterialIcons 替换为 Ionicons以保持图标库的一致性。
### 解决方案
将所有 MaterialIcons 导入和使用替换为对应的 Ionicons。
### 实现模式
#### 1. 替换导入语句
```typescript
// ❌ 禁止使用
import { MaterialIcons } from '@expo/vector-icons';
import { MaterialIcons } from "@expo/vector-icons";
// ✅ 正确写法
import { Ionicons } from '@expo/vector-icons';
import { Ionicons } from "@expo/vector-icons";
```
#### 2. 替换图标名称和属性
```typescript
// ❌ 禁止使用
<MaterialIcons name="arrow-back-ios" size={20} color="#333" />
@@ -34,6 +39,7 @@ import { Ionicons } from '@expo/vector-icons';
```
#### 3. 常见图标映射
- `arrow-back-ios``chevron-back` (返回按钮)
- `auto-awesome``star` (星星/自动推荐)
- `tips-and-updates``bulb` (提示/建议)
@@ -42,6 +48,7 @@ import { Ionicons } from '@expo/vector-icons';
- `remove``remove` (移除/删除,名称相同)
### 重要注意事项
1. **图标大小调整**Ionicons 和 MaterialIcons 的默认大小可能不同,需要适当调整
2. **图标名称差异**:两个图标库的图标名称不同,需要找到对应的功能图标
3. **样式一致性**:确保替换后的图标在视觉上与原设计保持一致
@@ -49,6 +56,7 @@ import { Ionicons } from '@expo/vector-icons';
5. **代码审查**:在代码审查中需要特别检查是否使用了 MaterialIcons
### 参考实现
- `components/ui/HeaderBar.tsx` - 返回按钮的标准实现
- `components/model/MembershipModal.tsx` - 完整的 MaterialIcons 替换示例
@@ -57,21 +65,25 @@ import { Ionicons } from '@expo/vector-icons';
**最后更新**: 2025-10-24
### 重要原则
**所有按钮组件都需要尝试兼容 Liquid Glass**,这是项目的设计要求。
### 实现模式
#### 1. 导入必要的组件
```typescript
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { GlassView, isLiquidGlassAvailable } from "expo-glass-effect";
```
#### 2. 检查设备支持情况
```typescript
const isGlassAvailable = isLiquidGlassAvailable();
```
#### 3. 实现条件渲染的按钮
```typescript
<TouchableOpacity
onPress={handlePress}
@@ -81,9 +93,9 @@ const isGlassAvailable = isLiquidGlassAvailable();
{isLiquidGlassAvailable() ? (
<GlassView
style={styles.button}
glassEffectStyle="clear" // 或 "regular"
tintColor="rgba(255, 255, 255, 0.3)" // 自定义色调
isInteractive={true} // 启用交互反馈
glassEffectStyle="clear" // 或 "regular"
tintColor="rgba(255, 255, 255, 0.3)" // 自定义色调
isInteractive={true} // 启用交互反馈
>
<Ionicons name="icon-name" size={20} color="#333" />
</GlassView>
@@ -96,26 +108,28 @@ const isGlassAvailable = isLiquidGlassAvailable();
```
#### 4. 定义样式
```typescript
const styles = StyleSheet.create({
button: {
width: 40,
height: 40,
borderRadius: 20, // 圆形按钮
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden', // 保证玻璃边界圆角效果
borderRadius: 20, // 圆形按钮
alignItems: "center",
justifyContent: "center",
overflow: "hidden", // 保证玻璃边界圆角效果
// 其他通用样式...
},
fallbackButton: {
backgroundColor: 'rgba(255, 255, 255, 0.9)',
backgroundColor: "rgba(255, 255, 255, 0.9)",
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.3)',
borderColor: "rgba(255, 255, 255, 0.3)",
},
});
```
### 重要注意事项
1. **兼容性检查**:必须使用 `isLiquidGlassAvailable()` 检查设备支持情况
2. **overflow: 'hidden'**GlassView 组件需要设置此属性以保证圆角效果
3. **降级样式**:为不支持 Liquid Glass 的设备提供视觉上相似的替代方案
@@ -124,6 +138,7 @@ const styles = StyleSheet.create({
6. **色调自定义**:通过 `tintColor` 属性自定义按钮的颜色主题
### 常用配置
- **glassEffectStyle**: "clear"(透明)或 "regular"(常规)
- **tintColor**: 根据按钮功能选择合适的颜色
- 返回/导航操作:白色系 `rgba(255, 255, 255, 0.3)`
@@ -132,6 +147,7 @@ const styles = StyleSheet.create({
- 信息操作:蓝色系 `rgba(33, 150, 243, 0.2)`
### 参考实现
- `components/model/MembershipModal.tsx` - 悬浮返回按钮
- `components/glass/button.tsx` - 通用 Glass 按钮组件
- `app/(tabs)/_layout.tsx` - 标签栏按钮实现
@@ -141,24 +157,29 @@ const styles = StyleSheet.create({
**最后更新**: 2025-10-16
### 问题描述
当使用 HeaderBar 组件时,需要正确处理内容区域的顶部距离,确保内容不会被状态栏或刘海屏遮挡。
### 解决方案
使用 `useSafeAreaTop` hook 获取安全区域顶部距离,并应用到内容容器的样式中。
### 实现模式
#### 1. 导入必要的 hook
```typescript
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
import { useSafeAreaTop } from "@/hooks/useSafeAreaWithPadding";
```
#### 2. 在组件中获取 safeAreaTop
```typescript
const safeAreaTop = useSafeAreaTop()
const safeAreaTop = useSafeAreaTop();
```
#### 3. 应用到内容容器
```typescript
// 方式1: 直接应用到 View 组件
<View style={[styles.filterContainer, { paddingTop: safeAreaTop }]}>
@@ -175,11 +196,13 @@ const safeAreaTop = useSafeAreaTop()
```
### 重要注意事项
1. **不要在 StyleSheet 中使用变量**:不能在 `StyleSheet.create()` 中直接使用 `safeAreaTop` 变量
2. **使用动态样式**:必须通过内联样式或数组样式的方式动态应用 `safeAreaTop`
3. **不需要额外偏移**:通常只需要 `safeAreaTop`,不需要添加额外的固定像素值
### 示例代码
```typescript
// ❌ 错误写法 - 在 StyleSheet 中使用变量
const styles = StyleSheet.create({
@@ -193,6 +216,7 @@ const styles = StyleSheet.create({
```
### 参考页面
- `app/steps/detail.tsx`
- `app/water/detail.tsx`
- `app/profile/goals.tsx`
@@ -204,24 +228,29 @@ const styles = StyleSheet.create({
**最后更新**: 2025-10-16
### 问题描述
在应用中实现符合 Liquid Glass 设计风格的图标按钮,需要考虑毛玻璃效果和兼容性处理。
### 解决方案
使用 `GlassView` 组件实现毛玻璃效果,并提供不支持 Liquid Glass 的设备的降级方案。
### 实现模式
#### 1. 导入必要的组件和函数
```typescript
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { GlassView, isLiquidGlassAvailable } from "expo-glass-effect";
```
#### 2. 检查设备支持情况
```typescript
const isGlassAvailable = isLiquidGlassAvailable();
```
#### 3. 实现条件渲染的按钮
```typescript
<TouchableOpacity
onPress={handlePress}
@@ -231,9 +260,9 @@ const isGlassAvailable = isLiquidGlassAvailable();
{isGlassAvailable ? (
<GlassView
style={styles.glassButton}
glassEffectStyle="clear" // 或 "regular"
tintColor="rgba(244, 67, 54, 0.2)" // 自定义色调
isInteractive={true} // 启用交互反馈
glassEffectStyle="clear" // 或 "regular"
tintColor="rgba(244, 67, 54, 0.2)" // 自定义色调
isInteractive={true} // 启用交互反馈
>
<Ionicons name="trash-outline" size={20} color="#F44336" />
</GlassView>
@@ -246,25 +275,27 @@ const isGlassAvailable = isLiquidGlassAvailable();
```
#### 4. 定义样式
```typescript
const styles = StyleSheet.create({
glassButton: {
width: 36,
height: 36,
borderRadius: 18, // 圆形按钮
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden', // 保证玻璃边界圆角效果
borderRadius: 18, // 圆形按钮
alignItems: "center",
justifyContent: "center",
overflow: "hidden", // 保证玻璃边界圆角效果
},
fallbackButton: {
borderWidth: 1,
borderColor: 'rgba(244, 67, 54, 0.3)',
backgroundColor: 'rgba(244, 67, 54, 0.1)',
borderColor: "rgba(244, 67, 54, 0.3)",
backgroundColor: "rgba(244, 67, 54, 0.1)",
},
});
```
### 重要注意事项
1. **兼容性处理**:必须使用 `isLiquidGlassAvailable()` 检查设备支持情况
2. **overflow: 'hidden'**GlassView 组件需要设置此属性以保证圆角效果
3. **降级样式**:为不支持 Liquid Glass 的设备提供视觉上相似的替代方案
@@ -272,6 +303,7 @@ const styles = StyleSheet.create({
5. **色调自定义**:通过 `tintColor` 属性自定义按钮的颜色主题
### 常用配置
- **glassEffectStyle**: "clear"(透明)或 "regular"(常规)
- **tintColor**: 根据按钮功能选择合适的颜色
- 删除操作:红色系 `rgba(244, 67, 54, 0.2)`
@@ -279,6 +311,7 @@ const styles = StyleSheet.create({
- 信息操作:蓝色系 `rgba(33, 150, 243, 0.2)`
### 参考实现
- `app/food/nutrition-analysis-history.tsx` - 删除按钮实现
- `components/glass/button.tsx` - 通用 Glass 按钮组件
- `app/(tabs)/_layout.tsx` - 标签栏按钮实现
@@ -288,27 +321,33 @@ const styles = StyleSheet.create({
**最后更新**: 2025-10-16
### 问题描述
在应用中实现需要登录才能访问的功能时,需要判断用户是否已登录,未登录时先跳转到登录页面。
### 解决方案
使用 `useAuthGuard` hook 中的 `pushIfAuthedElseLogin` 方法处理需要登录验证的导航操作,使用 `ensureLoggedIn` 方法处理需要登录验证的功能实现。
### 权限校验原则
**重要**: 功能实现如果包含服务端接口的调用,需要使用 `ensureLoggedIn` 来判断用户是否登录。
### 实现模式
#### 1. 导入必要的 hook
```typescript
import { useAuthGuard } from '@/hooks/useAuthGuard';
import { useAuthGuard } from "@/hooks/useAuthGuard";
```
#### 2. 在组件中获取方法
```typescript
const { pushIfAuthedElseLogin, ensureLoggedIn } = useAuthGuard();
```
#### 3. 替换导航操作
```typescript
// ❌ 原来的写法 - 没有登录验证
<TouchableOpacity
@@ -324,6 +363,7 @@ const { pushIfAuthedElseLogin, ensureLoggedIn } = useAuthGuard();
```
#### 4. 服务端接口调用的登录验证
对于需要调用服务端接口的功能,使用 `ensureLoggedIn` 进行登录验证:
```typescript
@@ -347,33 +387,37 @@ const { pushIfAuthedElseLogin, ensureLoggedIn } = useAuthGuard();
```
#### 5. 完整示例(包含 Liquid Glass 兼容性处理)
```typescript
{isLiquidGlassAvailable() ? (
<TouchableOpacity
onPress={() => pushIfAuthedElseLogin('/food/nutrition-analysis-history')}
activeOpacity={0.7}
>
<GlassView
style={styles.historyButton}
glassEffectStyle="clear"
tintColor="rgba(255, 255, 255, 0.2)"
isInteractive={true}
{
isLiquidGlassAvailable() ? (
<TouchableOpacity
onPress={() => pushIfAuthedElseLogin("/food/nutrition-analysis-history")}
activeOpacity={0.7}
>
<GlassView
style={styles.historyButton}
glassEffectStyle="clear"
tintColor="rgba(255, 255, 255, 0.2)"
isInteractive={true}
>
<Ionicons name="time-outline" size={24} color="#333" />
</GlassView>
</TouchableOpacity>
) : (
<TouchableOpacity
onPress={() => pushIfAuthedElseLogin("/food/nutrition-analysis-history")}
style={[styles.historyButton, styles.fallbackBackground]}
activeOpacity={0.7}
>
<Ionicons name="time-outline" size={24} color="#333" />
</GlassView>
</TouchableOpacity>
) : (
<TouchableOpacity
onPress={() => pushIfAuthedElseLogin('/food/nutrition-analysis-history')}
style={[styles.historyButton, styles.fallbackBackground]}
activeOpacity={0.7}
>
<Ionicons name="time-outline" size={24} color="#333" />
</TouchableOpacity>
)}
</TouchableOpacity>
);
}
```
### 重要注意事项
1. **统一体验**:使用 `pushIfAuthedElseLogin` 可以确保登录后自动跳转到目标页面
2. **参数传递**:该方法支持传递路由参数,格式为 `pushIfAuthedElseLogin('/path', { param: value })`
3. **登录重定向**:登录页面会接收 `redirectTo``redirectParams` 参数用于登录后跳转
@@ -382,16 +426,19 @@ const { pushIfAuthedElseLogin, ensureLoggedIn } = useAuthGuard();
6. **异步处理**`ensureLoggedIn` 是异步函数,需要使用 `await` 等待结果
### 其他可用方法
- `ensureLoggedIn()` - 检查登录状态,未登录时跳转到登录页面,返回布尔值表示是否已登录
- `guardHandler(fn, options)` - 包装一个函数,在执行前确保用户已登录
- `isLoggedIn` - 布尔值,表示当前用户是否已登录
### 使用场景选择
- **页面导航**:使用 `pushIfAuthedElseLogin` 处理页面跳转
- **服务端接口调用**:使用 `ensureLoggedIn` 验证登录状态后再执行功能
- **函数包装**:使用 `guardHandler` 包装需要登录验证的函数
### 参考实现
- `app/food/nutrition-label-analysis.tsx` - 成分表分析功能登录验证
- `app/(tabs)/personal.tsx` - 个人中心编辑按钮
- `hooks/useAuthGuard.ts` - 完整的认证守卫实现
@@ -401,39 +448,44 @@ const { pushIfAuthedElseLogin, ensureLoggedIn } = useAuthGuard();
**最后更新**: 2025-10-16
### 问题描述
在应用开发中,所有路由路径都应该使用常量定义,而不是硬编码字符串。这样可以确保路由的一致性,便于维护和重构。
### 解决方案
将所有路由路径定义在 `constants/Routes.ts` 文件中,并在组件中使用这些常量。
### 实现模式
#### 1. 添加新路由常量
`constants/Routes.ts` 文件中添加新的路由常量:
```typescript
export const ROUTES = {
// 现有路由...
// 新增路由
FOOD_CAMERA: '/food/camera',
FOOD_CAMERA: "/food/camera",
} as const;
```
#### 2. 在组件中使用路由常量
导入并使用路由常量,而不是硬编码路径:
```typescript
import { ROUTES } from '@/constants/Routes';
import { ROUTES } from "@/constants/Routes";
// ❌ 错误写法 - 硬编码路径
router.push('/food/camera?mealType=dinner');
router.push("/food/camera?mealType=dinner");
// ✅ 正确写法 - 使用路由常量
router.push(`${ROUTES.FOOD_CAMERA}?mealType=dinner`);
```
#### 3. 结合登录验证使用
对于需要登录验证的路由,结合 `pushIfAuthedElseLogin` 使用:
```typescript
@@ -450,6 +502,7 @@ const { pushIfAuthedElseLogin } = useAuthGuard();
```
### 重要注意事项
1. **统一管理**:所有路由路径都必须在 `constants/Routes.ts` 中定义
2. **命名规范**:使用大写字母和下划线,如 `FOOD_CAMERA`
3. **路径一致性**:常量名应该清晰表达路由的用途
@@ -457,26 +510,244 @@ const { pushIfAuthedElseLogin } = useAuthGuard();
5. **类型安全**:使用 `as const` 确保类型推导
### 路由分类
按照功能模块对路由进行分组:
```typescript
export const ROUTES = {
// Tab路由
TAB_EXPLORE: '/explore',
TAB_COACH: '/coach',
TAB_EXPLORE: "/explore",
TAB_COACH: "/coach",
// 营养相关路由
NUTRITION_RECORDS: '/nutrition/records',
FOOD_LIBRARY: '/food-library',
FOOD_CAMERA: '/food/camera',
NUTRITION_RECORDS: "/nutrition/records",
FOOD_LIBRARY: "/food-library",
FOOD_CAMERA: "/food/camera",
// 用户相关路由
AUTH_LOGIN: '/auth/login',
PROFILE_EDIT: '/profile/edit',
AUTH_LOGIN: "/auth/login",
PROFILE_EDIT: "/profile/edit",
} as const;
```
### 参考实现
- `constants/Routes.ts` - 路由常量定义
- `components/NutritionRadarCard.tsx` - 使用路由常量和登录验证
- `app/food/camera.tsx` - 食物拍照页面实现
- `app/food/camera.tsx` - 食物拍照页面实现
## 多语言翻译实现规范
**最后更新**: 2025-11-26
### 重要原则
**所有用户可见的文本都必须支持多语言翻译**,这是项目的基本要求。不允许在代码中硬编码任何用户可见的中文或英文文本。
### 问题描述
在开发新功能或修改现有功能时,所有用户界面文本都需要支持多语言切换,确保应用能够为不同语言用户提供本地化体验。
### 解决方案
使用项目集成的 i18next 翻译系统,在 `i18n/index.ts` 中定义翻译资源,在组件中使用 `useI18n` hook 获取翻译文本。
### 实现模式
#### 1. 导入必要的 hook
```typescript
import { useI18n } from "@/hooks/useI18n";
```
#### 2. 在组件中获取翻译函数
```typescript
const { t } = useI18n();
```
#### 3. 添加翻译资源
`i18n/index.ts` 中为新的功能模块添加翻译资源:
```typescript
// 中文翻译
const newFeatureResources = {
title: "新功能标题",
subtitle: "新功能描述",
button: "按钮文本",
loading: "加载中...",
error: "操作失败,请稍后重试",
success: "操作成功",
};
// 英文翻译
const newFeatureResourcesEn = {
title: "New Feature Title",
subtitle: "New feature description",
button: "Button Text",
loading: "Loading...",
error: "Operation failed, please try again later",
success: "Operation successful",
};
// 添加到资源对象中
resources = {
zh: {
translation: {
// 现有翻译...
newFeature: newFeatureResources,
},
},
en: {
translation: {
// 现有翻译...
newFeature: newFeatureResourcesEn,
},
},
};
```
#### 4. 在组件中使用翻译
```typescript
// ❌ 错误写法 - 硬编码文本
<Text>加载中...</Text>
<Text>操作失败,请稍后重试</Text>
// ✅ 正确写法 - 使用翻译函数
<Text>{t('newFeature.loading')}</Text>
<Text>{t('newFeature.error')}</Text>
```
#### 5. 动态参数翻译
对于包含动态参数的文本,使用插值语法:
```typescript
// 翻译资源中
welcome: '欢迎,{{name}}'
itemsCount: '共 {{count}} 个项目'
// 组件中使用
<Text>{t('newFeature.welcome', { name: userName })}</Text>
<Text>{t('newFeature.itemsCount', { count: items.length })}</Text>
```
#### 6. 嵌套翻译键
对于复杂功能,使用嵌套的翻译键结构:
```typescript
// 翻译资源
modal: {
title: '确认操作',
description: '确定要执行此操作吗?',
buttons: {
confirm: '确认',
cancel: '取消',
},
}
// 组件中使用
<Text>{t('newFeature.modal.title')}</Text>
<Text>{t('newFeature.modal.buttons.confirm')}</Text>
```
### 重要注意事项
1. **禁止硬编码**:所有用户可见的文本都必须通过翻译函数获取
2. **完整翻译**:中文和英文翻译都必须提供,保持翻译完整性
3. **语义化命名**:翻译键应该清晰表达文本的用途和含义
4. **参数化文本**:包含动态内容的文本应该使用插值参数
5. **一致性**:相同功能的文本应该使用相同的翻译键
6. **Toast 消息**Toast 提示消息也需要翻译支持
7. **错误消息**:错误提示信息必须支持多语言
8. **表单验证**:表单验证错误信息需要翻译
### 常见翻译模式
#### 1. 状态文本
```typescript
status: {
loading: '加载中...',
success: '操作成功',
error: '操作失败',
empty: '暂无数据',
}
```
#### 2. 按钮文本
```typescript
buttons: {
confirm: '确认',
cancel: '取消',
save: '保存',
delete: '删除',
edit: '编辑',
add: '添加',
}
```
#### 3. 表单相关
```typescript
form: {
placeholders: {
email: '请输入邮箱地址',
password: '请输入密码',
},
errors: {
required: '此字段为必填项',
invalid: '格式不正确',
},
}
```
#### 4. 列表和表格
```typescript
list: {
empty: '暂无数据',
loading: '加载中...',
loadMore: '加载更多',
refresh: '刷新',
}
```
### 翻译键命名规范
1. **使用小写字母和点号分隔**`feature.section.item`
2. **按功能模块分组**`challenges.title`, `challenges.subtitle`
3. **语义化命名**`buttons.confirm`, `errors.network`
4. **避免缩写**:使用 `description` 而不是 `desc`
### 参考实现
- `app/(tabs)/challenges.tsx` - 完整的多语言翻译实现示例
- `i18n/index.ts` - 翻译资源配置
- `hooks/useI18n.ts` - 翻译 hook 实现
- `app/(tabs)/personal.tsx` - 个人中心页面翻译实现
- `app/food/nutrition-label-analysis.tsx` - 营养分析页面翻译实现
### 检查清单
在开发新功能时,请确保:
- [ ] 所有用户可见的文本都使用了翻译函数
- [ ]`i18n/index.ts` 中添加了对应的中文和英文翻译
- [ ] Toast 消息支持多语言
- [ ] 错误提示信息支持多语言
- [ ] 表单验证错误信息支持多语言
- [ ] 动态参数文本使用了插值语法
- [ ] 翻译键命名符合规范
- [ ] 测试了语言切换功能
### 最佳实践
1. **开发时即考虑多语言**:在编写组件时就使用翻译函数,而不是事后添加
2. **保持翻译一致性**:相同含义的文本使用相同的翻译键
3. **定期审查**:定期检查是否有硬编码文本遗漏
4. **测试验证**:在开发完成后测试语言切换功能是否正常

View File

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

View File

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

View File

@@ -12,7 +12,9 @@ import { IconSymbol } from '@/components/ui/IconSymbol';
import { Colors } from '@/constants/Colors';
import { ROUTES } from '@/constants/Routes';
import { TAB_BAR_BOTTOM_OFFSET, TAB_BAR_HEIGHT } from '@/constants/TabBar';
import { useAppSelector } from '@/hooks/redux';
import { useColorScheme } from '@/hooks/useColorScheme';
import { selectEnabledTabs } from '@/store/tabBarConfigSlice';
// Tab configuration
type TabConfig = {
@@ -21,11 +23,11 @@ type TabConfig = {
};
const TAB_CONFIGS: Record<string, TabConfig> = {
statistics: { icon: 'chart.pie.fill', titleKey: 'statistics.tabs.health' },
medications: { icon: 'pills.fill', titleKey: 'statistics.tabs.medications' },
fasting: { icon: 'timer', titleKey: 'statistics.tabs.fasting' },
challenges: { icon: 'trophy.fill', titleKey: 'statistics.tabs.challenges' },
personal: { icon: 'person.fill', titleKey: 'statistics.tabs.personal' },
statistics: { icon: 'chart.pie.fill', titleKey: 'health.tabs.health' },
medications: { icon: 'pills.fill', titleKey: 'health.tabs.medications' },
fasting: { icon: 'timer', titleKey: 'health.tabs.fasting' },
challenges: { icon: 'trophy.fill', titleKey: 'health.tabs.challenges' },
personal: { icon: 'person.fill', titleKey: 'health.tabs.personal' },
};
export default function TabLayout() {
@@ -34,6 +36,9 @@ export default function TabLayout() {
const colorTokens = Colors[theme];
const pathname = usePathname();
const glassEffectAvailable = isLiquidGlassAvailable();
// 获取已启用的标签配置(按自定义顺序)
const enabledTabs = useAppSelector(selectEnabledTabs);
// Helper function to determine if a tab is selected
const isTabSelected = (routeName: string): boolean => {
@@ -94,7 +99,7 @@ export default function TabLayout() {
color: colorTokens.tabIconSelected,
fontSize: 12,
fontWeight: '600',
marginLeft: 6,
marginLeft: 6
}}
numberOfLines={1}
>
@@ -174,42 +179,45 @@ export default function TabLayout() {
tabBarShowLabel: false,
});
// 根据配置渲染标签页
if (glassEffectAvailable) {
return <NativeTabs>
<NativeTabs.Trigger name="statistics">
<Label>{t('statistics.tabs.health')}</Label>
<Icon sf="chart.pie.fill" drawable="custom_android_drawable" />
</NativeTabs.Trigger>
<NativeTabs.Trigger name="medications">
<Icon sf="pills.fill" drawable="custom_android_drawable" />
<Label>{t('statistics.tabs.medications')}</Label>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="fasting">
<Icon sf="timer" drawable="custom_android_drawable" />
<Label>{t('statistics.tabs.fasting')}</Label>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="challenges">
<Icon sf="trophy.fill" drawable="custom_android_drawable" />
<Label>{t('statistics.tabs.challenges')}</Label>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="personal">
<Icon sf="person.fill" drawable="custom_settings_drawable" />
<Label>{t('statistics.tabs.personal')}</Label>
</NativeTabs.Trigger>
</NativeTabs>
return (
<NativeTabs>
{enabledTabs.map((tab) => {
const tabConfig = TAB_CONFIGS[tab.id];
if (!tabConfig) return null;
return (
<NativeTabs.Trigger key={tab.id} name={tab.id}>
<Icon sf={tabConfig.icon as any} drawable="custom_android_drawable" />
<Label>{t(tabConfig.titleKey)}</Label>
</NativeTabs.Trigger>
);
})}
</NativeTabs>
);
}
// 确定初始路由(第一个启用的标签)
const initialRouteName = enabledTabs.length > 0 ? enabledTabs[0].id : 'statistics';
return (
<Tabs
initialRouteName="statistics"
initialRouteName={initialRouteName}
screenOptions={({ route }) => getScreenOptions(route.name)}
>
<Tabs.Screen name="statistics" options={{ title: t('statistics.tabs.health') }} />
<Tabs.Screen name="medications" options={{ title: t('statistics.tabs.medications') }} />
<Tabs.Screen name="fasting" options={{ title: t('statistics.tabs.fasting') }} />
<Tabs.Screen name="challenges" options={{ title: t('statistics.tabs.challenges') }} />
<Tabs.Screen name="personal" options={{ title: t('statistics.tabs.personal') }} />
{enabledTabs.map((tab) => {
const tabConfig = TAB_CONFIGS[tab.id];
if (!tabConfig) return null;
return (
<Tabs.Screen
key={tab.id}
name={tab.id}
options={{ title: t(tabConfig.titleKey) }}
/>
);
})}
</Tabs>
);
}

View File

@@ -1,20 +1,32 @@
import dayjs from 'dayjs';
import ChallengeProgressCard from '@/components/challenges/ChallengeProgressCard';
import { ConfirmationSheet } from '@/components/ui/ConfirmationSheet';
import { Colors } from '@/constants/Colors';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useAuthGuard } from '@/hooks/useAuthGuard';
import { useColorScheme } from '@/hooks/useColorScheme';
import { useI18n } from '@/hooks/useI18n';
import {
fetchChallenges,
joinChallengeByCode,
resetJoinByCodeState,
selectChallengeCards,
selectChallengesListError,
selectChallengesListStatus,
selectCustomChallengeCards,
selectJoinByCodeError,
selectJoinByCodeStatus,
selectOfficialChallengeCards,
type ChallengeCardViewModel,
} from '@/store/challengesSlice';
import { Toast } from '@/utils/toast.utils';
import { Ionicons } from '@expo/vector-icons';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient';
import { useRouter } from 'expo-router';
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
ActivityIndicator,
Animated,
@@ -22,6 +34,7 @@ import {
ScrollView,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
View,
useWindowDimensions
@@ -31,11 +44,6 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
const AVATAR_SIZE = 36;
const CARD_IMAGE_WIDTH = 132;
const CARD_IMAGE_HEIGHT = 96;
const STATUS_LABELS: Record<'upcoming' | 'ongoing' | 'expired', string> = {
upcoming: '即将开始',
ongoing: '进行中',
expired: '已结束',
};
const CAROUSEL_ITEM_SPACING = 16;
const MIN_CAROUSEL_CARD_WIDTH = 280;
@@ -44,16 +52,32 @@ const DOT_BASE_SIZE = 6;
export default function ChallengesScreen() {
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const insets = useSafeAreaInsets();
const { t } = useI18n();
const { ensureLoggedIn } = useAuthGuard();
const colorTokens = Colors[theme];
const router = useRouter();
const dispatch = useAppDispatch();
const challenges = useAppSelector(selectChallengeCards);
const glassAvailable = isLiquidGlassAvailable();
const allChallenges = useAppSelector(selectChallengeCards);
const customChallenges = useAppSelector(selectCustomChallengeCards);
const officialChallenges = useAppSelector(selectOfficialChallengeCards);
const joinedCustomChallenges = useMemo(
() => customChallenges.filter((item) => item.isJoined),
[customChallenges]
);
const listStatus = useAppSelector(selectChallengesListStatus);
const listError = useAppSelector(selectChallengesListError);
const joinByCodeStatus = useAppSelector(selectJoinByCodeStatus);
const joinByCodeError = useAppSelector(selectJoinByCodeError);
const [joinModalVisible, setJoinModalVisible] = useState(false);
const [shareCodeInput, setShareCodeInput] = useState('');
const ongoingChallenges = useMemo(() => {
const now = dayjs();
return challenges.filter((challenge) => {
return allChallenges.filter((challenge) => {
if (challenge.status !== 'ongoing' || !challenge.isJoined || !challenge.progress) {
return false;
}
@@ -67,7 +91,7 @@ export default function ChallengesScreen() {
return true;
});
}, [challenges]);
}, [allChallenges]);
const progressTrackColor = theme === 'dark' ? 'rgba(255, 255, 255, 0.08)' : '#eceffa';
const progressInactiveColor = theme === 'dark' ? 'rgba(255, 255, 255, 0.24)' : '#dfe4f6';
@@ -82,53 +106,132 @@ export default function ChallengesScreen() {
? ['#1f2230', '#10131e']
: [colorTokens.backgroundGradientStart, colorTokens.backgroundGradientEnd];
useEffect(() => {
if (!joinModalVisible) {
dispatch(resetJoinByCodeState());
setShareCodeInput('');
}
}, [dispatch, joinModalVisible]);
const handleCreatePress = useCallback(async () => {
const ok = await ensureLoggedIn();
if (!ok) return;
router.push('/challenges/create-custom');
}, [ensureLoggedIn, router]);
const handleOpenJoin = useCallback(async () => {
const ok = await ensureLoggedIn();
if (!ok) return;
setJoinModalVisible(true);
}, [ensureLoggedIn]);
const isJoiningByCode = joinByCodeStatus === 'loading';
const handleSubmitShareCode = useCallback(async () => {
if (isJoiningByCode) return;
const ok = await ensureLoggedIn();
if (!ok) return;
if (!shareCodeInput.trim()) {
Toast.warning(t('challenges.invalidInviteCode'));
return;
}
const formatted = shareCodeInput.trim().toUpperCase();
try {
const result = await dispatch(joinChallengeByCode(formatted)).unwrap();
await dispatch(fetchChallenges());
setJoinModalVisible(false);
Toast.success(t('challenges.joinSuccess'));
router.push({ pathname: '/challenges/[id]', params: { id: result.challenge.id } });
} catch (error) {
const message = typeof error === 'string' ? error : t('challenges.joinFailed');
Toast.error(message);
}
}, [dispatch, ensureLoggedIn, isJoiningByCode, router, shareCodeInput]);
const renderChallenges = () => {
if (listStatus === 'loading' && challenges.length === 0) {
if (listStatus === 'loading' && allChallenges.length === 0) {
return (
<View style={styles.stateContainer}>
<ActivityIndicator color={colorTokens.primary} />
<Text style={[styles.stateText, { color: colorTokens.textSecondary }]}></Text>
<Text style={[styles.stateText, { color: colorTokens.textSecondary }]}>{t('challenges.loading')}</Text>
</View>
);
}
if (listStatus === 'failed' && challenges.length === 0) {
if (listStatus === 'failed' && allChallenges.length === 0) {
return (
<View style={styles.stateContainer}>
<Text style={[styles.stateText, { color: colorTokens.textSecondary }]}>
{listError ?? '加载挑战失败,请稍后重试'}
{listError ?? t('challenges.loadFailed')}
</Text>
<TouchableOpacity
style={[styles.retryButton, { borderColor: colorTokens.primary }]}
activeOpacity={0.9}
onPress={() => dispatch(fetchChallenges())}
>
<Text style={[styles.retryText, { color: colorTokens.primary }]}></Text>
<Text style={[styles.retryText, { color: colorTokens.primary }]}>{t('challenges.retry')}</Text>
</TouchableOpacity>
</View>
);
}
if (challenges.length === 0) {
if (customChallenges.length === 0 && officialChallenges.length === 0) {
return (
<View style={styles.stateContainer}>
<Text style={[styles.stateText, { color: colorTokens.textSecondary }]}></Text>
<Text style={[styles.stateText, { color: colorTokens.textSecondary }]}>{t('challenges.empty')}</Text>
</View>
);
}
return challenges.map((challenge) => (
<ChallengeCard
key={challenge.id}
challenge={challenge}
surfaceColor={colorTokens.surface}
textColor={colorTokens.text}
mutedColor={colorTokens.textSecondary}
onPress={() =>
router.push({ pathname: '/challenges/[id]', params: { id: challenge.id } })
}
/>
));
return (
<View style={styles.cardGroups}>
{joinedCustomChallenges.length ? (
<>
<View style={styles.sectionHeaderRow}>
<Text style={[styles.sectionHeaderText, { color: colorTokens.text }]}>{t('challenges.customChallenges')}</Text>
</View>
<View style={styles.cardsContainer}>
{joinedCustomChallenges.map((challenge) => (
<ChallengeCard
key={challenge.id}
challenge={challenge}
surfaceColor={colorTokens.surface}
textColor={colorTokens.text}
mutedColor={colorTokens.textSecondary}
onPress={() =>
router.push({ pathname: '/challenges/[id]', params: { id: challenge.id } })
}
/>
))}
</View>
</>
) : null}
<View style={[styles.sectionHeaderRow, { marginTop: joinedCustomChallenges.length ? 12 : 0 }]}>
<Text style={[styles.sectionHeaderText, { color: colorTokens.text }]}>{t('challenges.officialChallengesTitle')}</Text>
</View>
{officialChallenges.length ? (
<View style={styles.cardsContainer}>
{officialChallenges.map((challenge) => (
<ChallengeCard
key={challenge.id}
challenge={challenge}
surfaceColor={colorTokens.surface}
textColor={colorTokens.text}
mutedColor={colorTokens.textSecondary}
onPress={() =>
router.push({ pathname: '/challenges/[id]', params: { id: challenge.id } })
}
/>
))}
</View>
) : (
<View style={[styles.stateContainer, styles.customEmpty]}>
<Text style={[styles.stateText, { color: colorTokens.textSecondary }]}>{t('challenges.officialChallenges')}</Text>
</View>
)}
</View>
);
};
return (
@@ -143,19 +246,42 @@ export default function ChallengesScreen() {
>
<View style={styles.headerRow}>
<View>
<Text style={[styles.title, { color: colorTokens.text }]}></Text>
<Text style={[styles.subtitle, { color: colorTokens.textSecondary }]}></Text>
<Text style={[styles.title, { color: colorTokens.text }]}>{t('challenges.title')}</Text>
<Text style={[styles.subtitle, { color: colorTokens.textSecondary }]}>{t('challenges.subtitle')}</Text>
</View>
<View style={styles.headerActions}>
<TouchableOpacity activeOpacity={0.85} onPress={handleOpenJoin}>
{glassAvailable ? (
<GlassView
style={styles.joinButtonGlass}
glassEffectStyle="regular"
tintColor="rgba(255,255,255,0.18)"
isInteractive
>
<Text style={styles.joinButtonLabel}>{t('challenges.join')}</Text>
</GlassView>
) : (
<View style={[styles.joinButtonGlass, styles.joinButtonFallback]}>
<Text style={[styles.joinButtonLabel, { color: colorTokens.text }]}>{t('challenges.join')}</Text>
</View>
)}
</TouchableOpacity>
<TouchableOpacity activeOpacity={0.9} onPress={handleCreatePress} style={{ marginLeft: 10 }}>
{glassAvailable ? (
<GlassView
style={styles.createButton}
tintColor="rgba(255,255,255,0.22)"
isInteractive
>
<Ionicons name="add" size={18} color="#0f1528" />
</GlassView>
) : (
<View style={[styles.createButton, styles.createButtonFallback]}>
<Ionicons name="add" size={18} color={colorTokens.text} />
</View>
)}
</TouchableOpacity>
</View>
{/* <TouchableOpacity activeOpacity={0.9} style={styles.giftShadow}>
<LinearGradient
colors={[colorTokens.primary, colorTokens.accentPurple]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.giftButton}
>
<IconSymbol name="gift.fill" size={18} color={colorTokens.onPrimary} />
</LinearGradient>
</TouchableOpacity> */}
</View>
{ongoingChallenges.length ? (
@@ -172,6 +298,34 @@ export default function ChallengesScreen() {
<View style={styles.cardsContainer}>{renderChallenges()}</View>
</ScrollView>
<ConfirmationSheet
visible={joinModalVisible}
onClose={() => setJoinModalVisible(false)}
onConfirm={handleSubmitShareCode}
title={t('challenges.joinModal.title')}
description={t('challenges.joinModal.description')}
confirmText={isJoiningByCode ? t('challenges.joinModal.joining') : t('challenges.joinModal.confirm')}
cancelText={t('challenges.joinModal.cancel')}
loading={isJoiningByCode}
content={
<View style={styles.modalInputWrapper}>
<TextInput
style={styles.modalInput}
placeholder={t('challenges.joinModal.placeholder')}
placeholderTextColor="#9ca3af"
value={shareCodeInput}
onChangeText={(text) => setShareCodeInput(text.toUpperCase())}
autoCapitalize="characters"
autoCorrect={false}
keyboardType="default"
maxLength={12}
/>
{joinByCodeError && joinModalVisible ? (
<Text style={styles.modalError}>{joinByCodeError}</Text>
) : null}
</View>
}
/>
</View>
);
}
@@ -185,7 +339,8 @@ type ChallengeCardProps = {
};
function ChallengeCard({ challenge, surfaceColor, textColor, mutedColor, onPress }: ChallengeCardProps) {
const statusLabel = STATUS_LABELS[challenge.status] ?? challenge.status;
const { t } = useI18n();
const statusLabel = t(`challenges.statusLabels.${challenge.status}`) ?? challenge.status;
return (
<TouchableOpacity
@@ -235,7 +390,7 @@ function ChallengeCard({ challenge, surfaceColor, textColor, mutedColor, onPress
style={[styles.cardParticipants, { color: mutedColor }]}
>
{challenge.participantsLabel}
{challenge.isJoined ? ' · 已加入' : ''}
{challenge.isJoined ? ` · ${t('challenges.joined')}` : ''}
</Text>
{challenge.avatars.length ? (
<AvatarStack avatars={challenge.avatars} borderColor={surfaceColor} />
@@ -325,7 +480,7 @@ function OngoingChallengesCarousel({
>
<ChallengeProgressCard
title={item.title}
endAt={item.endAt}
endAt={item.endAt as string}
progress={item.progress}
style={styles.carouselProgressCard}
backgroundColors={[colorTokens.card, colorTokens.card]}
@@ -447,34 +602,82 @@ const styles = StyleSheet.create({
marginBottom: 26,
},
title: {
fontSize: 32,
fontSize: 24,
fontWeight: '700',
letterSpacing: 1,
fontFamily: 'AliBold'
},
subtitle: {
marginTop: 6,
fontSize: 14,
fontSize: 12,
fontWeight: '500',
opacity: 0.8,
fontFamily: 'AliRegular'
},
giftShadow: {
shadowColor: 'rgba(94, 62, 199, 0.45)',
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.35,
shadowRadius: 12,
elevation: 8,
borderRadius: 26,
headerActions: {
flexDirection: 'row',
alignItems: 'center',
},
giftButton: {
width: 32,
height: 32,
borderRadius: 26,
joinButtonGlass: {
paddingHorizontal: 14,
paddingVertical: 8,
borderRadius: 16,
minWidth: 70,
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)',
},
createButton: {
width: 36,
height: 36,
borderRadius: 20,
alignItems: 'center',
justifyContent: 'center',
borderWidth: StyleSheet.hairlineWidth,
borderColor: 'rgba(255,255,255,0.6)',
backgroundColor: 'rgba(255,255,255,0.85)',
},
createButtonFallback: {
backgroundColor: 'rgba(255,255,255,0.75)',
},
cardsContainer: {
gap: 18,
},
cardGroups: {
gap: 20,
},
sectionHeaderRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 8,
},
sectionHeaderText: {
fontSize: 16,
fontWeight: '800',
},
customEmpty: {
borderRadius: 18,
backgroundColor: 'rgba(255,255,255,0.08)',
},
primaryGhostButton: {
marginTop: 12,
paddingHorizontal: 16,
paddingVertical: 8,
borderWidth: StyleSheet.hairlineWidth,
borderRadius: 14,
},
carouselContainer: {
marginBottom: 24,
},
@@ -555,16 +758,19 @@ const styles = StyleSheet.create({
fontSize: 18,
fontWeight: '700',
marginBottom: 4,
fontFamily: 'AliBold',
},
cardDate: {
fontSize: 13,
fontWeight: '500',
marginBottom: 4,
fontFamily: 'AliRegular',
},
cardParticipants: {
fontSize: 13,
fontWeight: '500',
fontFamily: 'AliRegular'
},
cardExpired: {
borderWidth: StyleSheet.hairlineWidth,
@@ -594,6 +800,7 @@ const styles = StyleSheet.create({
fontWeight: '600',
color: '#f7f9ff',
letterSpacing: 0.3,
fontFamily: 'AliRegular',
},
cardProgress: {
marginTop: 8,
@@ -614,4 +821,25 @@ const styles = StyleSheet.create({
avatarOffset: {
marginLeft: -12,
},
modalInputWrapper: {
borderRadius: 14,
borderWidth: 1,
borderColor: '#e5e7eb',
backgroundColor: '#f8fafc',
paddingHorizontal: 12,
paddingVertical: 10,
gap: 6,
},
modalInput: {
paddingVertical: 12,
fontSize: 16,
fontWeight: '700',
letterSpacing: 1.5,
color: '#0f1528',
},
modalError: {
marginTop: 10,
fontSize: 12,
color: '#ef4444',
},
});

View File

@@ -1,13 +1,17 @@
import CelebrationAnimation, { CelebrationAnimationRef } from '@/components/CelebrationAnimation';
import { DateSelector } from '@/components/DateSelector';
import { MedicationCard } from '@/components/medication/MedicationCard';
import { TakenMedicationsStack } from '@/components/medication/TakenMedicationsStack';
import { ThemedText } from '@/components/ThemedText';
import { IconSymbol } from '@/components/ui/IconSymbol';
import { MedicalDisclaimerSheet } from '@/components/ui/MedicalDisclaimerSheet';
import { MedicationAiSummaryInfoSheet } from '@/components/ui/MedicationAiSummaryInfoSheet';
import { Colors } from '@/constants/Colors';
import { useMembershipModal } from '@/contexts/MembershipModalContext';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useAuthGuard } from '@/hooks/useAuthGuard';
import { useColorScheme } from '@/hooks/useColorScheme';
import { medicationNotificationService } from '@/services/medicationNotifications';
import { useVipService } from '@/hooks/useVipService';
import { fetchMedicationRecords, fetchMedications, selectMedicationDisplayItemsByDate } from '@/store/medicationsSlice';
import { DEFAULT_MEMBER_NAME } from '@/store/userSlice';
import { getItemSync, setItemSync } from '@/utils/kvStore';
@@ -45,6 +49,9 @@ export default function MedicationsScreen() {
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colors: ThemeColors = Colors[theme];
const userProfile = useAppSelector((state) => state.user.profile);
const { ensureLoggedIn, isLoggedIn } = useAuthGuard();
const { checkServiceAccess } = useVipService();
const { openMembershipModal } = useMembershipModal();
const [selectedDate, setSelectedDate] = useState<Dayjs>(dayjs());
const [selectedDateIndex, setSelectedDateIndex] = useState<number>(selectedDate.date() - 1);
const [activeFilter, setActiveFilter] = useState<MedicationFilter>('all');
@@ -52,20 +59,44 @@ export default function MedicationsScreen() {
const celebrationTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [isCelebrationVisible, setIsCelebrationVisible] = useState(false);
const [disclaimerVisible, setDisclaimerVisible] = useState(false);
const [pendingAction, setPendingAction] = useState<'manual' | null>(null);
const [aiSummaryInfoVisible, setAiSummaryInfoVisible] = useState(false);
// 从 Redux 获取数据
const selectedKey = selectedDate.format('YYYY-MM-DD');
const medicationsForDay = useAppSelector((state) => selectMedicationDisplayItemsByDate(selectedKey)(state));
// 使用 useMemo 缓存 selector 实例,避免每次渲染都创建新的 selector
const medicationSelector = useMemo(
() => selectMedicationDisplayItemsByDate(selectedKey),
[selectedKey]
);
const medicationsForDay = useAppSelector(medicationSelector);
const handleOpenAddMedication = useCallback(() => {
// 检查是否已经读过免责声明
// 直接跳转到 AI 相机页面
const handleAddMedication = useCallback(async () => {
// 先检查登录状态
const isLoggedIn = await ensureLoggedIn();
if (!isLoggedIn) return;
// 检查 VIP 权限
const access = checkServiceAccess();
if (!access.canUseService) {
openMembershipModal();
return;
}
// 直接跳转到 AI 相机页面
router.push('/medications/ai-camera');
}, [checkServiceAccess, ensureLoggedIn, openMembershipModal]);
const handleManualAdd = useCallback(() => {
const hasRead = getItemSync(MEDICAL_DISCLAIMER_READ_KEY);
setPendingAction('manual');
if (hasRead === 'true') {
// 已读过,直接跳转
setPendingAction(null);
router.push('/medications/add-medication');
} else {
// 未读过,显示医疗免责声明弹窗
setDisclaimerVisible(true);
}
}, []);
@@ -74,12 +105,43 @@ export default function MedicationsScreen() {
// 用户同意免责声明后,记录已读状态,关闭弹窗并跳转到添加页面
setItemSync(MEDICAL_DISCLAIMER_READ_KEY, 'true');
setDisclaimerVisible(false);
router.push('/medications/add-medication');
}, []);
if (pendingAction === 'manual') {
setPendingAction(null);
router.push('/medications/add-medication');
}
}, [pendingAction]);
const handleDisclaimerClose = useCallback(() => {
// 用户不接受免责声明,只关闭弹窗,不跳转,不记录已读状态
setDisclaimerVisible(false);
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(() => {
@@ -111,9 +173,11 @@ export default function MedicationsScreen() {
// 加载药物和记录数据
useEffect(() => {
if (!isLoggedIn) return;
dispatch(fetchMedications());
dispatch(fetchMedicationRecords({ date: selectedKey }));
}, [dispatch, selectedKey]);
}, [dispatch, selectedKey, isLoggedIn]);
useEffect(() => {
return () => {
@@ -126,17 +190,16 @@ export default function MedicationsScreen() {
// 页面聚焦时刷新数据,确保从添加页面返回时能看到最新数据
useFocusEffect(
useCallback(() => {
if (!isLoggedIn) return;
// 重新安排药品通知并刷新数据
const refreshDataAndRescheduleNotifications = async () => {
try {
// 只获取一次药物数据,然后复用结果
const medications = await dispatch(fetchMedications({ isActive: true })).unwrap();
// 并行执行获取药物记录和安排通知
const [recordsAction] = await Promise.all([
dispatch(fetchMedicationRecords({ date: selectedKey })),
medicationNotificationService.rescheduleAllMedicationNotifications(medications),
]);
// 获取药物记录
const recordsAction = await dispatch(fetchMedicationRecords({ date: selectedKey }));
// 同步数据到小组件(仅同步今天的)
const today = dayjs().format('YYYY-MM-DD');
@@ -158,7 +221,7 @@ export default function MedicationsScreen() {
};
refreshDataAndRescheduleNotifications();
}, [dispatch, selectedKey])
}, [dispatch, selectedKey, isLoggedIn])
);
useEffect(() => {
@@ -189,6 +252,16 @@ export default function MedicationsScreen() {
return medicationsWithImages.filter((item: any) => item.status === activeFilter);
}, [activeFilter, medicationsWithImages]);
const activeMedications = useMemo(() => {
if (activeFilter !== 'all') return filteredMedications;
return filteredMedications.filter((item: any) => item.status !== 'taken' && item.status !== 'skipped');
}, [activeFilter, filteredMedications]);
const completedMedications = useMemo(() => {
if (activeFilter !== 'all') return [];
return filteredMedications.filter((item: any) => item.status === 'taken' || item.status === 'skipped');
}, [activeFilter, filteredMedications]);
const counts = useMemo(() => {
const taken = medicationsWithImages.filter((item: any) => item.status === 'taken').length;
// "未服用"计数包含 missed已错过和 upcoming待服用
@@ -241,31 +314,59 @@ export default function MedicationsScreen() {
</ThemedText>
</View>
<View style={styles.headerActions}>
<TouchableOpacity
activeOpacity={0.7}
onPress={handleOpenMedicationManagement}
>
{isLiquidGlassAvailable() ? (
{isLiquidGlassAvailable() ? (
<TouchableOpacity
activeOpacity={0.7}
onPress={handleOpenAiSummary}
>
<GlassView
style={styles.headerAddButton}
glassEffectStyle="clear"
tintColor="rgba(255, 255, 255, 0.3)"
tintColor="rgba(255, 255, 255, 0.36)"
isInteractive={true}
>
<IconSymbol name="pills.fill" size={18} color="#333" />
<IconSymbol name="sparkles" size={18} color="#333" />
</GlassView>
) : (
<View style={[styles.headerAddButton, styles.fallbackAddButton]}>
<IconSymbol name="pills.fill" size={18} color="#333" />
</View>
)}
</TouchableOpacity>
</TouchableOpacity>
) : (
<TouchableOpacity
activeOpacity={0.7}
onPress={handleOpenAiSummary}
style={[styles.headerAddButton, styles.fallbackAddButton]}
>
<IconSymbol name="sparkles" size={18} color="#333" />
</TouchableOpacity>
)}
<TouchableOpacity
activeOpacity={0.7}
onPress={handleOpenAddMedication}
>
{isLiquidGlassAvailable() ? (
{isLiquidGlassAvailable() ? (
<TouchableOpacity
activeOpacity={0.7}
onPress={handleOpenMedicationManagement}
>
<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
style={styles.headerAddButton}
glassEffectStyle="clear"
@@ -274,12 +375,16 @@ export default function MedicationsScreen() {
>
<IconSymbol name="plus" size={18} color="#333" />
</GlassView>
) : (
<View style={[styles.headerAddButton, styles.fallbackAddButton]}>
<IconSymbol name="plus" size={18} color="#333" />
</View>
)}
</TouchableOpacity>
</TouchableOpacity>
) : (
<TouchableOpacity
activeOpacity={0.7}
onPress={handleAddMedication}
style={[styles.headerAddButton, styles.fallbackAddButton]}
>
<IconSymbol name="plus" size={18} color="#333" />
</TouchableOpacity>
)}
</View>
</View>
@@ -354,7 +459,8 @@ export default function MedicationsScreen() {
</View>
) : (
<View style={styles.cardsWrapper}>
{filteredMedications.map((item: any) => (
{/* 渲染未服用的药物 */}
{activeMedications.map((item: any) => (
<MedicationCard
key={item.id}
medication={item}
@@ -364,6 +470,17 @@ export default function MedicationsScreen() {
onCelebrate={handleMedicationTakenCelebration}
/>
))}
{/* 渲染已完成(服用/跳过)的药物堆叠 */}
{completedMedications.length > 0 && (
<TakenMedicationsStack
medications={completedMedications}
colors={colors}
selectedDate={selectedDate}
onOpenDetails={(item) => handleOpenMedicationDetails(item.medicationId)}
onCelebrate={handleMedicationTakenCelebration}
/>
)}
</View>
)}
</ScrollView>
@@ -374,6 +491,13 @@ export default function MedicationsScreen() {
onClose={handleDisclaimerClose}
onConfirm={handleDisclaimerConfirm}
/>
{/* AI 用药总结介绍弹窗 */}
<MedicationAiSummaryInfoSheet
visible={aiSummaryInfoVisible}
onClose={handleAiSummaryInfoClose}
onConfirm={handleAiSummaryInfoConfirm}
/>
</View>
);
}
@@ -442,12 +566,14 @@ const styles = StyleSheet.create({
borderRadius: 30,
},
greeting: {
fontSize: 24,
fontSize: 20,
fontWeight: '600',
fontFamily: 'AliBold',
},
welcome: {
marginTop: 6,
fontSize: 14,
fontFamily: 'AliRegular',
},
sectionSpacing: {
gap: 16,
@@ -458,10 +584,12 @@ const styles = StyleSheet.create({
sectionTitle: {
fontSize: 16,
fontWeight: '500',
fontFamily: 'AliBold',
},
sectionHeader: {
fontSize: 20,
fontWeight: '600',
fontFamily: 'AliBold',
},
segmentedControl: {
flexDirection: 'row',
@@ -481,6 +609,7 @@ const styles = StyleSheet.create({
segmentLabel: {
fontSize: 14,
fontWeight: '600',
fontFamily: 'AliBold',
},
segmentBadge: {
minWidth: 24,
@@ -493,6 +622,7 @@ const styles = StyleSheet.create({
segmentBadgeText: {
fontSize: 12,
fontWeight: '600',
fontFamily: 'AliBold',
},
emptyState: {
alignItems: 'center',
@@ -510,11 +640,13 @@ const styles = StyleSheet.create({
textAlign: 'center',
fontSize: 18,
fontWeight: '600',
fontFamily: 'AliBold',
},
emptySubtitle: {
textAlign: 'center',
fontSize: 14,
lineHeight: 20,
fontFamily: 'AliRegular',
},
primaryButton: {
marginTop: 8,
@@ -528,6 +660,7 @@ const styles = StyleSheet.create({
primaryButtonText: {
fontSize: 16,
fontWeight: '600',
fontFamily: 'AliBold',
},
cardsWrapper: {
gap: 16,
@@ -541,5 +674,6 @@ const styles = StyleSheet.create({
},
loadingText: {
fontSize: 14,
fontFamily: 'AliRegular',
},
});

View File

@@ -1,13 +1,20 @@
import ActivityHeatMap from '@/components/ActivityHeatMap';
import { BadgeShowcaseModal } from '@/components/badges/BadgeShowcaseModal';
import { MembershipBanner } from '@/components/MembershipBanner';
import { PRIVACY_POLICY_URL, USER_AGREEMENT_URL } from '@/constants/Agree';
import { Colors } from '@/constants/Colors';
import { ROUTES } from '@/constants/Routes';
import { getTabBarBottomPadding } from '@/constants/TabBar';
import { useMembershipModal } from '@/contexts/MembershipModalContext';
import { useVersionCheck } from '@/contexts/VersionCheckContext';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useAuthGuard } from '@/hooks/useAuthGuard';
import { useColorScheme } from '@/hooks/useColorScheme';
import { useI18n } from '@/hooks/useI18n';
import type { BadgeDto } 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 { selectActiveMembershipPlanName } from '@/store/membershipSlice';
import { DEFAULT_MEMBER_NAME, fetchActivityHistory, fetchMyProfile } from '@/store/userSlice';
@@ -52,17 +59,26 @@ type LanguageOption = {
};
export default function PersonalScreen() {
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme];
const dispatch = useAppDispatch();
const { confirmLogout, confirmDeleteAccount, isLoggedIn, pushIfAuthedElseLogin, ensureLoggedIn } = useAuthGuard();
const { openMembershipModal } = useMembershipModal();
const insets = useSafeAreaInsets();
const { t, i18n } = useTranslation();
const router = useRouter();
const isLgAvaliable = isLiquidGlassAvailable();
const [languageModalVisible, setLanguageModalVisible] = useState(false);
const [isSwitchingLanguage, setIsSwitchingLanguage] = 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[]>(() => ([
{
@@ -78,7 +94,17 @@ export default function PersonalScreen() {
]), [t]);
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) => {
setLanguageModalVisible(false);
@@ -87,13 +113,33 @@ export default function PersonalScreen() {
}
try {
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);
// 如果用户已登录,同步更新服务器语言设置
if (isLoggedIn) {
try {
await updateUser({ language: userLanguage });
log.info('语言设置已同步到服务器', { language: userLanguage });
} catch (error) {
log.warn('同步语言设置到服务器失败', error);
// 服务器更新失败不影响本地语言切换,静默处理
}
}
} catch (error) {
log.warn('语言切换失败', error);
} finally {
setIsSwitchingLanguage(false);
}
}, [activeLanguageCode, isSwitchingLanguage]);
}, [activeLanguageCode, isSwitchingLanguage, isLoggedIn]);
// 推送通知设置仅在独立页面管理
@@ -163,22 +209,25 @@ export default function PersonalScreen() {
}
}, [showcaseBadge]);
console.log('badgePreview', badgePreview);
// 首次加载时获取用户信息和数据
useEffect(() => {
dispatch(fetchAvailableBadges());
if (!isLoggedIn) return;
dispatch(fetchMyProfile());
dispatch(fetchActivityHistory());
dispatch(fetchAvailableBadges());
}, [dispatch]);
}, [dispatch, isLoggedIn]);
// 页面聚焦时智能刷新(依赖 Redux 的缓存策略)
useFocusEffect(
useCallback(() => {
// 徽章数据由 Redux 的缓存策略控制,只有过期才会重新请求
dispatch(fetchAvailableBadges());
}, [dispatch])
}, [dispatch, isLoggedIn])
);
// 手动刷新处理
@@ -221,25 +270,6 @@ export default function PersonalScreen() {
}
};
// 数据格式化函数
const formatHeight = () => {
if (userProfile.height == null) return '--';
return `${parseFloat(userProfile.height).toFixed(1)}cm`;
};
const formatWeight = () => {
if (userProfile.weight == null) return '--';
return `${parseFloat(userProfile.weight).toFixed(1)}kg`;
};
const formatAge = () => {
if (!userProfile.birthDate) return '--';
const birthDate = new Date(userProfile.birthDate);
const today = new Date();
const age = today.getFullYear() - birthDate.getFullYear();
return `${age}${t('personal.stats.ageSuffix')}`;
};
// 显示名称
const displayName = (userProfile.name?.trim()) ? userProfile.name : DEFAULT_MEMBER_NAME;
const profileActionLabel = isLoggedIn ? t('personal.edit') : t('personal.login');
@@ -299,11 +329,11 @@ export default function PersonalScreen() {
<TouchableOpacity onPress={handleUserNamePress} activeOpacity={0.7}>
<Text style={styles.userName}>{displayName}</Text>
</TouchableOpacity>
{userProfile.memberNumber && (
{userProfile.memberNumber && String(userProfile.memberNumber).trim().length > 0 ? (
<Text style={styles.userMemberNumber}>
{t('personal.memberNumber', { number: userProfile.memberNumber })}
</Text>
)}
) : null}
{userProfile.freeUsageCount !== undefined && (
<View style={styles.aiUsageContainer}>
<Ionicons name="sparkles-outline" as any size={12} color="#9370DB" />
@@ -330,25 +360,6 @@ export default function PersonalScreen() {
</View>
);
const MembershipBanner = () => (
<View style={styles.sectionContainer}>
<TouchableOpacity
activeOpacity={0.9}
onPress={() => {
void handleMembershipPress();
}}
>
<Image
source={{ uri: 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/banner/vip2.png' }}
style={styles.membershipBannerImage}
contentFit="cover"
transition={200}
cachePolicy="memory-disk"
/>
</TouchableOpacity>
</View>
);
const VipMembershipCard = () => {
const fallbackProfile = userProfile as Record<string, unknown>;
const fallbackExpire = ['membershipExpiration', 'vipExpiredAt', 'vipExpiresAt', 'vipExpireDate']
@@ -364,8 +375,8 @@ export default function PersonalScreen() {
}
const planName =
activeMembershipPlanName?.trim() ||
userProfile.vipPlanName?.trim() ||
(activeMembershipPlanName && activeMembershipPlanName.trim()) ||
(userProfile.vipPlanName && userProfile.vipPlanName.trim()) ||
t('personal.membership.planFallback');
return (
@@ -415,72 +426,64 @@ export default function PersonalScreen() {
);
};
// 数据统计部分
const StatsSection = () => (
// 健康档案入口组件
const HealthProfileEntry = () => (
<View style={styles.sectionContainer}>
<View style={[styles.cardContainer, {
backgroundColor: 'unset'
}]}>
<View style={styles.statsContainer}>
<View style={styles.statItem}>
<Text style={styles.statValue}>{formatHeight()}</Text>
<Text style={styles.statLabel}>{t('personal.stats.height')}</Text>
<TouchableOpacity
style={styles.healthProfileCard}
activeOpacity={0.9}
onPress={() => router.push(ROUTES.HEALTH_PROFILE)}
>
<LinearGradient
colors={['#FFFFFF', '#F0F4FF']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.healthProfileGradient}
>
<View style={styles.healthProfileContent}>
<View style={styles.healthProfileLeft}>
<View style={styles.healthProfileTitleRow}>
<Text style={styles.healthProfileTitle}>{t('personal.healthProfile.title') || '健康档案'}</Text>
</View>
<Text style={styles.healthProfileSubtitle}>{t('personal.healthProfile.subtitle') || '管理您的个人健康数据与家庭档案'}</Text>
</View>
<View style={styles.healthProfileRight}>
<Ionicons name="chevron-forward" size={20} color="#9CA3AF" />
</View>
</View>
<View style={styles.statItem}>
<Text style={styles.statValue}>{formatWeight()}</Text>
<Text style={styles.statLabel}>{t('personal.stats.weight')}</Text>
</View>
<View style={styles.statItem}>
<Text style={styles.statValue}>{formatAge()}</Text>
<Text style={styles.statLabel}>{t('personal.stats.age')}</Text>
</View>
</View>
</View>
</LinearGradient>
</TouchableOpacity>
</View>
);
const BadgesPreviewSection = () => {
const previewBadges = badgePreview.slice(0, 3);
const hasBadges = previewBadges.length > 0;
const extraCount = Math.max(0, badgeCounts.total - previewBadges.length);
// 优化性能:使用 useMemo 缓存计算结果,避免每次渲染都重新计算
const BadgesPreviewSection = React.memo(() => {
// 使用 useMemo 缓存切片和计算结果,只有当 badgePreview 或 badgeCounts 变化时才重新计算
const { previewBadges, hasBadges, extraCount } = useMemo(() => {
const previewBadges = badgePreview.slice(0, 3);
const hasBadges = previewBadges.length > 0;
const extraCount = Math.max(0, badgeCounts.total - previewBadges.length);
return { previewBadges, hasBadges, extraCount };
}, [badgePreview, badgeCounts]);
// 使用 useMemo 缓存标题文本,避免每次渲染都调用 t() 函数
const titleText = useMemo(() => t('personal.badgesPreview.title'), [t]);
const emptyText = useMemo(() => t('personal.badgesPreview.empty'), [t]);
return (
<View style={styles.sectionContainer}>
<TouchableOpacity style={[styles.cardContainer, styles.badgesRowCard]} onPress={handleBadgesPress} activeOpacity={0.85}>
<Text style={styles.badgesRowTitle}>{t('personal.badgesPreview.title')}</Text>
<Text style={styles.badgesRowTitle}>{titleText}</Text>
{hasBadges ? (
<View style={styles.badgesRowContent}>
<View style={styles.badgesStack}>
{previewBadges.map((badge, index) => (
<View
<BadgeCompactItem
key={badge.code}
style={[
styles.badgeCompactBubble,
badge.isAwarded ? styles.badgeCompactBubbleEarned : styles.badgeCompactBubbleLocked,
{
marginLeft: index === 0 ? 0 : -12,
zIndex: previewBadges.length - index,
},
]}
>
{badge.imageUrl ? (
<Image
source={{ uri: badge.imageUrl }}
style={styles.badgeCompactImage}
contentFit="cover"
transition={200}
/>
) : (
<View style={styles.badgeCompactFallback}>
<Text style={styles.badgeCompactFallbackText}>{badge.icon ?? '🏅'}</Text>
</View>
)}
{!badge.isAwarded && (
<View style={styles.badgeCompactOverlay}>
<Ionicons name="lock-closed" as any size={16} color="#FFFFFF" />
</View>
)}
</View>
badge={badge}
index={index}
totalBadges={previewBadges.length}
/>
))}
</View>
{extraCount > 0 && (
@@ -490,12 +493,60 @@ export default function PersonalScreen() {
)}
</View>
) : (
<Text style={styles.badgesRowEmpty}>{t('personal.badgesPreview.empty')}</Text>
<Text style={styles.badgesRowEmpty}>{emptyText}</Text>
)}
</TouchableOpacity>
</View>
);
};
});
// 将徽章项提取为独立的 memo 组件,减少重复渲染
const BadgeCompactItem = React.memo(({ badge, index, totalBadges }: {
badge: BadgeDto;
index: number;
totalBadges: number;
}) => {
// 使用 useMemo 缓存样式计算,避免每次渲染都重新计算
const badgeStyle = useMemo(() => [
styles.badgeCompactBubble,
badge.isAwarded ? styles.badgeCompactBubbleEarned : styles.badgeCompactBubbleLocked,
{
marginLeft: index === 0 ? 0 : -12,
zIndex: totalBadges - index,
},
], [badge.isAwarded, index, totalBadges]);
// 使用 useMemo 缓存图标文本,避免每次渲染都重新计算
const iconText = useMemo(() =>
(badge.icon && String(badge.icon).trim()) || '🏅',
[badge.icon]
);
return (
<View style={badgeStyle}>
{badge.imageUrl ? (
<Image
source={{ uri: badge.imageUrl }}
style={styles.badgeCompactImage}
contentFit="cover"
transition={200}
cachePolicy="memory-disk"
/>
) : (
<View style={styles.badgeCompactFallback}>
<Text style={styles.badgeCompactFallbackText}>
{iconText}
</Text>
</View>
)}
{!badge.isAwarded && (
<View style={styles.badgeCompactOverlay}>
<Ionicons name="lock-closed" as any size={16} color="#FFFFFF" />
</View>
)}
</View>
);
});
// 菜单项组件
const MenuSection = ({ title, items }: { title: string; items: MenuItem[] }) => (
@@ -531,7 +582,7 @@ export default function PersonalScreen() {
/>
) : (
<View style={styles.menuRight}>
{item.rightText ? (
{item.rightText && String(item.rightText).trim() ? (
<Text style={styles.menuRightText}>{item.rightText}</Text>
) : null}
<Ionicons name="chevron-forward" as any size={20} color="#CCCCCC" />
@@ -582,7 +633,30 @@ export default function PersonalScreen() {
icon: 'language-outline' as React.ComponentProps<typeof Ionicons>['name'],
title: t('personal.language.menuTitle'),
onPress: () => setLanguageModalVisible(true),
rightText: activeLanguageLabel,
rightText: activeLanguageLabel || '',
},
],
},
{
title: t('personal.sections.customization'),
items: [
{
icon: 'albums-outline' as React.ComponentProps<typeof Ionicons>['name'],
title: t('personal.menu.tabBarConfig'),
onPress: () => router.push(ROUTES.TAB_BAR_CONFIG),
},
],
},
{
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,
},
],
},
@@ -671,8 +745,12 @@ export default function PersonalScreen() {
disabled={isSwitchingLanguage}
>
<View style={styles.languageOptionTextGroup}>
<Text style={styles.languageOptionLabel}>{option.label}</Text>
<Text style={styles.languageOptionDescription}>{option.description}</Text>
<Text style={styles.languageOptionLabel}>
{(option.label && String(option.label).trim()) || ''}
</Text>
<Text style={styles.languageOptionDescription}>
{(option.description && String(option.description).trim()) || ''}
</Text>
</View>
{isSelected && (
<Ionicons name="checkmark-circle" as any size={20} color="#9370DB" />
@@ -693,21 +771,15 @@ export default function PersonalScreen() {
);
return (
<View style={styles.container}>
<StatusBar barStyle={'dark-content'} backgroundColor="transparent" translucent />
<View style={[styles.container, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}>
<StatusBar barStyle={theme === 'dark' ? 'light-content' : 'dark-content'} backgroundColor="transparent" translucent />
{/* 背景渐变 */}
<LinearGradient
colors={['#f5e5fbff', '#e5fcfeff', '#eefdffff', '#e6f6fcff']}
style={styles.gradientBackground}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
colors={gradientColors}
style={StyleSheet.absoluteFillObject}
/>
{/* 装饰性圆圈 */}
<View style={styles.decorativeCircle1} />
<View style={styles.decorativeCircle2} />
<ScrollView
style={styles.scrollView}
contentContainerStyle={{
@@ -727,8 +799,8 @@ export default function PersonalScreen() {
}
>
<UserHeader />
{userProfile.isVip ? <VipMembershipCard /> : <MembershipBanner />}
<StatsSection />
{userProfile.isVip ? <VipMembershipCard /> : <MembershipBanner onPress={() => void handleMembershipPress()} />}
<HealthProfileEntry />
<BadgesPreviewSection />
<View style={styles.fishRecordContainer}>
{/* <Image
@@ -760,33 +832,6 @@ const styles = StyleSheet.create({
container: {
flex: 1,
},
gradientBackground: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
},
decorativeCircle1: {
position: 'absolute',
top: 40,
right: 20,
width: 60,
height: 60,
borderRadius: 30,
backgroundColor: '#0EA5E9',
opacity: 0.1,
},
decorativeCircle2: {
position: 'absolute',
bottom: -15,
left: -15,
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: '#0EA5E9',
opacity: 0.05,
},
scrollView: {
flex: 1,
},
@@ -812,11 +857,6 @@ const styles = StyleSheet.create({
elevation: 2,
overflow: 'hidden',
},
membershipBannerImage: {
width: '100%',
height: 180,
borderRadius: 16,
},
vipCard: {
borderRadius: 20,
padding: 20,
@@ -951,16 +991,20 @@ const styles = StyleSheet.create({
fontWeight: 'bold',
color: '#2C3E50',
marginBottom: 4,
fontFamily: 'AliBold',
},
userRole: {
fontSize: 14,
color: '#9370DB',
fontWeight: '500',
fontFamily: 'AliBold',
},
userMemberNumber: {
fontSize: 10,
color: '#6C757D',
marginTop: 4,
fontFamily: 'AliRegular',
},
aiUsageContainer: {
flexDirection: 'row',
@@ -972,6 +1016,7 @@ const styles = StyleSheet.create({
color: '#9370DB',
marginLeft: 2,
fontWeight: '500',
fontFamily: 'AliRegular',
},
editButton: {
backgroundColor: '#9370DB',
@@ -990,6 +1035,7 @@ const styles = StyleSheet.create({
color: 'white',
fontSize: 14,
fontWeight: '600',
fontFamily: 'AliBold',
},
editButtonTextGlass: {
color: 'rgba(147, 112, 219, 1)',
@@ -1011,11 +1057,13 @@ const styles = StyleSheet.create({
fontWeight: 'bold',
color: '#9370DB',
marginBottom: 4,
fontFamily: 'AliBold',
},
statLabel: {
fontSize: 12,
color: '#6C757D',
fontWeight: '500',
fontFamily: 'AliRegular',
},
badgesRowCard: {
flexDirection: 'row',
@@ -1035,6 +1083,7 @@ const styles = StyleSheet.create({
fontSize: 16,
fontWeight: '700',
color: '#111827',
fontFamily: 'AliBold',
},
badgesRowContent: {
flexDirection: 'row',
@@ -1073,6 +1122,7 @@ const styles = StyleSheet.create({
fontSize: 18,
fontWeight: '600',
color: '#475467',
fontFamily: 'AliBold',
},
badgeCompactOverlay: {
...StyleSheet.absoluteFillObject,
@@ -1092,11 +1142,14 @@ const styles = StyleSheet.create({
fontSize: 14,
fontWeight: '700',
color: '#5B21B6',
fontFamily: 'AliRegular',
},
badgesRowEmpty: {
fontSize: 13,
color: '#6B7280',
fontWeight: '500',
fontFamily: 'AliBold',
},
// 菜单项
menuItem: {
@@ -1121,6 +1174,7 @@ const styles = StyleSheet.create({
fontSize: 13,
color: '#6C757D',
marginRight: 6,
fontFamily: 'AliRegular',
},
iconContainer: {
width: 32,
@@ -1149,6 +1203,7 @@ const styles = StyleSheet.create({
fontWeight: 'bold',
color: '#2C3E50',
marginLeft: 4,
fontFamily: 'AliBold',
},
languageModalOverlay: {
flex: 1,
@@ -1174,11 +1229,13 @@ const styles = StyleSheet.create({
fontSize: 18,
fontWeight: 'bold',
color: '#2C3E50',
fontFamily: 'AliBold',
},
languageModalSubtitle: {
fontSize: 13,
color: '#6C757D',
marginBottom: 4,
fontFamily: 'AliRegular',
},
languageOption: {
flexDirection: 'row',
@@ -1203,11 +1260,13 @@ const styles = StyleSheet.create({
fontSize: 16,
fontWeight: '600',
color: '#2C3E50',
fontFamily: 'AliBold',
},
languageOptionDescription: {
fontSize: 12,
color: '#6C757D',
marginTop: 4,
fontFamily: 'AliRegular',
},
languageModalClose: {
marginTop: 4,
@@ -1217,5 +1276,62 @@ const styles = StyleSheet.create({
fontSize: 15,
fontWeight: '500',
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

@@ -14,17 +14,19 @@ import { WorkoutSummaryCard } from '@/components/WorkoutSummaryCard';
import { Colors } from '@/constants/Colors';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useAuthGuard } from '@/hooks/useAuthGuard';
import { BackgroundTaskManager } from '@/services/backgroundTaskManagerV2';
import { syncHealthKitToServer } from '@/services/healthKitSync';
import { syncDailyHealthReport, syncHealthKitToServer } from '@/services/healthKitSync';
import { setHealthData } from '@/store/healthSlice';
import { fetchDailyMoodCheckins, selectLatestMoodRecordByDate } from '@/store/moodSlice';
import { updateUserProfile } from '@/store/userSlice';
import { fetchTodayWaterStats } from '@/store/waterSlice';
import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date';
import { fetchHealthDataForDate, testHRVDataFetch } from '@/utils/health';
import { fetchHealthDataForDate } from '@/utils/health';
import { logger } from '@/utils/logger';
import { Ionicons } from '@expo/vector-icons';
import dayjs from 'dayjs';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { LinearGradient } from 'expo-linear-gradient';
import { useRouter } from 'expo-router';
import { debounce } from 'lodash';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
@@ -62,9 +64,11 @@ export default function ExploreScreen() {
const { t } = useTranslation();
const stepGoal = useAppSelector((s) => s.user.profile?.dailyStepsGoal) ?? 2000;
const userProfile = useAppSelector((s) => s.user.profile);
const todayWaterStats = useAppSelector((s) => s.water.todayStats);
const { pushIfAuthedElseLogin, isLoggedIn } = useAuthGuard();
const { pushIfAuthedElseLogin, isLoggedIn, ensureLoggedIn } = useAuthGuard();
const router = useRouter();
// 使用 dayjs当月日期与默认选中"今天"
const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth());
@@ -80,7 +84,11 @@ export default function ExploreScreen() {
return dayjs(currentSelectedDate).format('YYYY-MM-DD');
}, [currentSelectedDate]);
const handleOpenGallery = React.useCallback(async () => {
const ok = await ensureLoggedIn();
if (!ok) return;
router.push('/gallery');
}, [ensureLoggedIn, router]);
// 用于触发动画重置的 token当日期或数据变化时更新
const [animToken, setAnimToken] = useState(0);
@@ -287,6 +295,7 @@ export default function ExploreScreen() {
try {
logger.info('开始同步 HealthKit 个人健康数据到服务端...');
// 1. 同步个人资料 (身高、体重、出生日期)
// 传入当前用户资料,用于 diff 比较
const success = await syncHealthKitToServer(
async (data) => {
@@ -296,20 +305,36 @@ export default function ExploreScreen() {
);
if (success) {
logger.info('HealthKit 数据同步到服务端成功');
logger.info('HealthKit 个人资料同步到服务端成功');
} else {
logger.info('HealthKit 数据同步到服务端跳过(无变化)或失败');
logger.info('HealthKit 个人资料同步到服务端跳过(无变化)或失败');
}
// 2. 同步每日健康数据报表 (活动、睡眠、心率等)
// 传入今日饮水量
const waterIntake = todayWaterStats?.totalAmount;
logger.info('开始同步每日健康数据报表...', { waterIntake });
const reportSuccess = await syncDailyHealthReport(waterIntake);
if (reportSuccess) {
logger.info('每日健康数据报表同步成功');
} else {
logger.info('每日健康数据报表同步跳过(无变化)或失败');
}
} catch (error) {
logger.error('同步 HealthKit 数据到服务端失败:', error);
}
}, [isLoggedIn, dispatch, userProfile]);
}, [isLoggedIn, dispatch, userProfile, todayWaterStats]);
// 初始加载时执行数据加载和同步
useEffect(() => {
loadAllData(currentSelectedDate);
// 延迟1秒后执行同步避免影响初始加载性能
// 如果 todayWaterStats 还未加载完成,可能会导致第一次同步时 waterIntake 为 undefined
// 但 waterSlice.fetchTodayWaterStats 会在 loadAllData 中被调用
const syncTimer = setTimeout(() => {
syncHealthDataToServer();
}, 1000);
@@ -384,42 +409,41 @@ export default function ExploreScreen() {
{/* 顶部信息栏 */}
<View style={styles.headerContainer}>
<View style={styles.headerContent}>
{/* 左边logo */}
<Image
source={require('@/assets/icon.icon/Assets/icon-1756312748268.png')}
style={styles.logoImage}
resizeMode="cover"
/>
<View style={styles.headerLeft}>
<Image
source={require('@/assets/machine.png')}
style={styles.logoImage}
resizeMode="cover"
/>
{/* 右边文字区域 */}
<View style={styles.headerTextContainer}>
<Text style={styles.headerTitle}>{t('statistics.title')}</Text>
{/* 右边文字区域 */}
<View style={styles.headerTextContainer}>
<Text style={styles.headerTitle}>{t('statistics.title')}</Text>
</View>
</View>
{/* 开发环境调试按钮 */}
{__DEV__ && (
<View style={styles.debugButtonsContainer}>
<TouchableOpacity
style={styles.debugButton}
onPress={async () => {
console.log('🔧 Manual background task test...');
await BackgroundTaskManager.getInstance().triggerTaskForTesting();
}}
>
<Text style={styles.debugButtonText}>🔧</Text>
</TouchableOpacity>
<View style={styles.headerActions}>
<TouchableOpacity
activeOpacity={0.85}
onPress={handleOpenGallery}
>
{isLiquidGlassAvailable() ? (
<GlassView
style={styles.liquidGlassButton}
glassEffectStyle="regular"
tintColor="rgba(255, 255, 255, 0.3)"
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>
<TouchableOpacity
style={[styles.debugButton, styles.hrvTestButton]}
onPress={async () => {
console.log('🫀 Testing HRV data fetch...');
await testHRVDataFetch();
}}
>
<Text style={styles.debugButtonText}>🫀</Text>
</TouchableOpacity>
</View>
)}
</View>
</View>
</View>
@@ -524,6 +548,7 @@ export default function ExploreScreen() {
{/* 血氧饱和度卡片 */}
<FloatingCard style={styles.masonryCard}>
<OxygenSaturationCard
selectedDate={currentSelectedDate}
style={styles.basalMetabolismCardOverride}
/>
</FloatingCard>
@@ -536,6 +561,7 @@ export default function ExploreScreen() {
{/* 围度数据卡片 - 占满底部一行 */}
<CircumferenceCard style={styles.circumferenceCard} />
</ScrollView>
</View>
);
}
@@ -584,6 +610,13 @@ const styles = StyleSheet.create({
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
},
headerLeft: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
minWidth: 0,
},
logoImage: {
width: 28,
@@ -598,6 +631,7 @@ const styles = StyleSheet.create({
fontSize: 16,
fontWeight: '700',
color: '#192126',
fontFamily: 'AliBold'
},
debugButtonsContainer: {
flexDirection: 'row',
@@ -619,11 +653,9 @@ const styles = StyleSheet.create({
shadowRadius: 4,
elevation: 3,
},
hrvTestButton: {
backgroundColor: '#8B5CF6',
},
debugButtonText: {
fontSize: 12,
fontFamily: 'AliRegular',
},
metricsRow: {
flexDirection: 'row',
@@ -657,13 +689,15 @@ const styles = StyleSheet.create({
fontSize: 18,
lineHeight: 18,
fontWeight: '600',
textAlignVertical: 'bottom'
textAlignVertical: 'bottom',
fontFamily: 'AliBold'
},
caloriesUnit: {
color: '#515558ff',
fontSize: 12,
marginLeft: 4,
lineHeight: 18,
fontFamily: 'AliRegular',
},
trainingContent: {
marginTop: 8,
@@ -697,6 +731,7 @@ const styles = StyleSheet.create({
fontSize: 18,
fontWeight: '800',
color: '#8B74F3',
fontFamily: 'AliBold',
},
cyclingHeader: {
flexDirection: 'row',
@@ -716,6 +751,7 @@ const styles = StyleSheet.create({
color: '#FFFFFF',
fontSize: 20,
fontWeight: '800',
fontFamily: 'AliBold',
},
mapArea: {
backgroundColor: 'rgba(255,255,255,0.08)',
@@ -755,6 +791,7 @@ const styles = StyleSheet.create({
cardTitle: {
fontSize: 14,
color: '#192126',
fontFamily: 'AliBold',
},
heartCard: {
backgroundColor: '#FFE5E5',
@@ -775,12 +812,14 @@ const styles = StyleSheet.create({
alignSelf: 'flex-end',
color: '#5B5B5B',
fontWeight: '600',
fontFamily: 'AliBold',
},
stepsValue: {
fontSize: 14,
color: '#7A6A42',
fontWeight: '700',
marginBottom: 8,
fontFamily: 'AliBold',
},
errorContainer: {
flexDirection: 'row',
@@ -796,6 +835,7 @@ const styles = StyleSheet.create({
fontWeight: '600',
marginLeft: 8,
flex: 1,
fontFamily: 'AliRegular',
},
retryButton: {
padding: 4,
@@ -810,11 +850,13 @@ const styles = StyleSheet.create({
viewMoreText: {
fontSize: 14,
color: '#192126',
fontFamily: 'AliRegular',
},
viewMoreIcon: {
fontSize: 16,
color: '#192126',
marginLeft: 4,
fontFamily: 'AliRegular',
},
stressCardRow: {
flexDirection: 'row',
@@ -885,6 +927,7 @@ const styles = StyleSheet.create({
color: '#0369A1',
fontWeight: '800',
marginTop: 8,
fontFamily: 'AliBold',
},
addWeightButton: {
position: 'absolute',
@@ -905,6 +948,54 @@ const styles = StyleSheet.create({
fontWeight: '700',
color: '#192126',
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,15 +1,9 @@
import '@/i18n';
import { DefaultTheme, ThemeProvider } from '@react-navigation/native';
import { useFonts } from 'expo-font';
import { Stack, useRouter } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import 'react-native-reanimated';
import PrivacyConsentModal from '@/components/PrivacyConsentModal';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useQuickActions } from '@/hooks/useQuickActions';
import '@/i18n';
import { hrvMonitorService } from '@/services/hrvMonitor';
import { cleanupLegacyMedicationNotifications } from '@/services/medicationNotificationCleanup';
import { clearBadgeCount, notificationService } from '@/services/notifications';
import { setupQuickActions } from '@/services/quickActions';
import { sleepMonitorService } from '@/services/sleepMonitor';
@@ -23,17 +17,26 @@ import { createWaterRecordAction } from '@/store/waterSlice';
import { loadActiveFastingSchedule } from '@/utils/fasting';
import { initializeHealthPermissions } from '@/utils/health';
import { MoodNotificationHelpers, NutritionNotificationHelpers, WaterNotificationHelpers } from '@/utils/notificationHelpers';
import { getMoodReminderEnabled, getNutritionReminderEnabled, getWaterReminderSettings } from '@/utils/userPreferences';
import { clearPendingWaterRecords, syncPendingWidgetChanges } from '@/utils/widgetDataSync';
import { DefaultTheme, ThemeProvider } from '@react-navigation/native';
import { useFonts } from 'expo-font';
import { Stack, useRouter } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import React, { useEffect } from 'react';
import { AppState, AppStateStatus } from 'react-native';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import 'react-native-reanimated';
import { DialogProvider } from '@/components/ui/DialogProvider';
import { MembershipModalProvider } from '@/contexts/MembershipModalContext';
import { ToastProvider } from '@/contexts/ToastContext';
import { VersionCheckProvider } from '@/contexts/VersionCheckContext';
import { useAuthGuard } from '@/hooks/useAuthGuard';
import { STORAGE_KEYS, setUnauthorizedHandler } from '@/services/api';
import { BackgroundTaskManager } from '@/services/backgroundTaskManagerV2';
import { fetchChallenges } from '@/store/challengesSlice';
import { loadTabBarConfigs } from '@/store/tabBarConfigSlice';
import AsyncStorage from '@/utils/kvStore';
import { logger } from '@/utils/logger';
import { Provider } from 'react-redux';
@@ -119,15 +122,22 @@ function Bootstrapper({ children }: { children: React.ReactNode }) {
}
}, [isLoggedIn]);
// 初始化底部栏配置
useEffect(() => {
dispatch(loadTabBarConfigs());
}, [dispatch]);
// ==================== 基础服务初始化(不需要权限,总是执行)====================
React.useEffect(() => {
const initializeBasicServices = async () => {
try {
logger.info('🚀 开始初始化基础服务(不需要权限)...');
// 1. 加载用户数据(首屏展示需要)
await dispatch(fetchMyProfile());
logger.info('✅ 用户数据加载完成');
if (isLoggedIn) {
// 1. 加载用户数据(首屏展示需要)
await dispatch(fetchMyProfile());
logger.info('✅ 用户数据加载完成');
}
// 2. 初始化 HealthKit 权限系统(不请求权限,仅初始化)
initializeHealthPermissions();
@@ -173,7 +183,6 @@ function Bootstrapper({ children }: { children: React.ReactNode }) {
permissionInitializedRef.current = true;
const delay = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms));
// ==================== 辅助函数 ====================
@@ -213,27 +222,57 @@ function Bootstrapper({ children }: { children: React.ReactNode }) {
try {
logger.info('📢 开始批量注册通知提醒...');
// 并行注册所有通知,提高效率
await Promise.all([
// 营养提醒
NutritionNotificationHelpers.scheduleDailyLunchReminder(profile.name || '').then(() =>
logger.info('✅ 午餐提醒已注册')
),
NutritionNotificationHelpers.scheduleDailyDinnerReminder(profile.name || '').then(() =>
logger.info('✅ 晚餐提醒已注册')
),
// 心情提醒
MoodNotificationHelpers.scheduleDailyMoodReminder(profile.name || '').then(() =>
logger.info('✅ 心情提醒已注册')
),
// 喝水提醒
WaterNotificationHelpers.scheduleRegularWaterReminders(profile.name || '用户').then(() =>
logger.info('✅ 喝水提醒已注册')
),
// 获取用户偏好设置
const [nutritionReminderEnabled, moodReminderEnabled, waterSettings] = await Promise.all([
getNutritionReminderEnabled(),
getMoodReminderEnabled(),
getWaterReminderSettings(),
]);
// 准备所有通知注册任务
const notificationTasks = [];
// 营养提醒 - 根据用户设置决定是否注册
if (nutritionReminderEnabled) {
notificationTasks.push(
NutritionNotificationHelpers.scheduleDailyLunchReminder(profile.name || '').then(() =>
logger.info('✅ 午餐提醒已注册')
),
NutritionNotificationHelpers.scheduleDailyDinnerReminder(profile.name || '').then(() =>
logger.info('✅ 晚餐提醒已注册')
)
);
} else {
logger.info(' 用户未开启营养提醒,跳过注册');
}
// 心情提醒 - 根据用户设置决定是否注册
if (moodReminderEnabled) {
notificationTasks.push(
MoodNotificationHelpers.scheduleDailyMoodReminder(profile.name || '').then(() =>
logger.info('✅ 心情提醒已注册')
)
);
} else {
logger.info(' 用户未开启心情提醒,跳过注册');
}
// 喝水提醒 - 根据用户设置决定是否注册
if (waterSettings.enabled) {
notificationTasks.push(
WaterNotificationHelpers.scheduleCustomWaterReminders(profile.name || '用户', waterSettings).then(() =>
logger.info('✅ 自定义喝水提醒已注册')
)
);
} else {
logger.info(' 用户未开启喝水提醒,跳过注册');
}
// 并行执行所有通知注册任务
if (notificationTasks.length > 0) {
await Promise.all(notificationTasks);
}
// 检查断食通知(如果有活跃计划)
const fastingSchedule = store.getState().fasting.activeSchedule;
if (fastingSchedule) {
@@ -353,7 +392,12 @@ function Bootstrapper({ children }: { children: React.ReactNode }) {
await notificationService.initialize();
logger.info('✅ 通知服务初始化完成');
// 2. 异步同步 Widget 数据(不阻塞主流程
// 2. 清理旧的药品本地通知(迁移到服务端推送
cleanupLegacyMedicationNotifications().catch(error => {
logger.error('❌ 清理旧药品通知失败:', error);
});
// 3. 异步同步 Widget 数据(不阻塞主流程)
syncWidgetDataInBackground();
logger.info('🎉 权限相关服务初始化完成');
@@ -401,8 +445,8 @@ function Bootstrapper({ children }: { children: React.ReactNode }) {
// 2. 开发环境调试工具
if (__DEV__ && BackgroundTaskDebugger) {
BackgroundTaskDebugger.getInstance().initialize();
logger.info('✅ 后台任务调试工具已初始化(开发环境)');
logger.info('✅ 后台任务调试工具未初始化(开发环境)');
return
}
logger.info('🎉 空闲服务初始化完成');
@@ -440,6 +484,51 @@ function Bootstrapper({ children }: { children: React.ReactNode }) {
getPrivacyAgreed();
}, []);
// 使用 ref 确保更新检查只执行一次
// const updateCheckRequestedRef = React.useRef(false);
// useEffect(() => {
// // 如果已经执行过更新检查,直接返回
// if (updateCheckRequestedRef.current) {
// return;
// }
// updateCheckRequestedRef.current = true;
// async function checkUpdate() {
// try {
// logger.info(`Checking for updates..., env: ${__DEV__}, Updates.isEnabled: ${Updates.isEnabled}`);
// // 只有在 expo-updates 启用时才检查更新
// // 开发环境或 Updates 未启用时跳过
// if (__DEV__ || !Updates.isEnabled) {
// logger.info('Skipping update check: dev mode or updates not enabled');
// return;
// }
// const update = await Updates.checkForUpdateAsync();
// logger.info("Update check:", update);
// if (update.isAvailable) {
// logger.info("Update available, fetching...");
// const result = await Updates.fetchUpdateAsync();
// logger.info("Fetch result:", result);
// if (result.isNew) {
// logger.info("Reloading app to apply update...");
// await Updates.reloadAsync();
// }
// }
// } catch (e) {
// logger.error("Update error:", e);
// }
// }
// setTimeout(() => {
// checkUpdate();
// }, 5000);
// }, []);
const handlePrivacyAgree = () => {
dispatch(setPrivacyAgreed());
setShowPrivacyModal(false);
@@ -466,6 +555,8 @@ function Bootstrapper({ children }: { children: React.ReactNode }) {
export default function RootLayout() {
const [loaded] = useFonts({
SpaceMono: require('../assets/fonts/SpaceMono-Regular.ttf'),
AliRegular: require('../assets/fonts/ali-regular.ttf'),
AliBold: require('../assets/fonts/ali-bold.ttf'),
});
if (!loaded) {
@@ -478,33 +569,32 @@ export default function RootLayout() {
<Provider store={store}>
<Bootstrapper>
<ToastProvider>
<ThemeProvider value={DefaultTheme}>
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="onboarding" />
<Stack.Screen name="(tabs)" />
<Stack.Screen name="challenge" options={{ headerShown: false }} />
<Stack.Screen name="training-plan" options={{ headerShown: false }} />
<Stack.Screen name="workout" options={{ headerShown: false }} />
<Stack.Screen name="profile/edit" />
<Stack.Screen name="fasting/[planId]" options={{ headerShown: false }} />
<VersionCheckProvider>
<ThemeProvider value={DefaultTheme}>
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="onboarding" />
<Stack.Screen name="(tabs)" />
<Stack.Screen name="profile/edit" />
<Stack.Screen name="fasting/[planId]" options={{ headerShown: false }} />
<Stack.Screen name="ai-posture-assessment" />
<Stack.Screen name="auth/login" options={{ headerShown: false }} />
<Stack.Screen name="legal/user-agreement" 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="water-detail" options={{ headerShown: false }} />
<Stack.Screen name="water-settings" options={{ headerShown: false }} />
<Stack.Screen name="workout/notification-settings" options={{ headerShown: false }} />
<Stack.Screen
name="health-data-permissions"
options={{ headerShown: false }}
/>
<Stack.Screen name="badges/index" options={{ headerShown: false }} />
<Stack.Screen name="+not-found" />
</Stack>
<StatusBar style="dark" />
</ThemeProvider>
<Stack.Screen name="auth/login" options={{ headerShown: false }} />
<Stack.Screen name="legal/user-agreement" 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="workout/notification-settings" options={{ headerShown: false }} />
<Stack.Screen
name="health-data-permissions"
options={{ headerShown: false }}
/>
<Stack.Screen name="medications/ai-camera" options={{ headerShown: false }} />
<Stack.Screen name="medications/ai-progress" options={{ headerShown: false }} />
<Stack.Screen name="badges/index" options={{ headerShown: false }} />
<Stack.Screen name="settings/tab-bar-config" options={{ headerShown: false }} />
<Stack.Screen name="+not-found" />
</Stack>
<StatusBar style="dark" />
</ThemeProvider>
</VersionCheckProvider>
</ToastProvider>
</Bootstrapper>
</Provider>

View File

@@ -13,6 +13,7 @@ import { PRIVACY_POLICY_URL, USER_AGREEMENT_URL } from '@/constants/Agree';
import { Colors } from '@/constants/Colors';
import { useAppDispatch } from '@/hooks/redux';
import { useColorScheme } from '@/hooks/useColorScheme';
import { useI18n } from '@/hooks/useI18n';
import { fetchMyProfile, login } from '@/store/userSlice';
import Toast from 'react-native-toast-message';
@@ -23,6 +24,7 @@ export default function LoginScreen() {
const color = Colors[scheme];
const pageBackground = scheme === 'light' ? color.pageBackgroundEmphasis : color.background;
const dispatch = useAppDispatch();
const { t } = useI18n();
const AnimatedLinear = useMemo(() => Animated.createAnimatedComponent(LinearGradient), []);
// 背景动效:轻微平移/旋转与呼吸动画
@@ -79,12 +81,12 @@ export default function LoginScreen() {
const guardAgreement = useCallback((action: () => void) => {
if (!hasAgreed) {
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: () => {
setHasAgreed(true);
setTimeout(() => action(), 0);
@@ -96,7 +98,7 @@ export default function LoginScreen() {
return;
}
action();
}, [hasAgreed]);
}, [hasAgreed, t]);
const onAppleLogin = useCallback(async () => {
if (!appleAvailable) return;
@@ -110,7 +112,7 @@ export default function LoginScreen() {
});
const identityToken = (credential as any)?.identityToken;
if (!identityToken || typeof identityToken !== 'string') {
throw new Error('未获取到 Apple 身份令牌');
throw new Error(t('login.errors.appleIdentityTokenMissing'));
}
await dispatch(login({ appleIdentityToken: identityToken })).unwrap();
@@ -118,7 +120,7 @@ export default function LoginScreen() {
await dispatch(fetchMyProfile())
Toast.show({
text1: '登录成功',
text1: t('login.success.loginSuccess'),
type: 'success',
});
// 登录成功后处理重定向
@@ -145,12 +147,12 @@ export default function LoginScreen() {
console.log('err.code', err.code);
if (err?.code === 'ERR_CANCELED' || err?.code === 'ERR_REQUEST_CANCELED') return;
const message = err?.message || '登录失败,请稍后再试';
Alert.alert('登录失败', message);
const message = err?.message || t('login.errors.loginFailed');
Alert.alert(t('login.errors.loginFailedTitle'), message);
} finally {
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'} />
</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>
<ScrollView contentContainerStyle={styles.content} showsVerticalScrollIndicator={false}>
<View style={styles.headerWrap}>
<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>
{/* Apple 登录 */}
@@ -276,12 +278,12 @@ export default function LoginScreen() {
color="#FFFFFF"
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 }} />
<Text style={styles.appleText}>使 Apple </Text>
<Text style={styles.appleText}>{t('login.appleLogin')}</Text>
</>
)}
</GlassView>
@@ -294,12 +296,12 @@ export default function LoginScreen() {
color="#FFFFFF"
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 }} />
<Text style={styles.appleText}>使 Apple </Text>
<Text style={styles.appleText}>{t('login.appleLogin')}</Text>
</>
)}
</View>
@@ -319,13 +321,13 @@ export default function LoginScreen() {
{hasAgreed && <Ionicons name="checkmark" size={14} color={color.onPrimary} />}
</View>
</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)}>
<Text style={[styles.link, { color: color.primary }]}></Text>
<Text style={[styles.link, { color: color.primary }]}>{t('login.agreement.privacyPolicy')}</Text>
</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)}>
<Text style={[styles.link, { color: color.primary }]}></Text>
<Text style={[styles.link, { color: color.primary }]}>{t('login.agreement.userAgreement')}</Text>
</Pressable>
</View>

View File

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

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,7 @@ import { HeaderBar } from '@/components/ui/HeaderBar';
import { Colors } from '@/constants/Colors';
import { ROUTES } from '@/constants/Routes';
import { useAppSelector } from '@/hooks/redux';
import { useI18n } from '@/hooks/useI18n';
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
import { addDietRecord, type CreateDietRecordDto, type MealType } from '@/services/dietRecords';
import { selectFoodRecognitionResult } from '@/store/foodRecognitionSlice';
@@ -65,15 +66,8 @@ const mockFoodItems = [
}
];
// 餐次映射
const MEAL_TYPE_MAP = {
breakfast: '早餐',
lunch: '午餐',
dinner: '晚餐',
snack: '加餐'
};
export default function FoodAnalysisResultScreen() {
const { t } = useI18n();
const safeAreaTop = useSafeAreaTop()
const router = useRouter();
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 totalProtein = foodItems.reduce((sum, item) => sum + item.protein, 0);
@@ -253,24 +256,24 @@ export default function FoodAnalysisResultScreen() {
// 餐次选择选项
const mealOptions = [
{ key: 'breakfast' as const, label: '早餐', color: '#FF6B35' },
{ key: 'lunch' as const, label: '午餐', color: '#4CAF50' },
{ key: 'dinner' as const, label: '晚餐', color: '#2196F3' },
{ key: 'snack' as const, label: '加餐', color: '#FF9800' },
{ key: 'breakfast' as const, label: t('nutritionRecords.mealTypes.breakfast'), color: '#FF6B35' },
{ key: 'lunch' as const, label: t('nutritionRecords.mealTypes.lunch'), color: '#4CAF50' },
{ key: 'dinner' as const, label: t('nutritionRecords.mealTypes.dinner'), color: '#2196F3' },
{ key: 'snack' as const, label: t('nutritionRecords.mealTypes.snack'), color: '#FF9800' },
];
if (!imageUri && !recognitionResult) {
return (
<View style={styles.container}>
<HeaderBar
title="分析结果"
title={t('foodAnalysisResult.title')}
onBack={() => router.back()}
/>
<View style={{
paddingTop: safeAreaTop
}} />
<View style={styles.errorContainer}>
<Text style={styles.errorText}></Text>
<Text style={styles.errorText}>{t('foodAnalysisResult.error.notFound')}</Text>
</View>
</View>
);
@@ -287,7 +290,7 @@ export default function FoodAnalysisResultScreen() {
/>
<HeaderBar
title="分析结果"
title={t('foodAnalysisResult.title')}
onBack={() => router.back()}
transparent={true}
/>
@@ -316,7 +319,7 @@ export default function FoodAnalysisResultScreen() {
<View style={styles.placeholderContainer}>
<View style={styles.placeholderContent}>
<Ionicons name="restaurant-outline" size={48} color="#666" />
<Text style={styles.placeholderText}></Text>
<Text style={styles.placeholderText}>{t('foodAnalysisResult.placeholder')}</Text>
</View>
</View>
)}
@@ -325,8 +328,8 @@ export default function FoodAnalysisResultScreen() {
<View style={styles.descriptionBubble}>
<Text style={styles.descriptionText}>
{recognitionResult ?
`置信度: ${recognitionResult.confidence}%` :
dayjs().format('YYYY年M月D日')
t('foodAnalysisResult.confidence', { value: recognitionResult.confidence }) :
dayjs().format(t('foodAnalysisResult.dateFormats.today'))
}
</Text>
</View>
@@ -337,31 +340,31 @@ export default function FoodAnalysisResultScreen() {
{/* 卡路里 */}
<View style={styles.calorieSection}>
<Text style={styles.calorieValue}>{totalCalories}</Text>
<Text style={styles.calorieUnit}></Text>
<Text style={styles.calorieUnit}>{t('foodAnalysisResult.nutrients.caloriesUnit')}</Text>
</View>
{/* 营养圆环图 */}
<View style={styles.nutritionRings}>
<NutritionRing
label="蛋白质"
label={t('foodAnalysisResult.nutrients.protein')}
value={totalProtein.toFixed(1)}
unit="克"
unit={t('foodAnalysisResult.nutrients.unit')}
percentage={Math.min(100, proteinPercentage)}
color="#4CAF50"
resetToken={animationTrigger}
/>
<NutritionRing
label="脂肪"
label={t('foodAnalysisResult.nutrients.fat')}
value={totalFat.toFixed(1)}
unit="克"
unit={t('foodAnalysisResult.nutrients.unit')}
percentage={Math.min(100, fatPercentage)}
color="#FF9800"
resetToken={animationTrigger}
/>
<NutritionRing
label="碳水"
label={t('foodAnalysisResult.nutrients.carbs')}
value={totalCarbohydrate.toFixed(1)}
unit="克"
unit={t('foodAnalysisResult.nutrients.unit')}
percentage={Math.min(100, carbohydratePercentage)}
color="#2196F3"
resetToken={animationTrigger}
@@ -372,7 +375,7 @@ export default function FoodAnalysisResultScreen() {
{/* 食物摄入部分 */}
<View style={styles.foodIntakeSection}>
<Text style={styles.foodIntakeTitle}>
{recognitionResult ? '识别结果' : '食物摄入'}
{recognitionResult ? t('foodAnalysisResult.sections.recognitionResult') : t('foodAnalysisResult.sections.foodIntake')}
</Text>
{recognitionResult && recognitionResult.analysisText && (
<Text style={styles.analysisText}>{recognitionResult.analysisText}</Text>
@@ -384,15 +387,15 @@ export default function FoodAnalysisResultScreen() {
<View style={styles.nonFoodIcon}>
<Ionicons name="alert-circle-outline" size={48} color="#FF9800" />
</View>
<Text style={styles.nonFoodTitle}></Text>
<Text style={styles.nonFoodTitle}>{t('foodAnalysisResult.nonFood.title')}</Text>
<Text style={styles.nonFoodMessage}>
{recognitionResult.nonFoodMessage || recognitionResult.analysisText}
</Text>
<View style={styles.nonFoodSuggestions}>
<Text style={styles.nonFoodSuggestionsTitle}></Text>
<Text style={styles.nonFoodSuggestionItem}> </Text>
<Text style={styles.nonFoodSuggestionItem}> </Text>
<Text style={styles.nonFoodSuggestionItem}> 线</Text>
<Text style={styles.nonFoodSuggestionsTitle}>{t('foodAnalysisResult.nonFood.suggestions.title')}</Text>
<Text style={styles.nonFoodSuggestionItem}>{t('foodAnalysisResult.nonFood.suggestions.item1')}</Text>
<Text style={styles.nonFoodSuggestionItem}>{t('foodAnalysisResult.nonFood.suggestions.item2')}</Text>
<Text style={styles.nonFoodSuggestionItem}>{t('foodAnalysisResult.nonFood.suggestions.item3')}</Text>
</View>
</View>
)}
@@ -411,7 +414,7 @@ export default function FoodAnalysisResultScreen() {
</View>
<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
style={styles.editButton}
onPress={() => handleEditFood(item)}
@@ -442,7 +445,7 @@ export default function FoodAnalysisResultScreen() {
activeOpacity={0.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>
</View>
) : (
@@ -471,7 +474,7 @@ export default function FoodAnalysisResultScreen() {
{isRecording ? (
<ActivityIndicator size="small" color="#FFF" />
) : (
<Text style={styles.recordButtonText}></Text>
<Text style={styles.recordButtonText}>{t('foodAnalysisResult.actions.record')}</Text>
)}
</TouchableOpacity>
</View>
@@ -492,7 +495,7 @@ export default function FoodAnalysisResultScreen() {
/>
<View style={styles.mealSelectorModal}>
<View style={styles.mealSelectorHeader}>
<Text style={styles.mealSelectorTitle}></Text>
<Text style={styles.mealSelectorTitle}>{t('foodAnalysisResult.mealSelector.title')}</Text>
<TouchableOpacity onPress={() => setShowMealSelector(false)}>
<Ionicons name="close" size={24} color="#666" />
</TouchableOpacity>
@@ -539,8 +542,8 @@ export default function FoodAnalysisResultScreen() {
<View style={styles.imageViewerHeader}>
<Text style={styles.imageViewerHeaderText}>
{recognitionResult ?
`置信度: ${recognitionResult.confidence}%` :
dayjs().format('YYYY年M月D日 HH:mm')
t('foodAnalysisResult.confidence', { value: recognitionResult.confidence }) :
dayjs().format(t('foodAnalysisResult.dateFormats.full'))
}
</Text>
</View>
@@ -551,7 +554,7 @@ export default function FoodAnalysisResultScreen() {
style={styles.imageViewerFooterButton}
onPress={() => setShowImagePreview(false)}
>
<Text style={styles.imageViewerFooterButtonText}></Text>
<Text style={styles.imageViewerFooterButtonText}>{t('foodAnalysisResult.actions.close')}</Text>
</TouchableOpacity>
</View>
)}
@@ -587,6 +590,8 @@ function FoodEditModal({
onFormDataChange({ ...formData, [field]: value });
};
const { t } = useI18n();
return (
<Modal
visible={visible}
@@ -598,14 +603,14 @@ function FoodEditModal({
<View style={styles.editModalSheet}>
<View style={styles.modalHandle} />
<Text style={styles.modalTitle}></Text>
<Text style={styles.modalTitle}>{t('foodAnalysisResult.editModal.title')}</Text>
{/* 食物名称 */}
<View style={styles.editFieldContainer}>
<Text style={styles.editFieldLabel}></Text>
<Text style={styles.editFieldLabel}>{t('foodAnalysisResult.editModal.fields.name')}</Text>
<TextInput
style={styles.editInput}
placeholder="输入食物名称"
placeholder={t('foodAnalysisResult.editModal.fields.namePlaceholder')}
placeholderTextColor="#999"
value={formData.name}
onChangeText={(value) => handleFieldChange('name', value)}
@@ -615,10 +620,10 @@ function FoodEditModal({
{/* 重量/数量 */}
<View style={styles.editFieldContainer}>
<Text style={styles.editFieldLabel}> ()</Text>
<Text style={styles.editFieldLabel}>{t('foodAnalysisResult.editModal.fields.amount')}</Text>
<TextInput
style={styles.editInput}
placeholder="输入重量"
placeholder={t('foodAnalysisResult.editModal.fields.amountPlaceholder')}
placeholderTextColor="#999"
value={formData.amount}
onChangeText={(value) => handleFieldChange('amount', value)}
@@ -628,10 +633,10 @@ function FoodEditModal({
{/* 卡路里 */}
<View style={styles.editFieldContainer}>
<Text style={styles.editFieldLabel}> ()</Text>
<Text style={styles.editFieldLabel}>{t('foodAnalysisResult.editModal.fields.calories')}</Text>
<TextInput
style={styles.editInput}
placeholder="输入卡路里"
placeholder={t('foodAnalysisResult.editModal.fields.caloriesPlaceholder')}
placeholderTextColor="#999"
value={formData.calories}
onChangeText={(value) => handleFieldChange('calories', value)}
@@ -645,13 +650,13 @@ function FoodEditModal({
onPress={onClose}
style={styles.modalCancelBtn}
>
<Text style={styles.modalCancelText}></Text>
<Text style={styles.modalCancelText}>{t('foodAnalysisResult.editModal.actions.cancel')}</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={onSave}
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>
</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,6 @@
import { HeaderBar } from '@/components/ui/HeaderBar';
import { Colors } from '@/constants/Colors';
import { useI18n } from '@/hooks/useI18n';
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
import {
deleteNutritionAnalysisRecord,
@@ -30,6 +31,7 @@ import {
import ImageViewing from 'react-native-image-viewing';
export default function NutritionAnalysisHistoryScreen() {
const { t } = useI18n();
const safeAreaTop = useSafeAreaTop();
const router = useRouter();
@@ -95,15 +97,15 @@ export default function NutritionAnalysisHistoryScreen() {
setHasMore(page < response.data.totalPages);
setCurrentPage(page);
} else {
const errorMessage = response.message || '获取历史记录失败';
const errorMessage = response.message || t('nutritionAnalysisHistory.errors.fetchFailed');
setError(errorMessage);
Alert.alert('错误', errorMessage);
Alert.alert(t('nutritionAnalysisHistory.errors.error'), errorMessage);
}
} catch (error) {
console.error('[HISTORY] 获取历史记录失败:', error);
const errorMessage = '获取历史记录失败,请稍后重试';
const errorMessage = t('nutritionAnalysisHistory.errors.fetchFailedRetry');
setError(errorMessage);
Alert.alert('错误', errorMessage);
Alert.alert(t('nutritionAnalysisHistory.errors.error'), errorMessage);
} finally {
setLoading(false);
setRefreshing(false);
@@ -173,13 +175,13 @@ export default function NutritionAnalysisHistoryScreen() {
const getStatusText = (status: string) => {
switch (status) {
case 'success':
return '成功';
return t('nutritionAnalysisHistory.status.success');
case 'failed':
return '失败';
return t('nutritionAnalysisHistory.status.failed');
case 'processing':
return '处理中';
return t('nutritionAnalysisHistory.status.processing');
default:
return '未知';
return t('nutritionAnalysisHistory.status.unknown');
}
};
@@ -208,15 +210,15 @@ export default function NutritionAnalysisHistoryScreen() {
// 处理删除记录
const handleDeleteRecord = useCallback((recordId: number) => {
Alert.alert(
'确认删除',
'确定要删除这条营养分析记录吗?此操作无法撤销。',
t('nutritionAnalysisHistory.delete.confirmTitle'),
t('nutritionAnalysisHistory.delete.confirmMessage'),
[
{
text: '取消',
text: t('nutritionAnalysisHistory.delete.cancel'),
style: 'cancel',
},
{
text: '删除',
text: t('nutritionAnalysisHistory.delete.delete'),
style: 'destructive',
onPress: async () => {
try {
@@ -231,10 +233,10 @@ export default function NutritionAnalysisHistoryScreen() {
triggerLightHaptic();
// 显示成功提示
Alert.alert('成功', '记录已删除');
Alert.alert(t('nutritionAnalysisHistory.delete.successTitle'), t('nutritionAnalysisHistory.delete.successMessage'));
} catch (error) {
console.error('[HISTORY] 删除记录失败:', error);
Alert.alert('错误', '删除失败,请稍后重试');
Alert.alert(t('nutritionAnalysisHistory.errors.error'), t('nutritionAnalysisHistory.errors.deleteFailed'));
} finally {
setDeletingId(null);
}
@@ -256,11 +258,11 @@ export default function NutritionAnalysisHistoryScreen() {
<View style={styles.recordInfo}>
{isSuccess && (
<Text style={styles.recordTitle}>
{item.nutritionCount}
{t('nutritionAnalysisHistory.recognized', { count: item.nutritionCount })}
</Text>
)}
<Text style={styles.recordDate}>
{dayjs(item.createdAt).format('YYYY年M月D日 HH:mm')}
{dayjs(item.createdAt).format(t('nutritionAnalysisHistory.dateFormat'))}
</Text>
<View style={[styles.statusBadge, { backgroundColor: getStatusColor(item.status) }]}>
<Text style={styles.statusText}>{getStatusText(item.status)}</Text>
@@ -327,25 +329,25 @@ export default function NutritionAnalysisHistoryScreen() {
<>
{mainNutrients.energy && (
<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>
</View>
)}
{mainNutrients.protein && (
<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>
</View>
)}
{mainNutrients.carbs && (
<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>
</View>
)}
{mainNutrients.fat && (
<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>
</View>
)}
@@ -371,7 +373,7 @@ export default function NutritionAnalysisHistoryScreen() {
activeOpacity={0.7}
>
<Text style={styles.expandButtonText}>
{isExpanded ? '收起详情' : '展开详情'}
{isExpanded ? t('nutritionAnalysisHistory.actions.collapse') : t('nutritionAnalysisHistory.actions.expand')}
</Text>
<Ionicons
name={isExpanded ? 'chevron-up-outline' : 'chevron-down-outline'}
@@ -383,7 +385,7 @@ export default function NutritionAnalysisHistoryScreen() {
{/* 详细信息 */}
{isExpanded && isSuccess && item.analysisResult && item.analysisResult.data && (
<View style={styles.detailsContainer}>
<Text style={styles.detailsTitle}></Text>
<Text style={styles.detailsTitle}>{t('nutritionAnalysisHistory.details.title')}</Text>
{item.analysisResult.data.map((nutritionItem: NutritionItem) => (
<View key={nutritionItem.key} style={styles.detailItem}>
<View style={styles.nutritionInfo}>
@@ -397,8 +399,8 @@ export default function NutritionAnalysisHistoryScreen() {
))}
<View style={styles.metaInfo}>
<Text style={styles.metaText}>AI : {item.aiModel}</Text>
<Text style={styles.metaText}>: {item.aiProvider}</Text>
<Text style={styles.metaText}>{t('nutritionAnalysisHistory.details.aiModel')}: {item.aiModel}</Text>
<Text style={styles.metaText}>{t('nutritionAnalysisHistory.details.provider')}: {item.aiProvider}</Text>
</View>
</View>
)}
@@ -410,8 +412,8 @@ export default function NutritionAnalysisHistoryScreen() {
const renderEmptyState = () => (
<View style={styles.emptyState}>
<Ionicons name="document-text-outline" size={64} color="#CCC" />
<Text style={styles.emptyStateText}></Text>
<Text style={styles.emptyStateSubtext}></Text>
<Text style={styles.emptyStateText}>{t('nutritionAnalysisHistory.empty.title')}</Text>
<Text style={styles.emptyStateSubtext}>{t('nutritionAnalysisHistory.empty.subtitle')}</Text>
</View>
);
@@ -419,8 +421,8 @@ export default function NutritionAnalysisHistoryScreen() {
const renderErrorState = () => (
<View style={styles.errorState}>
<Ionicons name="alert-circle-outline" size={64} color="#F44336" />
<Text style={styles.errorStateText}></Text>
<Text style={styles.errorStateSubtext}>{error || '未知错误'}</Text>
<Text style={styles.errorStateText}>{t('nutritionAnalysisHistory.errors.loadFailed')}</Text>
<Text style={styles.errorStateSubtext}>{error || t('nutritionAnalysisHistory.errors.unknownError')}</Text>
<TouchableOpacity
style={styles.retryButton}
onPress={() => {
@@ -428,7 +430,7 @@ export default function NutritionAnalysisHistoryScreen() {
fetchRecords(1, true);
}}
>
<Text style={styles.retryButtonText}></Text>
<Text style={styles.retryButtonText}>{t('nutritionAnalysisHistory.actions.retry')}</Text>
</TouchableOpacity>
</View>
);
@@ -440,7 +442,7 @@ export default function NutritionAnalysisHistoryScreen() {
return (
<View style={styles.loadingFooter}>
<ActivityIndicator size="small" color={Colors.light.primary} />
<Text style={styles.loadingFooterText}>...</Text>
<Text style={styles.loadingFooterText}>{t('nutritionAnalysisHistory.loadingMore')}</Text>
</View>
);
};
@@ -456,7 +458,7 @@ export default function NutritionAnalysisHistoryScreen() {
/>
<HeaderBar
title="历史记录"
title={t('nutritionAnalysisHistory.title')}
onBack={() => router.back()}
transparent={true}
/>
@@ -477,7 +479,7 @@ export default function NutritionAnalysisHistoryScreen() {
activeOpacity={0.7}
>
<Text style={[styles.filterButtonText, !statusFilter && styles.filterButtonTextActive]}>
{t('nutritionAnalysisHistory.filter.all')}
</Text>
</TouchableOpacity>
<TouchableOpacity
@@ -494,7 +496,7 @@ export default function NutritionAnalysisHistoryScreen() {
activeOpacity={0.7}
>
<Text style={[styles.filterButtonText, statusFilter === 'success' && styles.filterButtonTextActive]}>
{t('nutritionAnalysisHistory.status.success')}
</Text>
</TouchableOpacity>
<TouchableOpacity
@@ -511,7 +513,7 @@ export default function NutritionAnalysisHistoryScreen() {
activeOpacity={0.7}
>
<Text style={[styles.filterButtonText, statusFilter === 'failed' && styles.filterButtonTextActive]}>
{t('nutritionAnalysisHistory.status.failed')}
</Text>
</TouchableOpacity>
</View>
@@ -520,7 +522,7 @@ export default function NutritionAnalysisHistoryScreen() {
{loading ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={Colors.light.primary} />
<Text style={styles.loadingText}>...</Text>
<Text style={styles.loadingText}>{t('nutritionAnalysisHistory.loading')}</Text>
</View>
) : (
<FlatList
@@ -555,7 +557,7 @@ export default function NutritionAnalysisHistoryScreen() {
HeaderComponent={() => (
<View style={styles.imageViewerHeader}>
<Text style={styles.imageViewerHeaderText}>
{dayjs().format('YYYY年M月D日 HH:mm')}
{dayjs().format(t('nutritionAnalysisHistory.dateFormat'))}
</Text>
</View>
)}
@@ -565,7 +567,7 @@ export default function NutritionAnalysisHistoryScreen() {
style={styles.imageViewerFooterButton}
onPress={() => setShowImagePreview(false)}
>
<Text style={styles.imageViewerFooterButtonText}></Text>
<Text style={styles.imageViewerFooterButtonText}>{t('nutritionLabelAnalysis.actions.close')}</Text>
</TouchableOpacity>
</View>
)}

View File

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

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

@@ -0,0 +1,687 @@
import { HeaderBar } from '@/components/ui/HeaderBar';
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 { Image as ExpoImage } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient';
import * as MediaLibrary from 'expo-media-library';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
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 { 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 { Image } from 'expo-image';
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,7 +2,8 @@ import { ThemedView } from '@/components/ThemedView';
import { ROUTES } from '@/constants/Routes';
import { usePushNotifications } from '@/hooks/usePushNotifications';
import { useThemeColor } from '@/hooks/useThemeColor';
import { preloadUserData } from '@/store/userSlice';
import { STORAGE_KEYS } from '@/services/api';
import AsyncStorage from '@/utils/kvStore';
import { router } from 'expo-router';
import React, { useEffect, useState } from 'react';
import { ActivityIndicator, View } from 'react-native';
@@ -19,10 +20,11 @@ export default function SplashScreen() {
const checkOnboardingStatus = async () => {
try {
// 先预加载用户数据,包括 onboarding 状态
console.log('开始预加载用户数据(包含 onboarding 状态...');
const userData = await preloadUserData();
console.log('用户数据预加载完成onboarding 状态:', userData.onboardingCompleted);
// 直接读取 onboarding 状态
console.log('检查 onboarding 状态...');
const onboardingCompletedStr = await AsyncStorage.getItem(STORAGE_KEYS.onboardingCompleted);
const onboardingCompleted = onboardingCompletedStr === 'true';
console.log('Onboarding 状态:', onboardingCompleted);
// 初始化推送通知(不阻塞应用启动,且不会请求权限)
console.log('开始初始化推送通知基础服务...');
@@ -30,8 +32,8 @@ export default function SplashScreen() {
console.warn('推送通知初始化失败,但不影响应用正常使用:', error);
});
// 根据预加载的状态决定跳转
if (userData.onboardingCompleted) {
// 根据状态决定跳转
if (onboardingCompleted) {
console.log('用户已完成引导,跳转到统计页面');
router.replace(ROUTES.TAB_STATISTICS);
} else {
@@ -39,7 +41,7 @@ export default function SplashScreen() {
router.replace(ROUTES.ONBOARDING);
}
} catch (error) {
console.error('检查引导状态或预加载用户数据失败:', error);
console.error('检查引导状态失败:', error);
// 如果出现错误,默认进入主应用(假设已完成引导)
router.replace(ROUTES.TAB_STATISTICS);
}

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,6 @@ import { useAppDispatch } from '@/hooks/redux';
import { useAuthGuard } from '@/hooks/useAuthGuard';
import { useColorScheme } from '@/hooks/useColorScheme';
import { useCosUpload } from '@/hooks/useCosUpload';
import { medicationNotificationService } from '@/services/medicationNotifications';
import { createMedicationAction, fetchMedicationRecords, fetchMedications } from '@/store/medicationsSlice';
import type { MedicationForm, RepeatPattern } from '@/types/medication';
import { Ionicons, MaterialCommunityIcons } from '@expo/vector-icons';
@@ -151,10 +150,13 @@ export default function AddMedicationScreen() {
const [timesPickerValue, setTimesPickerValue] = useState(1);
const [startDate, setStartDate] = useState<Date>(new Date());
const [endDate, setEndDate] = useState<Date | null>(null);
const [expiryDate, setExpiryDate] = useState<Date | null>(null);
const [datePickerVisible, setDatePickerVisible] = useState(false);
const [datePickerValue, setDatePickerValue] = useState<Date>(new Date());
const [endDatePickerVisible, setEndDatePickerVisible] = useState(false);
const [endDatePickerValue, setEndDatePickerValue] = useState<Date>(new Date());
const [expiryDatePickerVisible, setExpiryDatePickerVisible] = useState(false);
const [expiryDatePickerValue, setExpiryDatePickerValue] = useState<Date>(new Date());
const [datePickerMode, setDatePickerMode] = useState<'start' | 'end'>('start');
const [medicationTimes, setMedicationTimes] = useState<string[]>([DEFAULT_TIME_PRESETS[0]]);
const [timePickerVisible, setTimePickerVisible] = useState(false);
@@ -319,6 +321,7 @@ export default function AddMedicationScreen() {
medicationTimes: medicationTimes,
startDate: dayjs(startDate).startOf('day').toISOString(), // ISO 8601 格式
endDate: endDate ? dayjs(endDate).endOf('day').toISOString() : undefined, // 如果有结束日期,设置为当天结束时间
expiryDate: expiryDate ? dayjs(expiryDate).endOf('day').toISOString() : undefined, // 如果有有效期,设置为当天结束时间
repeatPattern: 'daily' as RepeatPattern,
note: note.trim() || undefined,
};
@@ -333,16 +336,6 @@ export default function AddMedicationScreen() {
const today = dayjs().format('YYYY-MM-DD');
await dispatch(fetchMedicationRecords({ date: today }));
// 重新安排药品通知
try {
// 获取最新的药品列表
const medications = await dispatch(fetchMedications({ isActive: true })).unwrap();
await medicationNotificationService.rescheduleAllMedicationNotifications(medications);
} catch (error) {
console.error('[MEDICATION] 安排药品通知失败:', error);
// 不影响添加药品的成功流程,只记录错误
}
// 成功提示
Alert.alert(
'添加成功',
@@ -531,6 +524,11 @@ export default function AddMedicationScreen() {
setEndDatePickerVisible(true);
}, [endDate]);
const openExpiryDatePicker = useCallback(() => {
setExpiryDatePickerValue(expiryDate || new Date());
setExpiryDatePickerVisible(true);
}, [expiryDate]);
const confirmStartDate = useCallback((date: Date) => {
// 验证开始日期不能早于今天
const today = new Date();
@@ -563,6 +561,22 @@ export default function AddMedicationScreen() {
setEndDatePickerVisible(false);
}, [startDate]);
const confirmExpiryDate = useCallback((date: Date) => {
// 验证有效期不能早于今天
const today = new Date();
today.setHours(0, 0, 0, 0);
const selectedDate = new Date(date);
selectedDate.setHours(0, 0, 0, 0);
if (selectedDate < today) {
Alert.alert('日期无效', '有效期不能早于今天');
return;
}
setExpiryDate(date);
setExpiryDatePickerVisible(false);
}, []);
const openTimePicker = useCallback(
(index?: number) => {
try {
@@ -872,6 +886,32 @@ export default function AddMedicationScreen() {
</TouchableOpacity>
</View>
</View>
<View style={styles.inputGroup}>
<View style={styles.periodHeader}>
<ThemedText style={[styles.groupLabel, { color: colors.textSecondary }]}></ThemedText>
</View>
<TouchableOpacity
activeOpacity={0.85}
style={[
styles.dateRow,
{
borderColor: softBorderColor,
backgroundColor: colors.surface,
},
]}
onPress={openExpiryDatePicker}
>
<View style={styles.dateLeft}>
<Ionicons name="time-outline" size={16} color={colors.textSecondary} />
<ThemedText style={[styles.dateLabel, { color: colors.textMuted }]}></ThemedText>
<ThemedText style={[styles.dateValue, { color: colors.text }]}>
{expiryDate ? dayjs(expiryDate).format('YYYY/MM/DD') : '未设置'}
</ThemedText>
</View>
<Ionicons name="chevron-forward" size={16} color={colors.textSecondary} />
</TouchableOpacity>
</View>
</View>
);
case 3:
@@ -1166,6 +1206,51 @@ export default function AddMedicationScreen() {
</View>
</Modal>
<Modal
visible={expiryDatePickerVisible}
transparent
animationType="fade"
onRequestClose={() => setExpiryDatePickerVisible(false)}
>
<Pressable style={styles.pickerBackdrop} onPress={() => setExpiryDatePickerVisible(false)} />
<View style={[styles.pickerSheet, { backgroundColor: colors.surface }]}
>
<ThemedText style={[styles.modalTitle, { color: colors.text }]}></ThemedText>
<DateTimePicker
value={expiryDatePickerValue}
mode="date"
display={Platform.OS === 'ios' ? 'inline' : 'calendar'}
onChange={(event, date) => {
if (Platform.OS === 'ios') {
if (date) setExpiryDatePickerValue(date);
} else {
if (event.type === 'set' && date) {
confirmExpiryDate(date);
} else {
setExpiryDatePickerVisible(false);
}
}
}}
/>
{Platform.OS === 'ios' && (
<View style={styles.modalActions}>
<Pressable
onPress={() => setExpiryDatePickerVisible(false)}
style={[styles.modalBtn, { borderColor: softBorderColor }]}
>
<ThemedText style={[styles.modalBtnText, { color: colors.textSecondary }]}></ThemedText>
</Pressable>
<Pressable
onPress={() => confirmExpiryDate(expiryDatePickerValue)}
style={[styles.modalBtn, styles.modalBtnPrimary, { backgroundColor: colors.primary }]}
>
<ThemedText style={[styles.modalBtnText, { color: colors.onPrimary }]}></ThemedText>
</Pressable>
</View>
)}
</View>
</Modal>
<Modal
visible={endDatePickerVisible}
transparent

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,521 @@
import { HeaderBar } from '@/components/ui/HeaderBar';
import { Colors, palette } from '@/constants/Colors';
import { useI18n } from '@/hooks/useI18n';
import { getMedicationRecognitionStatus } from '@/services/medications';
import { MedicationRecognitionTask } from '@/types/medication';
import { Ionicons } from '@expo/vector-icons';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient';
import { router, useLocalSearchParams } from 'expo-router';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { ActivityIndicator, Animated, Dimensions, Modal, SafeAreaView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
const { width: SCREEN_WIDTH } = Dimensions.get('window');
const STEP_KEYS: MedicationRecognitionTask['status'][] = [
'analyzing_product',
'analyzing_suitability',
'analyzing_ingredients',
'analyzing_effects',
];
export default function MedicationAiProgressScreen() {
const { t } = useI18n();
const { taskId, cover } = useLocalSearchParams<{ taskId?: string; cover?: string }>();
const insets = useSafeAreaInsets();
const [task, setTask] = useState<MedicationRecognitionTask | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showErrorModal, setShowErrorModal] = useState(false);
const [errorMessage, setErrorMessage] = useState<string>('');
const navigatingRef = useRef(false);
const pollingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
// 动画值:上下浮动和透明度
const floatAnim = useRef(new Animated.Value(0)).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(() => {
if (!task) return 0;
const idx = STEP_KEYS.indexOf(task.status as any);
if (idx >= 0) return idx;
if (task.status === 'completed') return STEP_KEYS.length;
return 0;
}, [task]);
const fetchStatus = async () => {
if (!taskId || navigatingRef.current) return;
try {
const data = await getMedicationRecognitionStatus(taskId as string);
setTask(data);
setError(null);
// 识别成功,跳转到详情页
if (data.status === 'completed' && data.result && !navigatingRef.current) {
navigatingRef.current = true;
// 清除轮询
if (pollingTimerRef.current) {
clearInterval(pollingTimerRef.current);
pollingTimerRef.current = null;
}
router.replace({
pathname: '/medications/[medicationId]',
params: {
medicationId: 'ai-draft',
aiTaskId: data.taskId,
cover: (cover as string) || data.result.photoUrl || '',
},
});
}
// 识别失败,停止轮询并显示错误弹窗
if (data.status === 'failed' && !navigatingRef.current) {
navigatingRef.current = true;
// 清除轮询
if (pollingTimerRef.current) {
clearInterval(pollingTimerRef.current);
pollingTimerRef.current = null;
}
// 显示错误提示弹窗
setErrorMessage(data.errorMessage || t('medications.aiProgress.errors.default'));
setShowErrorModal(true);
}
} catch (err: any) {
console.error('[MEDICATION_AI] status failed', err);
setError(err?.message || t('medications.aiProgress.errors.queryFailed'));
} finally {
setLoading(false);
}
};
// 处理重新拍摄
const handleRetry = () => {
setShowErrorModal(false);
router.back();
};
useEffect(() => {
fetchStatus();
pollingTimerRef.current = setInterval(fetchStatus, 2400);
return () => {
if (pollingTimerRef.current) {
clearInterval(pollingTimerRef.current);
pollingTimerRef.current = null;
}
};
}, [taskId]);
// 启动浮动和闪烁动画 - 更快的动画速度
useEffect(() => {
// 上下浮动动画 - 加快速度
const floatAnimation = Animated.loop(
Animated.sequence([
Animated.timing(floatAnim, {
toValue: -10,
duration: 1000,
useNativeDriver: true,
}),
Animated.timing(floatAnim, {
toValue: 0,
duration: 1000,
useNativeDriver: true,
}),
])
);
// 透明度闪烁动画 - 加快速度,增加对比度
const opacityAnimation = Animated.loop(
Animated.sequence([
Animated.timing(opacityAnim, {
toValue: 1,
duration: 800,
useNativeDriver: true,
}),
Animated.timing(opacityAnim, {
toValue: 0.4,
duration: 800,
useNativeDriver: true,
}),
])
);
floatAnimation.start();
opacityAnimation.start();
return () => {
floatAnimation.stop();
opacityAnimation.stop();
};
}, []);
const progress = task?.progress ?? Math.min(100, (currentStepIndex / steps.length) * 100 + 10);
return (
<SafeAreaView style={styles.container}>
<LinearGradient colors={[palette.gray[25], palette.gray[50]]} style={StyleSheet.absoluteFill} />
<HeaderBar title={t('medications.aiProgress.title')} onBack={() => router.back()} transparent />
<View style={{ height: insets.top }} />
<View style={styles.heroCard}>
<View style={styles.heroImageWrapper}>
{cover ? (
<Image source={{ uri: cover }} style={styles.heroImage} contentFit="cover" />
) : (
<View style={styles.heroPlaceholder} />
)}
{/* 识别中的点阵网格动画效果 - 带深色蒙版 */}
{task?.status !== 'completed' && task?.status !== 'failed' && (
<>
{/* 深色半透明蒙版层,让点阵更清晰 */}
<View style={styles.overlayMask} />
{/* 渐变蒙版边框,增加视觉层次 */}
<LinearGradient
colors={[Colors.light.primary + '4D', Colors.light.accentPurple + '33', 'transparent']}
style={styles.gradientBorder}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
{/* 点阵网格动画 */}
<Animated.View
style={[
styles.dottedGrid,
{
transform: [{ translateY: floatAnim }],
opacity: opacityAnim,
}
]}
>
{Array.from({ length: 11 }).map((_, idx) => (
<View key={idx} style={styles.dotRow}>
{Array.from({ length: 11 }).map((__, jdx) => (
<View key={`${idx}-${jdx}`} style={styles.dot} />
))}
</View>
))}
</Animated.View>
</>
)}
</View>
<View style={styles.progressRow}>
<View style={[styles.progressBar, { width: `${progress}%` }]} />
</View>
<Text style={styles.progressText}>{Math.round(progress)}%</Text>
</View>
<View style={styles.stepList}>
{steps.map((step, index) => {
const active = index === currentStepIndex;
const done = index < currentStepIndex;
return (
<View key={step.key} style={styles.stepRow}>
<View style={[styles.bullet, done && styles.bulletDone, active && styles.bulletActive]} />
<Text style={[styles.stepLabel, active && styles.stepLabelActive, done && styles.stepLabelDone]}>
{step.label}
</Text>
</View>
);
})}
{task?.status === 'completed' && (
<View style={styles.stepRow}>
<View style={[styles.bullet, styles.bulletDone]} />
<Text style={[styles.stepLabel, styles.stepLabelDone]}>{t('medications.aiProgress.steps.completed')}</Text>
</View>
)}
</View>
<View style={styles.loadingBox}>
{loading ? <ActivityIndicator color={Colors.light.primary} /> : null}
{error ? <Text style={styles.errorText}>{error}</Text> : null}
</View>
{/* 识别提示弹窗 */}
<Modal
visible={showErrorModal}
transparent={true}
animationType="fade"
onRequestClose={handleRetry}
>
<TouchableOpacity
style={styles.modalOverlay}
activeOpacity={1}
onPress={handleRetry}
>
<TouchableOpacity
activeOpacity={1}
onPress={(e) => e.stopPropagation()}
style={styles.errorModalContainer}
>
<View style={styles.errorModalContent}>
{/* 标题 */}
<Text style={styles.errorModalTitle}>{t('medications.aiProgress.modal.title')}</Text>
{/* 提示信息 */}
<View style={styles.errorMessageBox}>
<Text style={styles.errorMessageText}>{errorMessage}</Text>
</View>
{/* 重新拍摄按钮 */}
<TouchableOpacity
onPress={handleRetry}
activeOpacity={0.8}
style={{ width: '100%' }}
>
{isLiquidGlassAvailable() ? (
<GlassView
style={styles.retryButton}
glassEffectStyle="regular"
tintColor={Colors.light.primary}
isInteractive={true}
>
<LinearGradient
colors={[Colors.light.primary, Colors.light.accentPurple]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.retryButtonGradient}
>
<Ionicons name="camera" size={20} color="#FFFFFF" style={{ marginRight: 8 }} />
<Text style={styles.retryButtonText}>{t('medications.aiProgress.modal.retry')}</Text>
</LinearGradient>
</GlassView>
) : (
<View style={styles.retryButton}>
<LinearGradient
colors={[Colors.light.primary, Colors.light.accentPurple]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.retryButtonGradient}
>
<Ionicons name="camera" size={20} color="#FFFFFF" style={{ marginRight: 8 }} />
<Text style={styles.retryButtonText}>{t('medications.aiProgress.modal.retry')}</Text>
</LinearGradient>
</View>
)}
</TouchableOpacity>
</View>
</TouchableOpacity>
</TouchableOpacity>
</Modal>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
heroCard: {
marginHorizontal: 20,
marginTop: 24,
borderRadius: 24,
backgroundColor: Colors.light.card,
padding: 16,
shadowColor: Colors.light.text,
shadowOpacity: 0.08,
shadowRadius: 18,
shadowOffset: { width: 0, height: 10 },
},
heroImageWrapper: {
height: 230,
borderRadius: 18,
overflow: 'hidden',
backgroundColor: palette.gray[50],
},
heroImage: {
width: '100%',
height: '100%',
},
heroPlaceholder: {
flex: 1,
backgroundColor: palette.gray[50],
},
// 深色蒙版层,让点阵更清晰可见
overlayMask: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
backgroundColor: 'rgba(15, 23, 42, 0.35)',
},
// 渐变边框效果
gradientBorder: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
borderRadius: 18,
},
// 点阵网格容器
dottedGrid: {
position: 'absolute',
left: 16,
right: 16,
top: 16,
bottom: 16,
justifyContent: 'space-between',
},
dotRow: {
flexDirection: 'row',
justifyContent: 'space-between',
},
// 单个点样式 - 更明亮和更大的发光效果
dot: {
width: 5,
height: 5,
borderRadius: 2.5,
backgroundColor: Colors.light.background,
shadowColor: Colors.light.primary,
shadowOpacity: 0.9,
shadowRadius: 6,
shadowOffset: { width: 0, height: 0 },
},
progressRow: {
height: 8,
backgroundColor: palette.gray[50],
borderRadius: 10,
marginTop: 14,
overflow: 'hidden',
},
progressBar: {
height: '100%',
borderRadius: 10,
backgroundColor: Colors.light.primary,
},
progressText: {
marginTop: 8,
fontSize: 14,
fontWeight: '700',
color: Colors.light.text,
textAlign: 'right',
},
stepList: {
marginTop: 24,
marginHorizontal: 24,
gap: 14,
},
stepRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
},
bullet: {
width: 14,
height: 14,
borderRadius: 7,
backgroundColor: palette.gray[50],
},
bulletActive: {
backgroundColor: Colors.light.primary,
},
bulletDone: {
backgroundColor: Colors.light.success,
},
stepLabel: {
fontSize: 15,
color: Colors.light.textMuted,
},
stepLabelActive: {
color: Colors.light.text,
fontWeight: '700',
},
stepLabelDone: {
color: Colors.light.successDark,
fontWeight: '700',
},
loadingBox: {
marginTop: 30,
alignItems: 'center',
gap: 12,
},
errorText: {
color: Colors.light.danger,
fontSize: 14,
},
// Modal 样式
modalOverlay: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'rgba(15, 23, 42, 0.4)',
},
errorModalContainer: {
width: SCREEN_WIDTH - 48,
backgroundColor: Colors.light.card,
borderRadius: 28,
overflow: 'hidden',
shadowColor: Colors.light.primary,
shadowOpacity: 0.15,
shadowRadius: 24,
shadowOffset: { width: 0, height: 8 },
elevation: 8,
},
errorModalContent: {
padding: 32,
alignItems: 'center',
},
errorIconContainer: {
marginBottom: 24,
},
errorIconCircle: {
width: 96,
height: 96,
borderRadius: 48,
backgroundColor: palette.purple[50],
alignItems: 'center',
justifyContent: 'center',
},
errorModalTitle: {
fontSize: 22,
fontWeight: '700',
color: Colors.light.text,
marginBottom: 16,
textAlign: 'center',
},
errorMessageBox: {
backgroundColor: palette.purple[25],
borderRadius: 16,
padding: 20,
marginBottom: 28,
width: '100%',
borderWidth: 1,
borderColor: palette.purple[200],
},
errorMessageText: {
fontSize: 15,
lineHeight: 24,
color: Colors.light.textSecondary,
textAlign: 'center',
},
retryButton: {
borderRadius: 16,
overflow: 'hidden',
shadowColor: Colors.light.primary,
shadowOpacity: 0.25,
shadowRadius: 12,
shadowOffset: { width: 0, height: 6 },
elevation: 6,
},
retryButtonGradient: {
paddingVertical: 16,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
},
retryButtonText: {
fontSize: 18,
fontWeight: '700',
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

@@ -4,7 +4,6 @@ import { Colors } from '@/constants/Colors';
import { TIMES_PER_DAY_OPTIONS } from '@/constants/Medication';
import { useAppDispatch } from '@/hooks/redux';
import { useColorScheme } from '@/hooks/useColorScheme';
import { medicationNotificationService } from '@/services/medicationNotifications';
import { updateMedicationAction } from '@/store/medicationsSlice';
import type { RepeatPattern } from '@/types/medication';
import { Ionicons } from '@expo/vector-icons';
@@ -211,13 +210,6 @@ export default function EditMedicationFrequencyScreen() {
})
).unwrap();
// 重新安排药品通知
try {
await medicationNotificationService.scheduleMedicationNotifications(updated);
} catch (error) {
console.error('[MEDICATION] 安排药品通知失败:', error);
}
router.back();
} catch (err) {
console.error('更新频率失败', err);

View File

@@ -1,5 +1,6 @@
import { HeaderBar } from '@/components/ui/HeaderBar';
import { useAppSelector } from '@/hooks/redux';
import { useI18n } from '@/hooks/useI18n';
import { useMoodData } from '@/hooks/useMoodData';
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
import { getMoodOptions } from '@/services/moodCheckins';
@@ -61,6 +62,7 @@ const generateCalendarData = (targetDate: Date) => {
};
export default function MoodCalendarScreen() {
const { t } = useI18n();
const safeAreaTop = useSafeAreaTop()
const params = useLocalSearchParams();
const { fetchMoodRecords, fetchMoodHistoryRecords } = useMoodData();
@@ -89,9 +91,30 @@ export default function MoodCalendarScreen() {
return selectLatestMoodRecordByDate(selectedDateString)(state);
});
const moodOptions = getMoodOptions();
const weekDays = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
const monthNames = ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'];
const moodOptions = getMoodOptions(t);
const weekDays = [
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);
@@ -103,7 +126,7 @@ export default function MoodCalendarScreen() {
const endDate = dayjs(targetMonth).endOf('month').format('YYYY-MM-DD');
await fetchMoodHistoryRecordsRef.current({ startDate, endDate });
} catch (error) {
console.error('加载月份心情数据失败:', error);
console.error(t('mood.calendar.errors.loadMonthDataFailed'), error);
}
}, []);
@@ -112,7 +135,7 @@ export default function MoodCalendarScreen() {
try {
await fetchMoodRecordsRef.current(dateString);
} 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}>
<HeaderBar
title="心情日历"
title={t('mood.calendar.title')}
onBack={() => router.back()}
withSafeTop={false}
transparent={true}
@@ -255,7 +278,7 @@ export default function MoodCalendarScreen() {
>
<Text style={styles.navButtonText}></Text>
</TouchableOpacity>
<Text style={styles.monthTitle}>{year}{monthNames[month - 1]}</Text>
<Text style={styles.monthTitle}>{year} {monthNames[month - 1]}</Text>
<TouchableOpacity
style={styles.navButton}
onPress={goToNextMonth}
@@ -315,13 +338,13 @@ export default function MoodCalendarScreen() {
<View style={styles.selectedDateSection}>
<View style={styles.selectedDateHeader}>
<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>
<TouchableOpacity
style={styles.addMoodButton}
onPress={openMoodEdit}
>
<Text style={styles.addMoodButtonText}></Text>
<Text style={styles.addMoodButtonText}>{t('mood.calendar.selectedDate.record')}</Text>
</TouchableOpacity>
</View>
@@ -343,7 +366,7 @@ export default function MoodCalendarScreen() {
<Text style={styles.recordMood}>
{moodOptions.find(m => m.type === selectedDateMood.moodType)?.label}
</Text>
<Text style={styles.recordIntensity}>: {selectedDateMood.intensity}</Text>
<Text style={styles.recordIntensity}>{t('mood.calendar.selectedDate.intensity')}: {selectedDateMood.intensity}</Text>
{selectedDateMood.description && (
<Text style={styles.recordDescription}>{selectedDateMood.description}</Text>
)}
@@ -355,14 +378,14 @@ export default function MoodCalendarScreen() {
</TouchableOpacity>
) : (
<View style={styles.emptyRecord}>
<Text style={styles.emptyRecordText}></Text>
<Text style={styles.emptyRecordSubtext}>"记录"</Text>
<Text style={styles.emptyRecordText}>{t('mood.calendar.selectedDate.noRecord')}</Text>
<Text style={styles.emptyRecordSubtext}>{t('mood.calendar.selectedDate.noRecordHint')}</Text>
</View>
)
) : (
<View style={styles.emptyRecord}>
<Text style={styles.emptyRecordText}></Text>
<Text style={styles.emptyRecordSubtext}>"记录"</Text>
<Text style={styles.emptyRecordText}>{t('mood.calendar.selectedDate.noDateSelected')}</Text>
<Text style={styles.emptyRecordSubtext}>{t('mood.calendar.selectedDate.noDateSelectedHint')}</Text>
</View>
)}
</View>

View File

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

View File

@@ -1,40 +1,49 @@
import { ThemedText } from '@/components/ThemedText';
import { HeaderBar } from '@/components/ui/HeaderBar';
import { palette } from '@/constants/Colors';
import { useI18n } from '@/hooks/useI18n';
import { useNotifications } from '@/hooks/useNotifications';
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
import {
getMedicationReminderEnabled,
getMoodReminderEnabled,
getNotificationEnabled,
getNutritionReminderEnabled,
setMedicationReminderEnabled,
setNotificationEnabled
setMoodReminderEnabled,
setNotificationEnabled,
setNutritionReminderEnabled
} from '@/utils/userPreferences';
import { Ionicons } from '@expo/vector-icons';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { LinearGradient } from 'expo-linear-gradient';
import { router, useFocusEffect } from 'expo-router';
import React, { useCallback, useState } from 'react';
import { Alert, Linking, ScrollView, StatusBar, StyleSheet, Switch, Text, TouchableOpacity, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Alert, Linking, ScrollView, StatusBar, StyleSheet, Switch, Text, View } from 'react-native';
export default function NotificationSettingsScreen() {
const insets = useSafeAreaInsets();
const safeAreaTop = useSafeAreaTop(60);
const { t } = useI18n();
const { requestPermission, sendNotification } = useNotifications();
const isLgAvailable = isLiquidGlassAvailable();
// 通知设置状态
const [notificationEnabled, setNotificationEnabledState] = useState(false);
const [medicationReminderEnabled, setMedicationReminderEnabledState] = useState(false);
const [nutritionReminderEnabled, setNutritionReminderEnabledState] = useState(false);
const [moodReminderEnabled, setMoodReminderEnabledState] = useState(false);
const [isLoading, setIsLoading] = useState(true);
// 加载通知设置
const loadNotificationSettings = useCallback(async () => {
try {
const [notification, medicationReminder] = await Promise.all([
const [notification, medicationReminder, nutritionReminder, moodReminder] = await Promise.all([
getNotificationEnabled(),
getMedicationReminderEnabled(),
getNutritionReminderEnabled(),
getMoodReminderEnabled(),
]);
setNotificationEnabledState(notification);
setMedicationReminderEnabledState(medicationReminder);
setNutritionReminderEnabledState(nutritionReminder);
setMoodReminderEnabledState(moodReminder);
} catch (error) {
console.error('Failed to load notification settings:', error);
} finally {
@@ -87,9 +96,13 @@ export default function NotificationSettingsScreen() {
// 关闭推送,保存用户偏好设置
await setNotificationEnabled(false);
setNotificationEnabledState(false);
// 关闭总开关时,也关闭药品提醒
// 关闭总开关时,也关闭所有提醒
await setMedicationReminderEnabled(false);
setMedicationReminderEnabledState(false);
await setNutritionReminderEnabled(false);
setNutritionReminderEnabledState(false);
await setMoodReminderEnabled(false);
setMoodReminderEnabledState(false);
} catch (error) {
console.error('Failed to disable push notifications:', error);
Alert.alert(t('notificationSettings.alerts.error.title'), t('notificationSettings.alerts.error.saveFailed'));
@@ -118,57 +131,83 @@ export default function NotificationSettingsScreen() {
}
};
// 返回按钮
const BackButton = () => (
<TouchableOpacity
onPress={() => router.back()}
style={styles.backButton}
activeOpacity={0.7}
>
{isLgAvailable ? (
<GlassView
style={styles.glassButton}
glassEffectStyle="clear"
tintColor="rgba(255, 255, 255, 0.3)"
isInteractive={true}
>
<Ionicons name="chevron-back" size={24} color="#333" />
</GlassView>
) : (
<View style={[styles.glassButton, styles.fallbackButton]}>
<Ionicons name="chevron-back" size={24} color="#333" />
// 处理营养通知提醒开关变化
const handleNutritionReminderToggle = async (value: boolean) => {
try {
await setNutritionReminderEnabled(value);
setNutritionReminderEnabledState(value);
if (value) {
// 发送测试通知
await sendNotification({
title: t('notificationSettings.alerts.nutritionReminderEnabled.title'),
body: t('notificationSettings.alerts.nutritionReminderEnabled.body'),
sound: true,
priority: 'high',
});
}
} catch (error) {
console.error('Failed to set nutrition reminder:', error);
Alert.alert(t('notificationSettings.alerts.error.title'), t('notificationSettings.alerts.error.nutritionReminderFailed'));
}
};
// 处理心情通知提醒开关变化
const handleMoodReminderToggle = async (value: boolean) => {
try {
await setMoodReminderEnabled(value);
setMoodReminderEnabledState(value);
if (value) {
// 发送测试通知
await sendNotification({
title: t('notificationSettings.alerts.moodReminderEnabled.title'),
body: t('notificationSettings.alerts.moodReminderEnabled.body'),
sound: true,
priority: 'high',
});
}
} catch (error) {
console.error('Failed to set mood reminder:', error);
Alert.alert(t('notificationSettings.alerts.error.title'), t('notificationSettings.alerts.error.moodReminderFailed'));
}
};
// 渲染设置项
const renderSettingItem = (
icon: keyof typeof Ionicons.glyphMap,
title: string,
description: string,
value: boolean,
onValueChange: (value: boolean) => void,
disabled: boolean = false,
showSeparator: boolean = true
) => (
<View>
<View style={styles.settingItem}>
<View style={styles.itemInfo}>
<View style={[styles.iconContainer, disabled && styles.iconContainerDisabled]}>
<Ionicons name={icon} size={24} color={disabled ? '#C7C7CC' : '#9370DB'} />
</View>
<View style={styles.textContainer}>
<Text style={[styles.itemTitle, disabled && styles.itemTitleDisabled]}>{title}</Text>
<Text style={styles.itemDescription} numberOfLines={2}>{description}</Text>
</View>
</View>
<Switch
value={value}
onValueChange={onValueChange}
disabled={disabled}
trackColor={{ false: '#E5E5E5', true: '#9370DB' }}
thumbColor="#FFFFFF"
style={styles.switch}
/>
</View>
{showSeparator && (
<View style={styles.separatorContainer}>
<View style={styles.separator} />
</View>
)}
</TouchableOpacity>
);
// 开关项组件
const SwitchItem = ({
title,
description,
value,
onValueChange,
disabled = false
}: {
title: string;
description: string;
value: boolean;
onValueChange: (value: boolean) => void;
disabled?: boolean;
}) => (
<View style={styles.switchItem}>
<View style={styles.switchItemLeft}>
<Text style={styles.switchItemTitle}>{title}</Text>
<Text style={styles.switchItemDescription}>{description}</Text>
</View>
<Switch
value={value}
onValueChange={onValueChange}
disabled={disabled}
trackColor={{ false: '#E5E5E5', true: '#9370DB' }}
thumbColor="#FFFFFF"
style={styles.switch}
/>
</View>
);
@@ -177,10 +216,10 @@ export default function NotificationSettingsScreen() {
<View style={styles.container}>
<StatusBar barStyle="dark-content" backgroundColor="transparent" translucent />
<LinearGradient
colors={['#f5e5fbff', '#e5fcfeff', '#eefdffff', '#e6f6fcff']}
colors={[palette.purple[100], '#F5F5F5']}
start={{ x: 1, y: 0 }}
end={{ x: 0.3, y: 0.4 }}
style={styles.gradientBackground}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
/>
<View style={styles.loadingContainer}>
<Text style={styles.loadingText}>{t('notificationSettings.loading')}</Text>
@@ -193,69 +232,82 @@ export default function NotificationSettingsScreen() {
<View style={styles.container}>
<StatusBar barStyle="dark-content" backgroundColor="transparent" translucent />
{/* 背景渐变 */}
<LinearGradient
colors={['#f5e5fbff', '#e5fcfeff', '#eefdffff', '#e6f6fcff']}
colors={[palette.purple[100], '#F5F5F5']}
start={{ x: 1, y: 0 }}
end={{ x: 0.3, y: 0.4 }}
style={styles.gradientBackground}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
/>
{/* 装饰性圆圈 */}
<View style={styles.decorativeCircle1} />
<View style={styles.decorativeCircle2} />
<HeaderBar
title={t('notificationSettings.title')}
onBack={() => router.back()}
/>
<ScrollView
style={styles.scrollView}
contentContainerStyle={{
paddingTop: insets.top + 20,
paddingBottom: insets.bottom + 20,
paddingHorizontal: 16,
}}
contentContainerStyle={[
styles.scrollContent,
{ paddingTop: safeAreaTop }
]}
showsVerticalScrollIndicator={false}
>
{/* 头部 */}
<View style={styles.header}>
<BackButton />
<ThemedText style={styles.title}>{t('notificationSettings.title')}</ThemedText>
</View>
{/* 通知设置部分 */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>{t('notificationSettings.sections.notifications')}</Text>
<View style={styles.card}>
<SwitchItem
title={t('notificationSettings.items.pushNotifications.title')}
description={t('notificationSettings.items.pushNotifications.description')}
value={notificationEnabled}
onValueChange={handleNotificationToggle}
/>
{/* 顶部说明卡片 */}
<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('notificationSettings.description.text')}
</Text>
</View>
</View>
</View>
{/* 药品提醒部分 */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>{t('notificationSettings.sections.medicationReminder')}</Text>
<View style={styles.card}>
<SwitchItem
title={t('notificationSettings.items.medicationReminder.title')}
description={t('notificationSettings.items.medicationReminder.description')}
value={medicationReminderEnabled}
onValueChange={handleMedicationReminderToggle}
disabled={!notificationEnabled}
/>
</View>
{/* 设置项列表 */}
<View style={styles.sectionContainer}>
{renderSettingItem(
'notifications-outline',
t('notificationSettings.items.pushNotifications.title'),
t('notificationSettings.items.pushNotifications.description'),
notificationEnabled,
handleNotificationToggle,
false,
true
)}
{renderSettingItem(
'medkit-outline',
t('notificationSettings.items.medicationReminder.title'),
t('notificationSettings.items.medicationReminder.description'),
medicationReminderEnabled,
handleMedicationReminderToggle,
!notificationEnabled,
true
)}
{renderSettingItem(
'restaurant-outline',
t('notificationSettings.items.nutritionReminder.title'),
t('notificationSettings.items.nutritionReminder.description'),
nutritionReminderEnabled,
handleNutritionReminderToggle,
!notificationEnabled,
true
)}
{renderSettingItem(
'happy-outline',
t('notificationSettings.items.moodReminder.title'),
t('notificationSettings.items.moodReminder.description'),
moodReminderEnabled,
handleMoodReminderToggle,
!notificationEnabled,
false
)}
</View>
{/* 说明部分 */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>{t('notificationSettings.sections.description')}</Text>
<View style={styles.card}>
<Text style={styles.description}>
{t('notificationSettings.description.text')}
</Text>
</View>
</View>
</ScrollView>
</View>
);
@@ -264,37 +316,22 @@ export default function NotificationSettingsScreen() {
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#F5F5F5',
},
gradientBackground: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
},
decorativeCircle1: {
position: 'absolute',
top: 40,
right: 20,
width: 60,
height: 60,
borderRadius: 30,
backgroundColor: '#0EA5E9',
opacity: 0.1,
},
decorativeCircle2: {
position: 'absolute',
bottom: -15,
left: -15,
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: '#0EA5E9',
opacity: 0.05,
height: '60%',
},
scrollView: {
flex: 1,
},
scrollContent: {
paddingHorizontal: 16,
paddingBottom: 40,
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
@@ -304,82 +341,95 @@ const styles = StyleSheet.create({
fontSize: 16,
color: '#666',
},
header: {
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',
marginBottom: 24,
gap: 8,
},
backButton: {
marginRight: 16,
descriptionText: {
flex: 1,
fontSize: 13,
color: '#2C3E50',
lineHeight: 18,
},
glassButton: {
sectionContainer: {
backgroundColor: '#FFFFFF',
borderRadius: 20,
marginBottom: 20,
overflow: 'hidden',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.03,
shadowRadius: 8,
elevation: 2,
},
settingItem: {
flexDirection: 'row',
alignItems: 'center',
padding: 16,
paddingVertical: 16,
},
itemInfo: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
iconContainer: {
width: 40,
height: 40,
borderRadius: 20,
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
},
fallbackButton: {
backgroundColor: 'rgba(255, 255, 255, 0.9)',
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.3)',
},
title: {
fontSize: 28,
fontWeight: 'bold',
color: '#2C3E50',
},
section: {
marginBottom: 24,
},
sectionTitle: {
fontSize: 18,
fontWeight: '600',
color: '#2C3E50',
marginBottom: 12,
paddingHorizontal: 4,
},
card: {
backgroundColor: '#FFFFFF',
backgroundColor: 'rgba(147, 112, 219, 0.05)',
borderRadius: 12,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 4,
elevation: 2,
overflow: 'hidden',
},
switchItem: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 16,
paddingHorizontal: 16,
iconContainerDisabled: {
backgroundColor: '#F5F5F5',
},
switchItemLeft: {
textContainer: {
flex: 1,
marginRight: 16,
marginRight: 8,
},
switchItemTitle: {
itemTitle: {
fontSize: 16,
fontWeight: '500',
color: '#2C3E50',
marginBottom: 4,
},
switchItemDescription: {
fontSize: 14,
itemTitleDisabled: {
color: '#999',
},
itemDescription: {
fontSize: 12,
color: '#6C757D',
lineHeight: 20,
lineHeight: 16,
},
switch: {
transform: [{ scaleX: 0.8 }, { scaleY: 0.8 }],
transform: [{ scaleX: 0.9 }, { scaleY: 0.9 }],
},
description: {
fontSize: 14,
color: '#6C757D',
lineHeight: 22,
paddingVertical: 16,
paddingHorizontal: 16,
separatorContainer: {
paddingLeft: 68, // 40(icon) + 12(gap) + 16(padding)
paddingRight: 16,
},
separator: {
height: 1,
backgroundColor: '#F0F0F0',
},
});

View File

@@ -5,7 +5,9 @@ import { NutritionRecordCard } from '@/components/NutritionRecordCard';
import { HeaderBar } from '@/components/ui/HeaderBar';
import { Colors } from '@/constants/Colors';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useAuthGuard } from '@/hooks/useAuthGuard';
import { useColorScheme } from '@/hooks/useColorScheme';
import { useI18n } from '@/hooks/useI18n';
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
import { DietRecord } from '@/services/dietRecords';
import { type FoodRecognitionResponse } from '@/services/foodRecognition';
@@ -19,16 +21,20 @@ import {
selectNutritionRecordsByDate,
selectNutritionSummaryByDate
} from '@/store/nutritionSlice';
import { getMonthDaysZh, getMonthTitleZh, getTodayIndexInMonth } from '@/utils/date';
import { getTodayIndexInMonth } from '@/utils/date';
import { fetchBasalEnergyBurned } from '@/utils/health';
import { Ionicons } from '@expo/vector-icons';
import { useFocusEffect } from '@react-navigation/native';
import dayjs from 'dayjs';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient';
import { router } from 'expo-router';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import React, { useCallback, useEffect, useState } from 'react';
import {
FlatList,
RefreshControl,
StatusBar,
StyleSheet,
Text,
TouchableOpacity,
@@ -38,24 +44,21 @@ import {
type ViewMode = 'daily' | 'all';
export default function NutritionRecordsScreen() {
const safeAreaTop = useSafeAreaTop()
const { t } = useI18n();
const safeAreaTop = useSafeAreaTop();
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme];
const dispatch = useAppDispatch();
const isGlassAvailable = isLiquidGlassAvailable();
// 日期相关状态 - 使用与统计页面相同的日期逻辑
const days = getMonthDaysZh();
const { isLoggedIn } = useAuthGuard();
// 日期相关状态
const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth());
const monthTitle = getMonthTitleZh();
// 直接使用 state 管理当前选中日期,而不是从 days 数组派生,以支持 DateSelector 内部月份切换
const [currentSelectedDate, setCurrentSelectedDate] = useState<Date>(new Date());
// 获取当前选中日期 - 使用 useMemo 避免每次渲染都创建新对象
const currentSelectedDate = useMemo(() => {
return days[selectedIndex]?.date?.toDate() ?? new Date();
}, [selectedIndex, days]);
const currentSelectedDateString = useMemo(() => {
return dayjs(currentSelectedDate).format('YYYY-MM-DD');
}, [currentSelectedDate]);
const currentSelectedDateString = dayjs(currentSelectedDate).format('YYYY-MM-DD');
// 从 Redux 获取数据
const healthData = useAppSelector(selectHealthDataByDate(currentSelectedDateString));
@@ -86,11 +89,11 @@ export default function NutritionRecordsScreen() {
const displayRecords = viewMode === 'daily' ? nutritionRecords : allRecords;
const loading = viewMode === 'daily' ? nutritionLoading.records : allRecordsLoading;
// 页面聚焦时自动刷新数据
useFocusEffect(
useCallback(() => {
console.log('营养记录页面聚焦,刷新数据...');
if (!isLoggedIn) return;
if (viewMode === 'daily') {
dispatch(fetchDailyNutritionData(currentSelectedDate));
} else {
@@ -119,7 +122,7 @@ export default function NutritionRecordsScreen() {
loadAllRecords();
}
}, [viewMode, currentSelectedDateString, dispatch])
}, [viewMode, currentSelectedDateString, dispatch, isLoggedIn])
);
// 当选中日期或视图模式变化时重新加载数据
@@ -323,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 hour = new Date().getHours();
@@ -411,68 +349,160 @@ export default function NutritionRecordsScreen() {
// 渲染右侧添加按钮
const renderRightButton = () => (
<TouchableOpacity
style={[styles.addButton, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}
onPress={handleAddFood}
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>
);
return (
<View style={[styles.container, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}>
<HeaderBar
title="营养记录"
onBack={() => router.back()}
right={renderRightButton()}
/>
<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}
const renderEmptyState = () => (
<View style={styles.emptySimpleContainer}>
<Image
source={require('@/assets/images/icons/icon-yingyang.png')}
style={styles.emptySimpleImage}
contentFit="contain"
/>
<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>
);
{(
<FlatList
data={displayRecords}
renderItem={({ item, index }) => renderRecord({ item, index })}
keyExtractor={(item) => item.id.toString()}
contentContainerStyle={[
styles.listContainer,
{ paddingBottom: 40, paddingTop: 16 }
]}
showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
tintColor={colorTokens.primary}
colors={[colorTokens.primary]}
/>
}
ListEmptyComponent={renderEmptyState}
ListFooterComponent={renderFooter}
onEndReached={viewMode === 'all' ? loadMoreRecords : undefined}
onEndReachedThreshold={0.1}
const renderRecord = ({ item }: { item: DietRecord }) => (
<NutritionRecordCard
record={item}
onPress={() => handleRecordPress(item)}
onDelete={() => handleDeleteRecord(item.id)}
/>
);
const renderFooter = () => {
if (!hasMoreData) {
if (displayRecords.length === 0) return null;
return (
<View style={styles.footerContainer}>
<Text style={[styles.footerText, { color: colorTokens.textSecondary }]}>
{t('nutritionRecords.footer.end')}
</Text>
</View>
);
}
if (viewMode === 'all' && displayRecords.length > 0) {
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>
);
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
@@ -488,130 +518,105 @@ const styles = StyleSheet.create({
container: {
flex: 1,
},
viewModeContainer: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 12,
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',
topGradient: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
height: 320,
},
listContainer: {
paddingBottom: 100, // 留出底部空间防止遮挡
},
headerContent: {
marginBottom: 16,
},
dateSelectorContainer: {
paddingHorizontal: 16,
paddingTop: 8,
marginBottom: 16,
},
emptyContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingVertical: 60,
paddingHorizontal: 16,
chartWrapper: {
marginBottom: 24,
shadowColor: 'rgba(30, 41, 59, 0.05)',
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.1,
shadowRadius: 12,
elevation: 4,
},
emptyContent: {
alignItems: 'center',
maxWidth: 320,
listTitleContainer: {
flexDirection: 'row',
alignItems: 'baseline',
paddingHorizontal: 24,
marginBottom: 12,
gap: 8,
},
emptyTitle: {
listTitle: {
fontSize: 18,
fontWeight: '700',
marginTop: 16,
marginBottom: 8,
textAlign: 'center',
color: '#1c1f3a',
fontFamily: 'AliBold',
},
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,
fontWeight: '500',
textAlign: 'center',
lineHeight: 20,
color: '#94A3B8',
fontFamily: 'AliRegular',
marginBottom: 8,
},
emptyActionText: {
fontSize: 14,
fontWeight: '600',
fontFamily: 'AliBold',
},
footerContainer: {
paddingVertical: 20,
paddingVertical: 24,
alignItems: 'center',
},
footerText: {
fontSize: 14,
fontSize: 12,
fontWeight: '500',
opacity: 0.6,
fontFamily: 'AliRegular',
},
loadMoreButton: {
paddingVertical: 16,
alignItems: 'center',
},
loadMoreText: {
fontSize: 16,
fontSize: 14,
fontWeight: '600',
fontFamily: 'AliBold',
},
});

View File

@@ -20,6 +20,7 @@ import React, { useEffect, useMemo, useState } from 'react';
import {
ActivityIndicator,
Alert,
Keyboard,
KeyboardAvoidingView,
Modal,
Platform,
@@ -31,6 +32,7 @@ import {
TouchableOpacity,
View
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
interface UserProfile {
@@ -80,8 +82,9 @@ export default function EditProfileScreen() {
const [pickerDate, setPickerDate] = useState<Date>(new Date());
const [editingField, setEditingField] = useState<string | null>(null);
const [tempValue, setTempValue] = useState<string>('');
// 输入框字符串
// 键盘高度状态
const [keyboardHeight, setKeyboardHeight] = useState(0);
// 从本地存储加载(身高/体重等本地字段)
const loadLocalProfile = async () => {
@@ -128,6 +131,34 @@ export default function EditProfileScreen() {
loadLocalProfile();
}, []);
// 键盘事件监听器 - 只在名称和体重输入框显示时监听
useEffect(() => {
// 只有在编辑名称或体重字段时才需要监听键盘(这两个字段使用 TextInput
const needsKeyboardHandling = editingField === 'name' || editingField === 'weight';
if (!needsKeyboardHandling) {
setKeyboardHeight(0);
return;
}
const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';
const handleShow = (event: any) => {
const height = event?.endCoordinates?.height ?? 0;
setKeyboardHeight(height);
};
const handleHide = () => setKeyboardHeight(0);
const showSub = Keyboard.addListener(showEvent, handleShow);
const hideSub = Keyboard.addListener(hideEvent, handleHide);
return () => {
showSub.remove();
hideSub.remove();
};
}, [editingField]);
// 获取最大心率数据
useEffect(() => {
const loadMaximumHeartRate = async () => {
@@ -439,6 +470,7 @@ export default function EditProfileScreen() {
field={editingField}
value={tempValue}
profile={profile}
keyboardHeight={keyboardHeight}
onClose={() => {
setEditingField(null);
setTempValue('');
@@ -557,11 +589,12 @@ function ProfileCard({ icon, iconUri, iconColor, title, value, onPress, disabled
);
}
function EditModal({ visible, field, value, profile, onClose, onSave, colors, textColor, placeholderColor, t }: {
function EditModal({ visible, field, value, profile, keyboardHeight, onClose, onSave, colors, textColor, placeholderColor, t }: {
visible: boolean;
field: string | null;
value: string;
profile: UserProfile;
keyboardHeight: number;
onClose: () => void;
onSave: (field: string, value: string) => void;
colors: any;
@@ -569,6 +602,7 @@ function EditModal({ visible, field, value, profile, onClose, onSave, colors, te
placeholderColor: string;
t: (key: string) => string;
}) {
const insets = useSafeAreaInsets();
const [inputValue, setInputValue] = useState(value);
const [selectedGender, setSelectedGender] = useState(profile.gender || 'female');
const [selectedActivity, setSelectedActivity] = useState(profile.activityLevel || 1);
@@ -685,7 +719,10 @@ function EditModal({ visible, field, value, profile, onClose, onSave, colors, te
return (
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
<Pressable style={styles.modalBackdrop} onPress={onClose} />
<View style={styles.editModalSheet}>
<View style={[
styles.editModalSheet,
{ paddingBottom: Math.max(keyboardHeight, insets.bottom) + 12 }
]}>
<View style={styles.modalHandle} />
{renderContent()}
<View style={styles.modalButtons}>

View File

@@ -0,0 +1,305 @@
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
import { useVipService } from '@/hooks/useVipService';
import {
resetToDefault,
selectTabBarConfigs,
toggleTabEnabled,
type TabConfig,
} from '@/store/tabBarConfigSlice';
import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import { useRouter } from 'expo-router';
import React, { useCallback, useEffect, useState } from 'react';
import {
Alert,
ScrollView,
StyleSheet,
Switch,
Text,
TouchableOpacity,
View
} from 'react-native';
import { MembershipModal } from '@/components/model/MembershipModal';
import { HeaderBar } from '@/components/ui/HeaderBar';
import { IconSymbol } from '@/components/ui/IconSymbol';
import { palette } from '@/constants/Colors';
import { useI18n } from '@/hooks/useI18n';
export default function TabBarConfigScreen() {
const { t } = useI18n();
const router = useRouter();
const dispatch = useAppDispatch();
const safeAreaTop = useSafeAreaTop(60);
const configs = useAppSelector(selectTabBarConfigs);
const { isVip } = useVipService();
const [showMembershipModal, setShowMembershipModal] = useState(false);
// 处理开关切换
const handleToggle = useCallback(
(tabId: string) => {
// 直接检查用户是否是 VIP底部栏配置不是权益类功能而是基础功能
if (isVip) {
// VIP 用户可以正常切换
dispatch(toggleTabEnabled(tabId));
} else {
// 非 VIP 用户显示购买弹窗
setShowMembershipModal(true);
}
},
[dispatch, isVip]
);
// 页面加载时检查 VIP 状态
useEffect(() => {
if (!isVip) {
// 非 VIP 用户进入页面时立即显示购买弹窗
setShowMembershipModal(true);
}
}, [isVip]);
// 购买成功回调
const handlePurchaseSuccess = useCallback(() => {
// 购买成功后可以执行一些操作,比如刷新用户信息
console.log('会员购买成功');
}, []);
// 恢复默认设置
const handleReset = useCallback(() => {
Alert.alert(
t('personal.tabBarConfig.resetConfirm.title'),
t('personal.tabBarConfig.resetConfirm.message'),
[
{
text: t('personal.tabBarConfig.resetConfirm.cancel'),
style: 'cancel',
},
{
text: t('personal.tabBarConfig.resetConfirm.confirm'),
style: 'destructive',
onPress: () => {
dispatch(resetToDefault());
Alert.alert('', t('personal.tabBarConfig.resetSuccess'));
},
},
]
);
}, [dispatch, t]);
// 渲染单个 Tab 行
const renderTabRow = useCallback(
(item: TabConfig, index: number, total: number) => {
return (
<View key={item.id}>
<View style={styles.tabItem}>
{/* Tab 图标和名称 */}
<View style={styles.tabInfo}>
<View style={styles.iconContainer}>
<IconSymbol name={item.icon as any} size={24} color="#9370DB" />
</View>
<View style={styles.tabTextContainer}>
<Text style={styles.tabTitle}>{t(item.titleKey)}</Text>
{!item.canBeDisabled && (
<Text style={styles.tabSubtitle}>
{t('personal.tabBarConfig.cannotDisable')}
</Text>
)}
{item.canBeDisabled && !isVip && (
<Text style={styles.vipSubtitle}>
{t('personal.tabBarConfig.vipOnly')}
</Text>
)}
</View>
</View>
{/* 开关 */}
<Switch
value={item.enabled}
onValueChange={() => handleToggle(item.id)}
disabled={!item.canBeDisabled || !isVip}
trackColor={{ false: '#E5E5E5', true: '#9370DB' }}
thumbColor="#FFFFFF"
style={styles.switch}
/>
</View>
{/* 分割线 - 最后一项不显示 */}
{index < total - 1 && (
<View style={styles.separatorContainer}>
<View style={styles.separator} />
</View>
)}
</View>
);
},
[handleToggle, t]
);
return (
<View style={styles.container}>
<LinearGradient
colors={[palette.purple[100], '#F5F5F5']}
start={{ x: 1, y: 0 }}
end={{ x: 0.3, y: 0.4 }}
style={styles.gradientBackground}
/>
{/* 顶部导航栏 */}
<HeaderBar
title={t('personal.tabBarConfig.title')}
onBack={() => router.back()}
right={
<TouchableOpacity onPress={handleReset} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}>
<Text style={styles.headerRightButton}>
{t('personal.tabBarConfig.resetButton')}
</Text>
</TouchableOpacity>
}
/>
{/* 主内容区 */}
<ScrollView
style={styles.content}
contentContainerStyle={[styles.scrollContent, { paddingTop: safeAreaTop }]} // 增加顶部间距,因为 HeaderBar 现在是 absolute 的
showsVerticalScrollIndicator={false}
>
{/* 说明区域 */}
<View style={styles.headerSection}>
<Text style={styles.subtitle}>{t('personal.tabBarConfig.subtitle')}</Text>
<View style={styles.descriptionCard}>
<View style={styles.hintRow}>
<Ionicons name="information-circle-outline" size={20} color="#9370DB" />
<Text style={styles.descriptionText}>
{t('personal.tabBarConfig.description')}
</Text>
</View>
</View>
</View>
{/* Tab 列表 - 聚合在一个卡片中 */}
<View style={styles.sectionContainer}>
{configs.map((item, index) => renderTabRow(item, index, configs.length))}
</View>
</ScrollView>
{/* 会员购买弹窗 */}
<MembershipModal
visible={showMembershipModal}
onClose={() => setShowMembershipModal(false)}
onPurchaseSuccess={handlePurchaseSuccess}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#F5F5F5',
},
gradientBackground: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
height: '60%', // 渐变覆盖上半部分即可
},
content: {
flex: 1,
},
scrollContent: {
paddingHorizontal: 16,
paddingBottom: 40,
},
headerSection: {
marginBottom: 16,
},
subtitle: {
fontSize: 14,
color: '#6C757D',
marginBottom: 12,
},
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,
},
sectionContainer: {
backgroundColor: '#FFFFFF',
borderRadius: 20,
marginBottom: 20,
overflow: 'hidden',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.03,
shadowRadius: 8,
elevation: 2,
},
tabItem: {
flexDirection: 'row',
alignItems: 'center',
padding: 16,
paddingVertical: 16,
},
separatorContainer: {
paddingLeft: 68, // 40(icon) + 12(gap) + 16(padding)
paddingRight: 16,
},
separator: {
height: 1,
backgroundColor: '#F0F0F0',
},
tabInfo: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
iconContainer: {
width: 40,
height: 40,
alignItems: 'center',
justifyContent: 'center',
},
tabTextContainer: {
flex: 1,
},
tabTitle: {
fontSize: 16,
fontWeight: '500',
color: '#2C3E50',
marginBottom: 2,
},
tabSubtitle: {
fontSize: 12,
color: '#9370DB',
},
vipSubtitle: {
fontSize: 12,
color: '#FF6B6B',
},
switch: {
transform: [{ scaleX: 0.9 }, { scaleY: 0.9 }],
},
headerRightButton: {
fontSize: 15,
fontWeight: '600',
color: '#9370DB', // 使用主色调
},
});

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -3,6 +3,7 @@ import { useColorScheme } from '@/hooks/useColorScheme';
import { useWaterDataByDate } from '@/hooks/useWaterData';
import { getQuickWaterAmount } from '@/utils/userPreferences';
import { Ionicons } from '@expo/vector-icons';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient';
import { router, useLocalSearchParams } from 'expo-router';
@@ -20,6 +21,7 @@ import {
import { Swipeable } from 'react-native-gesture-handler';
import { HeaderBar } from '@/components/ui/HeaderBar';
import { useI18n } from '@/hooks/useI18n';
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
import dayjs from 'dayjs';
@@ -28,6 +30,7 @@ interface WaterDetailProps {
}
const WaterDetail: React.FC<WaterDetailProps> = () => {
const { t } = useI18n();
const safeAreaTop = useSafeAreaTop()
const { selectedDate } = useLocalSearchParams<{ selectedDate?: string }>();
@@ -37,22 +40,14 @@ const WaterDetail: React.FC<WaterDetailProps> = () => {
const [dailyGoal, setDailyGoal] = useState<string>('2000');
const [quickAddAmount, setQuickAddAmount] = useState<string>('250');
// Remove modal states as they are now in separate settings page
// 使用新的 hook 来处理指定日期的饮水数据
const { waterRecords, dailyWaterGoal, updateWaterGoal, removeWaterRecord } = useWaterDataByDate(selectedDate);
// 处理设置按钮点击 - 跳转到设置页面
const handleSettingsPress = () => {
router.push('/water/settings');
};
// Remove all modal-related functions as they are now in separate settings page
// 删除饮水记录
const handleDeleteRecord = async (recordId: string) => {
await removeWaterRecord(recordId);
@@ -70,13 +65,17 @@ const WaterDetail: React.FC<WaterDetailProps> = () => {
setDailyGoal(dailyWaterGoal.toString());
}
} catch (error) {
console.error('加载用户偏好设置失败:', error);
console.error(t('waterDetail.loadingUserPreferences'), error);
}
};
loadUserPreferences();
}, [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 swipeableRef = React.useRef<Swipeable>(null);
@@ -84,15 +83,15 @@ const WaterDetail: React.FC<WaterDetailProps> = () => {
// 处理删除操作
const handleDelete = () => {
Alert.alert(
'确认删除',
'确定要删除这条饮水记录吗?此操作无法撤销。',
t('waterDetail.deleteConfirm.title'),
t('waterDetail.deleteConfirm.message'),
[
{
text: '取消',
text: t('waterDetail.deleteConfirm.cancel'),
style: 'cancel',
},
{
text: '删除',
text: t('waterDetail.deleteConfirm.confirm'),
style: 'destructive',
onPress: () => {
onDelete();
@@ -112,7 +111,6 @@ const WaterDetail: React.FC<WaterDetailProps> = () => {
activeOpacity={0.8}
>
<Ionicons name="trash" size={20} color="#FFFFFF" />
<Text style={styles.deleteSwipeButtonText}></Text>
</TouchableOpacity>
);
};
@@ -125,29 +123,29 @@ const WaterDetail: React.FC<WaterDetailProps> = () => {
rightThreshold={40}
overshootRight={false}
>
<View style={[styles.recordCard, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}>
<View style={styles.recordCard}>
<View style={styles.recordMainContent}>
<View style={[styles.recordIconContainer, { backgroundColor: colorTokens.background }]}>
<View style={styles.recordIconContainer}>
<Image
source={require('@/assets/images/icons/IconGlass.png')}
style={styles.recordIcon}
/>
</View>
<View style={styles.recordInfo}>
<Text style={[styles.recordLabel, { color: colorTokens.text }]}></Text>
<Text style={styles.recordLabel}>{t('waterDetail.water')}</Text>
<View style={styles.recordTimeContainer}>
<Ionicons name="time-outline" size={14} color={colorTokens.textSecondary} />
<Text style={[styles.recordTimeText, { color: colorTokens.textSecondary }]}>
<Ionicons name="time-outline" size={14} color="#6f7ba7" />
<Text style={styles.recordTimeText}>
{dayjs(record.recordedAt || record.createdAt).format('HH:mm')}
</Text>
</View>
</View>
<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>
{record.note && (
<Text style={[styles.recordNote, { color: colorTokens.textSecondary }]}>{record.note}</Text>
<Text style={styles.recordNote}>{record.note}</Text>
)}
</View>
</Swipeable>
@@ -157,32 +155,47 @@ const WaterDetail: React.FC<WaterDetailProps> = () => {
return (
<View style={styles.container}>
{/* 背景渐变 */}
{/* 背景 */}
<LinearGradient
colors={['#f5e5fbff', '#e5fcfeff', '#eefdffff', '#e6f6fcff']}
style={styles.gradientBackground}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
colors={['#f3f4fb', '#f3f4fb']}
style={StyleSheet.absoluteFillObject}
/>
{/* 顶部装饰性渐变 - 模仿挑战页面的柔和背景感 */}
<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
title="饮水详情"
onBack={() => {
// 这里会通过路由自动处理返回
router.back();
}}
title={t('waterDetail.title')}
onBack={() => router.back()}
right={
<TouchableOpacity
style={styles.settingsButton}
onPress={handleSettingsPress}
activeOpacity={0.7}
>
<Ionicons name="settings-outline" size={24} color={colorTokens.text} />
</TouchableOpacity>
isLiquidGlassAvailable() ? (
<TouchableOpacity
onPress={handleSettingsPress}
activeOpacity={0.7}
style={styles.settingsButtonWrapper}
>
<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}
>
{/* 第二部分:饮水记录 */}
<View style={styles.section}>
<Text style={[styles.sectionTitle, { color: colorTokens.text }]}>
{selectedDate ? dayjs(selectedDate).format('MM月DD日') : '今日'}
<View style={styles.headerBlock}>
<Text style={styles.pageTitle}>
{selectedDate ? dayjs(selectedDate).format('MM-DD') : t('waterDetail.today')}
</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 ? (
<View style={styles.recordsList}>
{waterRecords.map((record) => (
@@ -213,29 +250,20 @@ const WaterDetail: React.FC<WaterDetailProps> = () => {
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 style={styles.noRecordsContainer}>
<Ionicons name="water-outline" size={48} color={colorTokens.textSecondary} />
<Text style={[styles.noRecordsText, { color: colorTokens.textSecondary }]}></Text>
<Text style={[styles.noRecordsSubText, { color: colorTokens.textSecondary }]}>&quot;&quot;</Text>
<Image
source={require('@/assets/images/icons/IconGlass.png')}
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>
</ScrollView>
</KeyboardAvoidingView>
{/* All modals have been moved to the separate water-settings page */}
</View>
);
};
@@ -245,32 +273,12 @@ const styles = StyleSheet.create({
flex: 1,
backgroundColor: '#f3f4fb',
},
gradientBackground: {
topGradient: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
},
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,
height: 300,
},
keyboardAvoidingView: {
flex: 1,
@@ -279,54 +287,107 @@ const styles = StyleSheet.create({
flex: 1,
},
scrollContent: {
paddingBottom: 40,
},
headerBlock: {
paddingHorizontal: 24,
paddingTop: 20,
marginTop: 10,
marginBottom: 24,
},
section: {
marginBottom: 36,
pageTitle: {
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,
fontWeight: '700',
marginBottom: 24,
letterSpacing: -0.5,
color: '#1c1f3a',
fontFamily: 'AliBold',
lineHeight: 32,
},
subsectionTitle: {
progressUnit: {
fontSize: 16,
fontWeight: '600',
marginBottom: 16,
letterSpacing: -0.3,
color: '#1c1f3a',
},
sectionSubtitle: {
fontSize: 14,
fontWeight: '500',
lineHeight: 20,
color: '#6f7ba7',
marginLeft: 2,
fontFamily: 'AliRegular',
},
// 饮水记录相关样式
progressBarBg: {
height: 12,
backgroundColor: '#F0F2F5',
borderRadius: 6,
overflow: 'hidden',
},
progressBarFill: {
height: '100%',
borderRadius: 6,
},
section: {
paddingHorizontal: 24,
},
// 记录列表样式
recordsList: {
gap: 16,
},
recordCardContainer: {
// iOS 阴影效果 - 增强阴影效果
shadowColor: 'rgba(30, 41, 59, 0.18)',
shadowColor: 'rgba(30, 41, 59, 0.08)',
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.16,
shadowRadius: 16,
// Android 阴影效果
elevation: 6,
shadowOpacity: 0.12,
shadowRadius: 12,
elevation: 4,
marginBottom: 2,
},
recordCard: {
borderRadius: 20,
borderRadius: 24,
padding: 18,
backgroundColor: '#ffffff',
},
recordMainContent: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
recordIconContainer: {
width: 48,
@@ -334,7 +395,7 @@ const styles = StyleSheet.create({
borderRadius: 16,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(79, 91, 213, 0.08)',
backgroundColor: '#f5f6ff',
},
recordIcon: {
width: 24,
@@ -345,15 +406,21 @@ const styles = StyleSheet.create({
marginLeft: 16,
},
recordLabel: {
fontSize: 17,
fontSize: 16,
fontWeight: '700',
color: '#1c1f3a',
marginBottom: 6,
marginBottom: 4,
fontFamily: 'AliBold',
},
recordTimeContainer: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
gap: 4,
},
recordTimeText: {
fontSize: 13,
color: '#6f7ba7',
fontFamily: 'AliRegular',
},
recordAmountContainer: {
alignItems: 'flex-end',
@@ -362,364 +429,74 @@ const styles = StyleSheet.create({
fontSize: 18,
fontWeight: '700',
color: '#4F5BD5',
},
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',
fontFamily: 'AliBold',
},
recordNote: {
marginTop: 12,
marginTop: 14,
padding: 12,
backgroundColor: 'rgba(79, 91, 213, 0.04)',
backgroundColor: '#F8F9FC',
borderRadius: 12,
fontSize: 14,
fontStyle: 'normal',
lineHeight: 20,
fontSize: 13,
lineHeight: 18,
color: '#5f6a97',
fontFamily: 'AliRegular',
},
recordsSummary: {
marginTop: 24,
padding: 20,
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',
deleteSwipeButton: {
backgroundColor: '#FF6B6B',
justifyContent: '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: {
alignItems: 'center',
justifyContent: 'center',
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: {
fontSize: 17,
fontSize: 16,
fontWeight: '600',
lineHeight: 24,
color: '#6f7ba7',
color: '#1c1f3a',
marginBottom: 8,
fontFamily: 'AliBold',
},
noRecordsSubText: {
fontSize: 14,
textAlign: 'center',
lineHeight: 20,
color: '#9ba3c7',
fontFamily: 'AliRegular',
},
modalBackdrop: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0,0,0,0.4)',
// Settings Button
settingsButtonWrapper: {
width: 40,
height: 40,
borderRadius: 20,
overflow: 'hidden',
},
modalSheet: {
position: 'absolute',
left: 0,
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,
settingsButtonGlass: {
width: 40,
height: 40,
alignItems: 'center',
justifyContent: 'center',
},
modalBtnPrimary: {
// backgroundColor will be set dynamically
},
modalBtnText: {
fontSize: 16,
fontWeight: '600',
},
modalBtnTextPrimary: {
// color will be set dynamically
},
settingsButton: {
settingsButtonFallback: {
width: 40,
height: 40,
alignItems: 'center',
justifyContent: 'center',
borderRadius: 20,
backgroundColor: 'rgba(255, 255, 255, 0.24)',
backgroundColor: '#ffffff',
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.45)',
},
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,
borderColor: 'rgba(0,0,0,0.05)',
},
});

View File

@@ -22,9 +22,11 @@ import {
} from 'react-native';
import { HeaderBar } from '@/components/ui/HeaderBar';
import { useI18n } from '@/hooks/useI18n';
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
const WaterReminderSettings: React.FC = () => {
const { t } = useI18n();
const safeAreaTop = useSafeAreaTop()
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme];
@@ -71,9 +73,9 @@ const WaterReminderSettings: React.FC = () => {
setStartTimePickerVisible(false);
} else {
Alert.alert(
'时间设置提示',
'开始时间不能晚于或等于结束时间,请重新选择',
[{ text: '确定' }]
t('waterReminderSettings.alerts.timeValidation.title'),
t('waterReminderSettings.alerts.timeValidation.startTimeInvalid'),
[{ text: t('waterReminderSettings.buttons.confirm') }]
);
}
};
@@ -91,9 +93,9 @@ const WaterReminderSettings: React.FC = () => {
setEndTimePickerVisible(false);
} else {
Alert.alert(
'时间设置提示',
'结束时间不能早于或等于开始时间,请重新选择',
[{ text: '确定' }]
t('waterReminderSettings.alerts.timeValidation.title'),
t('waterReminderSettings.alerts.timeValidation.endTimeInvalid'),
[{ text: t('waterReminderSettings.buttons.confirm') }]
);
}
};
@@ -125,18 +127,28 @@ const WaterReminderSettings: React.FC = () => {
if (waterReminderSettings.enabled) {
const timeInfo = `${waterReminderSettings.startTime}-${waterReminderSettings.endTime}`;
const intervalInfo = `${waterReminderSettings.interval}分钟`;
const intervalInfo = `${waterReminderSettings.interval}${t('waterReminderSettings.labels.minutes')}`;
Alert.alert(
'设置成功',
`喝水提醒已开启\n\n时间段${timeInfo}\n提醒间隔${intervalInfo}\n\n我们将在指定时间段内定期提醒您喝水`,
[{ text: '确定', onPress: () => router.back() }]
t('waterReminderSettings.alerts.success.enabled'),
t('waterReminderSettings.alerts.success.enabledMessage', {
timeRange: timeInfo,
interval: intervalInfo
}),
[{ text: t('waterReminderSettings.buttons.confirm'), onPress: () => router.back() }]
);
} 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) {
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} />
<HeaderBar
title="喝水提醒"
title={t('waterReminderSettings.title')}
onBack={() => {
router.back();
}}
@@ -198,7 +210,7 @@ const WaterReminderSettings: React.FC = () => {
<View style={styles.waterReminderSectionHeader}>
<View style={styles.waterReminderSectionTitleContainer}>
<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>
<Switch
value={waterReminderSettings.enabled}
@@ -208,7 +220,7 @@ const WaterReminderSettings: React.FC = () => {
/>
</View>
<Text style={[styles.waterReminderSectionDesc, { color: colorTokens.textSecondary }]}>
{t('waterReminderSettings.descriptions.notifications')}
</Text>
</View>
@@ -216,15 +228,15 @@ const WaterReminderSettings: React.FC = () => {
{waterReminderSettings.enabled && (
<>
<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 }]}>
{t('waterReminderSettings.descriptions.timeRange')}
</Text>
<View style={styles.timeRangeContainer}>
{/* 开始时间 */}
<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
style={[styles.timePicker, { backgroundColor: 'white' }]}
onPress={openStartTimePicker}
@@ -236,7 +248,7 @@ const WaterReminderSettings: React.FC = () => {
{/* 结束时间 */}
<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
style={[styles.timePicker, { backgroundColor: 'white' }]}
onPress={openEndTimePicker}
@@ -250,9 +262,9 @@ const WaterReminderSettings: React.FC = () => {
{/* 提醒间隔设置 */}
<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 }]}>
30-120
{t('waterReminderSettings.descriptions.interval')}
</Text>
<View style={styles.intervalContainer}>
@@ -263,7 +275,7 @@ const WaterReminderSettings: React.FC = () => {
style={styles.intervalPicker}
>
{[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>
</View>
@@ -279,7 +291,7 @@ const WaterReminderSettings: React.FC = () => {
onPress={handleWaterReminderSave}
activeOpacity={0.8}
>
<Text style={[styles.saveButtonText, { color: colorTokens.onPrimary }]}></Text>
<Text style={[styles.saveButtonText, { color: colorTokens.onPrimary }]}>{t('waterReminderSettings.labels.saveSettings')}</Text>
</TouchableOpacity>
</View>
</ScrollView>
@@ -295,11 +307,11 @@ const WaterReminderSettings: React.FC = () => {
<Pressable style={styles.modalBackdrop} onPress={() => setStartTimePickerVisible(false)} />
<View style={styles.timePickerModalSheet}>
<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.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}>
<Picker
selectedValue={tempStartHour}
@@ -314,12 +326,12 @@ const WaterReminderSettings: React.FC = () => {
</View>
<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 }]}>
{String(tempStartHour).padStart(2, '0')}:00 - {waterReminderSettings.endTime}
</Text>
{!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>
@@ -329,13 +341,13 @@ const WaterReminderSettings: React.FC = () => {
onPress={() => setStartTimePickerVisible(false)}
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
onPress={confirmStartTime}
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>
</View>
</View>
@@ -351,11 +363,11 @@ const WaterReminderSettings: React.FC = () => {
<Pressable style={styles.modalBackdrop} onPress={() => setEndTimePickerVisible(false)} />
<View style={styles.timePickerModalSheet}>
<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.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}>
<Picker
selectedValue={tempEndHour}
@@ -370,12 +382,12 @@ const WaterReminderSettings: React.FC = () => {
</View>
<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 }]}>
{waterReminderSettings.startTime} - {String(tempEndHour).padStart(2, '0')}:00
</Text>
{!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>
@@ -385,13 +397,13 @@ const WaterReminderSettings: React.FC = () => {
onPress={() => setEndTimePickerVisible(false)}
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
onPress={confirmEndTime}
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>
</View>
</View>

View File

@@ -21,9 +21,11 @@ import {
} from 'react-native';
import { HeaderBar } from '@/components/ui/HeaderBar';
import { useI18n } from '@/hooks/useI18n';
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
const WaterSettings: React.FC = () => {
const { t } = useI18n();
const safeAreaTop = useSafeAreaTop()
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme];
@@ -74,7 +76,10 @@ const WaterSettings: React.FC = () => {
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 {
await setQuickWaterAmount(tempQuickAdd);
Alert.alert('设置成功', `快速添加默认值已设置为 ${tempQuickAdd}ml`);
Alert.alert(
t('waterSettings.alerts.quickAddSuccess.title'),
t('waterSettings.alerts.quickAddSuccess.message', { amount: tempQuickAdd })
);
} 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();
setWaterReminderSettings(reminderSettings);
} 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} />
<HeaderBar
title="饮水设置"
title={t('waterSettings.title')}
onBack={() => {
router.back();
}}
@@ -156,8 +167,8 @@ const WaterSettings: React.FC = () => {
<Ionicons name="flag-outline" size={20} color="#9370DB" />
</View>
<View style={styles.settingsMenuItemContent}>
<Text style={[styles.settingsMenuItemTitle, { color: colorTokens.text }]}></Text>
<Text style={[styles.settingsMenuItemValue, { color: colorTokens.textSecondary }]}>{currentWaterGoal}ml</Text>
<Text style={[styles.settingsMenuItemTitle, { color: colorTokens.text }]}>{t('waterSettings.sections.dailyGoal')}</Text>
<Text style={[styles.settingsMenuItemValue, { color: colorTokens.textSecondary }]}>{currentWaterGoal}{t('waterSettings.labels.ml')}</Text>
</View>
</View>
<Ionicons name="chevron-forward" size={20} color="#CCCCCC" />
@@ -169,11 +180,11 @@ const WaterSettings: React.FC = () => {
<Ionicons name="add-outline" size={20} color="#9370DB" />
</View>
<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 }]}>
"+"
{t('waterSettings.descriptions.quickAdd')}
</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>
<Ionicons name="chevron-forward" size={20} color="#CCCCCC" />
@@ -185,12 +196,19 @@ const WaterSettings: React.FC = () => {
<Ionicons name="notifications-outline" size={20} color="#3498DB" />
</View>
<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 }]}>
{t('waterSettings.descriptions.reminder')}
</Text>
<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>
</View>
</View>
@@ -211,7 +229,7 @@ const WaterSettings: React.FC = () => {
<Pressable style={styles.modalBackdrop} onPress={() => setGoalModalVisible(false)} />
<View style={styles.modalSheet}>
<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}>
<Picker
selectedValue={tempGoal}
@@ -219,7 +237,7 @@ const WaterSettings: React.FC = () => {
style={styles.picker}
>
{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>
</View>
@@ -228,13 +246,13 @@ const WaterSettings: React.FC = () => {
onPress={() => setGoalModalVisible(false)}
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
onPress={handleGoalConfirm}
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>
</View>
</View>
@@ -250,7 +268,7 @@ const WaterSettings: React.FC = () => {
<Pressable style={styles.modalBackdrop} onPress={() => setQuickAddModalVisible(false)} />
<View style={styles.modalSheet}>
<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}>
<Picker
selectedValue={tempQuickAdd}
@@ -258,7 +276,7 @@ const WaterSettings: React.FC = () => {
style={styles.picker}
>
{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>
</View>
@@ -267,13 +285,13 @@ const WaterSettings: React.FC = () => {
onPress={() => setQuickAddModalVisible(false)}
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
onPress={handleQuickAddConfirm}
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>
</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 isBetween from 'dayjs/plugin/isBetween';
import { LinearGradient } from 'expo-linear-gradient';
import { useLocalSearchParams } from 'expo-router';
import React, { useCallback, useMemo, useState } from 'react';
import {
ActivityIndicator,
@@ -16,6 +17,7 @@ import {
import { HeaderBar } from '@/components/ui/HeaderBar';
import { IntensityBadge, WorkoutDetailModal } from '@/components/workout/WorkoutDetailModal';
import { useI18n } from '@/hooks/useI18n';
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
import { getWorkoutDetailMetrics, WorkoutDetailMetrics } from '@/services/workoutDetail';
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) {
return { label: '低强度', color: '#7C85A3', background: '#E4E7F2' };
return { label: t('workoutHistory.intensity.low'), color: '#7C85A3', background: '#E4E7F2' };
}
const minutes = Math.max(durationInSeconds / 60, 1);
const caloriesPerMinute = totalCalories / minutes;
if (caloriesPerMinute >= 9) {
return { label: '高强度', color: '#F85959', background: '#FFE6E6' };
return { label: t('workoutHistory.intensity.high'), color: '#F85959', background: '#FFE6E6' };
}
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[] {
@@ -265,13 +267,15 @@ function groupWorkouts(workouts: WorkoutData[]): WorkoutSection[] {
return Object.keys(grouped)
.sort((a, b) => dayjs(b).valueOf() - dayjs(a).valueOf())
.map((dateKey) => ({
title: dayjs(dateKey).format('M月D日'),
title: dayjs(dateKey).format('M月D日'), // 保持中文格式,因为这是日期格式
data: grouped[dateKey]
.sort((a, b) => dayjs(b.startDate || b.endDate).valueOf() - dayjs(a.startDate || a.endDate).valueOf()),
}));
}
export default function WorkoutHistoryScreen() {
const { t } = useI18n();
const { workoutId: workoutIdParam } = useLocalSearchParams<{ workoutId?: string | string[] }>();
const [sections, setSections] = useState<WorkoutSection[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -283,8 +287,19 @@ export default function WorkoutHistoryScreen() {
const [selectedIntensity, setSelectedIntensity] = useState<IntensityBadge | null>(null);
const [monthOccurrenceText, setMonthOccurrenceText] = useState<string | 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 () => {
setIsLoading(true);
@@ -302,7 +317,7 @@ export default function WorkoutHistoryScreen() {
if (!hasPermission) {
setSections([]);
setError('尚未授予健康数据权限');
setError(t('workoutHistory.error.permissionDenied'));
setMonthlyStats(null);
return;
}
@@ -315,8 +330,8 @@ export default function WorkoutHistoryScreen() {
setMonthlyStats(computeMonthlyStats(filteredWorkouts));
setSections(groupWorkouts(filteredWorkouts));
} catch (err) {
console.error('加载锻炼历史失败:', err);
setError('加载锻炼记录失败,请稍后再试');
console.error('Failed to load workout history:', err);
setError(t('workoutHistory.error.loadFailed'));
setSections([]);
setMonthlyStats(null);
} finally {
@@ -350,9 +365,9 @@ export default function WorkoutHistoryScreen() {
? dayjs(monthlyStats.snapshotDate).format('M月D日')
: dayjs().format('M月D日');
const overviewText = monthlyStats
? `截至${snapshotLabel},你已完成${monthlyStats.totalCount}次锻炼,累计${formatDurationShort(monthlyStats.totalDuration)}`
: '本月还没有锻炼记录,动起来收集第一条吧!';
const periodText = `统计周期1日 - ${monthEndDay}日(本月)`;
? t('workoutHistory.monthlyStats.overviewWithStats', { date: snapshotLabel, count: monthlyStats.totalCount, duration: formatDurationShort(monthlyStats.totalDuration) })
: t('workoutHistory.monthlyStats.overviewEmpty');
const periodText = t('workoutHistory.monthlyStats.periodText', { day: monthEndDay });
const maxDuration = statsItems[0]?.duration || 1;
return (
@@ -369,7 +384,7 @@ export default function WorkoutHistoryScreen() {
end={{ x: 1, y: 1 }}
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.statDescription}>{overviewText}</Text>
@@ -403,7 +418,7 @@ export default function WorkoutHistoryScreen() {
) : (
<View style={styles.statEmptyState}>
<MaterialCommunityIcons name="calendar-blank" size={20} color="#7C85A3" />
<Text style={styles.statEmptyText}></Text>
<Text style={styles.statEmptyText}>{t('workoutHistory.monthlyStats.emptyData')}</Text>
</View>
)}
</LinearGradient>
@@ -416,8 +431,8 @@ export default function WorkoutHistoryScreen() {
const emptyComponent = useMemo(() => (
<View style={styles.emptyContainer}>
<MaterialCommunityIcons name="calendar-blank" size={40} color="#9AA4C4" />
<Text style={styles.emptyText}></Text>
<Text style={styles.emptySubText}></Text>
<Text style={styles.emptyText}>{t('workoutHistory.empty.title')}</Text>
<Text style={styles.emptySubText}>{t('workoutHistory.empty.subtitle')}</Text>
</View>
), []);
@@ -453,7 +468,7 @@ export default function WorkoutHistoryScreen() {
}
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]);
const loadWorkoutDetail = useCallback(async (workout: WorkoutData) => {
@@ -463,16 +478,16 @@ export default function WorkoutHistoryScreen() {
const metrics = await getWorkoutDetailMetrics(workout);
setDetailMetrics(metrics);
} catch (err) {
console.error('加载锻炼详情失败:', err);
console.error('Failed to load workout details:', err);
setDetailMetrics(null);
setDetailError('加载锻炼详情失败,请稍后再试');
setDetailError(t('workoutHistory.error.detailLoadFailed'));
} finally {
setDetailLoading(false);
}
}, []);
const handleWorkoutPress = useCallback((workout: WorkoutData) => {
const intensity = getIntensityBadge(workout.totalEnergyBurned, workout.duration || 0);
const intensity = getIntensityBadge(t, workout.totalEnergyBurned, workout.duration || 0);
setSelectedIntensity(intensity);
setSelectedWorkout(workout);
setDetailMetrics(null);
@@ -482,6 +497,22 @@ export default function WorkoutHistoryScreen() {
loadWorkoutDetail(workout);
}, [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(() => {
if (selectedWorkout) {
loadWorkoutDetail(selectedWorkout);
@@ -495,7 +526,7 @@ export default function WorkoutHistoryScreen() {
const renderItem = useCallback(({ item }: { item: WorkoutData }) => {
const calories = Math.round(item.totalEnergyBurned || 0);
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 time = dayjs(item.startDate || item.endDate).format('HH:mm');
const activityLabel = getWorkoutTypeDisplayName(item.workoutActivityType);
@@ -512,12 +543,12 @@ export default function WorkoutHistoryScreen() {
<View style={styles.cardContent}>
<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 }]}>
<Text style={[styles.intensityText, { color: intensity.color }]}>{intensity.label}</Text>
</View>
</View>
<Text style={styles.cardSubtitle}>{activityLabel}{time}</Text>
<Text style={styles.cardSubtitle}>{t('workoutHistory.historyCard.activityTime', { activity: activityLabel, time })}</Text>
</View>
{/* <Ionicons name="chevron-forward" size={20} color="#9AA4C4" /> */}
@@ -535,11 +566,11 @@ export default function WorkoutHistoryScreen() {
colors={["#F3F5FF", "#FFFFFF"]}
style={StyleSheet.absoluteFill}
/>
<HeaderBar title="锻炼总结" variant="minimal" transparent={true} />
<HeaderBar title={t('workoutHistory.title')} variant="minimal" transparent={true} />
{isLoading ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color="#5C55FF" />
<Text style={styles.loadingText}>...</Text>
<Text style={styles.loadingText}>{t('workoutHistory.loading')}</Text>
</View>
) : (
<SectionList
@@ -556,7 +587,7 @@ export default function WorkoutHistoryScreen() {
<MaterialCommunityIcons name="alert-circle" size={40} color="#F85959" />
<Text style={[styles.emptyText, { color: '#F85959' }]}>{error}</Text>
<TouchableOpacity style={styles.retryButton} onPress={loadHistory}>
<Text style={styles.retryText}></Text>
<Text style={styles.retryText}>{t('workoutHistory.retry')}</Text>
</TouchableOpacity>
</View>
) : emptyComponent}

View File

@@ -29,6 +29,7 @@ const WORKOUT_TYPES = [
{ key: 'walking', label: '步行' },
{ key: 'other', label: '其他运动' },
];
const WORKOUT_TYPE_KEYS = WORKOUT_TYPES.map(type => type.key);
export default function WorkoutNotificationSettingsScreen() {
const safeAreaTop = useSafeAreaTop()
@@ -80,16 +81,18 @@ export default function WorkoutNotificationSettingsScreen() {
};
const handleWorkoutTypeToggle = (workoutType: string) => {
const currentTypes = preferences.enabledWorkoutTypes;
let newTypes: string[];
const currentTypes = preferences.enabledWorkoutTypes.length === 0
? [...WORKOUT_TYPE_KEYS] // 空数组表示全部启用,先展开成完整列表,避免影响其他开关的当前状态
: [...preferences.enabledWorkoutTypes];
if (currentTypes.includes(workoutType)) {
newTypes = currentTypes.filter(type => type !== workoutType);
} else {
newTypes = [...currentTypes, workoutType];
}
const nextTypes = currentTypes.includes(workoutType)
? currentTypes.filter(type => type !== workoutType)
: [...currentTypes, workoutType];
savePreferences({ enabledWorkoutTypes: newTypes });
// 如果全部类型都开启,回退为空数组表示“全部启用”,以保持原有存储约定
const normalizedTypes = nextTypes.length === WORKOUT_TYPE_KEYS.length ? [] : nextTypes;
savePreferences({ enabledWorkoutTypes: normalizedTypes });
};
const handleReset = () => {
@@ -345,4 +348,4 @@ const styles = StyleSheet.create({
fontSize: 16,
fontWeight: '600',
},
});
});

BIN
assets/fonts/ali-bold.ttf Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 225 KiB

After

Width:  |  Height:  |  Size: 672 KiB

File diff suppressed because one or more lines are too long

BIN
assets/machine.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

View File

@@ -2,6 +2,7 @@ import { IconSymbol } from '@/components/ui/IconSymbol';
import { Colors } from '@/constants/Colors';
import { useAppSelector } from '@/hooks/redux';
import { useColorScheme } from '@/hooks/useColorScheme';
import { useI18n } from '@/hooks/useI18n';
import dayjs from 'dayjs';
import React, { useMemo, useState } from 'react';
import { Dimensions, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
@@ -12,6 +13,7 @@ const ActivityHeatMap = () => {
const colorScheme = useColorScheme();
const colors = Colors[colorScheme ?? 'light'];
const [showPopover, setShowPopover] = useState(false);
const { t } = useI18n();
const activityData = useAppSelector(stat => stat.user.activityHistory);
@@ -103,8 +105,20 @@ const ActivityHeatMap = () => {
// 获取月份标签(简化的月份标签系统)
const getMonthLabels = useMemo(() => {
const monthNames = ['1月', '2月', '3月', '4月', '5月', '6月',
'7月', '8月', '9月', '10月', '11月', '12月'];
const monthNames = [
t('statistics.activityHeatMap.months.1'),
t('statistics.activityHeatMap.months.2'),
t('statistics.activityHeatMap.months.3'),
t('statistics.activityHeatMap.months.4'),
t('statistics.activityHeatMap.months.5'),
t('statistics.activityHeatMap.months.6'),
t('statistics.activityHeatMap.months.7'),
t('statistics.activityHeatMap.months.8'),
t('statistics.activityHeatMap.months.9'),
t('statistics.activityHeatMap.months.10'),
t('statistics.activityHeatMap.months.11'),
t('statistics.activityHeatMap.months.12'),
];
// 简单策略均匀分布4-5个月份标签
const totalWeeks = weeksToShow;
@@ -130,7 +144,7 @@ const ActivityHeatMap = () => {
});
return labelPositions;
}, [organizeDataByWeeks, weeksToShow]);
}, [organizeDataByWeeks, weeksToShow, t]);
// 计算活动统计
const activityStats = useMemo(() => {
@@ -156,14 +170,14 @@ const ActivityHeatMap = () => {
<View style={styles.header}>
<View style={styles.titleRow}>
<Text style={[styles.subtitle, { color: colors.textMuted }]}>
6 {activityStats.activeDays}
{t('statistics.activityHeatMap.subtitle', { days: activityStats.activeDays })}
</Text>
<View style={styles.rightSection}>
<View style={[styles.statsBadge, {
backgroundColor: 'rgba(122, 90, 248, 0.1)'
}]}>
<Text style={[styles.statsText, { color: colors.primary }]}>
{activityStats.activeRate}%
{t('statistics.activityHeatMap.activeRate', { rate: activityStats.activeRate })}
</Text>
</View>
<Popover
@@ -184,23 +198,23 @@ const ActivityHeatMap = () => {
>
<View style={[styles.popoverContent, { backgroundColor: colors.card }]}>
<Text style={[styles.popoverTitle, { color: colors.text }]}>
AI
{t('statistics.activityHeatMap.popover.title')}
</Text>
<Text style={[styles.popoverSubtitle, { color: colors.text }]}>
{t('statistics.activityHeatMap.popover.subtitle')}
</Text>
<View style={styles.popoverList}>
<Text style={[styles.popoverItem, { color: colors.textMuted }]}>
1. +1
{t('statistics.activityHeatMap.popover.rules.login')}
</Text>
<Text style={[styles.popoverItem, { color: colors.textMuted }]}>
2. +1
{t('statistics.activityHeatMap.popover.rules.mood')}
</Text>
<Text style={[styles.popoverItem, { color: colors.textMuted }]}>
3. +1
{t('statistics.activityHeatMap.popover.rules.diet')}
</Text>
<Text style={[styles.popoverItem, { color: colors.textMuted }]}>
4. +1
{t('statistics.activityHeatMap.popover.rules.goal')}
</Text>
</View>
</View>
@@ -263,7 +277,9 @@ const ActivityHeatMap = () => {
{/* 图例 */}
<View style={styles.legend}>
<Text style={[styles.legendText, { color: colors.textMuted }]}></Text>
<Text style={[styles.legendText, { color: colors.textMuted }]}>
{t('statistics.activityHeatMap.legend.less')}
</Text>
<View style={styles.legendColors}>
{[0, 1, 2, 3, 4].map((level) => (
<View
@@ -278,7 +294,9 @@ const ActivityHeatMap = () => {
/>
))}
</View>
<Text style={[styles.legendText, { color: colors.textMuted }]}></Text>
<Text style={[styles.legendText, { color: colors.textMuted }]}>
{t('statistics.activityHeatMap.legend.more')}
</Text>
</View>
</View>
);

View File

@@ -242,6 +242,7 @@ const styles = StyleSheet.create({
fontSize: 14,
color: '#0F172A',
fontWeight: '600',
fontFamily: 'AliBold',
},
titleIcon: {
width: 16,
@@ -256,6 +257,7 @@ const styles = StyleSheet.create({
statusText: {
fontSize: 11,
fontWeight: '600',
fontFamily: 'AliBold',
},
valueSection: {
flexDirection: 'row',
@@ -267,10 +269,12 @@ const styles = StyleSheet.create({
fontWeight: '600',
color: '#0F172A',
lineHeight: 28,
fontFamily: 'AliBold',
},
unit: {
fontSize: 12,
color: '#64748B',
marginLeft: 6,
fontFamily: 'AliRegular',
},
});

View File

@@ -1,9 +1,9 @@
import { ThemedText } from '@/components/ThemedText';
import { useI18n } from '@/hooks/useI18n';
import { useThemeColor } from '@/hooks/useThemeColor';
import React, { useEffect, useRef } from 'react';
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);
@@ -26,12 +26,8 @@ export function CalorieRingChart({
protein,
fat,
carbs,
proteinGoal,
fatGoal,
carbsGoal,
}: CalorieRingChartProps) {
const surfaceColor = useThemeColor({}, 'surface');
const { t } = useI18n();
const textColor = useThemeColor({}, 'text');
const textSecondaryColor = useThemeColor({}, 'textSecondary');
@@ -46,9 +42,9 @@ export function CalorieRingChart({
const totalAvailable = metabolism + exercise;
const progressPercentage = totalAvailable > 0 ? Math.min((consumed / totalAvailable) * 100, 100) : 0;
// 圆环参数 - 小尺寸以优化空间占用
const radius = 48;
const strokeWidth = 8; // 增加圆环厚度
// 圆环参数 - 小尺寸
const radius = 42;
const strokeWidth = 8;
const center = radius + strokeWidth;
const circumference = 2 * Math.PI * radius;
const strokeDasharray = circumference;
@@ -70,34 +66,32 @@ export function CalorieRingChart({
});
return (
<View style={[styles.container, { backgroundColor: surfaceColor }]}>
{/* 左上角公式展示 */}
<View style={styles.formulaContainer}>
<ThemedText style={[styles.formulaText, { color: textSecondaryColor }]}>
= + -
</ThemedText>
</View>
{/* 主要内容区域 */}
<View style={styles.container}>
<View style={styles.mainContent}>
{/* 左侧圆环图 */}
<View style={styles.chartContainer}>
<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
cx={center}
cy={center}
r={radius}
stroke="#F0F0F0"
stroke="#F5F7FA"
strokeWidth={strokeWidth}
fill="none"
/>
{/* 进度圆环 - 保持固定颜色 */}
{/* 进度圆环 */}
<AnimatedCircle
cx={center}
cy={center}
r={radius}
stroke={progressPercentage > 80 ? "#FF6B6B" : "#4ECDC4"}
stroke="url(#progressGradient)"
strokeWidth={strokeWidth}
fill="none"
strokeDasharray={`${strokeDasharray}`}
@@ -109,68 +103,68 @@ export function CalorieRingChart({
{/* 中心内容 */}
<View style={styles.centerContent}>
<ThemedText style={[styles.centerLabel, { color: textSecondaryColor }]}>
<ThemedText style={styles.centerLabel}>
{t('nutritionRecords.chart.remaining')}
</ThemedText>
<ThemedText style={[styles.centerValue, { color: textColor }]}>
{Math.round(canEat)}
<ThemedText style={styles.centerValue}>
{Math.round(canEat)}
</ThemedText>
<ThemedText style={styles.centerUnit}>
{t('nutritionRecords.nutrients.caloriesUnit')}
</ThemedText>
</View>
</View>
{/* 右侧数据展示 */}
{/* 右侧数据展示 - 优化布局 */}
<View style={styles.dataContainer}>
<View style={styles.dataBackground}>
{/* 左右两列布局 */}
<View style={styles.dataColumns}>
{/* 左列:卡路里数据 */}
<View style={styles.dataColumn}>
<View style={styles.dataItem}>
<ThemedText style={[styles.dataLabel, { color: textSecondaryColor }]}></ThemedText>
<ThemedText style={[styles.dataValue, { color: textColor }]}>
{Math.round(metabolism)}
</ThemedText>
</View>
{/* 公式 */}
<View style={styles.formulaContainer}>
<ThemedText style={styles.formulaText}>
{t('nutritionRecords.chart.formula')}
</ThemedText>
</View>
<View style={styles.dataItem}>
<ThemedText style={[styles.dataLabel, { color: textSecondaryColor }]}></ThemedText>
<ThemedText style={[styles.dataValue, { color: textColor }]}>
{Math.round(exercise)}
</ThemedText>
</View>
<View style={styles.dataItem}>
<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 style={styles.statsGroup}>
<View style={styles.statRowCompact}>
<View style={styles.labelWithDot}>
<View style={styles.dotMetabolism} />
<ThemedText style={styles.statLabel}>{t('nutritionRecords.chart.metabolism')}</ThemedText>
</View>
<ThemedText style={styles.statValue}>{Math.round(metabolism)}</ThemedText>
</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>
@@ -181,40 +175,35 @@ export function CalorieRingChart({
const styles = StyleSheet.create({
container: {
backgroundColor: '#FFFFFF',
borderRadius: 16,
borderRadius: 24,
padding: 16,
marginHorizontal: 16,
marginBottom: 8,
shadowColor: '#000000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.04,
shadowRadius: 8,
elevation: 2,
marginHorizontal: 20,
shadowColor: 'rgba(30, 41, 59, 0.08)',
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.12,
shadowRadius: 16,
elevation: 6,
},
formulaContainer: {
alignItems: 'flex-start',
marginBottom: 12,
},
formulaText: {
fontSize: 12,
fontSize: 10,
fontWeight: '500',
color: '#999999',
lineHeight: 16,
color: '#94A3B8',
fontFamily: 'AliRegular',
},
mainContent: {
width: '100%',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 0, // 移除底部间距,因为不再有底部营养容器
paddingHorizontal: 8,
alignItems: 'flex-start',
},
chartContainer: {
position: 'relative',
alignItems: 'center',
justifyContent: 'center',
width: 112, // 减少宽度以匹配更小的圆环 (48*2 + 8*2)
flexShrink: 0,
width: 100,
height: 100,
marginTop: 8,
},
centerContent: {
position: 'absolute',
@@ -222,71 +211,95 @@ const styles = StyleSheet.create({
justifyContent: 'center',
},
centerLabel: {
fontSize: 11,
fontSize: 10,
fontWeight: '500',
color: '#999999',
marginBottom: 2,
color: '#94A3B8',
marginBottom: 1,
fontFamily: 'AliRegular',
},
centerValue: {
fontSize: 14,
fontWeight: '600',
color: '#333333',
marginBottom: 1,
fontSize: 20,
fontWeight: '800',
color: '#1E293B',
lineHeight: 24,
fontFamily: 'AliBold',
},
centerPercentage: {
fontSize: 11,
fontWeight: '500',
color: '#999999',
centerUnit: {
fontSize: 10,
fontWeight: '600',
color: '#64748B',
fontFamily: 'AliRegular',
},
dataContainer: {
flex: 1,
marginLeft: 16,
marginLeft: 20,
},
dataBackground: {
backgroundColor: 'rgba(248, 250, 252, 0.8)', // 毛玻璃背景色
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,
statsGroup: {
gap: 6,
},
dataItem: {
statRowCompact: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
justifyContent: 'space-between',
},
dataIcon: {
width: 6,
height: 6,
borderRadius: 3,
labelWithDot: {
flexDirection: 'row',
alignItems: 'center',
},
dataLabel: {
fontSize: 11,
fontWeight: '500',
color: '#999999',
minWidth: 28,
dotMetabolism: {
width: 6,
height: 6,
borderRadius: 3,
backgroundColor: '#94A3B8',
marginRight: 6,
},
dataValue: {
fontSize: 11,
dotExercise: {
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',
color: '#333333',
color: '#334155',
fontFamily: 'AliBold',
},
dataColumns: {
divider: {
height: 1,
backgroundColor: '#F1F5F9',
marginVertical: 10,
},
nutritionRow: {
flexDirection: 'row',
justifyContent: 'space-between',
gap: 12,
},
dataColumn: {
flex: 1,
gap: 4,
nutritionItem: {
alignItems: 'center',
},
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 DateTimePicker from '@react-native-community/datetimepicker';
import dayjs from 'dayjs';
@@ -50,6 +51,8 @@ export const DateSelector: React.FC<DateSelectorProps> = ({
autoScrollToSelected = true,
showCalendarIcon = true,
}) => {
const { t, i18n } = useI18n();
// 内部状态管理
const [internalSelectedIndex, setInternalSelectedIndex] = useState(getTodayIndexInMonth());
const [currentMonth, setCurrentMonth] = useState(dayjs()); // 当前显示的月份
@@ -59,8 +62,8 @@ export const DateSelector: React.FC<DateSelectorProps> = ({
const isGlassAvailable = isLiquidGlassAvailable();
// 获取日期数据
const days = getMonthDaysZh(currentMonth);
const monthTitle = externalMonthTitle ?? getMonthTitleZh(currentMonth);
const days = getMonthDays(currentMonth, i18n.language as 'zh' | 'en');
const monthTitle = externalMonthTitle ?? getMonthTitle(currentMonth, i18n.language as 'zh' | 'en');
// 判断当前选中的日期是否是今天
const isSelectedDateToday = () => {
@@ -201,7 +204,7 @@ export const DateSelector: React.FC<DateSelectorProps> = ({
setCurrentMonth(selectedMonth);
// 计算选中日期在新月份中的索引
const newMonthDays = getMonthDaysZh(selectedMonth);
const newMonthDays = getMonthDays(selectedMonth, i18n.language as 'zh' | 'en');
const selectedDay = selectedMonth.date();
const newSelectedIndex = newMonthDays.findIndex(day => day.dayOfMonth === selectedDay);
@@ -219,7 +222,7 @@ export const DateSelector: React.FC<DateSelectorProps> = ({
const handleGoToday = () => {
const today = dayjs();
setCurrentMonth(today);
const todayDays = getMonthDaysZh(today);
const todayDays = getMonthDays(today, i18n.language as 'zh' | 'en');
const newSelectedIndex = todayDays.findIndex(day => day.dayOfMonth === today.date());
if (newSelectedIndex !== -1) {
@@ -250,11 +253,11 @@ export const DateSelector: React.FC<DateSelectorProps> = ({
tintColor="rgba(124, 58, 237, 0.08)"
isInteractive={true}
>
<Text style={styles.todayButtonText}></Text>
<Text style={styles.todayButtonText}>{t('dateSelector.backToToday')}</Text>
</GlassView>
) : (
<View style={[styles.todayButton, styles.todayButtonFallback]}>
<Text style={styles.todayButtonText}></Text>
<Text style={styles.todayButtonText}>{t('dateSelector.backToToday')}</Text>
</View>
)}
</TouchableOpacity>
@@ -379,7 +382,7 @@ export const DateSelector: React.FC<DateSelectorProps> = ({
display={Platform.OS === 'ios' ? 'inline' : 'calendar'}
minimumDate={dayjs().subtract(6, 'month').toDate()}
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) => {
if (Platform.OS === 'ios') {
if (date) setPickerDate(date);
@@ -395,12 +398,12 @@ export const DateSelector: React.FC<DateSelectorProps> = ({
{Platform.OS === 'ios' && (
<View style={styles.modalActions}>
<TouchableOpacity onPress={closeDatePicker} style={[styles.modalBtn]}>
<Text style={styles.modalBtnText}></Text>
<Text style={styles.modalBtnText}>{t('dateSelector.cancel')}</Text>
</TouchableOpacity>
<TouchableOpacity onPress={() => {
onConfirmDate(pickerDate);
}} style={[styles.modalBtn, styles.modalBtnPrimary]}>
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary]}></Text>
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary]}>{t('dateSelector.confirm')}</Text>
</TouchableOpacity>
</View>
)}
@@ -413,7 +416,7 @@ export const DateSelector: React.FC<DateSelectorProps> = ({
display={Platform.OS === 'ios' ? 'inline' : 'calendar'}
minimumDate={dayjs().subtract(6, 'month').toDate()}
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) => {
if (Platform.OS === 'ios') {
if (date) setPickerDate(date);
@@ -429,12 +432,12 @@ export const DateSelector: React.FC<DateSelectorProps> = ({
{Platform.OS === 'ios' && (
<View style={styles.modalActions}>
<TouchableOpacity onPress={closeDatePicker} style={[styles.modalBtn]}>
<Text style={styles.modalBtnText}></Text>
<Text style={styles.modalBtnText}>{t('dateSelector.cancel')}</Text>
</TouchableOpacity>
<TouchableOpacity onPress={() => {
onConfirmDate(pickerDate);
}} style={[styles.modalBtn, styles.modalBtnPrimary]}>
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary]}></Text>
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary]}>{t('dateSelector.confirm')}</Text>
</TouchableOpacity>
</View>
)}
@@ -460,15 +463,16 @@ const styles = StyleSheet.create({
alignItems: 'center',
},
monthTitle: {
fontSize: 22,
fontSize: 26,
fontWeight: '800',
color: '#1a1a1a',
letterSpacing: -0.5,
color: '#1c1f3a',
fontFamily: 'AliBold',
marginLeft: 8,
},
calendarIconButton: {
padding: 4,
borderRadius: 6,
marginLeft: 4,
padding: 6,
borderRadius: 12,
marginLeft: 8,
overflow: 'hidden',
},
calendarIconFallback: {
@@ -477,22 +481,20 @@ const styles = StyleSheet.create({
borderColor: 'rgba(255, 255, 255, 0.3)',
},
todayButton: {
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 12,
marginRight: 8,
paddingHorizontal: 14,
paddingVertical: 8,
borderRadius: 16,
marginRight: 4,
overflow: 'hidden',
},
todayButtonFallback: {
backgroundColor: '#EEF2FF',
borderWidth: 1,
borderColor: 'rgba(124, 58, 237, 0.2)',
},
todayButtonText: {
fontSize: 12,
fontWeight: '700',
color: '#7c3aed',
letterSpacing: 0.2,
color: '#5F6BF0',
fontFamily: 'AliBold',
},
daysContainer: {
paddingBottom: 8,
@@ -503,8 +505,8 @@ const styles = StyleSheet.create({
marginRight: 8,
},
dayPill: {
width: 40,
height: 60,
width: 48,
height: 68,
borderRadius: 24,
alignItems: 'center',
justifyContent: 'center',
@@ -518,14 +520,12 @@ const styles = StyleSheet.create({
transform: [{ scale: 0.96 }],
},
dayPillSelectedFallback: {
backgroundColor: '#FFFFFF',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.5)',
backgroundColor: '#5F6BF0',
shadowColor: 'rgba(95, 107, 240, 0.3)',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 4,
},
dayPillDisabled: {
backgroundColor: 'transparent',
@@ -533,27 +533,31 @@ const styles = StyleSheet.create({
},
dayLabel: {
fontSize: 11,
fontWeight: '700',
color: '#8e8e93',
marginBottom: 2,
letterSpacing: 0.1,
fontWeight: '600',
color: '#94A3B8',
marginBottom: 4,
fontFamily: 'AliRegular',
},
dayLabelSelected: {
color: '#1a1a1a',
fontWeight: '800',
fontWeight: '700',
fontFamily: 'AliBold',
opacity: 0.9,
},
dayLabelDisabled: {
color: '#c7c7cc',
},
dayDate: {
fontSize: 13,
fontSize: 15,
fontWeight: '700',
color: '#8e8e93',
letterSpacing: -0.2,
color: '#64748B',
fontFamily: 'AliBold',
},
dayDateSelected: {
color: '#1a1a1a',
fontWeight: '800',
fontSize: 16,
fontFamily: 'AliBold',
},
dayDateDisabled: {
color: '#c7c7cc',
@@ -607,11 +611,13 @@ const styles = StyleSheet.create({
fontWeight: '700',
fontSize: 14,
letterSpacing: 0.1,
fontFamily: 'AliBold',
},
modalBtnTextPrimary: {
color: '#FFFFFF',
fontWeight: '700',
fontSize: 14,
letterSpacing: 0.1,
fontFamily: 'AliBold',
},
});

View File

@@ -1,5 +1,6 @@
import { ROUTES } from '@/constants/Routes';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useI18n } from '@/hooks/useI18n';
import { ChallengeType } from '@/services/challengesApi';
import { reportChallengeProgress, selectChallengeList } from '@/store/challengesSlice';
import { ActivityRingsData, fetchActivityRingsForDate } from '@/utils/health';
@@ -26,6 +27,7 @@ export function FitnessRingsCard({
selectedDate,
resetToken,
}: FitnessRingsCardProps) {
const { t } = useI18n();
const dispatch = useAppDispatch();
const challenges = useAppSelector(selectChallengeList);
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 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 = () => {
router.push(ROUTES.FITNESS_RINGS_DETAIL);
};
@@ -191,47 +211,23 @@ export function FitnessRingsCard({
{/* 右侧数据显示 */}
<View style={styles.dataContainer}>
<View style={styles.dataRow}>
<Text style={styles.dataText}>
{loading ? (
<Text style={styles.dataValue}>--</Text>
) : (
<>
<Text style={styles.dataValue}>{Math.round(activeCalories)}</Text>
<Text style={styles.dataGoal}>/{activeCaloriesGoal}</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(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>
{fitnessRows.map((row) => (
<View key={row.key} style={styles.dataRow}>
<Text style={styles.dataText}>
{loading ? (
<Text style={styles.dataValue}>--</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}>{row.unit}</Text>
</View>
))}
</View>
</View>
</TouchableOpacity>
@@ -285,6 +281,7 @@ const styles = StyleSheet.create({
fontSize: 12,
fontWeight: '700',
flex: 1,
fontFamily: 'AliBold',
},
dataValue: {
color: '#192126',
@@ -298,5 +295,6 @@ const styles = StyleSheet.create({
fontWeight: '500',
minWidth: 25,
textAlign: 'right',
fontFamily: 'AliRegular',
},
});

View File

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

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

View File

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

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

View File

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

View File

@@ -82,10 +82,10 @@ const SimpleRingProgress = ({
/>
</Svg>
<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)}
</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>
);
@@ -100,6 +100,8 @@ export function NutritionRadarCard({
const [currentMealType] = useState<'breakfast' | 'lunch' | 'dinner' | 'snack'>('breakfast');
const [loading, setLoading] = useState(false);
const { isLoggedIn } = useAuthGuard()
const { pushIfAuthedElseLogin } = useAuthGuard();
const dispatch = useAppDispatch();
@@ -121,10 +123,11 @@ export function NutritionRadarCard({
try {
setLoading(true);
await Promise.all([
dispatch(fetchDailyNutritionData(targetDate)).unwrap(),
dispatch(fetchDailyBasalMetabolism(targetDate)).unwrap(),
]);
if (isLoggedIn) {
await dispatch(fetchDailyNutritionData(targetDate)).unwrap()
}
await dispatch(fetchDailyBasalMetabolism(targetDate)).unwrap()
} catch (error) {
console.error('NutritionRadarCard: Failed to get nutrition card data:', error);
} finally {
@@ -133,7 +136,7 @@ export function NutritionRadarCard({
};
loadNutritionCardData();
}, [selectedDate, dispatch]);
}, [selectedDate, dispatch, isLoggedIn]);
const nutritionStats = useMemo(() => {
return [
@@ -358,12 +361,14 @@ const styles = StyleSheet.create({
cardTitle: {
fontSize: 14,
color: '#192126',
fontWeight: '600'
fontWeight: '600',
fontFamily: 'AliBold',
},
cardSubtitle: {
fontSize: 10,
color: '#9AA3AE',
fontWeight: '600',
fontFamily: 'AliRegular',
},
contentContainer: {
flexDirection: 'row',
@@ -416,11 +421,13 @@ const styles = StyleSheet.create({
fontSize: 10,
color: '#9AA3AE',
flex: 1,
fontFamily: 'AliRegular',
},
statValue: {
fontSize: 12,
color: '#192126',
fontWeight: '600',
fontFamily: 'AliBold',
},
// 卡路里相关样式
calorieSection: {
@@ -439,6 +446,7 @@ const styles = StyleSheet.create({
fontSize: 16,
fontWeight: '800',
color: '#192126',
fontFamily: 'AliBold',
},
calorieContent: {
},
@@ -447,6 +455,7 @@ const styles = StyleSheet.create({
color: '#64748B',
fontWeight: '600',
marginRight: 4,
fontFamily: 'AliRegular',
},
calculationRow: {
flexDirection: 'row',
@@ -458,11 +467,13 @@ const styles = StyleSheet.create({
fontSize: 14,
fontWeight: '600',
color: '#192126',
fontFamily: 'AliBold',
},
calculationText: {
fontSize: 10,
fontWeight: '600',
color: '#64748B',
fontFamily: 'AliRegular',
},
calculationItem: {
flexDirection: 'row',
@@ -473,11 +484,13 @@ const styles = StyleSheet.create({
fontSize: 9,
color: '#64748B',
fontWeight: '500',
fontFamily: 'AliRegular',
},
calculationValue: {
fontSize: 11,
fontWeight: '700',
color: '#192126',
fontFamily: 'AliBold',
},
remainingCaloriesContainer: {
flexDirection: 'row',
@@ -488,6 +501,7 @@ const styles = StyleSheet.create({
fontSize: 10,
color: '#64748B',
fontWeight: '500',
fontFamily: 'AliRegular',
},
mealsContainer: {
flexDirection: 'row',
@@ -511,6 +525,7 @@ const styles = StyleSheet.create({
fontSize: 10,
color: '#64748B',
fontWeight: '600',
fontFamily: 'AliRegular',
},
// 食物选项样式
foodOptionsContainer: {
@@ -556,5 +571,6 @@ const styles = StyleSheet.create({
fontWeight: '500',
color: '#192126',
textAlign: 'center',
fontFamily: 'AliRegular',
},
});

View File

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

View File

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

View File

@@ -15,6 +15,7 @@ export function StressMeter({ curDate }: StressMeterProps) {
const { t } = useTranslation();
const [hrvValue, setHrvValue] = useState(0)
const [updateTime, setUpdateTime] = useState<Date>(new Date())
useEffect(() => {
@@ -32,6 +33,9 @@ export function StressMeter({ curDate }: StressMeterProps) {
if (result.hrvData) {
setHrvValue(Math.round(result.hrvData.value));
if (result.hrvData.recordedAt) {
setUpdateTime(new Date(result.hrvData.recordedAt));
}
console.log(`StressMeter: Using ${result.message}, HRV value: ${result.hrvData.value}ms`);
} else {
console.log('StressMeter: No HRV data obtained');
@@ -92,7 +96,7 @@ export function StressMeter({ curDate }: StressMeterProps) {
{/* 渐变背景进度条 */}
<View style={[styles.progressBar, { width: '100%' }]}>
<LinearGradient
colors={['#EF4444', '#FCD34D', '#10B981']}
colors={['#10B981', '#FCD34D', '#EF4444']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.gradientBar}
@@ -110,7 +114,7 @@ export function StressMeter({ curDate }: StressMeterProps) {
visible={showStressModal}
onClose={() => setShowStressModal(false)}
hrvValue={hrvValue}
updateTime={new Date()}
updateTime={updateTime}
/>
</>
);
@@ -158,7 +162,8 @@ const styles = StyleSheet.create({
title: {
fontSize: 14,
color: '#192126',
fontWeight: '600'
fontWeight: '600',
fontFamily: 'AliBold',
},
valueSection: {
flexDirection: 'row',
@@ -171,12 +176,14 @@ const styles = StyleSheet.create({
color: '#192126',
lineHeight: 20,
marginTop: 2,
fontFamily: 'AliBold',
},
unit: {
fontSize: 12,
fontWeight: '500',
color: '#9AA3AE',
marginLeft: 4,
fontFamily: 'AliRegular',
},
progressContainer: {
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,4 +1,5 @@
import { useWaterDataByDate } from '@/hooks/useWaterData';
import { appStoreReviewService } from '@/services/appStoreReview';
import { getQuickWaterAmount } from '@/utils/userPreferences';
import { useFocusEffect } from '@react-navigation/native';
import dayjs from 'dayjs';
@@ -139,6 +140,9 @@ const WaterIntakeCard: React.FC<WaterIntakeCardProps> = ({
// 如果有选中日期,则为该日期添加记录;否则为今天添加记录
const recordedAt = dayjs().toISOString()
await addWaterRecord(waterAmount, recordedAt);
// 记录饮水后尝试请求应用评分
await appStoreReviewService.requestReview();
};
// 处理卡片点击 - 跳转到饮水详情页面
@@ -305,6 +309,7 @@ const styles = StyleSheet.create({
fontSize: 14,
color: '#192126',
fontWeight: '600',
fontFamily: 'AliBold',
},
addButton: {
borderRadius: 16,
@@ -319,6 +324,7 @@ const styles = StyleSheet.create({
color: '#6366F1',
fontWeight: '700',
lineHeight: 10,
fontFamily: 'AliBold',
},
chartContainer: {
flex: 1,
@@ -359,11 +365,13 @@ const styles = StyleSheet.create({
fontSize: 14,
fontWeight: '600',
color: '#192126',
fontFamily: 'AliBold',
},
targetIntake: {
fontSize: 12,
color: '#6B7280',
marginLeft: 4,
fontFamily: 'AliRegular',
},
});

View File

@@ -269,6 +269,7 @@ const styles = StyleSheet.create({
fontSize: 16,
color: '#1F2355',
fontWeight: '600',
fontFamily: 'AliBold',
},
addButton: {
width: 28,
@@ -287,6 +288,7 @@ const styles = StyleSheet.create({
fontSize: 20,
color: '#7A8FFF',
marginTop: -2,
fontFamily: 'AliBold',
},
metricsRow: {
flexDirection: 'row',
@@ -310,12 +312,14 @@ const styles = StyleSheet.create({
fontSize: 24,
fontWeight: '700',
color: '#1F2355',
fontFamily: 'AliBold',
},
metricLabel: {
fontSize: 12,
color: '#4A5677',
fontWeight: '500',
marginBottom: 2,
fontFamily: 'AliRegular',
},
detailsRow: {
flexDirection: 'row',
@@ -331,14 +335,17 @@ const styles = StyleSheet.create({
fontSize: 13,
color: '#1F2355',
fontWeight: '500',
fontFamily: 'AliRegular',
},
lastWorkoutTime: {
fontSize: 12,
color: '#7C85A3',
fontFamily: 'AliRegular',
},
sourceText: {
fontSize: 11,
color: '#9AA3C0',
fontFamily: 'AliRegular',
},
badgesRow: {
flexDirection: 'row',

View File

@@ -215,11 +215,13 @@ const styles = StyleSheet.create({
title: {
fontSize: 18,
fontWeight: '700',
fontFamily: 'AliBold'
},
remaining: {
fontSize: 11,
fontWeight: '600',
alignSelf: 'flex-start',
fontFamily: 'AliRegular'
},
metaRow: {
marginTop: 12,
@@ -227,10 +229,12 @@ const styles = StyleSheet.create({
metaValue: {
fontSize: 14,
fontWeight: '700',
fontFamily: 'AliBold'
},
metaSuffix: {
fontSize: 13,
fontWeight: '500',
fontFamily: 'AliBold'
},
track: {
marginTop: 12,

View File

@@ -1,3 +1,4 @@
import { useI18n } from '@/hooks/useI18n';
import type { RankingItem } from '@/store/challengesSlice';
import { Ionicons } from '@expo/vector-icons';
import { Image } from 'expo-image';
@@ -18,34 +19,34 @@ const formatNumber = (value: number): string => {
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) {
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 targetLabel = formatValueWithUnit(item.todayTargetValue, unit);
const progressLabel = reportedLabel && targetLabel
? `今日 ${reportedLabel} / ${targetLabel}`
? `${t('challengeDetail.ranking.today')} ${reportedLabel} / ${targetLabel}`
: reportedLabel
? `今日 ${reportedLabel}`
? `${t('challengeDetail.ranking.today')} ${reportedLabel}`
: targetLabel
? `今日目标 ${targetLabel}`
? `${t('challengeDetail.ranking.todayGoal')} ${targetLabel}`
: undefined;
return (

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 { Colors, palette } from '@/constants/Colors';
import { MedicalRecordItem } from '@/services/healthProfile';
import { Ionicons } from '@expo/vector-icons';
import dayjs from 'dayjs';
import { Image } from 'expo-image';
import React from 'react';
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
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 { 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 { Image } from 'expo-image';
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

@@ -0,0 +1,409 @@
import { Ionicons } from '@expo/vector-icons';
import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient';
import React, { useEffect, useRef, useState } from 'react';
import { Animated, Modal, Pressable, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
type Props = {
visible: boolean;
onClose: () => void;
onManualAdd: () => void;
onAiRecognize: () => void;
};
export function MedicationAddOptionsSheet({ visible, onClose, onManualAdd, onAiRecognize }: Props) {
const translateY = useRef(new Animated.Value(300)).current;
const opacity = useRef(new Animated.Value(0)).current;
const [modalVisible, setModalVisible] = useState(false);
useEffect(() => {
if (visible) {
// 打开时:先显示 Modal然后执行动画
setModalVisible(true);
Animated.parallel([
Animated.spring(translateY, {
toValue: 0,
tension: 65,
friction: 11,
useNativeDriver: true,
}),
Animated.timing(opacity, {
toValue: 1,
duration: 200,
useNativeDriver: true,
}),
]).start();
} else if (modalVisible) {
// 关闭时:先执行动画,动画完成后隐藏 Modal
Animated.parallel([
Animated.timing(translateY, {
toValue: 300,
duration: 200,
useNativeDriver: true,
}),
Animated.timing(opacity, {
toValue: 0,
duration: 150,
useNativeDriver: true,
}),
]).start(({ finished }) => {
if (finished) {
setModalVisible(false);
}
});
}
}, [visible, modalVisible, opacity, translateY]);
const handleClose = () => {
// 触发关闭动画
onClose();
};
return (
<Modal visible={modalVisible} transparent animationType="none" onRequestClose={handleClose}>
<Pressable style={styles.overlay} onPress={onClose}>
<Animated.View style={[styles.backdrop, { opacity }]} />
</Pressable>
<Animated.View
style={[
styles.sheet,
{
transform: [{ translateY }],
},
]}
>
{/* Header */}
<View style={styles.header}>
<View style={styles.headerLeft}>
<Text style={styles.title}></Text>
<Text style={styles.subtitle}></Text>
</View>
<TouchableOpacity onPress={handleClose} style={styles.closeButton} activeOpacity={0.7}>
<Ionicons name="close" size={24} color="#64748b" />
</TouchableOpacity>
</View>
{/* AI 智能识别 - 主推荐 */}
<TouchableOpacity activeOpacity={0.95} onPress={onAiRecognize}>
<LinearGradient
colors={['#0ea5e9', '#0284c7']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.aiCard}
>
{/* 推荐标签 */}
<View style={styles.recommendBadge}>
<Ionicons name="sparkles" size={14} color="#fbbf24" />
<Text style={styles.recommendText}>使</Text>
</View>
<View style={styles.aiContent}>
<View style={styles.aiLeft}>
<View style={styles.aiIconWrapper}>
<Ionicons name="camera" size={32} color="#fff" />
</View>
<View style={styles.aiTexts}>
<Text style={styles.aiTitle}>AI </Text>
<Text style={styles.aiDescription}>
{'\n'}
</Text>
<View style={styles.aiFeatures}>
<View style={styles.featureItem}>
<Ionicons name="flash" size={14} color="#fff" />
<Text style={styles.featureText}></Text>
</View>
<View style={styles.featureItem}>
<Ionicons name="checkmark-circle" size={14} color="#fff" />
<Text style={styles.featureText}></Text>
</View>
</View>
</View>
</View>
<Image
source={require('@/assets/images/medicine/image-medicine.png')}
style={styles.aiImage}
contentFit="contain"
/>
</View>
{/* AI 说明 */}
<View style={styles.aiFooter}>
<Ionicons name="information-circle-outline" size={14} color="rgba(255,255,255,0.8)" />
<Text style={styles.aiFooterText}> AI · 线</Text>
</View>
</LinearGradient>
</TouchableOpacity>
{/* 分隔线 */}
<View style={styles.divider}>
<View style={styles.dividerLine} />
<Text style={styles.dividerText}></Text>
<View style={styles.dividerLine} />
</View>
{/* 手动录入 - 次要选项 */}
<TouchableOpacity activeOpacity={0.9} onPress={onManualAdd}>
<View style={styles.manualCard}>
<View style={styles.manualLeft}>
<View style={styles.manualIconWrapper}>
<Ionicons name="create-outline" size={24} color="#6366f1" />
</View>
<View style={styles.manualTexts}>
<Text style={styles.manualTitle}></Text>
<Text style={styles.manualDescription}>
</Text>
</View>
</View>
<View style={styles.manualRight}>
<View style={styles.manualBadge}>
<Text style={styles.manualBadgeText}></Text>
</View>
<Ionicons name="chevron-forward" size={20} color="#94a3b8" />
</View>
</View>
</TouchableOpacity>
{/* 底部安全距离 */}
<View style={styles.safeArea} />
</Animated.View>
</Modal>
);
}
const styles = StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: 'transparent',
},
backdrop: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0,0,0,0.4)',
},
sheet: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
backgroundColor: '#fff',
borderTopLeftRadius: 32,
borderTopRightRadius: 32,
paddingTop: 24,
paddingHorizontal: 20,
shadowColor: '#000',
shadowOpacity: 0.15,
shadowRadius: 20,
shadowOffset: { width: 0, height: -8 },
elevation: 12,
},
// Header
header: {
flexDirection: 'row',
alignItems: 'flex-start',
justifyContent: 'space-between',
marginBottom: 24,
},
headerLeft: {
flex: 1,
},
title: {
fontSize: 24,
fontWeight: '700',
color: '#0f172a',
marginBottom: 4,
},
subtitle: {
fontSize: 14,
color: '#64748b',
fontWeight: '500',
},
closeButton: {
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: '#f1f5f9',
alignItems: 'center',
justifyContent: 'center',
marginLeft: 12,
},
// AI 卡片 - 主推荐
aiCard: {
borderRadius: 24,
padding: 20,
marginBottom: 20,
overflow: 'hidden',
shadowColor: '#0ea5e9',
shadowOpacity: 0.3,
shadowRadius: 16,
shadowOffset: { width: 0, height: 8 },
elevation: 8,
},
recommendBadge: {
position: 'absolute',
top: 16,
right: 16,
flexDirection: 'row',
alignItems: 'center',
gap: 4,
backgroundColor: 'rgba(255,255,255,0.25)',
paddingHorizontal: 10,
paddingVertical: 6,
borderRadius: 16,
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.3)',
},
recommendText: {
fontSize: 12,
fontWeight: '700',
color: '#fff',
},
aiContent: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 16,
},
aiLeft: {
flex: 1,
flexDirection: 'row',
gap: 16,
},
aiIconWrapper: {
width: 56,
height: 56,
borderRadius: 16,
backgroundColor: 'rgba(255,255,255,0.2)',
alignItems: 'center',
justifyContent: 'center',
borderWidth: 2,
borderColor: 'rgba(255,255,255,0.3)',
},
aiTexts: {
flex: 1,
gap: 8,
},
aiTitle: {
fontSize: 20,
fontWeight: '700',
color: '#fff',
},
aiDescription: {
fontSize: 14,
color: 'rgba(255,255,255,0.9)',
lineHeight: 20,
},
aiFeatures: {
flexDirection: 'row',
gap: 12,
marginTop: 4,
},
featureItem: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
},
featureText: {
fontSize: 12,
fontWeight: '600',
color: '#fff',
},
aiImage: {
width: 80,
height: 80,
marginLeft: 12,
},
aiFooter: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingTop: 12,
borderTopWidth: 1,
borderTopColor: 'rgba(255,255,255,0.2)',
},
aiFooterText: {
fontSize: 12,
color: 'rgba(255,255,255,0.8)',
fontWeight: '500',
},
// 分隔线
divider: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 20,
},
dividerLine: {
flex: 1,
height: 1,
backgroundColor: '#e2e8f0',
},
dividerText: {
fontSize: 13,
color: '#94a3b8',
fontWeight: '600',
marginHorizontal: 16,
},
// 手动录入卡片 - 次要选项
manualCard: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
backgroundColor: '#f8fafc',
borderRadius: 20,
padding: 16,
borderWidth: 1.5,
borderColor: '#e2e8f0',
},
manualLeft: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
manualIconWrapper: {
width: 48,
height: 48,
borderRadius: 14,
backgroundColor: '#eef2ff',
alignItems: 'center',
justifyContent: 'center',
},
manualTexts: {
flex: 1,
gap: 4,
},
manualTitle: {
fontSize: 16,
fontWeight: '700',
color: '#0f172a',
},
manualDescription: {
fontSize: 13,
color: '#64748b',
lineHeight: 18,
},
manualRight: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
marginLeft: 12,
},
manualBadge: {
backgroundColor: '#dcfce7',
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 8,
},
manualBadgeText: {
fontSize: 11,
fontWeight: '700',
color: '#16a34a',
},
// 底部安全距离
safeArea: {
height: 32,
},
});

View File

@@ -1,7 +1,7 @@
import { ThemedText } from '@/components/ThemedText';
import { useAppDispatch } from '@/hooks/redux';
import { useI18n } from '@/hooks/useI18n';
import { takeMedicationAction } from '@/store/medicationsSlice';
import { skipMedicationAction, takeMedicationAction } from '@/store/medicationsSlice';
import type { MedicationDisplayItem } from '@/types/medication';
import { Ionicons } from '@expo/vector-icons';
import dayjs, { Dayjs } from 'dayjs';
@@ -100,6 +100,64 @@ export function MedicationCard({ medication, colors, selectedDate, onOpenDetails
}
};
/**
* 处理跳过操作
*/
const handleSkipMedication = async () => {
// 检查 recordId 是否存在
if (!medication.recordId || isSubmitting) {
return;
}
// 显示二次确认弹窗
Alert.alert(
t('medications.card.skipAlert.title'),
t('medications.card.skipAlert.message'),
[
{
text: t('medications.card.skipAlert.cancel'),
style: 'cancel',
onPress: () => {
console.log('用户取消跳过');
},
},
{
text: t('medications.card.skipAlert.confirm'),
style: 'destructive',
onPress: () => {
executeSkipMedication(medication.recordId!);
},
},
]
);
};
/**
* 执行跳过操作
*/
const executeSkipMedication = async (recordId: string) => {
setIsSubmitting(true);
try {
// 调用 Redux action 标记为已跳过
await dispatch(skipMedicationAction({
recordId: recordId,
})).unwrap();
// 可选:显示成功提示
// Alert.alert('跳过成功', '已跳过本次用药');
} catch (error) {
console.error('[MEDICATION_CARD] 跳过操作失败', error);
Alert.alert(
t('medications.card.skipError.title'),
error instanceof Error ? error.message : t('medications.card.skipError.message'),
[{ text: t('medications.card.skipError.confirm') }]
);
} finally {
setIsSubmitting(false);
}
};
const renderStatusBadge = () => {
if (medication.status === 'missed') {
return (
@@ -122,12 +180,12 @@ export function MedicationCard({ medication, colors, selectedDate, onOpenDetails
const hours = Math.floor(timeDiffMinutes / 60);
const minutes = timeDiffMinutes % 60;
const formatted =
hours > 0 ? `${hours}小时${minutes > 0 ? `${minutes}分钟` : ''}` : `${minutes}分钟`;
hours > 0 ? `${hours}:${minutes > 0 ? `${minutes}` : ''}` : `${minutes}`;
return (
<View style={[styles.statusChip, styles.statusChipUpcoming]}>
<Ionicons name="time-outline" size={14} color="#fff" />
<ThemedText style={styles.statusChipText}>{t('medications.card.status.remaining', { time: formatted })}</ThemedText>
<Ionicons name="time-outline" size={10} color="#fff" />
<ThemedText style={styles.statusChipText}>{formatted}</ThemedText>
</View>
);
}
@@ -136,6 +194,7 @@ export function MedicationCard({ medication, colors, selectedDate, onOpenDetails
};
const renderAction = () => {
// 已服用状态
if (medication.status === 'taken') {
return (
<View style={[styles.actionButton, styles.actionButtonTaken]}>
@@ -145,32 +204,73 @@ export function MedicationCard({ medication, colors, selectedDate, onOpenDetails
);
}
// 只要没有服药,都可以显示立即服用
// 已跳过状态
if (medication.status === 'skipped') {
return (
<View style={[styles.actionButton, styles.actionButtonSkipped]}>
<Ionicons name="close-circle" size={18} color="#fff" />
<ThemedText style={styles.actionButtonText}>{t('medications.card.action.skipped')}</ThemedText>
</View>
);
}
// 待服用或已错过状态,显示操作按钮
return (
<TouchableOpacity
activeOpacity={0.7}
onPress={handleTakeMedication}
disabled={isSubmitting}
>
{isLiquidGlassAvailable() ? (
<GlassView
style={[styles.actionButton, styles.actionButtonUpcoming]}
glassEffectStyle="clear"
tintColor="rgba(19, 99, 255, 0.3)"
isInteractive={!isSubmitting}
>
<ThemedText style={styles.actionButtonText}>
{isSubmitting ? t('medications.card.action.submitting') : t('medications.card.action.takeNow')}
</ThemedText>
</GlassView>
) : (
<View style={[styles.actionButton, styles.actionButtonUpcoming, styles.fallbackActionButton]}>
<ThemedText style={styles.actionButtonText}>
{isSubmitting ? t('medications.card.action.submitting') : t('medications.card.action.takeNow')}
</ThemedText>
</View>
)}
</TouchableOpacity>
<View style={styles.actionButtonsRow}>
{/* 跳过按钮 */}
<TouchableOpacity
activeOpacity={0.7}
onPress={handleSkipMedication}
disabled={isSubmitting}
style={styles.skipButtonWrapper}
>
{isLiquidGlassAvailable() ? (
<GlassView
style={[styles.actionButton, styles.actionButtonSkip]}
glassEffectStyle="clear"
tintColor="rgba(156, 163, 175, 0.2)"
isInteractive={!isSubmitting}
>
<ThemedText style={styles.actionButtonTextSkip}>
{t('medications.card.action.skip')}
</ThemedText>
</GlassView>
) : (
<View style={[styles.actionButton, styles.actionButtonSkip, styles.fallbackActionButtonSkip]}>
<ThemedText style={styles.actionButtonTextSkip}>
{t('medications.card.action.skip')}
</ThemedText>
</View>
)}
</TouchableOpacity>
{/* 立即服用按钮 */}
<TouchableOpacity
activeOpacity={0.7}
onPress={handleTakeMedication}
disabled={isSubmitting}
style={styles.takeButtonWrapper}
>
{isLiquidGlassAvailable() ? (
<GlassView
style={[styles.actionButton, styles.actionButtonUpcoming]}
glassEffectStyle="clear"
tintColor="rgba(19, 99, 255, 0.3)"
isInteractive={!isSubmitting}
>
<ThemedText style={styles.actionButtonText}>
{isSubmitting ? t('medications.card.action.submitting') : t('medications.card.action.takeNow')}
</ThemedText>
</GlassView>
) : (
<View style={[styles.actionButton, styles.actionButtonUpcoming, styles.fallbackActionButton]}>
<ThemedText style={styles.actionButtonText}>
{isSubmitting ? t('medications.card.action.submitting') : t('medications.card.action.takeNow')}
</ThemedText>
</View>
)}
</TouchableOpacity>
</View>
);
};
@@ -227,11 +327,11 @@ export function MedicationCard({ medication, colors, selectedDate, onOpenDetails
const styles = StyleSheet.create({
card: {
borderRadius: 18,
borderRadius: 24,
position: 'relative',
},
cardSurface: {
borderRadius: 18,
borderRadius: 24,
overflow: 'hidden',
},
cardBody: {
@@ -254,7 +354,7 @@ const styles = StyleSheet.create({
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
borderRadius: 18,
borderRadius: 24,
},
thumbnailImage: {
width: '70%',
@@ -265,8 +365,9 @@ const styles = StyleSheet.create({
flex: 1,
},
cardTitle: {
fontSize: 16,
fontWeight: '700',
fontSize: 15,
fontWeight: '600',
maxWidth: '70%',
},
cardDosage: {
fontSize: 12,
@@ -286,6 +387,16 @@ const styles = StyleSheet.create({
actionContainer: {
marginTop: 8,
},
actionButtonsRow: {
flexDirection: 'row',
gap: 8,
},
skipButtonWrapper: {
flex: 1,
},
takeButtonWrapper: {
flex: 2,
},
actionButton: {
alignSelf: 'stretch',
flexDirection: 'row',
@@ -302,6 +413,12 @@ const styles = StyleSheet.create({
actionButtonTaken: {
backgroundColor: '#1FBF4B',
},
actionButtonSkipped: {
backgroundColor: '#9CA3AF',
},
actionButtonSkip: {
backgroundColor: '#E5E7EB',
},
actionButtonMissed: {
backgroundColor: '#9CA3AF',
},
@@ -310,6 +427,11 @@ const styles = StyleSheet.create({
borderColor: 'rgba(19, 99, 255, 0.3)',
backgroundColor: 'rgba(19, 99, 255, 0.9)',
},
fallbackActionButtonSkip: {
borderWidth: 1,
borderColor: 'rgba(156, 163, 175, 0.2)',
backgroundColor: 'rgba(229, 231, 235, 0.9)',
},
fallbackActionButtonMissed: {
borderWidth: 1,
borderColor: 'rgba(156, 163, 175, 0.3)',
@@ -320,6 +442,11 @@ const styles = StyleSheet.create({
fontWeight: '700',
color: '#fff',
},
actionButtonTextSkip: {
fontSize: 14,
fontWeight: '600',
color: '#6B7280',
},
actionButtonTextMissed: {
fontSize: 14,
fontWeight: '700',
@@ -340,6 +467,7 @@ const styles = StyleSheet.create({
borderTopRightRadius: 0,
borderBottomRightRadius: 0,
backgroundColor: '#1363FF',
zIndex: 1
},
statusChipUpcoming: {
backgroundColor: '#1363FF',
@@ -348,7 +476,7 @@ const styles = StyleSheet.create({
backgroundColor: '#FF3B30',
},
statusChipText: {
fontSize: 10,
fontSize: 9,
fontWeight: '600',
color: '#fff',
},

View File

@@ -0,0 +1,272 @@
import { useI18n } from '@/hooks/useI18n';
import type { MedicationDisplayItem } from '@/types/medication';
import React, { useEffect } from 'react';
import { StyleSheet, TouchableOpacity, View } from 'react-native';
import Animated, {
Extrapolation,
interpolate,
type SharedValue,
useAnimatedStyle,
useSharedValue,
withSpring,
} from 'react-native-reanimated';
import { MedicationCard } from './MedicationCard';
type Props = {
medications: MedicationDisplayItem[];
colors: (typeof import('@/constants/Colors').Colors)[keyof typeof import('@/constants/Colors').Colors];
selectedDate: any;
onOpenDetails: (medication: MedicationDisplayItem) => void;
onCelebrate?: () => void;
};
const STACK_OFFSET = 12;
const STACK_SCALE_STEP = 0.04;
const MAX_STACK_VISIBLE = 3;
export function TakenMedicationsStack({
medications,
colors,
selectedDate,
onOpenDetails,
onCelebrate,
}: Props) {
const { t } = useI18n();
const [isExpanded, setIsExpanded] = React.useState(false);
const progress = useSharedValue(0);
useEffect(() => {
progress.value = withSpring(isExpanded ? 1 : 0, {
damping: 20,
stiffness: 200, // Faster spring
mass: 0.8,
});
}, [isExpanded, progress]);
const handleToggle = () => {
setIsExpanded(!isExpanded);
};
// Header arrow rotation style
const arrowStyle = useAnimatedStyle(() => {
return {
transform: [
{
rotate: `${interpolate(progress.value, [0, 1], [0, 180])}deg`,
},
],
};
});
if (medications.length === 0) {
return null;
}
return (
<View style={styles.container}>
{/* Stack/List Container */}
<View style={[styles.stackContainer, { minHeight: isExpanded ? undefined : 130 }]}>
{medications.map((item, index) => (
<CardItem
key={item.id || index}
item={item}
index={index}
total={medications.length}
progress={progress}
isExpanded={isExpanded}
colors={colors}
selectedDate={selectedDate}
onOpenDetails={onOpenDetails}
onCelebrate={onCelebrate}
onToggle={handleToggle}
/>
))}
</View>
</View>
);
}
const CardItem = ({
item,
index,
total,
progress,
isExpanded,
colors,
selectedDate,
onOpenDetails,
onCelebrate,
onToggle,
}: {
item: MedicationDisplayItem;
index: number;
total: number;
progress: SharedValue<number>;
isExpanded: boolean;
colors: (typeof import('@/constants/Colors').Colors)[keyof typeof import('@/constants/Colors').Colors];
selectedDate: any;
onOpenDetails: (medication: MedicationDisplayItem) => void;
onCelebrate?: () => void;
onToggle: () => void;
}) => {
// Only render top 3 cards when collapsed to save performance/visuals
// But we need to render all when expanding.
// We'll hide index >= MAX_STACK_VISIBLE when collapsed via opacity/zIndex.
const style = useAnimatedStyle(() => {
// Stack state (progress = 0)
const stackTranslateY = index * STACK_OFFSET;
const stackScale = 1 - index * STACK_SCALE_STEP;
const stackOpacity = index < MAX_STACK_VISIBLE ? 1 - index * 0.15 : 0;
const stackZIndex = total - index;
// List state (progress = 1)
// In list state, we rely on layout (relative positioning).
// However, to animate smoothly from absolute (stack) to relative (list),
// we need a strategy.
// Strategy: Always Absolute? No, height is dynamic.
// Strategy: Use negative margins for stack?
// Let's try:
// Collapsed: marginTop = -(height - offset).
// Expanded: marginTop = 16 (gap).
// But we don't know height.
// Alternative:
// Use 'top' offset relative to the first card?
// This is hard without measuring.
// Let's go with the "Transform" approach assuming standard card height for the stack effect,
// but switching to relative layout when expanded.
// Wait, switching 'position' prop is not animatable by useAnimatedStyle directly (requires Layout Animation).
// Let's keep it simple:
// When collapsed (progress 0):
// Items > 0 are absolutely positioned relative to the container (which wraps them all).
// Item 0 is relative.
// When expanded (progress 1):
// All items are relative.
// To smooth this, we can use interpolate for translateY.
return {
zIndex: stackZIndex,
opacity: interpolate(progress.value, [0, 1], [stackOpacity, 1]),
transform: [
{
scale: interpolate(progress.value, [0, 1], [stackScale, 1]),
},
{
translateY: interpolate(
progress.value,
[0, 1],
[stackTranslateY, 0] // In stack, they go down. In list, translation is 0 (relative flow handles pos).
),
},
],
};
});
// Logic for positioning:
// We'll use a container View for each card.
// When collapsed, the container height for index > 0 should be 0?
// That would pull them up.
const containerStyle = useAnimatedStyle(() => {
// We can animate the height of the wrapper view.
// But we don't know the content height.
// Assuming ~140px for card.
const approxHeight = 140;
if (index === 0) return {}; // First card always takes space
// For others:
// Collapsed: height is 0 (so they stack on top of first one, roughly)
// Expanded: height is 'auto' (we can't animate to auto easily in RN without LayoutAnimation)
return {
marginTop: interpolate(progress.value, [0, 1], [-approxHeight + STACK_OFFSET, 16], Extrapolation.CLAMP),
};
});
// Using Layout Animation for the actual position change support
// requires the parent to handle it.
// Simpler Visual Hack:
// When collapsed, we just set marginTop to a negative value that overlaps them.
// Since MedicationCard is roughly constant height, we can tune this.
// MedicationCard height is roughly 130-150.
// Let's guess -130 + 12.
const cardContainerStyle = useAnimatedStyle(() => {
// We assume a fixed height for the negative margin calculation logic.
// A better way is needed if heights vary wildly.
// But for now, let's use a safe estimated overlap.
const cardHeight = 140;
const collapsedMarginTop = index === 0 ? 0 : -(cardHeight - STACK_OFFSET);
const expandedMarginTop = index === 0 ? 0 : 16;
return {
marginTop: interpolate(progress.value, [0, 1], [collapsedMarginTop, expandedMarginTop]),
zIndex: total - index,
};
});
return (
<Animated.View style={[cardContainerStyle, style]}>
{/* When collapsed, clicking any card should expand. When expanded, open details. */}
{/* We can intercept touches if !isExpanded */}
<View style={{ position: 'relative' }}>
{/* Overlay to intercept clicks when collapsed */}
{!isExpanded && (
<TouchableOpacity
style={[StyleSheet.absoluteFill, { zIndex: 100, elevation: 100 }]}
onPress={onToggle}
activeOpacity={0.9}
/>
)}
<MedicationCard
medication={item}
colors={colors}
selectedDate={selectedDate}
onOpenDetails={isExpanded ? onOpenDetails : undefined} // Disable inner click when collapsed
onCelebrate={onCelebrate}
/>
</View>
</Animated.View>
);
};
const styles = StyleSheet.create({
container: {
marginTop: 8,
gap: 12,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 8,
paddingHorizontal: 4,
},
headerContent: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
iconContainer: {
width: 28,
height: 28,
borderRadius: 14,
alignItems: 'center',
justifyContent: 'center',
},
headerTitle: {
fontSize: 16,
fontWeight: '600',
},
stackContainer: {
position: 'relative',
// minHeight ensures space for the stack when collapsed
},
});

View File

@@ -0,0 +1,205 @@
import { ThemedText } from '@/components/ThemedText';
import { Colors } from '@/constants/Colors';
import { useColorScheme } from '@/hooks/useColorScheme';
import { useI18n } from '@/hooks/useI18n';
import DateTimePicker from '@react-native-community/datetimepicker';
import dayjs from 'dayjs';
import React, { useCallback, useEffect, useState } from 'react';
import { Alert, Modal, Platform, Pressable, StyleSheet, View } from 'react-native';
interface ExpiryDatePickerModalProps {
visible: boolean;
currentDate: Date | null;
onClose: () => void;
onConfirm: (date: Date) => void;
isAiDraft?: boolean;
}
/**
* 有效期日期选择器组件
*
* 功能:
* - 显示日期选择器弹窗
* - 验证日期不能早于今天
* - iOS 显示内联日历Android 显示原生对话框
* - 支持取消和确认操作
*/
export function ExpiryDatePickerModal({
visible,
currentDate,
onClose,
onConfirm,
isAiDraft = false,
}: ExpiryDatePickerModalProps) {
const { t } = useI18n();
const scheme = (useColorScheme() ?? 'light') as keyof typeof Colors;
const colors = Colors[scheme];
// 内部状态:选择的日期值
const [selectedDate, setSelectedDate] = useState<Date>(currentDate || new Date());
// 当弹窗显示时,同步当前日期
useEffect(() => {
if (visible) {
setSelectedDate(currentDate || new Date());
}
}, [visible, currentDate]);
/**
* 处理日期变化
* iOS: 实时更新选择的日期
* Android: 在用户点击确定时直接确认
*/
const handleDateChange = useCallback(
(event: any, date?: Date) => {
if (Platform.OS === 'ios') {
// iOS: 实时更新内部状态
if (date) {
setSelectedDate(date);
}
} else {
// Android: 处理用户操作
if (event.type === 'set' && date) {
// 用户点击确定
validateAndConfirm(date);
} else {
// 用户点击取消
onClose();
}
}
},
[onClose]
);
/**
* 验证并确认日期
*/
const validateAndConfirm = useCallback(
(dateToConfirm: Date) => {
// 验证有效期不能早于今天
const today = new Date();
today.setHours(0, 0, 0, 0);
const selected = new Date(dateToConfirm);
selected.setHours(0, 0, 0, 0);
if (selected < today) {
Alert.alert('日期无效', '有效期不能早于今天');
return;
}
// 检查日期是否真的发生了变化
const currentExpiry = currentDate ? dayjs(currentDate).format('YYYY-MM-DD') : null;
const newExpiry = dayjs(dateToConfirm).format('YYYY-MM-DD');
if (currentExpiry === newExpiry) {
// 日期没有变化,直接关闭
onClose();
return;
}
// 日期有效且发生了变化,执行确认回调
onConfirm(dateToConfirm);
onClose();
},
[currentDate, onClose, onConfirm]
);
/**
* iOS 平台的确认按钮处理
*/
const handleIOSConfirm = useCallback(() => {
validateAndConfirm(selectedDate);
}, [selectedDate, validateAndConfirm]);
return (
<Modal
visible={visible}
transparent
animationType="fade"
onRequestClose={onClose}
>
<Pressable style={styles.backdrop} onPress={onClose} />
<View style={[styles.sheet, { backgroundColor: colors.surface }]}>
<ThemedText style={[styles.title, { color: colors.text }]}>
</ThemedText>
<DateTimePicker
value={selectedDate}
mode="date"
display={Platform.OS === 'ios' ? 'inline' : 'calendar'}
minimumDate={new Date()}
onChange={handleDateChange}
locale="zh-CN"
/>
{/* iOS 平台显示确认和取消按钮 */}
{Platform.OS === 'ios' && (
<View style={styles.actions}>
<Pressable
onPress={onClose}
style={[styles.btn, { borderColor: colors.border }]}
>
<ThemedText style={[styles.btnText, { color: colors.textSecondary }]}>
{t('medications.detail.pickers.cancel')}
</ThemedText>
</Pressable>
<Pressable
onPress={handleIOSConfirm}
style={[styles.btn, styles.btnPrimary, { backgroundColor: colors.primary }]}
>
<ThemedText style={[styles.btnText, { color: colors.onPrimary }]}>
{t('medications.detail.pickers.confirm')}
</ThemedText>
</Pressable>
</View>
)}
</View>
</Modal>
);
}
const styles = StyleSheet.create({
backdrop: {
flex: 1,
backgroundColor: 'rgba(15, 23, 42, 0.4)',
},
sheet: {
position: 'absolute',
left: 20,
right: 20,
bottom: 40,
borderRadius: 24,
padding: 20,
shadowColor: '#000',
shadowOffset: { width: 0, height: 10 },
shadowOpacity: 0.2,
shadowRadius: 20,
elevation: 8,
},
title: {
fontSize: 20,
fontWeight: '700',
marginBottom: 20,
textAlign: 'center',
},
actions: {
flexDirection: 'row',
gap: 12,
marginTop: 16,
},
btn: {
flex: 1,
paddingVertical: 14,
borderRadius: 16,
alignItems: 'center',
borderWidth: 1,
},
btnPrimary: {
borderWidth: 0,
},
btnText: {
fontSize: 16,
fontWeight: '600',
},
});

View File

@@ -0,0 +1,276 @@
import { useI18n } from '@/hooks/useI18n';
import { Ionicons } from '@expo/vector-icons';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient';
import React from 'react';
import {
Dimensions,
Modal,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native';
const { width: SCREEN_WIDTH } = Dimensions.get('window');
interface MedicationPhotoGuideModalProps {
visible: boolean;
onClose: () => void;
}
/**
* 药品拍摄指南弹窗组件
* 展示如何正确拍摄药品照片的说明和示例
*/
export function MedicationPhotoGuideModal({ visible, onClose }: MedicationPhotoGuideModalProps) {
const { t } = useI18n();
return (
<Modal
visible={visible}
transparent={true}
animationType="fade"
onRequestClose={onClose}
>
<TouchableOpacity
style={styles.modalOverlay}
activeOpacity={1}
onPress={onClose}
>
<TouchableOpacity
activeOpacity={1}
onPress={(e) => e.stopPropagation()}
style={styles.guideModalContainer}
>
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.guideModalContent}
>
{/* 标题部分 */}
<View style={styles.guideHeader}>
<Text style={styles.guideStepBadge}>
{t('medications.aiCamera.guideModal.badge')}
</Text>
<Text style={styles.guideTitle}>
{t('medications.aiCamera.guideModal.title')}
</Text>
</View>
{/* 示例图片 */}
<View style={styles.guideImagesContainer}>
{/* 正确示例 */}
<View style={styles.guideImageWrapper}>
<View style={styles.guideImageBox}>
<Ionicons
name="checkmark-circle"
size={32}
color="#4CAF50"
style={styles.guideImageIcon}
/>
<Image
source={require('@/assets/images/medicine/image-medicine.png')}
style={styles.guideImage}
contentFit="cover"
/>
</View>
<View style={styles.guideImageIndicator}>
<Ionicons name="checkmark-circle" size={20} color="#4CAF50" />
</View>
</View>
{/* 错误示例 */}
<View style={styles.guideImageWrapper}>
<View style={[styles.guideImageBox, styles.guideImageBoxBlur]}>
<Ionicons
name="close-circle"
size={32}
color="#F44336"
style={styles.guideImageIcon}
/>
<Image
source={require('@/assets/images/medicine/image-medicine.png')}
style={[styles.guideImage, { opacity: 0.5 }]}
contentFit="cover"
blurRadius={8}
/>
</View>
<View style={[styles.guideImageIndicator, styles.guideImageIndicatorError]}>
<Ionicons name="close-circle" size={20} color="#F44336" />
</View>
</View>
</View>
{/* 说明文字 */}
<View style={styles.guideDescription}>
<Text style={styles.guideDescriptionText}>
{t('medications.aiCamera.guideModal.description1')}
</Text>
<Text style={styles.guideDescriptionText}>
{t('medications.aiCamera.guideModal.description2')}
</Text>
</View>
{/* 确认按钮 */}
<TouchableOpacity
onPress={onClose}
activeOpacity={0.8}
>
{isLiquidGlassAvailable() ? (
<GlassView
style={styles.guideConfirmButton}
glassEffectStyle="regular"
tintColor="rgba(255, 179, 0, 0.9)"
isInteractive={true}
>
<LinearGradient
colors={['rgba(255, 179, 0, 0.95)', 'rgba(255, 160, 0, 0.95)']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.guideConfirmButtonGradient}
>
<Text style={styles.guideConfirmButtonText}>
{t('medications.aiCamera.guideModal.button')}
</Text>
</LinearGradient>
</GlassView>
) : (
<View style={styles.guideConfirmButton}>
<LinearGradient
colors={['#FFB300', '#FFA000']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.guideConfirmButtonGradient}
>
<Text style={styles.guideConfirmButtonText}>
{t('medications.aiCamera.guideModal.button')}
</Text>
</LinearGradient>
</View>
)}
</TouchableOpacity>
</ScrollView>
</TouchableOpacity>
</TouchableOpacity>
</Modal>
);
}
const styles = StyleSheet.create({
modalOverlay: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.5)',
},
guideModalContainer: {
width: SCREEN_WIDTH - 48,
maxHeight: '80%',
backgroundColor: '#FFFFFF',
borderRadius: 24,
overflow: 'hidden',
shadowColor: '#000',
shadowOpacity: 0.25,
shadowRadius: 20,
shadowOffset: { width: 0, height: 10 },
elevation: 10,
},
guideModalContent: {
padding: 24,
},
guideHeader: {
alignItems: 'center',
marginBottom: 24,
},
guideStepBadge: {
fontSize: 16,
fontWeight: '700',
color: '#FFB300',
marginBottom: 8,
},
guideTitle: {
fontSize: 22,
fontWeight: '700',
color: '#0f172a',
textAlign: 'center',
},
guideImagesContainer: {
flexDirection: 'row',
justifyContent: 'space-between',
marginBottom: 24,
gap: 12,
},
guideImageWrapper: {
flex: 1,
alignItems: 'center',
},
guideImageBox: {
width: '100%',
aspectRatio: 1,
borderRadius: 16,
overflow: 'hidden',
backgroundColor: '#f8fafc',
position: 'relative',
borderWidth: 2,
borderColor: '#4CAF50',
},
guideImageBoxBlur: {
borderColor: '#F44336',
},
guideImage: {
width: '100%',
height: '100%',
},
guideImageIcon: {
position: 'absolute',
top: 8,
left: 8,
zIndex: 1,
},
guideImageIndicator: {
marginTop: 12,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(76, 175, 80, 0.1)',
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 12,
},
guideImageIndicatorError: {
backgroundColor: 'rgba(244, 67, 54, 0.1)',
},
guideDescription: {
backgroundColor: '#f8fafc',
borderRadius: 16,
padding: 16,
marginBottom: 24,
},
guideDescriptionText: {
fontSize: 14,
lineHeight: 22,
color: '#475569',
marginBottom: 8,
},
guideConfirmButton: {
borderRadius: 16,
overflow: 'hidden',
shadowColor: '#FFB300',
shadowOpacity: 0.3,
shadowRadius: 12,
shadowOffset: { width: 0, height: 6 },
elevation: 6,
},
guideConfirmButtonGradient: {
paddingVertical: 16,
alignItems: 'center',
justifyContent: 'center',
},
guideConfirmButtonText: {
fontSize: 18,
fontWeight: '700',
color: '#FFFFFF',
},
});

View File

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

View File

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

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

View File

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

View File

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

View File

@@ -207,6 +207,7 @@ const styles = StyleSheet.create({
fontWeight: '600',
color: '#192126',
marginBottom: 16,
fontFamily: 'AliBold',
},
measurementsContainer: {
flexDirection: 'row',
@@ -221,6 +222,7 @@ const styles = StyleSheet.create({
color: '#888',
marginBottom: 8,
textAlign: 'center',
fontFamily: 'AliRegular',
},
valueContainer: {
backgroundColor: '#F5F5F7',
@@ -236,6 +238,7 @@ const styles = StyleSheet.create({
fontWeight: '600',
color: '#192126',
textAlign: 'center',
fontFamily: 'AliBold',
},
});

View File

@@ -72,6 +72,7 @@ const styles = StyleSheet.create({
fontSize: 14,
color: '#192126',
fontWeight: '600',
fontFamily: 'AliBold',
},
valueContainer: {
flexDirection: 'row',
@@ -81,6 +82,7 @@ const styles = StyleSheet.create({
fontSize: 16,
fontWeight: '600',
color: '#192126',
fontFamily: 'AliBold',
},
unit: {
fontSize: 12,
@@ -88,6 +90,7 @@ const styles = StyleSheet.create({
marginLeft: 4,
marginBottom: 2,
fontWeight: '500',
fontFamily: 'AliRegular',
},
});

View File

@@ -1,7 +1,8 @@
import { fetchOxygenSaturation } from '@/utils/health';
import { useFocusEffect } from '@react-navigation/native';
import { ensureHealthPermissions, fetchOxygenSaturation } from '@/utils/health';
import { HealthKitUtils } from '@/utils/healthKit';
import { useIsFocused } from '@react-navigation/native';
import dayjs from 'dayjs';
import React, { useCallback, useRef, useState } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import HealthDataCard from './HealthDataCard';
@@ -15,42 +16,52 @@ const OxygenSaturationCard: React.FC<OxygenSaturationCardProps> = ({
selectedDate
}) => {
const { t } = useTranslation();
const isFocused = useIsFocused();
const [oxygenSaturation, setOxygenSaturation] = useState<number | null>(null);
const [loading, setLoading] = useState(false);
const loadingRef = useRef(false);
// 获取血氧饱和度数据 - 在页面聚焦、日期变化时触发
useFocusEffect(
useCallback(() => {
const loadOxygenSaturationData = async () => {
const dateToUse = selectedDate || new Date();
useEffect(() => {
const loadOxygenSaturationData = async () => {
const dateToUse = selectedDate || new Date();
// 防止重复请求
if (loadingRef.current) return;
if (!isFocused) return;
if (!HealthKitUtils.isAvailable()) {
setOxygenSaturation(null);
return;
}
try {
loadingRef.current = true;
setLoading(true);
// 防止重复请求
if (loadingRef.current) return;
const options = {
startDate: dayjs(dateToUse).startOf('day').toDate().toISOString(),
endDate: dayjs(dateToUse).endOf('day').toDate().toISOString()
};
try {
loadingRef.current = true;
setLoading(true);
const data = await fetchOxygenSaturation(options);
setOxygenSaturation(data);
} catch (error) {
console.error('OxygenSaturationCard: Failed to get blood oxygen data:', error);
const hasPermission = await ensureHealthPermissions();
if (!hasPermission) {
setOxygenSaturation(null);
} finally {
setLoading(false);
loadingRef.current = false;
return;
}
};
loadOxygenSaturationData();
}, [selectedDate])
);
const options = {
startDate: dayjs(dateToUse).startOf('day').toDate().toISOString(),
endDate: dayjs(dateToUse).endOf('day').toDate().toISOString()
};
const data = await fetchOxygenSaturation(options);
setOxygenSaturation(data);
} catch (error) {
console.error('OxygenSaturationCard: Failed to get blood oxygen data:', error);
setOxygenSaturation(null);
} finally {
setLoading(false);
loadingRef.current = false;
}
};
loadOxygenSaturationData();
}, [isFocused, selectedDate]);
return (
<HealthDataCard
@@ -62,4 +73,4 @@ const OxygenSaturationCard: React.FC<OxygenSaturationCardProps> = ({
);
};
export default OxygenSaturationCard;
export default OxygenSaturationCard;

View File

@@ -127,12 +127,14 @@ const styles = StyleSheet.create({
fontSize: 14,
color: '#192126',
fontWeight: '600',
fontFamily: 'AliBold',
},
sleepValue: {
fontSize: 16,
color: '#1E40AF',
fontWeight: '700',
marginTop: 8,
fontFamily: 'AliBold',
},
});

View File

@@ -1,10 +1,13 @@
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import * as Haptics from 'expo-haptics';
import React, { useEffect, useRef, useState } from 'react';
import {
ActivityIndicator,
Animated,
Dimensions,
KeyboardAvoidingView,
Modal,
Platform,
StyleSheet,
Text,
TouchableOpacity,
@@ -24,6 +27,7 @@ interface ConfirmationSheetProps {
cancelText?: string;
destructive?: boolean;
loading?: boolean;
content?: React.ReactNode;
}
export function ConfirmationSheet({
@@ -36,11 +40,13 @@ export function ConfirmationSheet({
cancelText = '取消',
destructive = false,
loading = false,
content,
}: ConfirmationSheetProps) {
const insets = useSafeAreaInsets();
const translateY = useRef(new Animated.Value(screenHeight)).current;
const backdropOpacity = useRef(new Animated.Value(0)).current;
const [modalVisible, setModalVisible] = useState(visible);
const isGlassAvailable = isLiquidGlassAvailable();
useEffect(() => {
if (visible) {
@@ -116,7 +122,10 @@ export function ConfirmationSheet({
onRequestClose={onClose}
statusBarTranslucent
>
<View style={styles.overlay}>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
style={styles.overlay}
>
<Animated.View
style={[
styles.backdrop,
@@ -140,35 +149,67 @@ export function ConfirmationSheet({
<View style={styles.handle} />
<Text style={styles.title}>{title}</Text>
{description ? <Text style={styles.description}>{description}</Text> : null}
{content}
<View style={styles.actions}>
<TouchableOpacity
style={styles.cancelButton}
style={[styles.buttonContainer, loading && styles.disabledButton]}
activeOpacity={0.85}
onPress={handleCancel}
disabled={loading}
>
<Text style={styles.cancelText}>{cancelText}</Text>
{isGlassAvailable ? (
<GlassView
style={styles.glassButton}
glassEffectStyle="regular"
tintColor="rgba(241, 245, 249, 0.6)"
isInteractive
>
<Text style={styles.cancelText}>{cancelText}</Text>
</GlassView>
) : (
<View style={styles.cancelButton}>
<Text style={styles.cancelText}>{cancelText}</Text>
</View>
)}
</TouchableOpacity>
<TouchableOpacity
style={[
styles.confirmButton,
destructive ? styles.destructiveButton : styles.primaryButton,
loading && styles.disabledButton,
]}
style={[styles.buttonContainer, loading && styles.disabledButton]}
activeOpacity={0.85}
onPress={handleConfirm}
disabled={loading}
>
{loading ? (
<ActivityIndicator color="#fff" />
{isGlassAvailable ? (
<GlassView
style={styles.glassButton}
glassEffectStyle="regular"
tintColor={destructive ? 'rgba(239, 68, 68, 0.85)' : 'rgba(37, 99, 235, 0.85)'}
isInteractive
>
{loading ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={styles.confirmText}>{confirmText}</Text>
)}
</GlassView>
) : (
<Text style={styles.confirmText}>{confirmText}</Text>
<View
style={[
styles.confirmButton,
destructive ? styles.destructiveButton : styles.primaryButton,
]}
>
{loading ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={styles.confirmText}>{confirmText}</Text>
)}
</View>
)}
</TouchableOpacity>
</View>
</Animated.View>
</View>
</KeyboardAvoidingView>
</Modal>
);
}
@@ -221,8 +262,17 @@ const styles = StyleSheet.create({
gap: 12,
marginTop: 8,
},
cancelButton: {
buttonContainer: {
flex: 1,
},
glassButton: {
height: 56,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
},
cancelButton: {
height: 56,
borderRadius: 18,
borderWidth: 1,
@@ -237,7 +287,6 @@ const styles = StyleSheet.create({
color: '#111827',
},
confirmButton: {
flex: 1,
height: 56,
borderRadius: 18,
alignItems: 'center',

View File

@@ -30,6 +30,9 @@ const MAPPING = {
'info.circle': 'info',
'magnifyingglass': 'search',
'xmark': 'close',
'chevron.left': 'chevron-left',
'sparkles': 'auto-awesome',
'arrow.clockwise': 'refresh',
} as IconMapping;
/**

View File

@@ -88,13 +88,19 @@ export function MedicalDisclaimerSheet({
}, [visible, modalVisible, backdropOpacity, translateY]);
const handleCancel = () => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
// 安全地执行触觉反馈,避免因触觉反馈失败导致页面卡顿
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch((error) => {
console.warn('[MEDICATION] Haptic feedback failed:', error);
});
onClose();
};
const handleConfirm = () => {
if (loading) return;
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
// 安全地执行触觉反馈,避免因触觉反馈失败导致页面卡顿
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch((error) => {
console.warn('[MEDICATION] Haptic feedback failed:', error);
});
onConfirm();
};

View File

@@ -0,0 +1,454 @@
import { Ionicons } from '@expo/vector-icons';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import * as Haptics from 'expo-haptics';
import { Image } from 'expo-image';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import {
ActivityIndicator,
Animated,
BackHandler,
Dimensions,
Modal,
StyleSheet,
Text,
TouchableOpacity,
View
} from 'react-native';
import ImageViewing from 'react-native-image-viewing';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useI18n } from '../../hooks/useI18n';
import { triggerLightHaptic } from '../../utils/haptics';
const { height: screenHeight } = Dimensions.get('window');
interface MedicationAiSummaryInfoSheetProps {
visible: boolean;
onClose: () => void;
onConfirm: () => void;
loading?: boolean;
}
/**
* AI 用药总结介绍弹窗组件
* 用于展示 AI 用药总结功能的介绍和说明
*/
export function MedicationAiSummaryInfoSheet({
visible,
onClose,
onConfirm,
loading = false,
}: MedicationAiSummaryInfoSheetProps) {
const { t } = useI18n();
const insets = useSafeAreaInsets();
const translateY = useRef(new Animated.Value(screenHeight)).current;
const backdropOpacity = useRef(new Animated.Value(0)).current;
const [showImagePreview, setShowImagePreview] = useState(false);
// 预览图片 - 直接使用 require 资源
const imageSource = require('@/assets/images/medicine/medicine-ai-summary.png');
useEffect(() => {
if (visible) {
translateY.setValue(screenHeight);
backdropOpacity.setValue(0);
Animated.parallel([
Animated.timing(backdropOpacity, {
toValue: 1,
duration: 200,
useNativeDriver: true,
}),
Animated.spring(translateY, {
toValue: 0,
useNativeDriver: true,
bounciness: 6,
speed: 12,
}),
]).start();
} else {
Animated.parallel([
Animated.timing(backdropOpacity, {
toValue: 0,
duration: 150,
useNativeDriver: true,
}),
Animated.timing(translateY, {
toValue: screenHeight,
duration: 240,
useNativeDriver: true,
}),
]).start(() => {
translateY.setValue(screenHeight);
backdropOpacity.setValue(0);
});
}
}, [visible, backdropOpacity, translateY]);
// 处理Android返回键关闭图片预览
useEffect(() => {
const backHandler = BackHandler.addEventListener('hardwareBackPress', () => {
if (showImagePreview) {
setShowImagePreview(false);
return true; // 阻止默认返回行为
}
return false;
});
return () => backHandler.remove();
}, [showImagePreview]);
// 处理图片预览
const handleImagePreview = useCallback(() => {
triggerLightHaptic();
setShowImagePreview(true);
}, []);
const handleClose = () => {
// 安全地执行触觉反馈,避免因触觉反馈失败导致页面卡顿
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch((error) => {
console.warn('[AI_SUMMARY] Haptic feedback failed:', error);
});
onClose();
};
const handleConfirm = () => {
if (loading) return;
// 安全地执行触觉反馈,避免因触觉反馈失败导致页面卡顿
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch((error) => {
console.warn('[AI_SUMMARY] Haptic feedback failed:', error);
});
onConfirm();
};
if (!visible) {
return null;
}
return (
<Modal
visible={visible}
transparent
animationType="none"
onRequestClose={handleClose}
statusBarTranslucent
>
<View style={styles.overlay}>
<Animated.View
style={[
styles.backdrop,
{
opacity: backdropOpacity,
},
]}
>
<TouchableOpacity style={StyleSheet.absoluteFillObject} activeOpacity={1} onPress={handleClose} />
</Animated.View>
<Animated.View
style={[
styles.sheet,
{
transform: [{ translateY }],
paddingBottom: Math.max(insets.bottom, 20),
},
]}
>
<View style={styles.handle} />
{/* 图标和标题 */}
<View style={styles.header}>
<View style={styles.iconContainer}>
<Ionicons name="sparkles" size={24} color="#8B5CF6" />
</View>
<Text style={styles.title}>{t('medications.aiSummaryInfo.title')}</Text>
</View>
{/* 介绍图片区域 */}
<View style={styles.imageContainer}>
<Image
source={require('@/assets/images/medicine/medicine-ai-summary.png')}
style={styles.introImage}
contentFit='contain'
/>
{/* 右上角查看大图提示按钮 */}
<TouchableOpacity
style={styles.viewImageButton}
onPress={handleImagePreview}
activeOpacity={0.8}
>
{isLiquidGlassAvailable() ? (
<GlassView
style={styles.glassViewButton}
glassEffectStyle="clear"
tintColor="rgba(255, 255, 255, 0.3)"
isInteractive={true}
>
<Ionicons name="expand-outline" size={16} color="#333" />
</GlassView>
) : (
<View style={[styles.glassViewButton, styles.fallbackViewButton]}>
<Ionicons name="expand-outline" size={16} color="#333" />
</View>
)}
</TouchableOpacity>
</View>
{/* 功能介绍内容 */}
<View style={styles.contentContainer}>
<View style={styles.featureItem}>
<View style={styles.featureIcon}>
<Ionicons name="analytics" size={20} color="#8B5CF6" />
</View>
<View style={styles.featureContent}>
<Text style={styles.featureTitle}>{t('medications.aiSummaryInfo.features.intelligent.title')}</Text>
<Text style={styles.featureDescription}>
{t('medications.aiSummaryInfo.features.intelligent.description')}
</Text>
</View>
</View>
<View style={styles.featureItem}>
<View style={styles.featureIcon}>
<Ionicons name="time" size={20} color="#8B5CF6" />
</View>
<View style={styles.featureContent}>
<Text style={styles.featureTitle}>{t('medications.aiSummaryInfo.features.tracking.title')}</Text>
<Text style={styles.featureDescription}>
{t('medications.aiSummaryInfo.features.tracking.description')}
</Text>
</View>
</View>
<View style={styles.featureItem}>
<View style={styles.featureIcon}>
<Ionicons name="shield-checkmark" size={20} color="#8B5CF6" />
</View>
<View style={styles.featureContent}>
<Text style={styles.featureTitle}>{t('medications.aiSummaryInfo.features.professional.title')}</Text>
<Text style={styles.featureDescription}>
{t('medications.aiSummaryInfo.features.professional.description')}
</Text>
</View>
</View>
</View>
{/* 确认按钮 - 支持 Liquid Glass */}
<View style={styles.actions}>
<TouchableOpacity
activeOpacity={0.9}
onPress={handleConfirm}
disabled={loading}
>
{isLiquidGlassAvailable() ? (
<GlassView
style={styles.confirmButton}
glassEffectStyle="regular"
tintColor="rgba(139, 92, 246, 0.8)"
isInteractive={true}
>
{loading ? (
<ActivityIndicator size="small" color="#fff" />
) : (
<>
<Ionicons name="arrow-forward" size={20} color="#fff" />
<Text style={styles.confirmText}>{t('medications.aiSummaryInfo.confirmButton')}</Text>
</>
)}
</GlassView>
) : (
<View style={[styles.confirmButton, styles.fallbackButton]}>
{loading ? (
<ActivityIndicator size="small" color="#fff" />
) : (
<>
<Ionicons name="arrow-forward" size={20} color="#fff" />
<Text style={styles.confirmText}>{t('medications.aiSummaryInfo.confirmButton')}</Text>
</>
)}
</View>
)}
</TouchableOpacity>
</View>
</Animated.View>
</View>
{/* 图片预览 */}
<ImageViewing
images={[imageSource]}
imageIndex={0}
visible={showImagePreview}
onRequestClose={() => setShowImagePreview(false)}
swipeToCloseEnabled={true}
doubleTapToZoomEnabled={true}
FooterComponent={() => (
<View style={styles.imageViewerFooter}>
<TouchableOpacity
style={styles.imageViewerFooterButton}
onPress={() => setShowImagePreview(false)}
>
<Text style={styles.imageViewerFooterButtonText}>{t('medications.detail.imageViewer.close')}</Text>
</TouchableOpacity>
</View>
)}
/>
</Modal>
);
}
const styles = StyleSheet.create({
overlay: {
flex: 1,
justifyContent: 'flex-end',
backgroundColor: 'transparent',
},
backdrop: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(15, 23, 42, 0.45)',
},
sheet: {
backgroundColor: '#fff',
borderTopLeftRadius: 28,
borderTopRightRadius: 28,
paddingHorizontal: 24,
paddingTop: 16,
shadowColor: '#000',
shadowOpacity: 0.12,
shadowRadius: 16,
shadowOffset: { width: 0, height: -4 },
elevation: 16,
gap: 20,
},
handle: {
width: 50,
height: 4,
borderRadius: 2,
backgroundColor: '#E5E7EB',
alignSelf: 'center',
marginBottom: 8,
},
header: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
iconContainer: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: '#F3E8FF',
alignItems: 'center',
justifyContent: 'center',
},
title: {
fontSize: 20,
fontWeight: '700',
color: '#111827',
},
imageContainer: {
width: '100%',
height: 380,
borderRadius: 16,
overflow: 'hidden',
backgroundColor: '#F9FAFB',
},
introImage: {
width: '100%',
height: '100%',
borderRadius: 16,
},
contentContainer: {
gap: 16,
paddingVertical: 8,
},
featureItem: {
flexDirection: 'row',
gap: 12,
alignItems: 'flex-start',
},
featureIcon: {
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: '#F3E8FF',
alignItems: 'center',
justifyContent: 'center',
},
featureContent: {
flex: 1,
},
featureTitle: {
fontSize: 16,
fontWeight: '600',
color: '#111827',
marginBottom: 4,
},
featureDescription: {
fontSize: 14,
lineHeight: 20,
color: '#6B7280',
},
actions: {
marginTop: 8,
},
confirmButton: {
height: 56,
borderRadius: 18,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
overflow: 'hidden', // 保证玻璃边界圆角效果
},
fallbackButton: {
backgroundColor: '#8B5CF6',
shadowColor: 'rgba(139, 92, 246, 0.45)',
shadowOffset: { width: 0, height: 10 },
shadowOpacity: 1,
shadowRadius: 20,
elevation: 6,
},
confirmText: {
fontSize: 16,
fontWeight: '700',
color: '#fff',
},
// 图片预览相关样式
viewImageButton: {
position: 'absolute',
top: 12,
right: 12,
zIndex: 1,
},
glassViewButton: {
width: 32,
height: 32,
borderRadius: 16,
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
},
fallbackViewButton: {
borderWidth: 1,
borderColor: 'rgba(0, 0, 0, 0.1)',
backgroundColor: 'rgba(255, 255, 255, 0.8)',
},
imageViewerFooter: {
position: 'absolute',
bottom: 60,
left: 20,
right: 20,
alignItems: 'center',
zIndex: 1,
},
imageViewerFooterButton: {
backgroundColor: 'rgba(0, 0, 0, 0.7)',
paddingHorizontal: 24,
paddingVertical: 12,
borderRadius: 20,
},
imageViewerFooterButtonText: {
color: '#FFF',
fontSize: 16,
fontWeight: '500',
},
});

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