20 Commits

Author SHA1 Message Date
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
162 changed files with 22686 additions and 9085 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

@@ -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",

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 = {
@@ -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,17 @@
import ActivityHeatMap from '@/components/ActivityHeatMap';
import { BadgeShowcaseModal } from '@/components/badges/BadgeShowcaseModal';
import { PRIVACY_POLICY_URL, USER_AGREEMENT_URL } from '@/constants/Agree';
import { palette } 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 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';
@@ -58,11 +62,13 @@ export default function PersonalScreen() {
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 languageOptions = useMemo<LanguageOption[]>(() => ([
{
@@ -78,7 +84,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 +103,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 +199,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])
);
// 手动刷新处理
@@ -299,11 +338,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" />
@@ -364,8 +403,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 (
@@ -419,7 +458,7 @@ export default function PersonalScreen() {
const StatsSection = () => (
<View style={styles.sectionContainer}>
<View style={[styles.cardContainer, {
backgroundColor: 'unset'
backgroundColor: 'transparent'
}]}>
<View style={styles.statsContainer}>
<View style={styles.statItem}>
@@ -439,48 +478,34 @@ export default function PersonalScreen() {
</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 +515,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 +604,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 +655,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 +767,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" />
@@ -698,16 +798,12 @@ export default function PersonalScreen() {
{/* 背景渐变 */}
<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} />
<ScrollView
style={styles.scrollView}
contentContainerStyle={{
@@ -759,33 +855,14 @@ export default function PersonalScreen() {
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,
@@ -951,16 +1028,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 +1053,7 @@ const styles = StyleSheet.create({
color: '#9370DB',
marginLeft: 2,
fontWeight: '500',
fontFamily: 'AliRegular',
},
editButton: {
backgroundColor: '#9370DB',
@@ -990,6 +1072,7 @@ const styles = StyleSheet.create({
color: 'white',
fontSize: 14,
fontWeight: '600',
fontFamily: 'AliBold',
},
editButtonTextGlass: {
color: 'rgba(147, 112, 219, 1)',
@@ -1011,11 +1094,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 +1120,7 @@ const styles = StyleSheet.create({
fontSize: 16,
fontWeight: '700',
color: '#111827',
fontFamily: 'AliBold',
},
badgesRowContent: {
flexDirection: 'row',
@@ -1073,6 +1159,7 @@ const styles = StyleSheet.create({
fontSize: 18,
fontWeight: '600',
color: '#475467',
fontFamily: 'AliBold',
},
badgeCompactOverlay: {
...StyleSheet.absoluteFillObject,
@@ -1092,11 +1179,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 +1211,7 @@ const styles = StyleSheet.create({
fontSize: 13,
color: '#6C757D',
marginRight: 6,
fontFamily: 'AliRegular',
},
iconContainer: {
width: 32,
@@ -1149,6 +1240,7 @@ const styles = StyleSheet.create({
fontWeight: 'bold',
color: '#2C3E50',
marginLeft: 4,
fontFamily: 'AliBold',
},
languageModalOverlay: {
flex: 1,
@@ -1174,11 +1266,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 +1297,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 +1313,6 @@ const styles = StyleSheet.create({
fontSize: 15,
fontWeight: '500',
color: '#9370DB',
fontFamily: 'AliBold',
},
});

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 { 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';
@@ -63,8 +65,8 @@ export default function ExploreScreen() {
const stepGoal = useAppSelector((s) => s.user.profile?.dailyStepsGoal) ?? 2000;
const userProfile = useAppSelector((s) => s.user.profile);
const { pushIfAuthedElseLogin, isLoggedIn } = useAuthGuard();
const { pushIfAuthedElseLogin, isLoggedIn, ensureLoggedIn } = useAuthGuard();
const router = useRouter();
// 使用 dayjs当月日期与默认选中"今天"
const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth());
@@ -80,7 +82,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);
@@ -384,42 +390,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 +529,7 @@ export default function ExploreScreen() {
{/* 血氧饱和度卡片 */}
<FloatingCard style={styles.masonryCard}>
<OxygenSaturationCard
selectedDate={currentSelectedDate}
style={styles.basalMetabolismCardOverride}
/>
</FloatingCard>
@@ -536,6 +542,7 @@ export default function ExploreScreen() {
{/* 围度数据卡片 - 占满底部一行 */}
<CircumferenceCard style={styles.circumferenceCard} />
</ScrollView>
</View>
);
}
@@ -584,6 +591,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 +612,7 @@ const styles = StyleSheet.create({
fontSize: 16,
fontWeight: '700',
color: '#192126',
fontFamily: 'AliBold'
},
debugButtonsContainer: {
flexDirection: 'row',
@@ -624,6 +639,7 @@ const styles = StyleSheet.create({
},
debugButtonText: {
fontSize: 12,
fontFamily: 'AliRegular',
},
metricsRow: {
flexDirection: 'row',
@@ -657,13 +673,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 +715,7 @@ const styles = StyleSheet.create({
fontSize: 18,
fontWeight: '800',
color: '#8B74F3',
fontFamily: 'AliBold',
},
cyclingHeader: {
flexDirection: 'row',
@@ -716,6 +735,7 @@ const styles = StyleSheet.create({
color: '#FFFFFF',
fontSize: 20,
fontWeight: '800',
fontFamily: 'AliBold',
},
mapArea: {
backgroundColor: 'rgba(255,255,255,0.08)',
@@ -755,6 +775,7 @@ const styles = StyleSheet.create({
cardTitle: {
fontSize: 14,
color: '#192126',
fontFamily: 'AliBold',
},
heartCard: {
backgroundColor: '#FFE5E5',
@@ -775,12 +796,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 +819,7 @@ const styles = StyleSheet.create({
fontWeight: '600',
marginLeft: 8,
flex: 1,
fontFamily: 'AliRegular',
},
retryButton: {
padding: 4,
@@ -810,11 +834,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 +911,7 @@ const styles = StyleSheet.create({
color: '#0369A1',
fontWeight: '800',
marginTop: 8,
fontFamily: 'AliBold',
},
addWeightButton: {
position: 'absolute',
@@ -905,6 +932,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

@@ -10,6 +10,7 @@ import PrivacyConsentModal from '@/components/PrivacyConsentModal';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useQuickActions } from '@/hooks/useQuickActions';
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,6 +24,7 @@ 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 React, { useEffect } from 'react';
import { AppState, AppStateStatus } from 'react-native';
@@ -30,10 +32,12 @@ import { AppState, AppStateStatus } from 'react-native';
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 +123,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 +184,6 @@ function Bootstrapper({ children }: { children: React.ReactNode }) {
permissionInitializedRef.current = true;
const delay = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms));
// ==================== 辅助函数 ====================
@@ -213,27 +223,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 +393,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 +446,8 @@ function Bootstrapper({ children }: { children: React.ReactNode }) {
// 2. 开发环境调试工具
if (__DEV__ && BackgroundTaskDebugger) {
BackgroundTaskDebugger.getInstance().initialize();
logger.info('✅ 后台任务调试工具已初始化(开发环境)');
logger.info('✅ 后台任务调试工具未初始化(开发环境)');
return
}
logger.info('🎉 空闲服务初始化完成');
@@ -466,6 +511,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 +525,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

@@ -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>

View File

@@ -5,15 +5,18 @@ import { Colors } from '@/constants/Colors';
import { getTabBarBottomPadding } from '@/constants/TabBar';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useColorScheme } from '@/hooks/useColorScheme';
import { useI18n } from '@/hooks/useI18n';
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
import { appStoreReviewService } from '@/services/appStoreReview';
import { deleteWeightRecord, fetchWeightHistory, updateUserProfile, updateWeightRecord, WeightHistoryItem } from '@/store/userSlice';
import { Ionicons } from '@expo/vector-icons';
import dayjs from 'dayjs';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { LinearGradient } from 'expo-linear-gradient';
import { router } from 'expo-router';
import React, { useCallback, useEffect, useState } from 'react';
import {
Alert,
Image,
Modal,
ScrollView,
StyleSheet,
@@ -23,6 +26,7 @@ import {
} from 'react-native';
export default function WeightRecordsPage() {
const { t } = useI18n();
const safeAreaTop = useSafeAreaTop()
const dispatch = useAppDispatch();
@@ -36,13 +40,11 @@ export default function WeightRecordsPage() {
const colorScheme = useColorScheme();
const themeColors = Colors[colorScheme ?? 'light'];
console.log('userProfile:', userProfile);
const loadWeightHistory = useCallback(async () => {
try {
await dispatch(fetchWeightHistory() as any);
} catch (error) {
console.error('加载体重历史失败:', error);
console.error(t('weightRecords.loadingHistory'), error);
}
}, [dispatch]);
@@ -50,10 +52,6 @@ export default function WeightRecordsPage() {
loadWeightHistory();
}, [loadWeightHistory]);
const handleGoBack = () => {
router.back();
};
const initializeInput = (weight: number) => {
setInputWeight(weight.toString());
};
@@ -91,15 +89,15 @@ export default function WeightRecordsPage() {
await dispatch(deleteWeightRecord(id) as any);
await loadWeightHistory();
} catch (error) {
console.error('删除体重记录失败:', error);
Alert.alert('错误', '删除体重记录失败,请重试');
console.error(t('weightRecords.alerts.deleteFailed'), error);
Alert.alert('错误', t('weightRecords.alerts.deleteFailed'));
}
};
const handleWeightSave = async () => {
const weight = parseFloat(inputWeight);
if (isNaN(weight) || weight <= 0 || weight > 500) {
alert('请输入有效的体重值0-500kg');
alert(t('weightRecords.alerts.invalidWeight'));
return;
}
@@ -107,6 +105,13 @@ export default function WeightRecordsPage() {
if (pickerType === 'current') {
// Update current weight in profile and add weight record
await dispatch(updateUserProfile({ weight: weight }) as any);
// 记录体重后尝试请求应用评分延迟1秒避免阻塞主流程
setTimeout(() => {
appStoreReviewService.requestReview().catch((error) => {
console.error('应用评分请求失败:', error);
});
}, 1000);
} else if (pickerType === 'initial') {
// Update initial weight in profile
console.log('更新初始体重');
@@ -122,8 +127,8 @@ export default function WeightRecordsPage() {
setEditingRecord(null);
await loadWeightHistory();
} catch (error) {
console.error('保存体重失败:', error);
Alert.alert('错误', '保存体重失败,请重试');
console.error(t('weightRecords.alerts.saveFailed'), error);
Alert.alert('错误', t('weightRecords.alerts.saveFailed'));
}
};
@@ -156,7 +161,11 @@ export default function WeightRecordsPage() {
// Group by month
const groupedHistory = sortedHistory.reduce((acc, item) => {
const monthKey = dayjs(item.createdAt).format('YYYY年MM月');
const date = dayjs(item.createdAt);
const monthKey = t('weightRecords.historyMonthFormat', {
year: date.format('YYYY'),
month: date.format('MM')
});
if (!acc[monthKey]) {
acc[monthKey] = [];
}
@@ -173,109 +182,160 @@ export default function WeightRecordsPage() {
return (
<View style={styles.container}>
{/* 背景渐变 */}
{/* 背景 */}
<LinearGradient
colors={['#F0F9FF', '#E0F2FE']}
style={styles.gradientBackground}
start={{ x: 0, y: 0 }}
end={{ x: 1, 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 }}
/>
<HeaderBar
title="体重记录"
right={<TouchableOpacity onPress={handleAddWeight} style={styles.addButton}>
<Ionicons name="add" size={24} color="#192126" />
</TouchableOpacity>}
title={t('weightRecords.title')}
right={
isLiquidGlassAvailable() ? (
<TouchableOpacity
onPress={handleAddWeight}
activeOpacity={0.7}
>
<GlassView
style={styles.addButtonGlass}
glassEffectStyle="regular"
tintColor="rgba(255, 255, 255, 0.4)"
isInteractive={true}
>
<Ionicons name="add" size={24} color="#1c1f3a" />
</GlassView>
</TouchableOpacity>
) : (
<TouchableOpacity
style={styles.addButtonFallback}
onPress={handleAddWeight}
activeOpacity={0.7}
>
<Ionicons name="add" size={24} color="#1c1f3a" />
</TouchableOpacity>
)
}
/>
<View style={{
paddingTop: safeAreaTop
}} />
{/* Weight Statistics */}
<View style={[styles.statsContainer]}>
<View style={styles.statsRow}>
<View style={styles.statItem}>
<Text style={styles.statValue}>{totalWeightLoss.toFixed(1)}kg</Text>
<Text style={styles.statLabel}></Text>
</View>
<View style={styles.statItem}>
<Text style={styles.statValue}>{currentWeight.toFixed(1)}kg</Text>
<Text style={styles.statLabel}></Text>
</View>
<View style={styles.statItem}>
<Text style={styles.statValue}>{initialWeight.toFixed(1)}kg</Text>
<View style={styles.statLabelContainer}>
<Text style={styles.statLabel}></Text>
<TouchableOpacity onPress={handleEditInitialWeight} style={styles.editIcon}>
<Ionicons name="create-outline" size={14} color="#FF9500" />
</TouchableOpacity>
</View>
</View>
<View style={styles.statItem}>
<Text style={styles.statValue}>{targetWeight.toFixed(1)}kg</Text>
<View style={styles.statLabelContainer}>
<Text style={styles.statLabel}></Text>
<TouchableOpacity onPress={handleEditTargetWeight} style={styles.editIcon}>
<Ionicons name="create-outline" size={14} color="#FF9500" />
</TouchableOpacity>
</View>
</View>
</View>
</View>
<ScrollView
style={styles.content}
contentContainerStyle={[styles.contentContainer, { paddingBottom: getTabBarBottomPadding() + 20 }]}
contentContainerStyle={[styles.contentContainer, { paddingBottom: getTabBarBottomPadding() + 20, paddingTop: safeAreaTop }]}
showsVerticalScrollIndicator={false}
>
<View style={styles.headerBlock}>
<Text style={styles.pageTitle}>{t('weightRecords.title')}</Text>
<Text style={styles.pageSubtitle}>{t('weightRecords.pageSubtitle')}</Text>
</View>
{/* Weight Statistics Cards */}
<View style={styles.statsGrid}>
{/* Current Weight - Hero Card */}
<View style={styles.mainStatCard}>
<View style={styles.mainStatContent}>
<Text style={styles.mainStatLabel}>{t('weightRecords.stats.currentWeight')}</Text>
<View style={styles.mainStatValueContainer}>
<Text style={styles.mainStatValue}>{currentWeight.toFixed(1)}</Text>
<Text style={styles.mainStatUnit}>kg</Text>
</View>
<View style={styles.totalLossTag}>
<Ionicons name={totalWeightLoss <= 0 ? "trending-down" : "trending-up"} size={16} color="#ffffff" />
<Text style={styles.totalLossText}>
{totalWeightLoss > 0 ? '+' : ''}{totalWeightLoss.toFixed(1)} kg
</Text>
</View>
</View>
<LinearGradient
colors={['#4F5BD5', '#6B6CFF']}
style={StyleSheet.absoluteFillObject}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
// @ts-ignore
borderRadius={24}
/>
<Image
source={require('@/assets/images/icons/iconWeight.png')}
style={styles.statIconBg}
/>
</View>
{/* Secondary Stats Row */}
<View style={styles.secondaryStatsRow}>
{/* Initial Weight */}
<TouchableOpacity
style={styles.secondaryStatCard}
onPress={handleEditInitialWeight}
activeOpacity={0.7}
>
<View style={styles.secondaryStatHeader}>
<Text style={styles.secondaryStatLabel}>{t('weightRecords.stats.initialWeight')}</Text>
<Ionicons name="create-outline" size={14} color="#9ba3c7" />
</View>
<Text style={styles.secondaryStatValue}>{initialWeight.toFixed(1)}<Text style={styles.secondaryStatUnit}>kg</Text></Text>
</TouchableOpacity>
{/* Target Weight */}
<TouchableOpacity
style={styles.secondaryStatCard}
onPress={handleEditTargetWeight}
activeOpacity={0.7}
>
<View style={styles.secondaryStatHeader}>
<Text style={styles.secondaryStatLabel}>{t('weightRecords.stats.targetWeight')}</Text>
<Ionicons name="create-outline" size={14} color="#9ba3c7" />
</View>
<Text style={styles.secondaryStatValue}>{targetWeight.toFixed(1)}<Text style={styles.secondaryStatUnit}>kg</Text></Text>
</TouchableOpacity>
</View>
</View>
{/* Monthly Records */}
{Object.keys(groupedHistory).length > 0 ? (
Object.entries(groupedHistory).map(([month, records]) => (
<View key={month} style={styles.monthContainer}>
{/* Month Header Card */}
{/* <View style={styles.monthHeaderCard}>
<View style={styles.monthTitleRow}>
<Text style={styles.monthNumber}>
{dayjs(month, 'YYYY年MM月').format('MM')}
</Text>
<Text style={styles.monthText}>月</Text>
<Text style={styles.yearText}>
{dayjs(month, 'YYYY年MM月').format('YYYY年')}
</Text>
<View style={styles.expandIcon}>
<Ionicons name="chevron-up" size={16} color="#FF9500" />
</View>
</View>
<Text style={styles.monthStatsText}>
累计减重:<Text style={styles.statsBold}>{totalWeightLoss.toFixed(1)}kg</Text> 日均减重:<Text style={styles.statsBold}>{avgWeightLoss.toFixed(1)}kg</Text>
</Text>
</View> */}
<View style={styles.historySection}>
<Text style={styles.sectionTitle}>{t('weightRecords.history')}</Text>
{Object.entries(groupedHistory).map(([month, records]) => (
<View key={month} style={styles.monthContainer}>
<View style={styles.monthHeader}>
<Text style={styles.monthTitle}>{month}</Text>
</View>
{/* Individual Record Cards */}
{records.map((record, recordIndex) => {
// Calculate weight change from previous record
const prevRecord = recordIndex < records.length - 1 ? records[recordIndex + 1] : null;
const weightChange = prevRecord ?
parseFloat(record.weight) - parseFloat(prevRecord.weight) : 0;
{/* Individual Record Cards */}
<View style={styles.recordsList}>
{records.map((record, recordIndex) => {
// Calculate weight change from previous record
const prevRecord = recordIndex < records.length - 1 ? records[recordIndex + 1] : null;
const weightChange = prevRecord ?
parseFloat(record.weight) - parseFloat(prevRecord.weight) : 0;
return (
<WeightRecordCard
key={`${record.createdAt}-${recordIndex}`}
record={record}
onPress={handleEditWeightRecord}
onDelete={handleDeleteWeightRecord}
weightChange={weightChange}
/>
);
})}
</View>
))
return (
<WeightRecordCard
key={`${record.createdAt}-${recordIndex}`}
record={record}
onPress={handleEditWeightRecord}
onDelete={handleDeleteWeightRecord}
weightChange={weightChange}
/>
);
})}
</View>
</View>
))}
</View>
) : (
<View style={styles.emptyContainer}>
<Image
source={require('@/assets/images/icons/iconWeight.png')}
style={{ width: 80, height: 80, opacity: 0.5, marginBottom: 16, tintColor: '#cbd5e1' }}
/>
<View style={styles.emptyContent}>
<Text style={styles.emptyText}></Text>
<Text style={styles.emptySubtext}></Text>
<Text style={styles.emptyText}>{t('weightRecords.empty.title')}</Text>
<Text style={styles.emptySubtext}>{t('weightRecords.empty.subtitle')}</Text>
</View>
</View>
)}
@@ -294,17 +354,20 @@ export default function WeightRecordsPage() {
activeOpacity={1}
onPress={() => setShowWeightPicker(false)}
/>
<View style={[styles.modalSheet, { backgroundColor: themeColors.background }]}>
<View style={[styles.modalSheet, { backgroundColor: '#ffffff' }]}>
{/* Header */}
<View style={styles.modalHeader}>
<TouchableOpacity onPress={() => setShowWeightPicker(false)}>
<Ionicons name="close" size={24} color={themeColors.text} />
<TouchableOpacity
onPress={() => setShowWeightPicker(false)}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Ionicons name="close" size={24} color="#1c1f3a" />
</TouchableOpacity>
<Text style={[styles.modalTitle, { color: themeColors.text }]}>
{pickerType === 'current' && '记录体重'}
{pickerType === 'initial' && '编辑初始体重'}
{pickerType === 'target' && '编辑目标体重'}
{pickerType === 'edit' && '编辑体重记录'}
<Text style={styles.modalTitle}>
{pickerType === 'current' && t('weightRecords.modal.recordWeight')}
{pickerType === 'initial' && t('weightRecords.modal.editInitialWeight')}
{pickerType === 'target' && t('weightRecords.modal.editTargetWeight')}
{pickerType === 'edit' && t('weightRecords.modal.editRecord')}
</Text>
<View style={{ width: 24 }} />
</View>
@@ -317,25 +380,26 @@ export default function WeightRecordsPage() {
<View style={styles.inputSection}>
<View style={styles.weightInputContainer}>
<View style={styles.weightIcon}>
<Ionicons name="scale-outline" size={20} color="#6366F1" />
<Image
source={require('@/assets/images/icons/iconWeight.png')}
style={{ width: 24, height: 24, tintColor: '#4F5BD5' }}
/>
</View>
<View style={styles.inputWrapper}>
<Text style={[styles.weightDisplay, { color: inputWeight ? themeColors.text : themeColors.textSecondary }]}>
{inputWeight || '输入体重'}
<Text style={[
styles.weightDisplay,
{ color: inputWeight ? '#1c1f3a' : '#9ba3c7' }
]}>
{inputWeight || t('weightRecords.modal.inputPlaceholder')}
</Text>
<Text style={[styles.unitLabel, { color: themeColors.textSecondary }]}>kg</Text>
<Text style={styles.unitLabel}>{t('weightRecords.modal.unit')}</Text>
</View>
</View>
{/* Weight Range Hint */}
<Text style={[styles.hintText, { color: themeColors.textSecondary }]}>
0-500
</Text>
</View>
{/* Quick Selection */}
<View style={styles.quickSelectionSection}>
<Text style={[styles.quickSelectionTitle, { color: themeColors.text }]}></Text>
<Text style={styles.quickSelectionTitle}>{t('weightRecords.modal.quickSelection')}</Text>
<View style={styles.quickButtons}>
{[50, 60, 70, 80, 90].map((weight) => (
<TouchableOpacity
@@ -345,12 +409,13 @@ export default function WeightRecordsPage() {
inputWeight === weight.toString() && styles.quickButtonSelected
]}
onPress={() => setInputWeight(weight.toString())}
activeOpacity={0.7}
>
<Text style={[
styles.quickButtonText,
inputWeight === weight.toString() && styles.quickButtonTextSelected
]}>
{weight}kg
{weight}{t('weightRecords.modal.unit')}
</Text>
</TouchableOpacity>
))}
@@ -377,8 +442,16 @@ export default function WeightRecordsPage() {
]}
onPress={handleWeightSave}
disabled={!inputWeight.trim()}
activeOpacity={0.8}
>
<Text style={styles.saveButtonText}></Text>
<LinearGradient
colors={['#4F5BD5', '#6B6CFF']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.saveButtonGradient}
>
<Text style={styles.saveButtonText}>{t('weightRecords.modal.confirm')}</Text>
</LinearGradient>
</TouchableOpacity>
</View>
</View>
@@ -392,143 +465,202 @@ const styles = StyleSheet.create({
container: {
flex: 1,
},
gradientBackground: {
topGradient: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingTop: 60,
paddingHorizontal: 20,
paddingBottom: 10,
},
backButton: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: 'rgba(255, 255, 255, 0.9)',
alignItems: 'center',
justifyContent: 'center',
},
addButton: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: 'rgba(255, 255, 255, 0.9)',
alignItems: 'center',
justifyContent: 'center',
height: 300,
},
content: {
flex: 1,
paddingHorizontal: 20,
},
contentContainer: {
flexGrow: 1,
paddingBottom: 40,
},
statsContainer: {
backgroundColor: '#FFFFFF',
borderRadius: 16,
padding: 20,
marginBottom: 20,
marginLeft: 20,
marginRight: 20,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 3,
headerBlock: {
paddingHorizontal: 24,
marginTop: 10,
marginBottom: 24,
},
statsRow: {
flexDirection: 'row',
justifyContent: 'space-between',
},
statItem: {
flex: 1,
flexDirection: 'column',
alignItems: 'center',
},
statValue: {
fontSize: 16,
pageTitle: {
fontSize: 28,
fontWeight: '800',
color: '#192126',
color: '#1c1f3a',
fontFamily: 'AliBold',
marginBottom: 4,
},
statLabelContainer: {
flexDirection: 'row',
pageSubtitle: {
fontSize: 16,
color: '#6f7ba7',
fontFamily: 'AliRegular',
},
// Add Button Styles
addButtonGlass: {
width: 40,
height: 40,
alignItems: 'center',
justifyContent: 'center',
borderRadius: 20,
overflow: 'hidden',
},
statLabel: {
fontSize: 12,
color: '#687076',
marginRight: 4,
addButtonFallback: {
width: 40,
height: 40,
alignItems: 'center',
justifyContent: 'center',
borderRadius: 20,
backgroundColor: '#ffffff',
borderWidth: 1,
borderColor: 'rgba(0,0,0,0.05)',
},
editIcon: {
padding: 2,
borderRadius: 8,
backgroundColor: 'rgba(255, 149, 0, 0.1)',
// Stats Grid
statsGrid: {
paddingHorizontal: 24,
marginBottom: 32,
gap: 16,
},
monthContainer: {
marginBottom: 20,
mainStatCard: {
backgroundColor: '#4F5BD5',
borderRadius: 28,
padding: 24,
height: 160,
position: 'relative',
overflow: 'hidden',
shadowColor: '#4F5BD5',
shadowOffset: { width: 0, height: 10 },
shadowOpacity: 0.3,
shadowRadius: 16,
elevation: 8,
},
monthHeaderCard: {
backgroundColor: '#FFFFFF',
borderRadius: 16,
padding: 20,
marginBottom: 12,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.08,
mainStatContent: {
zIndex: 2,
height: '100%',
justifyContent: 'space-between',
},
mainStatLabel: {
fontSize: 16,
color: 'rgba(255, 255, 255, 0.9)',
fontFamily: 'AliRegular',
},
mainStatValueContainer: {
flexDirection: 'row',
alignItems: 'baseline',
},
mainStatValue: {
fontSize: 48,
fontWeight: '800',
color: '#ffffff',
fontFamily: 'AliBold',
marginRight: 8,
},
mainStatUnit: {
fontSize: 20,
fontWeight: '600',
color: 'rgba(255, 255, 255, 0.9)',
fontFamily: 'AliRegular',
},
totalLossTag: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(255, 255, 255, 0.2)',
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 12,
alignSelf: 'flex-start',
},
totalLossText: {
fontSize: 14,
fontWeight: '600',
color: '#ffffff',
marginLeft: 4,
fontFamily: 'AliBold',
},
statIconBg: {
position: 'absolute',
right: -20,
bottom: -20,
width: 140,
height: 140,
opacity: 0.2,
transform: [{ rotate: '-15deg' }],
tintColor: '#ffffff'
},
secondaryStatsRow: {
flexDirection: 'row',
gap: 16,
},
secondaryStatCard: {
flex: 1,
backgroundColor: '#ffffff',
borderRadius: 24,
padding: 16,
shadowColor: 'rgba(30, 41, 59, 0.06)',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.1,
shadowRadius: 12,
elevation: 3,
},
monthTitleRow: {
secondaryStatHeader: {
flexDirection: 'row',
alignItems: 'baseline',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 12,
},
monthNumber: {
fontSize: 48,
fontWeight: '800',
color: '#192126',
lineHeight: 48,
secondaryStatLabel: {
fontSize: 13,
color: '#6f7ba7',
fontFamily: 'AliRegular',
},
monthText: {
fontSize: 16,
fontWeight: '600',
color: '#192126',
marginLeft: 4,
marginRight: 8,
},
yearText: {
fontSize: 16,
fontWeight: '500',
color: '#687076',
flex: 1,
},
expandIcon: {
padding: 4,
},
monthStatsText: {
fontSize: 14,
color: '#687076',
lineHeight: 20,
},
statsBold: {
secondaryStatValue: {
fontSize: 20,
fontWeight: '700',
color: '#192126',
color: '#1c1f3a',
fontFamily: 'AliBold',
},
secondaryStatUnit: {
fontSize: 14,
color: '#6f7ba7',
fontWeight: '500',
fontFamily: 'AliRegular',
marginLeft: 2,
},
// History Section
historySection: {
paddingHorizontal: 24,
},
sectionTitle: {
fontSize: 18,
fontWeight: '700',
color: '#1c1f3a',
marginBottom: 16,
fontFamily: 'AliBold',
},
monthContainer: {
marginBottom: 24,
},
monthHeader: {
marginBottom: 12,
paddingHorizontal: 4,
},
monthTitle: {
fontSize: 15,
fontWeight: '600',
color: '#6f7ba7',
fontFamily: 'AliRegular',
},
recordsList: {
gap: 12,
},
emptyContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
minHeight: 300,
paddingVertical: 60,
},
emptyContent: {
alignItems: 'center',
@@ -536,145 +668,161 @@ const styles = StyleSheet.create({
emptyText: {
fontSize: 16,
fontWeight: '700',
color: '#192126',
color: '#1c1f3a',
marginBottom: 8,
fontFamily: 'AliBold',
},
emptySubtext: {
fontSize: 14,
color: '#687076',
fontFamily: 'AliRegular',
},
// Modal Styles
// Modal Styles (Retain but refined)
modalContainer: {
flex: 1,
},
modalBackdrop: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0,0,0,0.35)',
backgroundColor: 'rgba(0,0,0,0.4)',
},
modalSheet: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
borderTopLeftRadius: 32,
borderTopRightRadius: 32,
maxHeight: '85%',
minHeight: 500,
shadowColor: '#000',
shadowOffset: { width: 0, height: -4 },
shadowOpacity: 0.1,
shadowRadius: 12,
elevation: 10,
},
modalHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 20,
paddingTop: 20,
paddingBottom: 10,
paddingHorizontal: 24,
paddingTop: 24,
paddingBottom: 16,
},
modalTitle: {
fontSize: 18,
fontWeight: '600',
fontWeight: '700',
color: '#1c1f3a',
fontFamily: 'AliBold',
},
modalContent: {
flex: 1,
paddingHorizontal: 20,
paddingHorizontal: 24,
},
inputSection: {
backgroundColor: '#FFFFFF',
borderRadius: 16,
padding: 16,
marginBottom: 12,
backgroundColor: '#F8F9FC',
borderRadius: 24,
padding: 24,
marginBottom: 24,
marginTop: 8,
},
weightInputContainer: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 8,
},
weightIcon: {
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: '#F0F9FF',
width: 48,
height: 48,
borderRadius: 24,
backgroundColor: '#EEF0FF',
alignItems: 'center',
justifyContent: 'center',
marginRight: 12,
marginRight: 16,
},
inputWrapper: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
alignItems: 'baseline',
borderBottomWidth: 2,
borderBottomColor: '#E5E7EB',
paddingBottom: 6,
borderBottomColor: '#E2E8F0',
paddingBottom: 8,
},
weightDisplay: {
flex: 1,
fontSize: 24,
fontWeight: '600',
fontSize: 36,
fontWeight: '700',
textAlign: 'center',
paddingVertical: 4,
color: '#1c1f3a',
fontFamily: 'AliBold',
},
unitLabel: {
fontSize: 18,
fontWeight: '500',
fontWeight: '600',
color: '#6f7ba7',
marginLeft: 8,
},
hintText: {
fontSize: 12,
textAlign: 'center',
marginTop: 4,
fontFamily: 'AliRegular',
},
quickSelectionSection: {
paddingHorizontal: 4,
marginBottom: 20,
marginBottom: 24,
},
quickSelectionTitle: {
fontSize: 16,
fontSize: 14,
fontWeight: '600',
color: '#6f7ba7',
marginBottom: 12,
textAlign: 'center',
fontFamily: 'AliRegular',
marginLeft: 4,
},
quickButtons: {
flexDirection: 'row',
flexWrap: 'wrap',
justifyContent: 'center',
gap: 8,
},
quickButton: {
paddingHorizontal: 14,
paddingVertical: 8,
borderRadius: 18,
backgroundColor: '#F3F4F6',
borderWidth: 1,
borderColor: '#E5E7EB',
minWidth: 60,
paddingHorizontal: 16,
paddingVertical: 10,
borderRadius: 20,
backgroundColor: '#F1F5F9',
minWidth: 64,
alignItems: 'center',
},
quickButtonSelected: {
backgroundColor: '#6366F1',
borderColor: '#6366F1',
backgroundColor: '#4F5BD5',
},
quickButtonText: {
fontSize: 13,
fontWeight: '500',
color: '#6B7280',
fontSize: 14,
fontWeight: '600',
color: '#64748B',
fontFamily: 'AliRegular',
},
quickButtonTextSelected: {
color: '#FFFFFF',
fontWeight: '600',
fontWeight: '700',
},
modalFooter: {
paddingHorizontal: 20,
paddingHorizontal: 24,
paddingTop: 16,
paddingBottom: 25,
paddingBottom: 34,
borderTopWidth: 1,
borderTopColor: '#F1F5F9',
},
saveButton: {
backgroundColor: '#6366F1',
borderRadius: 16,
borderRadius: 24,
overflow: 'hidden',
shadowColor: '#4F5BD5',
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.3,
shadowRadius: 12,
elevation: 8,
},
saveButtonGradient: {
paddingVertical: 16,
alignItems: 'center',
},
saveButtonText: {
color: '#FFFFFF',
fontSize: 18,
fontWeight: '600',
fontWeight: '700',
fontFamily: 'AliBold',
},
});

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

@@ -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

@@ -158,7 +158,8 @@ const styles = StyleSheet.create({
title: {
fontSize: 14,
color: '#192126',
fontWeight: '600'
fontWeight: '600',
fontFamily: 'AliBold',
},
valueSection: {
flexDirection: 'row',
@@ -171,12 +172,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,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,5 +1,6 @@
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';
@@ -7,19 +8,26 @@ import React, { useMemo } from 'react';
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import Svg, { Rect, Text as SvgText } from 'react-native-svg';
import { StyleProp, ViewStyle } from 'react-native';
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];
@@ -130,18 +138,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,27 +161,35 @@ export const SleepStageTimeline = ({
}
return (
<View style={[styles.container, { backgroundColor: colorTokens.background }]}>
<View style={[styles.container, { backgroundColor: colorTokens.background }, 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.timeLabel, { color: colorTokens.textSecondary }]}>
{t('sleepDetail.infoModalTitles.sleepTime')}
</Text>
<Text style={[styles.timeValue, { color: colorTokens.text }]}>
{formatTime(bedtime)}
</Text>
</View>
<View style={styles.timePoint}>
<Text style={[styles.timeLabel, { color: colorTokens.textSecondary }]}></Text>
<Text style={[styles.timeLabel, { color: colorTokens.textSecondary }]}>
{t('sleepDetail.sleepDuration')}
</Text>
<Text style={[styles.timeValue, { color: colorTokens.text }]}>
{formatTime(wakeupTime)}
</Text>
@@ -223,21 +243,29 @@ export const SleepStageTimeline = ({
<View style={styles.legendRow}>
<View style={styles.legendItem}>
<View style={[styles.legendDot, { backgroundColor: getSleepStageColor(SleepStage.Deep) }]} />
<Text style={[styles.legendText, { color: colorTokens.textSecondary }]}></Text>
<Text style={[styles.legendText, { color: colorTokens.textSecondary }]}>
{t('sleepDetail.deep')}
</Text>
</View>
<View style={styles.legendItem}>
<View style={[styles.legendDot, { backgroundColor: getSleepStageColor(SleepStage.Core) }]} />
<Text style={[styles.legendText, { color: colorTokens.textSecondary }]}></Text>
<Text style={[styles.legendText, { color: colorTokens.textSecondary }]}>
{t('sleepDetail.core')}
</Text>
</View>
</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>
<Text style={[styles.legendText, { color: colorTokens.textSecondary }]}>
{t('sleepDetail.rem')}
</Text>
</View>
<View style={styles.legendItem}>
<View style={[styles.legendDot, { backgroundColor: getSleepStageColor(SleepStage.Awake) }]} />
<Text style={[styles.legendText, { color: colorTokens.textSecondary }]}></Text>
<Text style={[styles.legendText, { color: colorTokens.textSecondary }]}>
{t('sleepDetail.awake')}
</Text>
</View>
</View>
</View>

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',
},
});

View File

@@ -348,7 +348,8 @@ const styles = StyleSheet.create({
fontSize: 14,
color: '#192126',
flex: 1,
fontWeight: '600'
fontWeight: '600',
fontFamily: 'AliBold',
},
headerButtons: {
flexDirection: 'row',
@@ -406,6 +407,7 @@ const styles = StyleSheet.create({
color: '#192126',
fontSize: 14,
fontWeight: '700',
fontFamily: 'AliBold',
},
chartContainer: {
width: '100%',
@@ -424,6 +426,7 @@ const styles = StyleSheet.create({
fontSize: 11,
color: '#687076',
fontWeight: '500',
fontFamily: 'AliRegular',
},
infoValue: {
fontSize: 14,
@@ -446,6 +449,7 @@ const styles = StyleSheet.create({
textAlign: 'center',
marginBottom: 24,
letterSpacing: -0.5,
fontFamily: 'AliBold',
},
bmiModalIntroSection: {
marginBottom: 32,
@@ -456,6 +460,7 @@ const styles = StyleSheet.create({
lineHeight: 24,
textAlign: 'center',
marginBottom: 16,
fontFamily: 'AliRegular',
},
bmiModalFormulaContainer: {
backgroundColor: '#F3F4F6',
@@ -467,6 +472,7 @@ const styles = StyleSheet.create({
fontSize: 14,
fontWeight: '600',
color: '#374151',
fontFamily: 'AliBold',
},
bmiModalSectionTitle: {
fontSize: 20,
@@ -474,6 +480,7 @@ const styles = StyleSheet.create({
color: '#111827',
marginBottom: 16,
letterSpacing: -0.5,
fontFamily: 'AliBold',
},
bmiModalStatsCard: {
marginBottom: 32,
@@ -493,14 +500,17 @@ const styles = StyleSheet.create({
bmiModalStatTitle: {
fontSize: 16,
fontWeight: '700',
fontFamily: 'AliBold',
},
bmiModalStatRange: {
fontSize: 14,
fontWeight: '600',
fontFamily: 'AliBold',
},
bmiModalStatAdvice: {
fontSize: 14,
lineHeight: 20,
fontFamily: 'AliRegular',
},
bmiModalHealthTips: {
marginBottom: 32,
@@ -520,6 +530,7 @@ const styles = StyleSheet.create({
marginLeft: 12,
flex: 1,
lineHeight: 20,
fontFamily: 'AliRegular',
},
bmiModalDisclaimer: {
flexDirection: 'row',
@@ -535,6 +546,7 @@ const styles = StyleSheet.create({
marginLeft: 8,
flex: 1,
lineHeight: 18,
fontFamily: 'AliRegular',
},
bmiModalBottomContainer: {
padding: 20,
@@ -553,6 +565,7 @@ const styles = StyleSheet.create({
fontSize: 16,
fontWeight: '700',
color: '#FFFFFF',
fontFamily: 'AliBold',
},
bmiModalHomeIndicator: {
height: 5,

View File

@@ -1,10 +1,11 @@
import { Colors } from '@/constants/Colors';
import { useColorScheme } from '@/hooks/useColorScheme';
import { useI18n } from '@/hooks/useI18n';
import { WeightHistoryItem } from '@/store/userSlice';
import { Ionicons } from '@expo/vector-icons';
import dayjs from 'dayjs';
import React, { useRef } from 'react';
import { Alert, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { Alert, Image, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { Swipeable } from 'react-native-gesture-handler';
interface WeightRecordCardProps {
@@ -20,6 +21,7 @@ export const WeightRecordCard: React.FC<WeightRecordCardProps> = ({
onDelete,
weightChange = 0
}) => {
const { t } = useI18n();
const swipeableRef = useRef<Swipeable>(null);
const colorScheme = useColorScheme();
const themeColors = Colors[colorScheme ?? 'light'];
@@ -27,15 +29,15 @@ export const WeightRecordCard: React.FC<WeightRecordCardProps> = ({
// 处理删除操作
const handleDelete = () => {
Alert.alert(
'确认删除',
`确定要删除这条体重记录吗?此操作无法撤销。`,
t('weightRecords.card.deleteConfirmTitle'),
t('weightRecords.card.deleteConfirmMessage'),
[
{
text: '取消',
text: t('weightRecords.card.cancelButton'),
style: 'cancel',
},
{
text: '删除',
text: t('weightRecords.card.deleteButton'),
style: 'destructive',
onPress: () => {
const recordId = record.id || record.createdAt;
@@ -56,124 +58,174 @@ export const WeightRecordCard: React.FC<WeightRecordCardProps> = ({
activeOpacity={0.8}
>
<Ionicons name="trash" size={20} color="#FFFFFF" />
<Text style={styles.deleteButtonText}></Text>
</TouchableOpacity>
);
};
return (
<Swipeable
ref={swipeableRef}
renderRightActions={renderRightActions}
rightThreshold={40}
overshootRight={false}
>
<View
style={[styles.recordCard]}
<View style={styles.cardContainer}>
<Swipeable
ref={swipeableRef}
renderRightActions={renderRightActions}
rightThreshold={40}
overshootRight={false}
>
<View style={styles.recordHeader}>
<Text style={[styles.recordDateTime, { color: themeColors.textSecondary }]}>
{dayjs(record.createdAt).format('MM月DD日 HH:mm')}
</Text>
<TouchableOpacity
style={styles.recordEditButton}
onPress={() => onPress?.(record)}
>
<Ionicons name="create-outline" size={16} color="#FF9500" />
</TouchableOpacity>
</View>
<View style={styles.recordContent}>
<Text style={[styles.recordWeightLabel, { color: themeColors.textSecondary }]}></Text>
<Text style={[styles.recordWeightValue, { color: themeColors.text }]}>{record.weight}kg</Text>
{Math.abs(weightChange) > 0 && (
<View style={[
styles.weightChangeTag,
{ backgroundColor: weightChange < 0 ? '#E8F5E8' : '#FFF2E8' }
]}>
<Ionicons
name={weightChange < 0 ? "arrow-down" : "arrow-up"}
size={12}
color={weightChange < 0 ? Colors.light.accentGreen : '#FF9500'}
<View style={styles.recordCard}>
<View style={styles.leftContent}>
<View style={styles.iconContainer}>
<Image
source={require('@/assets/images/icons/iconWeight.png')}
style={styles.icon}
/>
<Text style={[
styles.weightChangeText,
{ color: weightChange < 0 ? Colors.light.accentGreen : '#FF9500' }
]}>
{Math.abs(weightChange).toFixed(1)}
</Text>
</View>
)}
<View style={styles.textContent}>
<View style={styles.dateTimeContainer}>
<Text style={styles.dateText}>
{dayjs(record.createdAt).format('MM-DD')}
</Text>
<Text style={styles.timeText}>
{dayjs(record.createdAt).format('HH:mm')}
</Text>
</View>
<View style={styles.weightInfo}>
<Text style={styles.weightValue}>{record.weight}<Text style={styles.unitText}>{t('weightRecords.modal.unit')}</Text></Text>
</View>
</View>
</View>
<View style={styles.rightContent}>
{Math.abs(weightChange) > 0 && (
<View style={[
styles.changeTag,
{ backgroundColor: weightChange < 0 ? '#E8F5E8' : '#FFF2E8' }
]}>
<Ionicons
name={weightChange < 0 ? "arrow-down" : "arrow-up"}
size={10}
color={weightChange < 0 ? '#22C55E' : '#FF9500'}
/>
<Text style={[
styles.changeText,
{ color: weightChange < 0 ? '#22C55E' : '#FF9500' }
]}>
{Math.abs(weightChange).toFixed(1)}
</Text>
</View>
)}
<TouchableOpacity
style={styles.editButton}
onPress={() => onPress?.(record)}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Ionicons name="ellipsis-vertical" size={16} color="#9ba3c7" />
</TouchableOpacity>
</View>
</View>
</View>
</Swipeable>
</Swipeable>
</View>
);
};
const styles = StyleSheet.create({
cardContainer: {
shadowColor: 'rgba(30, 41, 59, 0.08)',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.12,
shadowRadius: 12,
elevation: 4,
},
recordCard: {
backgroundColor: '#ffffff',
borderRadius: 16,
padding: 20,
marginBottom: 12,
},
recordHeader: {
borderRadius: 24,
padding: 16,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 12,
},
recordDateTime: {
fontSize: 14,
color: '#687076',
fontWeight: '500',
},
recordEditButton: {
padding: 6,
borderRadius: 8,
backgroundColor: 'rgba(255, 149, 0, 0.1)',
},
recordContent: {
leftContent: {
flexDirection: 'row',
alignItems: 'center',
},
recordWeightLabel: {
fontSize: 16,
color: '#687076',
fontWeight: '500',
},
recordWeightValue: {
fontSize: 16,
fontWeight: '600',
color: '#192126',
marginLeft: 4,
flex: 1,
},
weightChangeTag: {
iconContainer: {
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: '#F0F2F5',
alignItems: 'center',
justifyContent: 'center',
marginRight: 16,
},
icon: {
width: 24,
height: 24,
tintColor: '#4F5BD5',
},
textContent: {
justifyContent: 'center',
},
dateTimeContainer: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 12,
marginLeft: 12,
marginBottom: 4,
},
weightChangeText: {
fontSize: 12,
dateText: {
fontSize: 14,
fontWeight: '600',
color: '#1c1f3a',
marginRight: 8,
fontFamily: 'AliBold',
},
timeText: {
fontSize: 12,
color: '#6f7ba7',
fontFamily: 'AliRegular',
},
weightInfo: {
flexDirection: 'row',
alignItems: 'baseline',
},
weightValue: {
fontSize: 18,
fontWeight: '700',
color: '#1c1f3a',
fontFamily: 'AliBold',
},
unitText: {
fontSize: 13,
fontWeight: '500',
color: '#6f7ba7',
marginLeft: 2,
fontFamily: 'AliRegular',
},
rightContent: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
changeTag: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: 8,
},
changeText: {
fontSize: 11,
fontWeight: '700',
marginLeft: 2,
fontFamily: 'AliBold',
},
editButton: {
padding: 4,
},
deleteButton: {
backgroundColor: '#EF4444',
backgroundColor: '#FF6B6B',
justifyContent: 'center',
alignItems: 'center',
width: 80,
borderRadius: 16,
marginBottom: 12,
marginLeft: 8,
},
deleteButtonText: {
color: '#FFFFFF',
fontSize: 12,
fontWeight: '600',
marginTop: 4,
width: 70,
borderRadius: 24,
marginLeft: 12,
height: '100%',
},
});

View File

@@ -1,10 +1,11 @@
import { MaterialCommunityIcons } from '@expo/vector-icons';
import dayjs from 'dayjs';
import 'dayjs/locale/en';
import 'dayjs/locale/zh-cn';
import { LinearGradient } from 'expo-linear-gradient';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import {
ActivityIndicator,
Animated,
Dimensions,
Modal,
ScrollView,
@@ -15,6 +16,7 @@ import {
View,
} from 'react-native';
import { useI18n } from '@/hooks/useI18n';
import {
HeartRateZoneStat,
WorkoutDetailMetrics,
@@ -59,62 +61,49 @@ export function WorkoutDetailModal({
onRetry,
errorMessage,
}: WorkoutDetailModalProps) {
const animation = useRef(new Animated.Value(visible ? 1 : 0)).current;
const { t, i18n } = useI18n();
const [isMounted, setIsMounted] = useState(visible);
const [shouldRenderChart, setShouldRenderChart] = useState(visible);
const [showIntensityInfo, setShowIntensityInfo] = useState(false);
const locale = useMemo(() => (i18n.language?.startsWith('en') ? 'en' : 'zh-cn'), [i18n.language]);
useEffect(() => {
if (visible) {
setIsMounted(true);
Animated.timing(animation, {
toValue: 1,
duration: 280,
useNativeDriver: true,
}).start();
setShouldRenderChart(true);
} else {
Animated.timing(animation, {
toValue: 0,
duration: 240,
useNativeDriver: true,
}).start(({ finished }) => {
if (finished) {
setIsMounted(false);
}
});
setShouldRenderChart(false);
setIsMounted(false);
setShowIntensityInfo(false);
}
}, [visible, animation]);
const translateY = animation.interpolate({
inputRange: [0, 1],
outputRange: [SHEET_MAX_HEIGHT, 0],
});
const backdropOpacity = animation.interpolate({
inputRange: [0, 1],
outputRange: [0, 1],
});
}, [visible]);
const activityName = workout
? getWorkoutTypeDisplayName(workout.workoutActivityType as WorkoutActivityType)
: '';
const chartWidth = useMemo(
() => Math.max(Dimensions.get('window').width - 96, 240),
[]
);
const dateInfo = useMemo(() => {
if (!workout) {
return { title: '', subtitle: '' };
}
const date = dayjs(workout.startDate || workout.endDate);
const date = dayjs(workout.startDate || workout.endDate).locale(locale);
if (!date.isValid()) {
return { title: '', subtitle: '' };
}
return {
title: date.format('M月D日'),
subtitle: date.format('YYYY年M月D日 dddd HH:mm'),
title: locale === 'en' ? date.format('MMM D') : date.format('M月D日'),
subtitle: locale === 'en'
? date.format('dddd, MMM D, YYYY HH:mm')
: date.format('YYYY年M月D日 dddd HH:mm'),
};
}, [workout]);
}, [locale, workout]);
const heartRateChart = useMemo(() => {
if (!metrics?.heartRateSeries?.length) {
@@ -156,23 +145,16 @@ export function WorkoutDetailModal({
return (
<Modal
transparent
visible={isMounted}
animationType="none"
visible={visible}
animationType='slide'
onRequestClose={onClose}
>
<View style={styles.modalContainer}>
<TouchableWithoutFeedback onPress={handleBackdropPress}>
<Animated.View style={[styles.backdrop, { opacity: backdropOpacity }]} />
<View style={styles.backdrop} />
</TouchableWithoutFeedback>
<Animated.View
style={[
styles.sheetContainer,
{
transform: [{ translateY }],
},
]}
>
<View style={styles.sheetContainer}>
<LinearGradient
colors={['#FFFFFF', '#F3F5FF']}
start={{ x: 0, y: 0 }}
@@ -206,7 +188,7 @@ export function WorkoutDetailModal({
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.contentContainer}
>
<View style={styles.summaryCard}>
<View style={[styles.summaryCard, loading ? styles.summaryCardLoading : null]}>
<View style={styles.summaryHeader}>
<Text style={styles.activityName}>{activityName}</Text>
{intensityBadge ? (
@@ -223,32 +205,34 @@ export function WorkoutDetailModal({
) : null}
</View>
<Text style={styles.summarySubtitle}>
{dayjs(workout?.startDate || workout?.endDate).format('YYYY年M月D日 dddd HH:mm')}
{dayjs(workout?.startDate || workout?.endDate)
.locale(locale)
.format(locale === 'en' ? 'dddd, MMM D, YYYY HH:mm' : 'YYYY年M月D日 dddd HH:mm')}
</Text>
{loading ? (
<View style={styles.loadingBlock}>
<ActivityIndicator color="#5C55FF" />
<Text style={styles.loadingLabel}>...</Text>
<Text style={styles.loadingLabel}>{t('workoutDetail.loading')}</Text>
</View>
) : metrics ? (
<>
<View style={styles.metricsRow}>
<View style={styles.metricItem}>
<Text style={styles.metricTitle}></Text>
<Text style={styles.metricTitle}>{t('workoutDetail.metrics.duration')}</Text>
<Text style={styles.metricValue}>{metrics.durationLabel}</Text>
</View>
<View style={styles.metricItem}>
<Text style={styles.metricTitle}></Text>
<Text style={styles.metricTitle}>{t('workoutDetail.metrics.calories')}</Text>
<Text style={styles.metricValue}>
{metrics.calories != null ? `${metrics.calories} 千卡` : '--'}
{metrics.calories != null ? `${metrics.calories} ${t('workoutDetail.metrics.caloriesUnit')}` : '--'}
</Text>
</View>
</View>
<View style={styles.metricsRow}>
<View style={styles.metricItem}>
<View style={styles.metricTitleRow}>
<Text style={styles.metricTitle}></Text>
<Text style={styles.metricTitle}>{t('workoutDetail.metrics.intensity')}</Text>
<TouchableOpacity
onPress={() => setShowIntensityInfo(true)}
style={styles.metricInfoButton}
@@ -262,9 +246,9 @@ export function WorkoutDetailModal({
</Text>
</View>
<View style={styles.metricItem}>
<Text style={styles.metricTitle}></Text>
<Text style={styles.metricTitle}>{t('workoutDetail.metrics.averageHeartRate')}</Text>
<Text style={styles.metricValue}>
{metrics.averageHeartRate != null ? `${metrics.averageHeartRate} 次/分` : '--'}
{metrics.averageHeartRate != null ? `${metrics.averageHeartRate} ${t('workoutDetail.metrics.heartRateUnit')}` : '--'}
</Text>
</View>
</View>
@@ -275,20 +259,20 @@ export function WorkoutDetailModal({
) : (
<View style={styles.errorBlock}>
<Text style={styles.errorText}>
{errorMessage || '未能获取到完整的锻炼详情'}
{errorMessage || t('workoutDetail.errors.loadFailed')}
</Text>
{onRetry ? (
<TouchableOpacity style={styles.retryButton} onPress={onRetry}>
<Text style={styles.retryButtonText}></Text>
<Text style={styles.retryButtonText}>{t('workoutDetail.retry')}</Text>
</TouchableOpacity>
) : null}
</View>
)}
</View>
<View style={styles.section}>
<View style={[styles.section, loading ? styles.sectionHeartRateLoading : null]}>
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}></Text>
<Text style={styles.sectionTitle}>{t('workoutDetail.sections.heartRateRange')}</Text>
</View>
{loading ? (
@@ -299,21 +283,21 @@ export function WorkoutDetailModal({
<>
<View style={styles.heartRateSummaryRow}>
<View style={styles.heartRateStat}>
<Text style={styles.statLabel}></Text>
<Text style={styles.statLabel}>{t('workoutDetail.sections.averageHeartRate')}</Text>
<Text style={styles.statValue}>
{metrics.averageHeartRate != null ? `${metrics.averageHeartRate}次/分` : '--'}
{metrics.averageHeartRate != null ? `${metrics.averageHeartRate} ${t('workoutDetail.sections.heartRateUnit')}` : '--'}
</Text>
</View>
<View style={styles.heartRateStat}>
<Text style={styles.statLabel}></Text>
<Text style={styles.statLabel}>{t('workoutDetail.sections.maximumHeartRate')}</Text>
<Text style={styles.statValue}>
{metrics.maximumHeartRate != null ? `${metrics.maximumHeartRate}次/分` : '--'}
{metrics.maximumHeartRate != null ? `${metrics.maximumHeartRate} ${t('workoutDetail.sections.heartRateUnit')}` : '--'}
</Text>
</View>
<View style={styles.heartRateStat}>
<Text style={styles.statLabel}></Text>
<Text style={styles.statLabel}>{t('workoutDetail.sections.minimumHeartRate')}</Text>
<Text style={styles.statValue}>
{metrics.minimumHeartRate != null ? `${metrics.minimumHeartRate}次/分` : '--'}
{metrics.minimumHeartRate != null ? `${metrics.minimumHeartRate} ${t('workoutDetail.sections.heartRateUnit')}` : '--'}
</Text>
</View>
</View>
@@ -321,67 +305,75 @@ export function WorkoutDetailModal({
{heartRateChart ? (
LineChart ? (
<View style={styles.chartWrapper}>
{/* @ts-ignore - react-native-chart-kit types are outdated */}
<LineChart
data={{
labels: heartRateChart.labels,
datasets: [
{
data: heartRateChart.data,
color: () => '#5C55FF',
strokeWidth: 2,
{shouldRenderChart ? (
/* @ts-ignore - react-native-chart-kit types are outdated */
<LineChart
data={{
labels: heartRateChart.labels,
datasets: [
{
data: heartRateChart.data,
color: () => '#5C55FF',
strokeWidth: 2,
},
],
}}
width={chartWidth}
height={220}
fromZero={false}
yAxisSuffix={t('workoutDetail.sections.heartRateUnit')}
withInnerLines={false}
bezier
paddingRight={48}
chartConfig={{
backgroundColor: '#FFFFFF',
backgroundGradientFrom: '#FFFFFF',
backgroundGradientTo: '#FFFFFF',
decimalPlaces: 0,
color: (opacity = 1) => `rgba(92, 85, 255, ${opacity})`,
labelColor: (opacity = 1) => `rgba(98, 105, 138, ${opacity})`,
propsForDots: {
r: '3',
strokeWidth: '2',
stroke: '#FFFFFF',
},
],
}}
width={Dimensions.get('window').width - 72}
height={220}
fromZero={false}
yAxisSuffix="次/分"
withInnerLines={false}
bezier
chartConfig={{
backgroundColor: '#FFFFFF',
backgroundGradientFrom: '#FFFFFF',
backgroundGradientTo: '#FFFFFF',
decimalPlaces: 0,
color: (opacity = 1) => `rgba(92, 85, 255, ${opacity})`,
labelColor: (opacity = 1) => `rgba(98, 105, 138, ${opacity})`,
propsForDots: {
r: '3',
strokeWidth: '2',
stroke: '#FFFFFF',
},
fillShadowGradientFromOpacity: 0.1,
fillShadowGradientToOpacity: 0.02,
}}
style={styles.chartStyle}
/>
fillShadowGradientFromOpacity: 0.1,
fillShadowGradientToOpacity: 0.02,
}}
style={styles.chartStyle}
/>
) : (
<View style={[styles.chartLoading, { width: chartWidth }]}>
<ActivityIndicator color="#5C55FF" />
<Text style={styles.chartLoadingText}>{t('workoutDetail.loading')}</Text>
</View>
)}
</View>
) : (
<View style={styles.chartEmpty}>
<MaterialCommunityIcons name="chart-line-variant" size={32} color="#C5CBE2" />
<Text style={styles.chartEmptyText}>线</Text>
<Text style={styles.chartEmptyText}>{t('workoutDetail.chart.unavailable')}</Text>
</View>
)
) : (
<View style={styles.chartEmpty}>
<MaterialCommunityIcons name="heart-off-outline" size={32} color="#C5CBE2" />
<Text style={styles.chartEmptyText}></Text>
<Text style={styles.chartEmptyText}>{t('workoutDetail.chart.noData')}</Text>
</View>
)}
</>
) : (
<View style={styles.sectionError}>
<Text style={styles.errorTextSmall}>
{errorMessage || '未获取到心率数据'}
{errorMessage || t('workoutDetail.errors.noHeartRateData')}
</Text>
</View>
)}
</View>
<View style={styles.section}>
<View style={[styles.section, loading ? styles.sectionZonesLoading : null]}>
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}></Text>
<Text style={styles.sectionTitle}>{t('workoutDetail.sections.heartRateZones')}</Text>
</View>
{loading ? (
@@ -389,15 +381,15 @@ export function WorkoutDetailModal({
<ActivityIndicator color="#5C55FF" />
</View>
) : metrics ? (
metrics.heartRateZones.map(renderHeartRateZone)
metrics.heartRateZones.map((zone) => renderHeartRateZone(zone, t))
) : (
<Text style={styles.errorTextSmall}></Text>
<Text style={styles.errorTextSmall}>{t('workoutDetail.errors.noZoneStats')}</Text>
)}
</View>
<View style={styles.homeIndicatorSpacer} />
</ScrollView>
</Animated.View>
</View>
{showIntensityInfo ? (
<Modal
transparent
@@ -410,36 +402,36 @@ export function WorkoutDetailModal({
<TouchableWithoutFeedback onPress={() => { }}>
<View style={styles.intensityInfoSheet}>
<View style={styles.intensityHandle} />
<Text style={styles.intensityInfoTitle}></Text>
<Text style={styles.intensityInfoTitle}>{t('workoutDetail.intensityInfo.title')}</Text>
<Text style={styles.intensityInfoText}>
MET/·
{t('workoutDetail.intensityInfo.description1')}
</Text>
<Text style={styles.intensityInfoText}>
MET 便
{t('workoutDetail.intensityInfo.description2')}
</Text>
<Text style={styles.intensityInfoText}>
3 km/h 2 METs 2
{t('workoutDetail.intensityInfo.description3')}
</Text>
<Text style={styles.intensityInfoText}>
METs 使70
{t('workoutDetail.intensityInfo.description4')}
</Text>
<View style={styles.intensityFormula}>
<Text style={styles.intensityFormulaLabel}></Text>
<Text style={styles.intensityFormulaValue}>METs = / ÷ 1 /</Text>
<Text style={styles.intensityFormulaLabel}>{t('workoutDetail.intensityInfo.formula.title')}</Text>
<Text style={styles.intensityFormulaValue}>{t('workoutDetail.intensityInfo.formula.value')}</Text>
</View>
<View style={styles.intensityLegend}>
<View style={styles.intensityLegendRow}>
<Text style={styles.intensityLegendRange}>{'< 3'}</Text>
<Text style={[styles.intensityLegendLabel, styles.intensityLow]}></Text>
<Text style={styles.intensityLegendRange}>{t('workoutDetail.intensityInfo.legend.low')}</Text>
<Text style={[styles.intensityLegendLabel, styles.intensityLow]}>{t('workoutDetail.intensityInfo.legend.lowLabel')}</Text>
</View>
<View style={styles.intensityLegendRow}>
<Text style={styles.intensityLegendRange}>3 - 6</Text>
<Text style={[styles.intensityLegendLabel, styles.intensityMedium]}></Text>
<Text style={styles.intensityLegendRange}>{t('workoutDetail.intensityInfo.legend.medium')}</Text>
<Text style={[styles.intensityLegendLabel, styles.intensityMedium]}>{t('workoutDetail.intensityInfo.legend.mediumLabel')}</Text>
</View>
<View style={styles.intensityLegendRow}>
<Text style={styles.intensityLegendRange}>{'≥ 6'}</Text>
<Text style={[styles.intensityLegendLabel, styles.intensityHigh]}></Text>
<Text style={styles.intensityLegendRange}>{t('workoutDetail.intensityInfo.legend.high')}</Text>
<Text style={[styles.intensityLegendLabel, styles.intensityHigh]}>{t('workoutDetail.intensityInfo.legend.highLabel')}</Text>
</View>
</View>
</View>
@@ -511,6 +503,7 @@ function trimHeartRateSeries(series: WorkoutDetailMetrics['heartRateSeries']) {
// 遍历所有点,选择重要点
let minDistance = Math.max(1, Math.floor(n / HEART_RATE_CHART_MAX_POINTS));
let lastSelectedIndex = 0;
for (let i = 1; i < n - 1; i++) {
const shouldKeep =
@@ -523,11 +516,9 @@ function trimHeartRateSeries(series: WorkoutDetailMetrics['heartRateSeries']) {
if (shouldKeep) {
// 检查与上一个选中点的距离,避免过于密集
const lastSelectedIndex = result.length > 0 ?
series.findIndex(p => p.timestamp === result[result.length - 1].timestamp) : 0;
if (i - lastSelectedIndex >= minDistance || isLocalExtremum(i)) {
result.push(series[i]);
lastSelectedIndex = i;
}
}
}
@@ -553,7 +544,21 @@ function trimHeartRateSeries(series: WorkoutDetailMetrics['heartRateSeries']) {
return result;
}
function renderHeartRateZone(zone: HeartRateZoneStat) {
function renderHeartRateZone(
zone: HeartRateZoneStat,
t: (key: string, options?: Record<string, any>) => string
) {
const label = t(`workoutDetail.zones.labels.${zone.key}`, {
defaultValue: zone.label,
});
const range = t(`workoutDetail.zones.ranges.${zone.key}`, {
defaultValue: zone.rangeText,
});
const meta = t('workoutDetail.zones.summary', {
minutes: zone.durationMinutes,
range,
});
return (
<View key={zone.key} style={styles.zoneRow}>
<View style={[styles.zoneBar, { backgroundColor: `${zone.color}33` }]}>
@@ -568,10 +573,8 @@ function renderHeartRateZone(zone: HeartRateZoneStat) {
/>
</View>
<View style={styles.zoneInfo}>
<Text style={styles.zoneLabel}>{zone.label}</Text>
<Text style={styles.zoneMeta}>
{zone.durationMinutes} · {zone.rangeText}
</Text>
<Text style={styles.zoneLabel}>{label}</Text>
<Text style={styles.zoneMeta}>{meta}</Text>
</View>
</View>
);
@@ -666,20 +669,28 @@ const styles = StyleSheet.create({
shadowRadius: 22,
elevation: 8,
},
summaryCardLoading: {
minHeight: 240,
},
summaryHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
alignItems: 'flex-start',
flexWrap: 'wrap',
gap: 10,
},
activityName: {
fontSize: 24,
fontWeight: '700',
color: '#1E2148',
flex: 1,
flexShrink: 1,
lineHeight: 30,
},
intensityPill: {
paddingHorizontal: 12,
paddingVertical: 4,
borderRadius: 999,
alignSelf: 'flex-start',
},
intensityPillText: {
fontSize: 12,
@@ -766,6 +777,12 @@ const styles = StyleSheet.create({
shadowRadius: 20,
elevation: 4,
},
sectionHeartRateLoading: {
minHeight: 360,
},
sectionZonesLoading: {
minHeight: 200,
},
sectionHeader: {
flexDirection: 'row',
alignItems: 'center',
@@ -809,11 +826,22 @@ const styles = StyleSheet.create({
color: '#1E2148',
},
chartWrapper: {
alignItems: 'flex-start',
overflow: 'visible',
},
chartLoading: {
height: 220,
alignItems: 'center',
justifyContent: 'center',
},
chartLoadingText: {
marginTop: 8,
fontSize: 12,
color: '#7E86A7',
},
chartStyle: {
marginLeft: -10,
marginRight: -10,
marginLeft: 0,
marginRight: 0,
},
chartEmpty: {
paddingVertical: 32,
@@ -947,4 +975,3 @@ const styles = StyleSheet.create({
height: 40,
},
});

View File

@@ -83,6 +83,9 @@ export const ROUTES = {
// 药品相关路由
MEDICATION_EDIT_FREQUENCY: '/medications/edit-frequency',
MEDICATION_MANAGE: '/medications/manage-medications',
// 底部栏配置路由
TAB_BAR_CONFIG: '/settings/tab-bar-config',
} as const;
// 路由参数常量

View File

@@ -0,0 +1,135 @@
import VersionUpdateModal from '@/components/VersionUpdateModal';
import { useToast } from '@/contexts/ToastContext';
import { fetchVersionInfo, getCurrentAppVersion, type VersionInfo } from '@/services/version';
import { log } from '@/utils/logger';
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { Linking } from 'react-native';
import { useTranslation } from 'react-i18next';
type VersionCheckContextValue = {
isChecking: boolean;
updateInfo: VersionInfo | null;
checkForUpdate: (options?: { manual?: boolean }) => Promise<VersionInfo | null>;
openStore: () => Promise<void>;
};
const VersionCheckContext = createContext<VersionCheckContextValue | undefined>(undefined);
export function VersionCheckProvider({ children }: { children: React.ReactNode }) {
const { showSuccess, showError } = useToast();
const { t } = useTranslation();
const [isChecking, setIsChecking] = useState(false);
const [updateInfo, setUpdateInfo] = useState<VersionInfo | null>(null);
const [modalVisible, setModalVisible] = useState(false);
const hasAutoCheckedRef = useRef(false);
const currentVersion = useMemo(() => getCurrentAppVersion(), []);
const openStore = useCallback(async () => {
if (!updateInfo?.appStoreUrl) {
showError(t('personal.versionCheck.missingUrl'));
return;
}
try {
const supported = await Linking.canOpenURL(updateInfo.appStoreUrl);
if (!supported) {
throw new Error('URL not supported');
}
await Linking.openURL(updateInfo.appStoreUrl);
log.info('version-update-open-store', { url: updateInfo.appStoreUrl });
} catch (error) {
log.error('version-update-open-store-failed', error);
showError(t('personal.versionCheck.openStoreFailed'));
}
}, [showError, t, updateInfo]);
const checkForUpdate = useCallback(
async ({ manual = false }: { manual?: boolean } = {}) => {
if (isChecking) {
if (manual) {
showSuccess(t('personal.versionCheck.checking'));
}
return updateInfo;
}
setIsChecking(true);
try {
const info = await fetchVersionInfo();
setUpdateInfo(info);
setModalVisible(Boolean(info?.needsUpdate));
if (info?.needsUpdate && manual) {
showSuccess(
t('personal.versionCheck.updateFound', {
version: info.latestVersion,
})
);
} else if (!info?.needsUpdate && manual) {
showSuccess(t('personal.versionCheck.upToDate'));
}
return info;
} catch (error) {
log.error('version-check-failed', error);
if (manual) {
showError(t('personal.versionCheck.failed'));
}
return null;
} finally {
setIsChecking(false);
}
},
[isChecking, showError, showSuccess, t, updateInfo]
);
useEffect(() => {
if (hasAutoCheckedRef.current) return;
hasAutoCheckedRef.current = true;
checkForUpdate({ manual: false }).catch((error) => {
log.error('auto-version-check-failed', error);
});
}, [checkForUpdate]);
const strings = useMemo(
() => ({
title: t('personal.versionCheck.modalTitle'),
tag: t('personal.versionCheck.modalTag'),
currentVersionLabel: t('personal.versionCheck.currentVersion'),
latestVersionLabel: t('personal.versionCheck.latestVersion'),
updatesTitle: t('personal.versionCheck.releaseNotesTitle'),
fallbackNote: t('personal.versionCheck.fallbackNotes'),
remindLater: t('personal.versionCheck.later'),
updateCta: t('personal.versionCheck.updateNow'),
}),
[t]
);
return (
<VersionCheckContext.Provider
value={{
isChecking,
updateInfo,
checkForUpdate,
openStore,
}}
>
{children}
<VersionUpdateModal
visible={modalVisible && Boolean(updateInfo?.needsUpdate)}
info={updateInfo}
currentVersion={currentVersion}
onClose={() => setModalVisible(false)}
onUpdate={openStore}
strings={strings}
/>
</VersionCheckContext.Provider>
);
}
export function useVersionCheck(): VersionCheckContextValue {
const context = useContext(VersionCheckContext);
if (!context) {
throw new Error('useVersionCheck must be used within VersionCheckProvider');
}
return context;
}

View File

@@ -1,304 +0,0 @@
# 推送通知功能实现文档
## 概述
本项目已成功集成本地推送通知功能,使用 Expo 官方的 `expo-notifications` 库。该功能支持立即通知、定时通知、重复通知等多种类型,并提供了完整的权限管理和通知处理机制。
## 技术栈
- **expo-notifications**: Expo 官方推送通知库
- **React Native**: 跨平台移动应用框架
- **TypeScript**: 类型安全的 JavaScript 超集
## 文件结构
```
services/
├── notifications.ts # 推送通知服务核心逻辑
hooks/
├── useNotifications.ts # 推送通知自定义 Hook
components/
├── NotificationTest.tsx # 通知功能测试组件
app/(tabs)/
├── personal.tsx # 个人页面(集成通知开关)
```
## 核心功能
### 1. 通知服务 (services/notifications.ts)
#### 主要特性
- **单例模式**: 确保全局只有一个通知服务实例
- **权限管理**: 自动请求和管理通知权限
- **多种通知类型**: 支持立即、定时、重复通知
- **通知监听**: 处理通知接收和点击事件
- **便捷方法**: 提供常用通知类型的快捷发送方法
#### 核心方法
```typescript
// 初始化通知服务
await notificationService.initialize();
// 发送立即通知
await notificationService.sendImmediateNotification({
title: '标题',
body: '内容',
sound: true,
priority: 'high'
});
// 安排定时通知
await notificationService.scheduleNotificationAtDate(
notification,
new Date(Date.now() + 5000) // 5秒后
);
// 安排重复通知
await notificationService.scheduleRepeatingNotification(
notification,
{ minutes: 1 } // 每分钟重复
);
// 取消通知
await notificationService.cancelNotification(notificationId);
await notificationService.cancelAllNotifications();
```
### 2. 自定义 Hook (hooks/useNotifications.ts)
#### 主要特性
- **状态管理**: 管理通知权限和初始化状态
- **自动初始化**: 组件挂载时自动初始化通知服务
- **便捷接口**: 提供简化的通知操作方法
- **类型安全**: 完整的 TypeScript 类型定义
#### 使用示例
```typescript
const {
isInitialized,
permissionStatus,
sendNotification,
scheduleNotification,
sendWorkoutReminder,
sendGoalAchievement,
} = useNotifications();
// 发送运动提醒
await sendWorkoutReminder('运动提醒', '该开始今天的普拉提训练了!');
// 发送目标达成通知
await sendGoalAchievement('目标达成', '恭喜您完成了本周的运动目标!');
```
### 3. 测试组件 (components/NotificationTest.tsx)
#### 功能特性
- **完整测试**: 测试所有通知功能
- **状态显示**: 显示初始化状态和权限状态
- **交互测试**: 提供各种通知类型的测试按钮
- **通知列表**: 显示已安排的通知列表
## 配置说明
### app.json 配置
```json
{
"expo": {
"plugins": [
[
"expo-notifications",
{
"icon": "./assets/images/Sealife.jpeg",
"color": "#ffffff",
"sounds": ["./assets/sounds/notification.wav"]
}
]
],
"ios": {
"infoPlist": {
"UIBackgroundModes": ["remote-notification"]
}
},
"android": {
"permissions": [
"android.permission.RECEIVE_BOOT_COMPLETED",
"android.permission.VIBRATE",
"android.permission.WAKE_LOCK"
]
}
}
}
```
## 使用场景
### 1. 运动提醒
```typescript
// 每天定时发送运动提醒
await scheduleRepeatingNotification(
{
title: '运动提醒',
body: '该开始今天的普拉提训练了!',
data: { type: 'workout_reminder' },
sound: true,
priority: 'high'
},
{ days: 1 }
);
```
### 2. 目标达成通知
```typescript
// 用户达成目标时立即发送通知
await sendGoalAchievement('目标达成', '恭喜您完成了本周的运动目标!');
```
### 3. 心情打卡提醒
```typescript
// 每天晚上提醒用户记录心情
const eveningTime = new Date();
eveningTime.setHours(20, 0, 0, 0);
await scheduleNotification(
{
title: '心情打卡',
body: '记得记录今天的心情状态哦',
data: { type: 'mood_checkin' },
sound: true,
priority: 'normal'
},
eveningTime
);
```
### 4. 营养提醒
```typescript
// 定时提醒用户记录饮食
await scheduleRepeatingNotification(
{
title: '营养记录',
body: '记得记录今天的饮食情况',
data: { type: 'nutrition_reminder' },
sound: true,
priority: 'normal'
},
{ hours: 4 } // 每4小时提醒一次
);
```
## 权限处理
### iOS 权限
- 自动请求通知权限
- 支持后台通知模式
- 处理权限被拒绝的情况
### Android 权限
- 自动请求必要权限
- 支持开机启动和唤醒锁
- 处理权限被拒绝的情况
## 通知处理
### 通知接收处理
```typescript
Notifications.addNotificationReceivedListener((notification) => {
console.log('收到通知:', notification);
// 可以在这里处理通知接收逻辑
});
```
### 通知点击处理
```typescript
Notifications.addNotificationResponseReceivedListener((response) => {
const { notification } = response;
const data = notification.request.content.data;
// 根据通知类型处理不同的逻辑
if (data?.type === 'workout_reminder') {
// 跳转到运动页面
} else if (data?.type === 'goal_achievement') {
// 跳转到目标页面
}
});
```
## 最佳实践
### 1. 通知内容
- 标题简洁明了不超过50个字符
- 内容具体有用不超过200个字符
- 使用适当的优先级和声音
### 2. 定时策略
- 避免过于频繁的通知
- 考虑用户的使用习惯
- 提供通知频率设置选项
### 3. 错误处理
- 始终处理权限请求失败的情况
- 提供用户友好的错误提示
- 记录通知发送失败的原因
### 4. 性能优化
- 避免同时发送大量通知
- 及时清理不需要的通知
- 合理使用重复通知
## 测试建议
### 1. 功能测试
- 测试所有通知类型
- 验证权限请求流程
- 检查通知点击处理
### 2. 兼容性测试
- 测试不同 iOS 版本
- 测试不同 Android 版本
- 验证后台通知功能
### 3. 用户体验测试
- 测试通知时机是否合适
- 验证通知内容是否清晰
- 检查通知频率是否合理
## 故障排除
### 常见问题
1. **通知不显示**
- 检查权限是否已授予
- 确认应用是否在前台
- 验证通知配置是否正确
2. **定时通知不触发**
- 检查设备是否重启
- 确认应用是否被系统杀死
- 验证时间设置是否正确
3. **权限被拒绝**
- 引导用户到系统设置
- 提供权限说明
- 实现降级处理方案
### 调试技巧
```typescript
// 启用详细日志
console.log('通知权限状态:', await notificationService.getPermissionStatus());
console.log('已安排通知:', await notificationService.getAllScheduledNotifications());
// 测试通知发送
await notificationService.sendImmediateNotification({
title: '测试通知',
body: '这是一个测试通知',
sound: true
});
```
## 总结
本推送通知功能实现完整、功能丰富,支持多种通知类型和场景。通过合理的架构设计和错误处理,确保了功能的稳定性和用户体验。开发者可以根据具体需求灵活使用各种通知功能,为用户提供个性化的提醒服务。

View File

@@ -5,6 +5,7 @@ import { Alert } from 'react-native';
import { ROUTES } from '@/constants/Routes';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useI18n } from '@/hooks/useI18n';
import { STORAGE_KEYS, api } from '@/services/api';
import { logout as logoutAction } from '@/store/userSlice';
@@ -21,8 +22,11 @@ export function useAuthGuard() {
const dispatch = useAppDispatch();
const currentPath = usePathname();
const user = useAppSelector(state => state.user);
const { t } = useI18n();
const isLoggedIn = !!user?.profile?.id;
// 判断登录状态:优先使用 token因为 token 是登录的根本凭证
// profile.id 可能在初始化时还未加载,但 token 已经从 AsyncStorage 恢复
const isLoggedIn = !!user?.token;
const ensureLoggedIn = useCallback(async (options?: EnsureOptions): Promise<boolean> => {
if (isLoggedIn) return true;
@@ -72,28 +76,28 @@ export function useAuthGuard() {
router.push('/auth/login');
} catch (error) {
console.error('退出登录失败:', error);
Alert.alert('错误', '退出登录失败,请稍后重试');
Alert.alert(t('authGuard.logout.error'), t('authGuard.logout.errorMessage'));
}
}, [dispatch, router]);
}, [dispatch, router, t]);
// 带确认对话框的退出登录
const confirmLogout = useCallback(() => {
Alert.alert(
'确认退出',
'确定要退出当前账号吗?',
t('authGuard.confirmLogout.title'),
t('authGuard.confirmLogout.message'),
[
{
text: '取消',
text: t('authGuard.confirmLogout.cancelButton'),
style: 'cancel',
},
{
text: '确定',
text: t('authGuard.confirmLogout.confirmButton'),
style: 'default',
onPress: handleLogout,
},
]
);
}, [handleLogout]);
}, [handleLogout, t]);
// 注销账号功能
const handleDeleteAccount = useCallback(async () => {
@@ -107,38 +111,38 @@ export function useAuthGuard() {
// 执行退出登录逻辑
await dispatch(logoutAction()).unwrap();
Alert.alert('账号已注销', '您的账号已成功注销', [
Alert.alert(t('authGuard.deleteAccount.successTitle'), t('authGuard.deleteAccount.successMessage'), [
{
text: '确定',
text: t('authGuard.deleteAccount.confirmButton'),
onPress: () => router.push('/auth/login'),
},
]);
} catch (error: any) {
console.error('注销账号失败:', error);
const message = error?.message || '注销失败,请稍后重试';
Alert.alert('注销失败', message);
const message = error?.message || t('authGuard.deleteAccount.errorMessage');
Alert.alert(t('authGuard.deleteAccount.errorTitle'), message);
}
}, [dispatch, router]);
}, [dispatch, router, t]);
// 带确认对话框的注销账号
const confirmDeleteAccount = useCallback(() => {
Alert.alert(
'确认注销账号',
'此操作不可恢复,将删除您的账号及相关数据。确定继续吗?',
t('authGuard.confirmDeleteAccount.title'),
t('authGuard.confirmDeleteAccount.message'),
[
{
text: '取消',
text: t('authGuard.confirmDeleteAccount.cancelButton'),
style: 'cancel',
},
{
text: '确认注销',
text: t('authGuard.confirmDeleteAccount.confirmButton'),
style: 'destructive',
onPress: handleDeleteAccount,
},
],
{ cancelable: true }
);
}, [handleDeleteAccount]);
}, [handleDeleteAccount, t]);
return {
isLoggedIn,

303
i18n/en/challenge.ts Normal file
View File

@@ -0,0 +1,303 @@
export const challengeDetail = {
title: 'Challenge Details',
notFound: 'Challenge not found, please try again later.',
loading: 'Loading challenge details…',
retry: 'Reload',
share: {
generating: 'Generating share card...',
failed: 'Share failed, please try again later',
messageJoined: 'I\'m participating in "{{title}}" challenge, completed {{completed}}/{{target}} days! Join me!',
messageNotJoined: 'Found an amazing challenge "{{title}}", let\'s join together!',
},
dateRange: {
format: '{{start}} - {{end}}',
monthDay: 'Month {{month}} Day {{day}}',
ongoing: 'Ongoing updates',
},
participants: {
count: '{{count}} participants',
ongoing: 'Ongoing updates',
more: 'More',
},
detail: {
requirement: 'Daily check-in auto accumulates',
viewAllRanking: 'View All',
},
checkIn: {
title: 'Challenge Check-in',
todayChecked: 'Checked in today',
subtitle: 'Daily check-ins accumulate progress towards goal',
subtitleChecked: 'Today\'s progress recorded, keep it up tomorrow',
button: {
checkIn: 'Check In Now',
checking: 'Checking in…',
checked: 'Checked in today',
notJoined: 'Join to check in',
upcoming: 'Not started yet',
expired: 'Challenge ended',
},
toast: {
alreadyChecked: 'Already checked in today',
notStarted: 'Challenge not started yet, check in after it begins',
expired: 'Challenge has ended, cannot check in',
mustJoin: 'Join the challenge to check in',
success: 'Check-in successful, keep going!',
failed: 'Check-in failed, please try again',
},
},
cta: {
join: 'Join Challenge',
joining: 'Joining…',
leave: 'Leave Challenge',
leaving: 'Leaving…',
delete: 'Delete Challenge',
deleting: 'Deleting…',
upcoming: 'Starting Soon',
expired: 'Challenge Ended',
},
highlight: {
join: {
title: 'Join Challenge Now',
subtitle: 'Invite friends to persist together, achieve more easily',
},
leave: {
title: 'Don\'t leave just yet',
subtitle: 'Keep going, the next milestone is around the corner',
},
upcoming: {
title: 'Challenge Starting Soon',
subtitle: 'Starts on {{date}}, stay tuned',
subtitleFallback: 'Challenge coming soon, stay tuned',
},
expired: {
title: 'Challenge Ended',
subtitle: 'Ended on {{date}}, look forward to the next one',
subtitleFallback: 'This round has ended, look forward to the next challenge',
},
},
alert: {
leaveConfirm: {
title: 'Confirm leaving challenge?',
message: 'You will need to rejoin to continue.',
cancel: 'Cancel',
confirm: 'Leave Challenge',
},
joinFailed: 'Failed to join challenge',
leaveFailed: 'Failed to leave challenge',
archiveConfirm: {
title: 'Delete this challenge?',
message: 'This cannot be undone and participants will lose access.',
cancel: 'Cancel',
confirm: 'Delete Challenge',
},
archiveFailed: 'Failed to delete challenge',
archiveSuccess: 'Challenge deleted',
},
ranking: {
title: 'Leaderboard',
description: '',
empty: 'Leaderboard opening soon, grab your spot.',
today: 'Today',
todayGoal: 'Today\'s Goal',
hour: 'hrs',
},
leaderboard: {
title: 'Leaderboard',
loading: 'Loading leaderboard…',
notFound: 'Challenge not found.',
loadFailed: 'Unable to load leaderboard, please try again later.',
empty: 'Leaderboard opening soon, grab your spot.',
loadMore: 'Loading more…',
loadMoreFailed: 'Failed to load more, pull to refresh and retry',
},
shareCard: {
footer: 'Out Live · Beyond Life',
progress: {
label: 'My Progress',
days: '{{completed}} / {{target}} days',
completed: '🎉 Challenge Completed!',
remaining: '{{remaining}} days to complete',
},
info: {
checkInDaily: 'Daily check-in',
joinUs: 'Join us!',
},
shareCode: {
copied: 'Share code copied',
},
},
shareCode: {
copied: 'Share code copied',
},
};
export const badges = {
title: 'Badge Gallery',
subtitle: 'Celebrate every effort',
hero: {
highlight: 'Keep checking in to unlock rarer badges.',
earnedLabel: 'Earned',
totalLabel: 'Total',
progressLabel: 'Progress',
},
categories: {
all: 'All',
sleep: 'Sleep',
exercise: 'Exercise',
diet: 'Nutrition',
challenge: 'Challenge',
social: 'Social',
special: 'Special',
},
rarities: {
common: 'Common',
uncommon: 'Uncommon',
rare: 'Rare',
epic: 'Epic',
legendary: 'Legendary',
},
status: {
earned: 'Unlocked',
locked: 'Locked',
earnedAt: 'Unlocked on {{date}}',
},
legend: 'Rarity legend',
filterLabel: 'Badge categories',
empty: {
title: 'No badges yet',
description: 'Complete sleep, workout, or challenge tasks to earn your first badge.',
action: 'Explore plans',
},
};
export const challenges = {
title: 'Challenges',
subtitle: 'Join challenges to stay consistent',
loading: 'Loading challenges…',
loadFailed: 'Failed to load challenges, please try again later.',
retry: 'Retry',
empty: 'No challenges yet. Join one to get started.',
customChallenges: 'Custom Challenges',
officialChallengesTitle: 'Official Challenges',
officialChallenges: 'Official challenges launching soon.',
join: 'Join',
joined: 'Joined',
invalidInviteCode: 'Please enter a valid invite code',
joinSuccess: 'Joined challenge successfully',
joinFailed: 'Failed to join challenge',
joinModal: {
title: 'Join via invite code',
description: 'Enter the invite code to join a challenge',
confirm: 'Join',
joining: 'Joining…',
cancel: 'Cancel',
placeholder: 'Enter invite code',
},
statusLabels: {
upcoming: 'Upcoming',
ongoing: 'Ongoing',
expired: 'Ended',
},
createCustom: {
title: 'Create Challenge',
editTitle: 'Edit Challenge',
yourChallenge: 'Your challenge',
basicInfo: 'Basic Info',
challengeSettings: 'Challenge Settings',
displayInteraction: 'Display & Interaction',
durationDays: '{{days}} days',
durationDaysChallenge: '{{days}}-day challenge',
dayUnit: 'days',
defaultTitle: 'Custom Challenge',
rankingDescription: 'Leaderboard updates daily',
typeLabels: {
water: 'Hydration',
exercise: 'Exercise',
diet: 'Diet',
sleep: 'Sleep',
mood: 'Mood',
weight: 'Weight',
custom: 'Custom',
},
fields: {
title: 'Challenge title',
titlePlaceholder: 'e.g., 21-day hydration',
coverImage: 'Cover image',
uploadCover: 'Upload cover',
challengeDescription: 'Challenge description',
descriptionPlaceholder: 'Describe the goal and check-in rules',
challengeType: 'Challenge type',
challengeTypeHelper: 'Pick the category closest to your goal',
timeRange: 'Time range',
start: 'Start date',
end: 'End date',
duration: 'Duration',
periodLabel: 'Period label',
periodLabelPlaceholder: 'e.g., 21-day sprint',
dailyTargetAndUnit: 'Daily target & unit',
dailyTargetPlaceholder: 'Daily target value',
unitPlaceholder: 'Unit (cups / mins / steps...)',
unitHelper: 'Optional, shown after the daily target',
minimumCheckInDays: 'Minimum check-in days',
minimumCheckInDaysPlaceholder: 'Cannot exceed total duration',
maxParticipants: 'Max participants',
noLimit: 'No limit',
isPublic: 'Allow public join',
publicDescription: 'Others can join with the invite code when enabled.',
},
floatingCTA: {
title: 'Generate invite code',
subtitle: 'Create a challenge and share it with friends',
editTitle: 'Save changes',
editSubtitle: 'Update the challenge for all participants',
},
buttons: {
createAndGenerateCode: 'Create & generate code',
creating: 'Creating…',
updateAndSave: 'Save changes',
updating: 'Saving…',
},
datePicker: {
confirm: 'Confirm',
cancel: 'Cancel',
},
alerts: {
titleRequired: 'Please enter a challenge title',
endTimeError: 'End date must be after start date',
targetValueError: 'Daily target must be between 1 and 1000',
minimumDaysError: 'Minimum check-in days must be between 1 and 365',
minimumDaysExceedError: 'Minimum check-in days cannot exceed total duration',
participantsError: 'Participants must be between 2 and 10000 or leave empty',
createFailed: 'Failed to create challenge',
createSuccess: 'Challenge created',
updateSuccess: 'Challenge updated',
},
imageUpload: {
selectSource: 'Choose cover',
selectMessage: 'Take a photo or pick from album',
camera: 'Camera',
album: 'Album',
cancel: 'Cancel',
cameraPermission: 'Camera permission required',
cameraPermissionMessage: 'Enable camera access to take a photo.',
albumPermissionMessage: 'Enable photo access to choose from library.',
cameraFailed: 'Failed to open camera',
cameraFailedMessage: 'Please try again or choose from album.',
selectFailed: 'Selection failed',
selectFailedMessage: 'Could not select an image, please try again.',
uploadFailed: 'Upload failed',
uploadFailedMessage: 'Cover upload failed, please retry.',
uploading: 'Uploading…',
clear: 'Remove cover',
helper: 'Use a 16:9 cover under 2MB for better results.',
},
shareModal: {
title: 'Invite code generated',
subtitle: 'Share this code so others can join your challenge',
generatingCode: 'Generating…',
copyCode: 'Copy code',
viewChallenge: 'View challenge',
later: 'Share later',
},
},
};

14
i18n/en/common.ts Normal file
View File

@@ -0,0 +1,14 @@
export const dateSelector = {
backToToday: 'Back to Today',
cancel: 'Cancel',
confirm: 'Confirm',
};
export const common = {
alert: 'Alert',
success: 'Success',
error: 'Error',
delete: 'Delete',
confirm: 'Confirm',
cancel: 'Cancel',
};

551
i18n/en/diet.ts Normal file
View File

@@ -0,0 +1,551 @@
export const nutritionRecords = {
title: 'Nutrition Records',
listTitle: 'Today\'s Meals',
recordCount: '{{count}} records',
empty: {
title: 'No records today',
action: 'Add Record',
},
footer: {
end: '- No more records -',
loadMore: 'Load More',
},
delete: {
title: 'Confirm Delete',
message: 'Are you sure you want to delete this nutrition record? This action cannot be undone.',
cancel: 'Cancel',
confirm: 'Delete',
},
mealTypes: {
breakfast: 'Breakfast',
lunch: 'Lunch',
dinner: 'Dinner',
snack: 'Snack',
other: 'Other',
},
nutrients: {
protein: 'Protein',
fat: 'Fat',
carbs: 'Carbs',
unit: 'g',
caloriesUnit: 'kcal',
},
overlay: {
title: 'Record Method',
scan: 'AI Scan',
foodLibrary: 'Food Library',
voiceRecord: 'Voice Log',
},
chart: {
remaining: 'Remaining',
formula: 'Remaining = Metabolism + Exercise - Diet',
metabolism: 'Metabolism',
exercise: 'Exercise',
diet: 'Diet',
},
};
export const foodCamera = {
title: 'Food Camera',
hint: 'Keep food within the frame',
permission: {
title: 'Camera Permission Required',
description: 'Camera access is needed to capture food for AI recognition',
button: 'Allow Access',
},
guide: {
title: 'Shooting Guide',
description: 'Please upload or take clear photos of food to improve recognition accuracy',
button: 'Got it',
good: 'Good lighting, clear subject',
bad: 'Blurry, poor lighting',
},
buttons: {
album: 'Album',
capture: 'Capture',
help: 'Help',
},
alerts: {
captureFailed: {
title: 'Capture Failed',
message: 'Please try again',
},
pickFailed: {
title: 'Selection Failed',
message: 'Please try again',
},
},
};
export const foodRecognition = {
title: 'Food Recognition',
header: {
confirm: 'Confirm Food',
recognizing: 'AI Recognizing',
},
errors: {
noImage: 'Image not found',
generic: 'Food recognition failed, please try again',
unknown: 'Unknown error',
noFoodDetected: 'Recognition failed: No food detected',
processError: 'Error during recognition process',
},
logs: {
uploading: '📤 Uploading image to cloud...',
uploadSuccess: '✅ Image upload completed',
analyzing: '🤖 AI model analyzing...',
analysisSuccess: '✅ AI analysis completed',
confidence: '🎯 Confidence: {{value}}%',
itemsFound: '🍽️ Detected {{count}} food items',
failed: '❌ Recognition failed: No food detected',
error: '❌ Error during recognition process',
},
status: {
idle: {
title: 'Ready',
subtitle: 'Please wait...',
},
uploading: {
title: 'Uploading Image',
subtitle: 'Uploading image to cloud server...',
},
recognizing: {
title: 'AI Analyzing',
subtitle: 'AI model is analyzing food ingredients...',
},
completed: {
title: 'Success',
subtitle: 'Redirecting to analysis results...',
},
failed: {
title: 'Failed',
subtitle: 'Please check network or try again later',
},
processing: {
title: 'Processing...',
subtitle: 'Please wait...',
},
},
mealTypes: {
breakfast: 'Breakfast',
lunch: 'Lunch',
dinner: 'Dinner',
snack: 'Snack',
unknown: 'Unknown',
},
info: {
title: 'Smart Food Recognition',
description: 'AI will analyze the photo, identify food types, estimate nutrition, and generate a detailed report.',
},
actions: {
start: 'Start Recognition',
retry: 'Retry',
logs: 'Process Logs',
logsPlaceholder: 'Ready to start...',
},
alerts: {
recognizing: {
title: 'Recognition in progress',
message: 'Recognition is not complete. Are you sure you want to go back?',
continue: 'Continue',
back: 'Go Back',
},
},
};
export const foodAnalysisResult = {
title: 'Analysis Result',
error: {
notFound: 'Image or recognition result not found',
},
placeholder: 'Nutrition Record',
nutrients: {
caloriesUnit: 'kcal',
protein: 'Protein',
fat: 'Fat',
carbs: 'Carbs',
unit: 'g',
},
sections: {
recognitionResult: 'Recognition Result',
foodIntake: 'Food Intake',
},
nonFood: {
title: 'No Food Detected',
suggestions: {
title: 'Suggestions:',
item1: '• Ensure food is in the frame',
item2: '• Try a clearer angle',
item3: '• Avoid blur or poor lighting',
},
},
actions: {
retake: 'Retake',
record: 'Record',
close: 'Close',
},
mealSelector: {
title: 'Select Meal',
},
editModal: {
title: 'Edit Food Info',
fields: {
name: 'Food Name',
namePlaceholder: 'Enter food name',
amount: 'Weight (g)',
amountPlaceholder: 'Enter weight',
calories: 'Calories (kcal)',
caloriesPlaceholder: 'Enter calories',
},
actions: {
cancel: 'Cancel',
save: 'Save',
},
},
confidence: 'Confidence: {{value}}%',
dateFormats: {
today: 'MMM D, YYYY',
full: 'MMM D, YYYY HH:mm',
},
};
export const foodLibrary = {
title: 'Food Library',
custom: 'Custom',
search: {
placeholder: 'Search food...',
loading: 'Searching...',
empty: 'No relevant food found',
noData: 'No food data',
},
loading: 'Loading food library...',
retry: 'Retry',
mealTypes: {
breakfast: 'Breakfast',
lunch: 'Lunch',
dinner: 'Dinner',
snack: 'Snack',
},
actions: {
record: 'Record',
selectMeal: 'Select Meal',
},
alerts: {
deleteFailed: {
title: 'Delete Failed',
message: 'Error occurred while deleting food, please try again later',
},
createFailed: {
title: 'Create Failed',
message: 'Error occurred while creating custom food, please try again later',
},
},
};
export const createCustomFood = {
title: 'Create Custom Food',
save: 'Save',
preview: {
title: 'Preview',
defaultName: 'Food Name',
},
basicInfo: {
title: 'Basic Info',
name: 'Food Name',
namePlaceholder: 'e.g. Hamburger',
defaultAmount: 'Default Amount',
calories: 'Calories',
},
optionalInfo: {
title: 'Optional Info',
photo: 'Photo',
addPhoto: 'Add Photo',
protein: 'Protein',
fat: 'Fat',
carbohydrate: 'Carbs',
},
units: {
kcal: 'kcal',
g: 'g',
gram: 'g',
},
alerts: {
permissionDenied: {
title: 'Permission Denied',
message: 'Photo library permission is required to select photos',
},
uploadFailed: {
title: 'Upload Failed',
message: 'Photo upload failed, please try again',
},
error: {
title: 'Error',
message: 'Failed to select photo, please try again',
},
validation: {
title: 'Notice',
nameRequired: 'Please enter food name',
caloriesRequired: 'Please enter valid calories',
},
},
};
export const voiceRecord = {
title: 'Voice Log',
intro: {
description: 'Describe your meal with voice, AI will intelligently analyze nutrition and calories',
},
status: {
idle: 'Tap microphone to start recording',
listening: 'Listening... Please start speaking...',
processing: 'AI is processing voice content...',
analyzing: 'AI model is deeply analyzing nutritional components...',
result: 'Voice recognition completed, please confirm the result',
},
hints: {
listening: 'Tell us about the food you want to record',
},
examples: {
title: 'Recording Examples:',
items: [
'This morning I had two fried eggs, a slice of whole wheat bread and a glass of milk',
'For lunch I had about 150g of braised pork, a small bowl of rice and a serving of vegetables',
'For dinner I had steamed egg custard, seaweed egg drop soup and a bowl of millet porridge',
],
},
analysis: {
progress: 'Analysis Progress: {{progress}}%',
hint: 'AI is deeply analyzing your food description...',
},
result: {
label: 'Recognition Result:',
},
actions: {
retry: 'Retry Recording',
confirm: 'Confirm & Use',
},
alerts: {
noVoiceInput: 'No voice input detected, please try again',
networkError: 'Network connection error, please check network and try again',
voiceError: 'Voice recognition problem occurred, please try again',
noValidContent: 'No valid content recognized, please record again',
pleaseRecordFirst: 'Please perform voice recognition first',
recordingFailed: 'Recording Failed',
recordingPermissionError: 'Unable to start voice recognition, please check microphone permission settings',
analysisFailed: 'Analysis Failed',
},
};
export const nutritionLabelAnalysis = {
title: 'Nutrition Label Analysis',
camera: {
permissionDenied: 'Permission Denied',
permissionMessage: 'Camera permission is required to take nutrition label photos',
},
actions: {
takePhoto: 'Take Photo',
selectFromAlbum: 'Select from Album',
startAnalysis: 'Start Analysis',
close: 'Close',
},
placeholder: {
text: 'Take or select a nutrition label photo',
},
status: {
uploading: 'Uploading image...',
analyzing: 'Analyzing nutrition label...',
},
errors: {
analysisFailed: {
title: 'Analysis Failed',
message: 'Error occurred while analyzing the image, please try again',
defaultMessage: 'Analysis service is temporarily unavailable',
},
cannotRecognize: 'Unable to recognize nutrition label, please try taking a clearer photo',
cameraPermissionDenied: 'Camera permission is required to take nutrition label photos',
},
results: {
title: 'Detailed Nutrition Analysis',
detailedAnalysis: 'Detailed Nutrition Analysis',
},
imageViewer: {
close: 'Close',
dateFormat: 'MMM D, YYYY HH:mm',
},
};
export const nutritionAnalysisHistory = {
title: 'History',
dateFormat: 'MMM D, YYYY HH:mm',
recognized: 'Recognized {{count}} nutrients',
loadingMore: 'Loading more...',
loading: 'Loading history...',
filter: {
all: 'All',
},
filters: {
all: 'All',
success: 'Success',
failed: 'Failed',
},
status: {
success: 'Success',
failed: 'Failed',
processing: 'Processing',
unknown: 'Unknown',
},
nutrients: {
energy: 'Energy',
protein: 'Protein',
carbs: 'Carbs',
fat: 'Fat',
},
delete: {
confirmTitle: 'Confirm Delete',
confirmMessage: 'Are you sure you want to delete this record?',
cancel: 'Cancel',
delete: 'Delete',
successTitle: 'Deleted Successfully',
successMessage: 'Record has been deleted successfully',
},
actions: {
expand: 'Expand Details',
collapse: 'Collapse Details',
expandDetails: 'Expand Details',
collapseDetails: 'Collapse Details',
confirmDelete: 'Confirm Delete',
delete: 'Delete',
cancel: 'Cancel',
retry: 'Retry',
},
empty: {
title: 'No History Records',
subtitle: 'Start recognizing nutrition labels',
},
errors: {
error: 'Error',
loadFailed: 'Load Failed',
unknownError: 'Unknown Error',
fetchFailed: 'Failed to fetch history records',
fetchFailedRetry: 'Failed to fetch history records, please retry',
deleteFailed: 'Delete failed, please try again later',
},
loadingState: {
records: 'Loading history...',
more: 'Loading more...',
},
details: {
title: 'Detailed Nutrition Information',
nutritionDetails: 'Detailed Nutrition Information',
aiModel: 'AI Model',
provider: 'Service Provider',
serviceProvider: 'Service Provider',
},
records: {
nutritionCount: 'Recognized {{count}} nutrients',
},
imageViewer: {
close: 'Close',
},
};
export const waterDetail = {
title: 'Water Details',
waterRecord: 'Water Records',
today: 'Today',
total: 'Total: ',
goal: 'Goal: ',
noRecords: 'No water records',
noRecordsSubtitle: 'Tap "Add Record" to start tracking water intake',
deleteConfirm: {
title: 'Confirm Delete',
message: 'Are you sure you want to delete this water record? This action cannot be undone.',
cancel: 'Cancel',
confirm: 'Delete',
},
deleteButton: 'Delete',
water: 'Water',
loadingUserPreferences: 'Failed to load user preferences',
};
export const waterSettings = {
title: 'Water Settings',
sections: {
dailyGoal: 'Daily Water Goal',
quickAdd: 'Quick Add Default',
reminder: 'Water Reminder',
},
descriptions: {
quickAdd: 'Set the default water amount when clicking the "+" button',
reminder: 'Set periodic reminders to replenish water',
},
labels: {
ml: 'ml',
disabled: 'Disabled',
},
alerts: {
goalSuccess: {
title: 'Settings Saved',
message: 'Daily water goal has been set to {{amount}}ml',
},
quickAddSuccess: {
title: 'Settings Saved',
message: 'Quick add default has been set to {{amount}}ml',
},
quickAddFailed: {
title: 'Save Failed',
message: 'Unable to save quick add default, please try again',
},
},
buttons: {
cancel: 'Cancel',
confirm: 'Confirm',
},
status: {
reminderEnabled: '{{startTime}}-{{endTime}}, every {{interval}} minutes',
},
};
export const waterReminderSettings = {
title: 'Water Reminder',
sections: {
notifications: 'Push Notifications',
timeRange: 'Reminder Time Range',
interval: 'Reminder Interval',
},
descriptions: {
notifications: 'Enable to receive periodic water reminders during specified time periods',
timeRange: 'Only send reminders during specified time periods to avoid disturbing your rest',
interval: 'Choose the reminder frequency, recommended 30-120 minutes',
},
labels: {
startTime: 'Start Time',
endTime: 'End Time',
interval: 'Reminder Interval',
saveSettings: 'Save Settings',
hours: 'Hours',
timeRangePreview: 'Time Range Preview',
minutes: 'Minutes',
},
alerts: {
timeValidation: {
title: 'Time Setting Tip',
startTimeInvalid: 'Start time cannot be later than or equal to end time, please select again',
endTimeInvalid: 'End time cannot be earlier than or equal to start time, please select again',
},
success: {
enabled: 'Settings Saved',
enabledMessage: 'Water reminder has been enabled\n\nTime range: {{timeRange}}\nReminder interval: {{interval}}\n\nWe will periodically remind you to drink water during the specified time period',
disabled: 'Settings Saved',
disabledMessage: 'Water reminder has been disabled',
},
error: {
title: 'Save Failed',
message: 'Unable to save water reminder settings, please try again',
},
},
buttons: {
confirm: 'Confirm',
cancel: 'Cancel',
},
};

689
i18n/en/health.ts Normal file
View File

@@ -0,0 +1,689 @@
export const healthPermissions = {
title: 'Health data disclosure',
subtitle: 'We integrate with Apple Health through HealthKit and CareKit to deliver precise training, recovery, and reminder experiences.',
cards: {
usage: {
title: 'Data we read or write',
items: [
'Activity: steps, active energy, and workouts fuel performance charts and rings.',
'Body metrics: height, weight, and body fat keep plans and nutrition tips personalized.',
'Sleep & recovery: duration and stages unlock recovery advice and reminders.',
'Hydration: we read and write water intake so Health and the app stay in sync.',
],
},
purpose: {
title: 'Why we need it',
items: [
'Generate adaptive training plans, challenges, and recovery nudges.',
'Display long-term trends so you can understand progress at a glance.',
'Reduce manual input by syncing reminders and challenge progress automatically.',
],
},
control: {
title: 'Your control',
items: [
'Permissions are granted inside Apple Health; change them anytime under iOS Settings > Health > Data Access & Devices.',
'We never access data you do not authorize, and cached values are removed if you revoke access.',
'Core functionality keeps working and offers manual input alternatives.',
],
},
privacy: {
title: 'Storage & privacy',
items: [
'Health data stays on your device — we do not upload it or share it with third parties.',
'Only aggregated, anonymized stats are synced when absolutely necessary.',
"We follow Apple's review requirements and will notify you before any changes.",
],
},
},
callout: {
title: 'What if I skip authorization?',
items: [
'The related modules will ask for permission and provide manual logging options.',
'Declining does not break other areas of the app that do not rely on Health data.',
],
},
contact: {
title: 'Need help?',
description: 'Questions about HealthKit or CareKit? Reach out via email or the in-app feedback form:',
email: 'richardwei1995@gmail.com',
},
};
export const statistics = {
title: 'Out Live',
aiReport: {
button: 'Report',
generating: 'Generating your AI health report, this may take 1030s…',
generatingShort: 'Generating',
success: 'Report ready',
failed: 'Failed to generate report, please try again',
missing: 'Report is not ready yet, please try again',
permission: 'Media permission is required to save the report',
saved: 'Saved to Photos',
saveFailed: 'Save failed, please try again',
save: 'Save',
saving: 'Saving…',
share: 'Share',
sharing: 'Sharing…',
shareFailed: 'Share failed, please try again',
shareTitle: 'AI Health Report',
shareMessage: 'Here is my AI health report—take a look!',
close: 'Close',
galleryTitle: 'AI Report Gallery',
gallerySubtitle: 'Browse and keep your immersive reports',
bannerDesc: 'Tap generate on the top right, takes about 1030s',
loadFailed: 'Failed to load report history',
emptyHistory: 'No reports yet',
emptyHistoryHint: 'Tap the top right to generate your first report',
generated: 'generated',
},
sections: {
bodyMetrics: 'Body Metrics',
},
components: {
diet: {
title: 'Diet Analysis',
loading: 'Loading...',
updated: 'Updated: {{time}}',
remaining: 'Can Still Eat',
calories: 'Calories',
protein: 'Protein',
carb: 'Carbs',
fat: 'Fat',
fiber: 'Fiber',
sodium: 'Sodium',
basal: 'Basal',
exercise: 'Exercise',
diet: 'Diet',
kcal: 'kcal',
aiRecognition: 'AI Scan',
foodLibrary: 'Food Library',
voiceRecord: 'Voice Log',
nutritionLabel: 'Nutrition Label',
},
fitness: {
kcal: 'kcal',
minutes: 'min',
hours: 'hrs',
},
steps: {
title: 'Steps',
},
mood: {
title: 'Mood',
empty: 'Tap to record mood',
},
stress: {
title: 'Stress',
unit: 'ms',
},
water: {
title: 'Water',
unit: 'ml',
addButton: '+ {{amount}}ml',
},
metabolism: {
title: 'Metabolism',
loading: 'Loading...',
unit: 'kcal/day',
status: {
high: 'High',
normal: 'Normal',
low: 'Low',
veryLow: 'Very Low',
unknown: 'Unknown',
},
},
sleep: {
title: 'Sleep',
loading: 'Loading...',
},
oxygen: {
title: 'Blood Oxygen',
},
circumference: {
title: 'Circumference (cm)',
setTitle: 'Set {{label}}',
confirm: 'Confirm',
measurements: {
chest: 'Chest',
waist: 'Waist',
hip: 'Hip',
arm: 'Arm',
thigh: 'Thigh',
calf: 'Calf',
},
},
workout: {
title: 'Recent Workout',
minutes: 'min',
kcal: 'kcal',
noData: 'No workout data',
syncing: 'Syncing...',
sourceWaiting: 'Source: Syncing...',
sourceUnknown: 'Source: Unknown',
sourceFormat: 'Source: {{source}}',
sourceFormatMultiple: 'Source: {{source}} et al.',
lastWorkout: 'Latest Workout',
updated: 'Updated',
},
weight: {
title: 'Weight Records',
addButton: 'Record Weight',
bmi: 'BMI',
weight: 'Weight',
days: 'days',
range: 'Range',
unit: 'kg',
bmiModal: {
title: 'BMI Index Explanation',
description: 'BMI (Body Mass Index) is an internationally recognized health indicator for assessing weight relative to height',
formula: 'Formula: weight(kg) ÷ height²(m)',
classificationTitle: 'BMI Classification Standards',
healthTipsTitle: 'Health Tips',
tips: {
nutrition: 'Maintain a balanced diet and control calorie intake',
exercise: 'At least 150 minutes of moderate-intensity exercise per week',
sleep: 'Ensure 7-9 hours of adequate sleep',
monitoring: 'Regularly monitor weight changes and adjust promptly',
},
disclaimer: 'BMI is for reference only and cannot reflect muscle mass, bone density, etc. If you have health concerns, please consult a professional doctor.',
continueButton: 'Continue',
},
},
fitnessRings: {
title: 'Fitness Rings',
activeCalories: 'Active Calories',
exerciseMinutes: 'Exercise Minutes',
standHours: 'Stand Hours',
goal: '/{{goal}}',
ringLabels: {
active: 'Active',
exercise: 'Exercise',
stand: 'Stand',
},
},
},
tabs: {
health: 'Health',
medications: 'Meds',
fasting: 'Fasting',
challenges: 'Challenges',
personal: 'Me',
},
activityHeatMap: {
subtitle: 'Active {{days}} days in the last 6 months',
activeRate: '{{rate}}%',
popover: {
title: 'Accumulated energy can be redeemed for AI-related benefits',
subtitle: 'How to earn',
rules: {
login: '1. Daily login earns energy +1',
mood: '2. Daily mood record earns energy +1',
diet: '3. Diet record earns energy +1',
goal: '4. Complete a goal earns energy +1',
},
},
months: {
1: 'Jan',
2: 'Feb',
3: 'Mar',
4: 'Apr',
5: 'May',
6: 'Jun',
7: 'Jul',
8: 'Aug',
9: 'Sep',
10: 'Oct',
11: 'Nov',
12: 'Dec',
},
legend: {
less: 'Less',
more: 'More',
},
},
};
export const sleepDetail = {
title: 'Sleep Details',
loading: 'Loading sleep data...',
today: 'Today',
sleepScore: 'Sleep Score',
noData: 'No sleep data available',
noDataRecommendation: 'Please ensure you are running on a real iOS device with authorized health data access, or wait until you have sleep data to view.',
sleepDuration: 'Sleep Duration',
sleepQuality: 'Sleep Quality',
sleepStages: 'Sleep Stages',
learnMore: 'Learn More',
awake: 'Awake',
rem: 'REM',
core: 'Core Sleep',
deep: 'Deep Sleep',
unknown: 'Unknown',
rawData: 'Raw Data',
rawDataDescription: 'Contains {{count}} HealthKit sleep sample records',
infoModalTitles: {
sleepTime: 'Sleep Time',
sleepQuality: 'Sleep Quality',
},
sleepGrades: {
low: 'Low',
normal: 'Normal',
good: 'Good',
excellent: 'Excellent',
poor: 'Poor',
fair: 'Fair',
},
sleepTimeDescription: 'Sleep is most important - it accounts for more than half of your sleep score. Longer sleep can reduce sleep debt, but regular sleep times are crucial for quality rest.',
sleepQualityDescription: 'Sleep quality comprehensively evaluates multiple indicators such as your sleep efficiency, deep sleep duration, REM sleep ratio, etc. High-quality sleep depends not only on duration but also on sleep continuity and balance of sleep stages.',
sleepStagesInfo: {
title: 'Understand Your Sleep Stages',
description: 'People have many misconceptions about sleep stages and sleep quality. Some people may need more deep sleep, while others may not. Scientists and doctors are still exploring the role of different sleep stages and their effects on the body. By tracking sleep stages and paying attention to how you feel each morning, you may gain deeper insights into your own sleep.',
awake: {
title: 'Awake Time',
description: 'During a sleep period, you may wake up several times. Occasional waking is normal. You may fall back asleep immediately and not remember waking up during the night.',
},
rem: {
title: 'REM Sleep',
description: 'This sleep stage may have some impact on learning and memory. During this stage, your muscles are most relaxed and your eyes move rapidly left and right. This is also the stage where most of your dreams occur.',
},
core: {
title: 'Core Sleep',
description: 'This stage is sometimes called light sleep and is as important as other stages. This stage usually occupies most of your sleep time each night. Brain waves that are crucial for cognition are generated during this stage.',
},
deep: {
title: 'Deep Sleep',
description: 'Due to the characteristics of brain waves, this stage is also called slow-wave sleep. During this stage, body tissues are repaired and important hormones are released. It usually occurs in the first half of sleep and lasts longer. During deep sleep, the body is very relaxed, so you may find it harder to wake up during this stage compared to other stages.',
},
},
};
export const sleepQuality = {
excellent: {
description: 'You feel refreshed and energized',
recommendation: 'Congratulations on getting quality sleep! If you feel energized, consider moderate exercise to maintain a healthy lifestyle and further reduce stress for optimal sleep.'
},
good: {
description: 'Good sleep quality, decent mental state',
recommendation: 'Your sleep quality is decent but has room for improvement.建议 maintaining regular sleep schedules, avoiding electronic devices before bed, and creating a quiet, comfortable sleep environment.'
},
fair: {
description: 'Fair sleep quality, may affect daytime performance',
recommendation: 'Your sleep needs improvement.建议 establishing a fixed bedtime routine, limiting caffeine intake, ensuring appropriate bedroom temperature, and considering light exercise to improve sleep quality.'
},
poor: {
description: 'Poor sleep quality, attention to sleep health recommended',
recommendation: 'Your sleep quality needs serious attention.建议 consulting a doctor or sleep specialist to check for sleep disorders, while improving sleep environment and habits, avoiding stimulating activities before bed.'
}
};
export const stepsDetail = {
title: 'Steps Details',
loading: 'Loading...',
stats: {
totalSteps: 'Total Steps',
averagePerHour: 'Average Per Hour',
mostActiveTime: 'Most Active Time',
},
chart: {
title: 'Hourly Steps Distribution',
averageLabel: 'Average {{steps}} steps',
},
activityLevel: {
currentActivity: 'Your activity level today is',
levels: {
inactive: 'Inactive',
light: 'Lightly Active',
moderate: 'Moderately Active',
very_active: 'Very Active',
},
progress: {
current: 'Current',
nextLevel: 'Next: {{level}}',
highestLevel: 'Highest Level',
},
},
timeLabels: {
midnight: '0:00',
noon: '12:00',
nextDay: '24:00',
},
};
export const fitnessRingsDetail = {
title: 'Fitness Rings Detail',
loading: 'Loading...',
weekDays: {
monday: 'Mon',
tuesday: 'Tue',
wednesday: 'Wed',
thursday: 'Thu',
friday: 'Fri',
saturday: 'Sat',
sunday: 'Sun',
},
dateFormats: {
header: 'MMM D, YYYY',
},
cards: {
activeCalories: {
title: 'Active Calories',
unit: 'kcal',
},
exerciseMinutes: {
title: 'Exercise Minutes',
unit: 'minutes',
info: {
title: 'Exercise Minutes:',
description: 'Exercise at an intensity of at least "brisk walking" will accumulate corresponding exercise minutes.',
recommendation: 'WHO recommends adults to maintain at least 30 minutes of moderate to high-intensity exercise daily.',
knowButton: 'Got it',
},
},
standHours: {
title: 'Stand Hours',
unit: 'hours',
},
},
stats: {
weeklyClosedRings: 'Weekly Closed Rings',
daysUnit: 'days',
},
datePicker: {
cancel: 'Cancel',
confirm: 'Confirm',
},
errors: {
loadExerciseInfoPreference: 'Failed to load exercise minutes info preference',
saveExerciseInfoPreference: 'Failed to save exercise minutes info preference',
},
};
export const circumferenceDetail = {
title: 'Circumference Statistics',
loading: 'Loading...',
error: 'Loading failed',
retry: 'Retry',
noData: 'No data available',
noDataSelected: 'Please select circumference data to display',
tabs: {
week: 'By Week',
month: 'By Month',
year: 'By Year',
},
measurements: {
chest: 'Chest',
waist: 'Waist',
upperHip: 'Upper Hip',
arm: 'Arm',
thigh: 'Thigh',
calf: 'Calf',
},
modal: {
title: 'Set {{label}}',
defaultTitle: 'Set Circumference',
confirm: 'Confirm',
},
chart: {
weekLabel: 'Week {{week}}',
monthLabel: '{{month}}',
empty: 'No data available',
noSelection: 'Please select circumference data to display',
},
};
export const basalMetabolismDetail = {
title: 'Metabolism',
currentData: {
title: '{{date}} Basal Metabolism',
unit: 'kcal',
normalRange: 'Normal range: {{min}}-{{max}} kcal',
noData: '--',
},
stats: {
title: 'Basal Metabolism Statistics',
tabs: {
week: 'By Week',
month: 'By Month',
},
},
chart: {
loading: 'Loading...',
loadingText: 'Loading...',
error: {
text: 'Loading failed: {{error}}',
retry: 'Retry',
fetchFailed: 'Failed to fetch data',
},
empty: 'No data available',
yAxisSuffix: 'kcal',
weekLabel: 'Week {{week}}',
},
modal: {
title: 'Basal Metabolism',
closeButton: '×',
description: 'Basal metabolism, also known as Basal Metabolic Rate (BMR), refers to the minimum energy consumption required for the human body to maintain basic life functions (heartbeat, breathing, body temperature regulation, etc.) in a completely resting state, usually measured in calories.',
sections: {
importance: {
title: 'Why is it important?',
content: 'Basal metabolism accounts for 60-75% of total energy consumption and is the foundation of energy balance. Understanding your basal metabolism helps develop scientific nutrition plans, optimize weight management strategies, and assess metabolic health status.',
},
normalRange: {
title: 'Normal Range',
formulas: {
male: 'Male: BMR = 10 × weight(kg) + 6.25 × height(cm) - 5 × age + 5',
female: 'Female: BMR = 10 × weight(kg) + 6.25 × height(cm) - 5 × age - 161',
},
userRange: 'Your normal range: {{min}}-{{max}} kcal/day',
rangeNote: '(Within 15% above or below the calculated value is considered normal)',
userInfo: 'Based on your information: {{gender}}, {{age}} years old, {{height}}cm, {{weight}}kg',
incompleteInfo: 'Please complete basic information to calculate your metabolic rate',
},
strategies: {
title: 'Strategies to Boost Metabolism',
subtitle: 'Scientific research supports the following methods:',
items: [
'1. Increase muscle mass (2-3 strength training sessions per week)',
'2. High-intensity interval training (HIIT)',
'3. Adequate protein intake (1.6-2.2g per kg of body weight)',
'4. Ensure adequate sleep (7-9 hours per night)',
'5. Avoid excessive calorie restriction (not less than 80% of BMR)',
],
},
},
},
gender: {
male: 'Male',
female: 'Female',
},
comments: {
reloadData: 'Reload data',
},
};
export const workoutTypes = {
americanfootball: 'American Football',
archery: 'Archery',
australianfootball: 'Australian Football',
badminton: 'Badminton',
baseball: 'Baseball',
basketball: 'Basketball',
bowling: 'Bowling',
boxing: 'Boxing',
climbing: 'Climbing',
cricket: 'Cricket',
crosstraining: 'Cross Training',
curling: 'Curling',
cycling: 'Cycling',
dance: 'Dance',
danceinspiredtraining: 'Dance Inspired Training',
elliptical: 'Elliptical',
equestriansports: 'Equestrian Sports',
fencing: 'Fencing',
fishing: 'Fishing',
functionalstrengthtraining: 'Functional Strength Training',
golf: 'Golf',
gymnastics: 'Gymnastics',
handball: 'Handball',
hiking: 'Hiking',
hockey: 'Hockey',
hunting: 'Hunting',
lacrosse: 'Lacrosse',
martialarts: 'Martial Arts',
mindandbody: 'Mind and Body',
mixedmetaboliccardiotraining: 'Mixed Metabolic Cardio Training',
paddlesports: 'Paddle Sports',
play: 'Play',
preparationandrecovery: 'Preparation & Recovery',
racquetball: 'Racquetball',
rowing: 'Rowing',
rugby: 'Rugby',
running: 'Running',
sailing: 'Sailing',
skatingsports: 'Skating Sports',
snowsports: 'Snow Sports',
soccer: 'Soccer',
softball: 'Softball',
squash: 'Squash',
stairclimbing: 'Stair Climbing',
surfingsports: 'Surfing Sports',
swimming: 'Swimming',
tabletennis: 'Table Tennis',
tennis: 'Tennis',
trackandfield: 'Track and Field',
traditionalstrengthtraining: 'Traditional Strength Training',
volleyball: 'Volleyball',
walking: 'Walking',
waterfitness: 'Water Fitness',
waterpolo: 'Water Polo',
watersports: 'Water Sports',
wrestling: 'Wrestling',
yoga: 'Yoga',
barre: 'Barre',
coretraining: 'Core Training',
crosscountryskiing: 'Cross-Country Skiing',
downhillskiing: 'Downhill Skiing',
flexibility: 'Flexibility',
highintensityintervaltraining: 'High-Intensity Interval Training',
jumprope: 'Jump Rope',
kickboxing: 'Kickboxing',
pilates: 'Pilates',
snowboarding: 'Snowboarding',
stairs: 'Stairs',
steptraining: 'Step Training',
wheelchairwalkpace: 'Wheelchair Walk Pace',
wheelchairrunpace: 'Wheelchair Run Pace',
taichi: 'Tai Chi',
mixedcardio: 'Mixed Cardio',
handcycling: 'Hand Cycling',
discsports: 'Disc Sports',
fitnessgaming: 'Fitness Gaming',
cardiodance: 'Cardio Dance',
socialdance: 'Social Dance',
pickleball: 'Pickleball',
cooldown: 'Cooldown',
swimbikerun: 'Swim Bike Run',
transition: 'Transition',
underwaterdiving: 'Underwater Diving',
other: 'Other',
};
export const workoutDetail = {
loading: 'Loading workout details...',
retry: 'Retry',
errors: {
loadFailed: 'Failed to load workout details',
noHeartRateData: 'No heart rate data available',
noZoneStats: 'No heart rate zone data',
},
metrics: {
duration: 'Duration',
calories: 'Calories',
caloriesUnit: 'kcal',
intensity: 'Intensity',
averageHeartRate: 'Average Heart Rate',
heartRateUnit: 'bpm',
},
sections: {
heartRateRange: 'Heart Rate Range',
averageHeartRate: 'Average',
maximumHeartRate: 'Maximum',
minimumHeartRate: 'Minimum',
heartRateUnit: 'bpm',
heartRateZones: 'Heart Rate Zones',
},
chart: {
unavailable: 'Chart unavailable',
noData: 'No heart rate chart data yet',
},
intensityInfo: {
title: 'About workout intensity (METs)',
description1: 'METs (metabolic equivalent) reflect energy cost; resting equals 1 MET.',
description2: '3-6 METs is moderate intensity, above 6 METs is high intensity.',
description3: 'Higher values mean more energy burned per minute—adjust to your fitness level.',
description4: 'Warm up and cool down before and after sustained high-intensity sessions.',
formula: {
title: 'Formula',
value: 'METs = Exercise VO₂ ÷ Resting VO₂',
},
legend: {
low: '2-3 METs',
lowLabel: 'Low intensity',
medium: '3-6 METs',
mediumLabel: 'Moderate',
high: '>6 METs',
highLabel: 'High intensity',
},
},
zones: {
summary: '{{minutes}} min · {{range}}',
labels: {
warmup: 'Warm-up',
fatburn: 'Fat burn',
aerobic: 'Aerobic',
anaerobic: 'Anaerobic',
max: 'Max effort',
},
ranges: {
warmup: '<100 bpm',
fatburn: '100-119 bpm',
aerobic: '120-149 bpm',
anaerobic: '150-169 bpm',
max: '≥170 bpm',
},
},
};
export const workoutHistory = {
title: 'Workout Summary',
loading: 'Loading workout records...',
error: {
permissionDenied: 'Health data permission not granted',
loadFailed: 'Failed to load workout records, please try again later',
detailLoadFailed: 'Failed to load workout details, please try again later',
},
retry: 'Retry',
monthlyStats: {
title: 'Workout Time',
periodText: 'Statistics period: 1st - {{day}} (This month)',
overviewWithStats: 'As of {{date}}, you have completed {{count}} workouts, totaling {{duration}}.',
overviewEmpty: 'No workout records this month yet, start moving to collect your first one!',
emptyData: 'No workout data this month',
},
intensity: {
low: 'Low Intensity',
medium: 'Medium Intensity',
high: 'High Intensity',
},
historyCard: {
calories: '{{calories}} kcal · {{minutes}} min',
activityTime: '{{activity}}, {{time}}',
},
empty: {
title: 'No Workout Records',
subtitle: 'Complete a workout to view detailed history here',
},
monthOccurrence: 'This is your {{index}} {{activity}} in {{month}}.',
};

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