265 Commits

Author SHA1 Message Date
richarjiang
b36922756d fix(medication): 统一处理名字编辑弹窗和备注弹窗的键盘监听逻辑 2025-11-19 16:24:30 +08:00
richarjiang
da09df1e9d feat(medications): 优化药物显示逻辑,未服用药品优先排序并更新计数逻辑 2025-11-19 16:12:52 +08:00
richarjiang
ee60f0756e feat(medication): 添加药物名称编辑和图片上传功能 2025-11-19 16:08:52 +08:00
richarjiang
6039d0a778 feat(healthkit): 实现HealthKit与服务端的双向数据同步,包括身高、体重和出生日期的获取与保存 2025-11-19 15:42:50 +08:00
richarjiang
dc205ad56e feat(nutrition): 添加营养数据保存功能到HealthKit,包括蛋白质、脂肪和碳水化合物 2025-11-19 14:27:49 +08:00
richarjiang
f43cfe7ac6 fix(ios): 修复HealthKit类型安全性并优化HRV通知频率
将HealthKit数据类型从强制解包改为可选类型,避免潜在的运行时崩溃。所有数据类型访问现在都通过guard语句进行安全检查,当类型不可用时返回明确的错误信息。同时修复了活动摘要日期计算错误,确保每个摘要使用正确的日期。

HRV压力通知的最小间隔从4小时缩短至2小时,并移除了每日一次的限制,允许更及时的压力状态提醒。

BREAKING CHANGE: HealthKit数据类型API现在可能返回"TYPE_NOT_AVAILABLE"错误,调用方需要处理此新错误类型
2025-11-19 09:23:42 +08:00
richarjiang
9d424c7bd2 feat(auth): 添加401未授权统一处理机制
- 在API服务层实现401状态码的统一拦截和处理
- 添加防抖机制,避免短时间内重复处理401错误
- 支持应用层注册自定义的未授权处理器
- 在应用启动时注册401处理器,自动清除登录状态并跳转到登录页
- 同时处理普通请求和流式请求的401响应
2025-11-18 15:59:47 +08:00
richarjiang
21e57634e0 feat(hrv): 添加心率变异性监控和压力评估功能
- 新增 HRV 监听服务,实时监控心率变异性数据
- 实现 HRV 到压力指数的转换算法和压力等级评估
- 添加智能通知服务,在压力偏高时推送健康建议
- 优化日志系统,修复日志丢失问题并增强刷新机制
- 改进个人页面下拉刷新,支持并行数据加载
- 优化勋章数据缓存策略,减少不必要的网络请求
- 重构应用初始化流程,优化权限服务和健康监听服务的启动顺序
- 移除冗余日志输出,提升应用性能
2025-11-18 14:08:20 +08:00
3f21f521ea feat(initialize): 优化权限和健康监听服务的初始化流程,增加延迟和错误处理
fix(version): 更新应用版本号至1.0.26
feat(sleep): 增加睡眠阶段统计的准确性,优化日志输出
2025-11-17 21:24:59 +08:00
3a312d396e fix(medications): 降低相机图片质量以优化性能
fix(ios): 降低项目对象版本以兼容性调整
chore(ios): 更新 Info.plist 版本号
chore(ios): 更新 Podfile.lock 以反映依赖项变更
2025-11-15 23:27:36 +08:00
richarjiang
705d921c14 feat(badges): 添加勋章系统和展示功能
实现完整的勋章系统,包括勋章列表展示、自动弹窗展示和分享功能。

- 新增勋章列表页面,支持已获得和待解锁勋章的分类展示
- 在个人中心添加勋章预览模块,显示前3个勋章和总数统计
- 实现勋章展示弹窗,支持动画效果和玻璃态UI
- 添加勋章分享功能,可生成分享卡片
- 新增 badgesSlice 管理勋章状态,包括获取、排序和计数逻辑
- 添加勋章服务 API 封装,支持获取勋章列表和标记已展示
- 完善中英文国际化文案
2025-11-14 17:17:17 +08:00
richarjiang
8cffbb990a refactor(init): 优化应用初始化流程,将权限请求延迟到引导完成后
- 将服务初始化拆分为基础服务和权限相关服务两个阶段
- 基础服务(用户数据、HealthKit初始化、快捷动作等)在应用启动时立即执行
- 权限相关服务(通知、HealthKit权限请求)仅在用户完成引导流程后才执行
- 在Redux store中添加onboardingCompleted状态管理
- 引导页面完成时通过Redux更新状态而非直接操作AsyncStorage
- 启动页面从预加载数据中读取引导完成状态,避免重复读取存储
- 使用ref防止权限服务重复初始化
2025-11-14 14:10:52 +08:00
richarjiang
7bd0b5fc52 # 方案总结
基于提供的 Git diff,我将生成以下 conventional commit message:

## 变更分析:

1. **核心功能**:
   - 新增睡眠监控服务(`services/sleepMonitor.ts`)
   - 新增睡眠通知服务(`services/sleepNotificationService.ts`)
   - iOS 原生端增加睡眠观察者方法

2. **应用启动优化**:
   - 重构 `app/_layout.tsx` 中的初始化流程,按优先级分阶段加载服务

3. **药品功能改进**:
   - 优化语音识别交互(实时预览、可取消)
   - Widget 增加 URL scheme 支持

4. **路由配置**:
   - 新增药品管理路由常量

## 提交信息类型:
- **主类型**:`feat` (新增睡眠监控功能)
- **作用域**:`health` (健康相关功能)

---

请确认方案后,我将生成最终的 commit message。

---

**最终 Commit Message:**

feat(health): 添加睡眠监控和通知服务,优化应用启动流程

- 新增睡眠监控服务,支持实时监听 HealthKit 睡眠数据更新
- 实现睡眠质量分析算法,计算睡眠评分和各阶段占比
- 新增睡眠通知服务,分析完成后自动推送质量评估和建议
- iOS 原生端实现睡眠数据观察者,支持后台数据传递
- 重构应用启动初始化流程,按优先级分阶段加载服务(关键/次要/后台/空闲)
- 优化药品录入页面语音识别交互,支持实时预览和取消操作
- 药品 Widget 增加 deeplink 支持,点击跳转到应用
- 新增药品管理路由常量配置
2025-11-14 10:52:26 +08:00
richarjiang
6c2f9295be feat(ui): 添加挑战详情分享卡片功能和玻璃态按钮
- 实现挑战分享卡片截图生成,根据参与状态展示不同内容
- 已参与用户显示个人进度条和完成情况
- 未参与用户显示挑战详细信息和参与邀请
- 使用 GlassView 组件优化分享按钮和编辑按钮的视觉效果
- 添加 react-native-view-shot 支持视图截图
- 移除硬编码背景色,统一使用玻璃态交互效果
2025-11-14 10:02:44 +08:00
richarjiang
6ad77bc0e2 feat(medical): 添加医疗免责声明和参考文献功能
- 在用药模块首次添加时显示医疗免责声明弹窗
- 新增断食参考文献页面,展示权威医学机构来源
- 在个人中心添加WHO医学来源入口
- 使用本地存储记录用户已读免责声明状态
- 支持Liquid Glass毛玻璃效果和降级方案
- 新增中英文国际化翻译支持
2025-11-14 09:14:12 +08:00
richarjiang
b0e93eedae feat(ios): 添加用药计划Widget小组件支持
- 创建medicineExtension小组件,支持iOS桌面显示用药计划
- 实现App Group数据共享机制,支持主应用与小组件数据同步
- 添加AppGroupUserDefaultsManager原生模块,提供跨应用数据访问能力
- 添加WidgetManager和WidgetCenterHelper,实现小组件刷新控制
- 在medications页面和Redux store中集成小组件数据同步逻辑
- 支持实时同步今日用药状态(待服用/已服用/已错过)到小组件
- 配置App Group entitlements (group.com.anonymous.digitalpilates)
- 更新Xcode项目配置,添加WidgetKit和SwiftUI框架支持
2025-11-14 08:51:02 +08:00
richarjiang
d282abd146 feat(background): 增强iOS后台任务系统,添加processing任务类型支持
- 添加新的processing任务标识符到iOS配置文件
- 重构BackgroundTaskBridge支持不同任务类型(refresh/processing)
- 增强后台任务日志记录和调试信息
- 修复任务类型配置不匹配问题
- 改进任务调度逻辑和错误处理机制
- 添加任务执行时间戳记录用于调试
- 移除notification-settings中未使用的AuthGuard依赖
2025-11-13 17:12:57 +08:00
richarjiang
2dca3253e6 feat(i18n): 实现应用国际化支持,添加中英文翻译
- 为所有UI组件添加国际化支持,替换硬编码文本
- 新增useI18n钩子函数统一管理翻译
- 完善中英文翻译资源,覆盖统计、用药、通知设置等模块
- 优化Tab布局使用翻译键值替代静态文本
- 更新药品管理、个人资料编辑等页面的多语言支持
2025-11-13 11:09:55 +08:00
richarjiang
416d144387 feat(i18n): 添加国际化支持和中英文切换功能
- 实现完整的中英文国际化系统,支持动态语言切换
- 新增健康数据权限说明页面,提供HealthKit数据使用说明
- 为服药记录添加庆祝动画效果,提升用户体验
- 优化药品添加页面的阴影效果和视觉层次
- 更新个人页面以支持多语言显示和语言选择模态框
2025-11-13 09:05:23 +08:00
richarjiang
7c8538f5c6 feat(medications): 添加AI用药分析功能
- 集成流式AI分析接口,支持实时展示分析结果
- 添加VIP权限校验和会员弹窗引导
- 使用Markdown渲染AI分析内容
- 优化底部按钮布局,AI分析按钮占2/3宽度
- 支持请求取消和错误处理
- 自动滚动到分析结果区域
- InfoCard组件优化,图标、标签和箭头排列在同一行
2025-11-12 17:07:42 +08:00
richarjiang
0bea454dca feat(fasting): 添加周期性断食计划功能
实现完整的周期性断食计划系统,支持每日自动续订和通知管理:

- 新增周期性断食状态管理(activeCycle、currentCycleSession、cycleHistory)
- 实现周期性断食会话的自动完成和续订逻辑
- 添加独立的周期性断食通知系统,避免与单次断食通知冲突
- 支持暂停/恢复周期性断食计划
- 添加周期性断食数据持久化和水合功能
- 优化断食界面,优先显示周期性断食信息
- 新增空状态引导界面,提升用户体验
- 保持单次断食功能向后兼容
2025-11-12 15:36:35 +08:00
richarjiang
8687be10e8 refactor(ui): 优化药品添加页面的图片选择和显示效果 2025-11-12 10:54:51 +08:00
richarjiang
be55c6f43e feat(medications): 添加药品停用确认弹窗
在药品详情页和管理页面添加停用药品的确认弹窗,防止用户误操作。
当用户尝试停用药品时,会显示确认对话框提醒用户停用后当天已生成的用药计划会被删除且无法恢复。
2025-11-12 10:49:39 +08:00
richarjiang
35f06951a0 feat(medications): 添加药品结束日期选择功能
- 新增药品结束日期选择器,支持设置服药周期
- 优化日期显示格式,从"开始日期"改为"服药周期"
- 添加日期验证逻辑,确保开始日期不早于今天且结束日期不早于开始日期
- 改进添加药品页面的日期选择UI,采用并排布局
- 调整InfoCard组件样式,移除图标背景色并减小字体大小
2025-11-12 10:27:20 +08:00
richarjiang
e412f80295 perf: 更换 logo 2025-11-12 09:43:22 +08:00
richarjiang
4f946a0566 refactor(ui): 简化卡片组件视觉样式并扩展引导流程
统一移除 InfoCard 和药品详情页面的阴影与边框装饰,实现更简洁的界面风格
新增用药管理引导页面,完善用户首次使用体验
添加药品管理相关插图资源,增强视觉引导效果
2025-11-11 19:01:30 +08:00
richarjiang
2ed3562a00 feat(ui): 优化药品管理页面的视觉设计和背景效果
- 调整药品列表页面的渐变背景颜色,移除最后一个颜色值
- 修复药品详情页面的样式数组格式问题
- 为添加药品页面添加渐变背景和装饰性圆圈元素
- 优化表单按钮的间距和样式,提升视觉层次感
- 统一背景颜色处理,增强页面一致性
2025-11-11 17:57:36 +08:00
richarjiang
81a6e43d7c feat(ui): 为药品管理页面添加渐变背景和装饰性元素
- 在药品详情页和管理页面添加线性渐变背景
- 增加装饰性圆圈元素提升视觉效果
- 为添加按钮应用玻璃效果(当可用时)
- 简化InfoCard组件,移除玻璃效果逻辑
- 统一页面视觉风格,提升用户体验
2025-11-11 17:39:52 +08:00
richarjiang
f4ce3d9edf feat(medications): 重构药品通知系统并添加独立设置页面
- 创建药品通知服务模块,统一管理药品提醒通知的调度和取消
- 新增独立的通知设置页面,支持总开关和药品提醒开关分离控制
- 重构药品详情页面,移除频率编辑功能到独立页面
- 优化药品添加流程,支持拍照和相册选择图片
- 改进通知权限检查和错误处理机制
- 更新用户偏好设置,添加药品提醒开关配置
2025-11-11 16:43:27 +08:00
richarjiang
d9975813cb feat(medications): 添加药品图片预览功能并优化InfoCard组件
- 在药品详情页面集成 react-native-image-viewing 实现图片全屏预览
- 添加图片预览提示图标,提升用户交互体验
- 优化 InfoCard 组件渲染逻辑,简化代码结构
- 调整药品图片样式,增加圆角效果并优化尺寸比例
- 为可点击的 InfoCard 图标和箭头添加玻璃态效果支持
2025-11-11 14:40:26 +08:00
richarjiang
7ea558847d feat(medications): 增强药品详情页面的编辑功能
- 添加剂量、剂型和服药频率的交互式选择器
- 实现提醒时间的动态编辑和添加功能
- 引入玻璃效果优化删除按钮的视觉体验
- 重构常量配置,提取药物相关常量到独立文件
- 创建可复用的InfoCard组件支持玻璃效果
2025-11-11 11:31:06 +08:00
richarjiang
50525f82a1 feat(medications): 优化药品管理功能和登录流程
- 更新默认药品图片为专用图标
- 移除未使用的 loading 状态选择器
- 优化 Apple 登录按钮样式,支持毛玻璃效果和加载状态
- 添加登录成功后返回功能(shouldBack 参数)
- 药品详情页添加信息卡片点击交互
- 添加药品添加页面的登录状态检查
- 增强时间选择器错误处理和数据验证
- 修复药品图片显示逻辑,支持网络图片
- 优化药品卡片样式和布局
- 添加图片加载错误处理
2025-11-11 10:02:37 +08:00
richarjiang
0594831c9f feat(medications): 添加药品详情页面和删除功能
新增药品详情页面,支持查看药品信息、编辑备注、切换提醒状态和删除药品
- 创建动态路由页面 /medications/[medicationId].tsx 展示药品详细信息
- 添加语音输入备注功能,支持 iOS 语音识别
- 实现药品删除确认对话框和删除操作
- 优化药品卡片点击跳转详情页面的交互
- 添加删除操作的加载状态和错误处理
- 改进药品管理页面的开关状态显示和加载指示器
2025-11-10 14:46:13 +08:00
richarjiang
25b8e45af8 feat(medications): 实现完整的用药管理功能
添加了药物管理的核心功能,包括:
- 药物列表展示和状态管理
- 添加新药物的完整流程
- 服药记录的创建和状态更新
- 药物管理界面,支持激活/停用操作
- Redux状态管理和API服务层
- 相关类型定义和辅助函数

主要文件:
- app/(tabs)/medications.tsx - 主界面,集成Redux数据
- app/medications/add-medication.tsx - 添加药物流程
- app/medications/manage-medications.tsx - 药物管理界面
- store/medicationsSlice.ts - Redux状态管理
- services/medications.ts - API服务层
- types/medication.ts - 类型定义
2025-11-10 10:02:53 +08:00
richarjiang
3aafc50702 feat(medications): 添加用药管理功能
- 新增用药标签页,包含完整的用药记录界面
- 实现用药卡片组件,支持状态显示(已服用/未服用/已错过)
- 增强日期选择器,添加"回到今天"快捷功能
- 添加用药相关的图标支持(pills.fill, plus)
- 集成用药路由配置,支持标签页导航

该功能为用户提供完整的用药管理体验,包括用药记录、状态跟踪和日期筛选等核心功能。
2025-11-06 17:51:06 +08:00
richarjiang
a228280ca4 feat(onboarding): 添加新用户引导流程
实现了完整的应用引导功能,包括:
- 新增引导页面UI,包含健康数据追踪、轻断食计划和健康挑战三个介绍页面
- 添加引导状态持久化存储,使用AsyncStorage管理用户完成状态
- 修改应用启动逻辑,根据引导状态决定跳转到主页或引导页
- 在开发者选项中添加重置引导状态功能,方便测试
- 更新路由配置和存储键常量,统一管理引导相关配置
2025-11-06 15:22:31 +08:00
richarjiang
9b1a40cea3 feat(background-task): 实现原生与JS层的任务同步机制
解决后台任务在JS监听器未就绪时丢失的问题。新增任务缓存队列,当检测到无JS监听器时将任务暂存,并启动20秒超时计时器等待JS初始化完成。JS层通过markJSReady接口通知原生层准备就绪,触发缓存任务的立即执行。超时后自动切换到默认处理逻辑,确保任务不丢失。
2025-11-06 09:20:52 +08:00
richarjiang
ea22901553 feat(background-task): 完善iOS后台任务系统并优化断食通知和UI体验
- 修复iOS后台任务注册时机问题,确保任务能正常触发
- 添加后台任务调试辅助工具和完整测试指南
- 优化断食通知系统,增加防抖机制避免频繁重调度
- 改进断食自动续订逻辑,使用固定时间而非相对时间计算
- 优化统计页面布局,添加身体指标section标题
- 增强饮水详情页面视觉效果,改进卡片样式和配色
- 添加用户反馈入口到个人设置页面
- 完善锻炼摘要卡片条件渲染逻辑
- 增强日志记录和错误处理机制

这些改进显著提升了应用的稳定性、性能和用户体验,特别是在iOS后台任务执行和断食功能方面。
2025-11-05 11:23:33 +08:00
richarjiang
d74046498d # 分析方案
## 变更内容总结
1. **iOS后台任务系统重构** - 修复后台任务无法自动运行的问题
2. **日志系统优化** - 改进日志记录机制,添加队列和批量写入
3. **文档新增** - 添加后台任务修复总结和测试指南文档
4. **应用启动优化** - 添加后台任务状态检查和恢复逻辑
5. **版本号更新** - Info.plist版本从1.0.23升级到1.0.24

## 提交信息类型判断
- **主要类型**: `fix` - 这是一个重要的bug修复,解决了iOS后台任务无法自动运行的核心问题
- **作用域**: `ios-background` - 专注于iOS后台任务功能
- **影响**: 这个修复对iOS用户的后台功能至关重要

## 提交信息

fix(ios-background): 修复iOS后台任务无法自动运行的问题

主要修复内容:
- 修复BackgroundTaskBridge任务调度逻辑,改用BGAppRefreshTaskRequest
- 添加任务完成后自动重新调度机制,确保任务持续执行
- 优化应用生命周期管理,移除重复的后台任务调度
- 在应用启动时添加后台任务状态检查和恢复功能
- 将默认任务间隔从30分钟优化为15分钟

次要改进:
- 重构日志系统,添加内存队列和批量写入机制,提升性能
- 添加写入锁和重试机制,防止日志数据丢失
- 新增详细的修复总结文档和测试指南

技术细节:
- 使用BGAppRefreshTaskRequest替代BGProcessingTaskRequest
- 实现任务过期自动重新调度
- 添加任务执行状态监控和恢复逻辑
- 优化错误处理和日志输出

影响范围: iOS后台任务调度、通知推送、应用状态管理
2025-11-04 19:14:53 +08:00
richarjiang
f80a1bae78 feat(background-task): 实现iOS原生后台任务V2系统并重构锻炼通知消息模板
- 新增iOS原生BackgroundTaskBridge桥接模块,支持后台任务注册、调度和完成
- 重构BackgroundTaskManager为V2版本,集成原生iOS后台任务能力
- 在AppDelegate中注册后台任务处理器,确保应用启动时正确初始化
- 重构锻炼通知消息生成逻辑,使用配置化模板提升可维护性
- 扩展健康数据类型映射,支持更多运动项目的中文显示
- 替换原有backgroundTaskManager引用为backgroundTaskManagerV2
2025-11-04 09:41:10 +08:00
richarjiang
fbffa07f74 feat(push-notification): 添加用户登录后自动更新推送token绑定功能
- 新增updateTokenUserId方法用于更新设备令牌的用户ID绑定关系
- 添加onUserLogin方法在用户登录成功后自动更新token绑定
- 优化checkAndRegisterToken逻辑,确保每次应用启动都更新用户ID绑定
- 修改UpdateTokenRequest接口,将appVersion和osVersion设为可选参数
- 在用户登录成功后自动触发推送token用户ID绑定更新
2025-11-03 17:58:17 +08:00
richarjiang
635d835a50 feat(fasting): 完善断食通知系统并优化错误提示
在应用启动时添加断食通知初始化逻辑,改进错误消息提示,并新增后台任务支持断食通知同步。同时优化挑战加入后的数据刷新流程和会员卡片显示样式。

主要更改:
- 添加断食通知启动检测和初始化
- 改进断食通知错误消息,提供更详细的用户指导
- 新增断食通知后台任务处理
- 优化挑战加入后自动刷新详情和排名数据
- 调整会员价格字体大小以提升视觉效果
2025-11-03 14:13:49 +08:00
richarjiang
ce382794ba style(membership): 调整会员卡片价格字体大小 2025-11-03 11:17:06 +08:00
richarjiang
0265ecfac2 feat: 添加后台任务调试工具并优化水提醒任务逻辑 2025-11-03 10:55:30 +08:00
richarjiang
16c2351160 feat: 移除目标管理功能模块
删除了完整的目标管理功能,包括目标创建、编辑、任务管理等相关页面和组件。同时移除了相关的API服务、Redux状态管理、类型定义和通知功能。应用版本从1.0.20升级到1.0.21。
2025-10-31 08:49:22 +08:00
richarjiang
7cd290d341 feat(membership): 重构会员系统架构并优化VIP卡片显示
- 创建独立的会员服务模块 services/membership.ts,统一管理会员计划元数据和工具函数
- 新增 membershipSlice Redux状态管理,集中处理会员数据和状态
- 重构个人中心VIP会员卡片,支持动态显示会员计划和有效期
- 优化会员购买弹窗,使用统一的会员计划配置
- 改进会员数据获取流程,确保状态同步和一致性
2025-10-29 16:08:58 +08:00
richarjiang
fcf1be211f feat(vip): 实现VIP服务权限控制和食物识别功能限制
- 添加VIP服务权限检查hook,支持免费使用次数限制
- 为食物识别功能添加登录验证和VIP权限检查
- 优化RevenueCat用户标识同步逻辑
- 修复会员购买状态检查的类型安全问题
- 为营养成分分析添加登录验证
2025-10-29 09:44:30 +08:00
richarjiang
eaa7f7275c feat(ui): 调整个人中心和会员购买界面布局
- 将推送通知设置移至开发者选项section
- 移除会员购买按钮的协议接受状态限制
- 优化购买按钮的禁用条件逻辑
2025-10-28 11:16:32 +08:00
richarjiang
71a8bb9740 feat(toast): 实现原生Toast系统并优化会员购买错误处理
- 新增iOS原生Toast模块(NativeToastManager),提供毛玻璃风格的Toast展示
- 重构ToastContext为原生模块调用,添加错误边界和回退机制
- 优化会员购买流程的错误处理,使用RevenueCat标准错误码
- 调整购买按钮高度和恢复购买按钮字体大小,改善UI体验
- 移除不必要的延迟和注释代码,提升代码质量
2025-10-28 11:04:34 +08:00
richarjiang
db8b50f6d7 feat(membership): 重构会员权益对比表并优化购买界面布局
- 重构权益对比数据结构,支持独占、有限、无限三种权限类型
- 新增权限图标显示逻辑,区分VIP和普通用户权限状态
- 优化会员卡片布局,采用三段式布局提升视觉效果
- 实现悬浮购买按钮,支持Liquid Glass毛玻璃效果
- 增强购买流程验证,添加自动选择产品和详细错误处理
- 调整界面间距和样式,提升整体用户体验
2025-10-27 14:43:19 +08:00
richarjiang
82edb2593c feat(membership): 重构会员购买界面并添加图标库使用规范
- 将 MaterialIcons 替换为 Ionicons 以保持图标库一致性
- 重新设计会员购买界面,采用分段卡片布局和权益对比表格
- 添加 Liquid Glass 兼容的悬浮返回按钮
- 优化套餐卡片样式,使用渐变背景和标签展示
- 添加会员权益对比功能,清晰展示 VIP 与普通用户差异
- 更新任务文档,记录图标库使用规范和按钮组件 Liquid Glass 兼容性实现模式
2025-10-27 08:19:15 +08:00
richarjiang
2e11f694f8 feat(membership): 实现会员系统和购买流程
- 创建 MembershipModalContext 统一管理会员弹窗
- 优化 MembershipModal 产品套餐展示和购买流程
- 集成 RevenueCat SDK 并初始化内购功能
- 在个人中心添加会员 Banner,引导非会员用户订阅
- 修复日志工具的循环引用问题,确保错误信息正确记录
- 版本更新至 1.0.20

新增了完整的会员购买流程,包括套餐选择、购买确认、购买恢复等功能。会员 Banner 仅对非会员用户展示,已是会员的用户不会看到。同时优化了错误日志记录,避免循环引用导致的序列化失败。
2025-10-24 09:16:04 +08:00
richarjiang
b75a8991ac feat(auth): 添加登录验证到食物记录相关功能
- 在食物拍照、语音记录和营养成分分析功能中添加登录验证
- 使用 ensureLoggedIn 方法确保用户已登录后再调用服务端接口
- 使用 pushIfAuthedElseLogin 方法处理需要登录的页面导航
- 添加新的营养图标资源
- 在路由常量中添加 FOOD_CAMERA 路由定义
- 更新 Memory Bank 任务文档,记录登录验证和路由常量管理的实现模式
2025-10-16 17:45:52 +08:00
richarjiang
339c748a0f feat(profile): 在个人资料页面显示免费AI使用次数 2025-10-16 17:17:36 +08:00
richarjiang
c6084fe702 feat(nutrition): 添加营养分析历史记录删除和图片预览功能
- 新增删除营养分析记录功能,支持本地状态更新和API调用
- 添加图片全屏预览功能,支持缩放和手势操作
- 实现Liquid Glass风格的删除按钮,包含兼容性处理
- 优化历史记录页面布局和交互体验
- 更新Memory Bank文档,添加Liquid Glass按钮实现指南
2025-10-16 16:43:45 +08:00
richarjiang
e4ddd21305 feat(nutrition): 添加营养成分分析历史记录功能
- 新增历史记录页面,支持查看、筛选和分页加载营养成分分析记录
- 在分析页面添加历史记录入口,使用Liquid Glass效果
- 优化分析结果展示样式,采用卡片式布局和渐变效果
- 移除流式分析相关代码,简化分析流程
- 添加历史记录API接口和类型定义
2025-10-16 16:02:48 +08:00
richarjiang
b27099c6d9 feat(nutrition): 优化营养成分表分析功能并移除流式显示
- 移除流式分析文本显示,简化用户界面
- 修复ImagePicker媒体类型配置,使用数组格式
- 简化API响应处理逻辑,直接使用服务端返回数据
- 移除旧格式转换函数,统一使用新的API响应格式
- 清理冗余状态变量和UI组件,提升代码可维护性
2025-10-16 12:46:43 +08:00
richarjiang
5013464a2c feat(nutrition): 添加营养成分表拍照分析功能
新增营养成分表拍照识别功能,用户可通过拍摄食物包装上的成分表自动解析营养信息:
- 创建成分表分析页面,支持拍照/选择图片和结果展示
- 集成新的营养成分分析API,支持图片上传和流式分析
- 在营养雷达卡片中添加成分表分析入口
- 更新应用版本至1.0.19
2025-10-16 12:16:08 +08:00
richarjiang
bef7d645a8 feat(auth): 为登录页面添加Liquid Glass效果并更新文案
- 使用expo-glass-effect为返回按钮添加毛玻璃效果
- 添加兼容性处理,在不支持时使用fallback样式
- 更新副标题文案为"健康生活,自律让我更自由"
- 优化返回按钮尺寸和圆角样式
2025-10-15 19:22:38 +08:00
richarjiang
d39a32c0d8 feat(fasting): add auto-renewal and reset functionality for fasting plans
- Implement auto-renewal logic for completed fasting cycles using dayjs
- Add reset button with information modal in FastingOverviewCard
- Configure iOS push notifications for production environment
- Add expo-media-library and react-native-view-shot dependencies
- Update FastingScheduleOrigin type to include 'auto' origin
2025-10-15 19:06:18 +08:00
richarjiang
039138f7e4 refactor(push): 使用logger替换console日志输出
将推送通知服务中的console.log和console.error替换为统一的logger工具,
提高日志管理的一致性和可维护性
2025-10-15 10:07:41 +08:00
richarjiang
6cdd2fdf9c feat(push): 新增iOS APNs推送通知功能
- 添加推送通知管理器和设备令牌管理
- 实现推送通知权限请求和令牌注册
- 新增推送通知设置页面
- 集成推送通知初始化到应用启动流程
- 添加推送通知API服务和本地存储管理
- 更新个人页面添加推送通知设置入口
2025-10-14 19:25:35 +08:00
richarjiang
435f5cc65c feat: 适配 headerbar ios26 2025-10-14 16:31:19 +08:00
richarjiang
cf069f3537 feat(fasting): 重构断食通知系统并增强可靠性
- 新增 useFastingNotifications hook 统一管理通知状态和同步逻辑
- 实现四阶段通知提醒:开始前30分钟、开始时、结束前30分钟、结束时
- 添加通知验证机制,确保通知正确设置和避免重复
- 新增 NotificationErrorAlert 组件显示通知错误并提供重试选项
- 实现断食计划持久化存储,应用重启后自动恢复
- 添加开发者测试面板用于验证通知系统可靠性
- 优化通知同步策略,支持选择性更新减少不必要的操作
- 修复个人页面编辑按钮样式问题
- 更新应用版本号至 1.0.18
2025-10-14 15:05:11 +08:00
richarjiang
e03b2b3032 feat(fasting): 新增轻断食功能模块
新增完整的轻断食功能,包括:
- 断食计划列表和详情页面,支持12-12、14-10、16-8、18-6四种计划
- 断食状态实时追踪和倒计时显示
- 自定义开始时间选择器
- 断食通知提醒功能
- Redux状态管理和数据持久化
- 新增tab导航入口和路由配置
2025-10-13 19:21:29 +08:00
richarjiang
971aebd560 feat(workout): 新增锻炼结束监听和个性化通知功能
实现了iOS HealthKit锻炼数据实时监听,当用户完成锻炼时自动发送个性化鼓励通知。包括锻炼类型筛选、时间范围控制、用户偏好设置等完整功能,并提供了测试工具和详细文档。
2025-10-13 10:05:02 +08:00
12883c5410 perf: 删除不必要的代码 2025-10-11 21:55:39 +08:00
ed3a178aa0 feat(workout): 优化心率图表性能并移除每日总结通知功能
- 重构心率数据采样算法,采用智能采样保留峰值、谷值和变化率大的点
- 减少心率图表最大数据点数和查询限制,提升渲染性能
- 移除图表背景线样式,简化视觉呈现
- 完全移除每日总结通知功能相关代码和调用
2025-10-11 21:53:18 +08:00
richarjiang
d43d8c692f feat(workout): 重构锻炼模块并新增详细数据展示
- 移除旧的锻炼会话页面和布局文件
- 新增锻炼详情模态框组件,支持心率区间、运动强度等详细数据展示
- 优化锻炼历史页面,增加月度统计卡片和交互式详情查看
- 新增锻炼详情服务,提供心率分析、METs计算等功能
- 更新应用版本至1.0.17并调整iOS后台任务配置
- 添加项目规则文档,明确React Native开发规范
2025-10-11 17:20:51 +08:00
79ddd41a49 feat(workout): 新增锻炼历史记录功能与健康数据集成
- 新增锻炼历史页面,展示最近一个月的锻炼记录详情
- 添加锻炼汇总卡片组件,在统计页面显示当日锻炼数据
- 集成HealthKit锻炼数据获取,支持多种运动类型和详细信息
- 完善锻炼数据处理工具,包含统计分析和格式化功能
- 优化后台任务,随机选择挑战发送鼓励通知
- 版本升级至1.0.16
2025-10-02 22:13:59 +08:00
303c36025b feat: 优化代码 2025-10-01 22:59:05 +08:00
richarjiang
47c8bfc5bc feat(water): 后台任务同步HealthKit饮水记录并优化目标读取逻辑 2025-09-30 15:10:48 +08:00
richarjiang
3e6f55d804 feat(challenges): 排行榜支持单位显示与健身圆环自动上报进度
- ChallengeRankingItem 新增 unit 字段,支持按单位格式化今日进度
- FitnessRingsCard 监听圆环闭合,自动向进行中的运动挑战上报 1 次进度
- 过滤已结束挑战,确保睡眠、喝水、运动进度仅上报进行中活动
- 移除 StressMeter 调试日志与 challengesSlice 多余打印
2025-09-30 14:37:15 +08:00
richarjiang
b0602b0a99 feat(challenges): 新增挑战详情页与排行榜及轮播卡片交互
- 重构挑战列表为横向轮播,支持多进行中的挑战
- 新增挑战详情页 /challenges/[id]/index 与排行榜 /challenges/[id]/leaderboard
- ChallengeProgressCard 支持小时级剩余时间显示
- 新增 ChallengeRankingItem 组件展示榜单项
- 排行榜支持分页加载、下拉刷新与错误重试
- 挑战卡片新增已结束角标与渐变遮罩
- 加入/退出挑战时展示庆祝动画与错误提示
- 统一背景渐变色与卡片阴影细节
2025-09-30 11:33:24 +08:00
richarjiang
d32a822604 feat(challenges): 支持即将开始与已结束挑战的禁用态及睡眠挑战自动进度上报 2025-09-30 10:21:50 +08:00
richarjiang
8f847465ef feat(challenges): 新增挑战鼓励提醒后台任务与通知支持
- 在 backgroundTaskManager 中增加 executeChallengeReminderTask,每日检查已加入且未打卡的挑战并发送鼓励通知
- 扩展 ChallengeNotificationHelpers 提供 sendEncouragementNotification 方法
- 新增 NotificationTypes.CHALLENGE_ENCOURAGEMENT 及对应点击跳转处理
- challengesApi 补充 checkedInToday 字段用于判断今日是否已打卡
- 临时注释掉挑战列表与详情页头部的礼物/分享按钮,避免干扰主流程
2025-09-29 17:24:07 +08:00
richarjiang
d74bd214ed feat(challenges): 登录态守卫与进度条动画优化
- 在 _layout 中仅当已登录时才拉取挑战列表,避免未授权请求
- 挑战详情页加入 ensureLoggedIn 守卫,未登录时跳转登录
- ChallengeProgressCard 新增分段进度动画,提升视觉反馈
- 升级版本号至 1.0.15
2025-09-29 15:39:52 +08:00
richarjiang
970a4b8568 feat(challenges): 新增 ChallengeProgressCard 组件并接入喝水挑战进度上报
- 抽离进度卡片为独立组件,支持主题色自定义与复用
- 挑战列表页顶部展示进行中的挑战进度
- 喝水记录自动上报至关联的水挑战
- 移除旧版 challengeSlice 与冗余进度样式
- 统一使用 value 字段上报进度,兼容多类型挑战
2025-09-29 15:14:59 +08:00
richarjiang
9c86b0e565 feat(challenges): 移除旧版挑战页面并优化详情页交互
删除废弃的 app/challenge 目录及其所有文件,统一使用新的 challenges 模块。在详情页新增退出挑战确认弹窗,优化浮动 CTA 文案与交互,调整进度卡片样式与布局。
2025-09-29 14:13:10 +08:00
richarjiang
31c4e4fafa feat(challenges): 移除进度徽章与副标题并动态计算剩余天数
- 使用 dayjs 实时计算挑战结束剩余天数,替代接口返回的固定值
- 删除 badge、subtitle 字段及相关渲染逻辑,简化 UI
- 注释掉未使用的打卡操作区块,保持界面整洁
2025-09-29 10:25:22 +08:00
richarjiang
b80af23f4f feat(challenges): 优化挑战列表与详情页交互体验
- 替换 Image 为 expo-image 并启用缓存策略
- 调整礼物按钮尺寸与图标大小
- 加入挑战失败时弹出 Toast 提示
- 统一异步流程并移除冗余状态监听
- 清理调试日志与多余空行
2025-09-29 09:59:47 +08:00
richarjiang
7259bd7a2c feat(challenges): 接入真实接口并完善挑战列表与详情状态管理
- 新增 challengesApi 服务层,支持列表/详情/加入/退出/打卡接口
- 重构 challengesSlice,使用 createAsyncThunk 管理异步状态
- 列表页支持加载、空态、错误重试及状态标签
- 详情页支持进度展示、打卡、退出及错误提示
- 统一卡片与详情数据模型,支持动态状态更新
2025-09-28 14:16:32 +08:00
richarjiang
2b86ac17a6 feat: 支持活动挑战页面 2025-09-28 08:29:10 +08:00
richarjiang
e2597c1bc4 feat(challenges): 新增挑战模块与详情页,优化标签栏布局
- 新增挑战列表页 `app/(tabs)/challenges.tsx`,展示热门挑战卡片
- 新增挑战详情页 `app/challenges/[id].tsx`,支持排行榜、分享与参与
- 在标签栏中新增“挑战”入口,替换原有“发现”与“AI”页
- 调整标签栏间距与圆角,适配新布局
- 新增挑战相关路由常量 `TAB_CHALLENGES`
- 迁移 `coach.tsx` 与 `explore.tsx` 至根目录,保持结构清晰
2025-09-26 17:29:00 +08:00
richarjiang
a014998848 feat(water): 重构饮水模块并新增自定义提醒设置功能
- 新增饮水详情页面 `/water/detail` 展示每日饮水记录与统计
- 新增饮水设置页面 `/water/settings` 支持目标与快速添加配置
- 新增喝水提醒设置页面 `/water/reminder-settings` 支持自定义时间段与间隔
- 重构 `useWaterData` Hook,支持按日期查询与实时刷新
- 新增 `WaterNotificationHelpers.scheduleCustomWaterReminders` 实现个性化提醒
- 优化心情编辑页键盘体验,新增 `KeyboardAvoidingView` 与滚动逻辑
- 升级版本号至 1.0.14 并补充路由常量
- 补充用户偏好存储字段 `waterReminderEnabled/startTime/endTime/interval`
- 废弃后台定时任务中的旧版喝水提醒逻辑,改为用户手动管理
2025-09-26 11:02:17 +08:00
richarjiang
badd68c039 fix:修复心情日历无法打开的问题 2025-09-26 08:54:02 +08:00
richarjiang
ad98d78e18 feat: 支持会员编号 2025-09-26 08:48:31 +08:00
richarjiang
94899fbc5c feat(ui): 添加设置弹窗功能并重构水摄入设置界面
- 添加设置弹窗状态管理
- 实现设置按钮点击处理函数
- 在头部添加设置图标按钮
- 移除原有的设置行界面,改为弹窗形式展示
- 添加新的样式定义支持设置弹窗布局
- 优化设置项的展示和交互体验
2025-09-25 18:56:01 +08:00
richarjiang
0f289fcae7 fix 2025-09-25 14:29:45 +08:00
richarjiang
79ab354f31 feat: 新增基础代谢详情页面并优化HRV数据获取逻辑
- 新增基础代谢详情页面,包含图表展示、数据缓存和防抖机制
- 优化HRV数据获取逻辑,支持实时、近期和历史数据的智能获取
- 移除WaterIntakeCard和WaterSettings中的登录验证逻辑
- 更新饮水数据管理hook,直接使用HealthKit数据
- 添加饮水目标存储和获取功能
- 更新依赖包版本
2025-09-25 14:15:42 +08:00
richarjiang
83e534c4a7 feat(health): 优化HRV数据质量分析与获取逻辑
- 新增HRV质量评分算法,综合评估数值有效性、数据源可靠性与元数据完整性
- 实现最佳质量HRV值自动选取,优先手动测量并过滤异常值
- 扩展TS类型定义,支持完整HRV数据结构及质量分析接口
- 移除StressMeter中未使用的时间格式化函数与注释代码
- 默认采样数提升至50条,增强质量分析准确性
2025-09-24 18:29:58 +08:00
richarjiang
6303795870 feat: 支持围度数据图表 2025-09-24 18:04:12 +08:00
028ef56caf feat: 修复健康数据 2025-09-24 09:43:17 +08:00
richarjiang
e6dfd4d59a feat(health): 重构营养卡片数据获取逻辑,支持基础代谢与运动消耗分离
- 新增 fetchCompleteNutritionCardData 异步 action,统一拉取营养、健康与基础代谢数据
- NutritionRadarCard 改用 Redux 数据源,移除 props 透传,自动根据日期刷新
- BasalMetabolismCard 新增详情弹窗,展示 BMR 计算公式、正常区间及提升策略
- StepsCard 与 StepsCardOptimized 引入 InteractionManager 与动画懒加载,减少 UI 阻塞
- HealthKitManager 新增饮水读写接口,支持将饮水记录同步至 HealthKit
- 移除 statistics 页面冗余 mock 与 nutrition/health 重复请求,缓存时间统一为 5 分钟
2025-09-23 10:01:50 +08:00
richarjiang
d082c66b72 feat:支持身体围度数据展示 2025-09-22 10:58:23 +08:00
richarjiang
dbe460a084 refactor(health): remove HRV field and improve notification types
- Remove heart rate variability (hrv) field from health data interfaces and implementations
- Update default member name to Chinese localization
- Replace type assertions with proper enum types in notification schedulers
2025-09-22 09:02:42 +08:00
richarjiang
fb85a5f30c refactor(health): remove basalEnergyBurned from global state and move to local component
Remove basalEnergyBurned from global health data structure and refactor BasalMetabolismCard to fetch its own data locally. This decouples the component from global state and improves data locality.

- Remove basalEnergyBurned from HealthData interface and health utilities
- Update BasalMetabolismCard to use selectedDate prop and fetch data locally
- Simplify statistics screen by removing unused basalMetabolism variable
- Update nutrition radar card to use activeCalories only for burned calories calculation
2025-09-19 17:01:45 +08:00
richarjiang
9bcea25a2f feat(auth): 为未登录用户添加登录引导界面
为目标页面、营养记录、食物添加等功能添加登录状态检查和引导界面,确保用户在未登录状态下能够获得清晰的登录提示和指引。

- 在目标页面添加精美的未登录引导界面,包含渐变背景和登录按钮
- 为食物记录相关组件添加登录状态检查,未登录时自动跳转登录页面
- 重构血氧饱和度卡片为独立数据获取,移除对外部数据依赖
- 移除个人页面的实验性SwiftUI组件,统一使用原生TouchableOpacity
- 清理统计页面和营养记录页面的冗余代码和未使用变量
2025-09-19 15:52:24 +08:00
richarjiang
ccfccca7bc feat(health): 完善HealthKit权限管理和数据获取系统
- 重构权限管理,新增SimpleEventEmitter实现状态监听
- 实现完整的健身圆环数据获取(活动热量、锻炼时间、站立小时)
- 优化组件状态管理,支持实时数据刷新和权限状态响应
- 新增useHealthPermissions Hook,简化权限状态管理
- 完善iOS原生代码,支持按小时统计健身数据
- 优化应用启动时权限初始化流程,避免启动弹窗

BREAKING CHANGE: FitnessRingsCard组件API变更,移除手动传参改为自动获取数据
2025-09-19 14:16:11 +08:00
184fb672b7 perf: 完善接口 2025-09-18 22:40:05 +08:00
richarjiang
2c382ab8de feat: 支持新接口 2025-09-18 16:27:11 +08:00
richarjiang
6f0c872223 feat: 支持原生模块健康数据 2025-09-18 09:51:37 +08:00
richarjiang
6b7776e51d feat: 支持 healthkit 2025-09-17 18:05:11 +08:00
richarjiang
63ed820e93 feat(ui): 统一健康卡片标题图标并优化语音录音稳定性
- 为所有健康数据卡片添加对应功能图标,提升视觉一致性
- 将“小鱼干”文案统一为“能量值”,并更新获取说明
- 语音录音页面增加组件卸载保护、错误提示与资源清理逻辑
- 个人页支持毛玻璃按钮样式,默认用户名置空
- 新增血氧、饮食、心情、压力、睡眠、步数、体重等图标资源
- 升级 react-native-purchases 至 9.4.3
- 移除 useAuthGuard 调试日志
2025-09-16 09:35:50 +08:00
richarjiang
42b6b2076c fix 2025-09-15 19:28:13 +08:00
richarjiang
281149201b feat(personal): 持久化开发者模式状态并优化登录后数据加载
- 新增 kv-store 持久化开发者模式开关,避免每次冷启动丢失
- 登录成功后立即拉取用户资料,减少首页空数据闪烁
- 修复体重卡片在未登录时重复请求的问题
- 移除 ActivityHeatMap 与 userSlice 中的调试日志
- useAuthGuard 增加 token 调试输出(临时)
2025-09-15 16:24:38 +08:00
richarjiang
2357596665 refactor(storage): 迁移 AsyncStorage 至 expo-sqlite/kv-store
- 统一替换所有 @react-native-async-storage/async-storage 导入为自定义 kvStore
- 新增 kvStore.ts 封装 expo-sqlite/kv-store,保持与 AsyncStorage 完全兼容
- 新增同步读写方法,提升性能
- 引入 expo-sqlite 依赖并更新 lock 文件

BREAKING CHANGE: 移除 @react-native-async-storage/async-storage 依赖,需重新安装依赖并清理旧数据
2025-09-15 12:51:18 +08:00
richarjiang
91df01bd79 feat(auth): 预加载用户数据并优化登录状态同步
- 在启动屏预加载用户 token 与资料,避免首页白屏
- 新增 rehydrateUserSync 同步注入 Redux,减少异步等待
- 登录页兼容 ERR_REQUEST_CANCELED 取消场景
- 各页面统一依赖 isLoggedIn 判断,移除冗余控制台日志
- 步数卡片与详情页改为实时拉取健康数据,不再缓存至 Redux
- 后台任务注册移至顶层,防止重复定义
- 体重记录、HeaderBar 等 UI 细节样式微调
2025-09-15 09:56:42 +08:00
55d133c470 feat: 更新 UI 样式以及消息通知 2025-09-14 21:41:33 +08:00
24b144a0d1 perf: 优化 logo 2025-09-13 10:12:49 +08:00
a9bb73e2a1 fix 2025-09-12 23:01:03 +08:00
ab87bddd51 fix: 修复压力数据 2025-09-12 22:51:14 +08:00
richarjiang
4627cb650e feat(ui): 更新应用图标和启动屏幕为新品牌标识并调整相关配置
更新应用图标、启动屏幕和相关配置以匹配新品牌标识,移除旧的 Sealife 图标文件并替换为新的 icon.icon 资源。同时更新 iOS 配置中的背景任务标识符以符合命名规范。调整统计页面头部 logo 尺寸和字体粗细以优化视觉效果。

- 替换所有平台的应用图标和启动图资源
- 更新 app.json、Info.plist 和各 imageset 配置文件
- 调整 statistics.tsx 中的 logo 样式
- 移除已废弃的腾讯云 COS 文档
2025-09-12 17:07:40 +08:00
richarjiang
edac180dd6 feat(health): 统一使用共享的HealthKit权限初始化方法并简化配置
将sleepHealthKit模块中的HealthKit权限初始化逻辑替换为使用health工具中的通用ensureHealthPermissions方法,移除重复的权限配置代码。同时更新后台任务标识符以保持一致性。
2025-09-12 15:54:33 +08:00
richarjiang
1b76cc305a feat(ui): 实现原生标签页与玻璃效果按钮组件
引入 NativeTabs 替代默认 Tabs 以支持原生标签栏样式,并添加 GlassButton 组件实现毛玻璃效果按钮。
移除对 useBottomTabBarHeight 的依赖,统一使用固定底部间距 60。
重构头像上传逻辑,使用新的 uploadImage API 替代 COS 直传方案。
更新 expo-router 至 ~6.0.1 版本以支持不稳定特性。
2025-09-12 15:48:58 +08:00
richarjiang
a84c026599 feat(ui): 更新应用品牌名称为 Out Live 并优化睡眠详情页默认数据展示
- 将 Sealife 更名为 Out Live(登录页、隐私弹窗)
- 睡眠详情页无数据时显示 "--" 替代固定默认值
- 移除睡眠阶段卡片中的质量标签与总览徽章
- 修复体重历史卡片依赖监听字段与跳转路由
- 调整喝水提醒后台任务时间范围为 8-21 点
- 标签栏按钮新增 activeOpacity=1 禁用点击透明度变化
2025-09-12 09:59:01 +08:00
1af0945a2f feat: 支持 glass 2025-09-11 23:25:56 +08:00
dfe9506a7a feat: 支持 expo 44 版本 2025-09-11 23:00:24 +08:00
0cb7e67b5e feat: 更新依赖项版本并添加新的UI库 2025-09-11 22:35:35 +08:00
richarjiang
3a4a55b78e feat: 新增语音记录饮食功能与开发者调试模块
- 集成 @react-native-voice/voice 实现中文语音识别,支持“一句话记录”餐食
- 新增语音录制页面,含波形动画、音量反馈与识别结果确认
- FloatingFoodOverlay 新增语音入口,打通拍照/库/语音三种记录方式
- 添加麦克风与语音识别权限描述(iOS Info.plist 与 Android manifest)
- 实现开发者模式:连续三次点击用户名激活,含日志查看、导出与清除
- 新增 logger 工具类,统一日志存储(AsyncStorage)与按级别输出
- 重构 BackgroundTaskManager 为单例并支持 Promise 初始化,避免重复注册
- 移除 sleep-detail 多余渐变背景,改用 ThemedView 统一主题
- 新增通用 haptic 反馈函数,支持多种震动类型(iOS only)
- 升级 expo-background-task、expo-notifications、expo-task-manager 至兼容版本
2025-09-11 19:11:09 +08:00
richarjiang
35d6b74451 feat(widget): 增强Widget数据同步机制并优化UI设计
- 在useWaterData中统一处理数据变更后的Widget同步逻辑
- 新增数组类型数据存取方法支持更复杂数据结构
- 重构Widget UI为圆形进度条设计,提升视觉体验
- 修复数据同步时可能存在的竞态条件问题
- 优化错误处理,确保Widget同步失败不影响主功能
2025-09-11 10:38:54 +08:00
richarjiang
62690ee3fc refactor(sleep): 重构睡眠数据获取逻辑,移除冗余代码并优化组件结构
- 从 healthSlice 和 health.ts 中移除 sleepDuration 字段及相关获取逻辑
- 将 SleepCard 改为按需异步获取睡眠数据,支持传入指定日期
- 睡眠详情页改为通过路由参数接收日期,支持查看历史记录
- 移除 statistics 页面对 sleepDuration 的直接依赖,统一由 SleepCard 管理
- 删除未使用的 SleepStageChart 组件,简化页面结构
2025-09-11 09:08:51 +08:00
richarjiang
aee87e8900 fix: 调整睡眠阶段图表的宽度和边距,优化标签显示逻辑 2025-09-10 19:20:05 +08:00
richarjiang
6fbdbafa3e feat: 添加睡眠阶段时间轴组件,优化睡眠数据可视化 2025-09-10 19:03:34 +08:00
98176ee988 Refactor iOS dependencies and update HealthKit integration
- Removed NitroModules and ReactNativeHealthkit from Podfile.lock and package files.
- Updated Info.plist to increment app version from 2 to 3.
- Refactored background task manager to define background tasks within the class.
- Added new utility file for sleep data management, including fetching sleep samples, calculating sleep statistics, and generating sleep quality scores.
2025-09-09 23:16:54 +08:00
b0c572c1d4 feat: 重构睡眠详情模块,扩展数据类型并引入独立组件以优化代码结构 2025-09-09 22:12:12 +08:00
richarjiang
a7f5379d5a feat: Update Podfile.lock to include NitroModules and ReactNativeHealthkit dependencies
fix: Adjust objectVersion in project.pbxproj and improve WaterWidget folder exception handling

refactor: Remove sleepService.ts as part of code cleanup

chore: Comment out HealthKit initialization in health.ts and clean up fetchSleepDuration function
2025-09-09 19:27:19 +08:00
richarjiang
6daf9500fc feat: 添加原始睡眠数据列表,优化睡眠详情数据处理逻辑,确保完整的睡眠周期计算 2025-09-09 16:20:11 +08:00
richarjiang
e56ebe3636 feat: 完善饮水 widget 2025-09-09 14:26:16 +08:00
richarjiang
cacfde064f feat: 优化睡眠数据 2025-09-09 10:01:11 +08:00
richarjiang
9ccd15319e feat: 在食物库页面中集成每日营养数据刷新功能,优化饮食记录成功后的用户体验;移除营养目标计算逻辑以简化组件 2025-09-09 08:31:32 +08:00
richarjiang
1de4b9fe4c feat: 更新睡眠详情页面,集成真实睡眠数据生成逻辑,优化睡眠阶段图表展示,添加睡眠样本数据处理功能,提升用户体验 2025-09-08 19:26:02 +08:00
richarjiang
bf3304eb06 feat: 优化提醒注册逻辑,确保用户姓名存在时注册午餐、晚餐和心情提醒;更新睡眠详情页面,添加清醒时间段的判断和模拟数据展示;调整样式以提升用户体验 2025-09-08 17:45:30 +08:00
richarjiang
f9a175d76c feat: 更新睡眠详情页面,添加睡眠等级和信息模态框组件,优化统计卡片样式,移除测试通知功能 2025-09-08 10:09:39 +08:00
richarjiang
e91283fe4e feat: 添加睡眠详情页面,集成睡眠数据获取功能,优化健康数据权限管理,更新相关组件以支持睡眠统计和展示 2025-09-08 09:54:33 +08:00
df7f04808e feat: 添加测试通知功能以验证后台任务执行,记录通知发送时间 2025-09-07 10:09:08 +08:00
aaa34a7a07 feat: 更新应用名称为“Out Live”,删除推送通知使用指南和喝水记录API修复测试文档,优化饮水设置页面,添加登录状态检查 2025-09-07 10:03:37 +08:00
2e7daae519 feat: 更新健康数据权限描述,添加HRV数据获取测试功能,优化后台任务配置,调整压力计显示单位 2025-09-06 16:34:56 +08:00
2df747109c feat: 优化体重记录页面,使用useCallback提升加载历史记录性能,调整样式以支持深色模式 2025-09-05 23:01:34 +08:00
8d6a848918 feat: 更新心情编辑页面,优化心情描述输入框,增加日记标题和副标题,调整样式和布局,提升用户体验;修改MoodIntensitySlider组件,优化滑块样式和交互效果 2025-09-05 22:56:00 +08:00
c37c3a16b1 feat: 优化统计和步数详情页面,添加活动等级计算和展示,更新压力计组件以支持HRV值直接显示 2025-09-05 22:28:04 +08:00
e6708e68c2 feat: 集成expo-background-task和expo-task-manager,重构后台任务管理,添加健康提醒功能,优化任务执行逻辑 2025-09-05 22:07:29 +08:00
3c416545db feat: 添加最大心率功能,更新用户资料编辑页面以显示最大心率数据,优化相关组件和服务 2025-09-05 21:58:46 +08:00
richarjiang
aee291bb69 feat: 添加快捷动作功能,支持快速记录饮水量,更新相关配置和服务 2025-09-05 17:17:22 +08:00
richarjiang
6af86800f2 更新依赖项版本,优化后台任务管理器,添加后台任务自动启动功能,调整后台获取配置,移除冗余代码 2025-09-05 16:52:00 +08:00
richarjiang
8d71d751d6 feat: 添加饮水设置页面,支持每日饮水目标和快速添加默认值的配置 2025-09-05 16:31:52 +08:00
richarjiang
83805a4b07 feat: Refactor MoodCalendarScreen to use dayjs for date handling and improve calendar data generation
feat: Update FitnessRingsCard to navigate to fitness rings detail page on press

feat: Modify NutritionRadarCard to enhance UI and add haptic feedback on actions

feat: Add FITNESS_RINGS_DETAIL route for navigation

fix: Adjust minimum fetch interval in BackgroundTaskManager for background tasks

feat: Implement haptic feedback utility functions for better user experience

feat: Extend health permissions to include Apple Exercise Time and Apple Stand Time

feat: Add functions to fetch hourly activity, exercise, and stand data for improved health tracking

feat: Enhance user preferences to manage fitness exercise minutes and active hours info dismissal
2025-09-05 15:32:34 +08:00
richarjiang
460a7e4289 feat: 添加后台任务管理器,支持喝水和站立提醒功能 2025-09-05 10:29:02 +08:00
richarjiang
acb3907344 Refactor: Remove background task management and related hooks
- Deleted `useBackgroundTasks.ts` hook and its associated logic for managing background tasks.
- Removed `backgroundTaskManager.ts` service and all related task definitions and registrations.
- Cleaned up `Podfile.lock` and `package.json` to remove unused dependencies related to background tasks.
- Updated iOS project files to eliminate references to removed background task components.
- Added new background fetch identifier in `Info.plist` for future use.
2025-09-05 09:47:49 +08:00
richarjiang
cb89ee7bc2 feat: 优化后台任务管理,添加系统权限和用户偏好的完整检查,增强通知功能 2025-09-04 18:23:05 +08:00
richarjiang
6c21c4b448 feat: 添加食物编辑功能,支持修改食物名称、重量和卡路里 2025-09-04 17:46:48 +08:00
richarjiang
a4a0e07227 feat: 添加后台任务测试通知功能,优化滑动删除交互体验 2025-09-04 16:12:27 +08:00
richarjiang
05a643a9e6 feat: 添加食物分析结果页面的图片预览功能,优化记录栏显示逻辑 2025-09-04 15:12:39 +08:00
richarjiang
5e00cb7788 feat: 优化营养记录和卡路里环图组件,增加毛玻璃背景和动画效果 2025-09-04 11:28:31 +08:00
richarjiang
4ae419754a feat(food): 添加拍摄指引弹窗与相册选择功能
- 在相机界面新增“拍摄示例”弹窗,展示正确/错误拍摄对比图
- 底部控制栏增加相册选择按钮与帮助按钮
- 优化控制栏布局为左右分布,提升操作便捷性
- 移除 food-recognition 中冗余的 isUploading 状态,简化上传流程
2025-09-04 10:52:00 +08:00
richarjiang
6cb0435b30 feat: add food camera and recognition features
- Implemented FoodCameraScreen for capturing food images with meal type selection.
- Created FoodRecognitionScreen for processing and recognizing food images.
- Added Redux slice for managing food recognition state and results.
- Integrated image upload functionality to cloud storage.
- Enhanced UI components for better user experience during food recognition.
- Updated FloatingFoodOverlay to navigate to the new camera screen.
- Added food recognition service for API interaction.
- Improved styling and layout for various components.
2025-09-04 10:18:42 +08:00
richarjiang
0b75087855 feat: 优化食物相机界面,调整导航和取景框样式 2025-09-03 19:24:53 +08:00
richarjiang
02883869fe feat: Implement Food Camera Screen and Floating Food Overlay
- Added FoodCameraScreen for capturing food images with camera functionality.
- Integrated image picker for selecting images from the gallery.
- Created FloatingFoodOverlay for quick access to food library and scanning options.
- Updated NutritionRadarCard to utilize FloatingFoodOverlay for adding food.
- Enhanced ExploreScreen layout and styles for better user experience.
- Removed unused SafeAreaView from ExploreScreen.
- Updated profile edit screen to remove unnecessary state variables.
- Updated avatar image source in profile edit screen.
- Added ExpoCamera dependency for camera functionalities.
2025-09-03 19:17:26 +08:00
richarjiang
45f8415a38 fix: 调整用户体重卡片样式,优化动画容器高度和字体大小 2025-09-03 16:48:34 +08:00
richarjiang
8b9689b269 Refactor components and enhance background task management
- Updated font sizes and weights in BasalMetabolismCard, MoodCard, HealthDataCard, and NutritionRadarCard for improved readability.
- Removed loading state from MoodCard to simplify the component.
- Adjusted styles in WeightHistoryCard for better layout and spacing.
- Integrated expo-background-fetch for improved background task handling.
- Updated Info.plist to include background fetch capability.
- Enhanced background task registration and execution logic in backgroundTaskManager.
- Added debug function to manually trigger background task execution for testing purposes.
2025-09-03 16:17:29 +08:00
richarjiang
16b4fc8816 perf: 2025-09-03 15:13:37 +08:00
richarjiang
951c02f644 feat: 新增动画资源与庆祝效果,优化布局与标签页配置 2025-09-03 15:03:26 +08:00
richarjiang
8b6ef378d0 feat: 添加用户推送通知偏好设置功能,支持开启/关闭推送通知 2025-09-03 10:58:45 +08:00
richarjiang
e33a690a36 feat: 支持将饮水记录同步到 HealthKit,新增相关功能和权限设置 2025-09-03 08:42:48 +08:00
richarjiang
a70cb1e407 feat: 新增步数详情页面,支持日期选择和步数统计展示
feat: 更新StepsCard组件,支持点击事件回调
feat: 在WaterIntakeCard中添加震动反馈功能
fix: 在用户重建时保存authToken
2025-09-02 19:22:02 +08:00
richarjiang
70e3152158 feat: 新增喝水提醒功能,支持定期提醒和目标检查 2025-09-02 18:56:40 +08:00
richarjiang
ccbc3417bc perf: 删除不必要的文件 2025-09-02 17:20:33 +08:00
richarjiang
ac748dc339 feat: 新增饮水记录功能,支持快速添加饮水量和用户偏好设置 2025-09-02 17:12:38 +08:00
richarjiang
85a3c742df feat: 支持饮水记录卡片 2025-09-02 15:50:35 +08:00
richarjiang
ed694f6142 feat: 优化步数柱状图显示,增加背景柱体并调整动画逻辑 2025-09-01 18:47:22 +08:00
richarjiang
73ca11e68f chore: 新增后台任务处理标识符配置项 2025-09-01 10:52:01 +08:00
richarjiang
a34ca556e8 feat(notifications): 新增晚餐和心情提醒功能,支持HRV压力检测和后台处理
- 新增晚餐提醒(18:00)和心情提醒(21:00)的定时通知
- 实现基于HRV数据的压力检测和智能鼓励通知
- 添加后台任务处理支持,修改iOS后台模式为processing
- 优化营养记录页面使用Redux状态管理,支持实时数据更新
- 重构卡路里计算公式,移除目标卡路里概念,改为基代+运动-饮食
- 新增营养目标动态计算功能,基于用户身体数据智能推荐
- 完善通知点击跳转逻辑,支持多种提醒类型的路由处理
2025-09-01 10:29:13 +08:00
richarjiang
fe634ba258 feat: 支持营养圆环 2025-08-31 16:30:08 +08:00
4bb0576d92 feat: 优化数据加载逻辑,添加应用状态监听以刷新统计数据;为步数卡片添加动画效果 2025-08-30 23:07:14 +08:00
6bdfda9fd3 feat: 更新统计标签和标题,优化健康数据卡片样式,调整步数和健康相关组件的样式 2025-08-30 22:37:27 +08:00
richarjiang
f4dd40ed46 fix: 修复开发环境判断 2025-08-30 17:11:51 +08:00
richarjiang
741688065d feat: 支持步数卡片; 优化数据分析各类卡片样式 2025-08-30 17:07:04 +08:00
465d5350f3 refactor: 注释掉浮动卡片动画逻辑,调整样式以增强视觉效果 2025-08-29 21:27:29 +08:00
3fdd2acaf2 feat: 增强食物库功能,支持自定义食物的创建与删除,优化用户体验 2025-08-29 21:03:45 +08:00
richarjiang
e9b593a07e feat: 优化内容 2025-08-29 16:01:13 +08:00
richarjiang
93db9e2928 perf: 优化 2025-08-29 11:22:07 +08:00
richarjiang
f38f495008 perf: 优化食物选择 2025-08-29 10:13:59 +08:00
richarjiang
8d567fb4cb feat: 支持食物库接口 2025-08-29 09:41:05 +08:00
richarjiang
c15a9176f4 feat: 支持食物详情弹窗 2025-08-28 19:24:22 +08:00
richarjiang
6551757ca8 feat: 2025-08-28 17:42:57 +08:00
richarjiang
5a59508b88 refactor(coach): 重构教练组件,统一导入并简化UI实现与类型定义 2025-08-28 09:46:14 +08:00
richarjiang
ba2d829e02 feat: 新增体重记录功能,优化用户资料更新及图片组件缓存 2025-08-27 19:18:54 +08:00
richarjiang
aaa462d476 feat: 更新个人页面和活动热图组件
- 在个人页面中新增鱼干记录展示,优化用户界面
- 修改活动热图组件,增加信息弹窗,提供小鱼干使用说明
- 调整样式,提升整体视觉效果和用户体验
- 更新颜色常量,确保一致性
2025-08-27 12:48:43 +08:00
richarjiang
9bb924202f feat: 更新应用版本号至 1.0.5 2025-08-27 10:20:05 +08:00
richarjiang
37d33b28e5 feat: 更新应用名称和版本,调整启动画面布局
- 将应用名称从 "Sealife" 修改为 "海豹健康"
- 更新应用版本号至 1.0.5
- 调整启动画面中 logo 的位置和约束,优化显示效果
- 修改项目配置文件,设置开发区域为简体中文
2025-08-27 10:15:27 +08:00
richarjiang
a6dbe7c723 feat: 更新用户资料编辑功能及相关组件
- 在 EditProfileScreen 中新增活动水平字段,支持用户设置和保存活动水平
- 更新个人信息卡片,增加活动水平的展示和编辑功能
- 在 ProfileCard 组件中优化样式,提升用户体验
- 更新 package.json 和 package-lock.json,新增 @react-native-picker/picker 依赖
- 在多个组件中引入 expo-image,优化图片加载和展示效果
2025-08-27 09:59:44 +08:00
richarjiang
5e3203f1ce feat: 添加历史会话模态框和更新组件
- 在 CoachScreen 中引入 HistoryModal 组件,优化历史会话展示
- 更新 NutritionRecordCard 组件,使用 Popover 替代 ActionSheet,提升操作体验
- 在 NutritionRecordsScreen 中引入 DateSelector 组件,简化日期选择逻辑
- 更新 package.json 和 package-lock.json,新增 react-native-popover-view 依赖
- 移除不再使用的历史会话模态框代码,提升代码整洁性
2025-08-27 08:49:56 +08:00
richarjiang
533b40a12d feat: 更新 CoachScreen 和欢迎消息生成逻辑
- 在 CoachScreen 中优化欢迎消息的生成,整合用户配置文件数据,支持选择选项和表情
- 更新欢迎消息生成函数,返回包含内容、选择和交互类型的结构
- 在多个组件中调整样式,提升用户体验和界面一致性
- 在 Statistics 组件中添加记录更新时间,确保数据展示的准确性
- 在 FitnessRingsCard 中修正卡路里和运动时间的显示,确保数值四舍五入
2025-08-27 08:15:42 +08:00
0a8b20f0ec feat: 增强目标管理功能及相关组件
- 在 GoalsListScreen 中新增目标编辑功能,支持用户编辑现有目标
- 更新 CreateGoalModal 组件,支持编辑模式下的目标更新
- 在 NutritionRecordsScreen 中新增删除营养记录功能,允许用户删除不需要的记录
- 更新 NutritionRecordCard 组件,增加操作选项,支持删除记录
- 修改 dietRecords 服务,添加删除营养记录的 API 调用
- 优化 goalsSlice,确保目标更新逻辑与 Redux 状态管理一致
2025-08-26 22:34:03 +08:00
richarjiang
0610f287ee feat: 更新目标创建功能及相关组件
- 在 CreateGoalModal 中新增目标创建表单,支持设置标题、描述、重复周期、频率、提醒时间和结束日期
- 更新 GoalCard 组件,增加显示结束日期的功能
- 修改 goals.tsx 文件,调整 CreateGoalModal 的导入路径
- 更新 eslint 配置,增加对 node_modules 的忽略设置,优化代码检查
2025-08-26 15:35:10 +08:00
richarjiang
3f89023447 feat: 更新 CLAUDE.md 文件及多个组件以优化用户体验和功能
- 更新 CLAUDE.md 文件,重构架构部分,增加认证和数据层的描述
- 在 GoalsScreen 中新增目标模板选择功能,支持用户选择和创建目标
- 在 CreateGoalModal 中添加初始数据支持,优化目标创建体验
- 新增 GoalTemplateModal 组件,提供目标模板选择界面
- 更新 NotificationHelpers,支持构建深度链接以便于导航
- 在 CoachScreen 中处理路由参数,增强用户交互体验
- 更新多个组件的样式和逻辑,提升整体用户体验
- 删除不再使用的中文回复规则文档
2025-08-26 15:04:04 +08:00
richarjiang
7f2afdf671 feat: 增强通知功能及用户体验
- 在 Bootstrapper 组件中新增通知服务初始化逻辑,注册每日午餐提醒
- 在 CoachScreen 中优化欢迎消息生成逻辑,整合用户配置文件数据
- 更新 GoalsScreen 组件,优化目标创建时的通知设置逻辑
- 在 NotificationTest 组件中添加调试通知状态功能,提升开发便利性
- 新增 NutritionNotificationHelpers 中的午餐提醒功能,支持每日提醒设置
- 更新相关文档,详细描述新功能和使用方法
2025-08-26 09:56:23 +08:00
richarjiang
e6bbda9d0f feat: 更新健康数据管理功能及相关组件
- 新增 healthSlice,用于管理健康数据的 Redux 状态
- 在 Statistics 组件中整合健康数据获取逻辑,优化数据展示
- 更新 NutritionRadarCard 组件,调整卡路里计算区域,提升用户体验
- 移除不必要的状态管理,简化组件逻辑
- 优化代码结构,提升可读性和维护性
2025-08-25 19:20:56 +08:00
richarjiang
91b7b0cb99 feat: 更新多个组件以优化用户体验和功能
- 在 CoachScreen 中移除不必要的 router 引入,简化代码结构
- 在 PersonalScreen 中移除未使用的 colorScheme 引入,优化组件性能
- 更新 NutritionRadarCard 组件,新增卡路里计算功能,提升营养数据展示
- 修改 Statistics 组件,调整样式以增强视觉效果
- 移除 iOS 项目中的多余健康数据权限设置,简化配置
2025-08-25 17:41:42 +08:00
richarjiang
be0a8e7393 feat: 优化健康数据相关组件及功能
- 在 CoachScreen 中调整键盘高度计算,移除不必要的 insets.bottom
- 更新 Statistics 组件,移除未使用的健康数据相关函数,简化代码
- 修改多个统计卡片,移除不必要的图标属性,提升组件简洁性
- 优化 HealthDataCard 和其他统计卡片的样式,提升视觉一致性
- 更新健康数据获取逻辑,确保数据处理更为准确
- 移除 MoodCard 中的多余元素,简化心情记录展示
- 调整 StressMeter 和其他组件的样式,提升用户体验
2025-08-25 12:44:40 +08:00
richarjiang
ee84a801fb feat: 更新多个组件以使用 SafeAreaView
- 在 goals-list、task-list、explore、personal、challenge/day 和 challenge/index 组件中引入 SafeAreaView,确保内容在安全区域内显示
- 移除不必要的 SafeAreaView 导入,优化代码结构
- 更新相关样式,提升用户体验和界面一致性
2025-08-25 09:37:12 +08:00
richarjiang
4f2d47c23f feat: 更新心情记录功能及相关组件
- 在心情日历中新增心情圆环展示,显示心情强度
- 修改心情记录编辑页面,支持使用图标替代表情
- 优化心情类型配置,使用图片资源替代原有表情
- 新增多种心情图标,丰富用户选择
- 更新相关样式,提升用户体验和界面美观性
- 更新文档,详细描述新功能和使用方法
2025-08-25 09:33:54 +08:00
23aa15f76e feat: 集成后台任务管理功能及相关组件
- 新增后台任务管理器,支持任务的注册、执行和状态监控
- 实现自定义Hook,简化后台任务的使用和管理
- 添加示例任务,包括数据同步、健康数据更新和通知检查等
- 更新文档,详细描述后台任务系统的实现和使用方法
- 优化相关组件,确保用户体验和界面一致性
2025-08-24 09:46:11 +08:00
4f2bd76b8f feat: 实现目标列表左滑删除功能及相关组件
- 在目标列表中添加左滑删除功能,用户可通过左滑手势显示删除按钮并确认删除目标
- 修改 GoalCard 组件,使用 Swipeable 组件包装卡片内容,支持删除操作
- 更新目标列表页面,集成删除目标的逻辑,确保与 Redux 状态管理一致
- 添加开发模式下的模拟数据,方便测试删除功能
- 更新相关文档,详细描述左滑删除功能的实现和使用方法
2025-08-23 17:58:39 +08:00
20a244e375 feat: 实现目标通知功能及相关组件
- 新增目标通知功能,支持根据用户创建目标时选择的频率和开始时间自动创建本地定时推送通知
- 实现每日、每周和每月的重复类型,用户可自定义选择提醒时间和重复规则
- 集成目标通知测试组件,方便开发者测试不同类型的通知
- 更新相关文档,详细描述目标通知功能的实现和使用方法
- 优化目标页面,确保用户体验和界面一致性
2025-08-23 17:13:04 +08:00
4382fb804f feat: 新增目标创建弹窗的重复周期选择功能
- 在目标创建弹窗中添加重复周期选择功能,支持用户选择每日、每周和每月的重复类型
- 实现每周和每月的具体日期选择,用户可自定义选择周几和每月的日期
- 更新相关样式,提升用户体验和界面美观性
- 新增图标资源,替换原有文本图标,增强视觉效果
2025-08-23 15:58:37 +08:00
8a7599f630 feat: 新增目标页面引导功能及相关组件
- 在目标页面中集成用户引导功能,帮助用户了解页面各项功能
- 创建 GoalsPageGuide 组件,支持多步骤引导和动态高亮功能区域
- 实现引导状态的检查、标记和重置功能,确保用户体验
- 添加开发测试按钮,方便开发者重置引导状态
- 更新相关文档,详细描述引导功能的实现和使用方法
2025-08-23 13:53:18 +08:00
b807e498ed feat: 新增任务详情页面及相关功能
- 创建任务详情页面,展示任务的详细信息,包括状态、描述、优先级和进度
- 实现任务完成和跳过功能,用户可通过按钮操作更新任务状态
- 添加评论功能,用户可以对任务进行评论并发送
- 优化任务卡片,点击后可跳转至任务详情页面
- 更新相关样式,确保界面一致性和美观性
2025-08-23 13:33:39 +08:00
75806df660 feat: 优化目标创建成功提示及任务筛选功能
- 将目标创建成功后的提示从系统默认的 Alert.alert 改为使用自定义确认弹窗,提升用户体验和视觉一致性
- 在任务筛选中新增“已跳过”选项,支持用户更好地管理任务状态
- 更新任务卡片和进度卡片,展示跳过任务的数量和状态
- 调整相关组件样式,确保界面一致性和美观性
- 编写相关文档,详细描述新功能和使用方法
2025-08-22 22:19:49 +08:00
7d28b79d86 feat: 集成推送通知功能及相关组件
- 在项目中引入expo-notifications库,支持本地推送通知功能
- 实现通知权限管理,用户可选择开启或关闭通知
- 新增通知发送、定时通知和重复通知功能
- 更新个人页面,集成通知开关和权限请求逻辑
- 编写推送通知功能实现文档,详细描述功能和使用方法
- 优化心情日历页面,确保数据实时刷新
2025-08-22 22:00:05 +08:00
c12329bc96 feat: 移除目标管理演示页面并优化相关组件
- 删除目标管理演示页面的代码,简化项目结构
- 更新底部导航,移除目标管理演示页面的路由
- 调整相关组件的样式和逻辑,确保界面一致性
- 优化颜色常量的使用,提升视觉效果
2025-08-22 21:24:31 +08:00
9e719a9eda feat: 增强任务管理功能,新增任务筛选和状态统计组件
- 在目标页面中集成任务筛选标签,支持按状态(全部、待完成、已完成)过滤任务
- 更新任务卡片,展示任务状态和优先级信息
- 新增任务进度卡片,统计各状态任务数量
- 优化空状态展示,根据筛选条件动态显示提示信息
- 引入新图标和图片资源,提升界面视觉效果
2025-08-22 20:45:15 +08:00
richarjiang
259f10540e feat: 新增任务管理功能及相关组件
- 将目标页面改为任务列表,支持任务的创建、完成和跳过功能
- 新增任务卡片和任务进度卡片组件,展示任务状态和进度
- 实现任务数据的获取和管理,集成Redux状态管理
- 更新API服务,支持任务相关的CRUD操作
- 编写任务管理功能实现文档,详细描述功能和组件架构
2025-08-22 17:30:14 +08:00
richarjiang
231620d778 feat: 完善目标管理功能及相关组件
- 新增创建目标弹窗,支持用户输入目标信息并提交
- 实现目标数据的转换,支持将目标转换为待办事项和时间轴事件
- 优化目标页面,集成Redux状态管理,处理目标的创建、完成和错误提示
- 更新时间轴组件,支持动态显示目标安排
- 编写目标管理功能实现文档,详细描述功能和组件架构
2025-08-22 12:05:27 +08:00
richarjiang
136c800084 feat: 新增目标管理功能及相关组件
- 创建目标管理演示页面,展示高保真的目标管理界面
- 实现待办事项卡片的横向滑动展示,支持时间筛选功能
- 新增时间轴组件,展示选中日期的具体任务
- 更新底部导航,添加目标管理和演示页面的路由
- 优化个人页面菜单项,提供目标管理的快速访问
- 编写目标管理功能实现文档,详细描述功能和组件架构
2025-08-22 09:31:35 +08:00
22142d587d feat: 更新启动画面布局,新增宽高约束
- 在启动画面中新增宽度和高度约束,确保 logo 在容器中的适配性
- 优化布局,提升启动画面的视觉效果
2025-08-21 23:06:35 +08:00
f10b7a0fb5 feat: 新增基础代谢率功能及相关组件
- 在健康数据中引入基础代谢率的读取和展示,支持用户记录健身进度
- 更新统计页面,替换BMI卡片为基础代谢卡片,提升用户体验
- 优化健康数据获取逻辑,确保基础代谢数据的准确性
- 更新权限描述,明确应用对健康数据的访问需求
2025-08-21 22:53:22 +08:00
richarjiang
098c65b23e feat: 更新心情相关页面和组件
- 在心情统计、日历和编辑页面中引入 HeaderBar 组件,提升界面一致性和用户体验
- 移除冗余的头部视图代码,简化组件结构
- 更新心情日历和编辑页面的样式,使用新的颜色常量,增强视觉效果
- 优化心情统计页面的加载状态显示,提升用户交互体验
2025-08-21 19:09:02 +08:00
richarjiang
72e75b602e feat: 更新心情记录功能和界面
- 调整启动画面中的图片宽度,提升视觉效果
- 移除引导页面相关组件,简化应用结构
- 新增心情统计页面,支持用户查看和分析心情数据
- 优化心情卡片组件,增强用户交互体验
- 更新登录页面标题,提升品牌一致性
- 新增心情日历和编辑功能,支持用户记录和管理心情
2025-08-21 17:59:22 +08:00
richarjiang
a7607e0f74 feat: 添加心情记录功能
在统计页面新增心情卡片和弹窗组件,支持用户记录和查看每日心情状态。
2025-08-21 15:34:47 +08:00
richarjiang
b93a863e25 feat: 添加用户活动热力图组件
在个人页面新增活动热力图展示组件,并实现浮动动画效果优化统计卡片交互体验。
2025-08-21 15:22:16 +08:00
richarjiang
b396a7d101 feat: 更新教练页面和布局,优化用户体验
- 将教练页面中的“Bot”名称更改为“Seal”,提升品牌一致性
- 在布局文件中调整标签标题和图标,确保与新名称一致
- 新增使用次数显示功能,优化用户对使用情况的了解
- 更新日期选择器样式,增强未来日期的禁用效果
- 优化压力分析模态框的颜色和文本,提升可读性
2025-08-21 10:29:12 +08:00
richarjiang
78620f18ee feat: 更新依赖项并优化组件结构
- 在 package.json 和 package-lock.json 中新增 @sentry/react-native、react-native-device-info 和 react-native-purchases 依赖
- 更新统计页面,替换 CircularRing 组件为 FitnessRingsCard,增强健身数据展示
- 在布局文件中引入 ToastProvider,优化用户通知体验
- 新增 SuccessToast 组件,提供全局成功提示功能
- 更新健康数据获取逻辑,支持健身圆环数据的提取
- 优化多个组件的样式和交互,提升用户体验
2025-08-21 09:51:25 +08:00
richarjiang
19b92547e1 feat: 优化个人页面样式和颜色配置
- 更新个人页面中菜单项的背景色和图标颜色,提升视觉效果
- 调整用户信息卡片和统计项的阴影效果及边框样式,增强层次感
- 优化颜色常量,提升文本和结构色的对比度,改善可读性
- 更新背景渐变色,提供更柔和的视觉体验
2025-08-20 19:15:54 +08:00
richarjiang
3d47073d2f feat: 新增饮食方案卡片及相关计算功能
- 在教练页面中新增饮食方案卡片,展示用户的饮食计划和相关数据
- 实现BMI计算、每日推荐摄入热量计算及营养素分配功能
- 优化体重历史记录卡片,增加动画效果提升用户体验
- 更新统计页面样式,增加体重卡片样式和按钮功能
- 修复获取HRV值的逻辑,确保数据准确性
2025-08-20 19:10:04 +08:00
richarjiang
1c44c3083b feat: 优化统计页面和BMI卡片,移除压力分析相关代码
- 在统计页面中移除压力分析模态框及相关状态管理,简化组件逻辑
- 更新BMI卡片,改进弹窗展示方式,增加渐变背景和健康建议
- 新增更新体重的功能,支持将体重数据写入健康数据中
- 优化压力计组件,调整数据展示逻辑,提升用户体验
2025-08-20 17:25:42 +08:00
richarjiang
d76ba48424 feat(ui): 统一应用主题色为天空蓝并优化渐变背景
将应用主色调从 '#BBF246' 更改为 '#87CEEB'(天空蓝),并更新所有相关组件和页面中的颜色引用。同时为多个页面添加统一的渐变背景,提升视觉效果和用户体验。新增压力分析模态框组件,并优化压力计组件的交互与显示逻辑。更新应用图标和启动图资源。
2025-08-20 09:38:25 +08:00
37f8c3c78d feat: 更新营养雷达图组件,移除加载状态属性
- 在统计页面的营养雷达图组件中移除不再使用的加载状态属性,简化组件接口
2025-08-19 22:05:01 +08:00
7d7d233bbb feat: 更新统计页面,优化HRV数据展示和逻辑
- 移除模拟HRV数据,改为从健康数据中获取实际HRV值
- 新增HRV更新时间显示,提升用户信息获取体验
- 优化日期推导逻辑,确保数据加载一致性
- 更新BMI卡片和营养雷达图组件,支持紧凑模式展示
- 移除不再使用的图片资源,简化项目结构
2025-08-19 22:04:39 +08:00
richarjiang
63b1c52909 feat: 新增 StressMeter 组件,移除原 HRV 压力监测代码 2025-08-19 19:22:38 +08:00
richarjiang
35cd320ea7 feat: 更新应用名称及图标,新增HRV数据管理,优化营养记录展示 2025-08-19 19:13:02 +08:00
richarjiang
260546ff46 feat: 新增营养记录页面及相关组件
- 在应用中新增营养记录页面,展示用户的饮食记录
- 引入营养记录卡片组件,优化记录展示效果
- 更新路由常量,添加营养记录相关路径
- 修改布局文件,整合营养记录功能
- 优化数据加载逻辑,支持分页和日期过滤
2025-08-19 11:34:50 +08:00
richarjiang
df2afeb5a1 feat: 更新营养雷达图组件,优化营养分析卡片
- 在营养分析卡片中引入更新日期和图标,提升信息展示
- 修改营养维度标签,简化文本内容
- 更新雷达图组件,支持自定义尺寸和标签显示
- 增加尺寸配置,优化图表显示效果
- 更新饮食记录服务,添加更新时间字段以支持新功能
2025-08-19 11:03:18 +08:00
richarjiang
9aa0a692a8 feat: 新增营养摄入分析卡片并优化相关页面
- 在统计页面中引入营养摄入分析卡片,展示用户的营养数据
- 更新探索页面,增加营养数据加载逻辑,确保用户体验一致性
- 移除不再使用的推荐文章逻辑,简化代码结构
- 更新路由常量,确保路径管理集中化
- 优化体重历史记录卡片,提升用户交互体验
2025-08-19 10:01:26 +08:00
richarjiang
c7d7255312 feat: 更新标签页和新增探索页面
- 将标签页中的“首页”改为“探索”,并更新相关逻辑
- 新增探索页面,展示推荐文章和训练计划
- 优化教练页面的导航,确保用户体验一致性
- 移除不再使用的代码,简化项目结构
- 更新路由常量,确保路径管理集中化
2025-08-18 19:08:22 +08:00
richarjiang
d52981ab29 feat: 实现聊天取消功能,提升用户交互体验
- 在教练页面中添加用户取消发送或终止回复的能力
- 更新发送按钮状态,支持发送和取消状态切换
- 在流式回复中显示取消按钮,允许用户中断助手的生成
- 增强请求管理,添加请求序列号和有效性验证,防止延迟响应影响用户体验
- 优化错误处理,区分用户主动取消和网络错误,提升系统稳定性
- 更新相关文档,详细描述取消功能的实现和用户体验设计
2025-08-18 18:59:23 +08:00
richarjiang
05a00236bc feat: 扩展饮食记录确认流程,支持选择选项和响应处理
- 在教练页面中新增AI选择选项和食物确认选项的数据结构
- 扩展消息结构以支持选择选项和交互类型
- 实现非流式JSON响应的处理逻辑,支持用户确认选择
- 添加选择选项的UI组件,提升用户交互体验
- 更新样式以适应新功能,确保视觉一致性
2025-08-18 17:29:19 +08:00
richarjiang
f8730a90e9 feat: 更新应用信息和权限描述
- 将应用名称修改为“Health Bot”,提升品牌识别度
- 更新相机使用权限描述,简化说明内容以更好地反映功能需求
2025-08-18 15:24:57 +08:00
richarjiang
27267c2f7f feat: 更新教练页面和消息结构,支持附件功能
- 在教练页面中引入附件类型,支持图片、视频和文件的上传和展示
- 重构消息数据结构,确保消息包含附件信息
- 优化消息发送逻辑,支持发送包含图片的消息
- 更新界面样式,提升附件展示效果
- 删除不再使用的旧图标,替换为新的应用图标和启动画面
2025-08-18 15:07:32 +08:00
richarjiang
849447c5da feat: 引入路由常量并更新相关页面导航
- 新增 ROUTES 常量文件,集中管理应用路由
- 更新多个页面的导航逻辑,使用 ROUTES 常量替代硬编码路径
- 修改教练页面和今日训练页面的路由,提升代码可维护性
- 优化标签页和登录页面的导航,确保一致性和易用性
2025-08-18 10:05:22 +08:00
richarjiang
93918366a9 feat: 更新标签页和新增统计页面
- 修改标签页的名称和图标,将“探索”改为“统计”,并更新相关逻辑
- 新增统计页面,展示用户健康数据和历史记录
- 优化首页布局,调整组件显示,提升用户体验
- 删除不再使用的代码,简化项目结构
2025-08-18 08:43:44 +08:00
6a67fb21f7 feat: 更新应用名称和图标,优化用户界面
- 将应用名称修改为“Health Bot”,提升品牌识别度
- 更新应用图标为 logo.png,确保视觉一致性
- 删除不再使用的 ai-coach-chat 页面,简化代码结构
- 更新多个页面的导航和按钮文本,提升用户体验
- 添加体重历史记录功能,支持用户追踪健康数据
- 优化 Redux 状态管理,确保数据处理的准确性和稳定性
2025-08-17 21:34:04 +08:00
b2c4f76c39 perf: version 2025-08-16 14:19:36 +08:00
4c6a0e0399 feat: 完善训练 2025-08-16 14:15:11 +08:00
5a4d86ff7d feat: 更新应用配置和引入新依赖
- 修改 app.json,禁用平板支持以优化用户体验
- 在 package.json 和 package-lock.json 中新增 react-native-toast-message 依赖,支持消息提示功能
- 在多个组件中集成 Toast 组件,提升用户交互反馈
- 更新训练计划相关逻辑,优化状态管理和数据处理
- 调整样式以适应新功能的展示和交互
2025-08-16 09:42:33 +08:00
3312250f2d feat: 添加教练功能和更新用户界面
- 新增教练页面,用户可以与教练进行互动和咨询
- 更新首页,切换到教练 tab 并传递名称参数
- 优化个人信息页面,添加注销帐号和退出登录功能
- 更新隐私政策和用户协议的链接,确保用户在使用前同意相关条款
- 修改今日训练页面标题为“开始训练”,提升用户体验
- 删除不再使用的进度条组件,简化代码结构
2025-08-15 21:38:19 +08:00
97e89b9bf0 feat: 更新隐私同意弹窗和应用名称
- 将应用名称修改为“每日普拉提”,提升品牌识别度
- 新增隐私同意弹窗,确保用户在使用应用前同意隐私政策
- 更新 Redux 状态管理,添加隐私同意状态的处理
- 优化用户信息页面,确保体重和身高的格式化显示
- 更新今日训练页面标题为“快速训练”,提升用户体验
- 添加开发工具函数,便于测试隐私同意功能
2025-08-15 20:44:06 +08:00
richarjiang
6b6c4fdbad feat: 更新训练计划和今日训练页面
- 在训练计划中添加了新的类型定义,优化了排课功能
- 修改了今日训练页面的布局,提升用户体验
- 删除了不再使用的排课相关文件,简化代码结构
- 更新了 Redux 状态管理,确保数据处理的准确性和稳定性
2025-08-15 17:16:39 +08:00
richarjiang
dacbee197c feat: 更新训练计划和打卡功能
- 在训练计划中新增训练项目的添加、更新和删除功能,支持用户灵活管理训练内容
- 优化训练计划排课界面,提升用户体验
- 更新打卡功能,支持按日期加载和展示打卡记录
- 删除不再使用的打卡相关页面,简化代码结构
- 新增今日训练页面,集成今日训练计划和动作展示
- 更新样式以适应新功能的展示和交互
2025-08-15 17:01:33 +08:00
richarjiang
f95401c1ce feat: 添加 BMI 计算和训练计划排课功能
- 新增 BMI 计算工具,支持用户输入体重和身高计算 BMI 值,并根据结果提供分类和建议
- 在训练计划中集成排课功能,允许用户选择和安排训练动作
- 更新个人信息页面,添加出生日期字段,支持用户完善个人资料
- 优化训练计划卡片样式,提升用户体验
- 更新相关依赖,确保项目兼容性和功能完整性
2025-08-15 10:45:37 +08:00
807e185761 feat: 更新应用版本和主题设置
- 将应用版本更新至 1.0.3,修改相关配置文件
- 强制全局使用浅色主题,确保一致的用户体验
- 在训练计划功能中新增激活计划的 API 接口,支持用户激活训练计划
- 优化打卡功能,支持自动同步打卡记录至服务器
- 更新样式以适应新功能的展示和交互
2025-08-14 22:23:45 +08:00
richarjiang
56d4c7fd7f feat: 更新应用图标和启动画面
- 将应用图标更改为 logo.jpeg,更新相关配置文件
- 删除旧的图标文件,确保资源整洁
- 更新启动画面使用新的 logo 图片,提升视觉一致性
- 在训练计划相关功能中集成新的 API 接口,支持训练计划的创建和管理
- 优化 Redux 状态管理,支持训练计划的加载和删除功能
- 更新样式以适应新图标和功能的展示
2025-08-14 19:28:38 +08:00
richarjiang
5d09cc05dc feat: 更新文章功能和相关依赖
- 新增文章详情页面,支持根据文章 ID 加载和展示文章内容
- 添加文章卡片组件,展示推荐文章的标题、封面和阅读量
- 更新文章服务,支持获取文章列表和根据 ID 获取文章详情
- 集成腾讯云 COS SDK,支持文件上传功能
- 优化打卡功能,支持按日期加载和展示打卡记录
- 更新相关依赖,确保项目兼容性和功能完整性
- 调整样式以适应新功能的展示和交互
2025-08-14 16:03:19 +08:00
richarjiang
532cf251e2 feat: 添加会话历史管理功能
- 在 AI 教练聊天界面中新增会话历史查看和选择功能,用户可以查看和选择之前的会话记录
- 实现会话删除功能,用户可以删除不需要的会话记录
- 优化历史会话的加载和展示,提升用户体验
- 更新相关样式以适应新功能的展示和交互
2025-08-14 10:15:02 +08:00
richarjiang
e3e2f1b8c6 feat: 优化 AI 教练聊天和打卡功能
- 在 AI 教练聊天界面中添加会话缓存功能,支持冷启动时恢复聊天记录
- 实现轻量防抖机制,确保会话变动时及时保存缓存
- 在打卡功能中集成按月加载打卡记录,提升用户体验
- 更新 Redux 状态管理,支持打卡记录的按月加载和缓存
- 新增打卡日历页面,允许用户查看每日打卡记录
- 优化样式以适应新功能的展示和交互
2025-08-14 09:57:13 +08:00
richarjiang
7ad26590e5 feat: 更新个人信息和打卡功能
- 在个人信息页面中修改用户姓名字段为“name”,并添加注销帐号功能,支持用户删除帐号及相关数据
- 在打卡页面中集成从后端获取当天打卡列表的功能,确保用户数据的实时同步
- 更新 Redux 状态管理,支持打卡记录的同步和更新
- 新增打卡服务,提供创建、更新和删除打卡记录的 API 接口
- 优化样式以适应新功能的展示和交互
2025-08-13 19:24:03 +08:00
richarjiang
ebc74eb1c8 feat: 优化 AI 教练聊天界面和个人信息页面
- 在 AI 教练聊天界面中添加消息自动滚动功能,提升用户体验
- 更新消息发送逻辑,确保新消息渲染后再滚动
- 在个人信息页面中集成用户资料拉取功能,支持每次聚焦时更新用户信息
- 修改登录页面,增加身份令牌验证,确保安全性
- 更新样式以适应新功能的展示和交互
2025-08-13 16:51:51 +08:00
richarjiang
321947db98 feat: 更新应用版本和集成腾讯云 COS 上传功能
- 将应用版本更新至 1.0.2,修改相关配置文件
- 集成腾讯云 COS 上传功能,新增相关服务和钩子
- 更新 AI 体态评估页面,支持照片上传和评估结果展示
- 添加雷达图组件以展示评估结果
- 更新样式以适应新功能的展示和交互
- 修改登录页面背景效果,提升用户体验
2025-08-13 15:21:54 +08:00
richarjiang
5814044cee feat: 更新 AI 教练聊天界面和个人信息页面
- 在 AI 教练聊天界面中添加训练记录分析功能,允许用户基于近期训练记录获取分析建议
- 更新 Redux 状态管理,集成每日步数和卡路里目标
- 在个人信息页面中优化用户头像显示,支持从库中选择头像
- 修改首页布局,添加可拖动的教练徽章,提升用户交互体验
- 更新样式以适应新功能的展示和交互
2025-08-13 10:09:55 +08:00
richarjiang
f3e6250505 feat: 添加训练计划和打卡功能
- 新增训练计划页面,允许用户制定个性化的训练计划
- 集成打卡功能,用户可以记录每日的训练情况
- 更新 Redux 状态管理,添加训练计划相关的 reducer
- 在首页中添加训练计划卡片,支持用户点击跳转
- 更新样式和布局,以适应新功能的展示和交互
- 添加日期选择器和相关依赖,支持用户选择训练日期
2025-08-13 09:10:00 +08:00
e0e000b64f feat: 移除个人信息页面中的减脂计划显示
- 在个人信息页面中移除减脂计划的文本显示
- 更新样式,删除相关的样式定义
2025-08-12 22:57:12 +08:00
5f05abc3d5 feat: 添加挑战页面和相关功能
- 在布局中新增挑战页面的导航
- 在首页中添加挑战计划卡片,支持用户点击跳转
- 更新登录页面的标题样式,调整字体粗细
- 集成 Redux 状态管理,新增挑战相关的 reducer
2025-08-12 22:54:23 +08:00
00ddec25c5 feat: 集成 Redux 状态管理和用户目标管理功能
- 添加 Redux 状态管理,支持用户登录和个人信息的持久化
- 新增目标管理页面,允许用户设置每日卡路里和步数目标
- 更新首页,移除旧的活动展示,改为固定的热点功能卡片
- 修改布局以适应新功能的展示和交互
- 更新依赖,添加 @reduxjs/toolkit 和 react-redux 库以支持状态管理
- 新增 API 服务模块,处理与后端的交互
2025-08-12 22:22:30 +08:00
richarjiang
c3d4630801 feat: 添加用户登录和法律协议页面
- 新增登录页面,支持 Apple 登录和游客登录功能
- 添加用户协议和隐私政策页面,用户需同意后才能登录
- 更新首页逻辑,首次进入时自动跳转到登录页面
- 修改个人信息页面,移除单位选择功能,统一使用 kg 和 cm
- 更新依赖,添加 expo-apple-authentication 库以支持 Apple 登录
- 更新布局以适应新功能的展示和交互
2025-08-12 19:21:07 +08:00
richarjiang
8ffebfb297 feat: 更新健康数据功能和用户个人信息页面
- 在 Explore 页面中添加日期选择功能,允许用户查看指定日期的健康数据
- 重构健康数据获取逻辑,支持根据日期获取健康数据
- 在个人信息页面中集成用户资料编辑功能,支持姓名、性别、年龄、体重和身高的输入
- 新增 AnimatedNumber 和 CircularRing 组件,优化数据展示效果
- 更新 package.json 和 package-lock.json,添加 react-native-svg 依赖
- 修改布局以支持新功能的显示和交互
2025-08-12 18:54:15 +08:00
491 changed files with 105123 additions and 6830 deletions

View File

@@ -0,0 +1,12 @@
# kilo-rule.md
永远记得你是一名专业的 reac native 工程师,并且当前项目是一个 prebuild 之后的 expo react native 项目,应用场景永远是 ios不要考虑 android, 代码设计优美、可读性高
## 指导原则
- 遇到比较复杂的页面,尽量使用可以复用的组件
- 不要尝试使用 `npm run ios` 命令
- 优先使用 Liquid Glass 风格组件
- 注重代码的可读性,尽量增加注释
- 不要随意新增 md 文档

View File

@@ -0,0 +1,167 @@
# Memory Bank
I am an expert software engineer with a unique characteristic: my memory resets completely between sessions. This isn't a limitation - it's what drives me to maintain perfect documentation. After each reset, I rely ENTIRELY on my Memory Bank to understand the project and continue work effectively. I MUST read ALL memory bank files at the start of EVERY task - this is not optional. The memory bank files are located in `.kilocode/rules/memory-bank` folder.
When I start a task, I will include `[Memory Bank: Active]` at the beginning of my response if I successfully read the memory bank files, or `[Memory Bank: Missing]` if the folder doesn't exist or is empty. If memory bank is missing, I will warn the user about potential issues and suggest initialization.
## Memory Bank Structure
The Memory Bank consists of core files and optional context files, all in Markdown format.
### Core Files (Required)
1. `brief.md`
This file is created and maintained manually by the developer. Don't edit this file directly but suggest to user to update it if it can be improved.
- Foundation document that shapes all other files
- Created at project start if it doesn't exist
- Defines core requirements and goals
- Source of truth for project scope
2. `product.md`
- Why this project exists
- Problems it solves
- How it should work
- User experience goals
3. `context.md`
This file should be short and factual, not creative or speculative.
- Current work focus
- Recent changes
- Next steps
4. `architecture.md`
- System architecture
- Source Code paths
- Key technical decisions
- Design patterns in use
- Component relationships
- Critical implementation paths
5. `tech.md`
- Technologies used
- Development setup
- Technical constraints
- Dependencies
- Tool usage patterns
### Additional Files
Create additional files/folders within memory-bank/ when they help organize:
- `tasks.md` - Documentation of repetitive tasks and their workflows
- Complex feature documentation
- Integration specifications
- API documentation
- Testing strategies
- Deployment procedures
## Core workflows
### Memory Bank Initialization
The initialization step is CRITICALLY IMPORTANT and must be done with extreme thoroughness as it defines all future effectiveness of the Memory Bank. This is the foundation upon which all future interactions will be built.
When user requests initialization of the memory bank (command `initialize memory bank`), I'll perform an exhaustive analysis of the project, including:
- All source code files and their relationships
- Configuration files and build system setup
- Project structure and organization patterns
- Documentation and comments
- Dependencies and external integrations
- Testing frameworks and patterns
I must be extremely thorough during initialization, spending extra time and effort to build a comprehensive understanding of the project. A high-quality initialization will dramatically improve all future interactions, while a rushed or incomplete initialization will permanently limit my effectiveness.
After initialization, I will ask the user to read through the memory bank files and verify product description, used technologies and other information. I should provide a summary of what I've understood about the project to help the user verify the accuracy of the memory bank files. I should encourage the user to correct any misunderstandings or add missing information, as this will significantly improve future interactions.
### Memory Bank Update
Memory Bank updates occur when:
1. Discovering new project patterns
2. After implementing significant changes
3. When user explicitly requests with the phrase **update memory bank** (MUST review ALL files)
4. When context needs clarification
If I notice significant changes that should be preserved but the user hasn't explicitly requested an update, I should suggest: "Would you like me to update the memory bank to reflect these changes?"
To execute Memory Bank update, I will:
1. Review ALL project files
2. Document current state
3. Document Insights & Patterns
4. If requested with additional context (e.g., "update memory bank using information from @/Makefile"), focus special attention on that source
Note: When triggered by **update memory bank**, I MUST review every memory bank file, even if some don't require updates. Focus particularly on context.md as it tracks current state.
### Add Task
When user completes a repetitive task (like adding support for a new model version) and wants to document it for future reference, they can request: **add task** or **store this as a task**.
This workflow is designed for repetitive tasks that follow similar patterns and require editing the same files. Examples include:
- Adding support for new AI model versions
- Implementing new API endpoints following established patterns
- Adding new features that follow existing architecture
Tasks are stored in the file `tasks.md` in the memory bank folder. The file is optional and can be empty. The file can store many tasks.
To execute Add Task workflow:
1. Create or update `tasks.md` in the memory bank folder
2. Document the task with:
- Task name and description
- Files that need to be modified
- Step-by-step workflow followed
- Important considerations or gotchas
- Example of the completed implementation
3. Include any context that was discovered during task execution but wasn't previously documented
Example task entry:
```markdown
## Add New Model Support
**Last performed:** [date]
**Files to modify:**
- `/providers/gemini.md` - Add model to documentation
- `/src/providers/gemini-config.ts` - Add model configuration
- `/src/constants/models.ts` - Add to model list
- `/tests/providers/gemini.test.ts` - Add test cases
**Steps:**
1. Add model configuration with proper token limits
2. Update documentation with model capabilities
3. Add to constants file for UI display
4. Write tests for new model configuration
**Important notes:**
- Check Google's documentation for exact token limits
- Ensure backward compatibility with existing configurations
- Test with actual API calls before committing
```
### Regular Task Execution
In the beginning of EVERY task I MUST read ALL memory bank files - this is not optional.
The memory bank files are located in `.kilocode/rules/memory-bank` folder. If the folder doesn't exist or is empty, I will warn user about potential issues with the memory bank. I will include `[Memory Bank: Active]` at the beginning of my response if I successfully read the memory bank files, or `[Memory Bank: Missing]` if the folder doesn't exist or is empty. If memory bank is missing, I will warn the user about potential issues and suggest initialization. I should briefly summarize my understanding of the project to confirm alignment with the user's expectations, like:
"[Memory Bank: Active] I understand we're building a React inventory system with barcode scanning. Currently implementing the scanner component that needs to work with the backend API."
When starting a task that matches a documented task in `tasks.md`, I should mention this and follow the documented workflow to ensure no steps are missed.
If the task was repetitive and might be needed again, I should suggest: "Would you like me to add this task to the memory bank for future reference?"
In the end of the task, when it seems to be completed, I will update `context.md` accordingly. If the change seems significant, I will suggest to the user: "Would you like me to update memory bank to reflect these changes?" I will not suggest updates for minor changes.
## Context Window Management
When the context window fills up during an extended session:
1. I should suggest updating the memory bank to preserve the current state
2. Recommend starting a fresh conversation/task
3. In the new conversation, I will automatically load the memory bank files to maintain continuity
## Technical Implementation
Memory Bank is built on Kilo Code's Custom Rules feature, with files stored as standard markdown documents that both the user and I can access.
## Important Notes
REMEMBER: After every memory reset, I begin completely fresh. The Memory Bank is my only link to previous work. It must be maintained with precision and clarity, as my effectiveness depends entirely on its accuracy.
If I detect inconsistencies between memory bank files, I should prioritize brief.md and note any discrepancies to the user.
IMPORTANT: I MUST read ALL memory bank files at the start of EVERY task - this is not optional. The memory bank files are located in `.kilocode/rules/memory-bank` folder.

View File

@@ -0,0 +1,304 @@
# 系统架构
## 整体架构概览
Out Live 采用典型的 React Native 应用架构,基于 Expo Prebuild 构建原生 iOS 应用。应用采用分层架构设计,包含表现层、业务逻辑层、数据访问层和基础设施层。
```mermaid
graph TB
A[用户界面层] --> B[业务逻辑层]
B --> C[数据访问层]
C --> D[基础设施层]
A1[React Components] --> A
A2[Expo Router] --> A
A3[Liquid Glass UI] --> A
B1[Redux Store] --> B
B2[Business Services] --> B
B3[Hooks] --> B
C1[API Services] --> C
C2[HealthKit Bridge] --> C
C3[Local Storage] --> C
D1[Expo SDK] --> D
D2[Native Modules] --> D
D3[Third-party SDKs] --> D
```
## 目录结构
```
digital-pilates/
├── app/ # Expo Router 页面和路由
│ ├── (tabs)/ # 标签页路由
│ ├── auth/ # 认证相关页面
│ ├── challenges/ # 挑战页面
│ ├── fasting/ # 轻断食页面
│ ├── food/ # 营养相关页面
│ ├── profile/ # 用户资料页面
│ ├── workout/ # 训练页面
│ └── _layout.tsx # 根布局组件
├── components/ # 可复用组件
│ ├── ui/ # 基础 UI 组件
│ ├── cards/ # 卡片组件
│ └── forms/ # 表单组件
├── services/ # 业务服务层
│ ├── api.ts # API 基础服务
│ ├── healthKit/ # HealthKit 集成
│ ├── notifications/ # 通知服务
│ └── aiCoach/ # AI 教练服务
├── store/ # Redux 状态管理
│ ├── slices/ # Redux Toolkit slices
│ └── index.ts # Store 配置
├── utils/ # 工具函数
│ ├── health.ts # 健康数据处理
│ ├── kvStore.ts # 本地存储
│ └── notificationHelpers.ts
├── constants/ # 常量定义
│ ├── Colors.ts # 颜色主题
│ ├── Routes.ts # 路由常量
│ └── Api.ts # API 配置
├── hooks/ # 自定义 Hooks
├── types/ # TypeScript 类型定义
├── assets/ # 静态资源
└── ios/ # iOS 原生代码
```
## 核心架构组件
### 1. 路由架构 (Expo Router)
采用 Expo Router 6.0 实现文件系统路由:
- **标签页导航**: 5个主要标签页健康、断食、习惯、挑战、个人
- **模态页面**: 认证、目标详情、设置等页面
- **嵌套路由**: 支持复杂的页面层级结构
- **类型安全**: 完全的 TypeScript 路由类型支持
### 2. 状态管理架构 (Redux Toolkit)
使用 Redux Toolkit 进行应用状态管理:
```mermaid
graph LR
A[UI Components] --> B[Redux Actions]
B --> C[Redux Slices]
C --> D[Redux Store]
D --> A
E[Async Thunks] --> C
F[Middleware] --> C
G[Selectors] --> A
```
**核心 Slices**:
- `userSlice`: 用户信息和认证状态
- `healthSlice`: 健康数据步数、心率、HRV等
- `nutritionSlice`: 营养数据和饮食记录
- `goalsSlice`: 目标和任务管理
- `fastingSlice`: 轻断食状态和计划
- `moodSlice`: 心情记录和分析
- `workoutSlice`: 训练数据和计划
### 3. 组件架构
采用组件化设计,按功能域组织:
```mermaid
graph TD
A[页面组件] --> B[容器组件]
B --> C[业务组件]
C --> D[UI 组件]
E[Hooks] --> B
F[Redux Selectors] --> B
G[Services] --> C
```
**组件层次**:
- **页面组件**: 路由对应的顶级组件
- **容器组件**: 处理数据获取和状态管理
- **业务组件**: 封装特定业务逻辑的组件
- **UI 组件**: 纯展示的可复用组件
### 4. 服务层架构
服务层负责业务逻辑和外部系统集成:
```mermaid
graph TB
A[Components] --> B[Service Layer]
B --> C[API Services]
B --> D[HealthKit Services]
B --> E[Notification Services]
B --> F[Storage Services]
C --> G[Remote API]
D --> H[Native HealthKit]
E --> I[Expo Notifications]
F --> J[Local Storage]
```
**核心服务**:
- `api.ts`: RESTful API 客户端
- `health.ts`: HealthKit 数据处理
- `notifications.ts`: 通知管理
- `aiCoach.ts`: AI 教练集成
- `waterRecords.ts`: 饮水记录管理
### 5. 数据流架构
采用单向数据流设计:
```mermaid
graph LR
A[User Action] --> B[Component Event]
B --> C[Redux Action]
C --> D[Service Call]
D --> E[API/HealthKit]
E --> F[Data Update]
F --> G[Redux State]
G --> H[Component Re-render]
```
## 关键设计模式
### 1. Repository 模式
数据访问层使用 Repository 模式抽象数据源:
```typescript
// 示例:健康数据 Repository
class HealthRepository {
async getSteps(date: Date): Promise<number> {
return fetchStepCount(date);
}
async getHeartRate(date: Date): Promise<number | null> {
return fetchHeartRate(date);
}
}
```
### 2. Observer 模式
通知系统使用观察者模式:
```typescript
// 权限状态监听
healthPermissionManager.on('permissionStatusChanged', (status) => {
// 处理权限状态变化
});
```
### 3. Strategy 模式
不同类型的数据处理使用策略模式:
```typescript
// 营养数据计算策略
interface NutritionStrategy {
calculate(records: DietRecord[]): NutritionSummary;
}
```
### 4. Factory 模式
组件创建使用工厂模式:
```typescript
// 卡片组件工厂
const CardFactory = {
createHealthCard: (props) => <HealthCard {...props} />,
createNutritionCard: (props) => <NutritionCard {...props} />,
};
```
## 性能优化架构
### 1. 组件优化
- **React.memo**: 防止不必要的重渲染
- **useMemo/useCallback**: 缓存计算结果和函数
- **FlatList**: 大列表虚拟化
- **InteractionManager**: 延迟非关键渲染
### 2. 数据优化
- **Redux Toolkit**: 自动化的状态优化
- **数据分页**: 大数据集分页加载
- **缓存策略**: 智能数据缓存
- **后台同步**: 异步数据同步
### 3. 资源优化
- **图片优化**: WebP 格式和懒加载
- **Bundle 分割**: 按需加载代码
- **内存管理**: 及时释放资源
- **网络优化**: 请求合并和缓存
## 安全架构
### 1. 数据安全
- **Token 管理**: JWT Token 安全存储
- **API 加密**: HTTPS 通信
- **数据脱敏**: 敏感数据处理
- **权限控制**: 细粒度权限管理
### 2. 隐私保护
- **本地加密**: 敏感数据本地加密存储
- **权限最小化**: 只请求必要的系统权限
- **数据匿名化**: 统计数据匿名化处理
- **用户控制**: 用户数据删除和导出
## 扩展性设计
### 1. 模块化架构
- **功能模块**: 独立的功能模块设计
- **插件系统**: 支持功能插件扩展
- **配置驱动**: 配置化的功能开关
- **版本兼容**: 向后兼容的 API 设计
### 2. 多端支持
- **跨平台**: React Native 跨平台能力
- **响应式**: 适配不同屏幕尺寸
- **主题系统**: 可切换的主题设计
- **国际化**: 多语言支持框架
## 监控和诊断
### 1. 错误监控
- **Sentry 集成**: 错误收集和分析
- **崩溃报告**: 自动崩溃报告
- **性能监控**: 应用性能指标
- **用户反馈**: 内置反馈系统
### 2. 日志系统
- **分级日志**: 不同级别的日志记录
- **结构化日志**: 便于分析的日志格式
- **远程日志**: 日志远程收集
- **隐私保护**: 敏感信息过滤
## 测试架构
### 1. 测试策略
- **单元测试**: 核心逻辑单元测试
- **集成测试**: 组件集成测试
- **端到端测试**: 关键流程 E2E 测试
- **性能测试**: 性能基准测试
### 2. 测试工具
- **Jest**: 单元测试框架
- **React Native Testing Library**: 组件测试
- **Detox**: E2E 测试框架
- **Flamegraph**: 性能分析工具

View File

@@ -0,0 +1 @@
一个 expo prebuild 之后的 react native 应用,只服务 ios 端,主要面向的是健康、减肥、瘦身、生活习惯养成的人群

View File

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

View File

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

View File

@@ -0,0 +1,482 @@
# 常见任务和模式
## 图标库使用规范 - 禁止使用 MaterialIcons
**最后更新**: 2025-10-24
### 重要规则
**项目中不允许使用 MaterialIcons**,所有图标必须使用 Ionicons 以保持图标库的一致性。
### 问题描述
在项目中发现使用 MaterialIcons 的情况,需要将所有 MaterialIcons 替换为 Ionicons以保持图标库的一致性。
### 解决方案
将所有 MaterialIcons 导入和使用替换为对应的 Ionicons。
### 实现模式
#### 1. 替换导入语句
```typescript
// ❌ 禁止使用
import { MaterialIcons } from '@expo/vector-icons';
// ✅ 正确写法
import { Ionicons } from '@expo/vector-icons';
```
#### 2. 替换图标名称和属性
```typescript
// ❌ 禁止使用
<MaterialIcons name="arrow-back-ios" size={20} color="#333" />
// ✅ 正确写法 - 使用 HeaderBar 中的返回按钮实现
<Ionicons name="chevron-back" size={24} color="#333" />
```
#### 3. 常见图标映射
- `arrow-back-ios``chevron-back` (返回按钮)
- `auto-awesome``star` (星星/自动推荐)
- `tips-and-updates``bulb` (提示/建议)
- `fact-check``checkbox` (检查/确认)
- `check-circle``checkmark-circle` (勾选圆圈)
- `remove``remove` (移除/删除,名称相同)
### 重要注意事项
1. **图标大小调整**Ionicons 和 MaterialIcons 的默认大小可能不同,需要适当调整
2. **图标名称差异**:两个图标库的图标名称不同,需要找到对应的功能图标
3. **样式一致性**:确保替换后的图标在视觉上与原设计保持一致
4. **Liquid Glass 兼容性**:替换后的图标需要继续支持 Liquid Glass 效果
5. **代码审查**:在代码审查中需要特别检查是否使用了 MaterialIcons
### 参考实现
- `components/ui/HeaderBar.tsx` - 返回按钮的标准实现
- `components/model/MembershipModal.tsx` - 完整的 MaterialIcons 替换示例
## 按钮组件 Liquid Glass 兼容性
**最后更新**: 2025-10-24
### 重要原则
**所有按钮组件都需要尝试兼容 Liquid Glass**,这是项目的设计要求。
### 实现模式
#### 1. 导入必要的组件
```typescript
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
```
#### 2. 检查设备支持情况
```typescript
const isGlassAvailable = isLiquidGlassAvailable();
```
#### 3. 实现条件渲染的按钮
```typescript
<TouchableOpacity
onPress={handlePress}
disabled={isLoading}
activeOpacity={0.7}
>
{isLiquidGlassAvailable() ? (
<GlassView
style={styles.button}
glassEffectStyle="clear" // 或 "regular"
tintColor="rgba(255, 255, 255, 0.3)" // 自定义色调
isInteractive={true} // 启用交互反馈
>
<Ionicons name="icon-name" size={20} color="#333" />
</GlassView>
) : (
<View style={[styles.button, styles.fallbackButton]}>
<Ionicons name="icon-name" size={20} color="#333" />
</View>
)}
</TouchableOpacity>
```
#### 4. 定义样式
```typescript
const styles = StyleSheet.create({
button: {
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)',
},
});
```
### 重要注意事项
1. **兼容性检查**:必须使用 `isLiquidGlassAvailable()` 检查设备支持情况
2. **overflow: 'hidden'**GlassView 组件需要设置此属性以保证圆角效果
3. **降级样式**:为不支持 Liquid Glass 的设备提供视觉上相似的替代方案
4. **交互反馈**:设置 `isInteractive={true}` 启用原生的触觉反馈
5. **图标居中**:确保使用 `alignItems: 'center'``justifyContent: 'center'` 使图标完全居中
6. **色调自定义**:通过 `tintColor` 属性自定义按钮的颜色主题
### 常用配置
- **glassEffectStyle**: "clear"(透明)或 "regular"(常规)
- **tintColor**: 根据按钮功能选择合适的颜色
- 返回/导航操作:白色系 `rgba(255, 255, 255, 0.3)`
- 删除操作:红色系 `rgba(244, 67, 54, 0.2)`
- 确认操作:绿色系 `rgba(76, 175, 80, 0.2)`
- 信息操作:蓝色系 `rgba(33, 150, 243, 0.2)`
### 参考实现
- `components/model/MembershipModal.tsx` - 悬浮返回按钮
- `components/glass/button.tsx` - 通用 Glass 按钮组件
- `app/(tabs)/_layout.tsx` - 标签栏按钮实现
## HeaderBar 顶部距离处理
**最后更新**: 2025-10-16
### 问题描述
当使用 HeaderBar 组件时,需要正确处理内容区域的顶部距离,确保内容不会被状态栏或刘海屏遮挡。
### 解决方案
使用 `useSafeAreaTop` hook 获取安全区域顶部距离,并应用到内容容器的样式中。
### 实现模式
#### 1. 导入必要的 hook
```typescript
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
```
#### 2. 在组件中获取 safeAreaTop
```typescript
const safeAreaTop = useSafeAreaTop()
```
#### 3. 应用到内容容器
```typescript
// 方式1: 直接应用到 View 组件
<View style={[styles.filterContainer, { paddingTop: safeAreaTop }]}>
// 方式2: 应用到 ScrollView 的 contentContainerStyle
<ScrollView
contentContainerStyle={{ paddingTop: safeAreaTop }}
>
// 方式3: 应用到 SectionList 的 style
<SectionList
style={{ paddingTop: safeAreaTop }}
>
```
### 重要注意事项
1. **不要在 StyleSheet 中使用变量**:不能在 `StyleSheet.create()` 中直接使用 `safeAreaTop` 变量
2. **使用动态样式**:必须通过内联样式或数组样式的方式动态应用 `safeAreaTop`
3. **不需要额外偏移**:通常只需要 `safeAreaTop`,不需要添加额外的固定像素值
### 示例代码
```typescript
// ❌ 错误写法 - 在 StyleSheet 中使用变量
const styles = StyleSheet.create({
filterContainer: {
paddingTop: safeAreaTop, // 这会导致错误
},
});
// ✅ 正确写法 - 使用动态样式
<View style={[styles.filterContainer, { paddingTop: safeAreaTop }]}>
```
### 参考页面
- `app/steps/detail.tsx`
- `app/water/detail.tsx`
- `app/profile/goals.tsx`
- `app/workout/history.tsx`
- `app/challenges/[id]/leaderboard.tsx`
## Liquid Glass 风格图标按钮实现
**最后更新**: 2025-10-16
### 问题描述
在应用中实现符合 Liquid Glass 设计风格的图标按钮,需要考虑毛玻璃效果和兼容性处理。
### 解决方案
使用 `GlassView` 组件实现毛玻璃效果,并提供不支持 Liquid Glass 的设备的降级方案。
### 实现模式
#### 1. 导入必要的组件和函数
```typescript
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
```
#### 2. 检查设备支持情况
```typescript
const isGlassAvailable = isLiquidGlassAvailable();
```
#### 3. 实现条件渲染的按钮
```typescript
<TouchableOpacity
onPress={handlePress}
disabled={isLoading}
activeOpacity={0.7}
>
{isGlassAvailable ? (
<GlassView
style={styles.glassButton}
glassEffectStyle="clear" // 或 "regular"
tintColor="rgba(244, 67, 54, 0.2)" // 自定义色调
isInteractive={true} // 启用交互反馈
>
<Ionicons name="trash-outline" size={20} color="#F44336" />
</GlassView>
) : (
<View style={[styles.glassButton, styles.fallbackButton]}>
<Ionicons name="trash-outline" size={20} color="#F44336" />
</View>
)}
</TouchableOpacity>
```
#### 4. 定义样式
```typescript
const styles = StyleSheet.create({
glassButton: {
width: 36,
height: 36,
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)',
},
});
```
### 重要注意事项
1. **兼容性处理**:必须使用 `isLiquidGlassAvailable()` 检查设备支持情况
2. **overflow: 'hidden'**GlassView 组件需要设置此属性以保证圆角效果
3. **降级样式**:为不支持 Liquid Glass 的设备提供视觉上相似的替代方案
4. **交互反馈**:设置 `isInteractive={true}` 启用原生的触觉反馈
5. **色调自定义**:通过 `tintColor` 属性自定义按钮的颜色主题
### 常用配置
- **glassEffectStyle**: "clear"(透明)或 "regular"(常规)
- **tintColor**: 根据按钮功能选择合适的颜色
- 删除操作:红色系 `rgba(244, 67, 54, 0.2)`
- 确认操作:绿色系 `rgba(76, 175, 80, 0.2)`
- 信息操作:蓝色系 `rgba(33, 150, 243, 0.2)`
### 参考实现
- `app/food/nutrition-analysis-history.tsx` - 删除按钮实现
- `components/glass/button.tsx` - 通用 Glass 按钮组件
- `app/(tabs)/_layout.tsx` - 标签栏按钮实现
## 登录验证实现模式
**最后更新**: 2025-10-16
### 问题描述
在应用中实现需要登录才能访问的功能时,需要判断用户是否已登录,未登录时先跳转到登录页面。
### 解决方案
使用 `useAuthGuard` hook 中的 `pushIfAuthedElseLogin` 方法处理需要登录验证的导航操作,使用 `ensureLoggedIn` 方法处理需要登录验证的功能实现。
### 权限校验原则
**重要**: 功能实现如果包含服务端接口的调用,需要使用 `ensureLoggedIn` 来判断用户是否登录。
### 实现模式
#### 1. 导入必要的 hook
```typescript
import { useAuthGuard } from '@/hooks/useAuthGuard';
```
#### 2. 在组件中获取方法
```typescript
const { pushIfAuthedElseLogin, ensureLoggedIn } = useAuthGuard();
```
#### 3. 替换导航操作
```typescript
// ❌ 原来的写法 - 没有登录验证
<TouchableOpacity
onPress={() => router.push('/food/nutrition-analysis-history')}
activeOpacity={0.7}
>
// ✅ 修改后的写法 - 带登录验证
<TouchableOpacity
onPress={() => pushIfAuthedElseLogin('/food/nutrition-analysis-history')}
activeOpacity={0.7}
>
```
#### 4. 服务端接口调用的登录验证
对于需要调用服务端接口的功能,使用 `ensureLoggedIn` 进行登录验证:
```typescript
// ❌ 原来的写法 - 没有登录验证
<TouchableOpacity
onPress={() => startNewAnalysis(imageUri)}
activeOpacity={0.8}
>
// ✅ 修改后的写法 - 带登录验证
<TouchableOpacity
onPress={async () => {
// 先验证登录状态
const isLoggedIn = await ensureLoggedIn();
if (isLoggedIn) {
startNewAnalysis(imageUri);
}
}}
activeOpacity={0.8}
>
```
#### 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}
>
<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>
)}
```
### 重要注意事项
1. **统一体验**:使用 `pushIfAuthedElseLogin` 可以确保登录后自动跳转到目标页面
2. **参数传递**:该方法支持传递路由参数,格式为 `pushIfAuthedElseLogin('/path', { param: value })`
3. **登录重定向**:登录页面会接收 `redirectTo``redirectParams` 参数用于登录后跳转
4. **兼容性**:与 Liquid Glass 设计风格完全兼容,可以同时使用
5. **服务端接口调用**:所有调用服务端接口的功能必须使用 `ensureLoggedIn` 进行登录验证
6. **异步处理**`ensureLoggedIn` 是异步函数,需要使用 `await` 等待结果
### 其他可用方法
- `ensureLoggedIn()` - 检查登录状态,未登录时跳转到登录页面,返回布尔值表示是否已登录
- `guardHandler(fn, options)` - 包装一个函数,在执行前确保用户已登录
- `isLoggedIn` - 布尔值,表示当前用户是否已登录
### 使用场景选择
- **页面导航**:使用 `pushIfAuthedElseLogin` 处理页面跳转
- **服务端接口调用**:使用 `ensureLoggedIn` 验证登录状态后再执行功能
- **函数包装**:使用 `guardHandler` 包装需要登录验证的函数
### 参考实现
- `app/food/nutrition-label-analysis.tsx` - 成分表分析功能登录验证
- `app/(tabs)/personal.tsx` - 个人中心编辑按钮
- `hooks/useAuthGuard.ts` - 完整的认证守卫实现
## 路由常量管理
**最后更新**: 2025-10-16
### 问题描述
在应用开发中,所有路由路径都应该使用常量定义,而不是硬编码字符串。这样可以确保路由的一致性,便于维护和重构。
### 解决方案
将所有路由路径定义在 `constants/Routes.ts` 文件中,并在组件中使用这些常量。
### 实现模式
#### 1. 添加新路由常量
`constants/Routes.ts` 文件中添加新的路由常量:
```typescript
export const ROUTES = {
// 现有路由...
// 新增路由
FOOD_CAMERA: '/food/camera',
} as const;
```
#### 2. 在组件中使用路由常量
导入并使用路由常量,而不是硬编码路径:
```typescript
import { ROUTES } from '@/constants/Routes';
// ❌ 错误写法 - 硬编码路径
router.push('/food/camera?mealType=dinner');
// ✅ 正确写法 - 使用路由常量
router.push(`${ROUTES.FOOD_CAMERA}?mealType=dinner`);
```
#### 3. 结合登录验证使用
对于需要登录验证的路由,结合 `pushIfAuthedElseLogin` 使用:
```typescript
import { ROUTES } from '@/constants/Routes';
import { useAuthGuard } from '@/hooks/useAuthGuard';
const { pushIfAuthedElseLogin } = useAuthGuard();
// 在需要登录验证的路由中使用
<TouchableOpacity
onPress={() => pushIfAuthedElseLogin(`${ROUTES.FOOD_CAMERA}?mealType=${currentMealType}`)}
activeOpacity={0.7}
>
```
### 重要注意事项
1. **统一管理**:所有路由路径都必须在 `constants/Routes.ts` 中定义
2. **命名规范**:使用大写字母和下划线,如 `FOOD_CAMERA`
3. **路径一致性**:常量名应该清晰表达路由的用途
4. **参数处理**:查询参数和路径参数在使用时动态拼接
5. **类型安全**:使用 `as const` 确保类型推导
### 路由分类
按照功能模块对路由进行分组:
```typescript
export const ROUTES = {
// Tab路由
TAB_EXPLORE: '/explore',
TAB_COACH: '/coach',
// 营养相关路由
NUTRITION_RECORDS: '/nutrition/records',
FOOD_LIBRARY: '/food-library',
FOOD_CAMERA: '/food/camera',
// 用户相关路由
AUTH_LOGIN: '/auth/login',
PROFILE_EDIT: '/profile/edit',
} as const;
```
### 参考实现
- `constants/Routes.ts` - 路由常量定义
- `components/NutritionRadarCard.tsx` - 使用路由常量和登录验证
- `app/food/camera.tsx` - 食物拍照页面实现

View File

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

View File

@@ -3,5 +3,6 @@
"source.fixAll": "explicit",
"source.organizeImports": "explicit",
"source.sortMembers": "explicit"
}
},
"kiroAgent.configureMCP": "Enabled"
}

32
AGENTS.md Normal file
View File

@@ -0,0 +1,32 @@
# Repository Guidelines
## Project Structure & Module Organization
- `app/` holds Expo Router screens; tab flows live in `app/(tabs)/`, while modal or detail pages sit alongside feature folders.
- Shared UI and domain logic belong in `components/`, `services/`, and `utils/`; Redux state is organized per feature under `store/`.
- Native iOS code (HealthKit bridge, widgets, quick actions) resides in `ios/`; design and process docs are tracked in `docs/`.
- Assets, fonts, and icons live in `assets/`; keep new media optimized and referenced via `@/assets` aliases.
## Build, Test, and Development Commands
- `npm run ios` / `npm run ios-device` builds and runs the prebuilt iOS app in Simulator or on a connected device.
- `npm run reset-project` clears caches and regenerates native artifacts; use after dependency or native module changes.
## Coding Style & Naming Conventions
- TypeScript with React hooks is standard; use functional components and keep state in Redux slices if shared.
- Follow ESLint (`eslint-config-expo`) and default Prettier formatting (2 spaces, trailing commas, single quotes).
- Name components in `PascalCase`, hooks/utilities in `camelCase`, and screen files with kebab-case (e.g., `ai-posture-assessment.tsx`).
- Co-locate feature assets, styles, and tests to simplify maintenance.
## Testing Guidelines
- Automated tests are minimal; add Jest + React Native Testing Library specs under `__tests__/` or alongside modules when adding complex logic.
- For health and native bridges, include reproduction steps and Simulator logs in PR descriptions.
- Always run linting and verify critical flows on an iOS simulator (HealthKit requires a real device for full validation).
## Commit & Pull Request Guidelines
- Prefer Conventional Commit prefixes (`feat`, `fix`, `chore`, etc.) with optional scope: `feat(water): 支持自定义提醒`. Keep summaries under 80 characters.
- Group related changes; avoid bundling unrelated features and formatting in one commit.
- PRs should describe the problem, solution, test evidence (commands run, screenshots, or screen recordings), and note any iOS-specific setup.
- Link to Linear/Jira issues where relevant and request review from feature owners or the iOS platform team.
## iOS Integration Notes
- HealthKit, widgets, and quick actions depend on native modules: update `ios/` and re-run `npm run ios` after modifying Swift or entitlement files.
- Keep App Group IDs, bundle identifiers, and signing assets consistent with `app.json` and `ios/digitalpilates.xcodeproj`; coordinate credential changes with release engineering.

129
CLAUDE.md
View File

@@ -3,17 +3,122 @@
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Commands
- **Start development server**: `npm start`
- **Run on Android**: `npm run android`
- **Run on iOS**: `npm run ios`
- **Run on Web**: `npm run web`
- **Lint**: `npm run lint`
- **Reset project**: `npm run reset-project`
### Development
- **`npm run ios`** - Build and run on iOS Simulator (iOS-only deployment)
### Testing
- Automated testing is minimal; complex logic should include Jest + React Native Testing Library specs under `__tests__/` directories or alongside modules
## Architecture
- **Framework**: React Native (Expo) with TypeScript.
- **Navigation**: Expo Router for file-based routing (`app/` directory).
- **UI**: Themed components (`ThemedText`, `ThemedView`) and reusable UI elements (`Collapsible`, `ParallaxScrollView`).
- **Platform-Specific**: Android (`android/`) and iOS (`ios/`) configurations with native modules.
- **Hooks**: Custom hooks for color scheme (`useColorScheme`) and theme management (`useThemeColor`).
- **Dependencies**: React Navigation for tab-based navigation, Expo modules for native features (haptics, blur, etc.).
### Core Stack
- **Framework**: React Native (Expo Prebuild/Ejected) with TypeScript
- **Navigation**: Expo Router with file-based routing in `app/` directory
- **State Management**: Redux Toolkit with domain-specific slices in `store/`
- **Styling**: Custom theme system with light/dark mode support
### Directory Structure
- **`app/`** - Expo Router screens; tab flows in `app/(tabs)/`, feature-specific pages in nested directories
- **`store/`** - Redux slices organized by feature (user, nutrition, workout, etc.)
- **`services/`** - API services, backend integration, and data layer logic
- **`components/`** - Reusable UI components and domain-specific components
- **`hooks/`** - Custom React hooks including typed Redux hooks (`hooks/redux.ts`)
- **`utils/`** - Utility functions (health data, notifications, fasting, etc.)
- **`contexts/`** - React Context providers (ToastContext, MembershipModalContext)
- **`constants/`** - Route definitions (`Routes.ts`), colors, and app-wide constants
- **`types/`** - TypeScript type definitions
- **`assets/`** - Images, fonts, and media files
- **`ios/`** - iOS native code and configuration
### Navigation
- **File-based routing**: Pages defined by file structure in `app/`
- **Tab navigation**: Main tabs in `app/(tabs)/` (Explore, Coach, Statistics, Challenges, Personal, Fasting)
- **Route constants**: All route paths defined in `constants/Routes.ts`
- **Nested layouts**: Feature-specific layouts in nested directories (e.g., `app/nutrition/_layout.tsx`)
### State Management
- **Redux slices**: Feature-based state organization (17+ slices including user, nutrition, workout, mood, etc.)
- **Auto-sync middleware**: Listener middleware automatically syncs checkin data changes to backend
- **Typed hooks**: Use `useAppSelector` and `useAppDispatch` from `hooks/redux.ts` for type safety
- **Persistence**: AsyncStorage for local data persistence
### UI System
- **Themed components**: `ThemedText`, `ThemedView` with dynamic color scheme support
- **Custom icons**: `IconSymbol` component for iOS SF Symbols
- **UI library**: Reusable components in `components/ui/`
- **Colors**: Centralized in `constants/Colors.ts`
- **Safe areas**: `useSafeAreaTop` and `useSafeAreaWithPadding` hooks for device-safe layouts
### Data Layer
- **API client**: Centralized in `services/api.ts` with interceptors and error handling
- **Service modules**: Domain-specific services in `services/` (nutrition, workout, notifications, etc.)
- **Background tasks**: Managed by `backgroundTaskManager.ts` for sync operations
- **Local storage**: AsyncStorage for offline-first data persistence
### Native Integration
- **HealthKit**: Health data integration in `utils/health.ts` and `utils/healthKit.ts`
- **Apple Authentication**: Configured in Expo settings
- **Camera & Photos**: Food recognition and posture assessment features
- **Push Notifications**: `services/notifications.ts` with background task support
- **Haptic Feedback**: `utils/haptics.ts` for user interactions
- **Quick Actions**: Expo quick actions integration
### Context Providers
- **ToastContext** - Global toast notification system
- **MembershipModalContext** - VIP membership feature access control
## Key Architecture Patterns
- **Redux Auto-sync**: Listener middleware in `store/index.ts` automatically syncs checkin data changes to backend
- **Type-safe Navigation**: Expo Router with TypeScript for compile-time route safety
- **Authentication Flow**: `pushIfAuthedElseLogin` function handles auth-protected navigation
- **Theme System**: Dynamic theming with light/dark mode and color tokens
- **Service Layer**: Centralized API client with interceptors and error handling
- **Background Sync**: Automatic data synchronization via background task manager
## Development Conventions
### Import Patterns
- **Absolute imports**: Use `@/` prefix for all internal imports (e.g., `@/store`, `@/services/api`)
- **Alias configuration**: Defined in `tsconfig.json` paths
### Redux Patterns
- **Feature slices**: Each feature has its own slice (userSlice.ts, nutritionSlice.ts, etc.)
- **Typed hooks**: Always use `useAppSelector` and `useAppDispatch` from `hooks/redux.ts`
- **Async actions**: Use Redux Toolkit thunks for async operations
- **Auto-sync**: Listener middleware handles automatic data synchronization
### Naming Conventions
- **Components**: PascalCase (e.g., `ThemedText.tsx`, `FitnessRingsCard.tsx`)
- **Hooks**: camelCase with "use" prefix (e.g., `useAppSelector`, `useSafeAreaTop`)
- **Utilities**: camelCase (e.g., `health.ts`, `notificationHelpers.ts`)
- **Screen files**: kebab-case (e.g., `ai-posture-assessment.tsx`, `nutrition-label-analysis.tsx`)
- **Constants**: UPPER_SNAKE_CASE for values, PascalCase for types
### Code Style
- **ESLint**: Configured with `eslint-config-expo` in `eslint.config.js`
- **Formatting**: 2 spaces, trailing commas, single quotes (Prettier defaults)
- **TypeScript**: Strict mode enabled, use proper type annotations
### Navigation & Routing
- **Route constants**: Always use constants from `constants/Routes.ts` for navigation
- **Auth guards**: Implement using `useAuthGuard` hook for protected features
- **Typed routes**: Leverage Expo Router's TypeScript integration
### Testing Guidelines
- **Minimal automated tests**: Add Jest + React Native Testing Library for complex logic
- **HealthKit testing**: Requires real device; verify on iOS Simulator when possible
- **Integration tests**: Include reproduction steps and logs in PR descriptions
### iOS Development
- **Native changes**: Update `ios/` directory and re-run `npm run ios` after modifying Swift or entitlements
- **HealthKit**: Requires entitlements configuration; coordinate with release engineering
- **App signing**: Keep bundle IDs consistent with `app.json` and iOS project configuration
- **App Groups**: Required for widget and quick actions integration
### Git Workflow
- **Conventional Commits**: Use `feat`, `fix`, `chore` prefixes with optional scope
- **PR descriptions**: Include problem, solution, test evidence (screenshots, commands), iOS setup notes
- **Change grouping**: Group related changes; avoid bundling unrelated features
- 总是先总结方案,等我确认之后,再进行实现

View File

@@ -64,9 +64,9 @@ react {
}
/**
* Set this to true to Run Proguard on Release builds to minify the Java bytecode.
* Set this to true in release builds to optimize the app using [R8](https://developer.android.com/topic/performance/app-optimization/enable-app-optimization).
*/
def enableProguardInReleaseBuilds = (findProperty('android.enableProguardInReleaseBuilds') ?: false).toBoolean()
def enableMinifyInReleaseBuilds = (findProperty('android.enableMinifyInReleaseBuilds') ?: false).toBoolean()
/**
* The preferred build flavor of JavaScriptCore (JSC)
@@ -93,7 +93,9 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "1.0.0"
versionName "1.0.12"
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
}
signingConfigs {
debug {
@@ -111,15 +113,18 @@ android {
// Caution! In production, you need to generate your own keystore file.
// see https://reactnative.dev/docs/signed-apk-android.
signingConfig signingConfigs.debug
shrinkResources (findProperty('android.enableShrinkResourcesInReleaseBuilds')?.toBoolean() ?: false)
minifyEnabled enableProguardInReleaseBuilds
def enableShrinkResources = findProperty('android.enableShrinkResourcesInReleaseBuilds') ?: 'false'
shrinkResources enableShrinkResources.toBoolean()
minifyEnabled enableMinifyInReleaseBuilds
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
crunchPngs (findProperty('android.enablePngCrunchInReleaseBuilds')?.toBoolean() ?: true)
def enablePngCrunchInRelease = findProperty('android.enablePngCrunchInReleaseBuilds') ?: 'true'
crunchPngs enablePngCrunchInRelease.toBoolean()
}
}
packagingOptions {
jniLibs {
useLegacyPackaging (findProperty('expo.useLegacyPackaging')?.toBoolean() ?: false)
def enableLegacyPackaging = findProperty('expo.useLegacyPackaging') ?: 'false'
useLegacyPackaging enableLegacyPackaging.toBoolean()
}
}
androidResources {

View File

@@ -1,6 +1,8 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
<uses-permission android:name="android.permission.VIBRATE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
@@ -11,7 +13,11 @@
<data android:scheme="https"/>
</intent>
</queries>
<application android:name=".MainApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="true" android:theme="@style/AppTheme" android:supportsRtl="true">
<application android:name=".MainApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:allowBackup="true" android:theme="@style/AppTheme" android:supportsRtl="true" android:enableOnBackInvokedCallback="false">
<meta-data android:name="com.google.firebase.messaging.default_notification_color" android:resource="@color/notification_icon_color"/>
<meta-data android:name="com.google.firebase.messaging.default_notification_icon" android:resource="@drawable/notification_icon"/>
<meta-data android:name="expo.modules.notifications.default_notification_color" android:resource="@color/notification_icon_color"/>
<meta-data android:name="expo.modules.notifications.default_notification_icon" android:resource="@drawable/notification_icon"/>
<meta-data android:name="expo.modules.updates.ENABLED" android:value="false"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_CHECK_ON_LAUNCH" android:value="ALWAYS"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_LAUNCH_WAIT_MS" android:value="0"/>

View File

@@ -5,13 +5,13 @@ import android.content.res.Configuration
import com.facebook.react.PackageList
import com.facebook.react.ReactApplication
import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative
import com.facebook.react.ReactNativeHost
import com.facebook.react.ReactPackage
import com.facebook.react.ReactHost
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.load
import com.facebook.react.common.ReleaseLevel
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint
import com.facebook.react.defaults.DefaultReactNativeHost
import com.facebook.react.soloader.OpenSourceMergedSoMapping
import com.facebook.soloader.SoLoader
import expo.modules.ApplicationLifecycleDispatcher
import expo.modules.ReactNativeHostWrapper
@@ -19,21 +19,19 @@ import expo.modules.ReactNativeHostWrapper
class MainApplication : Application(), ReactApplication {
override val reactNativeHost: ReactNativeHost = ReactNativeHostWrapper(
this,
object : DefaultReactNativeHost(this) {
override fun getPackages(): List<ReactPackage> {
val packages = PackageList(this).packages
// Packages that cannot be autolinked yet can be added manually here, for example:
// packages.add(MyReactNativePackage())
return packages
}
this,
object : DefaultReactNativeHost(this) {
override fun getPackages(): List<ReactPackage> =
PackageList(this).packages.apply {
// Packages that cannot be autolinked yet can be added manually here, for example:
// add(MyReactNativePackage())
}
override fun getJSMainModuleName(): String = ".expo/.virtual-metro-entry"
override fun getUseDeveloperSupport(): Boolean = BuildConfig.DEBUG
override val isNewArchEnabled: Boolean = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED
override val isHermesEnabled: Boolean = BuildConfig.IS_HERMES_ENABLED
}
)
@@ -42,11 +40,12 @@ class MainApplication : Application(), ReactApplication {
override fun onCreate() {
super.onCreate()
SoLoader.init(this, OpenSourceMergedSoMapping)
if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
// If you opted-in for the New Architecture, we load the native entry point for this app.
load()
DefaultNewArchitectureEntryPoint.releaseLevel = try {
ReleaseLevel.valueOf(BuildConfig.REACT_NATIVE_RELEASE_LEVEL.uppercase())
} catch (e: IllegalArgumentException) {
ReleaseLevel.STABLE
}
loadReactNative(this)
ApplicationLifecycleDispatcher.onApplicationCreate(this)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 812 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/iconBackground"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>

View File

@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/iconBackground"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -3,4 +3,5 @@
<color name="iconBackground">#ffffff</color>
<color name="colorPrimary">#023c69</color>
<color name="colorPrimaryDark">#ffffff</color>
<color name="notification_icon_color">#ffffff</color>
</resources>

View File

@@ -1,6 +1,6 @@
<resources>
<string name="app_name">digital-pilates</string>
<string name="expo_system_ui_user_interface_style" translatable="false">automatic</string>
<string name="app_name">Out Live</string>
<string name="expo_system_ui_user_interface_style" translatable="false">light</string>
<string name="expo_splash_screen_resize_mode" translatable="false">contain</string>
<string name="expo_splash_screen_status_bar_translucent" translatable="false">false</string>
</resources>

View File

@@ -1,5 +1,6 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<style name="AppTheme" parent="Theme.EdgeToEdge">
<style name="AppTheme" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="android:enforceNavigationBarContrast" tools:targetApi="29">true</item>
<item name="android:editTextBackground">@drawable/rn_edit_text_material</item>
<item name="colorPrimary">@color/colorPrimary</item>
<item name="android:statusBarColor">#ffffff</item>
@@ -8,5 +9,6 @@
<item name="windowSplashScreenBackground">@color/splashscreen_background</item>
<item name="windowSplashScreenAnimatedIcon">@drawable/splashscreen_logo</item>
<item name="postSplashScreenTheme">@style/AppTheme</item>
<item name="android:windowSplashScreenBehavior">icon_preferred</item>
</style>
</resources>

View File

@@ -12,21 +12,8 @@ buildscript {
}
}
def reactNativeAndroidDir = new File(
providers.exec {
workingDir(rootDir)
commandLine("node", "--print", "require.resolve('react-native/package.json')")
}.standardOutput.asText.get().trim(),
"../android"
)
allprojects {
repositories {
maven {
// All of React Native (JS, Obj-C sources, Android binaries) is installed from npm
url(reactNativeAndroidDir)
}
google()
mavenCentral()
maven { url 'https://www.jitpack.io' }

View File

@@ -15,7 +15,7 @@ org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true
org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
@@ -39,7 +39,12 @@ newArchEnabled=true
# Use this property to enable or disable the Hermes JS engine.
# If set to false, you will be using JSC instead.
hermesEnabled=true
hermesEnabled=false
# Use this property to enable edge-to-edge display support.
# This allows your app to draw behind system bars for an immersive UI.
# Note: Only works with ReactActivity and should not be used with custom Activity.
edgeToEdgeEnabled=true
# Enable GIF support in React Native images (~200 B increase)
expo.gif.enabled=true
@@ -55,5 +60,6 @@ EX_DEV_CLIENT_NETWORK_INSPECTOR=true
# Use legacy packaging to compress native libraries in the resulting APK.
expo.useLegacyPackaging=false
# Whether the app is configured to use edge-to-edge via the app config or `react-native-edge-to-edge` plugin
# Specifies whether the app is configured to use edge-to-edge via the app config or plugin
# WARNING: This property has been deprecated and will be removed in Expo SDK 55. Use `edgeToEdgeEnabled` or `react.edgeToEdgeEnabled` to determine whether the project is using edge-to-edge.
expo.edgeToEdgeEnabled=true

Binary file not shown.

View File

@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME

4
android/gradlew vendored
View File

@@ -114,7 +114,7 @@ case "$( uname )" in #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
@@ -213,7 +213,7 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.

4
android/gradlew.bat vendored
View File

@@ -70,11 +70,11 @@ goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell

View File

@@ -31,7 +31,7 @@ extensions.configure(com.facebook.react.ReactSettingsExtension) { ex ->
}
expoAutolinking.useExpoModules()
rootProject.name = 'digital-pilates'
rootProject.name = 'Out Live'
expoAutolinking.useExpoVersionCatalog()

View File

@@ -1,57 +1,77 @@
{
"expo": {
"name": "digital-pilates",
"name": "Out Live",
"slug": "digital-pilates",
"version": "1.0.0",
"version": "1.0.20",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "digitalpilates",
"userInterfaceStyle": "automatic",
"userInterfaceStyle": "light",
"icon": "./assets/logo.png",
"newArchEnabled": true,
"jsEngine": "jsc",
"ios": {
"supportsTablet": true,
"supportsTablet": false,
"deploymentTarget": "16.0",
"bundleIdentifier": "com.anonymous.digitalpilates",
"infoPlist": {
"ITSAppUsesNonExemptEncryption": false,
"NSCameraUsageDescription": "应用需要使用相机以拍摄您的体态照片用于AI测评。",
"NSPhotoLibraryUsageDescription": "应用需要访问相册以选择您的体态照片用于AI测评。",
"NSPhotoLibraryAddUsageDescription": "应用需要写入相册以保存拍摄的体态照片(可选)。"
}
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/images/adaptive-icon.png",
"backgroundColor": "#ffffff"
"NSPhotoLibraryAddUsageDescription": "应用需要写入相册以保存拍摄的体态照片(可选)。",
"NSMicrophoneUsageDescription": "应用需要使用麦克风进行语音识别,将您的语音转换为文字记录饮食信息。",
"NSSpeechRecognitionUsageDescription": "应用需要使用语音识别功能来转换您的语音为文字,帮助您快速记录饮食信息。",
"NSUserNotificationsUsageDescription": "应用需要发送通知以提醒您喝水和站立活动。",
"BGTaskSchedulerPermittedIdentifiers": [
"com.expo.modules.backgroundtask.processing",
"com.anonymous.digitalpilates.task",
"com.anonymous.digitalpilates.processing"
],
"UIBackgroundModes": [
"processing",
"fetch",
"remote-notification"
]
},
"edgeToEdgeEnabled": true,
"package": "com.anonymous.digitalpilates"
},
"web": {
"bundler": "metro",
"output": "static",
"favicon": "./assets/images/favicon.png"
"appleTeamId": "756WVXJ6MT"
},
"plugins": [
"expo-router",
[
"expo-splash-screen",
{
"image": "./assets/images/splash-icon.png",
"imageWidth": 200,
"image": "./assets/logo.png",
"imageWidth": 40,
"resizeMode": "contain",
"backgroundColor": "#ffffff"
}
],
[
"react-native-health",
"expo-notifications",
{
"enableHealthAPI": true,
"healthSharePermission": "应用需要访问您的健康数据(步数与能量消耗)以展示运动统计。"
"icon": "./assets/logo.png",
"color": "#ffffff"
}
],
[
"expo-quick-actions",
{
"androidIcons": {
"drink_water": "./assets/images/icons/IconGlass.png"
},
"iosIcons": {
"drink_water": "./assets/images/icons/IconGlass.png"
}
}
],
[
"expo-background-task"
]
],
"experiments": {
"typedRoutes": true
},
"android": {
"package": "com.anonymous.digitalpilates"
}
}
}

View File

@@ -1,212 +1,215 @@
import type { BottomTabNavigationOptions } from '@react-navigation/bottom-tabs';
import { GlassContainer, GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import * as Haptics from 'expo-haptics';
import { Tabs, usePathname } from 'expo-router';
import { Icon, Label, NativeTabs } from 'expo-router/unstable-native-tabs';
import { useTranslation } from 'react-i18next';
import React from 'react';
import { Text, TouchableOpacity, View } from 'react-native';
import { Text, TouchableOpacity, View, ViewStyle } from 'react-native';
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 { useColorScheme } from '@/hooks/useColorScheme';
// Tab configuration
type TabConfig = {
icon: string;
titleKey: string;
};
const TAB_CONFIGS: Record<string, TabConfig> = {
statistics: { icon: 'chart.pie.fill', titleKey: 'statistics.tabs.health' },
medications: { icon: 'pills.fill', titleKey: 'statistics.tabs.medications' },
fasting: { icon: 'timer', titleKey: 'statistics.tabs.fasting' },
challenges: { icon: 'trophy.fill', titleKey: 'statistics.tabs.challenges' },
personal: { icon: 'person.fill', titleKey: 'statistics.tabs.personal' },
};
export default function TabLayout() {
const { t } = useTranslation();
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme];
const pathname = usePathname();
const glassEffectAvailable = isLiquidGlassAvailable();
// Helper function to determine if a tab is selected
const isTabSelected = (routeName: string): boolean => {
const routeMap: Record<string, string> = {
statistics: ROUTES.TAB_STATISTICS,
medications: ROUTES.TAB_MEDICATIONS,
fasting: ROUTES.TAB_FASTING,
challenges: ROUTES.TAB_CHALLENGES,
personal: ROUTES.TAB_PERSONAL,
};
return routeMap[routeName] === pathname || pathname.includes(routeName);
};
// Custom tab button component
const createTabButton = (routeName: string) => (props: any) => {
const { onPress } = props;
const tabConfig = TAB_CONFIGS[routeName];
if (!tabConfig) return null;
const isSelected = isTabSelected(routeName);
const handlePress = (event: any) => {
if (process.env.EXPO_OS === 'ios') {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}
onPress?.(event);
};
return (
<TouchableOpacity
onPress={handlePress}
accessibilityRole="button"
activeOpacity={1}
style={{
flex: 1,
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'row',
marginHorizontal: 2,
marginVertical: 10,
borderRadius: 25,
backgroundColor: isSelected ? colorTokens.tabBarActiveBackground : 'transparent',
paddingHorizontal: isSelected ? 8 : 4,
paddingVertical: 8,
}}
>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<IconSymbol
size={22}
name={tabConfig.icon as any}
color={isSelected ? colorTokens.tabIconSelected : colorTokens.tabIconDefault}
/>
{isSelected && (
<Text
style={{
color: colorTokens.tabIconSelected,
fontSize: 12,
fontWeight: '600',
marginLeft: 6,
}}
numberOfLines={1}
>
{t(tabConfig.titleKey)}
</Text>
)}
</View>
</TouchableOpacity>
);
};
// Custom tab bar background component
const TabBarBackground = () => {
if (glassEffectAvailable) {
return (
<GlassContainer
spacing={8}
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
borderRadius: 34,
}}
>
<GlassView
isInteractive
glassEffectStyle="regular"
tintColor={theme === 'dark' ? 'rgba(0,0,0,0.3)' : 'rgba(255,255,255,0.3)'}
style={{
flex: 1,
borderRadius: 34,
}}
/>
</GlassContainer>
);
}
return null;
};
// Common screen options
const getScreenOptions = (routeName: string): BottomTabNavigationOptions => ({
headerShown: false,
tabBarActiveTintColor: colorTokens.tabIconSelected,
tabBarButton: createTabButton(routeName),
tabBarBackground: TabBarBackground,
tabBarStyle: {
position: 'absolute',
bottom: TAB_BAR_BOTTOM_OFFSET,
height: TAB_BAR_HEIGHT,
borderRadius: 34,
backgroundColor: glassEffectAvailable ? 'transparent' : colorTokens.tabBarBackground,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: glassEffectAvailable ? 0.1 : 0.2,
shadowRadius: 10,
elevation: 5,
paddingHorizontal: 6,
paddingTop: 0,
paddingBottom: 0,
marginHorizontal: 16,
left: 16,
right: 16,
alignSelf: 'center',
borderWidth: glassEffectAvailable ? 1 : 0,
borderColor: glassEffectAvailable ? (theme === 'dark' ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)') : 'transparent',
} as ViewStyle,
tabBarItemStyle: {
backgroundColor: 'transparent',
height: TAB_BAR_HEIGHT,
marginTop: 0,
marginBottom: 0,
paddingTop: 0,
paddingBottom: 0,
},
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 (
<Tabs
screenOptions={({ route }) => {
const routeName = route.name;
const isSelected = (routeName === 'index' && pathname === '/') ||
(routeName === 'explore' && pathname === '/explore') ||
pathname.includes(routeName);
initialRouteName="statistics"
screenOptions={({ route }) => getScreenOptions(route.name)}
>
return {
headerShown: false,
tabBarActiveTintColor: colorTokens.tabIconSelected,
tabBarButton: (props) => {
const { onPress } = props;
const handlePress = (event: any) => {
if (process.env.EXPO_OS === 'ios') {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}
onPress && onPress(event);
};
// 基于 routeName 设置图标与标题,避免 tabBarIcon 的包装导致文字裁剪
const getIconAndTitle = () => {
switch (routeName) {
case 'index':
return { icon: 'house.fill', title: '首页' } as const;
case 'explore':
return { icon: 'paperplane.fill', title: '探索' } as const;
case 'personal':
return { icon: 'person.fill', title: '个人' } as const;
default:
return { icon: 'circle', title: '' } as const;
}
};
const { icon, title } = getIconAndTitle();
const activeContentColor = colorTokens.onPrimary;
const inactiveContentColor = colorTokens.tabIconDefault;
return (
<TouchableOpacity
onPress={handlePress}
accessibilityRole="button"
style={{
flex: 1,
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'row',
marginHorizontal: 6,
marginVertical: 10,
borderRadius: 25,
backgroundColor: isSelected ? colorTokens.tabBarActiveBackground : 'transparent',
paddingHorizontal: isSelected ? 16 : 10,
paddingVertical: 8,
}}
>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<IconSymbol
size={22}
name={icon as any}
color={isSelected ? activeContentColor : inactiveContentColor}
/>
{isSelected && !!title && (
<Text
style={{
color: activeContentColor,
fontSize: 12,
fontWeight: '600',
marginLeft: 6,
}}
// 选中态下不限制行数,避免大屏布局下被裁剪成省略号
numberOfLines={0 as any}
>
{title}
</Text>
)}
</View>
</TouchableOpacity>
);
},
tabBarStyle: {
position: 'absolute',
bottom: TAB_BAR_BOTTOM_OFFSET,
height: TAB_BAR_HEIGHT,
borderRadius: 34,
backgroundColor: colorTokens.tabBarBackground,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.2,
shadowRadius: 10,
elevation: 5,
paddingHorizontal: 10,
paddingTop: 0,
paddingBottom: 0,
marginHorizontal: 20,
width: '90%',
alignSelf: 'center',
},
tabBarItemStyle: {
backgroundColor: 'transparent',
height: TAB_BAR_HEIGHT,
marginTop: 0,
marginBottom: 0,
paddingTop: 0,
paddingBottom: 0,
},
tabBarShowLabel: false,
};
}}>
<Tabs.Screen
name="index"
options={{
title: '首页',
tabBarIcon: ({ color }) => {
const isHomeSelected = pathname === '/' || pathname === '/index';
return (
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<IconSymbol size={22} name="house.fill" color={color} />
{isHomeSelected && (
<Text
numberOfLines={1}
style={{
color: color,
fontSize: 12,
fontWeight: '600',
marginLeft: 6,
textAlign: 'center',
flexShrink: 0,
}}>
</Text>
)}
</View>
);
},
}}
/>
<Tabs.Screen
name="explore"
options={{
title: '探索',
tabBarIcon: ({ color }) => {
const isExploreSelected = pathname === '/explore';
return (
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<IconSymbol size={22} name="paperplane.fill" color={color} />
{isExploreSelected && (
<Text
numberOfLines={1}
style={{
color: color,
fontSize: 12,
fontWeight: '600',
marginLeft: 6,
textAlign: 'center',
flexShrink: 0,
}}>
</Text>
)}
</View>
);
},
}}
/>
<Tabs.Screen
name="personal"
options={{
title: '个人',
tabBarIcon: ({ color }) => {
const isPersonalSelected = pathname === '/personal';
return (
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<IconSymbol size={22} name="person.fill" color={color} />
{isPersonalSelected && (
<Text
numberOfLines={1}
style={{
color: color,
fontSize: 12,
fontWeight: '600',
marginLeft: 6,
textAlign: 'center',
flexShrink: 0,
}}>
</Text>
)}
</View>
);
},
}}
/>
<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') }} />
</Tabs>
);
}

617
app/(tabs)/challenges.tsx Normal file
View File

@@ -0,0 +1,617 @@
import dayjs from 'dayjs';
import ChallengeProgressCard from '@/components/challenges/ChallengeProgressCard';
import { Colors } from '@/constants/Colors';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useColorScheme } from '@/hooks/useColorScheme';
import {
fetchChallenges,
selectChallengeCards,
selectChallengesListError,
selectChallengesListStatus,
type ChallengeCardViewModel,
} from '@/store/challengesSlice';
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 {
ActivityIndicator,
Animated,
FlatList,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View,
useWindowDimensions
} from 'react-native';
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;
const DOT_BASE_SIZE = 6;
export default function ChallengesScreen() {
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const insets = useSafeAreaInsets();
const colorTokens = Colors[theme];
const router = useRouter();
const dispatch = useAppDispatch();
const challenges = useAppSelector(selectChallengeCards);
const listStatus = useAppSelector(selectChallengesListStatus);
const listError = useAppSelector(selectChallengesListError);
const ongoingChallenges = useMemo(() => {
const now = dayjs();
return challenges.filter((challenge) => {
if (challenge.status !== 'ongoing' || !challenge.isJoined || !challenge.progress) {
return false;
}
if (challenge.endAt) {
const endDate = dayjs(challenge.endAt);
if (endDate.isValid() && endDate.isBefore(now)) {
return false;
}
}
return true;
});
}, [challenges]);
const progressTrackColor = theme === 'dark' ? 'rgba(255, 255, 255, 0.08)' : '#eceffa';
const progressInactiveColor = theme === 'dark' ? 'rgba(255, 255, 255, 0.24)' : '#dfe4f6';
useEffect(() => {
if (listStatus === 'idle') {
dispatch(fetchChallenges());
}
}, [dispatch, listStatus]);
const gradientColors: [string, string] =
theme === 'dark'
? ['#1f2230', '#10131e']
: [colorTokens.backgroundGradientStart, colorTokens.backgroundGradientEnd];
const renderChallenges = () => {
if (listStatus === 'loading' && challenges.length === 0) {
return (
<View style={styles.stateContainer}>
<ActivityIndicator color={colorTokens.primary} />
<Text style={[styles.stateText, { color: colorTokens.textSecondary }]}></Text>
</View>
);
}
if (listStatus === 'failed' && challenges.length === 0) {
return (
<View style={styles.stateContainer}>
<Text style={[styles.stateText, { color: colorTokens.textSecondary }]}>
{listError ?? '加载挑战失败,请稍后重试'}
</Text>
<TouchableOpacity
style={[styles.retryButton, { borderColor: colorTokens.primary }]}
activeOpacity={0.9}
onPress={() => dispatch(fetchChallenges())}
>
<Text style={[styles.retryText, { color: colorTokens.primary }]}></Text>
</TouchableOpacity>
</View>
);
}
if (challenges.length === 0) {
return (
<View style={styles.stateContainer}>
<Text style={[styles.stateText, { color: colorTokens.textSecondary }]}></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.screen, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}>
<LinearGradient colors={gradientColors} style={StyleSheet.absoluteFillObject} />
<ScrollView
contentContainerStyle={[styles.scrollContent, {
paddingTop: insets.top,
}]}
showsVerticalScrollIndicator={false}
bounces
>
<View style={styles.headerRow}>
<View>
<Text style={[styles.title, { color: colorTokens.text }]}></Text>
<Text style={[styles.subtitle, { color: colorTokens.textSecondary }]}></Text>
</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 ? (
<OngoingChallengesCarousel
challenges={ongoingChallenges}
colorTokens={colorTokens}
trackColor={progressTrackColor}
inactiveColor={progressInactiveColor}
onPress={(challenge) =>
router.push({ pathname: '/challenges/[id]', params: { id: challenge.id } })
}
/>
) : null}
<View style={styles.cardsContainer}>{renderChallenges()}</View>
</ScrollView>
</View>
);
}
type ChallengeCardProps = {
challenge: ChallengeCardViewModel;
surfaceColor: string;
textColor: string;
mutedColor: string;
onPress: () => void;
};
function ChallengeCard({ challenge, surfaceColor, textColor, mutedColor, onPress }: ChallengeCardProps) {
const statusLabel = STATUS_LABELS[challenge.status] ?? challenge.status;
return (
<TouchableOpacity
activeOpacity={0.92}
onPress={onPress}
style={[
styles.card,
{
backgroundColor: surfaceColor,
shadowColor: 'rgba(15, 23, 42, 0.18)',
},
]}
>
<View style={styles.cardInner}>
<View style={styles.cardMedia}>
<Image
source={{ uri: challenge.image }}
style={styles.cardImage}
cachePolicy={'memory-disk'}
/>
<>
<LinearGradient
pointerEvents="none"
colors={['rgba(17, 21, 32, 0.05)', 'rgba(13, 17, 28, 0.4)']}
style={styles.cardImageOverlay}
/>
<View style={styles.expiredBadge}>
<Text style={styles.expiredBadgeText}>{statusLabel}</Text>
</View>
</>
</View>
<View style={styles.cardContent}>
<Text
style={[styles.cardTitle, { color: textColor }]}
numberOfLines={1}
>
{challenge.title}
</Text>
<Text
style={[styles.cardDate, { color: mutedColor }]}
>
{challenge.dateRange}
</Text>
<Text
style={[styles.cardParticipants, { color: mutedColor }]}
>
{challenge.participantsLabel}
{challenge.isJoined ? ' · 已加入' : ''}
</Text>
{challenge.avatars.length ? (
<AvatarStack avatars={challenge.avatars} borderColor={surfaceColor} />
) : null}
</View>
</View>
</TouchableOpacity>
);
}
type ThemeColorTokens = (typeof Colors)['light'] | (typeof Colors)['dark'];
type OngoingChallengesCarouselProps = {
challenges: ChallengeCardViewModel[];
colorTokens: ThemeColorTokens;
trackColor: string;
inactiveColor: string;
onPress: (challenge: ChallengeCardViewModel) => void;
};
function OngoingChallengesCarousel({
challenges,
colorTokens,
trackColor,
inactiveColor,
onPress,
}: OngoingChallengesCarouselProps) {
const { width } = useWindowDimensions();
const cardWidth = Math.max(width - 40, MIN_CAROUSEL_CARD_WIDTH);
const snapInterval = cardWidth + CAROUSEL_ITEM_SPACING;
const scrollX = useRef(new Animated.Value(0)).current;
const listRef = useRef<FlatList<ChallengeCardViewModel> | null>(null);
useEffect(() => {
scrollX.setValue(0);
listRef.current?.scrollToOffset({ offset: 0, animated: false });
}, [scrollX, challenges.length]);
const onScroll = useMemo(
() =>
Animated.event(
[
{
nativeEvent: {
contentOffset: { x: scrollX },
},
},
],
{ useNativeDriver: true }
),
[scrollX]
);
const renderItem = useCallback(
({ item, index }: { item: ChallengeCardViewModel; index: number }) => {
const inputRange = [
(index - 1) * snapInterval,
index * snapInterval,
(index + 1) * snapInterval,
];
const scale = scrollX.interpolate({
inputRange,
outputRange: [0.94, 1, 0.94],
extrapolate: 'clamp',
});
const translateY = scrollX.interpolate({
inputRange,
outputRange: [10, 0, 10],
extrapolate: 'clamp',
});
return (
<Animated.View
style={[
styles.carouselCard,
{
width: cardWidth,
transform: [{ scale }, { translateY }],
},
]}
>
<TouchableOpacity
activeOpacity={0.92}
style={styles.carouselTouchable}
onPress={() => onPress(item)}
>
<ChallengeProgressCard
title={item.title}
endAt={item.endAt}
progress={item.progress}
style={styles.carouselProgressCard}
backgroundColors={[colorTokens.card, colorTokens.card]}
titleColor={colorTokens.text}
subtitleColor={colorTokens.textSecondary}
metaColor={colorTokens.primary}
metaSuffixColor={colorTokens.textSecondary}
accentColor={colorTokens.primary}
trackColor={trackColor}
inactiveColor={inactiveColor}
/>
</TouchableOpacity>
</Animated.View>
);
},
[cardWidth, colorTokens, inactiveColor, onPress, scrollX, snapInterval, trackColor]
);
return (
<View style={styles.carouselContainer}>
<Animated.FlatList
ref={listRef}
data={challenges}
keyExtractor={(item) => item.id}
horizontal
showsHorizontalScrollIndicator={false}
bounces
decelerationRate="fast"
snapToAlignment="start"
snapToInterval={snapInterval}
ItemSeparatorComponent={() => <View style={{ width: CAROUSEL_ITEM_SPACING }} />}
onScroll={onScroll}
scrollEventThrottle={16}
overScrollMode="never"
renderItem={renderItem}
/>
{challenges.length > 1 ? (
<View style={styles.carouselIndicators}>
{challenges.map((challenge, index) => {
const inputRange = [
(index - 1) * snapInterval,
index * snapInterval,
(index + 1) * snapInterval,
];
const scaleX = scrollX.interpolate({
inputRange,
outputRange: [1, 2.6, 1],
extrapolate: 'clamp',
});
const dotOpacity = scrollX.interpolate({
inputRange,
outputRange: [0.35, 1, 0.35],
extrapolate: 'clamp',
});
return (
<Animated.View
key={challenge.id}
style={[
styles.carouselDot,
{
opacity: dotOpacity,
backgroundColor: colorTokens.primary,
transform: [{ scaleX }],
},
]}
/>
);
})}
</View>
) : null}
</View>
);
}
type AvatarStackProps = {
avatars: string[];
borderColor: string;
};
function AvatarStack({ avatars, borderColor }: AvatarStackProps) {
return (
<View style={styles.avatarRow}>
{avatars
.filter(Boolean)
.map((avatar, index) => (
<Image
key={`${avatar}-${index}`}
source={{ uri: avatar }}
style={[
styles.avatar,
{ borderColor },
index === 0 ? null : styles.avatarOffset,
]}
/>
))}
</View>
);
}
const styles = StyleSheet.create({
screen: {
flex: 1,
},
safeArea: {
flex: 1,
},
scrollContent: {
paddingHorizontal: 20,
paddingBottom: 120,
},
headerRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginTop: 8,
marginBottom: 26,
},
title: {
fontSize: 32,
fontWeight: '700',
letterSpacing: 1,
},
subtitle: {
marginTop: 6,
fontSize: 14,
fontWeight: '500',
opacity: 0.8,
},
giftShadow: {
shadowColor: 'rgba(94, 62, 199, 0.45)',
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.35,
shadowRadius: 12,
elevation: 8,
borderRadius: 26,
},
giftButton: {
width: 32,
height: 32,
borderRadius: 26,
alignItems: 'center',
justifyContent: 'center',
},
cardsContainer: {
gap: 18,
},
carouselContainer: {
marginBottom: 24,
},
carouselCard: {
width: '100%',
},
carouselTouchable: {
flex: 1,
},
carouselProgressCard: {
width: '100%',
},
carouselIndicators: {
marginTop: 18,
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
},
carouselDot: {
width: DOT_BASE_SIZE,
height: DOT_BASE_SIZE,
borderRadius: DOT_BASE_SIZE / 2,
marginHorizontal: 4,
backgroundColor: 'transparent',
},
stateContainer: {
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 40,
paddingHorizontal: 20,
},
stateText: {
marginTop: 12,
fontSize: 14,
textAlign: 'center',
lineHeight: 20,
},
retryButton: {
marginTop: 16,
paddingHorizontal: 18,
paddingVertical: 8,
borderRadius: 18,
borderWidth: StyleSheet.hairlineWidth,
},
retryText: {
fontSize: 13,
fontWeight: '600',
},
card: {
borderRadius: 28,
padding: 18,
shadowOffset: { width: 0, height: 16 },
shadowOpacity: 0.18,
shadowRadius: 24,
elevation: 6,
position: 'relative',
overflow: 'hidden',
},
cardInner: {
flexDirection: 'row',
alignItems: 'center',
},
cardImage: {
width: CARD_IMAGE_WIDTH,
height: CARD_IMAGE_HEIGHT,
borderRadius: 22,
},
cardMedia: {
borderRadius: 22,
overflow: 'hidden',
position: 'relative',
},
cardContent: {
flex: 1,
marginLeft: 16,
},
cardTitle: {
fontSize: 18,
fontWeight: '700',
marginBottom: 4,
},
cardDate: {
fontSize: 13,
fontWeight: '500',
marginBottom: 4,
},
cardParticipants: {
fontSize: 13,
fontWeight: '500',
},
cardExpired: {
borderWidth: StyleSheet.hairlineWidth,
borderColor: 'rgba(148, 163, 184, 0.22)',
},
cardExpiredText: {
opacity: 0.7,
},
cardDimOverlay: {
...StyleSheet.absoluteFillObject,
borderRadius: 28,
},
cardImageOverlay: {
...StyleSheet.absoluteFillObject,
},
expiredBadge: {
position: 'absolute',
left: 12,
bottom: 12,
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 12,
backgroundColor: 'rgba(12, 16, 28, 0.45)',
},
expiredBadgeText: {
fontSize: 12,
fontWeight: '600',
color: '#f7f9ff',
letterSpacing: 0.3,
},
cardProgress: {
marginTop: 8,
fontSize: 13,
fontWeight: '600',
},
avatarRow: {
flexDirection: 'row',
marginTop: 16,
alignItems: 'center',
},
avatar: {
width: AVATAR_SIZE,
height: AVATAR_SIZE,
borderRadius: AVATAR_SIZE / 2,
borderWidth: 2,
},
avatarOffset: {
marginLeft: -12,
},
});

View File

@@ -1,442 +0,0 @@
import { ProgressBar } from '@/components/ProgressBar';
import { Colors } from '@/constants/Colors';
import { getTabBarBottomPadding } from '@/constants/TabBar';
import { getMonthDaysZh, getMonthTitleZh, getTodayIndexInMonth } from '@/utils/date';
import { ensureHealthPermissions, fetchTodayHealthData } from '@/utils/health';
import { Ionicons } from '@expo/vector-icons';
import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
import { useFocusEffect } from '@react-navigation/native';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import {
SafeAreaView,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
export default function ExploreScreen() {
// 使用 dayjs当月日期与默认选中“今天”
const days = getMonthDaysZh();
const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth());
const tabBarHeight = useBottomTabBarHeight();
const insets = useSafeAreaInsets();
const bottomPadding = useMemo(() => {
return getTabBarBottomPadding(tabBarHeight) + (insets?.bottom ?? 0);
}, [tabBarHeight, insets?.bottom]);
const monthTitle = getMonthTitleZh();
// 日期条自动滚动到选中项
const daysScrollRef = useRef<import('react-native').ScrollView | null>(null);
const [scrollWidth, setScrollWidth] = useState(0);
const DAY_PILL_WIDTH = 68;
const DAY_PILL_SPACING = 12;
const scrollToIndex = (index: number, animated = true) => {
const baseOffset = index * (DAY_PILL_WIDTH + DAY_PILL_SPACING);
const centerOffset = Math.max(0, baseOffset - (scrollWidth / 2 - DAY_PILL_WIDTH / 2));
daysScrollRef.current?.scrollTo({ x: centerOffset, animated });
};
useEffect(() => {
if (scrollWidth > 0) {
scrollToIndex(selectedIndex, false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [scrollWidth]);
// HealthKit: 每次页面聚焦都拉取今日数据
const [stepCount, setStepCount] = useState<number | null>(null);
const [activeCalories, setActiveCalories] = useState<number | null>(null);
const [isLoading, setIsLoading] = useState(false);
const loadHealthData = async () => {
try {
console.log('=== 开始HealthKit初始化流程 ===');
setIsLoading(true);
const ok = await ensureHealthPermissions();
if (!ok) {
const errorMsg = '无法获取健康权限请确保在真实iOS设备上运行并授权应用访问健康数据';
console.warn(errorMsg);
return;
}
console.log('权限获取成功,开始获取健康数据...');
const data = await fetchTodayHealthData();
console.log('设置UI状态:', data);
setStepCount(data.steps);
setActiveCalories(Math.round(data.activeEnergyBurned));
console.log('=== HealthKit数据获取完成 ===');
} catch (error) {
console.error('HealthKit流程出现异常:', error);
} finally {
setIsLoading(false);
}
};
useFocusEffect(
React.useCallback(() => {
loadHealthData();
}, [])
);
return (
<View style={styles.container}>
<SafeAreaView style={styles.safeArea}>
<ScrollView
style={styles.scrollView}
contentContainerStyle={{ paddingBottom: bottomPadding }}
showsVerticalScrollIndicator={false}
>
{/* 标题与日期选择 */}
<Text style={styles.monthTitle}>{monthTitle}</Text>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.daysContainer}
ref={daysScrollRef}
onLayout={(e) => setScrollWidth(e.nativeEvent.layout.width)}
>
{days.map((d, i) => {
const selected = i === selectedIndex;
return (
<View key={`${d.dayOfMonth}`} style={styles.dayItemWrapper}>
<TouchableOpacity
style={[styles.dayPill, selected ? styles.dayPillSelected : styles.dayPillNormal]}
onPress={() => {
setSelectedIndex(i);
scrollToIndex(i);
}}
activeOpacity={0.8}
>
<Text style={[styles.dayLabel, selected && styles.dayLabelSelected]}> {d.weekdayZh} </Text>
<Text style={[styles.dayDate, selected && styles.dayDateSelected]}>{d.dayOfMonth}</Text>
</TouchableOpacity>
{selected && <View style={styles.selectedDot} />}
</View>
);
})}
</ScrollView>
{/* 今日报告 标题 */}
<Text style={styles.sectionTitle}></Text>
{/* 健康数据错误提示 */}
{isLoading && (
<View style={styles.errorContainer}>
<Ionicons name="warning-outline" size={20} color="#E54D4D" />
<Text style={styles.errorText}>...</Text>
<TouchableOpacity
style={styles.retryButton}
onPress={loadHealthData} disabled={isLoading}
>
<Ionicons
name="refresh-outline"
size={16}
color={isLoading ? '#9AA3AE' : '#E54D4D'}
/>
</TouchableOpacity>
</View>
)}
{/* 指标行:左大卡(训练时间),右两小卡(消耗卡路里、步数) */}
<View style={styles.metricsRow}>
<View style={[styles.trainingCard, styles.metricsLeft]}>
<Text style={styles.cardTitleSecondary}></Text>
<View style={styles.trainingContent}>
<View style={styles.trainingRingTrack} />
<View style={styles.trainingRingProgress} />
<Text style={styles.trainingPercent}>80%</Text>
</View>
</View>
<View style={styles.metricsRight}>
<View style={[styles.metricsRightCard, styles.caloriesCard, { minHeight: 88 }]}>
<Text style={styles.cardTitleSecondary}></Text>
<Text style={styles.caloriesValue}>
{isLoading ? '加载中...' : activeCalories != null ? `${activeCalories} 千卡` : '——'}
</Text>
</View>
<View style={[styles.metricsRightCard, styles.stepsCard, { minHeight: 88 }]}>
<View style={styles.cardHeaderRow}>
<View style={styles.iconSquare}><Ionicons name="footsteps-outline" size={18} color="#192126" /></View>
<Text style={styles.cardTitle}></Text>
</View>
<Text style={styles.stepsValue}>{isLoading ? '加载中.../2000' : stepCount != null ? `${stepCount}/2000` : '——/2000'}</Text>
<ProgressBar progress={Math.min(1, Math.max(0, (stepCount ?? 0) / 2000))} height={12} trackColor="#FFEBCB" fillColor="#FFC365" />
</View>
</View>
</View>
</ScrollView>
</SafeAreaView>
</View>
);
}
const primary = Colors.light.primary;
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#F6F7F8',
},
safeArea: {
flex: 1,
},
scrollView: {
flex: 1,
paddingHorizontal: 20,
},
monthTitle: {
fontSize: 24,
fontWeight: '800',
color: '#192126',
marginTop: 8,
marginBottom: 14,
},
daysContainer: {
paddingBottom: 8,
},
dayItemWrapper: {
alignItems: 'center',
width: 68,
marginRight: 12,
},
dayPill: {
width: 68,
height: 68,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
},
dayPillNormal: {
backgroundColor: '#C8F852',
},
dayPillSelected: {
backgroundColor: '#192126',
},
dayLabel: {
fontSize: 16,
fontWeight: '700',
color: '#192126',
marginBottom: 2,
},
dayLabelSelected: {
color: '#FFFFFF',
},
dayDate: {
fontSize: 16,
fontWeight: '800',
color: '#192126',
},
dayDateSelected: {
color: '#FFFFFF',
},
selectedDot: {
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: '#192126',
marginTop: 10,
marginBottom: 4,
alignSelf: 'center',
},
sectionTitle: {
fontSize: 24,
fontWeight: '800',
color: '#192126',
marginTop: 24,
marginBottom: 14,
},
metricsRow: {
flexDirection: 'row',
justifyContent: 'space-between',
marginBottom: 16,
},
card: {
backgroundColor: '#0F1418',
borderRadius: 22,
padding: 18,
marginBottom: 16,
},
metricsLeft: {
flex: 1,
backgroundColor: '#EEE9FF',
borderRadius: 22,
padding: 18,
marginRight: 12,
},
metricsRight: {
width: 160,
gap: 12,
},
metricsRightCard: {
backgroundColor: '#FFFFFF',
borderRadius: 22,
padding: 16,
},
caloriesCard: {
backgroundColor: '#FFFFFF',
},
trainingCard: {
backgroundColor: '#EEE9FF',
},
cardTitleSecondary: {
color: '#9AA3AE',
fontSize: 14,
fontWeight: '600',
marginBottom: 10,
},
caloriesValue: {
color: '#192126',
fontSize: 22,
fontWeight: '800',
},
trainingContent: {
marginTop: 8,
width: 120,
height: 120,
borderRadius: 60,
alignItems: 'center',
justifyContent: 'center',
alignSelf: 'center',
},
trainingRingTrack: {
position: 'absolute',
width: '100%',
height: '100%',
borderRadius: 60,
borderWidth: 12,
borderColor: '#E2D9FD',
},
trainingRingProgress: {
position: 'absolute',
width: '100%',
height: '100%',
borderRadius: 60,
borderWidth: 12,
borderColor: 'transparent',
borderTopColor: '#8B74F3',
borderRightColor: '#8B74F3',
transform: [{ rotateZ: '45deg' }],
},
trainingPercent: {
fontSize: 18,
fontWeight: '800',
color: '#8B74F3',
},
cyclingHeader: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 12,
},
cyclingIconBadge: {
width: 30,
height: 30,
borderRadius: 6,
backgroundColor: primary,
alignItems: 'center',
justifyContent: 'center',
marginRight: 8,
},
cyclingTitle: {
color: '#FFFFFF',
fontSize: 20,
fontWeight: '800',
},
mapArea: {
backgroundColor: 'rgba(255,255,255,0.08)',
borderRadius: 14,
height: 180,
padding: 8,
flexDirection: 'row',
flexWrap: 'wrap',
overflow: 'hidden',
},
mapTile: {
width: '25%',
height: '25%',
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.12)',
},
routeLine: {
position: 'absolute',
height: 6,
backgroundColor: primary,
borderRadius: 3,
},
cardHeaderRow: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 12,
},
iconSquare: {
width: 30,
height: 30,
borderRadius: 8,
backgroundColor: '#FFFFFF',
alignItems: 'center',
justifyContent: 'center',
marginRight: 10,
},
cardTitle: {
fontSize: 18,
fontWeight: '800',
color: '#192126',
},
heartCard: {
backgroundColor: '#FFE5E5',
},
waveContainer: {
flexDirection: 'row',
alignItems: 'flex-end',
height: 70,
gap: 6,
marginBottom: 8,
},
waveBar: {
width: 6,
borderRadius: 3,
backgroundColor: '#E54D4D',
},
heartValue: {
alignSelf: 'flex-end',
color: '#5B5B5B',
fontWeight: '600',
},
stepsCard: {
backgroundColor: '#FFE4B8',
},
stepsValue: {
fontSize: 16,
color: '#7A6A42',
fontWeight: '700',
marginBottom: 8,
},
errorContainer: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#FFE5E5',
borderRadius: 12,
padding: 12,
marginBottom: 16,
},
errorText: {
fontSize: 14,
color: '#E54D4D',
fontWeight: '600',
marginLeft: 8,
flex: 1,
},
retryButton: {
padding: 4,
marginLeft: 8,
},
});

832
app/(tabs)/fasting.tsx Normal file
View File

@@ -0,0 +1,832 @@
import { FastingOverviewCard } from '@/components/fasting/FastingOverviewCard';
import { FastingPlanList } from '@/components/fasting/FastingPlanList';
import { FastingStartPickerModal } from '@/components/fasting/FastingStartPickerModal';
import { NotificationErrorAlert } from '@/components/ui/NotificationErrorAlert';
import { FASTING_PLANS, FastingPlan, getPlanById, getRecommendedStart } from '@/constants/Fasting';
import { ROUTES } from '@/constants/Routes';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useCountdown } from '@/hooks/useCountdown';
import { useFastingCycleNotifications } from '@/hooks/useFastingCycleNotifications';
import { useFastingNotifications } from '@/hooks/useFastingNotifications';
import {
clearActiveSchedule,
completeCurrentCycleSession,
hydrateFastingCycle,
pauseFastingCycle,
rescheduleActivePlan,
resumeFastingCycle,
scheduleFastingPlan,
selectActiveCyclePlan,
// 周期性断食相关的 selectors
selectActiveFastingCycle,
selectActiveFastingPlan,
selectActiveFastingSchedule,
selectCurrentCyclePlan,
selectCurrentCycleSession,
selectCurrentFastingPlan,
selectCurrentFastingTimes,
selectCycleHistory,
selectIsInCycleMode,
startFastingCycle,
stopFastingCycle,
updateFastingCycleTime
} from '@/store/fastingSlice';
import {
buildDisplayWindow,
getFastingPhase,
getPhaseLabel,
// 周期性断食相关的工具函数
loadActiveFastingCycle,
loadCurrentCycleSession,
loadCycleHistory,
loadPreferredPlanId,
saveActiveFastingCycle,
saveCurrentCycleSession,
saveCycleHistory,
savePreferredPlanId
} from '@/utils/fasting';
import { Ionicons } from '@expo/vector-icons';
import { useFocusEffect } from '@react-navigation/native';
import dayjs from 'dayjs';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { useRouter } from 'expo-router';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
export default function FastingTabScreen() {
const router = useRouter();
const dispatch = useAppDispatch();
const insets = useSafeAreaInsets();
const scrollViewRef = React.useRef<ScrollView>(null);
// 单次断食计划的状态
const activeSchedule = useAppSelector(selectActiveFastingSchedule);
const activePlan = useAppSelector(selectActiveFastingPlan);
// 周期性断食计划的状态
const activeCycle = useAppSelector(selectActiveFastingCycle);
const currentCycleSession = useAppSelector(selectCurrentCycleSession);
const cycleHistory = useAppSelector(selectCycleHistory);
const activeCyclePlan = useAppSelector(selectActiveCyclePlan);
const currentCyclePlan = useAppSelector(selectCurrentCyclePlan);
// 统一的当前断食信息(优先显示周期性)
const currentPlan = useAppSelector(selectCurrentFastingPlan);
const currentTimes = useAppSelector(selectCurrentFastingTimes);
const isInCycleMode = useAppSelector(selectIsInCycleMode);
const defaultPlan = FASTING_PLANS.find((plan) => plan.id === '14-10') ?? FASTING_PLANS[0];
const [preferredPlanId, setPreferredPlanId] = useState<string | undefined>(currentPlan?.id ?? undefined);
// 数据持久化
useEffect(() => {
if (!currentPlan?.id) return;
setPreferredPlanId(currentPlan.id);
void savePreferredPlanId(currentPlan.id);
}, [currentPlan?.id]);
useEffect(() => {
let cancelled = false;
const hydratePreferredPlan = async () => {
try {
const savedPlanId = await loadPreferredPlanId();
if (cancelled) return;
if (currentPlan?.id) return;
if (savedPlanId && getPlanById(savedPlanId)) {
setPreferredPlanId(savedPlanId);
}
} catch (error) {
console.warn('读取断食首选计划失败', error);
}
};
hydratePreferredPlan();
return () => {
cancelled = true;
};
}, [currentPlan?.id]);
// 加载周期性断食数据
useEffect(() => {
let cancelled = false;
const hydrateCycleData = async () => {
try {
if (cancelled) return;
const [cycleData, sessionData, historyData] = await Promise.all([
loadActiveFastingCycle(),
loadCurrentCycleSession(),
loadCycleHistory(),
]);
if (cancelled) return;
dispatch(hydrateFastingCycle({
activeCycle: cycleData,
currentCycleSession: sessionData,
cycleHistory: historyData,
}));
} catch (error) {
console.warn('加载周期性断食数据失败', error);
}
};
hydrateCycleData();
return () => {
cancelled = true;
};
}, [dispatch]);
// 保存周期性断食数据,增加错误处理
useEffect(() => {
const saveCycleData = async () => {
try {
if (activeCycle) {
await saveActiveFastingCycle(activeCycle);
} else {
await saveActiveFastingCycle(null);
}
} catch (error) {
console.error('保存周期性断食计划失败', error);
// TODO: 可以在这里添加用户提示
}
};
saveCycleData();
}, [activeCycle]);
useEffect(() => {
const saveSessionData = async () => {
try {
if (currentCycleSession) {
await saveCurrentCycleSession(currentCycleSession);
} else {
await saveCurrentCycleSession(null);
}
} catch (error) {
console.error('保存断食会话失败', error);
// TODO: 可以在这里添加用户提示
}
};
saveSessionData();
}, [currentCycleSession]);
useEffect(() => {
const saveHistoryData = async () => {
try {
await saveCycleHistory(cycleHistory);
} catch (error) {
console.error('保存断食历史失败', error);
// TODO: 可以在这里添加用户提示
}
};
saveHistoryData();
}, [cycleHistory]);
// 使用单次断食通知管理 hook
const {
isReady: notificationsReady,
isLoading: notificationsLoading,
error: notificationError,
notificationIds,
lastSyncTime,
verifyAndSync,
forceSync,
clearError,
} = useFastingNotifications(activeSchedule, activePlan);
// 使用周期性断食通知管理 hook
const {
isReady: cycleNotificationsReady,
isLoading: cycleNotificationsLoading,
error: cycleNotificationError,
lastSyncTime: cycleLastSyncTime,
verifyAndSync: verifyAndSyncCycle,
forceSync: forceSyncCycle,
clearError: clearCycleError,
} = useFastingCycleNotifications(activeCycle, currentCycleSession, currentCyclePlan);
// 每次进入页面时验证通知
// 添加节流机制,避免频繁触发验证
const lastVerifyTimeRef = React.useRef<number>(0);
const lastCycleVerifyTimeRef = React.useRef<number>(0);
useFocusEffect(
useCallback(() => {
const now = Date.now();
const timeSinceLastVerify = now - lastVerifyTimeRef.current;
// 如果距离上次验证不足 30 秒,跳过本次验证
if (timeSinceLastVerify < 30000) {
return;
}
lastVerifyTimeRef.current = now;
verifyAndSync();
}, [verifyAndSync])
);
useFocusEffect(
useCallback(() => {
const now = Date.now();
const timeSinceLastVerify = now - lastCycleVerifyTimeRef.current;
// 如果距离上次验证不足 30 秒,跳过本次验证
if (timeSinceLastVerify < 30000) {
return;
}
lastCycleVerifyTimeRef.current = now;
verifyAndSyncCycle();
}, [verifyAndSyncCycle])
);
// 使用统一的当前断食时间
const scheduleStart = useMemo(() => {
if (currentTimes) {
return new Date(currentTimes.startISO);
}
return undefined;
}, [currentTimes]);
const scheduleEnd = useMemo(() => {
if (currentTimes) {
return new Date(currentTimes.endISO);
}
return undefined;
}, [currentTimes]);
const phase = getFastingPhase(scheduleStart ?? null, scheduleEnd ?? null);
const countdownTarget = phase === 'fasting' ? scheduleEnd : scheduleStart;
const { formatted: countdownValue } = useCountdown({ target: countdownTarget ?? null });
const progress = useMemo(() => {
if (!scheduleStart || !scheduleEnd) return 0;
const total = scheduleEnd.getTime() - scheduleStart.getTime();
if (total <= 0) return 0;
const now = Date.now();
if (now <= scheduleStart.getTime()) return 0;
if (now >= scheduleEnd.getTime()) return 1;
return (now - scheduleStart.getTime()) / total;
}, [scheduleStart, scheduleEnd]);
const displayWindow = buildDisplayWindow(scheduleStart ?? null, scheduleEnd ?? null);
const [showPicker, setShowPicker] = useState(false);
// 显示通知错误(如果有)
useEffect(() => {
if (notificationError) {
console.warn('断食通知错误:', notificationError);
// 可以在这里添加用户提示,比如 Toast 或 Snackbar
}
}, [notificationError]);
const recommendedDate = useMemo(() => {
const planToUse = currentPlan || defaultPlan;
return getRecommendedStart(planToUse);
}, [currentPlan, defaultPlan]);
// 调试信息(开发环境)
useEffect(() => {
if (__DEV__ && lastSyncTime) {
console.log('单次断食通知状态:', {
ready: notificationsReady,
loading: notificationsLoading,
error: notificationError,
notificationIds,
lastSyncTime,
schedule: activeSchedule?.startISO,
plan: activePlan?.id,
});
}
}, [notificationsReady, notificationsLoading, notificationError, notificationIds, lastSyncTime, activeSchedule?.startISO, activePlan?.id]);
useEffect(() => {
if (__DEV__ && cycleLastSyncTime) {
console.log('周期性断食通知状态:', {
ready: cycleNotificationsReady,
loading: cycleNotificationsLoading,
error: cycleNotificationError,
lastSyncTime: cycleLastSyncTime,
cycle: activeCycle?.planId,
session: currentCycleSession?.cycleDate,
});
}
}, [cycleNotificationsReady, cycleNotificationsLoading, cycleNotificationError, cycleLastSyncTime, activeCycle?.planId, currentCycleSession?.cycleDate]);
// 周期性断食的自动续订逻辑
// 移除1小时限制但需要用户手动确认开始下一轮周期
useEffect(() => {
if (!currentCycleSession || !activeCycle || !currentCyclePlan) return;
if (!activeCycle.enabled) return; // 如果周期已暂停,不自动完成
if (phase !== 'completed') return;
const end = dayjs(currentCycleSession.endISO);
if (!end.isValid()) return;
const now = dayjs();
if (now.isBefore(end)) return;
// 检查当前会话是否已经标记为完成
if (currentCycleSession.completed) {
if (__DEV__) {
console.log('当前会话已完成,跳过自动完成');
}
return;
}
if (__DEV__) {
console.log('自动完成当前断食周期:', {
cycleDate: currentCycleSession.cycleDate,
planId: currentCycleSession.planId,
endTime: end.format('YYYY-MM-DD HH:mm'),
timeSinceEnd: now.diff(end, 'minute') + '分钟',
});
}
// 完成当前周期并创建下一个周期
// 这会自动创建下一天的会话,不需要用户手动操作
dispatch(completeCurrentCycleSession());
}, [dispatch, currentCycleSession, activeCycle, currentCyclePlan, phase]);
// 保留原有的单次断食自动续订逻辑(向后兼容)
useEffect(() => {
if (!activeSchedule || !activePlan) return;
if (phase !== 'completed') return;
const start = dayjs(activeSchedule.startISO);
const end = dayjs(activeSchedule.endISO);
if (!start.isValid() || !end.isValid()) return;
const now = dayjs();
if (now.isBefore(end)) return;
// 检查是否在短时间内已经续订过,避免重复续订
const timeSinceEnd = now.diff(end, 'minute');
if (timeSinceEnd > 60) {
// 如果周期结束超过1小时说明用户可能不再需要自动续订
if (__DEV__) {
console.log('断食周期结束超过1小时不自动续订');
}
return;
}
// 使用每日固定时间计算下一个周期
// 保持原始的开始时间(小时和分钟),只增加日期
const originalStartHour = start.hour();
const originalStartMinute = start.minute();
// 计算下一个开始时间:明天的同一时刻
let nextStart = now.startOf('day').hour(originalStartHour).minute(originalStartMinute);
// 如果计算出的时间在当前时间之前,则使用后天的同一时刻
if (nextStart.isBefore(now)) {
nextStart = nextStart.add(1, 'day');
}
const nextEnd = nextStart.add(activePlan.fastingHours, 'hour');
if (__DEV__) {
console.log('自动续订断食周期:', {
oldStart: start.format('YYYY-MM-DD HH:mm'),
oldEnd: end.format('YYYY-MM-DD HH:mm'),
nextStart: nextStart.format('YYYY-MM-DD HH:mm'),
nextEnd: nextEnd.format('YYYY-MM-DD HH:mm'),
});
}
dispatch(rescheduleActivePlan({
start: nextStart.toISOString(),
origin: 'auto',
}));
}, [dispatch, activeSchedule, activePlan, phase]);
const handleAdjustStart = () => {
setShowPicker(true);
};
const handleConfirmStart = (date: Date) => {
// 如果没有当前计划,使用默认计划
const planToUse = currentPlan || defaultPlan;
// 如果处于周期性模式,更新周期性时间
if (isInCycleMode && activeCycle) {
const hour = date.getHours();
const minute = date.getMinutes();
dispatch(updateFastingCycleTime({ startHour: hour, startMinute: minute }));
} else if (activeSchedule) {
// 单次断食模式,重新安排
dispatch(rescheduleActivePlan({ start: date.toISOString(), origin: 'manual' }));
} else {
// 创建新的单次断食计划
dispatch(scheduleFastingPlan({ planId: planToUse.id, start: date.toISOString(), origin: 'manual' }));
}
};
const handleSelectPlan = (plan: FastingPlan) => {
router.push(`${ROUTES.FASTING_PLAN_DETAIL}/${plan.id}`);
};
const handleViewMeal = () => {
router.push(ROUTES.FOOD_LIBRARY);
};
const handleResetPlan = () => {
// 如果没有活跃计划,不执行任何操作
if (!currentPlan) return;
if (isInCycleMode) {
// 停止周期性断食
dispatch(stopFastingCycle());
} else {
// 清除单次断食
dispatch(clearActiveSchedule());
}
};
// 新增:启动周期性断食
const handleStartCycle = async (plan: FastingPlan, startHour: number, startMinute: number) => {
try {
dispatch(startFastingCycle({
planId: plan.id,
startHour,
startMinute
}));
// 等待数据保存完成
// 注意dispatch 是同步的,但我们需要确保数据被正确保存
console.log('周期性断食计划已启动', {
planId: plan.id,
startHour,
startMinute
});
} catch (error) {
console.error('启动周期性断食失败', error);
// TODO: 添加用户错误提示
}
};
// 新增:暂停/恢复周期性断食
const handleToggleCycle = async () => {
if (!activeCycle) return;
try {
if (activeCycle.enabled) {
dispatch(pauseFastingCycle());
console.log('周期性断食已暂停');
} else {
dispatch(resumeFastingCycle());
console.log('周期性断食已恢复');
}
} catch (error) {
console.error('切换周期性断食状态失败', error);
// TODO: 添加用户错误提示
}
};
return (
<View style={[styles.safeArea]}>
<ScrollView
ref={scrollViewRef}
contentContainerStyle={[styles.scrollContainer, {
paddingTop: insets.top,
paddingBottom: 120
}]}
showsVerticalScrollIndicator={false}
>
<View style={styles.headerRow}>
<Text style={styles.screenTitle}></Text>
<Text style={styles.screenSubtitle}> · · </Text>
</View>
{/* 通知错误提示 */}
<NotificationErrorAlert
error={notificationError}
onRetry={forceSync}
onDismiss={clearError}
/>
{currentPlan ? (
<FastingOverviewCard
plan={currentPlan}
phaseLabel={getPhaseLabel(phase)}
countdownLabel={phase === 'fasting' ? '距离进食还有' : '距离断食还有'}
countdownValue={countdownValue}
startDayLabel={displayWindow.startDayLabel}
startTimeLabel={displayWindow.startTimeLabel}
endDayLabel={displayWindow.endDayLabel}
endTimeLabel={displayWindow.endTimeLabel}
onAdjustStartPress={handleAdjustStart}
onViewMealsPress={handleViewMeal}
onResetPress={handleResetPlan}
progress={progress}
/>
) : (
<View style={styles.emptyStateCard}>
<View style={styles.emptyStateHeader}>
<Text style={styles.emptyStateTitle}></Text>
<Text style={styles.emptyStateSubtitle}></Text>
</View>
<View style={styles.emptyStateContent}>
<View style={styles.emptyStateIcon}>
<Ionicons name="time-outline" size={48} color="#6F7D87" />
</View>
<Text style={styles.emptyStateDescription}>
</Text>
<Text style={styles.defaultPlanInfo}>
使 14-10 1410
</Text>
</View>
<View style={styles.emptyStateActions}>
<TouchableOpacity
style={styles.primaryButton}
onPress={() => setShowPicker(true)}
activeOpacity={0.8}
>
<Text style={styles.primaryButtonText}></Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.secondaryButton}
onPress={() => {
// 滚动到计划列表
setTimeout(() => {
scrollViewRef.current?.scrollTo({ y: 600, animated: true });
}, 100);
}}
activeOpacity={0.8}
>
<Text style={styles.secondaryButtonText}></Text>
</TouchableOpacity>
</View>
</View>
)}
{currentPlan && (
<View style={styles.highlightCard}>
<View style={styles.highlightHeader}>
<Text style={styles.highlightTitle}></Text>
<Text style={styles.highlightSubtitle}>{currentPlan.subtitle}</Text>
</View>
{currentPlan.highlights.map((highlight) => (
<View key={highlight} style={styles.highlightItem}>
<View style={[styles.highlightDot, { backgroundColor: currentPlan.theme.accent }]} />
<Text style={styles.highlightText}>{highlight}</Text>
</View>
))}
<View style={styles.resetRow}>
<Text style={styles.resetHint}>
</Text>
</View>
</View>
)}
<FastingPlanList
plans={FASTING_PLANS}
activePlanId={activePlan?.id ?? currentPlan?.id}
onSelectPlan={handleSelectPlan}
/>
{/* 参考文献入口 */}
<View style={styles.referencesSection}>
<TouchableOpacity
style={styles.referencesButton}
onPress={() => router.push(ROUTES.FASTING_REFERENCES)}
activeOpacity={0.8}
>
{isLiquidGlassAvailable() ? (
<GlassView
style={styles.referencesGlass}
glassEffectStyle="clear"
tintColor="rgba(46, 49, 66, 0.05)"
isInteractive={true}
>
<View style={styles.referencesContent}>
<Ionicons name="library-outline" size={20} color="#2E3142" />
<Text style={styles.referencesText}></Text>
<Ionicons name="chevron-forward" size={16} color="#6F7D87" />
</View>
</GlassView>
) : (
<View style={[styles.referencesGlass, styles.referencesFallback]}>
<View style={styles.referencesContent}>
<Ionicons name="library-outline" size={20} color="#2E3142" />
<Text style={styles.referencesText}></Text>
<Ionicons name="chevron-forward" size={16} color="#6F7D87" />
</View>
</View>
)}
</TouchableOpacity>
</View>
</ScrollView>
<FastingStartPickerModal
visible={showPicker}
onClose={() => setShowPicker(false)}
initialDate={scheduleStart}
recommendedDate={recommendedDate}
onConfirm={handleConfirmStart}
/>
</View>
);
}
const styles = StyleSheet.create({
safeArea: {
flex: 1,
backgroundColor: 'white'
},
scrollContainer: {
paddingHorizontal: 20,
paddingTop: 12,
},
headerRow: {
marginBottom: 20,
},
screenTitle: {
fontSize: 28,
fontWeight: '800',
color: '#2E3142',
marginBottom: 6,
},
screenSubtitle: {
fontSize: 14,
color: '#6F7D87',
fontWeight: '500',
},
highlightCard: {
marginTop: 28,
padding: 20,
borderRadius: 24,
backgroundColor: '#FFFFFF',
shadowColor: '#000',
shadowOffset: { width: 0, height: 12 },
shadowOpacity: 0.06,
shadowRadius: 20,
elevation: 4,
},
highlightHeader: {
marginBottom: 12,
},
highlightTitle: {
fontSize: 18,
fontWeight: '700',
color: '#2E3142',
},
highlightSubtitle: {
fontSize: 13,
color: '#6F7D87',
marginTop: 6,
},
highlightItem: {
flexDirection: 'row',
alignItems: 'flex-start',
marginBottom: 10,
},
highlightDot: {
width: 6,
height: 6,
borderRadius: 3,
marginTop: 7,
marginRight: 10,
},
highlightText: {
flex: 1,
fontSize: 14,
color: '#4A5460',
lineHeight: 20,
},
resetRow: {
marginTop: 16,
},
resetHint: {
fontSize: 12,
color: '#8A96A3',
},
emptyStateCard: {
borderRadius: 28,
padding: 24,
backgroundColor: '#FFFFFF',
shadowColor: '#000',
shadowOffset: { width: 0, height: 16 },
shadowOpacity: 0.08,
shadowRadius: 24,
elevation: 6,
marginBottom: 20,
},
emptyStateHeader: {
alignItems: 'center',
marginBottom: 24,
},
emptyStateTitle: {
fontSize: 24,
fontWeight: '700',
color: '#2E3142',
marginBottom: 8,
textAlign: 'center',
},
emptyStateSubtitle: {
fontSize: 16,
color: '#6F7D87',
textAlign: 'center',
lineHeight: 22,
},
emptyStateContent: {
alignItems: 'center',
marginBottom: 32,
},
emptyStateIcon: {
width: 80,
height: 80,
borderRadius: 40,
backgroundColor: 'rgba(111, 125, 135, 0.1)',
alignItems: 'center',
justifyContent: 'center',
marginBottom: 20,
},
emptyStateDescription: {
fontSize: 15,
color: '#4A5460',
textAlign: 'center',
lineHeight: 22,
paddingHorizontal: 20,
marginBottom: 12,
},
defaultPlanInfo: {
fontSize: 13,
color: '#8A96A3',
textAlign: 'center',
lineHeight: 18,
paddingHorizontal: 20,
fontStyle: 'italic',
},
emptyStateActions: {
gap: 12,
},
primaryButton: {
backgroundColor: '#2E3142',
paddingVertical: 16,
paddingHorizontal: 24,
borderRadius: 24,
alignItems: 'center',
justifyContent: 'center',
},
primaryButtonText: {
fontSize: 16,
fontWeight: '600',
color: '#FFFFFF',
},
secondaryButton: {
backgroundColor: 'transparent',
paddingVertical: 16,
paddingHorizontal: 24,
borderRadius: 24,
alignItems: 'center',
justifyContent: 'center',
borderWidth: 1.5,
borderColor: '#2E3142',
},
secondaryButtonText: {
fontSize: 16,
fontWeight: '600',
color: '#2E3142',
},
referencesSection: {
marginTop: 24,
marginBottom: 20,
},
referencesButton: {
borderRadius: 20,
overflow: 'hidden',
},
referencesGlass: {
borderRadius: 20,
paddingVertical: 16,
paddingHorizontal: 20,
},
referencesFallback: {
backgroundColor: 'rgba(246, 248, 250, 0.8)',
borderWidth: 1,
borderColor: 'rgba(46, 49, 66, 0.1)',
},
referencesContent: {
flexDirection: 'row',
alignItems: 'center',
},
referencesText: {
flex: 1,
fontSize: 16,
fontWeight: '600',
color: '#2E3142',
marginLeft: 12,
marginRight: 8,
},
});

View File

@@ -1,150 +0,0 @@
import { PlanCard } from '@/components/PlanCard';
import { SearchBox } from '@/components/SearchBox';
import { ThemedText } from '@/components/ThemedText';
import { ThemedView } from '@/components/ThemedView';
import { WorkoutCard } from '@/components/WorkoutCard';
import { getChineseGreeting } from '@/utils/date';
import { useRouter } from 'expo-router';
import React from 'react';
import { SafeAreaView, ScrollView, StyleSheet, View } from 'react-native';
const workoutData = [
{
id: 1,
title: 'AI体态评估',
duration: 5,
imageSource: 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/Imagettpg.png',
},
{
id: 2,
title: '认证教练',
imageSource: require('@/assets/images/react-logo.png'),
}
];
export default function HomeScreen() {
const router = useRouter();
return (
<SafeAreaView style={styles.safeArea}>
<ThemedView style={styles.container}>
<ScrollView showsVerticalScrollIndicator={false}>
{/* Header Section */}
<View style={styles.header}>
<ThemedText style={styles.greeting}>{getChineseGreeting()} 🔥</ThemedText>
<ThemedText style={styles.userName}></ThemedText>
</View>
{/* Search Box */}
<SearchBox placeholder="搜索" />
{/* Popular Workouts Section */}
<View style={styles.sectionContainer}>
<ThemedText style={styles.sectionTitle}></ThemedText>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.workoutScrollContainer}
style={styles.workoutScroll}
>
{workoutData.map((workout) => (
<WorkoutCard
key={workout.id}
title={workout.title}
duration={workout.duration}
imageSource={workout.imageSource}
onPress={() => {
if (workout.title === 'AI体态评估') {
router.push('/ai-posture-assessment');
} else if (workout.title === '认证教练') {
router.push('/health-consultation' as any);
} else {
console.log(`Pressed ${workout.title}`);
}
}}
/>
))}
</ScrollView>
</View>
{/* Today Plan Section */}
<View style={styles.sectionContainer}>
<ThemedText style={styles.sectionTitle}></ThemedText>
<View style={styles.planList}>
<PlanCard
image={'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/Imagettpg.png'}
title="体态评估"
subtitle="评估你的体态,制定训练计划"
level="初学者"
progress={0}
/>
<PlanCard
image={'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/Image30play@2x.png'}
title="30日训练打卡"
subtitle="坚持30天养成训练习惯"
level="初学者"
progress={0.75}
/>
</View>
</View>
{/* Add some spacing at the bottom */}
<View style={styles.bottomSpacing} />
</ScrollView>
</ThemedView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safeArea: {
flex: 1,
backgroundColor: '#F7F8FA',
},
container: {
flex: 1,
backgroundColor: '#F7F8FA',
},
header: {
paddingHorizontal: 24,
paddingTop: 16,
paddingBottom: 8,
},
greeting: {
fontSize: 16,
color: '#8A8A8E',
fontWeight: '400',
marginBottom: 6,
},
userName: {
fontSize: 30,
fontWeight: 'bold',
color: '#1A1A1A',
lineHeight: 36,
},
sectionContainer: {
marginTop: 24,
},
sectionTitle: {
fontSize: 24,
fontWeight: 'bold',
color: '#1A1A1A',
paddingHorizontal: 24,
marginBottom: 18,
},
planList: {
paddingHorizontal: 24,
},
workoutScroll: {
paddingLeft: 24,
},
workoutScrollContainer: {
paddingRight: 24,
},
bottomSpacing: {
height: 120,
},
});

545
app/(tabs)/medications.tsx Normal file
View File

@@ -0,0 +1,545 @@
import CelebrationAnimation, { CelebrationAnimationRef } from '@/components/CelebrationAnimation';
import { DateSelector } from '@/components/DateSelector';
import { MedicationCard } from '@/components/medication/MedicationCard';
import { ThemedText } from '@/components/ThemedText';
import { IconSymbol } from '@/components/ui/IconSymbol';
import { MedicalDisclaimerSheet } from '@/components/ui/MedicalDisclaimerSheet';
import { Colors } from '@/constants/Colors';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useColorScheme } from '@/hooks/useColorScheme';
import { medicationNotificationService } from '@/services/medicationNotifications';
import { fetchMedicationRecords, fetchMedications, selectMedicationDisplayItemsByDate } from '@/store/medicationsSlice';
import { DEFAULT_MEMBER_NAME } from '@/store/userSlice';
import { getItemSync, setItemSync } from '@/utils/kvStore';
import { convertMedicationDataToWidget, refreshWidget, syncMedicationDataToWidget } from '@/utils/widgetDataSync';
import { useFocusEffect } from '@react-navigation/native';
import dayjs, { Dayjs } from 'dayjs';
import 'dayjs/locale/zh-cn';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient';
import { router } from 'expo-router';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
ScrollView,
StyleSheet,
TouchableOpacity,
View,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
dayjs.locale('zh-cn');
// 本地存储键名:医疗免责声明已读状态
const MEDICAL_DISCLAIMER_READ_KEY = 'medical_disclaimer_read';
type MedicationFilter = 'all' | 'taken' | 'missed';
type ThemeColors = (typeof Colors)[keyof typeof Colors];
export default function MedicationsScreen() {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const insets = useSafeAreaInsets();
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colors: ThemeColors = Colors[theme];
const userProfile = useAppSelector((state) => state.user.profile);
const [selectedDate, setSelectedDate] = useState<Dayjs>(dayjs());
const [selectedDateIndex, setSelectedDateIndex] = useState<number>(selectedDate.date() - 1);
const [activeFilter, setActiveFilter] = useState<MedicationFilter>('all');
const celebrationRef = useRef<CelebrationAnimationRef>(null);
const celebrationTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [isCelebrationVisible, setIsCelebrationVisible] = useState(false);
const [disclaimerVisible, setDisclaimerVisible] = useState(false);
// 从 Redux 获取数据
const selectedKey = selectedDate.format('YYYY-MM-DD');
const medicationsForDay = useAppSelector((state) => selectMedicationDisplayItemsByDate(selectedKey)(state));
const handleOpenAddMedication = useCallback(() => {
// 检查是否已经读过免责声明
const hasRead = getItemSync(MEDICAL_DISCLAIMER_READ_KEY);
if (hasRead === 'true') {
// 已读过,直接跳转
router.push('/medications/add-medication');
} else {
// 未读过,显示医疗免责声明弹窗
setDisclaimerVisible(true);
}
}, []);
const handleDisclaimerConfirm = useCallback(() => {
// 用户同意免责声明后,记录已读状态,关闭弹窗并跳转到添加页面
setItemSync(MEDICAL_DISCLAIMER_READ_KEY, 'true');
setDisclaimerVisible(false);
router.push('/medications/add-medication');
}, []);
const handleDisclaimerClose = useCallback(() => {
// 用户不接受免责声明,只关闭弹窗,不跳转,不记录已读状态
setDisclaimerVisible(false);
}, []);
const handleOpenMedicationManagement = useCallback(() => {
router.push('/medications/manage-medications');
}, []);
const handleOpenMedicationDetails = useCallback((medicationId: string) => {
router.push({
pathname: '/medications/[medicationId]',
params: { medicationId },
});
}, []);
const handleMedicationTakenCelebration = useCallback(() => {
if (celebrationTimerRef.current) {
clearTimeout(celebrationTimerRef.current);
}
setIsCelebrationVisible(true);
requestAnimationFrame(() => {
celebrationRef.current?.play();
});
celebrationTimerRef.current = setTimeout(() => {
setIsCelebrationVisible(false);
}, 2400);
}, []);
// 加载药物和记录数据
useEffect(() => {
dispatch(fetchMedications());
dispatch(fetchMedicationRecords({ date: selectedKey }));
}, [dispatch, selectedKey]);
useEffect(() => {
return () => {
if (celebrationTimerRef.current) {
clearTimeout(celebrationTimerRef.current);
}
};
}, []);
// 页面聚焦时刷新数据,确保从添加页面返回时能看到最新数据
useFocusEffect(
useCallback(() => {
// 重新安排药品通知并刷新数据
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 today = dayjs().format('YYYY-MM-DD');
const records = recordsAction.payload as any;
if (selectedKey === today && records?.records) {
const medicationData = convertMedicationDataToWidget(
records.records,
medications,
selectedKey
);
await syncMedicationDataToWidget(medicationData);
// 刷新小组件
await refreshWidget();
}
} catch (error) {
console.error('刷新数据或重新安排药品通知失败:', error);
}
};
refreshDataAndRescheduleNotifications();
}, [dispatch, selectedKey])
);
useEffect(() => {
setActiveFilter('all');
}, [selectedDate]);
// 为每个药物添加默认图片(如果没有图片)
const medicationsWithImages = useMemo(() => {
return medicationsForDay.map((med: any) => ({
...med,
image: med.image || require('@/assets/images/medicine/image-medicine.png'), // 默认使用瓶子图标
}));
}, [medicationsForDay]);
const filteredMedications = useMemo(() => {
if (activeFilter === 'all') {
return medicationsWithImages;
}
// "未服用" tab 包含 missed已错过和 upcoming待服用两种状态
if (activeFilter === 'missed') {
return medicationsWithImages.filter((item: any) =>
item.status === 'missed' || item.status === 'upcoming'
);
}
// 其他状态按原逻辑过滤
return medicationsWithImages.filter((item: any) => item.status === activeFilter);
}, [activeFilter, medicationsWithImages]);
const counts = useMemo(() => {
const taken = medicationsWithImages.filter((item: any) => item.status === 'taken').length;
// "未服用"计数包含 missed已错过和 upcoming待服用
const missed = medicationsWithImages.filter((item: any) =>
item.status === 'missed' || item.status === 'upcoming'
).length;
return {
all: medicationsWithImages.length,
taken,
missed,
};
}, [medicationsWithImages]);
const displayName = userProfile.name?.trim() || DEFAULT_MEMBER_NAME;
const headerDateLabel = selectedDate.isSame(dayjs(), 'day')
? t('medications.dateFormats.today', { date: selectedDate.format('M月D日') })
: t('medications.dateFormats.other', { date: selectedDate.format('M月D日 dddd') });
const emptyState = filteredMedications.length === 0;
return (
<View style={styles.container}>
{isCelebrationVisible ? (
<CelebrationAnimation ref={celebrationRef} visible={isCelebrationVisible} />
) : null}
{/* 背景渐变 */}
<LinearGradient
colors={['#f5e5fbff', '#edf4f4ff', '#ffffff']}
style={styles.gradientBackground}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
/>
{/* 装饰性圆圈 */}
<View style={styles.decorativeCircle1} />
<View style={styles.decorativeCircle2} />
<ScrollView
contentContainerStyle={[
styles.scrollContent,
{ paddingTop: insets.top + 24, paddingBottom: insets.bottom + 36 },
]}
showsVerticalScrollIndicator={false}
>
<View style={styles.header}>
<View>
<ThemedText style={styles.greeting}>{t('medications.greeting', { name: displayName })}</ThemedText>
<ThemedText style={[styles.welcome, { color: colors.textMuted }]}>
{t('medications.welcome')}
</ThemedText>
</View>
<View style={styles.headerActions}>
<TouchableOpacity
activeOpacity={0.7}
onPress={handleOpenMedicationManagement}
>
{isLiquidGlassAvailable() ? (
<GlassView
style={styles.headerAddButton}
glassEffectStyle="clear"
tintColor="rgba(255, 255, 255, 0.3)"
isInteractive={true}
>
<IconSymbol name="pills.fill" size={18} color="#333" />
</GlassView>
) : (
<View style={[styles.headerAddButton, styles.fallbackAddButton]}>
<IconSymbol name="pills.fill" size={18} color="#333" />
</View>
)}
</TouchableOpacity>
<TouchableOpacity
activeOpacity={0.7}
onPress={handleOpenAddMedication}
>
{isLiquidGlassAvailable() ? (
<GlassView
style={styles.headerAddButton}
glassEffectStyle="clear"
tintColor="rgba(255, 255, 255, 0.3)"
isInteractive={true}
>
<IconSymbol name="plus" size={18} color="#333" />
</GlassView>
) : (
<View style={[styles.headerAddButton, styles.fallbackAddButton]}>
<IconSymbol name="plus" size={18} color="#333" />
</View>
)}
</TouchableOpacity>
</View>
</View>
<View style={styles.sectionSpacing}>
<DateSelector
selectedIndex={selectedDateIndex}
onDateSelect={(index, date) => {
setSelectedDate(dayjs(date));
setSelectedDateIndex(index);
}}
disableFutureDates
containerStyle={styles.dateSelectorContainer}
/>
</View>
<View style={styles.sectionSpacing}>
<ThemedText style={styles.sectionHeader}>{t('medications.todayMedications')}</ThemedText>
<View style={[styles.segmentedControl, { backgroundColor: colors.surface }]}>
{(['all', 'taken', 'missed'] as MedicationFilter[]).map((filter) => {
const isActive = activeFilter === filter;
return (
<TouchableOpacity
key={filter}
onPress={() => setActiveFilter(filter)}
style={[
styles.segment,
isActive && { backgroundColor: colors.primary },
]}
>
<ThemedText
style={[
styles.segmentLabel,
{ color: isActive ? colors.onPrimary : colors.textSecondary },
]}
>
{t(`medications.filters.${filter}`)}
</ThemedText>
<View
style={[
styles.segmentBadge,
{
backgroundColor: isActive ? colors.onPrimary : `${colors.primary}20`,
},
]}
>
<ThemedText
style={[
styles.segmentBadgeText,
{ color: isActive ? colors.primary : colors.textSecondary },
]}
>
{counts[filter]}
</ThemedText>
</View>
</TouchableOpacity>
);
})}
</View>
</View>
{emptyState ? (
<View style={[styles.emptyState, { backgroundColor: colors.surface }]}>
<Image
source={require('@/assets/images/task/ImageEmpty.png')}
style={styles.emptyIllustration}
contentFit="cover"
/>
<ThemedText style={styles.emptyTitle}>{t('medications.emptyState.title')}</ThemedText>
<ThemedText style={[styles.emptySubtitle, { color: colors.textMuted }]}>
{t('medications.emptyState.subtitle')}
</ThemedText>
</View>
) : (
<View style={styles.cardsWrapper}>
{filteredMedications.map((item: any) => (
<MedicationCard
key={item.id}
medication={item}
colors={colors}
selectedDate={selectedDate}
onOpenDetails={() => handleOpenMedicationDetails(item.medicationId)}
onCelebrate={handleMedicationTakenCelebration}
/>
))}
</View>
)}
</ScrollView>
{/* 医疗免责声明弹窗 */}
<MedicalDisclaimerSheet
visible={disclaimerVisible}
onClose={handleDisclaimerClose}
onConfirm={handleDisclaimerConfirm}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
gradientBackground: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
},
decorativeCircle1: {
position: 'absolute',
top: 40,
right: 20,
width: 60,
height: 60,
borderRadius: 30,
backgroundColor: '#0EA5E9',
opacity: 0.1,
},
decorativeCircle2: {
position: 'absolute',
bottom: -15,
left: -15,
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: '#0EA5E9',
opacity: 0.05,
},
scrollContent: {
paddingHorizontal: 20,
gap: 24,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
headerActions: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
},
headerAddButton: {
width: 32,
height: 32,
borderRadius: 16,
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
},
fallbackAddButton: {
backgroundColor: 'rgba(255, 255, 255, 0.9)',
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.3)',
},
avatar: {
width: 60,
height: 60,
borderRadius: 30,
},
greeting: {
fontSize: 24,
fontWeight: '600',
},
welcome: {
marginTop: 6,
fontSize: 14,
},
sectionSpacing: {
gap: 16,
},
dateSelectorContainer: {
paddingRight: 0,
},
sectionTitle: {
fontSize: 16,
fontWeight: '500',
},
sectionHeader: {
fontSize: 20,
fontWeight: '600',
},
segmentedControl: {
flexDirection: 'row',
borderRadius: 18,
padding: 6,
gap: 6,
},
segment: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
borderRadius: 14,
paddingVertical: 10,
},
segmentLabel: {
fontSize: 14,
fontWeight: '600',
},
segmentBadge: {
minWidth: 24,
height: 24,
borderRadius: 12,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 6,
},
segmentBadgeText: {
fontSize: 12,
fontWeight: '600',
},
emptyState: {
alignItems: 'center',
paddingHorizontal: 24,
paddingVertical: 32,
borderRadius: 24,
gap: 16,
},
emptyIllustration: {
width: 160,
height: 160,
resizeMode: 'contain',
},
emptyTitle: {
textAlign: 'center',
fontSize: 18,
fontWeight: '600',
},
emptySubtitle: {
textAlign: 'center',
fontSize: 14,
lineHeight: 20,
},
primaryButton: {
marginTop: 8,
paddingVertical: 14,
paddingHorizontal: 32,
borderRadius: 22,
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
primaryButtonText: {
fontSize: 16,
fontWeight: '600',
},
cardsWrapper: {
gap: 16,
},
loadingContainer: {
alignItems: 'center',
paddingHorizontal: 24,
paddingVertical: 48,
borderRadius: 24,
gap: 16,
},
loadingText: {
fontSize: 14,
},
});

File diff suppressed because it is too large Load Diff

911
app/(tabs)/statistics.tsx Normal file
View File

@@ -0,0 +1,911 @@
import { BasalMetabolismCard } from '@/components/BasalMetabolismCard';
import { DateSelector } from '@/components/DateSelector';
import { FitnessRingsCard } from '@/components/FitnessRingsCard';
import { MoodCard } from '@/components/MoodCard';
import { NutritionRadarCard } from '@/components/NutritionRadarCard';
import CircumferenceCard from '@/components/statistic/CircumferenceCard';
import OxygenSaturationCard from '@/components/statistic/OxygenSaturationCard';
import SleepCard from '@/components/statistic/SleepCard';
import StepsCard from '@/components/StepsCard';
import { StressMeter } from '@/components/StressMeter';
import WaterIntakeCard from '@/components/WaterIntakeCard';
import { WeightHistoryCard } from '@/components/weight/WeightHistoryCard';
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 { logger } from '@/utils/logger';
import dayjs from 'dayjs';
import { LinearGradient } from 'expo-linear-gradient';
import { debounce } from 'lodash';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
AppState,
Image,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
// 浮动动画组件
const FloatingCard = ({ children, style }: {
children: React.ReactNode;
style?: any;
}) => {
return (
<View
style={[
style,
{
marginBottom: 8,
},
]}
>
{children}
</View>
);
};
export default function ExploreScreen() {
const { t } = useTranslation();
const stepGoal = useAppSelector((s) => s.user.profile?.dailyStepsGoal) ?? 2000;
const userProfile = useAppSelector((s) => s.user.profile);
const { pushIfAuthedElseLogin, isLoggedIn } = useAuthGuard();
// 使用 dayjs当月日期与默认选中"今天"
const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth());
// const tabBarHeight = useBottomTabBarHeight();
const insets = useSafeAreaInsets();
// 获取当前选中日期 - 使用 useMemo 缓存避免重复计算
const currentSelectedDate = useMemo(() => {
const days = getMonthDaysZh();
return days[selectedIndex]?.date?.toDate() ?? new Date();
}, [selectedIndex]);
const currentSelectedDateString = useMemo(() => {
return dayjs(currentSelectedDate).format('YYYY-MM-DD');
}, [currentSelectedDate]);
// 用于触发动画重置的 token当日期或数据变化时更新
const [animToken, setAnimToken] = useState(0);
// 心情相关状态
const dispatch = useAppDispatch();
const [isMoodLoading, setIsMoodLoading] = useState(false);
// 记录最近一次请求的"日期键",避免旧请求覆盖新结果
const latestRequestKeyRef = useRef<string | null>(null);
// 请求状态管理,防止重复请求
const loadingRef = useRef({
health: false,
mood: false
});
// 数据缓存时间戳,避免短时间内重复拉取
const dataTimestampRef = useRef<{ [key: string]: number }>({});
const getDateKey = (d: Date) => `${dayjs(d).year()}-${dayjs(d).month() + 1}-${dayjs(d).date()}`;
// 检查数据是否需要刷新5分钟内不重复拉取
const shouldRefreshData = (dateKey: string, dataType: string) => {
const cacheKey = `${dateKey}-${dataType}`;
const lastUpdate = dataTimestampRef.current[cacheKey];
const now = Date.now();
// 使用5分钟缓存时间
const cacheTime = 5 * 60 * 1000;
return !lastUpdate || (now - lastUpdate) > cacheTime;
};
// 更新数据时间戳
const updateDataTimestamp = (dateKey: string, dataType: string) => {
const cacheKey = `${dateKey}-${dataType}`;
dataTimestampRef.current[cacheKey] = Date.now();
};
// 从 Redux 获取当前日期的心情记录
const currentMoodCheckin = useAppSelector(selectLatestMoodRecordByDate(
currentSelectedDateString
));
// 加载心情数据
const loadMoodData = async (targetDate?: Date, forceRefresh = false) => {
if (!isLoggedIn) return;
// 确定要查询的日期
let derivedDate: Date;
if (targetDate) {
derivedDate = targetDate;
} else {
derivedDate = currentSelectedDate;
}
const requestKey = getDateKey(derivedDate);
// 检查是否正在加载或不需要刷新
if (loadingRef.current.mood) {
console.log('心情数据正在加载中,跳过重复请求');
return;
}
if (!forceRefresh && !shouldRefreshData(requestKey, 'mood')) {
console.log('心情数据缓存未过期,跳过请求');
return;
}
try {
loadingRef.current.mood = true;
setIsMoodLoading(true);
const dateString = dayjs(derivedDate).format('YYYY-MM-DD');
await dispatch(fetchDailyMoodCheckins(dateString));
// 更新缓存时间戳
updateDataTimestamp(requestKey, 'mood');
} catch (error) {
console.error('加载心情数据失败:', error);
} finally {
loadingRef.current.mood = false;
setIsMoodLoading(false);
}
};
const loadHealthData = async (targetDate?: Date, forceRefresh = false) => {
// 确定要查询的日期
let derivedDate: Date;
if (targetDate) {
derivedDate = targetDate;
} else {
derivedDate = currentSelectedDate;
}
const requestKey = getDateKey(derivedDate);
// 检查是否正在加载或不需要刷新
if (loadingRef.current.health) {
console.log('健康数据正在加载中,跳过重复请求');
return;
}
if (!forceRefresh && !shouldRefreshData(requestKey, 'health')) {
console.log('健康数据缓存未过期,跳过请求');
return;
}
try {
loadingRef.current.health = true;
console.log('=== 开始HealthKit初始化流程 ===');
latestRequestKeyRef.current = requestKey;
console.log('权限获取成功,开始获取健康数据...', derivedDate);
const data = await fetchHealthDataForDate(derivedDate);
console.log('设置UI状态:', data);
// 仅当该请求仍是最新时,才应用结果
if (latestRequestKeyRef.current === requestKey) {
const dateString = dayjs(derivedDate).format('YYYY-MM-DD');
// 使用 Redux 存储健康数据
dispatch(setHealthData({
date: dateString,
data: {
activeCalories: data.activeEnergyBurned,
heartRate: data.heartRate,
activeEnergyBurned: data.activeEnergyBurned,
activeCaloriesGoal: data.activeCaloriesGoal,
exerciseMinutes: data.exerciseMinutes,
exerciseMinutesGoal: data.exerciseMinutesGoal,
standHours: data.standHours,
standHoursGoal: data.standHoursGoal,
}
}));
setAnimToken((t) => t + 1);
// 更新缓存时间戳
updateDataTimestamp(requestKey, 'health');
} else {
console.log('忽略过期健康数据请求结果key=', requestKey, '最新key=', latestRequestKeyRef.current);
}
console.log('=== HealthKit数据获取完成 ===');
} catch (error) {
console.error('HealthKit流程出现异常:', error);
} finally {
loadingRef.current.health = false;
}
};
// 加载营养数据
// 实际执行数据加载的方法
const executeLoadAllData = React.useCallback((targetDate?: Date, forceRefresh = false) => {
const dateToUse = targetDate || currentSelectedDate;
if (dateToUse) {
console.log('执行数据加载,日期:', dateToUse, '强制刷新:', forceRefresh);
loadHealthData(dateToUse, forceRefresh);
if (isLoggedIn) {
loadMoodData(dateToUse, forceRefresh);
// 加载喝水数据(只加载今日数据用于后台检查)
const isToday = dayjs(dateToUse).isSame(dayjs(), 'day');
if (isToday) {
dispatch(fetchTodayWaterStats());
}
}
}
}, [isLoggedIn, dispatch]);
// 使用 lodash debounce 防抖的加载所有数据方法
const debouncedLoadAllData = React.useMemo(
() => debounce(executeLoadAllData, 500), // 500ms 防抖延迟
[executeLoadAllData]
);
// 对外暴露的 loadAllData 方法
const loadAllData = React.useCallback((targetDate?: Date, forceRefresh = false) => {
if (forceRefresh) {
// 如果是强制刷新,立即执行,不使用防抖
executeLoadAllData(targetDate, forceRefresh);
} else {
// 普通调用使用防抖
debouncedLoadAllData(targetDate, forceRefresh);
}
}, [executeLoadAllData, debouncedLoadAllData]);
// 同步 HealthKit 数据到服务端(带智能 diff 比较)
const syncHealthDataToServer = React.useCallback(async () => {
if (!isLoggedIn || !userProfile) {
logger.info('用户未登录,跳过 HealthKit 数据同步');
return;
}
try {
logger.info('开始同步 HealthKit 个人健康数据到服务端...');
// 传入当前用户资料,用于 diff 比较
const success = await syncHealthKitToServer(
async (data) => {
await dispatch(updateUserProfile(data) as any);
},
userProfile // 传入当前用户资料进行比较
);
if (success) {
logger.info('HealthKit 数据同步到服务端成功');
} else {
logger.info('HealthKit 数据同步到服务端跳过(无变化)或失败');
}
} catch (error) {
logger.error('同步 HealthKit 数据到服务端失败:', error);
}
}, [isLoggedIn, dispatch, userProfile]);
// 初始加载时执行数据加载和同步
useEffect(() => {
loadAllData(currentSelectedDate);
// 延迟1秒后执行同步避免影响初始加载性能
const syncTimer = setTimeout(() => {
syncHealthDataToServer();
}, 1000);
return () => clearTimeout(syncTimer);
}, [])
// AppState 监听:应用从后台返回前台时的处理
useEffect(() => {
const handleAppStateChange = (nextAppState: string) => {
if (nextAppState === 'active') {
// 判断当前选中的日期是否是最新的(今天)
const todayIndex = getTodayIndexInMonth();
const isTodaySelected = selectedIndex === todayIndex;
if (!isTodaySelected) {
// 如果当前不是选中今天,则切换到今天(这个更新会触发数据加载)
console.log('应用回到前台,切换到今天并加载数据');
setSelectedIndex(todayIndex);
// 注意这里不直接调用loadAllData因为setSelectedIndex会触发useEffect重新计算currentSelectedDate
// 然后onSelectDate会被调用从而触发数据加载
} else {
// 如果已经是今天,则直接调用加载数据的方法
console.log('应用回到前台,当前已是今天,直接加载数据');
loadAllData(currentSelectedDate);
}
}
};
const subscription = AppState.addEventListener('change', handleAppStateChange);
return () => {
subscription?.remove();
};
}, [loadAllData, currentSelectedDate, selectedIndex]);
// 日期点击时,加载对应日期数据
const onSelectDate = React.useCallback((index: number, date: Date) => {
setSelectedIndex(index);
console.log('日期切换,加载数据...', date);
// 日期切换时不强制刷新,依赖缓存机制减少不必要的请求
// loadAllData 内部已经实现了防抖,无需额外防抖处理
loadAllData(date, false);
}, [loadAllData]);
return (
<View style={styles.container}>
{/* 背景渐变 */}
<LinearGradient
colors={['#f5e5fbff', '#edf4f4ff', '#f7f8f8ff']}
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={{
paddingTop: insets.top,
paddingBottom: 60,
paddingHorizontal: 20
}}
showsVerticalScrollIndicator={false}
>
{/* 顶部信息栏 */}
<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.headerTextContainer}>
<Text style={styles.headerTitle}>{t('statistics.title')}</Text>
</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>
<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>
{/* 日期选择器 */}
<DateSelector
selectedIndex={selectedIndex}
onDateSelect={onSelectDate}
showMonthTitle={false}
disableFutureDates={true}
/>
{/* 营养摄入雷达图卡片 */}
<NutritionRadarCard
selectedDate={currentSelectedDate}
resetToken={animToken}
/>
<WorkoutSummaryCard
date={currentSelectedDate}
style={styles.workoutCardOverride}
/>
{/* 身体指标section标题 */}
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>{t('statistics.sections.bodyMetrics')}</Text>
</View>
{/* 真正瀑布流布局 */}
<View style={styles.masonryContainer}>
{/* 左列 */}
<View style={styles.masonryColumn}>
{/* 心情卡片 */}
<FloatingCard style={styles.masonryCard}>
<MoodCard
moodCheckin={currentMoodCheckin}
onPress={() => pushIfAuthedElseLogin('/mood/calendar')}
isLoading={isMoodLoading}
/>
</FloatingCard>
<FloatingCard style={styles.masonryCard}>
<StepsCard
curDate={currentSelectedDate}
stepGoal={stepGoal}
style={styles.stepsCardOverride}
/>
</FloatingCard>
<FloatingCard style={styles.masonryCard}>
<StressMeter
curDate={currentSelectedDate}
/>
</FloatingCard>
{/* 心率卡片 */}
{/* <FloatingCard style={styles.masonryCard} delay={2000}>
<HeartRateCard
resetToken={animToken}
style={styles.basalMetabolismCardOverride}
heartRate={heartRate}
/>
</FloatingCard> */}
<FloatingCard style={styles.masonryCard}>
<SleepCard
selectedDate={currentSelectedDate}
/>
</FloatingCard>
</View>
{/* 右列 */}
<View style={styles.masonryColumn}>
<FloatingCard style={styles.masonryCard}>
<FitnessRingsCard
selectedDate={currentSelectedDate}
resetToken={animToken}
/>
</FloatingCard>
{/* 饮水记录卡片 */}
<FloatingCard style={styles.masonryCard}>
<WaterIntakeCard
selectedDate={currentSelectedDateString}
style={styles.waterCardOverride}
/>
</FloatingCard>
{/* 基础代谢卡片 */}
<FloatingCard style={styles.masonryCard}>
<BasalMetabolismCard
selectedDate={currentSelectedDate}
style={styles.basalMetabolismCardOverride}
/>
</FloatingCard>
{/* 血氧饱和度卡片 */}
<FloatingCard style={styles.masonryCard}>
<OxygenSaturationCard
style={styles.basalMetabolismCardOverride}
/>
</FloatingCard>
</View>
</View>
<WeightHistoryCard />
{/* 围度数据卡片 - 占满底部一行 */}
<CircumferenceCard style={styles.circumferenceCard} />
</ScrollView>
</View>
);
}
const primary = Colors.light.primary;
const styles = StyleSheet.create({
container: {
flex: 1,
},
gradientBackground: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
},
decorativeCircle1: {
position: 'absolute',
top: 40,
right: 20,
width: 60,
height: 60,
borderRadius: 30,
backgroundColor: '#0EA5E9',
opacity: 0.1,
},
decorativeCircle2: {
position: 'absolute',
bottom: -15,
left: -15,
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: '#0EA5E9',
opacity: 0.05,
},
scrollView: {
flex: 1,
},
headerContainer: {
marginBottom: 10,
},
headerContent: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
logoImage: {
width: 28,
height: 28,
borderRadius: 20,
},
headerTextContainer: {
flex: 1,
marginLeft: 12,
},
headerTitle: {
fontSize: 16,
fontWeight: '700',
color: '#192126',
},
debugButtonsContainer: {
flexDirection: 'row',
gap: 8,
},
debugButton: {
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: '#FF6B6B',
alignItems: 'center',
justifyContent: 'center',
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
hrvTestButton: {
backgroundColor: '#8B5CF6',
},
debugButtonText: {
fontSize: 12,
},
metricsRow: {
flexDirection: 'row',
justifyContent: 'space-between',
marginBottom: 16,
},
card: {
backgroundColor: '#0F1418',
borderRadius: 22,
padding: 18,
marginBottom: 16,
},
metricsLeft: {
flex: 1,
backgroundColor: '#EEE9FF',
borderRadius: 22,
padding: 18,
marginRight: 12,
},
metricsRight: {
width: 160,
gap: 12,
},
metricsRightCard: {
backgroundColor: '#FFFFFF',
borderRadius: 22,
padding: 16,
},
caloriesValue: {
color: '#192126',
fontSize: 18,
lineHeight: 18,
fontWeight: '600',
textAlignVertical: 'bottom'
},
caloriesUnit: {
color: '#515558ff',
fontSize: 12,
marginLeft: 4,
lineHeight: 18,
},
trainingContent: {
marginTop: 8,
width: 120,
height: 120,
borderRadius: 60,
alignItems: 'center',
justifyContent: 'center',
alignSelf: 'center',
},
trainingRingTrack: {
position: 'absolute',
width: '100%',
height: '100%',
borderRadius: 60,
borderWidth: 12,
borderColor: '#E2D9FD',
},
trainingRingProgress: {
position: 'absolute',
width: '100%',
height: '100%',
borderRadius: 60,
borderWidth: 12,
borderColor: 'transparent',
borderTopColor: '#8B74F3',
borderRightColor: '#8B74F3',
transform: [{ rotateZ: '45deg' }],
},
trainingPercent: {
fontSize: 18,
fontWeight: '800',
color: '#8B74F3',
},
cyclingHeader: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 12,
},
cyclingIconBadge: {
width: 30,
height: 30,
borderRadius: 6,
backgroundColor: primary,
alignItems: 'center',
justifyContent: 'center',
marginRight: 8,
},
cyclingTitle: {
color: '#FFFFFF',
fontSize: 20,
fontWeight: '800',
},
mapArea: {
backgroundColor: 'rgba(255,255,255,0.08)',
borderRadius: 14,
height: 180,
padding: 8,
flexDirection: 'row',
flexWrap: 'wrap',
overflow: 'hidden',
},
mapTile: {
width: '25%',
height: '25%',
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.12)',
},
routeLine: {
position: 'absolute',
height: 6,
backgroundColor: primary,
borderRadius: 3,
},
cardHeaderRow: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 12,
},
iconSquare: {
width: 24,
height: 24,
borderRadius: 8,
backgroundColor: '#FFFFFF',
alignItems: 'center',
justifyContent: 'center',
marginRight: 10,
},
cardTitle: {
fontSize: 14,
color: '#192126',
},
heartCard: {
backgroundColor: '#FFE5E5',
},
waveContainer: {
flexDirection: 'row',
alignItems: 'flex-end',
height: 70,
gap: 6,
marginBottom: 8,
},
waveBar: {
width: 6,
borderRadius: 3,
backgroundColor: '#E54D4D',
},
heartValue: {
alignSelf: 'flex-end',
color: '#5B5B5B',
fontWeight: '600',
},
stepsValue: {
fontSize: 14,
color: '#7A6A42',
fontWeight: '700',
marginBottom: 8,
},
errorContainer: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#FFE5E5',
borderRadius: 12,
padding: 12,
marginBottom: 16,
},
errorText: {
fontSize: 14,
color: '#E54D4D',
fontWeight: '600',
marginLeft: 8,
flex: 1,
},
retryButton: {
padding: 4,
marginLeft: 8,
},
viewMoreContainer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
marginBottom: 16,
},
viewMoreText: {
fontSize: 14,
color: '#192126',
},
viewMoreIcon: {
fontSize: 16,
color: '#192126',
marginLeft: 4,
},
stressCardRow: {
flexDirection: 'row',
justifyContent: 'flex-start',
marginBottom: 16,
},
healthCardsRow: {
flexDirection: 'row',
justifyContent: 'space-between',
marginBottom: 16,
},
masonryContainer: {
flexDirection: 'row',
gap: 16,
marginTop: 6,
},
masonryColumn: {
flex: 1,
},
masonryCard: {
width: '100%',
backgroundColor: '#FFFFFF',
borderRadius: 16,
padding: 16,
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 4,
},
shadowOpacity: 0.12,
shadowRadius: 12,
elevation: 6,
minHeight: 100,
justifyContent: 'center',
marginTop: 6
},
basalMetabolismCardOverride: {
margin: -16, // 抵消 masonryCard 的 padding
borderRadius: 16,
},
stepsCardOverride: {
margin: -16, // 抵消 masonryCard 的 padding
borderRadius: 16,
height: '100%', // 填充整个masonryCard
},
workoutCardOverride: {
marginTop: 16,
},
waterCardOverride: {
margin: -16, // 抵消 masonryCard 的 padding
borderRadius: 16,
height: '100%', // 填充整个masonryCard
},
compactStepsCard: {
minHeight: 100,
},
stepsContent: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginTop: 8,
},
weightCard: {
backgroundColor: '#F0F9FF',
},
weightValue: {
fontSize: 22,
color: '#0369A1',
fontWeight: '800',
marginTop: 8,
},
addWeightButton: {
position: 'absolute',
right: 0,
top: 0,
padding: 4,
},
circumferenceCard: {
marginBottom: 36,
marginTop: 16
},
sectionHeader: {
marginTop: 24,
paddingHorizontal: 4,
},
sectionTitle: {
fontSize: 18,
fontWeight: '700',
color: '#192126',
textAlign: 'left',
},
});

View File

@@ -1,13 +1,469 @@
import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native';
import '@/i18n';
import { DefaultTheme, ThemeProvider } from '@react-navigation/native';
import { useFonts } from 'expo-font';
import { Stack } from 'expo-router';
import { Stack, useRouter } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import 'react-native-reanimated';
import { useColorScheme } from '@/hooks/useColorScheme';
import PrivacyConsentModal from '@/components/PrivacyConsentModal';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useQuickActions } from '@/hooks/useQuickActions';
import { hrvMonitorService } from '@/services/hrvMonitor';
import { clearBadgeCount, notificationService } from '@/services/notifications';
import { setupQuickActions } from '@/services/quickActions';
import { sleepMonitorService } from '@/services/sleepMonitor';
import { initializeWaterRecordBridge } from '@/services/waterRecordBridge';
import { WaterRecordSource } from '@/services/waterRecords';
import { workoutMonitorService } from '@/services/workoutMonitor';
import { store } from '@/store';
import { hydrateActiveSchedule, selectActiveFastingSchedule } from '@/store/fastingSlice';
import { fetchMyProfile, logout, setPrivacyAgreed } from '@/store/userSlice';
import { createWaterRecordAction } from '@/store/waterSlice';
import { loadActiveFastingSchedule } from '@/utils/fasting';
import { initializeHealthPermissions } from '@/utils/health';
import { MoodNotificationHelpers, NutritionNotificationHelpers, WaterNotificationHelpers } from '@/utils/notificationHelpers';
import { clearPendingWaterRecords, syncPendingWidgetChanges } from '@/utils/widgetDataSync';
import React, { useEffect } from 'react';
import { AppState, AppStateStatus } from 'react-native';
import { DialogProvider } from '@/components/ui/DialogProvider';
import { MembershipModalProvider } from '@/contexts/MembershipModalContext';
import { ToastProvider } from '@/contexts/ToastContext';
import { useAuthGuard } from '@/hooks/useAuthGuard';
import { STORAGE_KEYS, setUnauthorizedHandler } from '@/services/api';
import { BackgroundTaskManager } from '@/services/backgroundTaskManagerV2';
import { fetchChallenges } from '@/store/challengesSlice';
import AsyncStorage from '@/utils/kvStore';
import { logger } from '@/utils/logger';
import { Provider } from 'react-redux';
// 在开发环境中导入调试工具
let BackgroundTaskDebugger: any = null;
if (__DEV__) {
try {
const debuggerModule = require('@/services/backgroundTaskDebugger');
BackgroundTaskDebugger = debuggerModule.BackgroundTaskDebugger;
} catch (error) {
logger.warn('无法导入后台任务调试工具:', error);
}
}
function Bootstrapper({ children }: { children: React.ReactNode }) {
const dispatch = useAppDispatch();
const router = useRouter();
const { profile, onboardingCompleted } = useAppSelector((state) => state.user);
const activeFastingSchedule = useAppSelector(selectActiveFastingSchedule);
const [showPrivacyModal, setShowPrivacyModal] = React.useState(false);
const { isLoggedIn } = useAuthGuard()
const fastingHydrationRequestedRef = React.useRef(false);
const permissionInitializedRef = React.useRef(false);
// 初始化快捷动作处理
useQuickActions();
// 注册401未授权处理器应用启动时执行一次
React.useEffect(() => {
const handle401 = async () => {
try {
logger.info('[401处理] 开始处理登录过期');
// 清除Redux状态
await dispatch(logout());
// 跳转到登录页
router.push('/auth/login');
logger.info('[401处理] 登录过期处理完成');
} catch (error) {
logger.error('[401处理] 处理失败:', error);
}
};
setUnauthorizedHandler(handle401);
logger.info('[401处理器] 已注册到API服务');
}, [dispatch, router]);
React.useEffect(() => {
if (fastingHydrationRequestedRef.current) return;
if (activeFastingSchedule) {
fastingHydrationRequestedRef.current = true;
return;
}
fastingHydrationRequestedRef.current = true;
let cancelled = false;
const hydrate = async () => {
try {
const stored = await loadActiveFastingSchedule();
if (cancelled || !stored) return;
if (store.getState().fasting.activeSchedule) return;
dispatch(hydrateActiveSchedule(stored));
} catch (error) {
logger.warn('恢复断食计划失败:', error);
}
};
hydrate();
return () => {
cancelled = true;
};
}, [dispatch, activeFastingSchedule]);
useEffect(() => {
if (isLoggedIn) {
dispatch(fetchChallenges());
}
}, [isLoggedIn]);
// ==================== 基础服务初始化(不需要权限,总是执行)====================
React.useEffect(() => {
const initializeBasicServices = async () => {
try {
logger.info('🚀 开始初始化基础服务(不需要权限)...');
// 1. 加载用户数据(首屏展示需要)
await dispatch(fetchMyProfile());
logger.info('✅ 用户数据加载完成');
// 2. 初始化 HealthKit 权限系统(不请求权限,仅初始化)
initializeHealthPermissions();
logger.info('✅ HealthKit 权限系统初始化完成');
// 3. 初始化快捷动作(用户可能立即使用)
await setupQuickActions();
logger.info('✅ 快捷动作初始化完成');
// 5. 初始化喝水记录 Bridge
initializeWaterRecordBridge();
logger.info('✅ 喝水记录 Bridge 初始化完成');
logger.info('🎉 基础服务初始化完成');
} catch (error) {
logger.error('❌ 基础服务初始化失败:', error);
}
};
initializeBasicServices();
}, [dispatch]);
// ==================== 应用状态监听 - 进入前台时清除角标 ====================
React.useEffect(() => {
const subscription = AppState.addEventListener('change', (nextAppState: AppStateStatus) => {
if (nextAppState === 'active') {
// 应用进入前台时清除角标
clearBadgeCount();
}
});
return () => {
subscription.remove();
};
}, []);
// ==================== 权限相关服务初始化(应用启动时执行)====================
React.useEffect(() => {
// 如果已经初始化过,则跳过(确保只初始化一次)
if (permissionInitializedRef.current) {
return;
}
permissionInitializedRef.current = true;
const delay = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms));
// ==================== 辅助函数 ====================
// 异步同步 Widget 数据(不阻塞主流程)
const syncWidgetDataInBackground = async () => {
try {
const widgetSync = await syncPendingWidgetChanges();
if (widgetSync.hasPendingChanges && widgetSync.pendingRecords) {
logger.info(`🔄 检测到 ${widgetSync.pendingRecords.length} 条待同步的水记录`);
// 异步处理每条记录
for (const record of widgetSync.pendingRecords) {
try {
await store.dispatch(createWaterRecordAction({
amount: record.amount,
recordedAt: record.recordedAt,
source: WaterRecordSource.Auto,
})).unwrap();
logger.info(`✅ 成功同步水记录: ${record.amount}ml at ${record.recordedAt}`);
} catch (error) {
logger.error('❌ 同步水记录失败:', error);
}
}
// 清除已同步的记录
await clearPendingWaterRecords();
logger.info('✅ 所有待同步的水记录已处理完成');
}
} catch (error) {
logger.error('❌ Widget 数据同步失败:', error);
}
};
// 批量注册所有通知提醒
const registerAllNotifications = async () => {
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 fastingSchedule = store.getState().fasting.activeSchedule;
if (fastingSchedule) {
logger.info('✅ 检测到活跃的断食计划,将通过页面 hook 自动安排通知');
}
logger.info('🎉 所有通知提醒注册完成');
} catch (error) {
logger.error('❌ 通知提醒注册失败:', error);
}
};
// 初始化后台任务管理器
const initializeBackgroundTaskManager = async () => {
try {
logger.info('⚙️ 初始化后台任务管理器...');
await BackgroundTaskManager.getInstance().initialize();
logger.info('✅ 后台任务管理器初始化成功');
// 简单的任务调度检查
const taskManager = BackgroundTaskManager.getInstance();
const status = await taskManager.getStatus();
if (status === 'available') {
const pendingRequests = await taskManager.getPendingRequests();
if (pendingRequests.length === 0) {
await taskManager.scheduleNextTask();
logger.info('✅ 已调度新的后台任务');
}
}
} catch (error) {
logger.error('❌ 后台任务管理器初始化失败:', error);
}
};
// 初始化健康监听服务(锻炼 + 睡眠)
const initializeHealthMonitoring = async () => {
try {
logger.info('💪 初始化健康监听服务...');
const [workoutResult, sleepResult, hrvResult] = await Promise.allSettled([
workoutMonitorService.initialize(),
sleepMonitorService.initialize(),
hrvMonitorService.initialize(),
]);
const workoutReady = workoutResult.status === 'fulfilled';
if (workoutReady) {
logger.info('✅ 锻炼监听服务初始化成功');
} else {
logger.error('❌ 锻炼监听服务初始化失败:', workoutResult.reason);
}
const sleepReady = sleepResult.status === 'fulfilled';
if (sleepReady) {
logger.info('✅ 睡眠监听服务初始化成功');
} else {
logger.error('❌ 睡眠监听服务初始化失败:', sleepResult.reason);
}
const hrvReady = hrvResult.status === 'fulfilled';
if (hrvReady) {
logger.info('✅ HRV 监听服务初始化成功');
} else {
logger.error('❌ HRV 监听服务初始化失败:', hrvResult.reason);
}
if (workoutReady && sleepReady && hrvReady) {
logger.info('🎉 健康监听服务初始化完成');
} else {
logger.warn('⚠️ 健康监听服务部分未能初始化成功,请检查上述错误日志');
}
return workoutReady && sleepReady && hrvReady;
} catch (error) {
logger.error('❌ 健康监听服务初始化失败:', error);
return false;
}
};
// 后台任务详细状态检查(空闲时执行)
const checkBackgroundTaskStatus = async () => {
try {
logger.info('🔍 检查后台任务详细状态...');
const taskManager = BackgroundTaskManager.getInstance();
const status = await taskManager.getStatus();
const statusText = await taskManager.checkStatus();
logger.info(`📊 后台任务状态: ${status} (${statusText})`);
// 检查上次执行时间
const lastCheckTime = await taskManager.getLastBackgroundCheckTime();
if (lastCheckTime) {
const timeSinceLastCheck = Date.now() - lastCheckTime;
const hoursSinceLastCheck = timeSinceLastCheck / (1000 * 60 * 60);
logger.info(`⏱️ 上次执行: ${new Date(lastCheckTime).toLocaleString()} (${hoursSinceLastCheck.toFixed(1)}小时前)`);
if (hoursSinceLastCheck > 24) {
logger.warn('⚠️ 超过24小时未执行后台任务请检查系统设置');
}
}
logger.info('✅ 后台任务状态检查完成');
} catch (error) {
logger.error('❌ 后台任务状态检查失败:', error);
}
};
// 权限服务初始化
const initializePermissionServices = async () => {
try {
logger.info('🔐 开始初始化需要权限的服务...');
// 1. 初始化通知服务(包含权限请求)
await notificationService.initialize();
logger.info('✅ 通知服务初始化完成');
// 2. 异步同步 Widget 数据(不阻塞主流程)
syncWidgetDataInBackground();
logger.info('🎉 权限相关服务初始化完成');
logger.info('💡 HealthKit 权限将在用户首次访问健康数据时请求');
} catch (error) {
logger.error('❌ 权限相关服务初始化失败:', error);
throw error;
}
};
// ==================== 后台服务初始化(延迟执行)====================
const initializeBackgroundServices = () => {
const { InteractionManager } = require('react-native');
InteractionManager.runAfterInteractions(() => {
setTimeout(async () => {
try {
logger.info('📅 开始初始化后台服务...');
// 1. 批量注册所有通知提醒
await registerAllNotifications();
// 2. 初始化后台任务管理器
await initializeBackgroundTaskManager();
// 3. 初始化健康监听服务
await initializeHealthMonitoring();
logger.info('🎉 后台服务初始化完成');
} catch (error) {
logger.error('❌ 后台服务初始化失败:', error);
}
}, 3000);
});
};
// ==================== 空闲服务初始化====================
const initializeIdleServices = () => {
setTimeout(async () => {
try {
logger.info('🔄 开始初始化空闲服务...');
// 1. 后台任务详细状态检查
await checkBackgroundTaskStatus();
// 2. 开发环境调试工具
if (__DEV__ && BackgroundTaskDebugger) {
BackgroundTaskDebugger.getInstance().initialize();
logger.info('✅ 后台任务调试工具已初始化(开发环境)');
}
logger.info('🎉 空闲服务初始化完成');
} catch (error) {
logger.error('❌ 空闲服务初始化失败:', error);
}
}, 8000);
};
const runInitializationSequence = async () => {
try {
await initializePermissionServices();
} catch {
logger.warn('⚠️ 权限相关服务初始化失败,将继续启动后台和空闲服务以便后续重试');
}
// 交互完成后执行后台服务
initializeBackgroundServices();
// 空闲时执行非关键服务
initializeIdleServices();
};
runInitializationSequence();
}, []); // 每次应用启动都执行,不依赖其他状态
React.useEffect(() => {
const getPrivacyAgreed = async () => {
const str = await AsyncStorage.getItem(STORAGE_KEYS.privacyAgreed)
setShowPrivacyModal(str !== 'true');
}
getPrivacyAgreed();
}, []);
const handlePrivacyAgree = () => {
dispatch(setPrivacyAgreed());
setShowPrivacyModal(false);
};
const handlePrivacyDisagree = () => {
// RNExitApp.exitApp();
};
return (
<DialogProvider>
<MembershipModalProvider>
{children}
<PrivacyConsentModal
visible={showPrivacyModal}
onAgree={handlePrivacyAgree}
onDisagree={handlePrivacyDisagree}
/>
</MembershipModalProvider>
</DialogProvider>
);
}
export default function RootLayout() {
const colorScheme = useColorScheme();
const [loaded] = useFonts({
SpaceMono: require('../assets/fonts/SpaceMono-Regular.ttf'),
});
@@ -18,14 +474,40 @@ export default function RootLayout() {
}
return (
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
<Stack>
<Stack.Screen name="onboarding" options={{ headerShown: false }} />
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="ai-posture-assessment" options={{ headerShown: false }} />
<Stack.Screen name="+not-found" />
</Stack>
<StatusBar style="auto" />
</ThemeProvider>
<GestureHandlerRootView style={{ flex: 1 }}>
<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 }} />
<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>
</ToastProvider>
</Bootstrapper>
</Provider>
</GestureHandlerRootView>
);
}

View File

@@ -1,531 +0,0 @@
import { Ionicons } from '@expo/vector-icons';
import { BlurView } from 'expo-blur';
import * as ImagePicker from 'expo-image-picker';
import { useRouter } from 'expo-router';
import React, { useEffect, useMemo, useState } from 'react';
import {
Alert,
Image,
Linking,
Platform,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Colors } from '@/constants/Colors';
type PoseView = 'front' | 'side' | 'back';
type UploadState = {
front?: string | null;
side?: string | null;
back?: string | null;
};
type Sample = { uri: string; correct: boolean };
const SAMPLES: Record<PoseView, Sample[]> = {
front: [
{ uri: 'https://images.unsplash.com/photo-1594737625785-c6683fc87c73?w=400&q=80&auto=format', correct: true },
{ uri: 'https://images.unsplash.com/photo-1544716278-ca5e3f4abd8c?w=400&q=80&auto=format', correct: false },
{ uri: 'https://images.unsplash.com/photo-1571019614242-c5c5dee9f50b?w=400&q=80&auto=format', correct: false },
],
side: [
{ uri: 'https://images.unsplash.com/photo-1554463529-e27854014799?w=400&q=80&auto=format', correct: true },
{ uri: 'https://images.unsplash.com/photo-1596357395104-5bcae0b1a5eb?w=400&q=80&auto=format', correct: false },
{ uri: 'https://images.unsplash.com/photo-1526506118085-60ce8714f8c5?w=400&q=80&auto=format', correct: false },
],
back: [
{ uri: 'https://images.unsplash.com/photo-1517836357463-d25dfeac3438?w=400&q=80&auto=format', correct: true },
{ uri: 'https://images.unsplash.com/photo-1571721797421-f4c9f2b13107?w=400&q=80&auto=format', correct: false },
{ uri: 'https://images.unsplash.com/photo-1518611012118-696072aa579a?w=400&q=80&auto=format', correct: false },
],
};
export default function AIPostureAssessmentScreen() {
const router = useRouter();
const insets = useSafeAreaInsets();
const theme = Colors.dark;
const [uploadState, setUploadState] = useState<UploadState>({});
const canStart = useMemo(
() => Boolean(uploadState.front && uploadState.side && uploadState.back),
[uploadState]
);
const [cameraPerm, setCameraPerm] = useState<ImagePicker.PermissionStatus | null>(null);
const [libraryPerm, setLibraryPerm] = useState<ImagePicker.PermissionStatus | null>(null);
const [libraryAccess, setLibraryAccess] = useState<'all' | 'limited' | 'none' | null>(null);
const [cameraCanAsk, setCameraCanAsk] = useState<boolean | null>(null);
const [libraryCanAsk, setLibraryCanAsk] = useState<boolean | null>(null);
useEffect(() => {
(async () => {
const cam = await ImagePicker.getCameraPermissionsAsync();
const lib = await ImagePicker.getMediaLibraryPermissionsAsync();
setCameraPerm(cam.status);
setLibraryPerm(lib.status);
setLibraryAccess(
(lib as any).accessPrivileges ?? (lib.status === 'granted' ? 'all' : 'none')
);
setCameraCanAsk(cam.canAskAgain);
setLibraryCanAsk(lib.canAskAgain);
})();
}, []);
async function requestAllPermissions() {
try {
const cam = await ImagePicker.requestCameraPermissionsAsync();
const lib = await ImagePicker.requestMediaLibraryPermissionsAsync();
setCameraPerm(cam.status);
setLibraryPerm(lib.status);
setLibraryAccess(
(lib as any).accessPrivileges ?? (lib.status === 'granted' ? 'all' : 'none')
);
setCameraCanAsk(cam.canAskAgain);
setLibraryCanAsk(lib.canAskAgain);
const libGranted = lib.status === 'granted' || (lib as any).accessPrivileges === 'limited';
if (cam.status !== 'granted' || !libGranted) {
Alert.alert(
'权限未完全授予',
'请在系统设置中授予相机与相册权限以完成上传',
[
{ text: '取消', style: 'cancel' },
{ text: '去设置', onPress: () => Linking.openSettings() },
]
);
}
} catch { }
}
async function requestPermissionAndPick(source: 'camera' | 'library', key: PoseView) {
try {
if (source === 'camera') {
const resp = await ImagePicker.requestCameraPermissionsAsync();
setCameraPerm(resp.status);
setCameraCanAsk(resp.canAskAgain);
if (resp.status !== 'granted') {
Alert.alert(
'权限不足',
'需要相机权限以拍摄照片',
resp.canAskAgain
? [{ text: '好的' }]
: [
{ text: '取消', style: 'cancel' },
{ text: '去设置', onPress: () => Linking.openSettings() },
]
);
return;
}
const result = await ImagePicker.launchCameraAsync({
allowsEditing: true,
quality: 0.8,
aspect: [3, 4],
});
if (!result.canceled) {
setUploadState((s) => ({ ...s, [key]: result.assets[0]?.uri ?? null }));
}
} else {
const resp = await ImagePicker.requestMediaLibraryPermissionsAsync();
setLibraryPerm(resp.status);
setLibraryAccess(
(resp as any).accessPrivileges ?? (resp.status === 'granted' ? 'all' : 'none')
);
setLibraryCanAsk(resp.canAskAgain);
const libGranted = resp.status === 'granted' || (resp as any).accessPrivileges === 'limited';
if (!libGranted) {
Alert.alert(
'权限不足',
'需要相册权限以选择照片',
resp.canAskAgain
? [{ text: '好的' }]
: [
{ text: '取消', style: 'cancel' },
{ text: '去设置', onPress: () => Linking.openSettings() },
]
);
return;
}
const result = await ImagePicker.launchImageLibraryAsync({
allowsEditing: true,
quality: 0.8,
aspect: [3, 4],
});
if (!result.canceled) {
setUploadState((s) => ({ ...s, [key]: result.assets[0]?.uri ?? null }));
}
}
} catch (e) {
Alert.alert('发生错误', '选择图片失败,请重试');
}
}
function handleStart() {
if (!canStart) return;
// TODO: 调用后端或进入分析页面
Alert.alert('开始测评', '已收集三视角照片准备开始AI体态分析');
}
return (
<View style={[styles.screen, { backgroundColor: theme.background }]}>
{/* Header */}
<View style={[styles.header, { paddingTop: insets.top + 8 }]}>
<TouchableOpacity
accessibilityRole="button"
onPress={() => router.back()}
style={styles.backButton}
>
<Ionicons name="chevron-back" size={24} color="#ECEDEE" />
</TouchableOpacity>
<Text style={styles.headerTitle}>AI体态测评</Text>
<View style={{ width: 32 }} />
</View>
<ScrollView
contentContainerStyle={{ paddingBottom: insets.bottom + 120 }}
showsVerticalScrollIndicator={false}
>
{/* Permissions Banner (iOS 优先提示) */}
{Platform.OS === 'ios' && (
(cameraPerm !== 'granted' || !(libraryPerm === 'granted' || libraryAccess === 'limited')) && (
<BlurView intensity={18} tint="dark" style={styles.permBanner}>
<Text style={styles.permTitle}></Text>
<Text style={styles.permDesc}>
AI体态测评
</Text>
<View style={styles.permActions}>
{((cameraCanAsk ?? true) || (libraryCanAsk ?? true)) ? (
<TouchableOpacity style={styles.permPrimary} onPress={requestAllPermissions}>
<Text style={styles.permPrimaryText}></Text>
</TouchableOpacity>
) : (
<TouchableOpacity style={styles.permPrimary} onPress={() => Linking.openSettings()}>
<Text style={styles.permPrimaryText}></Text>
</TouchableOpacity>
)}
<TouchableOpacity style={styles.permSecondary} onPress={() => requestPermissionAndPick('library', 'front')}>
<Text style={styles.permSecondaryText}></Text>
</TouchableOpacity>
</View>
</BlurView>
)
)}
{/* Intro */}
<View style={styles.introBox}>
<Text style={styles.title}>姿</Text>
<Text style={styles.description}>
线
</Text>
</View>
{/* Upload sections */}
<UploadTile
label="正面"
value={uploadState.front}
onPickCamera={() => requestPermissionAndPick('camera', 'front')}
onPickLibrary={() => requestPermissionAndPick('library', 'front')}
samples={SAMPLES.front}
/>
<UploadTile
label="侧面"
value={uploadState.side}
onPickCamera={() => requestPermissionAndPick('camera', 'side')}
onPickLibrary={() => requestPermissionAndPick('library', 'side')}
samples={SAMPLES.side}
/>
<UploadTile
label="背面"
value={uploadState.back}
onPickCamera={() => requestPermissionAndPick('camera', 'back')}
onPickLibrary={() => requestPermissionAndPick('library', 'back')}
samples={SAMPLES.back}
/>
</ScrollView>
{/* Bottom CTA */}
<View style={[styles.bottomCtaWrap, { paddingBottom: insets.bottom + 10 }]}>
<TouchableOpacity
disabled={!canStart}
activeOpacity={1}
onPress={handleStart}
style={[
styles.bottomCta,
{ backgroundColor: canStart ? theme.primary : theme.neutral300 },
]}
>
<Text style={[styles.bottomCtaText, { color: canStart ? theme.onPrimary : theme.textMuted }]}>
{canStart ? '开始测评' : '请先完成三视角上传'}
</Text>
</TouchableOpacity>
</View>
</View>
);
}
function UploadTile({
label,
value,
onPickCamera,
onPickLibrary,
samples,
}: {
label: string;
value?: string | null;
onPickCamera: () => void;
onPickLibrary: () => void;
samples: Sample[];
}) {
return (
<View style={styles.section}>
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>{label}</Text>
{value ? (
<Text style={styles.retakeHint}></Text>
) : (
<Text style={styles.retakeHint}></Text>
)}
</View>
<TouchableOpacity
activeOpacity={0.95}
onLongPress={onPickLibrary}
onPress={onPickCamera}
style={styles.uploader}
>
{value ? (
<Image source={{ uri: value }} style={styles.preview} />
) : (
<View style={styles.placeholder}>
<View style={styles.plusBadge}>
<Ionicons name="camera" size={16} color="#192126" />
</View>
<Text style={styles.placeholderTitle}></Text>
<Text style={styles.placeholderDesc}></Text>
</View>
)}
</TouchableOpacity>
<BlurView intensity={18} tint="dark" style={styles.sampleBox}>
<Text style={styles.sampleTitle}></Text>
<View style={styles.sampleRow}>
{samples.map((s, idx) => (
<View key={idx} style={styles.sampleItem}>
<Image source={{ uri: s.uri }} style={styles.sampleImg} />
<View style={[styles.sampleTag, { backgroundColor: s.correct ? '#2BCC7F' : '#E24D4D' }]}>
<Text style={styles.sampleTagText}>{s.correct ? '正确示范' : '错误示范'}</Text>
</View>
</View>
))}
</View>
</BlurView>
</View>
);
}
const styles = StyleSheet.create({
screen: {
flex: 1,
},
permBanner: {
marginTop: 12,
marginHorizontal: 16,
padding: 14,
borderRadius: 16,
backgroundColor: 'rgba(255,255,255,0.04)'
},
permTitle: {
color: '#ECEDEE',
fontSize: 16,
fontWeight: '700',
},
permDesc: {
color: 'rgba(255,255,255,0.75)',
marginTop: 6,
fontSize: 13,
},
permActions: {
flexDirection: 'row',
gap: 10,
marginTop: 10,
},
permPrimary: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 14,
height: 40,
borderRadius: 12,
backgroundColor: '#BBF246',
},
permPrimaryText: {
color: '#192126',
fontSize: 14,
fontWeight: '800',
},
permSecondary: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 14,
height: 40,
borderRadius: 12,
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.18)',
},
permSecondaryText: {
color: 'rgba(255,255,255,0.85)',
fontSize: 14,
fontWeight: '700',
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 16,
},
backButton: {
width: 32,
height: 32,
borderRadius: 16,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(255,255,255,0.06)',
},
headerTitle: {
fontSize: 22,
color: '#ECEDEE',
fontWeight: '700',
},
introBox: {
marginTop: 12,
paddingHorizontal: 20,
gap: 10,
},
title: {
fontSize: 26,
color: '#ECEDEE',
fontWeight: '800',
},
description: {
fontSize: 15,
lineHeight: 22,
color: 'rgba(255,255,255,0.75)',
},
section: {
marginTop: 16,
paddingHorizontal: 16,
gap: 12,
},
sectionHeader: {
paddingHorizontal: 4,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
sectionTitle: {
color: '#ECEDEE',
fontSize: 18,
fontWeight: '700',
},
retakeHint: {
color: 'rgba(255,255,255,0.55)',
fontSize: 13,
},
uploader: {
height: 220,
borderRadius: 18,
borderWidth: 1,
borderStyle: 'dashed',
borderColor: 'rgba(255,255,255,0.18)',
backgroundColor: '#1E262C',
overflow: 'hidden',
},
preview: {
width: '100%',
height: '100%',
},
placeholder: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
gap: 8,
},
plusBadge: {
width: 36,
height: 36,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#BBF246',
},
placeholderTitle: {
color: '#ECEDEE',
fontSize: 16,
fontWeight: '700',
},
placeholderDesc: {
color: 'rgba(255,255,255,0.65)',
fontSize: 12,
},
sampleBox: {
marginTop: 8,
borderRadius: 16,
padding: 12,
backgroundColor: 'rgba(255,255,255,0.04)',
},
sampleTitle: {
color: 'rgba(255,255,255,0.8)',
fontSize: 14,
marginBottom: 8,
fontWeight: '600',
},
sampleRow: {
flexDirection: 'row',
gap: 10,
},
sampleItem: {
flex: 1,
},
sampleImg: {
width: '100%',
height: 90,
borderRadius: 12,
backgroundColor: '#111',
},
sampleTag: {
alignSelf: 'flex-start',
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 8,
marginTop: 6,
},
sampleTagText: {
color: '#192126',
fontSize: 12,
fontWeight: '700',
},
bottomCtaWrap: {
position: 'absolute',
left: 16,
right: 16,
bottom: 0,
},
bottomCta: {
height: 64,
borderRadius: 32,
alignItems: 'center',
justifyContent: 'center',
},
bottomCtaText: {
fontSize: 18,
fontWeight: '800',
},
});

126
app/article/[id].tsx Normal file
View File

@@ -0,0 +1,126 @@
import { HeaderBar } from '@/components/ui/HeaderBar';
import { Colors } from '@/constants/Colors';
import { useColorScheme } from '@/hooks/useColorScheme';
import { Article, getArticleById } from '@/services/articles';
import dayjs from 'dayjs';
import { useLocalSearchParams, useRouter } from 'expo-router';
import React, { useEffect, useState } from 'react';
import { ScrollView, StyleSheet, Text, View, useWindowDimensions } from 'react-native';
import RenderHTML from 'react-native-render-html';
export default function ArticleDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const router = useRouter();
const [article, setArticle] = useState<Article | undefined>(undefined);
const { width } = useWindowDimensions();
const colorScheme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const theme = Colors[colorScheme];
useEffect(() => {
if (id) {
getArticleById(id).then((article) => {
console.log('article', article);
setArticle(article);
});
}
}, [id]);
if (!article) {
return (
<View style={{ flex: 1 }}>
<HeaderBar title="文章" onBack={() => router.back()} showBottomBorder />
<View style={{ padding: 24 }}>
</View>
</View>
);
}
const source = { html: wrapHtml(article.htmlContent) };
return (
<View style={{ flex: 1, backgroundColor: theme.surface }}>
<HeaderBar title="文章" onBack={() => router.back()} showBottomBorder />
<ScrollView contentContainerStyle={styles.contentContainer} showsVerticalScrollIndicator={false}>
<View style={styles.headerMeta}>
<Text style={[styles.title, { color: theme.text }]}>{article.title}</Text>
<View style={styles.row}>
<Text style={[styles.metaText, { color: theme.textMuted }]}>{dayjs(article.publishedAt).format('YYYY-MM-DD')}</Text>
<Text style={[styles.metaText, styles.dot]}>·</Text>
<Text style={[styles.metaText, { color: theme.textMuted }]}>{article.readCount} </Text>
</View>
</View>
<RenderHTML
contentWidth={width - 48}
source={source}
baseStyle={{ ...htmlBaseStyles, color: theme.text }}
tagsStyles={htmlTagStyles}
enableExperimentalMarginCollapsing={true}
/>
<View style={{ height: 36 }} />
</ScrollView>
</View>
);
}
function wrapHtml(inner: string) {
// 为了统一排版与图片自适应
return `
<div class="article">
${inner}
</div>
<style>
.article img { max-width: 100%; height: auto; border-radius: 12px; }
</style>
`;
}
const styles = StyleSheet.create({
contentContainer: {
paddingHorizontal: 24,
paddingTop: 12,
},
headerMeta: {
marginBottom: 12,
},
title: {
fontSize: 22,
fontWeight: '800',
color: '#192126',
},
row: {
flexDirection: 'row',
alignItems: 'center',
marginTop: 8,
},
metaText: {
fontSize: 12,
color: '#8A8A8E',
},
dot: {
paddingHorizontal: 6,
},
});
const htmlBaseStyles = {
color: '#192126',
lineHeight: 24,
fontSize: 16,
} as const;
const htmlTagStyles = {
h1: { fontSize: 26, fontWeight: '800', marginBottom: 8 },
h2: { fontSize: 22, fontWeight: '800', marginTop: 8, marginBottom: 8 },
h3: { fontSize: 18, fontWeight: '700', marginTop: 12, marginBottom: 6 },
p: { marginBottom: 12 },
ol: { marginBottom: 12, paddingLeft: 18 },
ul: { marginBottom: 12, paddingLeft: 18 },
li: { marginBottom: 6 },
img: { marginTop: 8, marginBottom: 8, borderRadius: 12 },
em: { fontStyle: 'italic' },
strong: { fontWeight: '800' },
} as const;

477
app/auth/login.tsx Normal file
View File

@@ -0,0 +1,477 @@
import { Ionicons } from '@expo/vector-icons';
import * as AppleAuthentication from 'expo-apple-authentication';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { LinearGradient } from 'expo-linear-gradient';
import { useLocalSearchParams, useRouter } from 'expo-router';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ActivityIndicator, Alert, Animated, Linking, Pressable, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { ThemedText } from '@/components/ThemedText';
import { ThemedView } from '@/components/ThemedView';
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 { fetchMyProfile, login } from '@/store/userSlice';
import Toast from 'react-native-toast-message';
export default function LoginScreen() {
const router = useRouter();
const searchParams = useLocalSearchParams<{ redirectTo?: string; redirectParams?: string; shouldBack?: string }>();
const scheme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const color = Colors[scheme];
const pageBackground = scheme === 'light' ? color.pageBackgroundEmphasis : color.background;
const dispatch = useAppDispatch();
const AnimatedLinear = useMemo(() => Animated.createAnimatedComponent(LinearGradient), []);
// 背景动效:轻微平移/旋转与呼吸动画
const translateAnim = useRef(new Animated.Value(0)).current;
const rotateAnim = useRef(new Animated.Value(0)).current;
const pulseAnimA = useRef(new Animated.Value(0)).current;
const pulseAnimB = useRef(new Animated.Value(0)).current;
useEffect(() => {
const loopTranslate = Animated.loop(
Animated.sequence([
Animated.timing(translateAnim, { toValue: 1, duration: 6000, useNativeDriver: true }),
Animated.timing(translateAnim, { toValue: 0, duration: 6000, useNativeDriver: true }),
])
);
const loopRotate = Animated.loop(
Animated.sequence([
Animated.timing(rotateAnim, { toValue: 1, duration: 10000, useNativeDriver: true }),
Animated.timing(rotateAnim, { toValue: 0, duration: 10000, useNativeDriver: true }),
])
);
const loopPulseA = Animated.loop(
Animated.sequence([
Animated.timing(pulseAnimA, { toValue: 1, duration: 3500, useNativeDriver: true }),
Animated.timing(pulseAnimA, { toValue: 0, duration: 3500, useNativeDriver: true }),
])
);
const loopPulseB = Animated.loop(
Animated.sequence([
Animated.timing(pulseAnimB, { toValue: 1, duration: 4200, useNativeDriver: true }),
Animated.timing(pulseAnimB, { toValue: 0, duration: 4200, useNativeDriver: true }),
])
);
loopTranslate.start();
loopRotate.start();
loopPulseA.start();
loopPulseB.start();
return () => {
loopTranslate.stop();
loopRotate.stop();
loopPulseA.stop();
loopPulseB.stop();
};
}, [pulseAnimA, pulseAnimB, rotateAnim, translateAnim]);
const [hasAgreed, setHasAgreed] = useState<boolean>(false);
const [appleAvailable, setAppleAvailable] = useState<boolean>(false);
const [loading, setLoading] = useState<boolean>(false);
useEffect(() => {
AppleAuthentication.isAvailableAsync().then(setAppleAvailable).catch(() => setAppleAvailable(false));
}, []);
const guardAgreement = useCallback((action: () => void) => {
if (!hasAgreed) {
Alert.alert(
'请先阅读并同意',
'继续登录前,请阅读并勾选《隐私政策》和《用户协议》。点击“同意并继续”将默认勾选并继续登录。',
[
{ text: '取消', style: 'cancel' },
{
text: '同意并继续',
onPress: () => {
setHasAgreed(true);
setTimeout(() => action(), 0);
},
},
],
{ cancelable: true }
);
return;
}
action();
}, [hasAgreed]);
const onAppleLogin = useCallback(async () => {
if (!appleAvailable) return;
try {
setLoading(true);
const credential = await AppleAuthentication.signInAsync({
requestedScopes: [
AppleAuthentication.AppleAuthenticationScope.FULL_NAME,
AppleAuthentication.AppleAuthenticationScope.EMAIL,
],
});
const identityToken = (credential as any)?.identityToken;
if (!identityToken || typeof identityToken !== 'string') {
throw new Error('未获取到 Apple 身份令牌');
}
await dispatch(login({ appleIdentityToken: identityToken })).unwrap();
// 拉取用户信息
await dispatch(fetchMyProfile())
Toast.show({
text1: '登录成功',
type: 'success',
});
// 登录成功后处理重定向
const shouldBack = searchParams?.shouldBack === 'true';
if (shouldBack) {
// 如果设置了 shouldBack直接返回上一页
router.back();
} else {
// 否则按照原有逻辑进行重定向
const to = searchParams?.redirectTo as string | undefined;
const paramsJson = searchParams?.redirectParams as string | undefined;
let parsedParams: Record<string, any> | undefined;
if (paramsJson) {
try { parsedParams = JSON.parse(paramsJson); } catch { }
}
if (to) {
router.replace({ pathname: to, params: parsedParams } as any);
} else {
router.back();
}
}
} catch (err: any) {
console.log('err.code', err.code);
if (err?.code === 'ERR_CANCELED' || err?.code === 'ERR_REQUEST_CANCELED') return;
const message = err?.message || '登录失败,请稍后再试';
Alert.alert('登录失败', message);
} finally {
setLoading(false);
}
}, [appleAvailable, router, searchParams?.redirectParams, searchParams?.redirectTo]);
// 登录按钮不再因未勾选协议而禁用,仅在加载中禁用
return (
<SafeAreaView edges={['top']} style={[styles.safeArea, { backgroundColor: pageBackground }]}>
<ThemedView style={[styles.container, { backgroundColor: pageBackground }]}>
{/* 动态背景层(置于内容之下) */}
<View pointerEvents="none" style={styles.bgWrap}>
{/* 基础全屏渐变:保证覆盖全屏 */}
<AnimatedLinear
colors={
scheme === 'light'
? [color.pageBackgroundEmphasis, color.heroSurfaceTint, color.surface]
: [color.background, '#0F1112', color.surface]
}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={[styles.bgGradientFull]}
/>
{/* 次级大面积渐变:对角线方向形成层次 */}
<AnimatedLinear
colors={
scheme === 'light'
? ['rgba(164,138,237,0.12)', 'rgba(187,242,70,0.16)', 'transparent']
: ['rgba(164,138,237,0.16)', 'rgba(187,242,70,0.12)', 'transparent']
}
start={{ x: 1, y: 0 }}
end={{ x: 0, y: 1 }}
style={[
styles.bgGradientCover,
{
transform: [
{
rotate: rotateAnim.interpolate({ inputRange: [0, 1], outputRange: ['-4deg', '6deg'] }),
},
],
opacity: scheme === 'light' ? 0.9 : 0.65,
},
]}
/>
{/* 动感色块 A主色呼吸置于左下 */}
<Animated.View
style={[
styles.accentBlobLarge,
{
backgroundColor: color.ornamentPrimary,
transform: [
{ translateX: -80 },
{ translateY: 320 },
{ scale: pulseAnimA.interpolate({ inputRange: [0, 1], outputRange: [1, 1.05] }) },
],
opacity: scheme === 'light' ? 0.55 : 0.4,
},
]}
/>
{/* 动感色块 B辅色漂移置于右上 */}
<Animated.View
style={[
styles.accentBlobMedium,
{
backgroundColor: color.ornamentAccent,
transform: [
{ translateX: 240 },
{ translateY: -40 },
{ scale: pulseAnimB.interpolate({ inputRange: [0, 1], outputRange: [1, 1.07] }) },
],
opacity: scheme === 'light' ? 0.5 : 0.38,
},
]}
/>
</View>
{/* 自定义头部,与其它页面风格一致 */}
<View style={styles.header}>
{isLiquidGlassAvailable() ? (
<TouchableOpacity accessibilityRole="button" onPress={() => router.back()} activeOpacity={0.7}>
<GlassView
style={styles.backButton}
glassEffectStyle="clear"
tintColor="rgba(255, 255, 255, 0.2)"
isInteractive={true}
>
<Ionicons name="chevron-back" size={24} color={scheme === 'dark' ? '#ECEDEE' : '#192126'} />
</GlassView>
</TouchableOpacity>
) : (
<TouchableOpacity accessibilityRole="button" onPress={() => router.back()} style={[styles.backButton, styles.fallbackBackground]} activeOpacity={0.7}>
<Ionicons name="chevron-back" size={24} color={scheme === 'dark' ? '#ECEDEE' : '#192126'} />
</TouchableOpacity>
)}
<Text style={[styles.headerTitle, { color: color.text }]}></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>
</View>
{/* Apple 登录 */}
{appleAvailable && (
<TouchableOpacity
accessibilityRole="button"
onPress={() => guardAgreement(onAppleLogin)}
disabled={loading}
activeOpacity={0.7}
>
{isLiquidGlassAvailable() ? (
<GlassView
style={styles.appleButton}
glassEffectStyle="regular"
tintColor="rgba(0, 0, 0, 0.8)"
isInteractive={true}
>
{loading ? (
<>
<ActivityIndicator
size="small"
color="#FFFFFF"
style={{ marginRight: 10 }}
/>
<Text style={styles.appleText}>...</Text>
</>
) : (
<>
<Ionicons name="logo-apple" size={22} color="#FFFFFF" style={{ marginRight: 10 }} />
<Text style={styles.appleText}>使 Apple </Text>
</>
)}
</GlassView>
) : (
<View style={[styles.appleButton, styles.appleButtonFallback, loading && { opacity: 0.7 }]}>
{loading ? (
<>
<ActivityIndicator
size="small"
color="#FFFFFF"
style={{ marginRight: 10 }}
/>
<Text style={styles.appleText}>...</Text>
</>
) : (
<>
<Ionicons name="logo-apple" size={22} color="#FFFFFF" style={{ marginRight: 10 }} />
<Text style={styles.appleText}>使 Apple </Text>
</>
)}
</View>
)}
</TouchableOpacity>
)}
{/* 协议勾选 */}
<View style={styles.agreementRow}>
<Pressable onPress={() => setHasAgreed((v) => !v)} style={styles.checkboxWrap} accessibilityRole="checkbox" accessibilityState={{ checked: hasAgreed }}>
<View
style={[styles.checkbox, {
backgroundColor: hasAgreed ? color.primary : 'transparent',
borderColor: hasAgreed ? color.primary : color.border,
}]}
>
{hasAgreed && <Ionicons name="checkmark" size={14} color={color.onPrimary} />}
</View>
</Pressable>
<Text style={[styles.agreementText, { color: color.textMuted }]}></Text>
<Pressable onPress={() => Linking.openURL(PRIVACY_POLICY_URL)}>
<Text style={[styles.link, { color: color.primary }]}></Text>
</Pressable>
<Text style={[styles.agreementText, { color: color.textMuted }]}></Text>
<Pressable onPress={() => Linking.openURL(USER_AGREEMENT_URL)}>
<Text style={[styles.link, { color: color.primary }]}></Text>
</Pressable>
</View>
{/* 占位底部间距 */}
<View style={{ height: 40 }} />
</ScrollView>
</ThemedView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safeArea: { flex: 1 },
container: { flex: 1 },
content: {
flexGrow: 1,
paddingHorizontal: 24,
justifyContent: 'center',
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 16,
paddingTop: 4,
paddingBottom: 8,
},
backButton: {
width: 38,
height: 38,
alignItems: 'center',
justifyContent: 'center',
borderRadius: 38,
overflow: 'hidden',
},
fallbackBackground: {
backgroundColor: 'rgba(255, 255, 255, 0.5)',
},
headerTitle: { fontSize: 18, fontWeight: '700' },
headerWrap: {
marginBottom: 36,
},
title: {
fontSize: 32,
fontWeight: '500',
letterSpacing: 0.5,
lineHeight: 38,
},
subtitle: {
marginTop: 8,
fontSize: 14,
fontWeight: '500',
},
appleButton: {
height: 56,
borderRadius: 28,
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'row',
marginBottom: 16,
overflow: 'hidden',
shadowColor: '#000',
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.15,
shadowRadius: 12,
elevation: 2,
},
appleButtonFallback: {
backgroundColor: '#000000',
},
appleText: {
fontSize: 16,
color: '#FFFFFF',
fontWeight: '600',
},
guestButton: {
height: 52,
borderRadius: 26,
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'row',
borderWidth: 1,
marginTop: 6,
},
guestText: {
fontSize: 15,
fontWeight: '500',
},
agreementRow: {
flexDirection: 'row',
alignItems: 'center',
flexWrap: 'wrap',
marginTop: 24,
},
checkboxWrap: { marginRight: 8 },
checkbox: {
width: 18,
height: 18,
borderRadius: 5,
borderWidth: 1,
alignItems: 'center',
justifyContent: 'center',
},
agreementText: { fontSize: 12 },
link: { fontSize: 12, fontWeight: '600' },
footerHint: { marginTop: 24 },
hintText: { fontSize: 12 },
// 背景样式
bgWrap: {
...StyleSheet.absoluteFillObject,
zIndex: 0,
},
bgGradientFull: {
position: 'absolute',
left: 0,
top: 0,
right: 0,
bottom: 0,
},
bgGradientCover: {
position: 'absolute',
left: '-10%',
top: '-15%',
width: '130%',
height: '70%',
borderBottomLeftRadius: 36,
borderBottomRightRadius: 36,
},
accentBlob: {
position: 'absolute',
width: 180,
height: 180,
borderRadius: 90,
},
accentBlobLarge: {
position: 'absolute',
width: 260,
height: 260,
borderRadius: 130,
},
accentBlobMedium: {
position: 'absolute',
width: 180,
height: 180,
borderRadius: 90,
},
});

242
app/badges/index.tsx Normal file
View File

@@ -0,0 +1,242 @@
import { HeaderBar } from '@/components/ui/HeaderBar';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import type { BadgeDto } from '@/services/badges';
import { fetchAvailableBadges, selectBadgesLoading, selectSortedBadges } from '@/store/badgesSlice';
import { DEFAULT_MEMBER_NAME, selectUserProfile } from '@/store/userSlice';
import { BadgeShowcaseModal } from '@/components/badges/BadgeShowcaseModal';
import { Toast } from '@/utils/toast.utils';
import { Ionicons } from '@expo/vector-icons';
import { useFocusEffect } from '@react-navigation/native';
import * as Haptics from 'expo-haptics';
import { Image } from 'expo-image';
import React, { useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { FlatList, Pressable, RefreshControl, StyleSheet, Text, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
export default function BadgesScreen() {
const dispatch = useAppDispatch();
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const badges = useAppSelector(selectSortedBadges);
const loading = useAppSelector(selectBadgesLoading);
const userProfile = useAppSelector(selectUserProfile);
const [refreshing, setRefreshing] = useState(false);
const [showcaseBadge, setShowcaseBadge] = useState<BadgeDto | null>(null);
useFocusEffect(
useCallback(() => {
dispatch(fetchAvailableBadges());
}, [dispatch])
);
const handleRefresh = useCallback(async () => {
setRefreshing(true);
try {
await dispatch(fetchAvailableBadges()).unwrap();
} catch (error: any) {
const message = typeof error === 'string' ? error : error?.message ?? 'Failed to refresh badges';
Toast.error(message);
} finally {
setRefreshing(false);
}
}, [dispatch]);
const gridData = useMemo(() => badges, [badges]);
const handleBadgePress = useCallback(async (badge: BadgeDto) => {
try {
await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
} catch {
// Best-effort haptics; ignore if unavailable
}
setShowcaseBadge(badge);
}, []);
const renderBadgeTile = ({ item }: { item: BadgeDto }) => {
const isAwarded = item.isAwarded;
return (
<Pressable
style={({ pressed }) => [styles.badgeTile, pressed && styles.badgeTilePressed]}
onPress={() => handleBadgePress(item)}
accessibilityRole="button"
>
<View style={[styles.badgeImageContainer, isAwarded ? styles.badgeImageEarned : styles.badgeImageLocked]}>
{item.imageUrl ? (
<Image source={{ uri: item.imageUrl }} style={styles.badgeImage} contentFit="cover" transition={200} />
) : (
<View style={styles.badgeImageFallback}>
<Text style={styles.badgeImageFallbackText}>{item.icon ?? '🏅'}</Text>
</View>
)}
{!isAwarded && (
<View style={styles.badgeOverlay}>
<Ionicons name="lock-closed" size={16} color="#FFFFFF" />
</View>
)}
</View>
<Text style={styles.badgeTitle} numberOfLines={1}>{item.name}</Text>
<Text style={styles.badgeDescription} numberOfLines={2}>{item.description}</Text>
<Text style={[styles.badgeStatus, isAwarded ? styles.badgeStatusEarned : styles.badgeStatusLocked]}>
{isAwarded
? t('badges.status.earned')
: t('badges.status.locked')}
</Text>
</Pressable>
);
};
const headerOffset = insets.top + 64;
return (
<View style={styles.container}>
<HeaderBar
title={t('badges.title')}
/>
<FlatList
data={gridData}
keyExtractor={(item) => item.code}
numColumns={3}
contentContainerStyle={[
styles.listContent,
{ paddingTop: headerOffset, paddingBottom: insets.bottom + 24 },
]}
columnWrapperStyle={styles.columnWrapper}
renderItem={renderBadgeTile}
ListHeaderComponent={null}
ListEmptyComponent={
<View style={styles.emptyState}>
<Text style={styles.emptyStateTitle}>{t('badges.empty.title')}</Text>
<Text style={styles.emptyStateDescription}>{t('badges.empty.description')}</Text>
</View>
}
refreshControl={
<RefreshControl refreshing={refreshing || loading} onRefresh={handleRefresh} tintColor="#7C3AED" />
}
showsVerticalScrollIndicator={false}
/>
<BadgeShowcaseModal
badge={showcaseBadge}
onClose={() => setShowcaseBadge(null)}
username={userProfile?.name && userProfile.name.trim() ? userProfile.name : DEFAULT_MEMBER_NAME}
appName="Out Live"
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#ffffff',
},
listContent: {
paddingHorizontal: 16,
minHeight: '100%',
backgroundColor: '#ffffff'
},
columnWrapper: {
justifyContent: 'space-between',
marginBottom: 16,
},
badgeTile: {
flex: 1,
marginHorizontal: 4,
padding: 12,
alignItems: 'center',
borderRadius: 16,
backgroundColor: '#FFFFFF',
shadowColor: '#0F172A',
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.06,
shadowRadius: 12,
elevation: 2,
},
badgeTilePressed: {
transform: [{ scale: 0.97 }],
shadowOpacity: 0.02,
},
badgeImageContainer: {
width: 88,
height: 88,
borderRadius: 28,
overflow: 'hidden',
marginBottom: 10,
position: 'relative',
},
badgeImageEarned: {
borderWidth: 1.5,
borderColor: 'rgba(16,185,129,0.6)',
},
badgeImageLocked: {
borderWidth: 1.5,
borderColor: 'rgba(148,163,184,0.5)',
},
badgeImage: {
width: '100%',
height: '100%',
},
badgeOverlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(15,23,42,0.45)',
alignItems: 'center',
justifyContent: 'center',
},
badgeImageFallback: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#F3F4F6',
},
badgeImageFallbackText: {
fontSize: 36,
},
badgeTitle: {
fontSize: 14,
fontWeight: '700',
color: '#111827',
},
badgeDescription: {
fontSize: 12,
color: '#6B7280',
textAlign: 'center',
marginTop: 4,
},
badgeStatus: {
fontSize: 11,
fontWeight: '600',
marginTop: 6,
},
badgeStatusEarned: {
color: '#0F766E',
},
badgeStatusLocked: {
color: '#9CA3AF',
},
emptyState: {
alignItems: 'center',
padding: 32,
borderRadius: 24,
backgroundColor: '#FFFFFF',
marginTop: 24,
shadowColor: '#000',
shadowOffset: { width: 0, height: 6 },
shadowOpacity: 0.08,
shadowRadius: 12,
elevation: 3,
},
emptyStateTitle: {
fontSize: 18,
fontWeight: '700',
color: '#0F172A',
},
emptyStateDescription: {
fontSize: 14,
color: '#475467',
textAlign: 'center',
marginTop: 8,
lineHeight: 20,
},
});

View File

@@ -0,0 +1,825 @@
import { DateSelector } from '@/components/DateSelector';
import { HeaderBar } from '@/components/ui/HeaderBar';
import { Colors } from '@/constants/Colors';
import { useAppSelector } from '@/hooks/redux';
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
import { selectUserAge, selectUserProfile } from '@/store/userSlice';
import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date';
import { fetchBasalEnergyBurned } from '@/utils/health';
import { Ionicons } from '@expo/vector-icons';
import dayjs from 'dayjs';
import weekOfYear from 'dayjs/plugin/weekOfYear';
import { LinearGradient } from 'expo-linear-gradient';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { ActivityIndicator, Dimensions, Modal, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { BarChart } from 'react-native-chart-kit';
dayjs.extend(weekOfYear);
type TabType = 'week' | 'month';
type BasalMetabolismData = {
date: Date;
value: number | null;
};
export default function BasalMetabolismDetailScreen() {
const userProfile = useAppSelector(selectUserProfile);
const userAge = useAppSelector(selectUserAge);
const safeAreaTop = useSafeAreaTop()
// 日期相关状态
const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth());
const [activeTab, setActiveTab] = useState<TabType>('week');
// 说明弹窗状态
const [infoModalVisible, setInfoModalVisible] = useState(false);
// 数据状态
const [chartData, setChartData] = useState<BasalMetabolismData[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// 缓存和防抖相关参照BasalMetabolismCard
const [cacheRef] = useState(() => new Map<string, { data: BasalMetabolismData[]; timestamp: number }>());
const [loadingRef] = useState(() => new Map<string, Promise<BasalMetabolismData[]>>());
const CACHE_DURATION = 5 * 60 * 1000; // 5分钟缓存
console.log('basal metabolism chartData', chartData);
// 生成日期范围的函数
const generateDateRange = useCallback((tab: TabType): Date[] => {
const today = new Date();
const dates: Date[] = [];
switch (tab) {
case 'week':
// 获取最近7天
for (let i = 6; i >= 0; i--) {
const date = dayjs(today).subtract(i, 'day').toDate();
dates.push(date);
}
break;
case 'month':
// 获取最近30天按周分组
for (let i = 3; i >= 0; i--) {
const date = dayjs(today).subtract(i * 7, 'day').toDate();
dates.push(date);
}
break;
}
return dates;
}, []);
// 优化的数据获取函数,包含缓存和去重复请求
const fetchBasalMetabolismData = useCallback(async (tab: TabType): Promise<BasalMetabolismData[]> => {
const cacheKey = `${tab}-${dayjs().format('YYYY-MM-DD')}`;
const now = Date.now();
// 检查缓存
const cached = cacheRef.get(cacheKey);
if (cached && (now - cached.timestamp) < CACHE_DURATION) {
return cached.data;
}
// 检查是否已经在请求中(防止重复请求)
const existingRequest = loadingRef.get(cacheKey);
if (existingRequest) {
return existingRequest;
}
// 创建新的请求
const request = (async () => {
try {
const dates = generateDateRange(tab);
const results: BasalMetabolismData[] = [];
// 并行获取所有日期的数据
const promises = dates.map(async (date) => {
try {
const options = {
startDate: dayjs(date).startOf('day').toDate().toISOString(),
endDate: dayjs(date).endOf('day').toDate().toISOString()
};
const basalEnergy = await fetchBasalEnergyBurned(options);
return {
date,
value: basalEnergy || null
};
} catch (error) {
console.error('获取单日基础代谢数据失败:', error);
return {
date,
value: null
};
}
});
const data = await Promise.all(promises);
results.push(...data);
// 更新缓存
cacheRef.set(cacheKey, { data: results, timestamp: now });
return results;
} catch (error) {
console.error('获取基础代谢数据失败:', error);
return [];
} finally {
// 清理请求记录
loadingRef.delete(cacheKey);
}
})();
// 记录请求
loadingRef.set(cacheKey, request);
return request;
}, [generateDateRange, cacheRef, loadingRef, CACHE_DURATION]);
// 获取当前选中日期
const currentSelectedDate = useMemo(() => {
const days = getMonthDaysZh();
return days[selectedIndex]?.date?.toDate() ?? new Date();
}, [selectedIndex]);
// 计算BMR范围
const bmrRange = useMemo(() => {
const { gender, weight, height } = userProfile;
// 检查是否有足够的信息来计算BMR
if (!gender || !weight || !height || !userAge) {
return null;
}
// 将体重和身高转换为数字
const weightNum = parseFloat(weight);
const heightNum = parseFloat(height);
if (isNaN(weightNum) || isNaN(heightNum) || weightNum <= 0 || heightNum <= 0 || userAge <= 0) {
return null;
}
// 使用Mifflin-St Jeor公式计算BMR
let bmr: number;
if (gender === 'male') {
bmr = 10 * weightNum + 6.25 * heightNum - 5 * userAge + 5;
} else {
bmr = 10 * weightNum + 6.25 * heightNum - 5 * userAge - 161;
}
// 计算正常范围±15%
const minBMR = Math.round(bmr * 0.85);
const maxBMR = Math.round(bmr * 1.15);
return { min: minBMR, max: maxBMR, base: Math.round(bmr) };
}, [userProfile.gender, userProfile.weight, userProfile.height, userAge]);
// 获取单个日期的代谢数据
const fetchSingleDateData = useCallback(async (date: Date): Promise<BasalMetabolismData> => {
try {
const options = {
startDate: dayjs(date).startOf('day').toDate().toISOString(),
endDate: dayjs(date).endOf('day').toDate().toISOString()
};
const basalEnergy = await fetchBasalEnergyBurned(options);
return {
date,
value: basalEnergy || null
};
} catch (error) {
console.error('获取单日基础代谢数据失败:', error);
return {
date,
value: null
};
}
}, []);
// 日期选择回调
const onSelectDate = useCallback(async (index: number) => {
setSelectedIndex(index);
// 获取选中日期
const days = getMonthDaysZh();
const selectedDate = days[index]?.date?.toDate();
if (selectedDate) {
// 检查是否已经有该日期的数据
const existingData = chartData.find(item =>
dayjs(item.date).isSame(selectedDate, 'day')
);
// 如果没有数据,则获取该日期的数据
if (!existingData) {
try {
const newData = await fetchSingleDateData(selectedDate);
// 更新chartData添加新数据并按日期排序
setChartData(prevData => {
const updatedData = [...prevData, newData];
return updatedData.sort((a, b) => a.date.getTime() - b.date.getTime());
});
} catch (error) {
console.error('获取选中日期数据失败:', error);
}
}
}
}, [chartData, fetchSingleDateData]);
// Tab切换
const handleTabPress = useCallback((tab: TabType) => {
setActiveTab(tab);
}, []);
// 初始化和Tab切换时加载数据
useEffect(() => {
let isCancelled = false;
const loadData = async () => {
setIsLoading(true);
setError(null);
try {
const data = await fetchBasalMetabolismData(activeTab);
if (!isCancelled) {
setChartData(data);
}
} catch (err) {
if (!isCancelled) {
setError(err instanceof Error ? err.message : '获取数据失败');
}
} finally {
if (!isCancelled) {
setIsLoading(false);
}
}
};
loadData();
// 清理函数,防止组件卸载后的状态更新
return () => {
isCancelled = true;
};
}, [activeTab, fetchBasalMetabolismData]);
// 处理图表数据
const processedChartData = useMemo(() => {
if (!chartData || chartData.length === 0) {
return { labels: [], datasets: [] };
}
// 根据activeTab生成标签和数据
const labels = chartData.map(item => {
switch (activeTab) {
case 'week':
// 显示星期几
return dayjs(item.date).format('dd');
case 'month':
// 显示周数
const weekOfYear = dayjs(item.date).week();
const firstWeekOfYear = dayjs(item.date).startOf('year').week();
return `${weekOfYear - firstWeekOfYear + 1}`;
default:
return dayjs(item.date).format('MM-DD');
}
});
// 生成基础代谢数据集
const data = chartData.map(item => {
const value = item.value;
if (value === null || value === undefined) {
return 0; // 明确处理null/undefined值
}
// 对于非常小的正值保证至少显示1但对于0值保持为0
const roundedValue = Math.round(value);
return value > 0 && roundedValue === 0 ? 1 : roundedValue;
});
console.log('processedChartData:', { labels, data, originalValues: chartData.map(item => item.value) });
return {
labels,
datasets: [{
data
}]
};
}, [chartData, activeTab]);
return (
<View style={styles.container}>
{/* 背景渐变 */}
<LinearGradient
colors={['#f5e5fbff', '#e5fcfeff', '#eefdffff', '#e6f6fcff']}
style={styles.gradientBackground}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
/>
{/* 头部导航 */}
<HeaderBar
title="基础代谢"
transparent
right={
<TouchableOpacity
onPress={() => setInfoModalVisible(true)}
style={styles.infoButton}
>
<Ionicons name="information-circle-outline" size={24} color="#666" />
</TouchableOpacity>
}
/>
<ScrollView
style={styles.scrollView}
contentContainerStyle={{
paddingBottom: 60,
paddingHorizontal: 20,
paddingTop: safeAreaTop
}}
showsVerticalScrollIndicator={false}
>
{/* 日期选择器 */}
<View style={styles.dateContainer}>
<DateSelector
selectedIndex={selectedIndex}
onDateSelect={onSelectDate}
showMonthTitle={false}
disableFutureDates={true}
/>
</View>
{/* 当前日期基础代谢显示 */}
<View style={styles.currentDataCard}>
<Text style={styles.currentDataTitle}>
{dayjs(currentSelectedDate).format('M月D日')}
</Text>
<View style={styles.currentValueContainer}>
<Text style={styles.currentValue}>
{(() => {
const selectedDateData = chartData.find(item =>
dayjs(item.date).isSame(currentSelectedDate, 'day')
);
if (selectedDateData?.value) {
return Math.round(selectedDateData.value).toString();
}
return '--';
})()}
</Text>
<Text style={styles.currentUnit}></Text>
</View>
{bmrRange && (
<Text style={styles.rangeText}>
: {bmrRange.min}-{bmrRange.max}
</Text>
)}
</View>
{/* 基础代谢统计 */}
<View style={styles.statsCard}>
<Text style={styles.statsTitle}></Text>
{/* Tab 切换 */}
<View style={styles.tabContainer}>
<TouchableOpacity
style={[styles.tab, activeTab === 'week' && styles.activeTab]}
onPress={() => handleTabPress('week')}
activeOpacity={0.7}
>
<Text style={[styles.tabText, activeTab === 'week' && styles.activeTabText]}>
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.tab, activeTab === 'month' && styles.activeTab]}
onPress={() => handleTabPress('month')}
activeOpacity={0.7}
>
<Text style={[styles.tabText, activeTab === 'month' && styles.activeTabText]}>
</Text>
</TouchableOpacity>
</View>
{/* 柱状图 */}
{isLoading ? (
<View style={styles.loadingChart}>
<ActivityIndicator size="large" color="#4ECDC4" />
<Text style={styles.loadingText}>...</Text>
</View>
) : error ? (
<View style={styles.errorChart}>
<Text style={styles.errorText}>: {error}</Text>
<TouchableOpacity
style={styles.retryButton}
onPress={() => {
// 重新加载数据
setIsLoading(true);
setError(null);
fetchBasalMetabolismData(activeTab).then(data => {
setChartData(data);
setIsLoading(false);
}).catch(err => {
setError(err instanceof Error ? err.message : '获取数据失败');
setIsLoading(false);
});
}}
activeOpacity={0.7}
>
<Text style={styles.retryText}></Text>
</TouchableOpacity>
</View>
) : processedChartData.datasets.length > 0 && processedChartData.datasets[0].data.length > 0 ? (
<BarChart
data={{
labels: processedChartData.labels,
datasets: processedChartData.datasets,
}}
width={Dimensions.get('window').width - 80}
height={220}
yAxisLabel=""
yAxisSuffix="千卡"
chartConfig={{
backgroundColor: '#ffffff',
backgroundGradientFrom: '#ffffff',
backgroundGradientTo: '#ffffff',
decimalPlaces: 0,
color: (opacity = 1) => `${Colors.light.primary}${Math.round(opacity * 255).toString(16).padStart(2, '0')}`, // 使用主题紫色
labelColor: (opacity = 1) => `${Colors.light.text}${Math.round(opacity * 255).toString(16).padStart(2, '0')}`, // 使用主题文字颜色
style: {
borderRadius: 16,
},
barPercentage: 0.7, // 增加柱体宽度
propsForBackgroundLines: {
strokeDasharray: "2,2",
stroke: Colors.light.border, // 使用主题边框颜色
strokeWidth: 1
},
propsForLabels: {
fontSize: 12,
fontWeight: '500',
},
}}
style={styles.chart}
showValuesOnTopOfBars={true}
fromZero={false}
segments={4}
/>
) : (
<View style={styles.emptyChart}>
<Text style={styles.emptyChartText}></Text>
</View>
)}
</View>
</ScrollView>
{/* 基础代谢说明弹窗 */}
<Modal
animationType="fade"
transparent={true}
visible={infoModalVisible}
onRequestClose={() => setInfoModalVisible(false)}
>
<View style={styles.modalOverlay}>
<View style={styles.modalContent}>
{/* 关闭按钮 */}
<TouchableOpacity
style={styles.closeButton}
onPress={() => setInfoModalVisible(false)}
>
<Text style={styles.closeButtonText}>×</Text>
</TouchableOpacity>
{/* 标题 */}
<Text style={styles.modalTitle}></Text>
{/* 基础代谢定义 */}
<Text style={styles.modalDescription}>
BMR
</Text>
{/* 为什么重要 */}
<Text style={styles.sectionTitle}></Text>
<Text style={styles.sectionContent}>
60-75%
</Text>
{/* 正常范围 */}
<Text style={styles.sectionTitle}></Text>
<Text style={styles.formulaText}>
- BMR = 10 × (kg) + 6.25 × (cm) - 5 × + 5
</Text>
<Text style={styles.formulaText}>
- BMR = 10 × (kg) + 6.25 × (cm) - 5 × - 161
</Text>
{bmrRange ? (
<>
<Text style={styles.rangeText}>{bmrRange.min}-{bmrRange.max}/</Text>
<Text style={styles.rangeNote}>
(15%)
</Text>
<Text style={styles.userInfoText}>
{userProfile.gender === 'male' ? '男性' : '女性'}{userAge}{userProfile.height}cm{userProfile.weight}kg
</Text>
</>
) : (
<Text style={styles.rangeText}></Text>
)}
{/* 提高代谢率的策略 */}
<Text style={styles.sectionTitle}></Text>
<Text style={styles.strategyText}></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>
</View>
</View>
</View>
</Modal>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#FFFFFF',
},
gradientBackground: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
},
scrollView: {
flex: 1,
},
infoButton: {
padding: 4,
},
dateContainer: {
marginTop: 16,
marginBottom: 20,
},
currentDataCard: {
backgroundColor: '#FFFFFF',
borderRadius: 16,
padding: 20,
marginBottom: 20,
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 4,
},
shadowOpacity: 0.12,
shadowRadius: 12,
elevation: 6,
alignItems: 'center',
},
currentDataTitle: {
fontSize: 16,
fontWeight: '700',
color: '#192126',
marginBottom: 16,
textAlign: 'center',
},
currentValueContainer: {
flexDirection: 'row',
alignItems: 'baseline',
marginBottom: 8,
},
currentValue: {
fontSize: 36,
fontWeight: '700',
color: '#4ECDC4',
},
currentUnit: {
fontSize: 16,
color: '#666',
marginLeft: 8,
},
rangeText: {
fontSize: 14,
color: '#059669',
textAlign: 'center',
},
statsCard: {
backgroundColor: '#FFFFFF',
borderRadius: 16,
padding: 20,
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 4,
},
shadowOpacity: 0.12,
shadowRadius: 12,
elevation: 6,
},
statsTitle: {
fontSize: 16,
fontWeight: '700',
color: '#192126',
marginBottom: 16,
},
tabContainer: {
flexDirection: 'row',
backgroundColor: '#F5F5F7',
borderRadius: 12,
padding: 4,
marginBottom: 20,
},
tab: {
flex: 1,
paddingVertical: 8,
paddingHorizontal: 16,
borderRadius: 8,
alignItems: 'center',
},
activeTab: {
backgroundColor: '#FFFFFF',
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 1,
},
shadowOpacity: 0.1,
shadowRadius: 2,
elevation: 2,
},
tabText: {
fontSize: 14,
fontWeight: '600',
color: '#888',
},
activeTabText: {
color: '#192126',
},
chart: {
marginVertical: 8,
borderRadius: 16,
},
emptyChart: {
height: 220,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#F8F9FA',
borderRadius: 16,
marginVertical: 8,
},
emptyChartText: {
fontSize: 14,
color: '#999',
fontWeight: '500',
},
loadingChart: {
height: 220,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#F8F9FA',
borderRadius: 16,
marginVertical: 8,
},
loadingText: {
fontSize: 14,
color: '#666',
marginTop: 8,
fontWeight: '500',
},
errorChart: {
height: 220,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#FFF5F5',
borderRadius: 16,
marginVertical: 8,
padding: 20,
},
errorText: {
fontSize: 14,
color: '#E53E3E',
textAlign: 'center',
marginBottom: 12,
},
retryButton: {
backgroundColor: '#4ECDC4',
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 8,
},
retryText: {
color: '#FFFFFF',
fontSize: 14,
fontWeight: '600',
},
// Modal styles
modalOverlay: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
justifyContent: 'flex-end',
},
modalContent: {
backgroundColor: '#FFFFFF',
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
padding: 24,
maxHeight: '90%',
width: '100%',
shadowColor: '#000',
shadowOffset: {
width: 0,
height: -5,
},
shadowOpacity: 0.25,
shadowRadius: 20,
elevation: 10,
},
closeButton: {
position: 'absolute',
top: 16,
right: 16,
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: '#F1F5F9',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1,
},
closeButtonText: {
fontSize: 20,
color: '#64748B',
fontWeight: '600',
},
modalTitle: {
fontSize: 24,
fontWeight: '700',
color: '#0F172A',
marginBottom: 16,
textAlign: 'center',
},
modalDescription: {
fontSize: 15,
color: '#475569',
lineHeight: 22,
marginBottom: 24,
},
sectionTitle: {
fontSize: 18,
fontWeight: '700',
color: '#0F172A',
marginBottom: 12,
marginTop: 8,
},
sectionContent: {
fontSize: 15,
color: '#475569',
lineHeight: 22,
marginBottom: 20,
},
formulaText: {
fontSize: 14,
color: '#64748B',
fontFamily: 'monospace',
marginBottom: 4,
paddingLeft: 8,
},
rangeNote: {
fontSize: 12,
color: '#9CA3AF',
textAlign: 'center',
marginBottom: 20,
},
userInfoText: {
fontSize: 13,
color: '#6B7280',
textAlign: 'center',
marginTop: 8,
marginBottom: 16,
fontStyle: 'italic',
},
strategyText: {
fontSize: 15,
color: '#475569',
marginBottom: 12,
},
strategyList: {
marginBottom: 20,
},
strategyItem: {
fontSize: 14,
color: '#64748B',
lineHeight: 20,
marginBottom: 8,
paddingLeft: 8,
},
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,303 @@
import ChallengeProgressCard from '@/components/challenges/ChallengeProgressCard';
import { ChallengeRankingItem } from '@/components/challenges/ChallengeRankingItem';
import { HeaderBar } from '@/components/ui/HeaderBar';
import { Colors } from '@/constants/Colors';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useColorScheme } from '@/hooks/useColorScheme';
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
import {
fetchChallengeDetail,
fetchChallengeRankings,
selectChallengeById,
selectChallengeDetailError,
selectChallengeDetailStatus,
selectChallengeRankingError,
selectChallengeRankingList,
selectChallengeRankingLoadMoreStatus,
selectChallengeRankingStatus,
} from '@/store/challengesSlice';
import { useLocalSearchParams, useRouter } from 'expo-router';
import React, { useEffect, useMemo } from 'react';
import type { NativeScrollEvent, NativeSyntheticEvent } from 'react-native';
import {
ActivityIndicator,
RefreshControl,
ScrollView,
StyleSheet,
Text,
View
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
export default function ChallengeLeaderboardScreen() {
const safeAreaTop = useSafeAreaTop()
const { id } = useLocalSearchParams<{ id?: string }>();
const router = useRouter();
const dispatch = useAppDispatch();
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme];
const insets = useSafeAreaInsets();
const challengeSelector = useMemo(() => (id ? selectChallengeById(id) : undefined), [id]);
const challenge = useAppSelector((state) => (challengeSelector ? challengeSelector(state) : undefined));
const detailStatusSelector = useMemo(() => (id ? selectChallengeDetailStatus(id) : undefined), [id]);
const detailStatus = useAppSelector((state) => (detailStatusSelector ? detailStatusSelector(state) : 'idle'));
const detailErrorSelector = useMemo(() => (id ? selectChallengeDetailError(id) : undefined), [id]);
const detailError = useAppSelector((state) => (detailErrorSelector ? detailErrorSelector(state) : undefined));
const rankingListSelector = useMemo(() => (id ? selectChallengeRankingList(id) : undefined), [id]);
const rankingList = useAppSelector((state) => (rankingListSelector ? rankingListSelector(state) : undefined));
const rankingStatusSelector = useMemo(() => (id ? selectChallengeRankingStatus(id) : undefined), [id]);
const rankingStatus = useAppSelector((state) => (rankingStatusSelector ? rankingStatusSelector(state) : 'idle'));
const rankingLoadMoreStatusSelector = useMemo(
() => (id ? selectChallengeRankingLoadMoreStatus(id) : undefined),
[id]
);
const rankingLoadMoreStatus = useAppSelector((state) =>
rankingLoadMoreStatusSelector ? rankingLoadMoreStatusSelector(state) : 'idle'
);
const rankingErrorSelector = useMemo(() => (id ? selectChallengeRankingError(id) : undefined), [id]);
const rankingError = useAppSelector((state) => (rankingErrorSelector ? rankingErrorSelector(state) : undefined));
useEffect(() => {
if (id) {
void dispatch(fetchChallengeDetail(id));
}
}, [dispatch, id]);
useEffect(() => {
if (id && !rankingList) {
void dispatch(fetchChallengeRankings({ id }));
}
}, [dispatch, id, rankingList]);
if (!id) {
return (
<View style={[styles.safeArea, { backgroundColor: colorTokens.background }]}>
<HeaderBar title="排行榜" onBack={() => router.back()} withSafeTop />
<View style={{
paddingTop: safeAreaTop
}} />
<View style={styles.missingContainer}>
<Text style={[styles.missingText, { color: colorTokens.textSecondary }]}></Text>
</View>
</View>
);
}
if (detailStatus === 'loading' && !challenge) {
return (
<View style={[styles.safeArea, { backgroundColor: '#f3f4fb' }]}>
<HeaderBar title="排行榜" onBack={() => router.back()} withSafeTop />
<View style={styles.loadingContainer}>
<ActivityIndicator color={colorTokens.primary} />
<Text style={[styles.loadingText, { color: colorTokens.textSecondary }]}></Text>
</View>
</View>
);
}
const hasMore = rankingList?.hasMore ?? false;
const isRefreshing = rankingStatus === 'loading';
const isLoadingMore = rankingLoadMoreStatus === 'loading';
const defaultPageSize = rankingList?.pageSize ?? 20;
const showInitialRankingLoading = isRefreshing && (!rankingList || rankingList.items.length === 0);
const handleRefresh = () => {
if (!id) {
return;
}
void dispatch(fetchChallengeRankings({ id, page: 1, pageSize: defaultPageSize }));
};
const handleLoadMore = () => {
if (!id || !rankingList || !hasMore || isLoadingMore || rankingStatus === 'loading') {
return;
}
void dispatch(
fetchChallengeRankings({ id, page: rankingList.page + 1, pageSize: rankingList.pageSize })
);
};
const handleScroll = (event: NativeSyntheticEvent<NativeScrollEvent>) => {
const { layoutMeasurement, contentOffset, contentSize } = event.nativeEvent;
const paddingToBottom = 160;
if (layoutMeasurement.height + contentOffset.y >= contentSize.height - paddingToBottom) {
handleLoadMore();
}
};
if (!challenge) {
return (
<View style={[styles.safeArea, { backgroundColor: '#f3f4fb' }]}>
<HeaderBar title="排行榜" onBack={() => router.back()} withSafeTop />
<View style={styles.missingContainer}>
<Text style={[styles.missingText, { color: colorTokens.textSecondary }]}>
{detailError ?? '暂时无法加载榜单,请稍后再试。'}
</Text>
</View>
</View>
);
}
const rankingData = rankingList?.items ?? challenge.rankings ?? [];
const subtitle = challenge.rankingDescription ?? challenge.summary;
return (
<View style={[styles.safeArea, { backgroundColor: '#f3f4fb' }]}>
<HeaderBar title="排行榜" onBack={() => router.back()} withSafeTop />
<ScrollView
style={styles.scrollView}
contentContainerStyle={{ paddingBottom: insets.bottom + 40, paddingTop: safeAreaTop }}
showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl
refreshing={isRefreshing}
onRefresh={handleRefresh}
tintColor={colorTokens.primary}
/>
}
onScroll={handleScroll}
scrollEventThrottle={16}
>
<View style={styles.pageHeader}>
<Text style={styles.challengeTitle}>{challenge.title}</Text>
{subtitle ? <Text style={styles.challengeSubtitle}>{subtitle}</Text> : null}
{challenge.progress ? (
<ChallengeProgressCard
title={challenge.title}
endAt={challenge.endAt}
progress={challenge.progress}
style={styles.progressCardWrapper}
/>
) : null}
</View>
<View style={styles.rankingCard}>
{showInitialRankingLoading ? (
<View style={styles.rankingLoading}>
<ActivityIndicator color={colorTokens.primary} />
<Text style={[styles.loadingText, { color: colorTokens.textSecondary }]}></Text>
</View>
) : rankingData.length ? (
rankingData.map((item, index) => (
<ChallengeRankingItem
key={item.id ?? index}
item={item}
index={index}
showDivider={index > 0}
unit={challenge?.unit}
/>
))
) : rankingError ? (
<View style={styles.emptyRanking}>
<Text style={styles.rankingErrorText}>{rankingError}</Text>
</View>
) : (
<View style={styles.emptyRanking}>
<Text style={styles.emptyRankingText}></Text>
</View>
)}
{isLoadingMore ? (
<View style={styles.loadMoreIndicator}>
<ActivityIndicator color={colorTokens.primary} size="small" />
<Text style={[styles.loadingText, { color: colorTokens.textSecondary, marginTop: 8 }]}></Text>
</View>
) : null}
{rankingLoadMoreStatus === 'failed' ? (
<View style={styles.loadMoreIndicator}>
<Text style={styles.loadMoreErrorText}></Text>
</View>
) : null}
</View>
</ScrollView>
</View>
);
}
const styles = StyleSheet.create({
safeArea: {
flex: 1,
},
scrollView: {
flex: 1,
backgroundColor: 'transparent',
},
pageHeader: {
paddingHorizontal: 24,
paddingTop: 24,
},
challengeTitle: {
fontSize: 22,
fontWeight: '800',
color: '#1c1f3a',
},
challengeSubtitle: {
marginTop: 8,
fontSize: 14,
color: '#6f7ba7',
lineHeight: 20,
},
progressCardWrapper: {
marginTop: 20,
},
rankingCard: {
marginTop: 24,
marginHorizontal: 24,
borderRadius: 24,
backgroundColor: '#ffffff',
paddingVertical: 10,
shadowColor: 'rgba(30, 41, 59, 0.12)',
shadowOpacity: 0.16,
shadowRadius: 18,
shadowOffset: { width: 0, height: 10 },
elevation: 6,
},
emptyRanking: {
paddingVertical: 40,
alignItems: 'center',
justifyContent: 'center',
},
emptyRankingText: {
fontSize: 14,
color: '#6f7ba7',
},
rankingLoading: {
paddingVertical: 32,
alignItems: 'center',
justifyContent: 'center',
},
rankingErrorText: {
fontSize: 14,
color: '#eb5757',
},
loadMoreIndicator: {
paddingVertical: 16,
alignItems: 'center',
justifyContent: 'center',
},
loadMoreErrorText: {
fontSize: 13,
color: '#eb5757',
},
loadingContainer: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
loadingText: {
marginTop: 16,
fontSize: 14,
},
missingContainer: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 32,
},
missingText: {
fontSize: 14,
textAlign: 'center',
},
});

View File

@@ -0,0 +1,703 @@
import { DateSelector } from '@/components/DateSelector';
import { FloatingSelectionModal, SelectionItem } from '@/components/ui/FloatingSelectionModal';
import { HeaderBar } from '@/components/ui/HeaderBar';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useAuthGuard } from '@/hooks/useAuthGuard';
import { fetchCircumferenceAnalysis, selectCircumferenceData, selectCircumferenceError, selectCircumferenceLoading } from '@/store/circumferenceSlice';
import { selectUserProfile, updateUserBodyMeasurements, UserProfile } from '@/store/userSlice';
import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date';
import dayjs from 'dayjs';
import weekOfYear from 'dayjs/plugin/weekOfYear';
import { LinearGradient } from 'expo-linear-gradient';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { ActivityIndicator, Dimensions, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { LineChart } from 'react-native-chart-kit';
dayjs.extend(weekOfYear);
// 围度类型数据
const CIRCUMFERENCE_TYPES = [
{ key: 'chestCircumference', label: '胸围', color: '#FF6B6B' },
{ key: 'waistCircumference', label: '腰围', color: '#4ECDC4' },
{ key: 'upperHipCircumference', label: '上臀围', color: '#45B7D1' },
{ key: 'armCircumference', label: '臂围', color: '#96CEB4' },
{ key: 'thighCircumference', label: '大腿围', color: '#FFEAA7' },
{ key: 'calfCircumference', label: '小腿围', color: '#DDA0DD' },
];
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
import { CircumferencePeriod } from '@/services/circumferenceAnalysis';
type TabType = CircumferencePeriod;
export default function CircumferenceDetailScreen() {
const safeAreaTop = useSafeAreaTop()
const dispatch = useAppDispatch();
const userProfile = useAppSelector(selectUserProfile);
const { ensureLoggedIn } = useAuthGuard();
// 日期相关状态
const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth());
const [activeTab, setActiveTab] = useState<TabType>('week');
// 弹窗状态
const [modalVisible, setModalVisible] = useState(false);
const [selectedMeasurement, setSelectedMeasurement] = useState<{
key: string;
label: string;
currentValue?: number;
} | null>(null);
// Redux状态
const chartData = useAppSelector(state => selectCircumferenceData(state, activeTab));
const isLoading = useAppSelector(state => selectCircumferenceLoading(state, activeTab));
const error = useAppSelector(selectCircumferenceError);
console.log('chartData', chartData);
// 图例显示状态 - 控制哪些维度显示在图表中
const [visibleTypes, setVisibleTypes] = useState<Set<string>>(
new Set(CIRCUMFERENCE_TYPES.map(type => type.key))
);
// 获取当前选中日期
const currentSelectedDate = useMemo(() => {
const days = getMonthDaysZh();
return days[selectedIndex]?.date?.toDate() ?? new Date();
}, [selectedIndex]);
// 判断选中日期是否是今天
const isSelectedDateToday = useMemo(() => {
const today = new Date();
const selectedDate = currentSelectedDate;
return dayjs(selectedDate).isSame(today, 'day');
}, [currentSelectedDate]);
// 当前围度数据
const measurements = [
{
key: 'chestCircumference',
label: '胸围',
value: userProfile?.chestCircumference,
color: '#FF6B6B',
},
{
key: 'waistCircumference',
label: '腰围',
value: userProfile?.waistCircumference,
color: '#4ECDC4',
},
{
key: 'upperHipCircumference',
label: '上臀围',
value: userProfile?.upperHipCircumference,
color: '#45B7D1',
},
{
key: 'armCircumference',
label: '臂围',
value: userProfile?.armCircumference,
color: '#96CEB4',
},
{
key: 'thighCircumference',
label: '大腿围',
value: userProfile?.thighCircumference,
color: '#FFEAA7',
},
{
key: 'calfCircumference',
label: '小腿围',
value: userProfile?.calfCircumference,
color: '#DDA0DD',
},
];
// 日期选择回调
const onSelectDate = (index: number) => {
setSelectedIndex(index);
};
// Tab切换
const handleTabPress = useCallback((tab: TabType) => {
setActiveTab(tab);
// 切换tab时重新获取数据
dispatch(fetchCircumferenceAnalysis(tab));
}, [dispatch]);
// 初始化加载数据
useEffect(() => {
dispatch(fetchCircumferenceAnalysis(activeTab));
}, [dispatch, activeTab]);
// 处理图例点击,切换显示/隐藏
const handleLegendPress = (typeKey: string) => {
const newVisibleTypes = new Set(visibleTypes);
if (newVisibleTypes.has(typeKey)) {
// 至少保留一个维度显示
if (newVisibleTypes.size > 1) {
newVisibleTypes.delete(typeKey);
}
} else {
newVisibleTypes.add(typeKey);
}
setVisibleTypes(newVisibleTypes);
};
// 根据不同围度类型获取合理的默认值
const getDefaultCircumferenceValue = (measurementKey: string, userProfile?: UserProfile): number => {
// 如果用户已有该围度数据,直接使用
const existingValue = userProfile?.[measurementKey as keyof UserProfile] as number;
if (existingValue) {
return existingValue;
}
// 根据性别设置合理的默认值
const isMale = userProfile?.gender === 'male';
switch (measurementKey) {
case 'chestCircumference':
// 胸围:男性 85-110cm女性 75-95cm
return isMale ? 95 : 80;
case 'waistCircumference':
// 腰围:男性 70-90cm女性 60-80cm
return isMale ? 80 : 70;
case 'upperHipCircumference':
// 上臀围:
return 30;
case 'armCircumference':
// 臂围:男性 25-35cm女性 20-30cm
return isMale ? 30 : 25;
case 'thighCircumference':
// 大腿围:男性 45-60cm女性 40-55cm
return isMale ? 50 : 45;
case 'calfCircumference':
// 小腿围:男性 30-40cm女性 25-35cm
return isMale ? 35 : 30;
default:
return 70; // 默认70cm
}
};
// Generate circumference options (30-150 cm)
const circumferenceOptions: SelectionItem[] = Array.from({ length: 121 }, (_, i) => {
const value = i + 30;
return {
label: `${value} cm`,
value: value,
};
});
// 处理围度数据点击
const handleMeasurementPress = async (measurement: typeof measurements[0]) => {
// 只有选中今天日期才能编辑
if (!isSelectedDateToday) {
return;
}
const isLoggedIn = await ensureLoggedIn();
if (!isLoggedIn) {
// 如果未登录,用户会被重定向到登录页面
return;
}
// 使用智能默认值,如果用户已有数据则使用现有数据,否则使用基于性别的合理默认值
const defaultValue = getDefaultCircumferenceValue(measurement.key, userProfile);
setSelectedMeasurement({
key: measurement.key,
label: measurement.label,
currentValue: measurement.value || defaultValue,
});
setModalVisible(true);
};
// 处理围度数据更新
const handleUpdateMeasurement = (value: string | number) => {
if (!selectedMeasurement) return;
const updateData = {
[selectedMeasurement.key]: Number(value),
};
dispatch(updateUserBodyMeasurements(updateData));
setModalVisible(false);
setSelectedMeasurement(null);
};
// 处理图表数据
const processedChartData = useMemo(() => {
if (!chartData || chartData.length === 0) {
return { labels: [], datasets: [] };
}
// 根据activeTab生成标签
const labels = chartData.map(item => {
switch (activeTab) {
case 'week':
// 将YYYY-MM-DD格式转换为星期几
const weekDay = dayjs(item.label).format('dd');
return weekDay;
case 'month':
// 将YYYY-MM-DD格式转换为第几周
const weekOfYear = dayjs(item.label).week();
const firstWeekOfMonth = dayjs(item.label).startOf('month').week();
return `${weekOfYear - firstWeekOfMonth + 1}`;
case 'year':
// 将YYYY-MM格式转换为月份
return dayjs(item.label).format('M月');
default:
return item.label;
}
});
// 为每个可见的围度类型生成数据集
const datasets: any[] = [];
CIRCUMFERENCE_TYPES.forEach((type) => {
if (visibleTypes.has(type.key)) {
const data = chartData.map(item => {
const value = item[type.key as keyof typeof item] as number | null;
return value || 0; // null值转换为0图表会自动处理
});
// 只有数据中至少有一个非零值才添加到数据集
if (data.some(value => value > 0)) {
datasets.push({
data,
color: () => type.color,
strokeWidth: 2,
});
}
}
});
return { labels, datasets };
}, [chartData, activeTab, visibleTypes]);
return (
<View style={styles.container}>
{/* 背景渐变 */}
<LinearGradient
colors={['#f5e5fbff', '#e5fcfeff', '#eefdffff', '#e6f6fcff']}
style={styles.gradientBackground}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
/>
{/* 头部导航 */}
<HeaderBar
title="围度统计"
transparent
/>
<ScrollView
style={styles.scrollView}
contentContainerStyle={{
paddingBottom: 60,
paddingHorizontal: 20,
paddingTop: safeAreaTop
}}
showsVerticalScrollIndicator={false}
>
{/* 日期选择器 */}
<View style={styles.dateContainer}>
<DateSelector
selectedIndex={selectedIndex}
onDateSelect={onSelectDate}
showMonthTitle={false}
disableFutureDates={true}
/>
</View>
{/* 当前日期围度数据 */}
<View style={styles.currentDataCard}>
<View style={styles.measurementsContainer}>
{measurements.map((measurement, index) => (
<TouchableOpacity
key={index}
style={[
styles.measurementItem,
!isSelectedDateToday && styles.measurementItemDisabled
]}
onPress={() => handleMeasurementPress(measurement)}
activeOpacity={isSelectedDateToday ? 0.7 : 1}
disabled={!isSelectedDateToday}
>
<View style={[styles.colorIndicator, { backgroundColor: measurement.color }]} />
<Text style={styles.label}>{measurement.label}</Text>
<View style={styles.valueContainer}>
<Text style={styles.value}>
{measurement.value ? measurement.value.toString() : '--'}
</Text>
</View>
</TouchableOpacity>
))}
</View>
</View>
{/* 围度统计 */}
<View style={styles.statsCard}>
<Text style={styles.statsTitle}></Text>
{/* Tab 切换 */}
<View style={styles.tabContainer}>
<TouchableOpacity
style={[styles.tab, activeTab === 'week' && styles.activeTab]}
onPress={() => handleTabPress('week')}
activeOpacity={0.7}
>
<Text style={[styles.tabText, activeTab === 'week' && styles.activeTabText]}>
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.tab, activeTab === 'month' && styles.activeTab]}
onPress={() => handleTabPress('month')}
activeOpacity={0.7}
>
<Text style={[styles.tabText, activeTab === 'month' && styles.activeTabText]}>
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.tab, activeTab === 'year' && styles.activeTab]}
onPress={() => handleTabPress('year')}
activeOpacity={0.7}
>
<Text style={[styles.tabText, activeTab === 'year' && styles.activeTabText]}>
</Text>
</TouchableOpacity>
</View>
{/* 图例 - 支持点击切换显示/隐藏 */}
<View style={styles.legendContainer}>
{CIRCUMFERENCE_TYPES.map((type, index) => {
const isVisible = visibleTypes.has(type.key);
return (
<TouchableOpacity
key={index}
style={[styles.legendItem, !isVisible && styles.legendItemHidden]}
onPress={() => handleLegendPress(type.key)}
activeOpacity={0.7}
>
<View style={[
styles.legendColor,
{ backgroundColor: isVisible ? type.color : '#E0E0E0' }
]} />
<Text style={[
styles.legendText,
!isVisible && styles.legendTextHidden
]}>
{type.label}
</Text>
</TouchableOpacity>
);
})}
</View>
{/* 折线图 */}
{isLoading ? (
<View style={styles.loadingChart}>
<ActivityIndicator size="large" color="#4ECDC4" />
<Text style={styles.loadingText}>...</Text>
</View>
) : error ? (
<View style={styles.errorChart}>
<Text style={styles.errorText}>: {error}</Text>
<TouchableOpacity
style={styles.retryButton}
onPress={() => dispatch(fetchCircumferenceAnalysis(activeTab))}
activeOpacity={0.7}
>
<Text style={styles.retryText}></Text>
</TouchableOpacity>
</View>
) : processedChartData.datasets.length > 0 ? (
<LineChart
data={{
labels: processedChartData.labels,
datasets: processedChartData.datasets,
}}
width={Dimensions.get('window').width - 80}
height={220}
yAxisSuffix="cm"
chartConfig={{
backgroundColor: '#ffffff',
backgroundGradientFrom: '#ffffff',
backgroundGradientTo: '#ffffff',
fillShadowGradientFromOpacity: 0,
fillShadowGradientToOpacity: 0,
decimalPlaces: 0,
color: (opacity = 1) => `rgba(100, 100, 100, ${opacity * 0.8})`,
labelColor: (opacity = 1) => `rgba(60, 60, 60, ${opacity})`,
style: {
borderRadius: 16,
},
propsForDots: {
r: "3",
strokeWidth: "2",
stroke: "#ffffff"
},
propsForBackgroundLines: {
strokeDasharray: "2,2",
stroke: "#E0E0E0",
strokeWidth: 1
},
}}
bezier
style={styles.chart}
/>
) : (
<View style={styles.emptyChart}>
<Text style={styles.emptyChartText}>
{processedChartData.datasets.length === 0 && !isLoading && !error
? '暂无数据'
: '请选择要显示的围度数据'
}
</Text>
</View>
)}
</View>
</ScrollView>
{/* 围度编辑弹窗 */}
<FloatingSelectionModal
visible={modalVisible}
onClose={() => {
setModalVisible(false);
setSelectedMeasurement(null);
}}
title={selectedMeasurement ? `设置${selectedMeasurement.label}` : '设置围度'}
items={circumferenceOptions}
selectedValue={selectedMeasurement?.currentValue}
onValueChange={() => { }} // Real-time update not needed
onConfirm={handleUpdateMeasurement}
confirmButtonText="确认"
pickerHeight={180}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#FFFFFF',
},
gradientBackground: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
},
scrollView: {
flex: 1,
},
dateContainer: {
marginTop: 16,
marginBottom: 20,
},
currentDataCard: {
backgroundColor: '#FFFFFF',
borderRadius: 16,
padding: 20,
marginBottom: 20,
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 4,
},
shadowOpacity: 0.12,
shadowRadius: 12,
elevation: 6,
},
currentDataTitle: {
fontSize: 16,
fontWeight: '700',
color: '#192126',
marginBottom: 16,
textAlign: 'center',
},
measurementsContainer: {
flexDirection: 'row',
justifyContent: 'space-between',
flexWrap: 'wrap',
},
measurementItem: {
alignItems: 'center',
width: '16%',
marginBottom: 12,
},
measurementItemDisabled: {
opacity: 0.6,
},
colorIndicator: {
width: 12,
height: 12,
borderRadius: 6,
marginBottom: 4,
},
label: {
fontSize: 12,
color: '#888',
marginBottom: 8,
textAlign: 'center',
},
valueContainer: {
backgroundColor: '#F5F5F7',
borderRadius: 10,
paddingHorizontal: 8,
paddingVertical: 6,
minWidth: 32,
alignItems: 'center',
justifyContent: 'center',
},
value: {
fontSize: 14,
fontWeight: '600',
color: '#192126',
textAlign: 'center',
},
statsCard: {
backgroundColor: '#FFFFFF',
borderRadius: 16,
padding: 20,
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 4,
},
shadowOpacity: 0.12,
shadowRadius: 12,
elevation: 6,
},
statsTitle: {
fontSize: 16,
fontWeight: '700',
color: '#192126',
marginBottom: 16,
},
tabContainer: {
flexDirection: 'row',
backgroundColor: '#F5F5F7',
borderRadius: 12,
padding: 4,
marginBottom: 20,
},
tab: {
flex: 1,
paddingVertical: 8,
paddingHorizontal: 16,
borderRadius: 8,
alignItems: 'center',
},
activeTab: {
backgroundColor: '#FFFFFF',
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 1,
},
shadowOpacity: 0.1,
shadowRadius: 2,
elevation: 2,
},
tabText: {
fontSize: 14,
fontWeight: '600',
color: '#888',
},
activeTabText: {
color: '#192126',
},
legendContainer: {
flexDirection: 'row',
flexWrap: 'wrap',
marginBottom: 16,
gap: 6,
},
legendItem: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 4,
paddingHorizontal: 8,
borderRadius: 8,
},
legendItemHidden: {
opacity: 0.5,
},
legendColor: {
width: 4,
height: 4,
borderRadius: 4,
marginRight: 4,
},
legendText: {
fontSize: 10,
color: '#666',
fontWeight: '500',
},
legendTextHidden: {
color: '#999',
},
chart: {
marginVertical: 8,
borderRadius: 16,
},
emptyChart: {
height: 220,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#F8F9FA',
borderRadius: 16,
marginVertical: 8,
},
emptyChartText: {
fontSize: 14,
color: '#999',
fontWeight: '500',
},
loadingChart: {
height: 220,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#F8F9FA',
borderRadius: 16,
marginVertical: 8,
},
loadingText: {
fontSize: 14,
color: '#666',
marginTop: 8,
fontWeight: '500',
},
errorChart: {
height: 220,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#FFF5F5',
borderRadius: 16,
marginVertical: 8,
padding: 20,
},
errorText: {
fontSize: 14,
color: '#E53E3E',
textAlign: 'center',
marginBottom: 12,
},
retryButton: {
backgroundColor: '#4ECDC4',
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 8,
},
retryText: {
color: '#FFFFFF',
fontSize: 14,
fontWeight: '600',
},
});

2350
app/coach.tsx Normal file

File diff suppressed because it is too large Load Diff

166
app/developer.tsx Normal file
View File

@@ -0,0 +1,166 @@
import { ROUTES } from '@/constants/Routes';
import { STORAGE_KEYS } from '@/services/api';
import AsyncStorage from '@/utils/kvStore';
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import React from 'react';
import { Alert, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
export default function DeveloperScreen() {
const router = useRouter();
const insets = useSafeAreaInsets();
const resetOnboardingStatus = async () => {
try {
await AsyncStorage.removeItem(STORAGE_KEYS.onboardingCompleted);
Alert.alert('成功', '引导状态已重置,下次启动应用将重新显示引导页面');
} catch (error) {
console.error('重置引导状态失败:', error);
Alert.alert('错误', '重置引导状态失败,请重试');
}
};
const developerItems = [
{
title: '日志',
subtitle: '查看应用运行日志',
icon: 'document-text-outline',
onPress: () => router.push(ROUTES.DEVELOPER_LOGS),
},
{
title: '重置引导状态',
subtitle: '清除 onboarding 缓存,下次启动将重新显示引导页面',
icon: 'refresh-outline',
onPress: resetOnboardingStatus,
},
];
return (
<View style={[styles.container, { paddingTop: insets.top }]}>
{/* Header */}
<View style={styles.header}>
<TouchableOpacity onPress={() => router.back()} style={styles.backButton}>
<Ionicons name="chevron-back" size={24} color="#2C3E50" />
</TouchableOpacity>
<Text style={styles.title}></Text>
<View style={styles.placeholder} />
</View>
<ScrollView style={styles.scrollView} showsVerticalScrollIndicator={false}>
<View style={styles.content}>
<View style={styles.cardContainer}>
{developerItems.map((item, index) => (
<TouchableOpacity
key={index}
style={[
styles.menuItem,
index === developerItems.length - 1 && { borderBottomWidth: 0 }
]}
onPress={item.onPress}
>
<View style={styles.menuItemLeft}>
<View style={styles.iconContainer}>
<Ionicons
name={item.icon as any}
size={20}
color="#9370DB"
/>
</View>
<View style={styles.textContainer}>
<Text style={styles.menuItemTitle}>{item.title}</Text>
<Text style={styles.menuItemSubtitle}>{item.subtitle}</Text>
</View>
</View>
<Ionicons name="chevron-forward" size={20} color="#CCCCCC" />
</TouchableOpacity>
))}
</View>
</View>
</ScrollView>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#F8F9FA',
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 16,
paddingVertical: 12,
backgroundColor: '#FFFFFF',
borderBottomWidth: 1,
borderBottomColor: '#E5E7EB',
},
backButton: {
width: 40,
height: 40,
justifyContent: 'center',
alignItems: 'center',
},
title: {
fontSize: 18,
fontWeight: 'bold',
color: '#2C3E50',
},
placeholder: {
width: 40,
},
scrollView: {
flex: 1,
},
content: {
padding: 16,
},
cardContainer: {
backgroundColor: '#FFFFFF',
borderRadius: 12,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 4,
elevation: 2,
overflow: 'hidden',
},
menuItem: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 16,
paddingHorizontal: 16,
borderBottomWidth: 1,
borderBottomColor: '#F1F3F4',
},
menuItemLeft: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
},
iconContainer: {
width: 40,
height: 40,
borderRadius: 8,
backgroundColor: 'rgba(147, 112, 219, 0.1)',
alignItems: 'center',
justifyContent: 'center',
marginRight: 12,
},
textContainer: {
flex: 1,
},
menuItemTitle: {
fontSize: 16,
color: '#2C3E50',
fontWeight: '600',
marginBottom: 2,
},
menuItemSubtitle: {
fontSize: 13,
color: '#6B7280',
},
});

328
app/developer/logs.tsx Normal file
View File

@@ -0,0 +1,328 @@
import { FastingNotificationTestPanel } from '@/components/developer/FastingNotificationTestPanel';
import { log, LogEntry, logger } from '@/utils/logger';
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import React, { useEffect, useState } from 'react';
import {
Alert,
FlatList,
RefreshControl,
Share,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
export default function LogsScreen() {
const router = useRouter();
const insets = useSafeAreaInsets();
const [logs, setLogs] = useState<LogEntry[]>([]);
const [refreshing, setRefreshing] = useState(false);
const [showTestPanel, setShowTestPanel] = useState(false);
const loadLogs = async () => {
try {
const allLogs = await logger.getAllLogs();
// 按时间倒序排列,最新的在前面
setLogs(allLogs.reverse());
} catch (error) {
log.error('加载日志失败', error);
}
};
const onRefresh = async () => {
setRefreshing(true);
await loadLogs();
setRefreshing(false);
};
const handleClearLogs = () => {
Alert.alert(
'清除日志',
'确定要清除所有日志吗?此操作不可恢复。',
[
{ text: '取消', style: 'cancel' },
{
text: '确定',
style: 'destructive',
onPress: async () => {
await logger.clearLogs();
setLogs([]);
log.info('日志已清除');
},
},
]
);
};
const handleExportLogs = async () => {
try {
const exportData = await logger.exportLogs();
await Share.share({
message: exportData,
title: '应用日志导出',
});
} catch (error) {
log.error('导出日志失败', error);
Alert.alert('错误', '导出日志失败');
}
};
const handleTestNotifications = () => {
setShowTestPanel(true);
};
useEffect(() => {
loadLogs();
// 添加测试日志
log.info('进入日志页面');
}, []);
const formatTimestamp = (timestamp: number) => {
const date = new Date(timestamp);
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
};
const getLevelColor = (level: string) => {
switch (level) {
case 'ERROR':
return '#FF4444';
case 'WARN':
return '#FF8800';
case 'INFO':
return '#0088FF';
case 'DEBUG':
return '#888888';
default:
return '#333333';
}
};
const getLevelIcon = (level: string) => {
switch (level) {
case 'ERROR':
return 'close-circle';
case 'WARN':
return 'warning';
case 'INFO':
return 'information-circle';
case 'DEBUG':
return 'bug';
default:
return 'ellipse';
}
};
const renderLogItem = ({ item }: { item: LogEntry }) => (
<View style={styles.logItem}>
<View style={styles.logHeader}>
<View style={styles.logLevelContainer}>
<Ionicons
name={getLevelIcon(item.level) as any}
size={16}
color={getLevelColor(item.level)}
style={styles.logIcon}
/>
<Text style={[styles.logLevel, { color: getLevelColor(item.level) }]}>
{item.level}
</Text>
</View>
<Text style={styles.timestamp}>{formatTimestamp(item.timestamp)}</Text>
</View>
<Text style={styles.logMessage}>{item.message}</Text>
{item.data && (
<Text style={styles.logData}>{JSON.stringify(item.data, null, 2)}</Text>
)}
</View>
);
return (
<View style={[styles.container, { paddingTop: insets.top }]}>
{/* Header */}
<View style={styles.header}>
<TouchableOpacity onPress={() => router.back()} style={styles.backButton}>
<Ionicons name="chevron-back" size={24} color="#2C3E50" />
</TouchableOpacity>
<Text style={styles.title}> ({logs.length})</Text>
<View style={styles.headerActions}>
<TouchableOpacity onPress={handleTestNotifications} style={styles.actionButton}>
<Ionicons name="notifications-outline" size={20} color="#FF8800" />
</TouchableOpacity>
<TouchableOpacity onPress={handleExportLogs} style={styles.actionButton}>
<Ionicons name="share-outline" size={20} color="#9370DB" />
</TouchableOpacity>
<TouchableOpacity onPress={handleClearLogs} style={styles.actionButton}>
<Ionicons name="trash-outline" size={20} color="#FF4444" />
</TouchableOpacity>
</View>
</View>
{/* Logs List */}
<FlatList
data={logs}
renderItem={renderLogItem}
keyExtractor={(item) => item.id}
style={styles.logsList}
contentContainerStyle={styles.logsContent}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
}
ListEmptyComponent={
<View style={styles.emptyContainer}>
<Ionicons name="document-text-outline" size={48} color="#CCCCCC" />
<Text style={styles.emptyText}></Text>
<TouchableOpacity
style={styles.testButton}
onPress={() => {
log.debug('测试调试日志');
log.info('测试信息日志');
log.warn('测试警告日志');
log.error('测试错误日志');
setTimeout(loadLogs, 100);
}}
>
<Text style={styles.testButtonText}></Text>
</TouchableOpacity>
</View>
}
/>
{/* 断食通知测试面板 */}
{showTestPanel && (
<FastingNotificationTestPanel
onClose={() => setShowTestPanel(false)}
/>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#F8F9FA',
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 16,
paddingVertical: 12,
backgroundColor: '#FFFFFF',
borderBottomWidth: 1,
borderBottomColor: '#E5E7EB',
},
backButton: {
width: 40,
height: 40,
justifyContent: 'center',
alignItems: 'center',
},
title: {
fontSize: 18,
fontWeight: 'bold',
color: '#2C3E50',
flex: 1,
marginLeft: 8,
},
headerActions: {
flexDirection: 'row',
alignItems: 'center',
},
actionButton: {
width: 36,
height: 36,
justifyContent: 'center',
alignItems: 'center',
marginLeft: 8,
},
logsList: {
flex: 1,
},
logsContent: {
padding: 16,
},
logItem: {
backgroundColor: '#FFFFFF',
padding: 12,
marginBottom: 8,
borderRadius: 8,
borderLeftWidth: 3,
borderLeftColor: '#E5E7EB',
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
elevation: 1,
},
logHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 6,
},
logLevelContainer: {
flexDirection: 'row',
alignItems: 'center',
},
logIcon: {
marginRight: 4,
},
logLevel: {
fontSize: 12,
fontWeight: 'bold',
fontFamily: 'monospace',
},
timestamp: {
fontSize: 11,
color: '#9CA3AF',
fontFamily: 'monospace',
},
logMessage: {
fontSize: 14,
color: '#374151',
lineHeight: 20,
fontFamily: 'monospace',
},
logData: {
fontSize: 12,
color: '#6B7280',
marginTop: 8,
padding: 8,
backgroundColor: '#F3F4F6',
borderRadius: 4,
fontFamily: 'monospace',
},
emptyContainer: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 48,
},
emptyText: {
fontSize: 16,
color: '#9CA3AF',
marginTop: 12,
marginBottom: 24,
},
testButton: {
backgroundColor: '#9370DB',
paddingHorizontal: 20,
paddingVertical: 10,
borderRadius: 8,
},
testButtonText: {
color: '#FFFFFF',
fontSize: 14,
fontWeight: '600',
},
});

524
app/explore.tsx Normal file
View File

@@ -0,0 +1,524 @@
import { ArticleCard } from '@/components/ArticleCard';
import { PlanCard } from '@/components/PlanCard';
import { SearchBox } from '@/components/SearchBox';
import { ThemedText } from '@/components/ThemedText';
import { ThemedView } from '@/components/ThemedView';
import { Colors } from '@/constants/Colors';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useColorScheme } from '@/hooks/useColorScheme';
import { fetchRecommendations, RecommendationType } from '@/services/recommendations';
import { loadPlans } from '@/store/trainingPlanSlice';
// Removed WorkoutCard import since we no longer use the horizontal carousel
import { QUERY_PARAMS, ROUTE_PARAMS, ROUTES } from '@/constants/Routes';
import { useAuthGuard } from '@/hooks/useAuthGuard';
import { TrainingPlan } from '@/services/trainingPlanApi';
import { useRouter } from 'expo-router';
import React from 'react';
import { Animated, Image, PanResponder, Pressable, ScrollView, StyleSheet, useWindowDimensions, View } from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
// 移除旧的“热门活动”滑动数据,改为固定的“热点功能”卡片
export default function HomeScreen() {
const router = useRouter();
const dispatch = useAppDispatch();
const { pushIfAuthedElseLogin, isLoggedIn } = useAuthGuard();
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme];
const insets = useSafeAreaInsets();
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
// 训练计划状态
const { plans } = useAppSelector((s) => s.trainingPlan);
const [activePlan, setActivePlan] = React.useState<TrainingPlan | null>(null);
// Draggable coach badge state
const pan = React.useRef(new Animated.ValueXY()).current;
const [coachSize, setCoachSize] = React.useState({ width: 0, height: 0 });
const hasInitPos = React.useRef(false);
const startRef = React.useRef({ x: 0, y: 0 });
const dragState = React.useRef({ moved: false });
const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(max, value));
const panResponder = React.useMemo(() => PanResponder.create({
onStartShouldSetPanResponder: () => true,
onMoveShouldSetPanResponder: (_evt, gesture) => Math.abs(gesture.dx) + Math.abs(gesture.dy) > 2,
onPanResponderGrant: () => {
dragState.current.moved = false;
// @ts-ignore access current value
const currentX = (pan.x as any)._value ?? 0;
// @ts-ignore access current value
const currentY = (pan.y as any)._value ?? 0;
startRef.current = { x: currentX, y: currentY };
},
onPanResponderMove: (_evt, gesture) => {
if (!dragState.current.moved && (Math.abs(gesture.dx) + Math.abs(gesture.dy) > 4)) {
dragState.current.moved = true;
}
const nextX = startRef.current.x + gesture.dx;
const nextY = startRef.current.y + gesture.dy;
pan.setValue({ x: nextX, y: nextY });
},
onPanResponderRelease: (_evt, gesture) => {
const minX = 8;
const minY = insets.top + 2;
const maxX = Math.max(minX, windowWidth - coachSize.width - 8);
const maxY = Math.max(minY, windowHeight - coachSize.height - (insets.bottom + 8));
const rawX = startRef.current.x + gesture.dx;
const rawY = startRef.current.y + gesture.dy;
const clampedX = clamp(rawX, minX, maxX);
const clampedY = clamp(rawY, minY, maxY);
// Snap horizontally to nearest side (left/right only)
const distLeft = Math.abs(clampedX - minX);
const distRight = Math.abs(maxX - clampedX);
const snapX = distLeft <= distRight ? minX : maxX;
Animated.spring(pan, { toValue: { x: snapX, y: clampedY }, useNativeDriver: false, bounciness: 6 }).start(() => {
if (!dragState.current.moved) {
// 切换到教练 tab并传递name参数
router.push(`${ROUTES.TAB_COACH}?${QUERY_PARAMS.COACH_NAME}=Iris` as any);
}
});
},
}), [coachSize.height, coachSize.width, insets.bottom, insets.top, pan, windowHeight, windowWidth, router]);
// 推荐项类型(本地 UI 使用)
type RecommendItem =
| {
type: 'plan';
key: string;
image: string;
title: string;
subtitle: string;
level?: '初学者' | '中级' | '高级';
onPress?: () => void;
}
| {
type: 'article';
key: string;
id: string;
title: string;
coverImage: string;
publishedAt: string;
readCount: number;
};
const [items, setItems] = React.useState<RecommendItem[]>();
// 加载训练计划数据
React.useEffect(() => {
if (isLoggedIn) {
dispatch(loadPlans());
}
}, [isLoggedIn, dispatch]);
// 获取激活的训练计划
React.useEffect(() => {
if (isLoggedIn && plans.length > 0) {
const currentPlan = plans.find(p => p.isActive);
setActivePlan(currentPlan || null);
} else {
setActivePlan(null);
}
}, [isLoggedIn, plans]);
// 拉取推荐接口(已登录时)
React.useEffect(() => {
let canceled = false;
async function load() {
try {
const cards = await fetchRecommendations();
if (canceled) return;
const mapped: RecommendItem[] = [];
for (const c of cards || []) {
if (c.type === RecommendationType.Article) {
const publishedAt = (c.extra && (c.extra.publishedDate || c.extra.published_at)) || new Date().toISOString();
const readCount = (c.extra && (c.extra.readCount ?? c.extra.read_count)) || 0;
mapped.push({
type: 'article',
key: c.id,
id: c.articleId || c.id,
title: c.title || '',
coverImage: c.coverUrl,
publishedAt,
readCount,
});
} else if (c.type === RecommendationType.Checkin) {
mapped.push({
type: 'plan',
key: c.id || 'checkin',
image: 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/imagedemo.jpeg',
title: c.title || '今日训练',
subtitle: c.subtitle || '完成一次普拉提训练,记录你的坚持',
onPress: () => pushIfAuthedElseLogin(ROUTES.WORKOUT_TODAY),
});
}
}
// 若接口返回空,也回退到打底
setItems(mapped.length > 0 ? mapped : []);
} catch (e) {
console.error('fetchRecommendations error', e);
setItems([]);
}
}
load();
return () => { canceled = true; };
}, [isLoggedIn, pushIfAuthedElseLogin]);
// 处理点击训练计划卡片跳转到锻炼tab
const handlePlanCardPress = () => {
if (activePlan) {
// 跳转到训练计划页面的锻炼tab并传递planId参数
router.push(`${ROUTES.TRAINING_PLAN}?${ROUTE_PARAMS.TRAINING_PLAN_ID}=${activePlan.id}&${ROUTE_PARAMS.TRAINING_PLAN_TAB}=${QUERY_PARAMS.TRAINING_PLAN_TAB_SCHEDULE}` as any);
}
};
return (
<SafeAreaView style={[styles.safeArea, { backgroundColor: theme === 'light' ? colorTokens.pageBackgroundEmphasis : colorTokens.background }]}>
<ThemedView style={[styles.container, { backgroundColor: theme === 'light' ? colorTokens.pageBackgroundEmphasis : colorTokens.background }]}>
{/* Floating Coach Badge */}
<View pointerEvents="box-none" style={styles.coachOverlayWrap}>
<Animated.View
{...panResponder.panHandlers}
onLayout={(e) => {
const { width, height } = e.nativeEvent.layout;
if (width !== coachSize.width || height !== coachSize.height) {
setCoachSize({ width, height });
}
if (!hasInitPos.current && width > 0 && windowWidth > 0) {
const initX = windowWidth - width - 14;
const initY = insets.top + 2; // 默认更靠上,避免遮挡搜索框
pan.setValue({ x: initX, y: initY });
hasInitPos.current = true;
}
}}
style={[
styles.coachBadge,
{
transform: [{ translateX: pan.x }, { translateY: pan.y }],
backgroundColor: colorTokens.heroSurfaceTint,
borderColor: 'rgba(187,242,70,0.35)',
shadowColor: '#000',
shadowOpacity: 0.08,
shadowRadius: 10,
shadowOffset: { width: 0, height: 4 },
elevation: 3,
position: 'absolute',
left: 0,
top: 0,
},
]}
>
<Image
source={{ uri: 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/avatar/imageCoach01.jpeg' }}
style={styles.coachAvatar}
/>
<View style={styles.coachMeta}>
<ThemedText style={styles.coachName}>Iris</ThemedText>
<View style={styles.coachStatusRow}>
<View style={styles.statusDot} />
<ThemedText style={styles.coachStatusText}>线</ThemedText>
</View>
</View>
</Animated.View>
</View>
<ScrollView showsVerticalScrollIndicator={false}>
{/* Header Section */}
{/* <View style={styles.header}>
<ThemedText style={styles.greeting}>{getChineseGreeting()}</ThemedText>
<ThemedText style={styles.userName}></ThemedText>
</View> */}
{/* Search Box */}
<SearchBox placeholder="搜索" />
{/* Hot Features Section */}
<View style={styles.sectionContainer}>
<ThemedText style={styles.sectionTitle}></ThemedText>
<View style={styles.featureGrid}>
<Pressable
style={[styles.featureCard, styles.featureCardQuinary]}
onPress={() => pushIfAuthedElseLogin(ROUTES.WORKOUT_TODAY)}
>
<View style={styles.featureIconWrapper}>
<View style={styles.featureIconPlaceholder}>
<Image
source={require('@/assets/images/icons/iconWorkout.png')}
style={styles.featureIconImage}
/>
</View>
</View>
<ThemedText style={styles.featureTitle}></ThemedText>
</Pressable>
<Pressable
style={[styles.featureCard, styles.featureCardPrimary]}
onPress={() => pushIfAuthedElseLogin(ROUTES.AI_POSTURE_ASSESSMENT)}
>
<View style={styles.featureIconWrapper}>
<Image
source={require('@/assets/images/demo/imageBody.jpeg')}
style={styles.featureIconImage}
/>
</View>
<ThemedText style={styles.featureTitle}></ThemedText>
</Pressable>
<Pressable
style={[styles.featureCard, styles.featureCardQuaternary]}
onPress={() => pushIfAuthedElseLogin(ROUTES.TRAINING_PLAN)}
>
<View style={styles.featureIconWrapper}>
<View style={styles.featureIconPlaceholder}>
<Image
source={require('@/assets/images/icons/iconPlan.png')}
style={styles.featureIconImage}
/>
</View>
</View>
<ThemedText style={styles.featureTitle}></ThemedText>
</Pressable>
</View>
</View>
{/* My Plan Section - 显示激活的训练计划 */}
{/* {activePlan && (
<MyPlanCard
plan={activePlan}
onPress={handlePlanCardPress}
/>
)} */}
{/* Today Plan Section */}
<View style={styles.sectionContainer}>
<ThemedText style={styles.sectionTitle}></ThemedText>
<View style={styles.planList}>
{items?.map((item) => {
if (item.type === 'article') {
return (
<ArticleCard
key={item.key}
id={item.id}
title={item.title}
coverImage={item.coverImage}
publishedAt={item.publishedAt}
readCount={item.readCount}
/>
);
}
const card = (
<PlanCard
image={item.image}
title={item.title}
subtitle={item.subtitle}
level={item.level}
/>
);
return item.onPress ? (
<Pressable key={item.key} onPress={item.onPress}>
{card}
</Pressable>
) : (
<View key={item.key}>{card}</View>
);
})}
</View>
</View>
{/* Add some spacing at the bottom */}
<View style={styles.bottomSpacing} />
</ScrollView>
</ThemedView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safeArea: {
flex: 1,
backgroundColor: '#F7F8FA',
},
container: {
flex: 1,
backgroundColor: '#F7F8FA',
},
header: {
paddingHorizontal: 24,
paddingTop: 16,
paddingBottom: 8,
},
coachOverlayWrap: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
zIndex: 10,
},
greeting: {
fontSize: 16,
color: '#8A8A8E',
fontWeight: '400',
marginBottom: 6,
},
userName: {
fontSize: 30,
fontWeight: 'bold',
color: '#1A1A1A',
lineHeight: 36,
},
coachBadge: {
flexDirection: 'row',
alignItems: 'center',
// RN 不完全支持 gap这里用 margin 实现
paddingHorizontal: 10,
paddingVertical: 8,
borderRadius: 20,
borderWidth: 1,
backgroundColor: '#FFFFFF00',
},
coachAvatar: {
width: 26,
height: 26,
borderRadius: 13,
},
coachMeta: {
marginLeft: 8,
},
coachName: {
fontSize: 13,
fontWeight: '700',
color: '#192126',
},
coachStatusRow: {
flexDirection: 'row',
alignItems: 'center',
marginTop: 2,
},
statusDot: {
width: 6,
height: 6,
borderRadius: 3,
backgroundColor: '#22C55E',
marginRight: 4,
},
coachStatusText: {
fontSize: 11,
color: '#6B7280',
},
sectionContainer: {
marginTop: 24,
},
sectionTitle: {
fontSize: 24,
fontWeight: 'bold',
color: '#1A1A1A',
paddingHorizontal: 24,
marginBottom: 18,
},
featureGrid: {
paddingHorizontal: 24,
flexDirection: 'row',
gap: 12,
},
featureCard: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
borderRadius: 12,
paddingHorizontal: 12,
paddingVertical: 10,
backgroundColor: '#FFFFFF',
// 精致的阴影效果
shadowColor: '#000',
shadowOpacity: 0.06,
shadowRadius: 8,
shadowOffset: { width: 0, height: 2 },
elevation: 3,
// 渐变边框效果
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.8)',
// 添加微妙的内阴影效果
position: 'relative',
minHeight: 48,
},
featureCardPrimary: {
// 由于RN不支持CSS渐变使用渐变色背景
backgroundColor: '#667eea',
},
featureCardSecondary: {
backgroundColor: '#4facfe',
},
featureCardTertiary: {
backgroundColor: '#43e97b',
},
featureCardQuaternary: {
backgroundColor: '#fa709a',
},
featureCardQuinary: {
backgroundColor: '#f59e0b',
},
featureIconWrapper: {
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: 'rgba(255, 255, 255, 0.25)',
alignItems: 'center',
justifyContent: 'center',
marginRight: 10,
// 图标容器的阴影
shadowColor: '#000',
shadowOpacity: 0.06,
shadowRadius: 4,
shadowOffset: { width: 0, height: 1 },
elevation: 2,
},
featureIconImage: {
width: 20,
height: 20,
borderRadius: 10,
resizeMode: 'cover',
},
featureIconPlaceholder: {
width: 20,
height: 20,
borderRadius: 10,
backgroundColor: 'rgba(255, 255, 255, 0.3)',
alignItems: 'center',
justifyContent: 'center',
},
featureIconText: {
fontSize: 12,
},
featureTitle: {
fontSize: 14,
fontWeight: '700',
color: '#FFFFFF',
textAlign: 'left',
letterSpacing: 0.2,
flex: 1,
},
featureSubtitle: {
fontSize: 12,
color: 'rgba(255, 255, 255, 0.85)',
lineHeight: 16,
textAlign: 'center',
fontWeight: '500',
},
planList: {
paddingHorizontal: 24,
},
// 移除旧的滑动样式
bottomSpacing: {
height: 120,
},
});

528
app/fasting/[planId].tsx Normal file
View File

@@ -0,0 +1,528 @@
import { CircularRing } from '@/components/CircularRing';
import { FastingStartPickerModal } from '@/components/fasting/FastingStartPickerModal';
import { Colors } from '@/constants/Colors';
import { FASTING_PLANS, FastingPlan, getPlanById, getRecommendedStart } from '@/constants/Fasting';
import { ROUTES } from '@/constants/Routes';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useColorScheme } from '@/hooks/useColorScheme';
import {
rescheduleActivePlan,
scheduleFastingPlan,
selectActiveFastingSchedule,
} from '@/store/fastingSlice';
import { buildDisplayWindow, calculateFastingWindow, savePreferredPlanId } from '@/utils/fasting';
import { Ionicons } from '@expo/vector-icons';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { LinearGradient } from 'expo-linear-gradient';
import { useLocalSearchParams, useRouter } from 'expo-router';
import React, { useEffect, useMemo, useState } from 'react';
import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
type InfoTab = 'fit' | 'avoid' | 'intro';
const TAB_LABELS: Record<InfoTab, string> = {
fit: '适合人群',
avoid: '不适合人群',
intro: '计划介绍',
};
export default function FastingPlanDetailScreen() {
const router = useRouter();
const insets = useSafeAreaInsets();
const theme = useColorScheme() ?? 'light';
const colors = Colors[theme];
const dispatch = useAppDispatch();
const activeSchedule = useAppSelector(selectActiveFastingSchedule);
const { planId } = useLocalSearchParams<{ planId: string }>();
const fallbackPlan = FASTING_PLANS[0];
const plan: FastingPlan = useMemo(
() => (planId ? getPlanById(planId) ?? fallbackPlan : fallbackPlan),
[planId, fallbackPlan]
);
useEffect(() => {
void savePreferredPlanId(plan.id);
}, [plan.id]);
const [infoTab, setInfoTab] = useState<InfoTab>('fit');
const [showPicker, setShowPicker] = useState(false);
const glassAvailable = isLiquidGlassAvailable();
const recommendedStart = useMemo(() => getRecommendedStart(plan), [plan]);
const window = calculateFastingWindow(recommendedStart, plan.fastingHours);
const displayWindow = buildDisplayWindow(window.start, window.end);
const handleStartWithRecommended = () => {
dispatch(scheduleFastingPlan({ planId: plan.id, start: recommendedStart.toISOString(), origin: 'recommended' }));
router.replace(ROUTES.TAB_FASTING);
};
const handleOpenPicker = () => {
setShowPicker(true);
};
const handleConfirmPicker = (date: Date) => {
if (activeSchedule?.planId === plan.id) {
dispatch(rescheduleActivePlan({ start: date.toISOString(), origin: 'manual' }));
} else {
dispatch(scheduleFastingPlan({ planId: plan.id, start: date.toISOString(), origin: 'manual' }));
}
setShowPicker(false);
router.replace(ROUTES.TAB_FASTING);
};
const renderInfoList = () => {
let items: string[] = [];
if (infoTab === 'fit') items = plan.audienceFit;
if (infoTab === 'avoid') items = plan.audienceAvoid;
if (infoTab === 'intro') items = [plan.description, ...plan.nutritionTips];
return (
<View style={styles.infoList}>
{items.map((item) => (
<View key={item} style={styles.infoItem}>
<View style={[styles.infoDot, { backgroundColor: plan.theme.accent }]} />
<Text style={styles.infoText}>{item}</Text>
</View>
))}
</View>
);
};
const fastingRatio = plan.fastingHours / 24;
return (
<View style={[styles.safeArea, { backgroundColor: '#ffffff' }]}>
{/* 固定悬浮的返回按钮 */}
<View style={[styles.backButtonContainer, { paddingTop: insets.top + 12 }]}>
<TouchableOpacity style={styles.backButton} onPress={router.back} activeOpacity={0.8}>
{glassAvailable ? (
<GlassView
style={styles.backButtonGlass}
glassEffectStyle="regular"
tintColor="rgba(255,255,255,0.4)"
isInteractive={true}
>
<Ionicons name="chevron-back" size={24} color="#2E3142" />
</GlassView>
) : (
<View style={styles.backButtonFallback}>
<Ionicons name="chevron-back" size={24} color="#2E3142" />
</View>
)}
</TouchableOpacity>
</View>
<ScrollView contentContainerStyle={{ paddingBottom: 40 }}>
<LinearGradient
colors={[plan.theme.accentSecondary, plan.theme.backdrop]}
style={[styles.hero, { paddingTop: insets.top + 12 }]}
>
<View style={{
paddingTop: insets.top + 12
}}>
<View style={styles.heroHeader}>
<Text style={styles.planId}>{plan.id}</Text>
{plan.badge && (
<View style={[styles.heroBadge, { backgroundColor: `${plan.theme.accent}2B` }]}>
<Text style={[styles.heroBadgeText, { color: plan.theme.accent }]}>{plan.badge}</Text>
</View>
)}
</View>
<Text style={styles.heroTitle}>{plan.title}</Text>
<Text style={styles.heroSubtitle}>{plan.subtitle}</Text>
<View style={styles.tagRow}>
<View style={[styles.tagChip, { backgroundColor: `${plan.theme.accent}22` }]}>
<Text style={[styles.tagChipText, { color: plan.theme.accent }]}>
{plan.fastingHours}
</Text>
</View>
<View style={[styles.tagChip, { backgroundColor: `${plan.theme.accent}22` }]}>
<Text style={[styles.tagChipText, { color: plan.theme.accent }]}>
{plan.eatingHours}
</Text>
</View>
</View>
</View>
</LinearGradient>
<View style={styles.body}>
<View style={styles.chartCard}>
<CircularRing
size={190}
strokeWidth={18}
progress={fastingRatio}
progressColor={plan.theme.accent}
trackColor={plan.theme.ringTrack}
showCenterText={false}
/>
<View style={styles.chartContent}>
<Text style={styles.chartTitle}></Text>
<Text style={styles.chartValue}>{plan.fastingHours} h </Text>
<Text style={styles.chartSubtitle}> {plan.eatingHours} h</Text>
</View>
<View style={styles.legendRow}>
<View style={styles.legendItem}>
<View style={[styles.legendDot, { backgroundColor: plan.theme.accent }]} />
<Text style={styles.legendText}></Text>
</View>
<View style={styles.legendItem}>
<View style={[styles.legendDot, { backgroundColor: plan.theme.ringTrack }]} />
<Text style={styles.legendText}></Text>
</View>
</View>
</View>
<View style={styles.windowCard}>
<Text style={styles.windowLabel}></Text>
<View style={styles.windowRow}>
<View style={styles.windowCell}>
<Text style={styles.windowTitle}></Text>
<Text style={styles.windowDay}>{displayWindow.startDayLabel}</Text>
<Text style={[styles.windowTime, { color: plan.theme.accent }]}>
{displayWindow.startTimeLabel}
</Text>
</View>
<View style={styles.windowDivider} />
<View style={styles.windowCell}>
<Text style={styles.windowTitle}></Text>
<Text style={styles.windowDay}>{displayWindow.endDayLabel}</Text>
<Text style={[styles.windowTime, { color: plan.theme.accent }]}>
{displayWindow.endTimeLabel}
</Text>
</View>
</View>
<Text style={styles.windowHint}>
2
</Text>
</View>
<View style={styles.tabContainer}>
{(Object.keys(TAB_LABELS) as InfoTab[]).map((tabKey) => {
const isActive = infoTab === tabKey;
return (
<TouchableOpacity
key={tabKey}
style={[
styles.tabButton,
isActive && { backgroundColor: plan.theme.accent },
]}
onPress={() => setInfoTab(tabKey)}
activeOpacity={0.9}
>
<Text
style={[
styles.tabButtonText,
{ color: isActive ? '#fff' : colors.textSecondary },
]}
>
{TAB_LABELS[tabKey]}
</Text>
</TouchableOpacity>
);
})}
</View>
{renderInfoList()}
<View style={styles.actionBlock}>
<TouchableOpacity
style={[styles.secondaryAction, { borderColor: plan.theme.accent }]}
onPress={handleOpenPicker}
activeOpacity={0.85}
>
<Text style={[styles.secondaryActionText, { color: plan.theme.accent }]}>
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.primaryAction, { backgroundColor: plan.theme.accent }]}
onPress={handleStartWithRecommended}
activeOpacity={0.9}
>
<Text style={styles.primaryActionText}>
</Text>
</TouchableOpacity>
</View>
</View>
</ScrollView>
<FastingStartPickerModal
visible={showPicker}
onClose={() => setShowPicker(false)}
initialDate={recommendedStart}
recommendedDate={recommendedStart}
onConfirm={handleConfirmPicker}
/>
</View>
);
}
const styles = StyleSheet.create({
safeArea: {
flex: 1,
},
hero: {
paddingHorizontal: 24,
paddingBottom: 32,
borderBottomLeftRadius: 32,
borderBottomRightRadius: 32,
},
backButtonContainer: {
position: 'absolute',
top: 0,
left: 24,
zIndex: 10,
},
backButton: {
width: 44,
height: 44,
borderRadius: 22,
alignItems: 'center',
justifyContent: 'center',
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 4,
},
shadowOpacity: 0.15,
shadowRadius: 8,
elevation: 8,
},
backButtonGlass: {
width: 44,
height: 44,
borderRadius: 22,
alignItems: 'center',
justifyContent: 'center',
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.3)',
overflow: 'hidden',
},
backButtonFallback: {
width: 44,
height: 44,
borderRadius: 22,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(255,255,255,0.85)',
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.5)',
},
heroContent: {
},
heroHeader: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 14,
},
planId: {
fontSize: 18,
fontWeight: '700',
color: '#2E3142',
marginRight: 12,
},
heroBadge: {
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 16,
},
heroBadgeText: {
fontSize: 12,
fontWeight: '600',
},
heroTitle: {
fontSize: 26,
fontWeight: '800',
color: '#2E3142',
marginBottom: 8,
},
heroSubtitle: {
fontSize: 14,
color: '#5B6572',
marginBottom: 16,
},
tagRow: {
flexDirection: 'row',
},
tagChip: {
marginRight: 10,
paddingHorizontal: 14,
paddingVertical: 8,
borderRadius: 18,
},
tagChipText: {
fontSize: 12,
fontWeight: '600',
},
body: {
paddingHorizontal: 24,
paddingTop: 28,
},
chartCard: {
alignItems: 'center',
marginBottom: 24,
},
chartContent: {
position: 'absolute',
top: 70,
alignItems: 'center',
},
chartTitle: {
fontSize: 14,
color: '#6F7D87',
marginBottom: 6,
},
chartValue: {
fontSize: 20,
fontWeight: '700',
color: '#2E3142',
},
chartSubtitle: {
fontSize: 12,
color: '#6F7D87',
marginTop: 4,
},
legendRow: {
flexDirection: 'row',
justifyContent: 'center',
marginTop: 20,
},
legendItem: {
flexDirection: 'row',
alignItems: 'center',
marginHorizontal: 12,
},
legendDot: {
width: 12,
height: 12,
borderRadius: 6,
marginRight: 6,
},
legendText: {
fontSize: 12,
color: '#5B6572',
},
windowCard: {
borderRadius: 20,
backgroundColor: '#FFFFFF',
padding: 20,
marginBottom: 24,
shadowColor: '#000',
shadowOpacity: 0.04,
shadowRadius: 12,
shadowOffset: { width: 0, height: 10 },
elevation: 3,
},
windowLabel: {
fontSize: 16,
fontWeight: '700',
color: '#2E3142',
marginBottom: 12,
},
windowRow: {
flexDirection: 'row',
alignItems: 'center',
},
windowCell: {
flex: 1,
alignItems: 'center',
},
windowTitle: {
fontSize: 12,
color: '#778290',
marginBottom: 6,
},
windowDay: {
fontSize: 16,
fontWeight: '600',
color: '#2E3142',
},
windowTime: {
fontSize: 24,
fontWeight: '700',
marginTop: 6,
},
windowDivider: {
width: 1,
height: 60,
backgroundColor: 'rgba(95,105,116,0.2)',
},
windowHint: {
fontSize: 12,
color: '#6F7D87',
marginTop: 16,
lineHeight: 18,
},
tabContainer: {
flexDirection: 'row',
marginBottom: 20,
borderRadius: 20,
backgroundColor: '#F2F3F5',
padding: 4,
},
tabButton: {
flex: 1,
borderRadius: 16,
paddingVertical: 12,
alignItems: 'center',
},
tabButtonText: {
fontSize: 13,
fontWeight: '600',
},
infoList: {
marginBottom: 28,
},
infoItem: {
flexDirection: 'row',
alignItems: 'flex-start',
marginBottom: 12,
},
infoDot: {
width: 6,
height: 6,
borderRadius: 3,
marginRight: 10,
marginTop: 7,
},
infoText: {
flex: 1,
fontSize: 14,
color: '#4A5460',
lineHeight: 21,
},
actionBlock: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 50,
},
secondaryAction: {
flex: 1,
borderWidth: 1.4,
borderRadius: 24,
paddingVertical: 14,
alignItems: 'center',
marginRight: 12,
backgroundColor: '#FFFFFF',
},
secondaryActionText: {
fontSize: 14,
fontWeight: '600',
},
primaryAction: {
flex: 1,
borderRadius: 24,
paddingVertical: 14,
alignItems: 'center',
},
primaryActionText: {
fontSize: 16,
fontWeight: '700',
color: '#FFFFFF',
},
});

300
app/fasting/references.tsx Normal file
View File

@@ -0,0 +1,300 @@
import { Colors } from '@/constants/Colors';
import { useColorScheme } from '@/hooks/useColorScheme';
import { Ionicons } from '@expo/vector-icons';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { useRouter } from 'expo-router';
import React from 'react';
import { Linking, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
// 参考文献数据
const references = [
{
id: 5,
name: '中国国家卫生健康委员会(国家卫健委)',
englishName: 'National Health Commission of the People\'s Republic of China',
url: 'http://www.nhc.gov.cn',
note: '(用于中文用户环境非常合适)',
},
{
id: 1,
name: '美国国立卫生研究院NIH',
englishName: 'National Institutes of Health',
url: 'https://www.nih.gov',
},
{
id: 3,
name: '世界卫生组织WHO',
englishName: 'World Health Organization',
url: 'https://www.who.int',
},
{
id: 6,
name: '中国营养学会Chinese Nutrition Society',
englishName: 'Chinese Nutrition Society',
url: 'https://www.cnsoc.org',
},
];
export default function FastingReferencesScreen() {
const router = useRouter();
const insets = useSafeAreaInsets();
const theme = useColorScheme() ?? 'light';
const colors = Colors[theme];
const glassAvailable = isLiquidGlassAvailable();
const handleBack = () => {
router.back();
};
const handleLinkPress = async (url: string) => {
try {
const canOpen = await Linking.canOpenURL(url);
if (canOpen) {
await Linking.openURL(url);
} else {
console.log('无法打开链接:', url);
}
} catch (error) {
console.error('打开链接时发生错误:', error);
}
};
return (
<View style={[styles.safeArea, { backgroundColor: '#ffffff' }]}>
{/* 固定悬浮的返回按钮 */}
<View style={[styles.backButtonContainer, { paddingTop: insets.top + 12 }]}>
<TouchableOpacity style={styles.backButton} onPress={handleBack} activeOpacity={0.8}>
{glassAvailable ? (
<GlassView
style={styles.backButtonGlass}
glassEffectStyle="regular"
tintColor="rgba(255,255,255,0.4)"
isInteractive={true}
>
<Ionicons name="chevron-back" size={24} color="#2E3142" />
</GlassView>
) : (
<View style={styles.backButtonFallback}>
<Ionicons name="chevron-back" size={24} color="#2E3142" />
</View>
)}
</TouchableOpacity>
</View>
<ScrollView
contentContainerStyle={[
styles.scrollContainer,
{ paddingTop: insets.top + 80 }
]}
showsVerticalScrollIndicator={false}
>
<View style={styles.headerSection}>
<Text style={styles.title}></Text>
<Text style={styles.subtitle}>
</Text>
</View>
<View style={styles.referencesList}>
{references.map((reference) => (
<View key={reference.id} style={styles.referenceCard}>
<View style={styles.referenceHeader}>
<View style={styles.referenceIcon}>
<Ionicons name="medical-outline" size={24} color="#2E3142" />
</View>
<View style={styles.referenceInfo}>
<Text style={styles.referenceName}>{reference.name}</Text>
<Text style={styles.referenceEnglishName}>{reference.englishName}</Text>
</View>
</View>
<TouchableOpacity
style={styles.referenceLink}
onPress={() => handleLinkPress(reference.url)}
activeOpacity={0.8}
>
<Text style={styles.referenceUrl}>{reference.url}</Text>
<Ionicons name="open-outline" size={16} color="#6F7D87" />
</TouchableOpacity>
{reference.note && (
<Text style={styles.referenceNote}>{reference.note}</Text>
)}
</View>
))}
</View>
<View style={styles.disclaimerSection}>
<View style={styles.disclaimerHeader}>
<Ionicons name="information-circle-outline" size={20} color="#6F7D87" />
<Text style={styles.disclaimerTitle}></Text>
</View>
<Text style={styles.disclaimerText}>
怀
</Text>
</View>
</ScrollView>
</View>
);
}
const styles = StyleSheet.create({
safeArea: {
flex: 1,
},
backButtonContainer: {
position: 'absolute',
top: 0,
left: 24,
zIndex: 10,
},
backButton: {
width: 44,
height: 44,
borderRadius: 22,
alignItems: 'center',
justifyContent: 'center',
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 4,
},
shadowOpacity: 0.15,
shadowRadius: 8,
elevation: 8,
},
backButtonGlass: {
width: 44,
height: 44,
borderRadius: 22,
alignItems: 'center',
justifyContent: 'center',
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.3)',
overflow: 'hidden',
},
backButtonFallback: {
width: 44,
height: 44,
borderRadius: 22,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(255,255,255,0.85)',
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.5)',
},
scrollContainer: {
paddingHorizontal: 24,
paddingBottom: 40,
},
headerSection: {
alignItems: 'center',
marginBottom: 32,
},
title: {
fontSize: 28,
fontWeight: '800',
color: '#2E3142',
marginBottom: 12,
textAlign: 'center',
},
subtitle: {
fontSize: 16,
color: '#6F7D87',
textAlign: 'center',
lineHeight: 24,
paddingHorizontal: 20,
},
referencesList: {
marginBottom: 32,
},
referenceCard: {
backgroundColor: '#FFFFFF',
borderRadius: 20,
padding: 20,
marginBottom: 16,
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 8,
},
shadowOpacity: 0.06,
shadowRadius: 16,
elevation: 4,
},
referenceHeader: {
flexDirection: 'row',
alignItems: 'flex-start',
marginBottom: 12,
},
referenceIcon: {
width: 48,
height: 48,
borderRadius: 24,
backgroundColor: 'rgba(46, 49, 66, 0.08)',
alignItems: 'center',
justifyContent: 'center',
marginRight: 16,
},
referenceInfo: {
flex: 1,
},
referenceName: {
fontSize: 16,
fontWeight: '700',
color: '#2E3142',
marginBottom: 4,
},
referenceEnglishName: {
fontSize: 14,
color: '#6F7D87',
lineHeight: 20,
},
referenceLink: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
backgroundColor: 'rgba(111, 125, 135, 0.08)',
paddingHorizontal: 16,
paddingVertical: 12,
borderRadius: 12,
marginBottom: 8,
},
referenceUrl: {
fontSize: 14,
color: '#2E3142',
flex: 1,
},
referenceNote: {
fontSize: 13,
color: '#8A96A3',
fontStyle: 'italic',
lineHeight: 18,
},
disclaimerSection: {
backgroundColor: 'rgba(255, 248, 225, 0.6)',
borderRadius: 20,
padding: 20,
borderWidth: 1,
borderColor: 'rgba(255, 193, 7, 0.2)',
},
disclaimerHeader: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 12,
},
disclaimerTitle: {
fontSize: 16,
fontWeight: '700',
color: '#2E3142',
marginLeft: 8,
},
disclaimerText: {
fontSize: 14,
color: '#5B6572',
lineHeight: 22,
},
});

View File

@@ -0,0 +1,877 @@
import { CircularRing } from '@/components/CircularRing';
import { ThemedView } from '@/components/ThemedView';
import { HeaderBar } from '@/components/ui/HeaderBar';
import { Colors } from '@/constants/Colors';
import { useColorScheme } from '@/hooks/useColorScheme';
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
import {
fetchActivityRingsForDate,
fetchHourlyActiveCaloriesForDate,
fetchHourlyExerciseMinutesForDate,
fetchHourlyStandHoursForDate,
type ActivityRingsData,
type HourlyActivityData,
type HourlyExerciseData,
type HourlyStandData
} from '@/utils/health';
import { getFitnessExerciseMinutesInfoDismissed, setFitnessExerciseMinutesInfoDismissed } from '@/utils/userPreferences';
import { Ionicons } from '@expo/vector-icons';
import DateTimePicker from '@react-native-community/datetimepicker';
import dayjs from 'dayjs';
import timezone from 'dayjs/plugin/timezone';
import utc from 'dayjs/plugin/utc';
import weekday from 'dayjs/plugin/weekday';
import { router } from 'expo-router';
import React, { useEffect, useRef, useState } from 'react';
import {
Animated,
Modal,
Platform,
Pressable,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View
} from 'react-native';
// 配置 dayjs 插件
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(weekday);
// 设置默认时区为中国时区
dayjs.tz.setDefault('Asia/Shanghai');
type WeekData = {
date: Date;
data: ActivityRingsData | null;
isToday: boolean;
dayName: string;
};
export default function FitnessRingsDetailScreen() {
const safeAreaTop = useSafeAreaTop()
const colorScheme = useColorScheme();
const [weekData, setWeekData] = useState<WeekData[]>([]);
const [selectedDate, setSelectedDate] = useState<Date>(new Date());
const [selectedDayData, setSelectedDayData] = useState<ActivityRingsData | null>(null);
const [datePickerVisible, setDatePickerVisible] = useState(false);
const [pickerDate, setPickerDate] = useState<Date>(new Date());
// 每小时数据状态
const [hourlyCaloriesData, setHourlyCaloriesData] = useState<HourlyActivityData[]>([]);
const [hourlyExerciseData, setHourlyExerciseData] = useState<HourlyExerciseData[]>([]);
const [hourlyStandData, setHourlyStandData] = useState<HourlyStandData[]>([]);
const [showExerciseInfo, setShowExerciseInfo] = useState(true);
const exerciseInfoAnim = useRef(new Animated.Value(1)).current;
useEffect(() => {
// 加载周数据和选中日期的详细数据
loadWeekData(selectedDate);
loadSelectedDayData();
loadExerciseInfoPreference();
}, [selectedDate]);
const loadExerciseInfoPreference = async () => {
try {
const dismissed = await getFitnessExerciseMinutesInfoDismissed();
setShowExerciseInfo(!dismissed);
if (!dismissed) {
exerciseInfoAnim.setValue(1);
} else {
exerciseInfoAnim.setValue(0);
}
} catch (error) {
console.error('加载锻炼分钟说明偏好失败:', error);
}
};
const loadWeekData = async (targetDate: Date) => {
const target = dayjs(targetDate).tz('Asia/Shanghai');
const today = dayjs().tz('Asia/Shanghai');
const weekDays = [];
// 获取目标日期所在周的数据 (周一到周日)
// 使用 weekday() 确保周一为一周的开始 (0=Monday, 6=Sunday)
const startOfWeek = target.weekday(0); // 周一开始
for (let i = 0; i < 7; i++) {
const currentDay = startOfWeek.add(i, 'day');
const isToday = currentDay.isSame(today, 'day');
const dayNames = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
try {
const activityRingsData = await fetchActivityRingsForDate(currentDay.toDate());
weekDays.push({
date: currentDay.toDate(),
data: activityRingsData,
isToday,
dayName: dayNames[i]
});
} catch (error) {
console.error('Failed to fetch activity rings data for', currentDay.format('YYYY-MM-DD'), error);
weekDays.push({
date: currentDay.toDate(),
data: null,
isToday,
dayName: dayNames[i]
});
}
}
setWeekData(weekDays);
};
const loadSelectedDayData = async () => {
try {
// 并行获取活动圆环数据和每小时详细数据
const [activityRingsData, hourlyCalories, hourlyExercise, hourlyStand] = await Promise.all([
fetchActivityRingsForDate(selectedDate),
fetchHourlyActiveCaloriesForDate(selectedDate),
fetchHourlyExerciseMinutesForDate(selectedDate),
fetchHourlyStandHoursForDate(selectedDate)
]);
setSelectedDayData(activityRingsData);
setHourlyCaloriesData(hourlyCalories);
setHourlyExerciseData(hourlyExercise);
setHourlyStandData(hourlyStand);
} catch (error) {
console.error('Failed to fetch selected day activity rings data', error);
setSelectedDayData(null);
setHourlyCaloriesData([]);
setHourlyExerciseData([]);
setHourlyStandData([]);
}
};
// 日期选择器相关函数
const openDatePicker = () => {
setPickerDate(selectedDate);
setDatePickerVisible(true);
};
const closeDatePicker = () => setDatePickerVisible(false);
const onConfirmDate = async (date: Date) => {
const today = dayjs().tz('Asia/Shanghai').startOf('day');
const picked = dayjs(date).tz('Asia/Shanghai').startOf('day');
const finalDate = picked.isAfter(today) ? today.toDate() : picked.toDate();
setSelectedDate(finalDate);
closeDatePicker();
};
// 格式化头部显示的日期
const formatHeaderDate = (date: Date) => {
const dayJsDate = dayjs(date).tz('Asia/Shanghai');
return `${dayJsDate.format('YYYY年MM月DD日')}`;
};
const renderWeekRingItem = (item: WeekData, index: number) => {
const isSelected = dayjs(item.date).tz('Asia/Shanghai').isSame(dayjs(selectedDate).tz('Asia/Shanghai'), 'day');
// 使用默认值确保即使没有数据也能显示圆环
const data = item.data || {
activeEnergyBurned: 0,
activeEnergyBurnedGoal: 350,
appleExerciseTime: 0,
appleExerciseTimeGoal: 30,
appleStandHours: 0,
appleStandHoursGoal: 12,
};
const { activeEnergyBurned, activeEnergyBurnedGoal, appleExerciseTime, appleExerciseTimeGoal, appleStandHours, appleStandHoursGoal } = data;
// 计算进度百分比
const caloriesProgress = Math.min(1, Math.max(0, activeEnergyBurned / activeEnergyBurnedGoal));
const exerciseProgress = Math.min(1, Math.max(0, appleExerciseTime / appleExerciseTimeGoal));
const standProgress = Math.min(1, Math.max(0, appleStandHours / appleStandHoursGoal));
// 检查是否完成了所有目标
const isComplete = caloriesProgress >= 1 && exerciseProgress >= 1 && standProgress >= 1;
return (
<TouchableOpacity
key={index}
style={[styles.weekRingItem, isSelected && styles.weekRingItemSelected]}
onPress={() => setSelectedDate(item.date)}
>
<View style={styles.weekRingContainer}>
{/* {isComplete && (
<View style={styles.weekStarContainer}>
<Text style={styles.weekStarIcon}>✓</Text>
</View>
)} */}
<View style={styles.weekRingsWrapper}>
{/* 外圈 - 活动卡路里 (红色) */}
<View style={styles.ringPosition}>
<CircularRing
size={50}
strokeWidth={3}
trackColor="rgba(255, 59, 48, 0.15)"
progressColor="#FF3B30"
progress={caloriesProgress}
showCenterText={false}
startAngleDeg={-90}
/>
</View>
{/* 中圈 - 锻炼分钟 (橙色) */}
<View style={styles.ringPosition}>
<CircularRing
size={36}
strokeWidth={2.5}
trackColor="rgba(255, 149, 0, 0.15)"
progressColor="#FF9500"
progress={exerciseProgress}
showCenterText={false}
startAngleDeg={-90}
/>
</View>
{/* 内圈 - 站立小时 (蓝色) */}
<View style={styles.ringPosition}>
<CircularRing
size={22}
strokeWidth={2}
trackColor="rgba(0, 122, 255, 0.15)"
progressColor="#007AFF"
progress={standProgress}
showCenterText={false}
startAngleDeg={-90}
/>
</View>
</View>
<Text style={[
styles.weekDayNumber,
item.isToday && styles.weekTodayNumber,
isSelected && styles.weekSelectedNumber,
{ color: isSelected ? '#007AFF' : (item.isToday ? '#007AFF' : Colors[colorScheme ?? 'light'].text) }
]}>
{dayjs(item.date).tz('Asia/Shanghai').date()}
</Text>
</View>
<Text style={[
styles.weekDayLabel,
item.isToday && styles.weekTodayLabel,
isSelected && styles.weekSelectedLabel,
{ color: isSelected ? '#007AFF' : (item.isToday ? '#007AFF' : Colors[colorScheme ?? 'light'].tabIconDefault) }
]}>
{item.dayName}
</Text>
</TouchableOpacity>
);
};
const getClosedRingCount = () => {
let count = 0;
weekData.forEach(item => {
// 使用默认值处理空数据情况
const data = item.data || {
activeEnergyBurned: 0,
activeEnergyBurnedGoal: 350,
appleExerciseTime: 0,
appleExerciseTimeGoal: 30,
appleStandHours: 0,
appleStandHoursGoal: 12,
};
const { activeEnergyBurned, activeEnergyBurnedGoal, appleExerciseTime, appleExerciseTimeGoal, appleStandHours, appleStandHoursGoal } = data;
const caloriesComplete = activeEnergyBurned >= activeEnergyBurnedGoal;
const exerciseComplete = appleExerciseTime >= appleExerciseTimeGoal;
const standComplete = appleStandHours >= appleStandHoursGoal;
if (caloriesComplete && exerciseComplete && standComplete) {
count++;
}
});
return count;
};
const handleKnowButtonPress = async () => {
try {
await setFitnessExerciseMinutesInfoDismissed(true);
Animated.timing(exerciseInfoAnim, {
toValue: 0,
duration: 300,
useNativeDriver: true,
}).start(() => {
setShowExerciseInfo(false);
});
} catch (error) {
console.error('保存锻炼分钟说明偏好失败:', error);
}
};
// 渲染简单的柱状图
const renderBarChart = (data: number[], maxValue: number, color: string, unit: string) => {
// 确保始终有24小时的数据没有数据时用0填充
const chartData = Array.from({ length: 24 }, (_, index) => {
if (data && data.length > index) {
return data[index] || 0;
}
return 0;
});
// 计算最大值如果所有数据都是0使用传入的maxValue作为参考
const maxChartValue = Math.max(...chartData, 1); // 确保最小值为1避免除零
const effectiveMaxValue = Math.max(maxChartValue, maxValue);
return (
<View style={styles.chartContainer}>
<View style={styles.chartBars}>
{chartData.map((value, index) => {
const height = Math.max(2, (value / effectiveMaxValue) * 40); // 最小高度2最大40
return (
<View
key={index}
style={[
styles.chartBar,
{
flex: 1,
height: value > 0 ? height : 2, // 没有数据时显示最小高度的灰色条
backgroundColor: value > 0 ? color : '#E5E5EA',
opacity: value > 0 ? 1 : 0.5,
marginHorizontal: 0.5
}
]}
/>
);
})}
</View>
<View style={styles.chartLabels}>
{chartData.map((_, index) => {
// 只在关键时间点显示标签0点、6点、12点、18点
if (index === 0 || index === 6 || index === 12 || index === 18) {
const hour = index;
return (
<Text key={index} style={styles.chartLabel}>
{hour.toString().padStart(2, '0')}:00
</Text>
);
}
// 对于不显示标签的小时返回一个占位的View
return <View key={index} style={styles.chartLabelSpacer} />;
})}
</View>
</View>
);
};
const renderSelectedDayDetail = () => {
// 使用默认值确保即使没有数据也能显示图表
const data = selectedDayData || {
activeEnergyBurned: 0,
activeEnergyBurnedGoal: 350,
appleExerciseTime: 0,
appleExerciseTimeGoal: 30,
appleStandHours: 0,
appleStandHoursGoal: 12,
};
const { activeEnergyBurned, activeEnergyBurnedGoal, appleExerciseTime, appleExerciseTimeGoal, appleStandHours, appleStandHoursGoal } = data;
return (
<View style={styles.detailContainer}>
{/* 活动热量卡片 */}
<View style={styles.metricCard}>
<View style={styles.cardHeader}>
<Text style={styles.cardTitle}></Text>
<TouchableOpacity style={styles.helpButton}>
<Text style={styles.helpIcon}>?</Text>
</TouchableOpacity>
</View>
<View style={styles.cardValue}>
<Text style={[styles.valueText, { color: '#FF3B30' }]}>
{Math.round(activeEnergyBurned)}/{activeEnergyBurnedGoal}
</Text>
<Text style={styles.unitText}></Text>
</View>
<Text style={styles.cardSubtext}>
{Math.round(activeEnergyBurned)}
</Text>
{renderBarChart(
hourlyCaloriesData.map(h => h.calories),
Math.max(activeEnergyBurnedGoal / 24, 1),
'#FF3B30',
'千卡'
)}
</View>
{/* 锻炼分钟卡片 */}
<View style={styles.metricCard}>
<View style={styles.cardHeader}>
<Text style={styles.cardTitle}></Text>
<TouchableOpacity style={styles.helpButton}>
<Text style={styles.helpIcon}>?</Text>
</TouchableOpacity>
</View>
<View style={styles.cardValue}>
<Text style={[styles.valueText, { color: '#FF9500' }]}>
{Math.round(appleExerciseTime)}/{appleExerciseTimeGoal}
</Text>
<Text style={styles.unitText}></Text>
</View>
<Text style={styles.cardSubtext}>
{Math.round(appleExerciseTime)}
</Text>
{renderBarChart(
hourlyExerciseData.map(h => h.minutes),
Math.max(appleExerciseTimeGoal / 8, 1),
'#FF9500',
'分钟'
)}
{/* 锻炼分钟说明 */}
{showExerciseInfo && (
<Animated.View
style={[
styles.exerciseInfo,
{
opacity: exerciseInfoAnim,
transform: [
{
scale: exerciseInfoAnim.interpolate({
inputRange: [0, 1],
outputRange: [0.95, 1],
}),
},
],
}
]}
>
<Text style={styles.exerciseTitle}>:</Text>
<Text style={styles.exerciseDesc}>
"快走"
</Text>
<Text style={styles.exerciseRecommendation}>
30
</Text>
<TouchableOpacity style={styles.knowButton} onPress={handleKnowButtonPress}>
<Text style={styles.knowButtonText}></Text>
</TouchableOpacity>
</Animated.View>
)}
</View>
{/* 活动小时数卡片 */}
<View style={styles.metricCard}>
<View style={styles.cardHeader}>
<Text style={styles.cardTitle}></Text>
<TouchableOpacity style={styles.helpButton}>
<Text style={styles.helpIcon}>?</Text>
</TouchableOpacity>
</View>
<View style={styles.cardValue}>
<Text style={[styles.valueText, { color: '#007AFF' }]}>
{Math.round(appleStandHours)}/{appleStandHoursGoal}
</Text>
<Text style={styles.unitText}></Text>
</View>
<Text style={styles.cardSubtext}>
{Math.round(appleStandHours)}
</Text>
{renderBarChart(
hourlyStandData.map(h => h.hasStood),
1,
'#007AFF',
'小时'
)}
</View>
</View>
);
};
return (
<ThemedView style={styles.container}>
{/* 头部 */}
<HeaderBar
title={formatHeaderDate(selectedDate)}
onBack={() => router.back()}
right={
<TouchableOpacity style={styles.calendarButton} onPress={openDatePicker}>
<Ionicons name="calendar-outline" size={20} color="#666666" />
</TouchableOpacity>
}
withSafeTop={true}
transparent={true}
variant="default"
/>
<ScrollView
style={styles.scrollView}
contentContainerStyle={[styles.scrollContent, {
paddingTop: safeAreaTop
}]}
showsVerticalScrollIndicator={false}
>
{/* 本周圆环横向滚动 */}
<View style={styles.weekSection}>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.weekScrollContent}
style={styles.weekScrollView}
>
{weekData.map((item, index) => renderWeekRingItem(item, index))}
</ScrollView>
</View>
{/* 选中日期的详细数据 */}
{renderSelectedDayDetail()}
{/* 周闭环天数统计 */}
<View style={styles.statsContainer}>
<View style={styles.statRow}>
<Text style={[styles.statLabel, { color: Colors[colorScheme ?? 'light'].text }]}></Text>
<View style={styles.statValue}>
<Text style={[styles.statNumber, { color: Colors[colorScheme ?? 'light'].text }]}>{getClosedRingCount()}</Text>
</View>
</View>
</View>
</ScrollView>
{/* 日期选择器弹窗 */}
<Modal
visible={datePickerVisible}
transparent
animationType="fade"
onRequestClose={closeDatePicker}
>
<Pressable style={styles.modalBackdrop} onPress={closeDatePicker} />
<View style={styles.modalSheet}>
<DateTimePicker
value={pickerDate}
mode="date"
display={Platform.OS === 'ios' ? 'inline' : 'calendar'}
minimumDate={new Date(2020, 0, 1)}
maximumDate={new Date()}
{...(Platform.OS === 'ios' ? { locale: 'zh-CN' } : {})}
onChange={(event, date) => {
if (Platform.OS === 'ios') {
if (date) setPickerDate(date);
} else {
if (event.type === 'set' && date) {
onConfirmDate(date);
} else {
closeDatePicker();
}
}
}}
/>
{Platform.OS === 'ios' && (
<View style={styles.modalActions}>
<Pressable onPress={closeDatePicker} style={[styles.modalBtn]}>
<Text style={styles.modalBtnText}></Text>
</Pressable>
<Pressable onPress={() => {
onConfirmDate(pickerDate);
}} style={[styles.modalBtn, styles.modalBtnPrimary]}>
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary]}></Text>
</Pressable>
</View>
)}
</View>
</Modal>
</ThemedView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
calendarButton: {
width: 32,
height: 32,
alignItems: 'center',
justifyContent: 'center',
},
scrollView: {
flex: 1,
},
scrollContent: {
paddingBottom: 32,
},
weekSection: {
paddingVertical: 20,
},
weekScrollView: {
paddingHorizontal: 16,
},
weekScrollContent: {
paddingHorizontal: 8,
},
weekRingItem: {
alignItems: 'center',
marginHorizontal: 8,
padding: 8,
borderRadius: 12,
},
weekRingItemSelected: {
backgroundColor: 'rgba(0, 122, 255, 0.1)',
},
weekRingContainer: {
position: 'relative',
alignItems: 'center',
justifyContent: 'center',
marginBottom: 8,
},
weekStarContainer: {
position: 'absolute',
top: -8,
right: -8,
zIndex: 10,
},
weekStarIcon: {
fontSize: 12,
},
weekRingsWrapper: {
position: 'relative',
width: 50,
height: 50,
alignItems: 'center',
justifyContent: 'center',
},
ringPosition: {
position: 'absolute',
alignItems: 'center',
justifyContent: 'center',
},
weekDayNumber: {
fontSize: 11,
fontWeight: '600',
marginTop: 6,
},
weekTodayNumber: {
color: '#007AFF',
},
weekSelectedNumber: {
fontWeight: '700',
},
weekDayLabel: {
fontSize: 10,
fontWeight: '500',
marginTop: 2,
},
weekTodayLabel: {
color: '#007AFF',
},
weekSelectedLabel: {
fontWeight: '600',
},
detailContainer: {
paddingHorizontal: 16,
paddingVertical: 20,
},
// 卡片样式
metricCard: {
backgroundColor: '#FFFFFF',
borderRadius: 16,
padding: 20,
marginBottom: 16,
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.06,
shadowRadius: 8,
elevation: 3,
},
cardHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 16,
},
cardTitle: {
fontSize: 18,
fontWeight: '600',
color: '#1C1C1E',
},
helpButton: {
width: 24,
height: 24,
borderRadius: 12,
backgroundColor: '#F2F2F7',
alignItems: 'center',
justifyContent: 'center',
},
helpIcon: {
fontSize: 14,
fontWeight: '600',
color: '#8E8E93',
},
cardValue: {
flexDirection: 'row',
alignItems: 'baseline',
marginBottom: 8,
},
valueText: {
fontSize: 32,
fontWeight: '700',
letterSpacing: -1,
},
unitText: {
fontSize: 18,
fontWeight: '500',
color: '#8E8E93',
marginLeft: 4,
},
cardSubtext: {
fontSize: 14,
color: '#8E8E93',
marginBottom: 20,
},
// 图表样式
chartContainer: {
marginTop: 16,
},
chartBars: {
flexDirection: 'row',
alignItems: 'flex-end',
height: 60,
marginBottom: 8,
paddingHorizontal: 2,
},
chartBar: {
borderRadius: 1.5,
},
chartLabels: {
flexDirection: 'row',
paddingHorizontal: 2,
justifyContent: 'space-between',
},
chartLabel: {
fontSize: 10,
color: '#8E8E93',
fontWeight: '500',
textAlign: 'center',
flex: 6, // 给显示标签的元素更多空间
},
chartLabelSpacer: {
flex: 1, // 占位元素使用较少空间
},
// 锻炼信息样式
exerciseInfo: {
marginTop: 20,
padding: 16,
backgroundColor: '#F2F2F7',
borderRadius: 12,
},
exerciseTitle: {
fontSize: 16,
fontWeight: '600',
color: '#1C1C1E',
marginBottom: 8,
},
exerciseDesc: {
fontSize: 14,
color: '#3C3C43',
lineHeight: 20,
marginBottom: 12,
},
exerciseRecommendation: {
fontSize: 14,
color: '#3C3C43',
lineHeight: 20,
marginBottom: 16,
},
knowButton: {
alignSelf: 'flex-end',
paddingHorizontal: 16,
paddingVertical: 8,
backgroundColor: '#007AFF',
borderRadius: 20,
},
knowButtonText: {
fontSize: 14,
fontWeight: '600',
color: '#FFFFFF',
},
noDataText: {
fontSize: 16,
textAlign: 'center',
marginTop: 40,
},
statsContainer: {
marginHorizontal: 16,
marginTop: 32,
padding: 16,
backgroundColor: 'rgba(0, 0, 0, 0.05)',
borderRadius: 12,
},
statRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
statLabel: {
fontSize: 16,
fontWeight: '500',
},
statValue: {
flexDirection: 'row',
alignItems: 'center',
},
statNumber: {
fontSize: 16,
fontWeight: '600',
marginLeft: 4,
},
starIcon: {
fontSize: 16,
},
// 日期选择器样式
modalBackdrop: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0,0,0,0.4)',
},
modalSheet: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
padding: 16,
backgroundColor: '#FFFFFF',
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
},
modalActions: {
flexDirection: 'row',
justifyContent: 'flex-end',
marginTop: 8,
gap: 12,
},
modalBtn: {
paddingHorizontal: 14,
paddingVertical: 10,
borderRadius: 10,
backgroundColor: '#F1F5F9',
},
modalBtnPrimary: {
backgroundColor: '#7a5af8',
},
modalBtnText: {
color: '#334155',
fontWeight: '700',
},
modalBtnTextPrimary: {
color: '#FFFFFF',
fontWeight: '700',
},
});

934
app/food-library.tsx Normal file
View File

@@ -0,0 +1,934 @@
import { CreateCustomFoodModal, type CustomFoodData } from '@/components/model/food/CreateCustomFoodModal';
import { FoodDetailModal } from '@/components/model/food/FoodDetailModal';
import { HeaderBar } from '@/components/ui/HeaderBar';
import { Colors } from '@/constants/Colors';
import { DEFAULT_IMAGE_FOOD } from '@/constants/Image';
import { useAppDispatch } from '@/hooks/redux';
import { useFoodLibrary, useFoodSearch } from '@/hooks/useFoodLibrary';
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
import { addDietRecord, type CreateDietRecordDto } from '@/services/dietRecords';
import { foodLibraryApi, type CreateCustomFoodDto } from '@/services/foodLibraryApi';
import { fetchDailyNutritionData } from '@/store/nutritionSlice';
import type { FoodItem, MealType, SelectedFoodItem } from '@/types/food';
import { saveNutritionToHealthKit } from '@/utils/health';
import { Ionicons } from '@expo/vector-icons';
import { Image } from 'expo-image';
import { useLocalSearchParams, useRouter } from 'expo-router';
import React, { useEffect, useMemo, useState } from 'react';
import {
ActivityIndicator,
Alert,
Modal,
ScrollView,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
View,
} from 'react-native';
// 餐次映射保持不变
// 餐次映射
const MEAL_TYPE_MAP = {
breakfast: '早餐',
lunch: '午餐',
dinner: '晚餐',
snack: '加餐'
};
export default function FoodLibraryScreen() {
const safeAreaTop = useSafeAreaTop()
const router = useRouter();
const params = useLocalSearchParams<{ mealType?: string }>();
const mealType = (params.mealType as MealType) || 'breakfast';
// Redux hooks
const dispatch = useAppDispatch();
const { categories, loading, error, clearErrors, loadFoodLibrary } = useFoodLibrary();
const { searchResults, searchLoading, search, clearResults } = useFoodSearch();
// 本地状态
const [selectedCategoryId, setSelectedCategoryId] = useState('common');
const [searchText, setSearchText] = useState('');
const [selectedFood, setSelectedFood] = useState<FoodItem | null>(null);
const [showFoodDetail, setShowFoodDetail] = useState(false);
const [selectedFoodItems, setSelectedFoodItems] = useState<SelectedFoodItem[]>([]);
const [showMealSelector, setShowMealSelector] = useState(false);
const [currentMealType, setCurrentMealType] = useState<MealType>(mealType);
const [isRecording, setIsRecording] = useState(false);
const [showCreateCustomFood, setShowCreateCustomFood] = useState(false);
// 获取当前选中的分类
const selectedCategory = categories.find(cat => cat.id === selectedCategoryId);
// 过滤食物列表 - 优先显示搜索结果
const filteredFoods = useMemo(() => {
if (searchText.trim() && searchResults.length > 0) {
return searchResults;
}
if (selectedCategory) {
return selectedCategory.foods
}
return [];
}, [searchText, searchResults, selectedCategory]);
// 处理搜索
useEffect(() => {
const timeoutId = setTimeout(() => {
if (searchText.trim()) {
search(searchText);
} else {
clearResults();
}
}, 300); // 防抖
return () => clearTimeout(timeoutId);
}, [searchText, search, clearResults]);
// 处理食物选择 - 显示详情弹窗
const handleSelectFood = (food: FoodItem) => {
console.log('选择食物:', food);
setSelectedFood(food);
setShowFoodDetail(true);
console.log('设置弹窗状态:', {
showFoodDetail: true,
selectedFood: food,
foodName: food.name,
foodId: food.id
});
};
// 处理食物保存
const handleSaveFood = (food: FoodItem, amount: number, unit: string) => {
// 计算实际热量
const actualCalories = Math.round((food.calories * amount) / 100);
// 创建新的选择项目
const newSelectedItem: SelectedFoodItem = {
id: `${food.id}_${Date.now()}`, // 使用时间戳确保唯一性
food,
amount,
unit,
calories: actualCalories
};
// 添加到已选择列表
setSelectedFoodItems(prev => [...prev, newSelectedItem]);
console.log('保存食物:', food, amount, unit, '热量:', actualCalories);
setShowFoodDetail(false);
};
// 移除已选择的食物
const handleRemoveSelectedFood = (itemId: string) => {
setSelectedFoodItems(prev => prev.filter(item => item.id !== itemId));
};
// 计算总热量
const totalCalories = selectedFoodItems.reduce((sum, item) => sum + item.calories, 0);
// 关闭详情弹窗
const handleCloseFoodDetail = () => {
setShowFoodDetail(false);
setSelectedFood(null);
};
// 处理删除自定义食物
const handleDeleteFood = async (foodId: string) => {
try {
await foodLibraryApi.deleteCustomFood(Number(foodId));
// 删除成功后重新加载食物库数据
await loadFoodLibrary();
// 关闭弹窗
handleCloseFoodDetail();
} catch (error) {
console.error('删除食物失败:', error);
Alert.alert('删除失败', '删除食物时发生错误,请稍后重试');
}
};
// 处理饮食记录
const handleRecordDiet = async () => {
if (selectedFoodItems.length === 0) return;
setIsRecording(true);
try {
// 逐个记录选中的食物
for (const item of selectedFoodItems) {
const dietRecordData: CreateDietRecordDto = {
mealType: currentMealType,
foodName: item.food.name,
foodDescription: item.food.description,
portionDescription: `${item.amount}${item.unit}`,
estimatedCalories: item.calories,
proteinGrams: item.food.protein ? Number(((item.food.protein * item.amount) / 100).toFixed(2)) : undefined,
carbohydrateGrams: item.food.carbohydrate ? Number(((item.food.carbohydrate * item.amount) / 100).toFixed(2)) : undefined,
fatGrams: item.food.fat ? Number(((item.food.fat * item.amount) / 100).toFixed(2)) : undefined,
fiberGrams: item.food.fiber ? Number(((item.food.fiber * item.amount) / 100).toFixed(2)) : undefined,
sugarGrams: item.food.sugar ? Number(((item.food.sugar * item.amount) / 100).toFixed(2)) : undefined,
sodiumMg: item.food.sodium ? Number(((item.food.sodium * item.amount) / 100).toFixed(2)) : undefined,
additionalNutrition: item.food.additionalNutrition,
source: 'manual',
mealTime: new Date().toISOString(),
imageUrl: item.food.imageUrl,
};
// 先保存到后端
await addDietRecord(dietRecordData);
// 然后尝试同步到 HealthKit非阻塞
// 提取蛋白质、脂肪和碳水化合物数据
const { proteinGrams, fatGrams, carbohydrateGrams, mealTime } = dietRecordData;
if (proteinGrams !== undefined || fatGrams !== undefined || carbohydrateGrams !== undefined) {
// 使用 catch 确保 HealthKit 同步失败不影响后端记录
saveNutritionToHealthKit(
{
proteinGrams: proteinGrams || undefined,
fatGrams: fatGrams || undefined,
carbohydrateGrams: carbohydrateGrams || undefined
},
mealTime
).catch(error => {
// HealthKit 同步失败只记录日志,不影响用户体验
console.error('HealthKit 营养数据同步失败(不影响记录):', error);
});
}
}
// 记录成功后,刷新当天的营养数据
const today = new Date();
await dispatch(fetchDailyNutritionData(today));
// 清空选择列表并返回
setSelectedFoodItems([]);
router.back();
} catch (error) {
console.error('记录饮食失败:', error);
// 这里可以显示错误提示
} finally {
setIsRecording(false);
}
};
// 处理餐次选择
const handleMealTypeSelect = (selectedMealType: MealType) => {
setCurrentMealType(selectedMealType);
setShowMealSelector(false);
};
// 处理创建自定义食物
const handleCreateCustomFood = () => {
setShowCreateCustomFood(true);
};
// 处理保存自定义食物
const handleSaveCustomFood = async (customFoodData: CustomFoodData) => {
try {
// 转换数据格式以匹配API要求
const createData: CreateCustomFoodDto = {
name: customFoodData.name,
caloriesPer100g: customFoodData.calories,
proteinPer100g: customFoodData.protein,
carbohydratePer100g: customFoodData.carbohydrate,
fatPer100g: customFoodData.fat,
imageUrl: customFoodData.imageUrl,
};
// 调用API创建自定义食物
const createdFood = await foodLibraryApi.createCustomFood(createData);
// 需要拉取一遍最新的食物列表
await loadFoodLibrary();
// 创建FoodItem对象
const customFoodItem: FoodItem = {
id: createdFood.id.toString(),
name: createdFood.name,
calories: createdFood.caloriesPer100g || 0,
unit: 'g',
description: createdFood.description || `自定义食物 - ${createdFood.name}`,
imageUrl: createdFood.imageUrl,
protein: createdFood.proteinPer100g,
fat: createdFood.fatPer100g,
carbohydrate: createdFood.carbohydratePer100g,
};
// 添加到选择列表中
const newSelectedItem: SelectedFoodItem = {
id: createdFood.id.toString(),
food: customFoodItem,
amount: customFoodData.defaultAmount,
unit: 'g',
calories: Math.round((customFoodItem.calories * customFoodData.defaultAmount) / 100)
};
setSelectedFoodItems(prev => [...prev, newSelectedItem]);
} catch (error) {
console.error('创建自定义食物失败:', error);
Alert.alert('创建失败', '创建自定义食物时发生错误,请稍后重试');
}
};
// 关闭自定义食物弹窗
const handleCloseCreateCustomFood = () => {
setShowCreateCustomFood(false);
};
// 餐次选择选项
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' },
];
return (
<View style={styles.container}>
{/* 头部 */}
<HeaderBar
title="食物库"
onBack={() => router.back()}
variant="elevated"
right={
<TouchableOpacity style={styles.customButton} onPress={handleCreateCustomFood}>
<Text style={styles.customButtonText}></Text>
</TouchableOpacity>
}
/>
<View style={{
paddingTop: safeAreaTop
}} />
{/* 搜索框 */}
<View style={styles.searchContainer}>
<Ionicons name="search" size={20} color="#999" style={styles.searchIcon} />
<TextInput
style={styles.searchInput}
placeholder="搜索食物"
value={searchText}
onChangeText={setSearchText}
placeholderTextColor="#999"
/>
</View>
{/* 主要内容区域 - 卡片样式 */}
<View style={styles.mainContentCard}>
{loading && categories.length === 0 ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color="#4CAF50" />
<Text style={styles.loadingText}>...</Text>
</View>
) : error ? (
<View style={styles.errorContainer}>
<Text style={styles.errorText}>{error}</Text>
<TouchableOpacity
style={styles.retryButton}
onPress={() => {
clearErrors();
// 这里可以重新加载数据
}}
>
<Text style={styles.retryButtonText}></Text>
</TouchableOpacity>
</View>
) : (
<View style={styles.mainContent}>
{/* 左侧分类导航 */}
<View style={styles.categoryContainer}>
<ScrollView showsVerticalScrollIndicator={false}>
{categories.map((category) => (
<TouchableOpacity
key={category.id}
style={[
styles.categoryItem,
selectedCategoryId === category.id && styles.categoryItemActive
]}
onPress={() => {
setSelectedCategoryId(category.id);
// 切换分类时清除搜索
if (searchText) {
setSearchText('');
clearResults();
}
}}
>
<Text style={[
styles.categoryText,
selectedCategoryId === category.id && styles.categoryTextActive
]}>
{category.name}
</Text>
</TouchableOpacity>
))}
</ScrollView>
</View>
{/* 右侧食物列表 */}
<View style={styles.foodContainer}>
{searchLoading ? (
<View style={styles.searchLoadingContainer}>
<ActivityIndicator size="small" color="#4CAF50" />
<Text style={styles.searchLoadingText}>...</Text>
</View>
) : (
<ScrollView showsVerticalScrollIndicator={false}>
{filteredFoods.map((food) => (
<View key={food.id} style={styles.foodItem}>
<View style={styles.foodInfo}>
<Image
style={styles.foodImage}
source={{ uri: food.imageUrl || DEFAULT_IMAGE_FOOD }}
cachePolicy={'memory-disk'}
/>
<View style={styles.foodDetails}>
<Text style={styles.foodName}>{food.name}</Text>
<Text style={styles.foodCalories}>
{food.calories}/{food.unit}
</Text>
</View>
</View>
<TouchableOpacity
style={styles.addButton}
onPress={() => handleSelectFood(food)}
>
<Ionicons name="add" size={20} color="#666" />
</TouchableOpacity>
</View>
))}
{filteredFoods.length === 0 && !searchLoading && (
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>
{searchText ? '未找到相关食物' : '暂无食物数据'}
</Text>
{searchText && (
<Text style={styles.emptySubText}>
使
</Text>
)}
</View>
)}
</ScrollView>
)}
</View>
</View>
)}
</View>
{/* 已选择食物列表 */}
{selectedFoodItems.length > 0 && (
<View style={styles.selectedFoodsContainer}>
<View style={styles.selectedFoodsHeader}>
<Text style={styles.selectedFoodsTitle}> ({selectedFoodItems.length})</Text>
<Text style={styles.totalCalories}>: {totalCalories}</Text>
</View>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.selectedFoodsList}
contentContainerStyle={styles.selectedFoodsContent}
>
{selectedFoodItems.map((item) => (
<View key={item.id} style={styles.selectedFoodItem}>
<TouchableOpacity
style={styles.removeButton}
onPress={() => handleRemoveSelectedFood(item.id)}
>
<Ionicons name="close-circle" size={16} color="#FF4444" />
</TouchableOpacity>
{item.food.imageUrl ? <Image
style={styles.selectedFoodImage}
source={{ uri: item.food.imageUrl }}
/> : <Text style={styles.selectedFoodEmoji}>{item.food.emoji}</Text>}
<Text style={styles.selectedFoodName} numberOfLines={1}>{item.food.name}</Text>
<Text style={styles.selectedFoodAmount}>{item.amount}{item.unit}</Text>
<Text style={styles.selectedFoodCalories}>{item.calories}</Text>
</View>
))}
</ScrollView>
</View>
)}
{/* 底部餐次选择和记录按钮 */}
<View style={styles.bottomContainer}>
<TouchableOpacity
style={styles.mealSelector}
onPress={() => setShowMealSelector(true)}
>
<View style={[
styles.mealIndicator,
{ backgroundColor: mealOptions.find(option => option.key === currentMealType)?.color || '#FF6B35' }
]} />
<Text style={styles.mealText}>{MEAL_TYPE_MAP[currentMealType]}</Text>
<Ionicons name="chevron-down" size={16} color="#333" />
</TouchableOpacity>
<TouchableOpacity
style={[
styles.recordButton,
(selectedFoodItems.length === 0 || isRecording) && styles.recordButtonDisabled
]}
disabled={selectedFoodItems.length === 0 || isRecording}
onPress={handleRecordDiet}
>
{isRecording ? (
<ActivityIndicator size="small" color="#FFF" />
) : (
<Text style={[
styles.recordButtonText,
selectedFoodItems.length === 0 && styles.recordButtonTextDisabled
]}></Text>
)}
</TouchableOpacity>
</View>
{/* 餐次选择弹窗 */}
<Modal
visible={showMealSelector}
transparent={true}
animationType="fade"
onRequestClose={() => setShowMealSelector(false)}
>
<View style={styles.mealSelectorOverlay}>
<TouchableOpacity
style={styles.mealSelectorBackdrop}
onPress={() => setShowMealSelector(false)}
/>
<View style={styles.mealSelectorModal}>
<View style={styles.mealSelectorHeader}>
<Text style={styles.mealSelectorTitle}></Text>
<TouchableOpacity onPress={() => setShowMealSelector(false)}>
<Ionicons name="close" size={24} color="#666" />
</TouchableOpacity>
</View>
<View style={styles.mealOptionsContainer}>
{mealOptions.map((option) => (
<TouchableOpacity
key={option.key}
style={[
styles.mealOption,
currentMealType === option.key && styles.mealOptionActive
]}
onPress={() => handleMealTypeSelect(option.key)}
>
<View style={[styles.mealOptionIndicator, { backgroundColor: option.color }]} />
<Text style={[
styles.mealOptionText,
currentMealType === option.key && styles.mealOptionTextActive
]}>
{option.label}
</Text>
{currentMealType === option.key && (
<Ionicons name="checkmark" size={20} color="#4CAF50" />
)}
</TouchableOpacity>
))}
</View>
</View>
</View>
</Modal>
{/* 食物详情弹窗 */}
<FoodDetailModal
visible={showFoodDetail}
food={selectedFood}
category={selectedCategory}
onClose={handleCloseFoodDetail}
onSave={handleSaveFood}
onDelete={handleDeleteFood}
/>
{/* 创建自定义食物弹窗 */}
<CreateCustomFoodModal
visible={showCreateCustomFood}
onClose={handleCloseCreateCustomFood}
onSave={handleSaveCustomFood}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: Colors.light.pageBackgroundEmphasis,
},
customButton: {
paddingHorizontal: 8,
paddingVertical: 4,
},
customButtonText: {
fontSize: 16,
color: Colors.light.textSecondary,
fontWeight: '500',
},
searchContainer: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#FFF',
marginHorizontal: 16,
marginVertical: 12,
paddingHorizontal: 12,
paddingVertical: 10,
borderRadius: 12,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.1,
shadowRadius: 2,
elevation: 2,
},
searchIcon: {
marginRight: 8,
},
searchInput: {
flex: 1,
fontSize: 16,
color: '#333',
},
mainContentCard: {
flex: 1,
marginHorizontal: 16,
marginBottom: 16,
backgroundColor: '#FFFFFF',
borderRadius: 16,
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 4,
overflow: 'hidden',
},
mainContent: {
flex: 1,
flexDirection: 'row',
},
categoryContainer: {
width: 100,
backgroundColor: 'transparent',
},
categoryItem: {
paddingVertical: 16,
paddingHorizontal: 12,
alignItems: 'center',
},
categoryItemActive: {
backgroundColor: '#F0F9FF'
},
categoryText: {
fontSize: 14,
color: '#666',
textAlign: 'center',
},
categoryTextActive: {
color: Colors.light.text,
fontWeight: '500',
},
foodContainer: {
flex: 1,
backgroundColor: 'transparent',
},
foodItem: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 16,
paddingVertical: 12,
},
foodInfo: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
},
foodImage: {
width: 32,
height: 32,
},
foodEmoji: {
fontSize: 32,
marginRight: 12,
},
foodDetails: {
flex: 1,
marginLeft: 12
},
foodName: {
fontSize: 16,
color: '#333',
fontWeight: '500',
marginBottom: 2,
},
foodCalories: {
fontSize: 14,
color: '#999',
},
addButton: {
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: '#F5F5F5',
alignItems: 'center',
justifyContent: 'center',
},
emptyContainer: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 40,
},
emptyText: {
fontSize: 16,
color: '#999',
},
emptySubText: {
fontSize: 14,
color: '#CCC',
marginTop: 8,
textAlign: 'center',
},
// 加载状态样式
loadingContainer: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 60,
},
loadingText: {
fontSize: 16,
color: '#666',
marginTop: 12,
},
// 错误状态样式
errorContainer: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 60,
paddingHorizontal: 20,
},
errorText: {
fontSize: 16,
color: '#FF4444',
textAlign: 'center',
marginBottom: 16,
},
retryButton: {
backgroundColor: '#4CAF50',
paddingHorizontal: 20,
paddingVertical: 10,
borderRadius: 20,
},
retryButtonText: {
fontSize: 14,
color: '#FFF',
fontWeight: '500',
},
// 搜索加载状态样式
searchLoadingContainer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 20,
},
searchLoadingText: {
fontSize: 14,
color: '#666',
marginLeft: 8,
},
bottomContainer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 16,
paddingVertical: 12,
backgroundColor: '#FFF',
borderTopWidth: 1,
borderTopColor: '#E5E5E5',
},
mealSelector: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 12,
paddingVertical: 8,
backgroundColor: '#F8F9FA',
borderRadius: 20,
},
mealIndicator: {
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: '#FF6B35',
marginRight: 8,
},
mealText: {
fontSize: 14,
color: '#333',
marginRight: 4,
},
recordButton: {
backgroundColor: Colors.light.primary,
paddingHorizontal: 24,
paddingVertical: 10,
borderRadius: 20,
},
recordButtonText: {
fontSize: 16,
color: '#FFF',
fontWeight: '500',
},
recordButtonDisabled: {
backgroundColor: '#CCC',
},
recordButtonTextDisabled: {
color: '#999',
},
// 已选择食物列表样式
selectedFoodsContainer: {
backgroundColor: '#FFF',
borderTopWidth: 1,
borderTopColor: '#E5E5E5',
paddingVertical: 12,
maxHeight: 140, // 限制最大高度
},
selectedFoodsHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 16,
marginBottom: 8,
},
selectedFoodsTitle: {
fontSize: 14,
fontWeight: '600',
color: '#333',
},
totalCalories: {
fontSize: 14,
color: Colors.light.text,
fontWeight: '500',
},
selectedFoodsList: {
paddingHorizontal: 16,
},
selectedFoodsContent: {
paddingRight: 16,
},
selectedFoodItem: {
backgroundColor: '#F8F9FA',
borderRadius: 12,
padding: 8,
marginRight: 8,
minWidth: 80,
alignItems: 'center',
position: 'relative',
},
removeButton: {
position: 'absolute',
top: 4,
right: 4,
backgroundColor: '#FFF',
borderRadius: 8,
zIndex: 1,
},
selectedFoodImage: {
width: 20,
height: 20,
marginBottom: 4,
},
selectedFoodEmoji: {
fontSize: 20,
marginBottom: 4,
},
selectedFoodName: {
fontSize: 12,
color: '#333',
fontWeight: '500',
textAlign: 'center',
marginBottom: 2,
maxWidth: 64,
},
selectedFoodAmount: {
fontSize: 11,
color: '#666',
marginBottom: 2,
},
selectedFoodCalories: {
fontSize: 11,
color: '#4CAF50',
fontWeight: '500',
},
// 餐次选择弹窗样式
mealSelectorOverlay: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
justifyContent: 'flex-end',
},
mealSelectorBackdrop: {
flex: 1,
},
mealSelectorModal: {
backgroundColor: '#FFF',
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
paddingBottom: 34, // 为底部安全区域留出空间
},
mealSelectorHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 20,
paddingVertical: 16,
borderBottomWidth: 1,
borderBottomColor: '#E5E5E5',
},
mealSelectorTitle: {
fontSize: 18,
fontWeight: '600',
color: '#333',
},
mealOptionsContainer: {
paddingHorizontal: 20,
paddingVertical: 16,
},
mealOption: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 16,
paddingHorizontal: 16,
borderRadius: 12,
marginBottom: 8,
backgroundColor: '#F8F9FA',
},
mealOptionActive: {
backgroundColor: '#E8F5E8',
borderWidth: 1,
borderColor: '#4CAF50',
},
mealOptionIndicator: {
width: 12,
height: 12,
borderRadius: 6,
marginRight: 12,
},
mealOptionText: {
flex: 1,
fontSize: 16,
color: '#333',
fontWeight: '500',
},
mealOptionTextActive: {
color: '#4CAF50',
},
});

1269
app/food/analysis-result.tsx Normal file

File diff suppressed because it is too large Load Diff

627
app/food/camera.tsx Normal file
View File

@@ -0,0 +1,627 @@
import { HeaderBar } from '@/components/ui/HeaderBar';
import { Colors } from '@/constants/Colors';
import { useAuthGuard } from '@/hooks/useAuthGuard';
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
import { Ionicons } from '@expo/vector-icons';
import { CameraType, CameraView, useCameraPermissions } from 'expo-camera';
import { Image } from 'expo-image';
import * as ImagePicker from 'expo-image-picker';
import { useLocalSearchParams, useRouter } from 'expo-router';
import React, { useRef, useState } from 'react';
import {
Alert,
Modal,
StatusBar,
StyleSheet,
Text,
TouchableOpacity,
View
} from 'react-native';
type MealType = 'breakfast' | 'lunch' | 'dinner' | 'snack';
export default function FoodCameraScreen() {
const safeAreaTop = useSafeAreaTop()
const router = useRouter();
const params = useLocalSearchParams<{ mealType?: string }>();
const cameraRef = useRef<CameraView>(null);
const { ensureLoggedIn } = useAuthGuard();
const [currentMealType, setCurrentMealType] = useState<MealType>(
(params.mealType as MealType) || 'dinner'
);
const [facing, setFacing] = useState<CameraType>('back');
const [permission, requestPermission] = useCameraPermissions();
const [showInstructionModal, setShowInstructionModal] = useState(false);
// 餐次选择选项
const mealOptions = [
{ key: 'breakfast' as const, label: '早餐', icon: '☀️' },
{ key: 'lunch' as const, label: '午餐', icon: '🌤️' },
{ key: 'dinner' as const, label: '晚餐', icon: '🌙' },
{ key: 'snack' as const, label: '加餐', icon: '🍎' },
];
if (!permission) {
// 权限仍在加载中
return (
<View style={styles.container}>
<HeaderBar
title="食物拍摄"
onBack={() => router.back()}
transparent={true}
/>
<View style={{
paddingTop: safeAreaTop
}} />
<View style={styles.loadingContainer}>
<Text style={styles.loadingText}>...</Text>
</View>
</View>
);
}
if (!permission.granted) {
// 没有相机权限
return (
<View style={styles.container}>
<HeaderBar
title="食物拍摄"
onBack={() => router.back()}
backColor='#ffffff'
/>
<View style={{
paddingTop: safeAreaTop
}} />
<View style={styles.permissionContainer}>
<Ionicons name="camera-outline" size={64} color="#999" />
<Text style={styles.permissionTitle}></Text>
<Text style={styles.permissionText}>
访
</Text>
<TouchableOpacity
style={styles.permissionButton}
onPress={requestPermission}
>
<Text style={styles.permissionButtonText}>访</Text>
</TouchableOpacity>
</View>
</View>
);
}
// 切换相机前后摄像头
function toggleCameraFacing() {
setFacing(current => (current === 'back' ? 'front' : 'back'));
}
// 拍摄照片
const takePicture = async () => {
if (cameraRef.current) {
try {
const photo = await cameraRef.current.takePictureAsync({
quality: 0.8,
base64: false,
});
if (photo) {
// 先验证登录状态,再跳转到食物识别页面
console.log('照片拍摄成功:', photo.uri);
const isLoggedIn = await ensureLoggedIn();
if (isLoggedIn) {
router.replace(`/food/food-recognition?imageUri=${encodeURIComponent(photo.uri)}&mealType=${currentMealType}`);
}
}
} catch (error) {
console.error('拍照失败:', error);
Alert.alert('拍照失败', '请重试');
}
}
};
// 从相册选择照片
const pickImageFromGallery = async () => {
try {
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ['images'],
allowsEditing: true,
aspect: [4, 3],
quality: 0.8,
});
if (!result.canceled && result.assets[0]) {
const imageUri = result.assets[0].uri;
console.log('从相册选择的照片:', imageUri);
// 先验证登录状态,再跳转到食物识别页面
const isLoggedIn = await ensureLoggedIn();
if (isLoggedIn) {
router.push(`/food/food-recognition?imageUri=${encodeURIComponent(imageUri)}&mealType=${currentMealType}`);
}
}
} catch (error) {
console.error('选择照片失败:', error);
Alert.alert('选择失败', '请重试');
}
};
// AR功能暂时显示提示
const handleARPress = () => {
Alert.alert('AR功能', 'AR食物识别功能即将推出');
};
// 餐次选择
const handleMealTypeChange = (mealType: MealType) => {
setCurrentMealType(mealType);
};
return (
<View style={styles.container}>
<StatusBar barStyle="light-content" backgroundColor="transparent" translucent />
{/* 头部导航 */}
<HeaderBar
title=""
onBack={() => router.back()}
transparent={true}
backColor={'#fff'}
/>
<View style={{
paddingTop: safeAreaTop
}} />
{/* 主要内容区域 */}
<View style={styles.contentContainer}>
{/* 取景框容器 */}
<View style={styles.cameraFrameContainer}>
<Text style={styles.hintText}></Text>
{/* 相机取景框包装器 */}
<View style={styles.cameraWrapper}>
{/* 相机取景框 */}
<View style={styles.cameraFrame}>
<CameraView
ref={cameraRef}
style={styles.cameraView}
facing={facing}
/>
</View>
{/* 取景框装饰 - 放在外层避免被截断 */}
<View style={styles.viewfinderOverlay}>
<View style={[styles.corner, styles.topLeft]} />
<View style={[styles.corner, styles.topRight]} />
<View style={[styles.corner, styles.bottomLeft]} />
<View style={[styles.corner, styles.bottomRight]} />
</View>
</View>
</View>
{/* 餐次选择器 */}
<View style={styles.mealTypeContainer}>
{mealOptions.map((option) => (
<TouchableOpacity
key={option.key}
style={[
styles.mealTypeButton,
currentMealType === option.key && styles.mealTypeButtonActive
]}
onPress={() => handleMealTypeChange(option.key)}
>
<Text style={styles.mealTypeIcon}>{option.icon}</Text>
<Text style={[
styles.mealTypeText,
currentMealType === option.key && styles.mealTypeTextActive
]}>
{option.label}
</Text>
</TouchableOpacity>
))}
</View>
{/* 底部控制栏 */}
<View style={styles.bottomContainer}>
<View style={styles.controlsContainer}>
{/* 相册选择按钮 */}
<TouchableOpacity style={styles.galleryButton} onPress={pickImageFromGallery}>
<Ionicons name="images-outline" size={24} color="#FFF" />
</TouchableOpacity>
{/* 拍照按钮 */}
<TouchableOpacity style={styles.captureButton} onPress={takePicture}>
<View style={styles.captureButtonInner} />
</TouchableOpacity>
{/* 帮助按钮 */}
<TouchableOpacity style={styles.helpButton} onPress={() => setShowInstructionModal(true)}>
<Ionicons name="help-outline" size={24} color="#FFF" />
</TouchableOpacity>
</View>
</View>
</View>
{/* 拍摄说明弹窗 */}
<Modal
visible={showInstructionModal}
animationType="fade"
transparent={true}
onRequestClose={() => setShowInstructionModal(false)}
>
<View style={styles.modalOverlay}>
<View style={styles.instructionModal}>
<Text style={styles.instructionTitle}></Text>
<View style={styles.exampleContainer}>
{/* 好的示例 */}
<View style={styles.exampleItem}>
<View style={styles.exampleImagePlaceholder}>
<View style={styles.checkmarkContainer}>
<Ionicons name="checkmark" size={32} color="#FFF" />
</View>
{/* 这里可以放置好的示例图片 */}
<Image
style={styles.exampleImage}
source={{ uri: 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/function/food-right.jpeg' }}
cachePolicy={'memory-disk'}
/>
</View>
</View>
{/* 不好的示例 */}
<View style={styles.exampleItem}>
<View style={styles.exampleImagePlaceholder}>
<View style={styles.crossContainer}>
<Ionicons name="close" size={32} color="#FFF" />
</View>
<Image
style={styles.exampleImage}
source={{ uri: 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/function/food-wrong.jpeg' }}
cachePolicy={'memory-disk'}
/>
</View>
</View>
</View>
<Text style={styles.instructionDescription}>
</Text>
<TouchableOpacity
style={styles.knowButton}
onPress={() => setShowInstructionModal(false)}
>
<Text style={styles.knowButtonText}></Text>
</TouchableOpacity>
</View>
</View>
</Modal>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#000',
},
contentContainer: {
flex: 1,
paddingTop: 100,
},
cameraFrameContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: 20,
},
cameraWrapper: {
width: 300,
height: 300,
position: 'relative',
},
cameraFrame: {
width: 300,
height: 300,
borderRadius: 20,
overflow: 'hidden',
backgroundColor: '#000',
},
cameraView: {
flex: 1,
},
viewfinderOverlay: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
zIndex: 1,
pointerEvents: 'none',
},
camera: {
flex: 1,
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#000',
},
loadingText: {
color: '#FFF',
fontSize: 16,
},
permissionContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#000',
paddingHorizontal: 40,
},
permissionTitle: {
color: '#FFF',
fontSize: 20,
fontWeight: '600',
marginTop: 20,
marginBottom: 10,
},
permissionText: {
color: '#CCC',
fontSize: 16,
textAlign: 'center',
marginBottom: 30,
lineHeight: 22,
},
permissionButton: {
backgroundColor: Colors.light.primary,
paddingHorizontal: 24,
paddingVertical: 12,
borderRadius: 24,
},
permissionButtonText: {
color: '#FFF',
fontSize: 16,
fontWeight: '600',
},
header: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
zIndex: 10,
},
hintText: {
color: '#FFF',
fontSize: 16,
fontWeight: '500',
marginBottom: 20,
textAlign: 'center',
},
corner: {
position: 'absolute',
width: 30,
height: 30,
borderColor: '#FFF',
borderWidth: 3,
},
topLeft: {
top: 0,
left: 0,
borderRightWidth: 0,
borderBottomWidth: 0,
},
topRight: {
top: 0,
right: 0,
borderLeftWidth: 0,
borderBottomWidth: 0,
},
bottomLeft: {
bottom: 0,
left: 0,
borderRightWidth: 0,
borderTopWidth: 0,
},
bottomRight: {
bottom: 0,
right: 0,
borderLeftWidth: 0,
borderTopWidth: 0,
},
mealTypeContainer: {
flexDirection: 'row',
justifyContent: 'center',
paddingHorizontal: 20,
marginVertical: 20,
},
mealTypeButton: {
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 8,
marginHorizontal: 8,
borderRadius: 20,
backgroundColor: 'rgba(255, 255, 255, 0.2)',
minWidth: 70,
},
mealTypeButtonActive: {
backgroundColor: 'rgba(255, 255, 255, 0.9)',
},
mealTypeIcon: {
fontSize: 20,
marginBottom: 2,
},
mealTypeText: {
color: '#FFF',
fontSize: 12,
fontWeight: '500',
},
mealTypeTextActive: {
color: '#333',
},
bottomContainer: {
paddingBottom: 40,
},
controlsContainer: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingVertical: 20,
paddingHorizontal: 40,
},
controlButton: {
alignItems: 'center',
},
controlButtonText: {
color: '#FFF',
fontSize: 12,
marginTop: 8,
fontWeight: '500',
},
albumButton: {
width: 50,
height: 50,
borderRadius: 10,
backgroundColor: 'rgba(255, 255, 255, 0.2)',
justifyContent: 'center',
alignItems: 'center',
borderWidth: 2,
borderColor: '#FFF',
},
captureButton: {
width: 80,
height: 80,
borderRadius: 40,
backgroundColor: '#FFF',
justifyContent: 'center',
alignItems: 'center',
borderWidth: 4,
borderColor: 'rgba(255, 255, 255, 0.3)',
},
captureButtonInner: {
width: 60,
height: 60,
borderRadius: 30,
backgroundColor: '#FFF',
borderWidth: 2,
borderColor: '#333',
},
arButton: {
width: 50,
height: 50,
borderRadius: 25,
backgroundColor: 'rgba(255, 255, 255, 0.2)',
justifyContent: 'center',
alignItems: 'center',
borderWidth: 2,
borderColor: '#FFF',
},
arButtonText: {
color: '#FFF',
fontSize: 14,
fontWeight: 'bold',
},
galleryButton: {
width: 50,
height: 50,
borderRadius: 25,
backgroundColor: 'rgba(255, 255, 255, 0.2)',
justifyContent: 'center',
alignItems: 'center',
borderWidth: 2,
borderColor: '#FFF',
},
helpButton: {
width: 50,
height: 50,
borderRadius: 25,
backgroundColor: 'rgba(255, 255, 255, 0.2)',
justifyContent: 'center',
alignItems: 'center',
borderWidth: 2,
borderColor: '#FFF',
},
modalOverlay: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.7)',
justifyContent: 'flex-end',
},
instructionModal: {
backgroundColor: '#FFF',
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
paddingHorizontal: 24,
paddingVertical: 32,
minHeight: 400,
},
instructionTitle: {
fontSize: 24,
fontWeight: 'bold',
textAlign: 'center',
marginBottom: 32,
color: '#333',
},
exampleContainer: {
flexDirection: 'row',
justifyContent: 'space-between',
marginBottom: 24,
paddingHorizontal: 16,
},
exampleItem: {
flex: 1,
marginHorizontal: 8,
},
exampleImagePlaceholder: {
width: '100%',
aspectRatio: 3 / 4,
backgroundColor: '#F0F0F0',
borderRadius: 16,
position: 'relative',
justifyContent: 'center',
alignItems: 'center',
},
checkmarkContainer: {
position: 'absolute',
top: 12,
right: 12,
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: '#4CAF50',
justifyContent: 'center',
alignItems: 'center',
zIndex: 10,
},
crossContainer: {
position: 'absolute',
top: 12,
right: 12,
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: '#F44336',
justifyContent: 'center',
alignItems: 'center',
zIndex: 10,
},
exampleImage: {
width: '100%',
height: '100%',
},
instructionDescription: {
fontSize: 16,
textAlign: 'center',
color: '#666',
marginBottom: 32,
lineHeight: 24,
paddingHorizontal: 16,
},
knowButton: {
backgroundColor: '#000',
borderRadius: 25,
paddingVertical: 16,
marginHorizontal: 16,
},
knowButtonText: {
color: '#FFF',
fontSize: 16,
fontWeight: '600',
textAlign: 'center',
},
});

View File

@@ -0,0 +1,784 @@
import { HeaderBar } from '@/components/ui/HeaderBar';
import { Colors } from '@/constants/Colors';
import { useMembershipModal } from '@/contexts/MembershipModalContext';
import { useAppDispatch } from '@/hooks/redux';
import { useAuthGuard } from '@/hooks/useAuthGuard';
import { useCosUpload } from '@/hooks/useCosUpload';
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
import { useVipService } from '@/hooks/useVipService';
import { recognizeFood } from '@/services/foodRecognition';
import { saveRecognitionResult, setError, setLoading } from '@/store/foodRecognitionSlice';
import { Ionicons } from '@expo/vector-icons';
import { useLocalSearchParams, useRouter } from 'expo-router';
import React, { useEffect, useRef, useState } from 'react';
import {
ActivityIndicator,
Alert,
Animated,
Easing,
Image,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
export default function FoodRecognitionScreen() {
const safeAreaTop = useSafeAreaTop()
const router = useRouter();
const params = useLocalSearchParams<{
imageUri?: string;
mealType?: string;
}>();
const { imageUri, mealType } = params;
const { upload } = useCosUpload();
const [showRecognitionProcess, setShowRecognitionProcess] = useState(false);
const [recognitionLogs, setRecognitionLogs] = useState<string[]>([]);
const [currentStep, setCurrentStep] = useState<'idle' | 'uploading' | 'recognizing' | 'completed' | 'failed'>('idle');
const dispatch = useAppDispatch();
// 添加认证和VIP服务相关hooks
const { ensureLoggedIn } = useAuthGuard();
const { handleServiceAccess } = useVipService();
const { openMembershipModal } = useMembershipModal();
// 动画引用
const scaleAnim = useRef(new Animated.Value(1)).current;
const fadeAnim = useRef(new Animated.Value(0)).current;
const slideAnim = useRef(new Animated.Value(50)).current;
const progressAnim = useRef(new Animated.Value(0)).current;
const pulseAnim = useRef(new Animated.Value(1)).current;
// 启动动画效果
useEffect(() => {
if (showRecognitionProcess) {
// 进入动画
Animated.parallel([
Animated.timing(fadeAnim, {
toValue: 1,
duration: 600,
easing: Easing.out(Easing.cubic),
useNativeDriver: true,
}),
Animated.timing(slideAnim, {
toValue: 0,
duration: 600,
easing: Easing.out(Easing.cubic),
useNativeDriver: true,
})
]).start();
// 启动进度条动画
if (currentStep === 'uploading' || currentStep === 'recognizing') {
Animated.timing(progressAnim, {
toValue: currentStep === 'uploading' ? 0.5 : 1,
duration: 2000,
easing: Easing.inOut(Easing.ease),
useNativeDriver: false,
}).start();
}
// 脉冲动画
const pulseAnimation = Animated.loop(
Animated.sequence([
Animated.timing(pulseAnim, {
toValue: 1.1,
duration: 800,
easing: Easing.inOut(Easing.sin),
useNativeDriver: true,
}),
Animated.timing(pulseAnim, {
toValue: 1,
duration: 800,
easing: Easing.inOut(Easing.sin),
useNativeDriver: true,
})
])
);
if (currentStep === 'uploading' || currentStep === 'recognizing') {
pulseAnimation.start();
} else {
pulseAnimation.stop();
pulseAnim.setValue(1);
}
} else {
fadeAnim.setValue(0);
slideAnim.setValue(50);
progressAnim.setValue(0);
}
}, [showRecognitionProcess, currentStep]);
const addLog = (message: string) => {
setRecognitionLogs(prev => [...prev, message]);
};
const handleConfirm = async () => {
if (!imageUri) return;
// 按钮动画效果
Animated.sequence([
Animated.timing(scaleAnim, {
toValue: 0.95,
duration: 100,
useNativeDriver: true,
}),
Animated.timing(scaleAnim, {
toValue: 1,
duration: 100,
useNativeDriver: true,
})
]).start();
// 先验证登录状态
const isLoggedIn = await ensureLoggedIn();
if (!isLoggedIn) {
return;
}
// 检查用户是否可以使用 AI 食物识别功能
const canAccess = handleServiceAccess(
() => {
// 允许使用,继续执行识别流程
},
() => {
// 不允许使用,显示会员付费弹窗
openMembershipModal();
}
);
// 如果用户没有权限,直接返回
if (!canAccess) {
return;
}
try {
setShowRecognitionProcess(true);
setRecognitionLogs([]);
setCurrentStep('uploading');
dispatch(setLoading(true));
addLog('📤 正在上传图片到云端...');
// 上传图片到 COS
const { url } = await upload(
{ uri: imageUri, name: 'food-image.jpg', type: 'image/jpeg' },
{ prefix: 'food-images/' }
);
addLog('✅ 图片上传完成');
addLog('🤖 AI大模型分析中...');
setCurrentStep('recognizing');
// 调用食物识别 API
const recognitionResult = await recognizeFood({
imageUrls: [url]
});
console.log('食物识别结果:', recognitionResult);
if (!recognitionResult.isFoodDetected) {
addLog('❌ 识别失败:未检测到食物');
addLog(`💭 ${recognitionResult.nonFoodMessage || recognitionResult.analysisText}`);
setCurrentStep('failed');
return;
}
addLog('✅ AI分析完成');
addLog(`🎯 识别置信度: ${recognitionResult.confidence}%`);
addLog(`🍽️ 识别到 ${recognitionResult.items.length} 种食物`);
setCurrentStep('completed');
// 生成唯一的识别ID
const recognitionId = `recognition_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
// 保存识别结果到 Redux
dispatch(saveRecognitionResult({
id: recognitionId,
result: recognitionResult
}));
// 延迟跳转,让用户看到完成状态
setTimeout(() => {
router.replace(`/food/analysis-result?imageUri=${encodeURIComponent(url)}&mealType=${mealType}&recognitionId=${recognitionId}`);
}, 1500);
} catch (error) {
console.warn('食物识别失败', error);
addLog('❌ 识别过程出错');
addLog(`💥 ${error instanceof Error ? error.message : '未知错误'}`);
setCurrentStep('failed');
dispatch(setError('食物识别失败,请重试'));
} finally {
dispatch(setLoading(false));
}
};
const handleRetry = () => {
setShowRecognitionProcess(false);
setCurrentStep('idle');
setRecognitionLogs([]);
dispatch(setError(null));
router.back()
};
const handleGoBack = () => {
if (showRecognitionProcess && currentStep !== 'failed') {
Alert.alert(
'正在识别中',
'识别过程尚未完成,确定要返回吗?',
[
{ text: '继续识别', style: 'cancel' },
{ text: '返回', style: 'destructive', onPress: () => router.back() }
]
);
} else {
router.back();
}
};
if (!imageUri) {
return (
<SafeAreaView style={styles.container}>
<HeaderBar
title="食物识别"
onBack={() => router.back()}
/>
<View style={{
paddingTop: safeAreaTop
}} />
<View style={styles.errorContainer}>
<Text style={styles.errorText}></Text>
</View>
</SafeAreaView>
);
}
return (
<View style={styles.container}>
<HeaderBar
title={showRecognitionProcess ? "食物识别" : "确认食物"}
onBack={handleGoBack}
/>
{/* 主要内容区域 */}
<ScrollView style={styles.contentContainer} contentContainerStyle={{
paddingTop: safeAreaTop
}} showsVerticalScrollIndicator={false}>
{!showRecognitionProcess ? (
// 确认界面
<>
{/* 照片卡片 */}
<View style={styles.photoCard}>
<View style={styles.photoFrame}>
<Image
source={{ uri: imageUri }}
style={styles.photoImage}
resizeMode="cover"
/>
{/* 餐次标签叠加 */}
{mealType && (
<View style={styles.mealTypeBadge}>
<Text style={styles.mealTypeBadgeText}>
{getMealTypeLabel(mealType)}
</Text>
</View>
)}
</View>
</View>
{/* AI 识别说明卡片 */}
<View style={styles.infoCard}>
<View style={styles.infoHeader}>
<View style={styles.aiIconContainer}>
<Ionicons name="sparkles" size={20} color={Colors.light.primary} />
</View>
<Text style={styles.infoTitle}></Text>
</View>
<Text style={styles.infoDescription}>
AI
</Text>
</View>
{/* 底部按钮区域 */}
<View style={styles.bottomContainer}>
<Animated.View style={[styles.confirmButtonContainer, { transform: [{ scale: scaleAnim }] }]}>
<TouchableOpacity
style={styles.confirmButton}
onPress={handleConfirm}
activeOpacity={0.8}
>
<View style={styles.confirmButtonContent}>
<Ionicons name="scan" size={20} color={Colors.light.onPrimary} style={{ marginRight: 8 }} />
<Text style={styles.confirmButtonText}></Text>
</View>
</TouchableOpacity>
</Animated.View>
</View>
</>
) : (
// 识别过程界面
<Animated.View style={[styles.recognitionContainer, {
opacity: fadeAnim,
transform: [{ translateY: slideAnim }]
}]}>
{/* 照片缩略图卡片 */}
<View style={styles.thumbnailCard}>
<Image
source={{ uri: imageUri }}
style={styles.thumbnailImage}
resizeMode="cover"
/>
<View style={styles.thumbnailInfo}>
{mealType && (
<View style={styles.thumbnailMealType}>
<Text style={styles.thumbnailMealTypeText}>
{getMealTypeLabel(mealType)}
</Text>
</View>
)}
<Text style={styles.thumbnailTitle}>AI ...</Text>
</View>
</View>
{/* 进度指示卡片 */}
<View style={styles.progressCard}>
<View style={styles.progressHeader}>
<Animated.View style={[styles.statusIconAnimated, { transform: [{ scale: pulseAnim }] }]}>
<View style={[styles.statusIcon, {
backgroundColor: currentStep === 'uploading' || currentStep === 'recognizing' ? Colors.light.primary :
currentStep === 'completed' ? Colors.light.success :
currentStep === 'failed' ? Colors.light.danger : Colors.light.neutral200
}]}>
{currentStep === 'uploading' || currentStep === 'recognizing' ? (
<ActivityIndicator size="small" color={Colors.light.onPrimary} />
) : currentStep === 'completed' ? (
<Ionicons name="checkmark" size={20} color={Colors.light.onPrimary} />
) : currentStep === 'failed' ? (
<Ionicons name="close" size={20} color={Colors.light.onPrimary} />
) : null}
</View>
</Animated.View>
<View style={styles.progressInfo}>
<Text style={styles.statusText}>{
currentStep === 'idle' ? '准备中' :
currentStep === 'uploading' ? '上传图片中' :
currentStep === 'recognizing' ? 'AI 分析中' :
currentStep === 'completed' ? '识别完成' :
currentStep === 'failed' ? '识别失败' : ''
}</Text>
<Text style={styles.statusSubtext}>{
currentStep === 'uploading' ? '正在将图片上传到云端处理...' :
currentStep === 'recognizing' ? '智能模型正在分析食物成分...' :
currentStep === 'completed' ? '即将跳转到分析结果页面' :
currentStep === 'failed' ? '请检查网络连接或重新拍照' : ''
}</Text>
</View>
</View>
{/* 进度条 */}
{(currentStep === 'uploading' || currentStep === 'recognizing') && (
<View style={styles.progressBarContainer}>
<View style={styles.progressBarBackground}>
<Animated.View
style={[styles.progressBarFill, {
width: progressAnim.interpolate({
inputRange: [0, 1],
outputRange: ['0%', '100%'],
})
}]}
/>
</View>
</View>
)}
</View>
{/* 识别日志卡片 */}
<View style={styles.logCard}>
<View style={styles.logHeader}>
<Ionicons name="document-text-outline" size={18} color={Colors.light.primary} />
<Text style={styles.logTitle}></Text>
</View>
<ScrollView
style={styles.logScrollView}
contentContainerStyle={styles.logContent}
showsVerticalScrollIndicator={false}
>
{recognitionLogs.map((log, index) => (
<Animated.View
key={index}
style={[styles.logItem, {
opacity: fadeAnim,
transform: [{ translateX: slideAnim }]
}]}
>
<Text style={styles.logText}>{log}</Text>
</Animated.View>
))}
{recognitionLogs.length === 0 && (
<Text style={styles.logPlaceholder}>...</Text>
)}
</ScrollView>
</View>
{/* 重试按钮 */}
{currentStep === 'failed' && (
<View style={styles.bottomContainer}>
<TouchableOpacity
style={styles.retryButton}
onPress={handleRetry}
activeOpacity={0.8}
>
<Ionicons name="refresh" size={18} color={Colors.light.onPrimary} style={{ marginRight: 8 }} />
<Text style={styles.retryButtonText}></Text>
</TouchableOpacity>
</View>
)}
</Animated.View>
)}
</ScrollView>
</View>
);
}
// 获取餐次标签
function getMealTypeLabel(mealType: string): string {
const mealTypeMap: Record<string, string> = {
breakfast: '早餐',
lunch: '午餐',
dinner: '晚餐',
snack: '加餐',
};
return mealTypeMap[mealType] || '未知餐次';
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: Colors.light.pageBackgroundEmphasis,
},
contentContainer: {
flex: 1,
paddingHorizontal: 16,
paddingTop: 16,
},
// 照片卡片样式
photoCard: {
backgroundColor: Colors.light.card,
borderRadius: 24,
padding: 20,
marginBottom: 16,
shadowColor: '#000',
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.08,
shadowRadius: 20,
elevation: 8,
},
photoFrame: {
width: '100%',
aspectRatio: 1,
borderRadius: 20,
overflow: 'hidden',
backgroundColor: Colors.light.neutral100,
position: 'relative',
},
photoImage: {
width: '100%',
height: '100%',
},
mealTypeBadge: {
position: 'absolute',
top: 16,
right: 16,
paddingHorizontal: 14,
paddingVertical: 8,
backgroundColor: 'rgba(122, 90, 248, 0.95)',
borderRadius: 20,
backdropFilter: 'blur(10px)',
},
mealTypeBadgeText: {
color: Colors.light.onPrimary,
fontSize: 13,
fontWeight: '700',
letterSpacing: 0.3,
},
// 信息卡片样式
infoCard: {
backgroundColor: Colors.light.card,
borderRadius: 20,
padding: 20,
marginBottom: 24,
shadowColor: Colors.light.primary,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.06,
shadowRadius: 16,
elevation: 4,
borderWidth: 1,
borderColor: Colors.light.heroSurfaceTint,
},
infoHeader: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 12,
},
aiIconContainer: {
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: Colors.light.heroSurfaceTint,
alignItems: 'center',
justifyContent: 'center',
marginRight: 12,
},
infoTitle: {
fontSize: 18,
fontWeight: '700',
color: Colors.light.text,
letterSpacing: 0.2,
},
infoDescription: {
fontSize: 14,
color: Colors.light.textSecondary,
lineHeight: 22,
letterSpacing: 0.1,
},
// 按钮样式
bottomContainer: {
paddingBottom: 40,
paddingTop: 8,
},
confirmButtonContainer: {},
confirmButton: {
backgroundColor: Colors.light.primary,
paddingVertical: 18,
paddingHorizontal: 32,
borderRadius: 28,
alignItems: 'center',
shadowColor: Colors.light.primary,
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.35,
shadowRadius: 20,
elevation: 8,
},
confirmButtonContent: {
flexDirection: 'row',
alignItems: 'center',
},
confirmButtonText: {
color: Colors.light.onPrimary,
fontSize: 16,
fontWeight: '700',
letterSpacing: 0.4,
},
// 识别过程容器
recognitionContainer: {
flex: 1,
},
// 缩略图卡片
thumbnailCard: {
backgroundColor: Colors.light.card,
borderRadius: 20,
padding: 16,
marginBottom: 16,
flexDirection: 'row',
alignItems: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.06,
shadowRadius: 12,
elevation: 4,
},
thumbnailImage: {
width: 70,
height: 70,
borderRadius: 16,
},
thumbnailInfo: {
flex: 1,
marginLeft: 16,
},
thumbnailMealType: {
alignSelf: 'flex-start',
paddingHorizontal: 12,
paddingVertical: 6,
backgroundColor: Colors.light.primary,
borderRadius: 16,
marginBottom: 6,
},
thumbnailMealTypeText: {
color: Colors.light.onPrimary,
fontSize: 11,
fontWeight: '700',
letterSpacing: 0.2,
},
thumbnailTitle: {
fontSize: 16,
fontWeight: '600',
color: Colors.light.text,
letterSpacing: 0.1,
},
// 进度卡片
progressCard: {
backgroundColor: Colors.light.card,
borderRadius: 20,
padding: 20,
marginBottom: 16,
shadowColor: '#000',
shadowOffset: { width: 0, height: 6 },
shadowOpacity: 0.08,
shadowRadius: 16,
elevation: 6,
},
progressHeader: {
flexDirection: 'row',
alignItems: 'flex-start',
},
statusIconAnimated: {},
statusIcon: {
width: 48,
height: 48,
borderRadius: 24,
alignItems: 'center',
justifyContent: 'center',
marginRight: 16,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 3,
},
progressInfo: {
flex: 1,
justifyContent: 'center',
},
statusText: {
fontSize: 18,
fontWeight: '700',
color: Colors.light.text,
marginBottom: 4,
letterSpacing: 0.2,
},
statusSubtext: {
fontSize: 14,
color: Colors.light.textSecondary,
lineHeight: 20,
letterSpacing: 0.1,
},
// 进度条
progressBarContainer: {
marginTop: 20,
},
progressBarBackground: {
width: '100%',
height: 8,
backgroundColor: Colors.light.neutral100,
borderRadius: 4,
overflow: 'hidden',
},
progressBarFill: {
height: '100%',
backgroundColor: Colors.light.primary,
borderRadius: 4,
},
// 日志卡片
logCard: {
backgroundColor: Colors.light.card,
borderRadius: 20,
padding: 20,
marginBottom: 16,
flex: 1,
minHeight: 200,
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.06,
shadowRadius: 12,
elevation: 4,
},
logHeader: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 16,
},
logTitle: {
fontSize: 16,
fontWeight: '700',
color: Colors.light.text,
marginLeft: 8,
letterSpacing: 0.2,
},
logScrollView: {
flex: 1,
backgroundColor: Colors.light.heroSurfaceTint,
borderRadius: 16,
paddingHorizontal: 16,
paddingVertical: 12,
},
logContent: {
flexGrow: 1,
},
logItem: {
paddingVertical: 6,
paddingHorizontal: 4,
},
logText: {
fontSize: 14,
color: Colors.light.text,
lineHeight: 22,
letterSpacing: 0.1,
},
logPlaceholder: {
fontSize: 14,
color: Colors.light.textMuted,
fontStyle: 'italic',
textAlign: 'center',
marginTop: 40,
},
// 重试按钮
retryButton: {
backgroundColor: Colors.light.primary,
paddingVertical: 18,
paddingHorizontal: 32,
borderRadius: 28,
alignItems: 'center',
flexDirection: 'row',
justifyContent: 'center',
shadowColor: Colors.light.primary,
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.35,
shadowRadius: 20,
elevation: 8,
},
retryButtonText: {
color: Colors.light.onPrimary,
fontSize: 16,
fontWeight: '700',
letterSpacing: 0.4,
},
// 通用样式
errorContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
errorText: {
fontSize: 16,
color: Colors.light.textMuted,
textAlign: 'center',
},
});

View File

@@ -0,0 +1,902 @@
import { HeaderBar } from '@/components/ui/HeaderBar';
import { Colors } from '@/constants/Colors';
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
import {
deleteNutritionAnalysisRecord,
getNutritionAnalysisRecords,
type GetNutritionRecordsParams,
type NutritionAnalysisRecord,
type NutritionItem
} from '@/services/nutritionLabelAnalysis';
import { triggerLightHaptic } from '@/utils/haptics';
import { Ionicons } from '@expo/vector-icons';
import dayjs from 'dayjs';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient';
import { useRouter } from 'expo-router';
import React, { useCallback, useEffect, useState } from 'react';
import {
ActivityIndicator,
Alert,
BackHandler,
FlatList,
RefreshControl,
StyleSheet,
Text,
TouchableOpacity,
View
} from 'react-native';
import ImageViewing from 'react-native-image-viewing';
export default function NutritionAnalysisHistoryScreen() {
const safeAreaTop = useSafeAreaTop();
const router = useRouter();
const [records, setRecords] = useState<NutritionAnalysisRecord[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const [expandedItems, setExpandedItems] = useState<Set<number>>(new Set());
const [currentPage, setCurrentPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [total, setTotal] = useState(0);
const [statusFilter, setStatusFilter] = useState<string>('');
const [error, setError] = useState<string | null>(null);
const [showImagePreview, setShowImagePreview] = useState(false);
const [previewImageUri, setPreviewImageUri] = useState<string | null>(null);
const [deletingId, setDeletingId] = useState<number | null>(null);
const isGlassAvailable = isLiquidGlassAvailable();
// 处理Android返回键关闭图片预览
useEffect(() => {
const backHandler = BackHandler.addEventListener('hardwareBackPress', () => {
if (showImagePreview) {
setShowImagePreview(false);
return true; // 阻止默认返回行为
}
return false;
});
return () => backHandler.remove();
}, [showImagePreview]);
// 获取历史记录
const fetchRecords = useCallback(async (page: number = 1, isRefresh: boolean = false, currentStatusFilter?: string) => {
try {
// 清除之前的错误
setError(null);
const params: GetNutritionRecordsParams = {
page,
limit: 20,
};
// 使用传入的筛选条件或当前状态
const filterToUse = currentStatusFilter !== undefined ? currentStatusFilter : statusFilter;
if (filterToUse) {
params.status = filterToUse;
}
const response = await getNutritionAnalysisRecords(params);
console.log('response', JSON.stringify(response));
if (response.code === 0) {
const newRecords = response.data.records;
if (isRefresh || page === 1) {
setRecords(newRecords);
} else {
setRecords(prev => [...prev, ...newRecords]);
}
setTotal(response.data.total);
setHasMore(page < response.data.totalPages);
setCurrentPage(page);
} else {
const errorMessage = response.message || '获取历史记录失败';
setError(errorMessage);
Alert.alert('错误', errorMessage);
}
} catch (error) {
console.error('[HISTORY] 获取历史记录失败:', error);
const errorMessage = '获取历史记录失败,请稍后重试';
setError(errorMessage);
Alert.alert('错误', errorMessage);
} finally {
setLoading(false);
setRefreshing(false);
setLoadingMore(false);
}
}, [statusFilter]);
// 初始加载 - 只在组件挂载时执行一次
useEffect(() => {
setLoading(true);
fetchRecords(1, true);
}, []); // 移除 fetchRecords 依赖,避免循环
// 筛选条件变化时的处理
useEffect(() => {
// 只有在非初始加载时才执行
if (!loading) {
setLoading(true);
setCurrentPage(1);
fetchRecords(1, true, statusFilter);
}
}, [statusFilter]); // 只依赖 statusFilter
// 下拉刷新
const handleRefresh = useCallback(() => {
setRefreshing(true);
fetchRecords(1, true);
}, []); // 移除 fetchRecords 依赖
// 加载更多
const handleLoadMore = useCallback(() => {
if (!hasMore || loadingMore || loading || error) return; // 添加错误状态检查
setLoadingMore(true);
fetchRecords(currentPage + 1, false);
}, [hasMore, loadingMore, loading, currentPage, error]); // 移除 fetchRecords 依赖,添加 error 依赖
// 切换展开状态
const toggleExpanded = useCallback((id: number) => {
triggerLightHaptic();
setExpandedItems(prev => {
const newSet = new Set(prev);
if (newSet.has(id)) {
newSet.delete(id);
} else {
newSet.add(id);
}
return newSet;
});
}, []);
// 获取状态颜色
const getStatusColor = (status: string) => {
switch (status) {
case 'success':
return '#4CAF50';
case 'failed':
return '#F44336';
case 'processing':
return '#FF9800';
default:
return '#9E9E9E';
}
};
// 获取状态文本
const getStatusText = (status: string) => {
switch (status) {
case 'success':
return '成功';
case 'failed':
return '失败';
case 'processing':
return '处理中';
default:
return '未知';
}
};
// 从营养数据中提取主要营养素的辅助函数
const getMainNutrients = (data: NutritionItem[]) => {
const energy = data.find(item => item.key === 'energy_kcal');
const protein = data.find(item => item.key === 'protein');
const carbs = data.find(item => item.key === 'carbohydrate');
const fat = data.find(item => item.key === 'fat');
return {
energy: energy?.value || '',
protein: protein?.value || '',
carbs: carbs?.value || '',
fat: fat?.value || ''
};
};
// 处理图片预览
const handleImagePreview = useCallback((imageUrl: string) => {
triggerLightHaptic();
setPreviewImageUri(imageUrl);
setShowImagePreview(true);
}, []);
// 处理删除记录
const handleDeleteRecord = useCallback((recordId: number) => {
Alert.alert(
'确认删除',
'确定要删除这条营养分析记录吗?此操作无法撤销。',
[
{
text: '取消',
style: 'cancel',
},
{
text: '删除',
style: 'destructive',
onPress: async () => {
try {
setDeletingId(recordId);
await deleteNutritionAnalysisRecord(recordId);
// 从本地状态中移除删除的记录
setRecords(prev => prev.filter(record => record.id !== recordId));
setTotal(prev => Math.max(0, prev - 1));
// 触发轻微震动反馈
triggerLightHaptic();
// 显示成功提示
Alert.alert('成功', '记录已删除');
} catch (error) {
console.error('[HISTORY] 删除记录失败:', error);
Alert.alert('错误', '删除失败,请稍后重试');
} finally {
setDeletingId(null);
}
},
},
]
);
}, []);
// 渲染历史记录项
const renderRecordItem = useCallback(({ item }: { item: NutritionAnalysisRecord }) => {
const isExpanded = expandedItems.has(item.id);
const isSuccess = item.status === 'success';
return (
<View style={styles.recordItem}>
{/* 头部信息 */}
<View style={styles.recordHeader}>
<View style={styles.recordInfo}>
{isSuccess && (
<Text style={styles.recordTitle}>
{item.nutritionCount}
</Text>
)}
<Text style={styles.recordDate}>
{dayjs(item.createdAt).format('YYYY年M月D日 HH:mm')}
</Text>
<View style={[styles.statusBadge, { backgroundColor: getStatusColor(item.status) }]}>
<Text style={styles.statusText}>{getStatusText(item.status)}</Text>
</View>
</View>
{/* 删除按钮 */}
<TouchableOpacity
onPress={() => handleDeleteRecord(item.id)}
disabled={deletingId === item.id}
activeOpacity={0.7}
>
{isGlassAvailable ? (
<GlassView
style={styles.glassDeleteButton}
glassEffectStyle="clear"
tintColor="rgba(244, 67, 54, 0.2)"
isInteractive={true}
>
{deletingId === item.id ? (
<ActivityIndicator size="small" color="#F44336" />
) : (
<Ionicons name="trash-outline" size={20} color="#F44336" />
)}
</GlassView>
) : (
<View style={[styles.glassDeleteButton, styles.fallbackDeleteButton]}>
{deletingId === item.id ? (
<ActivityIndicator size="small" color="#F44336" />
) : (
<Ionicons name="trash-outline" size={20} color="#F44336" />
)}
</View>
)}
</TouchableOpacity>
</View>
{/* 图片预览 */}
{item.imageUrl && (
<TouchableOpacity
style={styles.imageContainer}
onPress={() => handleImagePreview(item.imageUrl)}
activeOpacity={0.9}
>
<Image
source={{ uri: item.imageUrl }}
style={styles.thumbnail}
contentFit="cover"
/>
{/* 预览提示图标 */}
<View style={styles.previewHint}>
<Ionicons name="expand-outline" size={16} color="#FFF" />
</View>
</TouchableOpacity>
)}
{/* 分析结果摘要 */}
{isSuccess && item.analysisResult && item.analysisResult.data && item.analysisResult.data.length > 0 && (
<View style={styles.summaryContainer}>
<View style={styles.nutritionSummary}>
{(() => {
const mainNutrients = getMainNutrients(item.analysisResult.data);
return (
<>
{mainNutrients.energy && (
<View style={styles.nutritionItem}>
<Text style={styles.nutritionLabel}></Text>
<Text style={styles.nutritionValue}>{mainNutrients.energy}</Text>
</View>
)}
{mainNutrients.protein && (
<View style={styles.nutritionItem}>
<Text style={styles.nutritionLabel}></Text>
<Text style={styles.nutritionValue}>{mainNutrients.protein}</Text>
</View>
)}
{mainNutrients.carbs && (
<View style={styles.nutritionItem}>
<Text style={styles.nutritionLabel}></Text>
<Text style={styles.nutritionValue}>{mainNutrients.carbs}</Text>
</View>
)}
{mainNutrients.fat && (
<View style={styles.nutritionItem}>
<Text style={styles.nutritionLabel}></Text>
<Text style={styles.nutritionValue}>{mainNutrients.fat}</Text>
</View>
)}
</>
);
})()}
</View>
</View>
)}
{/* 失败信息 */}
{!isSuccess && (
<View style={styles.errorContainer}>
<Ionicons name="alert-circle-outline" size={20} color="#F44336" />
<Text style={styles.errorMessage}>{item.message}</Text>
</View>
)}
{/* 展开/收起按钮 */}
<TouchableOpacity
style={styles.expandButton}
onPress={() => toggleExpanded(item.id)}
activeOpacity={0.7}
>
<Text style={styles.expandButtonText}>
{isExpanded ? '收起详情' : '展开详情'}
</Text>
<Ionicons
name={isExpanded ? 'chevron-up-outline' : 'chevron-down-outline'}
size={16}
color={Colors.light.primary}
/>
</TouchableOpacity>
{/* 详细信息 */}
{isExpanded && isSuccess && item.analysisResult && item.analysisResult.data && (
<View style={styles.detailsContainer}>
<Text style={styles.detailsTitle}></Text>
{item.analysisResult.data.map((nutritionItem: NutritionItem) => (
<View key={nutritionItem.key} style={styles.detailItem}>
<View style={styles.nutritionInfo}>
<Text style={styles.detailLabel}>{nutritionItem.name}</Text>
<Text style={styles.detailValue}>{nutritionItem.value}</Text>
</View>
{nutritionItem.analysis && (
<Text style={styles.analysisText}>{nutritionItem.analysis}</Text>
)}
</View>
))}
<View style={styles.metaInfo}>
<Text style={styles.metaText}>AI : {item.aiModel}</Text>
<Text style={styles.metaText}>: {item.aiProvider}</Text>
</View>
</View>
)}
</View>
);
}, [expandedItems, toggleExpanded]);
// 渲染空状态
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>
</View>
);
// 渲染错误状态
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>
<TouchableOpacity
style={styles.retryButton}
onPress={() => {
setLoading(true);
fetchRecords(1, true);
}}
>
<Text style={styles.retryButtonText}></Text>
</TouchableOpacity>
</View>
);
// 渲染底部加载指示器
const renderFooter = () => {
if (!loadingMore) return null;
return (
<View style={styles.loadingFooter}>
<ActivityIndicator size="small" color={Colors.light.primary} />
<Text style={styles.loadingFooterText}>...</Text>
</View>
);
};
return (
<View style={styles.container}>
{/* 背景渐变 */}
<LinearGradient
colors={['#f5e5fbff', '#e5fcfeff', '#eefdffff', '#ffffffff']}
style={styles.gradientBackground}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
/>
<HeaderBar
title="历史记录"
onBack={() => router.back()}
transparent={true}
/>
{/* 筛选按钮 */}
<View style={[styles.filterContainer, { paddingTop: safeAreaTop }]}>
<TouchableOpacity
style={[styles.filterButton, !statusFilter && styles.filterButtonActive]}
onPress={() => {
if (statusFilter !== '') {
setStatusFilter('');
setCurrentPage(1);
// 直接调用数据获取,不依赖 useEffect
setLoading(true);
fetchRecords(1, true, '');
}
}}
activeOpacity={0.7}
>
<Text style={[styles.filterButtonText, !statusFilter && styles.filterButtonTextActive]}>
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.filterButton, statusFilter === 'success' && styles.filterButtonActive]}
onPress={() => {
if (statusFilter !== 'success') {
setStatusFilter('success');
setCurrentPage(1);
// 直接调用数据获取,不依赖 useEffect
setLoading(true);
fetchRecords(1, true, 'success');
}
}}
activeOpacity={0.7}
>
<Text style={[styles.filterButtonText, statusFilter === 'success' && styles.filterButtonTextActive]}>
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.filterButton, statusFilter === 'failed' && styles.filterButtonActive]}
onPress={() => {
if (statusFilter !== 'failed') {
setStatusFilter('failed');
setCurrentPage(1);
// 直接调用数据获取,不依赖 useEffect
setLoading(true);
fetchRecords(1, true, 'failed');
}
}}
activeOpacity={0.7}
>
<Text style={[styles.filterButtonText, statusFilter === 'failed' && styles.filterButtonTextActive]}>
</Text>
</TouchableOpacity>
</View>
{/* 记录列表 */}
{loading ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={Colors.light.primary} />
<Text style={styles.loadingText}>...</Text>
</View>
) : (
<FlatList
data={records}
renderItem={renderRecordItem}
keyExtractor={item => item.id.toString()}
contentContainerStyle={styles.listContainer}
showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={handleRefresh}
colors={[Colors.light.primary]}
tintColor={Colors.light.primary}
/>
}
onEndReached={handleLoadMore}
onEndReachedThreshold={0.2} // 提高阈值,减少频繁触发
ListEmptyComponent={error ? renderErrorState : renderEmptyState} // 错误时显示错误状态
ListFooterComponent={renderFooter}
/>
)}
{/* 图片预览 */}
<ImageViewing
images={previewImageUri ? [{ uri: previewImageUri }] : []}
imageIndex={0}
visible={showImagePreview}
onRequestClose={() => setShowImagePreview(false)}
swipeToCloseEnabled={true}
doubleTapToZoomEnabled={true}
HeaderComponent={() => (
<View style={styles.imageViewerHeader}>
<Text style={styles.imageViewerHeaderText}>
{dayjs().format('YYYY年M月D日 HH:mm')}
</Text>
</View>
)}
FooterComponent={() => (
<View style={styles.imageViewerFooter}>
<TouchableOpacity
style={styles.imageViewerFooterButton}
onPress={() => setShowImagePreview(false)}
>
<Text style={styles.imageViewerFooterButtonText}></Text>
</TouchableOpacity>
</View>
)}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f5e5fbff',
},
gradientBackground: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
},
filterContainer: {
flexDirection: 'row',
paddingHorizontal: 16,
paddingBottom: 12,
gap: 8,
},
filterButton: {
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 20,
backgroundColor: 'rgba(255, 255, 255, 0.7)',
borderWidth: 1,
borderColor: '#E0E0E0',
},
filterButtonActive: {
backgroundColor: Colors.light.primary,
borderColor: Colors.light.primary,
},
filterButtonText: {
fontSize: 14,
fontWeight: '500',
color: '#666',
},
filterButtonTextActive: {
color: '#FFF',
},
listContainer: {
paddingHorizontal: 16,
paddingBottom: 20,
},
recordItem: {
backgroundColor: Colors.light.background,
borderRadius: 16,
padding: 16,
marginBottom: 12,
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
recordHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
marginBottom: 12,
},
recordInfo: {
flex: 1,
},
recordTitle: {
fontSize: 16,
fontWeight: '600',
color: Colors.light.text,
marginBottom: 4,
},
recordDate: {
fontSize: 14,
color: Colors.light.textSecondary,
marginBottom: 4,
},
statusBadge: {
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 8,
alignSelf: 'flex-start',
},
statusText: {
fontSize: 12,
fontWeight: '500',
color: '#FFF',
},
glassDeleteButton: {
width: 36,
height: 36,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
},
fallbackDeleteButton: {
borderWidth: 1,
borderColor: 'rgba(244, 67, 54, 0.3)',
backgroundColor: 'rgba(244, 67, 54, 0.1)',
},
imageContainer: {
marginBottom: 12,
position: 'relative',
},
thumbnail: {
width: '100%',
height: 120,
borderRadius: 12,
},
previewHint: {
position: 'absolute',
top: 8,
right: 8,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
borderRadius: 16,
padding: 6,
},
summaryContainer: {
marginBottom: 12,
},
nutritionSummary: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 12,
},
nutritionItem: {
flex: 1,
minWidth: '45%',
backgroundColor: 'rgba(74, 144, 226, 0.1)',
padding: 8,
borderRadius: 8,
},
nutritionLabel: {
fontSize: 12,
color: Colors.light.textSecondary,
marginBottom: 2,
},
nutritionValue: {
fontSize: 14,
fontWeight: '600',
color: Colors.light.primary,
},
errorContainer: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(244, 67, 54, 0.1)',
padding: 12,
borderRadius: 8,
marginBottom: 12,
},
errorMessage: {
fontSize: 14,
color: '#F44336',
marginLeft: 8,
flex: 1,
},
expandButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 8,
},
expandButtonText: {
fontSize: 14,
color: Colors.light.primary,
fontWeight: '500',
marginRight: 4,
},
detailsContainer: {
borderTopWidth: 1,
borderTopColor: '#F0F0F0',
paddingTop: 16,
marginTop: 8,
},
detailsTitle: {
fontSize: 16,
fontWeight: '600',
color: Colors.light.text,
marginBottom: 12,
},
detailItem: {
paddingVertical: 12,
borderBottomWidth: 1,
borderBottomColor: '#F8F8F8',
},
detailLabel: {
fontSize: 14,
color: Colors.light.text,
},
nutritionInfo: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 4,
},
detailValue: {
fontSize: 14,
fontWeight: '600',
color: Colors.light.primary,
},
analysisText: {
fontSize: 12,
color: Colors.light.textSecondary,
lineHeight: 16,
marginTop: 4,
paddingHorizontal: 8,
paddingVertical: 4,
backgroundColor: 'rgba(74, 144, 226, 0.05)',
borderRadius: 6,
},
metaInfo: {
marginTop: 12,
paddingTop: 12,
borderTopWidth: 1,
borderTopColor: '#F0F0F0',
},
metaText: {
fontSize: 12,
color: Colors.light.textSecondary,
marginBottom: 4,
},
emptyState: {
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 60,
},
emptyStateText: {
fontSize: 18,
fontWeight: '600',
color: Colors.light.text,
marginTop: 16,
},
emptyStateSubtext: {
fontSize: 14,
color: Colors.light.textSecondary,
marginTop: 8,
},
loadingContainer: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
loadingText: {
fontSize: 16,
color: Colors.light.textSecondary,
marginTop: 12,
},
loadingFooter: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 20,
},
loadingFooterText: {
fontSize: 14,
color: Colors.light.textSecondary,
marginLeft: 8,
},
errorState: {
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 60,
},
errorStateText: {
fontSize: 18,
fontWeight: '600',
color: Colors.light.text,
marginTop: 16,
},
errorStateSubtext: {
fontSize: 14,
color: Colors.light.textSecondary,
marginTop: 8,
textAlign: 'center',
paddingHorizontal: 32,
},
retryButton: {
marginTop: 20,
paddingHorizontal: 24,
paddingVertical: 12,
backgroundColor: Colors.light.primary,
borderRadius: 24,
},
retryButtonText: {
color: '#FFF',
fontSize: 16,
fontWeight: '600',
},
// ImageViewing 组件样式
imageViewerHeader: {
position: 'absolute',
top: 60,
left: 20,
right: 20,
backgroundColor: 'rgba(0, 0, 0, 0.7)',
borderRadius: 12,
paddingHorizontal: 16,
paddingVertical: 12,
zIndex: 1,
},
imageViewerHeaderText: {
color: '#FFF',
fontSize: 14,
fontWeight: '500',
textAlign: 'center',
},
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

@@ -0,0 +1,781 @@
import { HeaderBar } from '@/components/ui/HeaderBar';
import { Colors } from '@/constants/Colors';
import { useAuthGuard } from '@/hooks/useAuthGuard';
import { useCosUpload } from '@/hooks/useCosUpload';
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
import {
analyzeNutritionImage,
type NutritionAnalysisResponse
} from '@/services/nutritionLabelAnalysis';
import { triggerLightHaptic } from '@/utils/haptics';
import { Ionicons } from '@expo/vector-icons';
import dayjs from 'dayjs';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { Image } from 'expo-image';
import * as ImagePicker from 'expo-image-picker';
import { LinearGradient } from 'expo-linear-gradient';
import { useRouter } from 'expo-router';
import React, { useCallback, useRef, useState } from 'react';
import {
ActivityIndicator,
Alert,
BackHandler,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View
} from 'react-native';
import ImageViewing from 'react-native-image-viewing';
export default function NutritionLabelAnalysisScreen() {
const safeAreaTop = useSafeAreaTop();
const router = useRouter();
const { pushIfAuthedElseLogin, ensureLoggedIn } = useAuthGuard();
const { upload, uploading: uploadingToCos, progress: uploadProgress } = useCosUpload({
prefix: 'nutrition-labels'
});
const [imageUri, setImageUri] = useState<string | null>(null);
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [showImagePreview, setShowImagePreview] = useState(false);
const [newAnalysisResult, setNewAnalysisResult] = useState<NutritionAnalysisResponse | null>(null);
const [isUploading, setIsUploading] = useState(false);
// 流式请求相关引用
const streamAbortRef = useRef<{ abort: () => void } | null>(null);
const scrollViewRef = useRef<ScrollView>(null);
// 处理Android返回键关闭图片预览
React.useEffect(() => {
const backHandler = BackHandler.addEventListener('hardwareBackPress', () => {
if (showImagePreview) {
setShowImagePreview(false);
return true; // 阻止默认返回行为
}
return false;
});
return () => backHandler.remove();
}, [showImagePreview]);
// 组件卸载时清理流式请求
React.useEffect(() => {
return () => {
try {
if (streamAbortRef.current) {
streamAbortRef.current.abort();
streamAbortRef.current = null;
}
} catch (error) {
console.warn('[NUTRITION_ANALYSIS] Error aborting stream on unmount:', error);
}
};
}, []);
// 请求相机权限
const requestCameraPermission = async () => {
const { status } = await ImagePicker.requestCameraPermissionsAsync();
if (status !== 'granted') {
Alert.alert('权限不足', '需要相机权限才能拍摄成分表');
return false;
}
return true;
};
// 拍照
const takePhoto = async () => {
const hasPermission = await requestCameraPermission();
if (!hasPermission) return;
triggerLightHaptic();
const result = await ImagePicker.launchCameraAsync({
mediaTypes: ['images'],
allowsEditing: true,
aspect: [4, 3],
quality: 0.8,
});
if (!result.canceled && result.assets[0]) {
setImageUri(result.assets[0].uri);
setNewAnalysisResult(null); // 清除之前的分析结果
}
};
// 从相册选择
const pickImage = async () => {
triggerLightHaptic();
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ['images'],
allowsEditing: true,
aspect: [4, 3],
quality: 0.8,
});
if (!result.canceled && result.assets[0]) {
setImageUri(result.assets[0].uri);
setNewAnalysisResult(null); // 清除之前的分析结果
}
};
// 新的分析函数先上传图片到COS然后调用新API
const startNewAnalysis = useCallback(async (uri: string) => {
if (isAnalyzing || isUploading) return;
setIsUploading(true);
setNewAnalysisResult(null);
// 延迟滚动到分析结果区域给UI一些时间更新
setTimeout(() => {
scrollViewRef.current?.scrollTo({ y: 350, animated: true });
}, 300);
try {
// 第一步上传图片到COS
console.log('[NUTRITION_ANALYSIS] 开始上传图片到COS...');
const uploadResult = await upload(uri);
console.log('[NUTRITION_ANALYSIS] 图片上传成功:', uploadResult.url);
setIsUploading(false);
setIsAnalyzing(true);
// 第二步调用新的营养成分分析API
console.log('[NUTRITION_ANALYSIS] 开始调用营养成分分析API...');
const analysisResponse = await analyzeNutritionImage({
imageUrl: uploadResult.url
});
console.log('[NUTRITION_ANALYSIS] API响应:', analysisResponse);
if (analysisResponse.success && analysisResponse.data) {
// 直接使用服务端返回的数据,不做任何转换
setNewAnalysisResult(analysisResponse);
} else {
throw new Error(analysisResponse.message || '分析失败');
}
} catch (error: any) {
console.error('[NUTRITION_ANALYSIS] 新API分析失败:', error);
setIsUploading(false);
setIsAnalyzing(false);
// 显示错误提示
Alert.alert(
'分析失败',
error.message || '无法识别成分表,请尝试拍摄更清晰的照片'
);
} finally {
setIsUploading(false);
setIsAnalyzing(false);
}
}, [isAnalyzing, isUploading, upload]);
return (
<View style={styles.container}>
{/* 背景渐变 */}
<LinearGradient
colors={['#f5e5fbff', '#e5fcfeff', '#eefdffff', '#ffffffff']}
style={styles.gradientBackground}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
/>
<HeaderBar
title="成分表分析"
onBack={() => router.back()}
transparent={true}
right={
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" />
</TouchableOpacity>
)
}
/>
<ScrollView
ref={scrollViewRef}
style={styles.scrollContainer}
contentContainerStyle={{
paddingTop: safeAreaTop
}}
showsVerticalScrollIndicator={false}
>
{/* 图片区域 */}
<View style={styles.imageContainer}>
{imageUri ? (
<View>
<TouchableOpacity
onPress={() => setShowImagePreview(true)}
activeOpacity={0.9}
>
<Image
source={{ uri: imageUri }}
style={styles.foodImage}
cachePolicy={'memory-disk'}
/>
{/* 预览提示图标 */}
<View style={styles.previewHint}>
<Ionicons name="expand-outline" size={20} color="#FFF" />
</View>
</TouchableOpacity>
{/* 开始分析按钮 */}
{!isAnalyzing && !isUploading && !newAnalysisResult && (
<TouchableOpacity
style={styles.analyzeButton}
onPress={async () => {
// 先验证登录状态
const isLoggedIn = await ensureLoggedIn();
if (isLoggedIn) {
startNewAnalysis(imageUri);
}
}}
activeOpacity={0.8}
>
<Ionicons name="search-outline" size={20} color="#FFF" />
<Text style={styles.analyzeButtonText}></Text>
</TouchableOpacity>
)}
{/* 删除图片按钮 */}
<TouchableOpacity
style={styles.deleteImageButton}
onPress={() => {
setImageUri(null);
setNewAnalysisResult(null);
triggerLightHaptic();
}}
activeOpacity={0.8}
>
<Ionicons name="trash-outline" size={16} color="#FFF" />
</TouchableOpacity>
</View>
) : (
<View style={styles.placeholderContainer}>
<View style={styles.placeholderContent}>
<Ionicons name="document-text-outline" size={48} color="#666" />
<Text style={styles.placeholderText}></Text>
</View>
{/* 操作按钮区域 */}
<View style={styles.imageActionButtonsContainer}>
<TouchableOpacity
style={styles.imageActionButton}
onPress={takePhoto}
activeOpacity={0.8}
>
<Ionicons name="camera-outline" size={20} color={Colors.light.onPrimary} />
<Text style={styles.imageActionButtonText}></Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.imageActionButton, styles.imageActionButtonSecondary]}
onPress={pickImage}
activeOpacity={0.8}
>
<Ionicons name="image-outline" size={20} color={Colors.light.primary} />
<Text style={[styles.imageActionButtonText, { color: Colors.light.primary }]}></Text>
</TouchableOpacity>
</View>
</View>
)}
</View>
{/* 新API营养成分详细分析结果 */}
{newAnalysisResult && newAnalysisResult.success && newAnalysisResult.data && (
<View style={styles.analysisSection}>
<View style={styles.analysisSectionHeader}>
<View style={styles.analysisSectionHeaderIcon}>
<Ionicons name="document-text-outline" size={18} color="#6B6ED6" />
</View>
<Text style={styles.analysisSectionTitle}></Text>
</View>
<View style={styles.analysisCardsWrapper}>
{newAnalysisResult.data.map((item, index) => (
<View
key={item.key || index}
style={[
styles.analysisCardItem,
index === newAnalysisResult.data.length - 1 && styles.analysisCardItemLast
]}
>
<LinearGradient
colors={['#7F77FF', '#9B7CFF']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.analysisItemIconGradient}
>
<Ionicons name="nutrition-outline" size={24} color="#FFFFFF" />
</LinearGradient>
<View style={styles.analysisItemContent}>
<View style={styles.analysisItemHeader}>
<Text style={styles.analysisItemName}>{item.name}</Text>
<Text style={styles.analysisItemValue}>{item.value}</Text>
</View>
<View style={styles.analysisItemDescriptionRow}>
<Ionicons
name="information-circle-outline"
size={16}
color="rgba(107, 110, 214, 0.8)"
style={styles.analysisItemDescriptionIcon}
/>
<Text style={styles.analysisItemDescription}>{item.analysis}</Text>
</View>
</View>
</View>
))}
</View>
</View>
)}
{/* 上传状态 */}
{isUploading && (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={Colors.light.primary} />
<Text style={styles.loadingText}>
... {uploadProgress > 0 ? `${Math.round(uploadProgress)}%` : ''}
</Text>
</View>
)}
{/* 加载状态 */}
{isAnalyzing && !newAnalysisResult && !isUploading && (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={Colors.light.primary} />
<Text style={styles.loadingText}>...</Text>
</View>
)}
</ScrollView>
{/* 图片预览 */}
<ImageViewing
images={imageUri ? [{ uri: imageUri }] : []}
imageIndex={0}
visible={showImagePreview}
onRequestClose={() => setShowImagePreview(false)}
swipeToCloseEnabled={true}
doubleTapToZoomEnabled={true}
HeaderComponent={() => (
<View style={styles.imageViewerHeader}>
<Text style={styles.imageViewerHeaderText}>
{dayjs().format('YYYY年M月D日 HH:mm')}
</Text>
</View>
)}
FooterComponent={() => (
<View style={styles.imageViewerFooter}>
<TouchableOpacity
style={styles.imageViewerFooterButton}
onPress={() => setShowImagePreview(false)}
>
<Text style={styles.imageViewerFooterButtonText}></Text>
</TouchableOpacity>
</View>
)}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f5e5fbff',
},
gradientBackground: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
},
scrollContainer: {
flex: 1,
},
imageContainer: {
position: 'relative',
height: 300,
marginHorizontal: 16,
marginTop: 16,
borderRadius: 20,
overflow: 'hidden',
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
foodImage: {
width: '100%',
height: '100%',
borderRadius: 20,
},
previewHint: {
position: 'absolute',
top: 16,
right: 16,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
borderRadius: 20,
padding: 8,
},
deleteImageButton: {
position: 'absolute',
top: 16,
left: 16,
backgroundColor: 'rgba(255, 59, 48, 0.9)',
borderRadius: 20,
padding: 8,
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.2,
shadowRadius: 4,
elevation: 3,
},
placeholderContainer: {
width: '100%',
height: '100%',
backgroundColor: '#FFFFFF',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 20,
shadowColor: 'rgba(0, 0, 0, 0.1)',
shadowOffset: {
width: 0,
height: 4,
},
shadowOpacity: 0.15,
shadowRadius: 8,
elevation: 5,
},
placeholderContent: {
alignItems: 'center',
marginBottom: 40,
},
placeholderText: {
fontSize: 16,
color: '#666',
fontWeight: '500',
marginTop: 8,
},
imageActionButtonsContainer: {
flexDirection: 'row',
gap: 12,
paddingHorizontal: 20,
},
imageActionButton: {
flex: 1,
backgroundColor: Colors.light.primary,
paddingVertical: 12,
paddingHorizontal: 16,
borderRadius: 16,
alignItems: 'center',
flexDirection: 'row',
justifyContent: 'center',
shadowColor: Colors.light.primary,
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.2,
shadowRadius: 4,
elevation: 3,
},
imageActionButtonSecondary: {
backgroundColor: 'transparent',
borderWidth: 1.5,
borderColor: Colors.light.primary,
shadowOpacity: 0,
elevation: 0,
},
imageActionButtonText: {
color: Colors.light.onPrimary,
fontSize: 14,
fontWeight: '600',
marginLeft: 6,
},
resultCard: {
backgroundColor: Colors.light.background,
margin: 16,
borderRadius: 16,
padding: 20,
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
resultHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 16,
},
resultTitle: {
fontSize: 18,
fontWeight: '600',
color: Colors.light.text,
},
confidenceContainer: {
backgroundColor: '#E8F5E8',
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 8,
},
confidenceText: {
fontSize: 12,
color: '#4CAF50',
fontWeight: '500',
},
foodInfoContainer: {
marginBottom: 12,
},
foodNameLabel: {
fontSize: 14,
color: Colors.light.textSecondary,
marginBottom: 4,
},
foodNameValue: {
fontSize: 16,
color: Colors.light.text,
fontWeight: '500',
},
nutritionGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
marginTop: 8,
},
nutritionItem: {
width: '50%',
marginBottom: 12,
paddingRight: 8,
},
nutritionLabel: {
fontSize: 12,
color: Colors.light.textSecondary,
marginBottom: 2,
},
nutritionValue: {
fontSize: 16,
color: Colors.light.text,
fontWeight: '600',
},
loadingContainer: {
alignItems: 'center',
justifyContent: 'center',
padding: 40,
},
loadingText: {
fontSize: 16,
color: Colors.light.textSecondary,
marginTop: 12,
},
// ImageViewing 组件样式
imageViewerHeader: {
position: 'absolute',
top: 60,
left: 20,
right: 20,
backgroundColor: 'rgba(0, 0, 0, 0.7)',
borderRadius: 12,
paddingHorizontal: 16,
paddingVertical: 12,
zIndex: 1,
},
imageViewerHeaderText: {
color: '#FFF',
fontSize: 14,
fontWeight: '500',
textAlign: 'center',
},
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',
},
// 开始分析按钮样式
analyzeButton: {
position: 'absolute',
bottom: 16,
right: 16,
backgroundColor: Colors.light.primary,
paddingHorizontal: 16,
paddingVertical: 12,
borderRadius: 20,
alignItems: 'center',
flexDirection: 'row',
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.2,
shadowRadius: 4,
elevation: 3,
},
analyzeButtonText: {
color: Colors.light.onPrimary,
fontSize: 14,
fontWeight: '600',
marginLeft: 6,
},
// 营养成分详细分析卡片样式
analysisSection: {
marginHorizontal: 16,
marginTop: 28,
marginBottom: 20,
},
analysisSectionHeader: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 14,
},
analysisSectionHeaderIcon: {
width: 32,
height: 32,
borderRadius: 12,
backgroundColor: 'rgba(107, 110, 214, 0.12)',
alignItems: 'center',
justifyContent: 'center',
},
analysisSectionTitle: {
marginLeft: 10,
fontSize: 18,
fontWeight: '600',
color: Colors.light.text,
},
analysisCardsWrapper: {
backgroundColor: '#FFFFFF',
borderRadius: 28,
padding: 18,
shadowColor: 'rgba(107, 110, 214, 0.25)',
shadowOffset: {
width: 0,
height: 10,
},
shadowOpacity: 0.2,
shadowRadius: 20,
elevation: 6,
},
analysisCardItem: {
flexDirection: 'row',
alignItems: 'flex-start',
padding: 16,
backgroundColor: 'rgba(127, 119, 255, 0.1)',
borderRadius: 22,
shadowColor: 'rgba(127, 119, 255, 0.28)',
shadowOffset: {
width: 0,
height: 6,
},
shadowOpacity: 0.16,
shadowRadius: 12,
elevation: 4,
marginBottom: 12,
},
analysisCardItemLast: {
marginBottom: 0,
},
analysisItemIconGradient: {
width: 52,
height: 52,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
marginRight: 14,
},
analysisItemContent: {
flex: 1,
},
analysisItemHeader: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 8,
},
analysisItemName: {
flex: 1,
fontSize: 16,
fontWeight: '600',
color: '#272753',
},
analysisItemValue: {
fontSize: 16,
fontWeight: '700',
color: Colors.light.primary,
},
analysisItemDescriptionRow: {
flexDirection: 'row',
alignItems: 'flex-start',
},
analysisItemDescriptionIcon: {
marginRight: 6,
marginTop: 2,
},
analysisItemDescription: {
flex: 1,
fontSize: 13,
lineHeight: 18,
color: 'rgba(39, 39, 83, 0.72)',
},
// 历史记录按钮样式
historyButton: {
width: 38,
height: 38,
alignItems: 'center',
justifyContent: 'center',
borderRadius: 19,
overflow: 'hidden',
},
fallbackBackground: {
backgroundColor: 'rgba(255, 255, 255, 0.7)',
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 1,
},
shadowOpacity: 0.1,
shadowRadius: 2,
elevation: 2,
},
});

View File

@@ -1,481 +0,0 @@
import { ThemedText } from '@/components/ThemedText';
import { ThemedView } from '@/components/ThemedView';
import { useThemeColor } from '@/hooks/useThemeColor';
import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import { useRouter } from 'expo-router';
import React, { useEffect, useState } from 'react';
import {
Dimensions,
Pressable,
SafeAreaView,
ScrollView,
StyleSheet,
Text,
View
} from 'react-native';
const { width: screenWidth } = Dimensions.get('window');
// 健康数据项类型
interface HealthItem {
id: string;
title: string;
subtitle: string;
status: 'warning' | 'good' | 'info';
icon: string;
recommendation: string;
value?: string;
color: string;
bgColor: string;
}
// 健康数据
const healthData: HealthItem[] = [
{
id: '1',
title: '运动状态',
subtitle: '本周运动不足',
status: 'warning',
icon: '🏃‍♀️',
recommendation: '建议每天进行30分钟普拉提训练',
value: '2天/周',
color: '#FF6B6B',
bgColor: '#FFE5E5',
},
{
id: '2',
title: '体态评估',
subtitle: '需要进行评估',
status: 'info',
icon: '🧘‍♀️',
recommendation: '进行AI体态评估了解身体状况',
color: '#4ECDC4',
bgColor: '#E5F9F7',
},
{
id: '3',
title: '核心力量',
subtitle: '待加强',
status: 'warning',
icon: '💪',
recommendation: '推荐核心训练课程',
value: '初级',
color: '#FFB84D',
bgColor: '#FFF4E5',
},
{
id: '4',
title: '柔韧性',
subtitle: '良好',
status: 'good',
icon: '🤸‍♀️',
recommendation: '保持每日拉伸习惯',
value: '良好',
color: '#95E1D3',
bgColor: '#E5F9F5',
},
{
id: '5',
title: '平衡能力',
subtitle: '需要提升',
status: 'info',
icon: '⚖️',
recommendation: '尝试单腿站立训练',
color: '#A8E6CF',
bgColor: '#E8F8F0',
},
{
id: '6',
title: '呼吸质量',
subtitle: '待改善',
status: 'warning',
icon: '🌬️',
recommendation: '学习普拉提呼吸法',
color: '#C7CEEA',
bgColor: '#F0F1F8',
},
];
export default function HealthConsultationScreen() {
const router = useRouter();
const primaryColor = useThemeColor({}, 'primary');
const backgroundColor = useThemeColor({}, 'background');
const textColor = useThemeColor({}, 'text');
const [greeting, setGreeting] = useState('');
useEffect(() => {
const hour = new Date().getHours();
if (hour < 12) {
setGreeting('早上好');
} else if (hour < 18) {
setGreeting('下午好');
} else {
setGreeting('晚上好');
}
}, []);
const handleHealthItemPress = (item: HealthItem) => {
// 根据不同的健康项导航到相应页面
if (item.title === '体态评估') {
router.push('/ai-posture-assessment');
} else {
console.log(`点击了 ${item.title}`);
// 可以添加更多导航逻辑
}
};
return (
<SafeAreaView style={[styles.safeArea, { backgroundColor }]}>
<ThemedView style={styles.container}>
<ScrollView showsVerticalScrollIndicator={false}>
{/* 顶部导航栏 */}
<View style={styles.header}>
<Pressable onPress={() => router.back()} style={styles.backButton}>
<Ionicons name="chevron-back" size={24} color={textColor} />
</Pressable>
<ThemedText style={styles.headerTitle}></ThemedText>
<Pressable style={styles.notificationButton}>
<Ionicons name="notifications-outline" size={24} color={textColor} />
</Pressable>
</View>
{/* 教练问候卡片 */}
<LinearGradient
colors={[primaryColor, '#A8E063']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.coachCard}
>
<View style={styles.coachContent}>
<View style={styles.coachInfo}>
<View style={styles.coachAvatar}>
<Text style={styles.coachAvatarEmoji}>👩</Text>
</View>
<View style={styles.coachTextContainer}>
<Text style={styles.coachGreeting}>{greeting}</Text>
<Text style={styles.coachName}> Sarah</Text>
</View>
</View>
<Text style={styles.coachQuestion}></Text>
<Text style={styles.coachSubtext}></Text>
</View>
</LinearGradient>
{/* 快速操作按钮 */}
<View style={styles.quickActions}>
<Pressable style={[styles.actionButton, { backgroundColor: primaryColor }]}>
<Ionicons name="body-outline" size={20} color="#192126" />
<Text style={styles.actionButtonText}></Text>
</Pressable>
<Pressable style={[styles.actionButton, styles.actionButtonOutline]}>
<Ionicons name="chatbubble-outline" size={20} color={textColor} />
<Text style={[styles.actionButtonText, { color: textColor }]}></Text>
</Pressable>
<Pressable style={[styles.actionButton, styles.actionButtonOutline]}>
<Ionicons name="calendar-outline" size={20} color={textColor} />
<Text style={[styles.actionButtonText, { color: textColor }]}></Text>
</Pressable>
</View>
{/* 健康状况标题 */}
<View style={styles.sectionHeader}>
<ThemedText style={styles.sectionTitle}></ThemedText>
<View style={[styles.healthBadge, { backgroundColor: primaryColor }]}>
<Text style={styles.healthBadgeText}></Text>
</View>
</View>
{/* 健康数据网格 */}
<View style={styles.healthGrid}>
{healthData.map((item) => (
<Pressable
key={item.id}
style={[styles.healthCard, { backgroundColor: item.bgColor }]}
onPress={() => handleHealthItemPress(item)}
>
<View style={styles.healthCardHeader}>
<Text style={styles.healthIcon}>{item.icon}</Text>
{item.value && (
<Text style={[styles.healthValue, { color: item.color }]}>
{item.value}
</Text>
)}
</View>
<Text style={styles.healthTitle}>{item.title}</Text>
<Text style={styles.healthSubtitle}>{item.subtitle}</Text>
<View style={styles.healthRecommendation}>
<Ionicons name="bulb-outline" size={12} color={item.color} />
<Text style={[styles.recommendationText, { color: item.color }]} numberOfLines={2}>
{item.recommendation}
</Text>
</View>
<Ionicons
name="arrow-forward-circle"
size={20}
color={item.color}
style={styles.cardArrow}
/>
</Pressable>
))}
</View>
{/* 今日建议 */}
<View style={styles.suggestionSection}>
<ThemedText style={styles.suggestionTitle}></ThemedText>
<View style={[styles.suggestionCard, { backgroundColor: '#F0F8FF' }]}>
<View style={styles.suggestionIcon}>
<Text style={{ fontSize: 24 }}>💡</Text>
</View>
<View style={styles.suggestionContent}>
<Text style={styles.suggestionMainText}>
</Text>
<Text style={styles.suggestionSubText}>
</Text>
<Pressable style={[styles.startButton, { backgroundColor: primaryColor }]}>
<Text style={styles.startButtonText}></Text>
<Ionicons name="arrow-forward" size={16} color="#192126" />
</Pressable>
</View>
</View>
</View>
{/* 底部间距 */}
<View style={styles.bottomSpacing} />
</ScrollView>
</ThemedView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safeArea: {
flex: 1,
},
container: {
flex: 1,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 20,
paddingVertical: 12,
},
backButton: {
padding: 8,
},
headerTitle: {
fontSize: 18,
fontWeight: '600',
},
notificationButton: {
padding: 8,
},
coachCard: {
marginHorizontal: 20,
marginTop: 12,
borderRadius: 20,
padding: 24,
elevation: 5,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 8,
},
coachContent: {
gap: 16,
},
coachInfo: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
coachAvatar: {
width: 50,
height: 50,
borderRadius: 25,
backgroundColor: '#fff',
alignItems: 'center',
justifyContent: 'center',
},
coachAvatarEmoji: {
fontSize: 30,
},
coachTextContainer: {
flex: 1,
},
coachGreeting: {
fontSize: 14,
color: '#192126',
opacity: 0.8,
},
coachName: {
fontSize: 16,
fontWeight: '600',
color: '#192126',
},
coachQuestion: {
fontSize: 28,
fontWeight: 'bold',
color: '#192126',
marginTop: 8,
},
coachSubtext: {
fontSize: 14,
color: '#192126',
opacity: 0.7,
},
quickActions: {
flexDirection: 'row',
paddingHorizontal: 20,
marginTop: 20,
gap: 12,
},
actionButton: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 12,
borderRadius: 12,
gap: 6,
},
actionButtonOutline: {
borderWidth: 1,
borderColor: '#E0E0E0',
},
actionButtonText: {
fontSize: 14,
fontWeight: '500',
},
sectionHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 20,
marginTop: 32,
marginBottom: 16,
},
sectionTitle: {
fontSize: 22,
fontWeight: 'bold',
},
healthBadge: {
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 12,
},
healthBadgeText: {
fontSize: 12,
fontWeight: '600',
color: '#192126',
},
healthGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
paddingHorizontal: 16,
gap: 12,
},
healthCard: {
width: (screenWidth - 44) / 2,
padding: 16,
borderRadius: 16,
position: 'relative',
},
healthCardHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 8,
},
healthIcon: {
fontSize: 28,
},
healthValue: {
fontSize: 12,
fontWeight: '600',
},
healthTitle: {
fontSize: 16,
fontWeight: '600',
color: '#192126',
marginBottom: 4,
},
healthSubtitle: {
fontSize: 13,
color: '#666',
marginBottom: 12,
},
healthRecommendation: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: 6,
paddingRight: 20,
},
recommendationText: {
fontSize: 11,
flex: 1,
},
cardArrow: {
position: 'absolute',
bottom: 12,
right: 12,
},
suggestionSection: {
paddingHorizontal: 20,
marginTop: 32,
},
suggestionTitle: {
fontSize: 22,
fontWeight: 'bold',
marginBottom: 16,
},
suggestionCard: {
flexDirection: 'row',
padding: 20,
borderRadius: 16,
gap: 16,
},
suggestionIcon: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: '#fff',
alignItems: 'center',
justifyContent: 'center',
},
suggestionContent: {
flex: 1,
gap: 8,
},
suggestionMainText: {
fontSize: 15,
fontWeight: '500',
color: '#192126',
},
suggestionSubText: {
fontSize: 13,
color: '#666',
},
startButton: {
flexDirection: 'row',
alignItems: 'center',
alignSelf: 'flex-start',
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 20,
marginTop: 8,
gap: 6,
},
startButtonText: {
fontSize: 14,
fontWeight: '600',
color: '#192126',
},
bottomSpacing: {
height: 100,
},
});

View File

@@ -0,0 +1,248 @@
import { HeaderBar } from '@/components/ui/HeaderBar';
import { Ionicons } from '@expo/vector-icons';
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Linking, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
type CardConfig = {
key: string;
icon: React.ComponentProps<typeof Ionicons>['name'];
color: string;
title: string;
items: string[];
};
export default function HealthDataPermissionsScreen() {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const cards = useMemo<CardConfig[]>(() => ([
{
key: 'usage',
icon: 'pulse-outline',
color: '#34D399',
title: t('healthPermissions.cards.usage.title'),
items: t('healthPermissions.cards.usage.items', { returnObjects: true }) as string[],
},
{
key: 'purpose',
icon: 'bulb-outline',
color: '#FBBF24',
title: t('healthPermissions.cards.purpose.title'),
items: t('healthPermissions.cards.purpose.items', { returnObjects: true }) as string[],
},
{
key: 'control',
icon: 'shield-checkmark-outline',
color: '#60A5FA',
title: t('healthPermissions.cards.control.title'),
items: t('healthPermissions.cards.control.items', { returnObjects: true }) as string[],
},
{
key: 'privacy',
icon: 'lock-closed-outline',
color: '#A78BFA',
title: t('healthPermissions.cards.privacy.title'),
items: t('healthPermissions.cards.privacy.items', { returnObjects: true }) as string[],
},
]), [t]);
const calloutItems = useMemo(() => (
t('healthPermissions.callout.items', { returnObjects: true }) as string[]
), [t]);
const contactDescription = t('healthPermissions.contact.description');
const contactEmail = t('healthPermissions.contact.email');
const handleContactPress = () => {
if (!contactEmail) return;
void Linking.openURL(`mailto:${contactEmail}`);
};
const contentTopPadding = insets.top + 72;
return (
<View style={styles.container}>
<HeaderBar
title={t('healthPermissions.title')}
variant="elevated"
transparent={true}
/>
<ScrollView
style={styles.scrollView}
contentContainerStyle={{
paddingTop: contentTopPadding,
paddingBottom: insets.bottom + 32,
paddingHorizontal: 20,
}}
showsVerticalScrollIndicator={false}
>
<View style={styles.heroCard}>
<Text style={styles.heroTitle}>{t('healthPermissions.title')}</Text>
<Text style={styles.heroSubtitle}>{t('healthPermissions.subtitle')}</Text>
</View>
{cards.map((card) => (
<View key={card.key} style={styles.infoCard}>
<View style={styles.cardHeader}>
<View style={[styles.cardIcon, { backgroundColor: `${card.color}22` }]}>
<Ionicons name={card.icon} size={20} color={card.color} />
</View>
<Text style={styles.cardTitle}>{card.title}</Text>
</View>
{card.items.map((item, index) => (
<View key={`${card.key}-${index}`} style={styles.cardItemRow}>
<View style={styles.bullet} />
<Text style={styles.cardItemText}>{item}</Text>
</View>
))}
</View>
))}
<View style={styles.calloutCard}>
<View style={styles.cardHeader}>
<View style={[styles.cardIcon, { backgroundColor: '#F472B622' }]}>
<Ionicons name="alert-circle-outline" size={20} color="#F472B6" />
</View>
<Text style={styles.cardTitle}>{t('healthPermissions.callout.title')}</Text>
</View>
{calloutItems.map((item, index) => (
<View key={`callout-${index}`} style={styles.cardItemRow}>
<View style={styles.bullet} />
<Text style={styles.cardItemText}>{item}</Text>
</View>
))}
</View>
<View style={styles.contactCard}>
<Text style={styles.contactTitle}>{t('healthPermissions.contact.title')}</Text>
<Text style={styles.contactDescription}>{contactDescription}</Text>
{contactEmail ? (
<TouchableOpacity style={styles.contactButton} onPress={handleContactPress} activeOpacity={0.85}>
<Ionicons name="mail-outline" size={18} color="#fff" />
<Text style={styles.contactButtonText}>{contactEmail}</Text>
</TouchableOpacity>
) : null}
</View>
</ScrollView>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#F9FAFB',
},
scrollView: {
flex: 1,
},
heroCard: {
backgroundColor: '#fff',
borderRadius: 20,
padding: 20,
marginBottom: 16,
shadowColor: '#000',
shadowOpacity: 0.05,
shadowRadius: 10,
shadowOffset: { width: 0, height: 8 },
elevation: 2,
},
heroTitle: {
fontSize: 24,
fontWeight: '700',
color: '#111827',
marginBottom: 12,
},
heroSubtitle: {
fontSize: 16,
color: '#4B5563',
lineHeight: 22,
},
infoCard: {
backgroundColor: '#fff',
borderRadius: 18,
padding: 18,
marginBottom: 14,
borderWidth: 1,
borderColor: '#F3F4F6',
},
cardHeader: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 12,
},
cardIcon: {
width: 36,
height: 36,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
marginRight: 10,
},
cardTitle: {
fontSize: 16,
fontWeight: '600',
color: '#111827',
},
cardItemRow: {
flexDirection: 'row',
alignItems: 'flex-start',
marginBottom: 8,
},
bullet: {
width: 6,
height: 6,
borderRadius: 3,
backgroundColor: '#9370DB',
marginTop: 8,
marginRight: 10,
},
cardItemText: {
flex: 1,
fontSize: 14,
color: '#374151',
lineHeight: 20,
},
calloutCard: {
backgroundColor: '#FEF3F2',
borderRadius: 18,
padding: 18,
marginBottom: 14,
borderWidth: 1,
borderColor: '#FECACA',
},
contactCard: {
backgroundColor: '#fff',
borderRadius: 18,
padding: 18,
borderWidth: 1,
borderColor: '#F3F4F6',
},
contactTitle: {
fontSize: 16,
fontWeight: '600',
color: '#111827',
marginBottom: 8,
},
contactDescription: {
fontSize: 14,
color: '#4B5563',
lineHeight: 20,
marginBottom: 12,
},
contactButton: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#111827',
paddingHorizontal: 16,
paddingVertical: 12,
borderRadius: 12,
},
contactButtonText: {
marginLeft: 8,
color: '#fff',
fontWeight: '600',
},
});

View File

@@ -1,16 +1,17 @@
import { ThemedView } from '@/components/ThemedView';
import { ROUTES } from '@/constants/Routes';
import { usePushNotifications } from '@/hooks/usePushNotifications';
import { useThemeColor } from '@/hooks/useThemeColor';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { preloadUserData } from '@/store/userSlice';
import { router } from 'expo-router';
import React, { useEffect, useState } from 'react';
import { ActivityIndicator, Text, View } from 'react-native';
const ONBOARDING_COMPLETED_KEY = '@onboarding_completed';
import { ActivityIndicator, View } from 'react-native';
export default function SplashScreen() {
const backgroundColor = useThemeColor({}, 'background');
const primaryColor = useThemeColor({}, 'primary');
const [isLoading, setIsLoading] = useState(true);
const { initializePushNotifications } = usePushNotifications();
useEffect(() => {
checkOnboardingStatus();
@@ -18,25 +19,31 @@ export default function SplashScreen() {
const checkOnboardingStatus = async () => {
try {
const onboardingCompleted = await AsyncStorage.getItem(ONBOARDING_COMPLETED_KEY);
// 先预加载用户数据,包括 onboarding 状态
console.log('开始预加载用户数据(包含 onboarding 状态)...');
const userData = await preloadUserData();
console.log('用户数据预加载完成onboarding 状态:', userData.onboardingCompleted);
// 添加一个短暂的延迟以显示启动画面
setTimeout(() => {
if (onboardingCompleted === 'true') {
router.replace('/(tabs)');
} else {
router.replace('/onboarding');
}
setIsLoading(false);
}, 1000);
// 初始化推送通知(不阻塞应用启动,且不会请求权限)
console.log('开始初始化推送通知基础服务...');
initializePushNotifications().catch((error) => {
console.warn('推送通知初始化失败,但不影响应用正常使用:', error);
});
// 根据预加载的状态决定跳转
if (userData.onboardingCompleted) {
console.log('用户已完成引导,跳转到统计页面');
router.replace(ROUTES.TAB_STATISTICS);
} else {
console.log('用户未完成引导,跳转到引导页面');
router.replace(ROUTES.ONBOARDING);
}
} catch (error) {
console.error('检查引导状态失败:', error);
// 如果出现错误,默认显示引导页面
setTimeout(() => {
router.replace('/onboarding');
setIsLoading(false);
}, 1000);
console.error('检查引导状态或预加载用户数据失败:', error);
// 如果出现错误,默认进入主应用(假设已完成引导)
router.replace(ROUTES.TAB_STATISTICS);
}
setIsLoading(false);
};
if (!isLoading) {
@@ -54,16 +61,12 @@ export default function SplashScreen() {
width: 80,
height: 80,
borderRadius: 40,
backgroundColor: primaryColor,
// backgroundColor: primaryColor,
justifyContent: 'center',
alignItems: 'center',
marginBottom: 20,
}}>
<Text style={{
fontSize: 32,
}}>
🧘
</Text>
</View>
<ActivityIndicator size="large" color={primaryColor} />
</ThemedView>

View File

@@ -0,0 +1,15 @@
import React from 'react';
import { ScrollView, Text } from 'react-native';
export default function PrivacyPolicy() {
return (
<ScrollView style={{ flex: 1, padding: 16 }}>
<Text style={{ fontSize: 20, fontWeight: '700', marginBottom: 12 }}></Text>
<Text style={{ lineHeight: 22, color: '#4A4A4A' }}>
</Text>
</ScrollView>
);
}

View File

@@ -0,0 +1,15 @@
import React from 'react';
import { ScrollView, Text } from 'react-native';
export default function UserAgreement() {
return (
<ScrollView style={{ flex: 1, padding: 16 }}>
<Text style={{ fontSize: 20, fontWeight: '700', marginBottom: 12 }}></Text>
<Text style={{ lineHeight: 22, color: '#4A4A4A' }}>
</Text>
</ScrollView>
);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,680 @@
import { ThemedText } from '@/components/ThemedText';
import { HeaderBar } from '@/components/ui/HeaderBar';
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';
import DateTimePicker from '@react-native-community/datetimepicker';
import { Picker } from '@react-native-picker/picker';
import dayjs from 'dayjs';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { useLocalSearchParams, useRouter } from 'expo-router';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
ActivityIndicator,
Alert,
Modal,
Platform,
Pressable,
ScrollView,
StyleSheet,
TouchableOpacity,
View,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
const DEFAULT_TIME_PRESETS = ['08:00', '12:00', '18:00', '22:00'];
// 辅助函数:从时间字符串创建 Date 对象
const createDateFromTime = (time: string) => {
try {
if (!time || typeof time !== 'string') {
console.warn('[MEDICATION] Invalid time string provided:', time);
return new Date();
}
const parts = time.split(':');
if (parts.length !== 2) {
console.warn('[MEDICATION] Invalid time format:', time);
return new Date();
}
const hour = parseInt(parts[0], 10);
const minute = parseInt(parts[1], 10);
if (isNaN(hour) || isNaN(minute) || hour < 0 || hour > 23 || minute < 0 || minute > 59) {
console.warn('[MEDICATION] Invalid time values:', { hour, minute });
return new Date();
}
const next = new Date();
next.setHours(hour, minute, 0, 0);
if (isNaN(next.getTime())) {
console.error('[MEDICATION] Failed to create valid date');
return new Date();
}
return next;
} catch (error) {
console.error('[MEDICATION] Error in createDateFromTime:', error);
return new Date();
}
};
// 辅助函数:格式化时间
const formatTime = (date: Date) => dayjs(date).format('HH:mm');
// 辅助函数:获取默认时间
const getDefaultTimeByIndex = (index: number) => {
return DEFAULT_TIME_PRESETS[index % DEFAULT_TIME_PRESETS.length];
};
export default function EditMedicationFrequencyScreen() {
const params = useLocalSearchParams<{
medicationId?: string;
medicationName?: string;
repeatPattern?: RepeatPattern;
timesPerDay?: string;
medicationTimes?: string;
}>();
const medicationId = Array.isArray(params.medicationId) ? params.medicationId[0] : params.medicationId;
const medicationName = Array.isArray(params.medicationName) ? params.medicationName[0] : params.medicationName;
const initialRepeatPattern = (Array.isArray(params.repeatPattern) ? params.repeatPattern[0] : params.repeatPattern) as RepeatPattern || 'daily';
const initialTimesPerDay = parseInt(Array.isArray(params.timesPerDay) ? params.timesPerDay[0] : params.timesPerDay || '1', 10);
const initialTimes = params.medicationTimes
? (Array.isArray(params.medicationTimes) ? params.medicationTimes[0] : params.medicationTimes).split(',')
: ['08:00'];
const dispatch = useAppDispatch();
const router = useRouter();
const scheme = (useColorScheme() ?? 'light') as keyof typeof Colors;
const colors = Colors[scheme];
const insets = useSafeAreaInsets();
const [repeatPattern, setRepeatPattern] = useState<RepeatPattern>(initialRepeatPattern);
const [timesPerDay, setTimesPerDay] = useState(initialTimesPerDay);
const [medicationTimes, setMedicationTimes] = useState<string[]>(initialTimes);
const [saving, setSaving] = useState(false);
// 时间选择器相关状态
const [timePickerVisible, setTimePickerVisible] = useState(false);
const [timePickerDate, setTimePickerDate] = useState<Date>(new Date());
const [editingTimeIndex, setEditingTimeIndex] = useState<number | null>(null);
// 根据 timesPerDay 动态调整 medicationTimes
useEffect(() => {
setMedicationTimes((prev) => {
if (timesPerDay > prev.length) {
const next = [...prev];
while (next.length < timesPerDay) {
next.push(getDefaultTimeByIndex(next.length));
}
return next;
}
if (timesPerDay < prev.length) {
return prev.slice(0, timesPerDay);
}
return prev;
});
}, [timesPerDay]);
// 打开时间选择器
const openTimePicker = useCallback(
(index?: number) => {
try {
if (typeof index === 'number') {
if (index >= 0 && index < medicationTimes.length) {
setEditingTimeIndex(index);
setTimePickerDate(createDateFromTime(medicationTimes[index]));
} else {
console.error('[MEDICATION] Invalid time index:', index);
return;
}
} else {
setEditingTimeIndex(null);
setTimePickerDate(createDateFromTime(getDefaultTimeByIndex(medicationTimes.length)));
}
setTimePickerVisible(true);
} catch (error) {
console.error('[MEDICATION] Error in openTimePicker:', error);
}
},
[medicationTimes]
);
// 确认时间选择
const confirmTime = useCallback(
(date: Date) => {
try {
if (!date || isNaN(date.getTime())) {
console.error('[MEDICATION] Invalid date provided to confirmTime');
setTimePickerVisible(false);
setEditingTimeIndex(null);
return;
}
const nextValue = formatTime(date);
setMedicationTimes((prev) => {
if (editingTimeIndex == null) {
return [...prev, nextValue];
}
return prev.map((time, idx) => (idx === editingTimeIndex ? nextValue : time));
});
setTimePickerVisible(false);
setEditingTimeIndex(null);
} catch (error) {
console.error('[MEDICATION] Error in confirmTime:', error);
setTimePickerVisible(false);
setEditingTimeIndex(null);
}
},
[editingTimeIndex]
);
// 删除时间
const removeTime = useCallback((index: number) => {
setMedicationTimes((prev) => {
if (prev.length === 1) {
return prev; // 至少保留一个时间
}
return prev.filter((_, idx) => idx !== index);
});
// 同时更新 timesPerDay
setTimesPerDay((prev) => Math.max(1, prev - 1));
}, []);
// 添加时间
const addTime = useCallback(() => {
openTimePicker();
// 同时更新 timesPerDay
setTimesPerDay((prev) => prev + 1);
}, [openTimePicker]);
// 保存修改
const handleSave = useCallback(async () => {
if (!medicationId || saving) return;
setSaving(true);
try {
const updated = await dispatch(
updateMedicationAction({
id: medicationId,
repeatPattern,
timesPerDay,
medicationTimes,
})
).unwrap();
// 重新安排药品通知
try {
await medicationNotificationService.scheduleMedicationNotifications(updated);
} catch (error) {
console.error('[MEDICATION] 安排药品通知失败:', error);
}
router.back();
} catch (err) {
console.error('更新频率失败', err);
Alert.alert('更新失败', '更新服药频率时出现问题,请稍后重试。');
} finally {
setSaving(false);
}
}, [dispatch, medicationId, medicationTimes, repeatPattern, router, saving, timesPerDay]);
const frequencyLabel = useMemo(() => {
switch (repeatPattern) {
case 'daily':
return `每日 ${timesPerDay}`;
case 'weekly':
return `每周 ${timesPerDay}`;
default:
return `自定义 · ${timesPerDay} 次/日`;
}
}, [repeatPattern, timesPerDay]);
if (!medicationId) {
return (
<View style={[styles.container, { backgroundColor: colors.pageBackgroundEmphasis }]}>
<HeaderBar title="编辑服药频率" variant="minimal" />
<View style={styles.centered}>
<ThemedText style={styles.emptyText}></ThemedText>
</View>
</View>
);
}
return (
<View style={[styles.container, { backgroundColor: colors.pageBackgroundEmphasis }]}>
<HeaderBar
title="编辑服药频率"
variant="minimal"
transparent
/>
<ScrollView
contentContainerStyle={[
styles.content,
{
paddingTop: insets.top + 72,
paddingBottom: Math.max(insets.bottom, 16) + 120,
},
]}
showsVerticalScrollIndicator={false}
>
{/* 药品名称提示 */}
{medicationName && (
<View style={[styles.medicationNameCard, { backgroundColor: colors.surface }]}>
<Ionicons name="medical" size={20} color={colors.primary} />
<ThemedText style={[styles.medicationNameText, { color: colors.text }]}>
{medicationName}
</ThemedText>
</View>
)}
{/* 频率选择 */}
<View style={styles.section}>
<ThemedText style={[styles.sectionTitle, { color: colors.text }]}>
</ThemedText>
<ThemedText style={[styles.sectionDescription, { color: colors.textSecondary }]}>
</ThemedText>
<View style={styles.pickerRow}>
<View style={styles.pickerColumn}>
<ThemedText style={[styles.pickerLabel, { color: colors.textSecondary }]}>
</ThemedText>
<Picker
selectedValue={repeatPattern}
onValueChange={(value) => setRepeatPattern(value as RepeatPattern)}
itemStyle={styles.pickerItem}
style={styles.picker}
>
<Picker.Item label="每日" value="daily" />
{/* <Picker.Item label="每周" value="weekly" />
<Picker.Item label="自定义" value="custom" /> */}
</Picker>
</View>
<View style={styles.pickerColumn}>
<ThemedText style={[styles.pickerLabel, { color: colors.textSecondary }]}>
</ThemedText>
<Picker
selectedValue={timesPerDay}
onValueChange={(value) => setTimesPerDay(Number(value))}
itemStyle={styles.pickerItem}
style={styles.picker}
>
{TIMES_PER_DAY_OPTIONS.map((times) => (
<Picker.Item
key={times}
label={`${times}`}
value={times}
/>
))}
</Picker>
</View>
</View>
<View style={[styles.frequencySummary, { backgroundColor: colors.surface }]}>
<Ionicons name="repeat" size={18} color={colors.primary} />
<ThemedText style={[styles.frequencySummaryText, { color: colors.text }]}>
{frequencyLabel}
</ThemedText>
</View>
</View>
{/* 提醒时间列表 */}
<View style={styles.section}>
<ThemedText style={[styles.sectionTitle, { color: colors.text }]}>
</ThemedText>
<ThemedText style={[styles.sectionDescription, { color: colors.textSecondary }]}>
</ThemedText>
<View style={styles.timeList}>
{medicationTimes.map((time, index) => (
<View
key={`${time}-${index}`}
style={[
styles.timeItem,
{
borderColor: `${colors.border}80`,
backgroundColor: colors.surface,
},
]}
>
<TouchableOpacity style={styles.timeValue} onPress={() => openTimePicker(index)}>
<Ionicons name="time" size={20} color={colors.primary} />
<ThemedText style={[styles.timeText, { color: colors.text }]}>{time}</ThemedText>
</TouchableOpacity>
<Pressable
onPress={() => removeTime(index)}
disabled={medicationTimes.length === 1}
hitSlop={12}
>
<Ionicons
name="close-circle"
size={20}
color={medicationTimes.length === 1 ? `${colors.border}80` : colors.textSecondary}
/>
</Pressable>
</View>
))}
<TouchableOpacity
style={[styles.addTimeButton, { borderColor: colors.primary }]}
onPress={addTime}
>
<Ionicons name="add" size={18} color={colors.primary} />
<ThemedText style={[styles.addTimeLabel, { color: colors.primary }]}>
</ThemedText>
</TouchableOpacity>
</View>
</View>
</ScrollView>
{/* 底部保存按钮 */}
<View
style={[
styles.footerBar,
{
paddingBottom: Math.max(insets.bottom, 18),
backgroundColor: colors.pageBackgroundEmphasis,
},
]}
>
<TouchableOpacity
activeOpacity={0.9}
onPress={handleSave}
disabled={saving}
>
{isLiquidGlassAvailable() ? (
<GlassView
style={styles.saveButton}
glassEffectStyle="regular"
tintColor={`rgba(122, 90, 248, 0.8)`}
isInteractive={!saving}
>
{saving ? (
<ActivityIndicator color="#fff" size="small" />
) : (
<>
<Ionicons name="checkmark-circle" size={20} color="#fff" />
<ThemedText style={styles.saveButtonText}></ThemedText>
</>
)}
</GlassView>
) : (
<View style={[styles.saveButton, styles.fallbackSaveButton]}>
{saving ? (
<ActivityIndicator color="#fff" size="small" />
) : (
<>
<Ionicons name="checkmark-circle" size={20} color="#fff" />
<ThemedText style={styles.saveButtonText}></ThemedText>
</>
)}
</View>
)}
</TouchableOpacity>
</View>
{/* 时间选择器 Modal */}
<Modal
visible={timePickerVisible}
transparent
animationType="fade"
onRequestClose={() => {
setTimePickerVisible(false);
setEditingTimeIndex(null);
}}
>
<Pressable
style={styles.pickerBackdrop}
onPress={() => {
setTimePickerVisible(false);
setEditingTimeIndex(null);
}}
/>
<View style={[styles.pickerSheet, { backgroundColor: colors.surface }]}>
<ThemedText style={[styles.pickerTitle, { color: colors.text }]}>
{editingTimeIndex !== null ? '修改提醒时间' : '添加提醒时间'}
</ThemedText>
<DateTimePicker
value={timePickerDate}
mode="time"
display={Platform.OS === 'ios' ? 'spinner' : 'default'}
onChange={(event, date) => {
if (Platform.OS === 'ios') {
if (date) setTimePickerDate(date);
} else {
if (event.type === 'set' && date) {
confirmTime(date);
} else {
setTimePickerVisible(false);
setEditingTimeIndex(null);
}
}
}}
/>
{Platform.OS === 'ios' && (
<View style={styles.pickerActions}>
<Pressable
onPress={() => {
setTimePickerVisible(false);
setEditingTimeIndex(null);
}}
style={[styles.pickerBtn, { borderColor: colors.border }]}
>
<ThemedText style={[styles.pickerBtnText, { color: colors.textSecondary }]}>
</ThemedText>
</Pressable>
<Pressable
onPress={() => confirmTime(timePickerDate)}
style={[styles.pickerBtn, styles.pickerBtnPrimary, { backgroundColor: colors.primary }]}
>
<ThemedText style={[styles.pickerBtnText, { color: colors.onPrimary }]}>
</ThemedText>
</Pressable>
</View>
)}
</View>
</Modal>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
content: {
paddingHorizontal: 20,
gap: 32,
},
centered: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
emptyText: {
fontSize: 16,
textAlign: 'center',
},
medicationNameCard: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
borderRadius: 20,
paddingHorizontal: 18,
paddingVertical: 14,
},
medicationNameText: {
fontSize: 16,
fontWeight: '600',
},
section: {
gap: 16,
},
sectionTitle: {
fontSize: 20,
fontWeight: '700',
},
sectionDescription: {
fontSize: 14,
lineHeight: 20,
},
pickerRow: {
flexDirection: 'row',
gap: 16,
},
pickerColumn: {
flex: 1,
gap: 8,
},
pickerLabel: {
fontSize: 14,
fontWeight: '600',
textAlign: 'center',
},
picker: {
width: '100%',
height: 150,
},
pickerItem: {
fontSize: 18,
height: 150,
},
frequencySummary: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
borderRadius: 16,
paddingHorizontal: 16,
paddingVertical: 14,
},
frequencySummaryText: {
fontSize: 16,
fontWeight: '600',
},
timeList: {
gap: 12,
},
timeItem: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
borderWidth: 1,
borderRadius: 16,
paddingHorizontal: 16,
paddingVertical: 14,
},
timeValue: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
},
timeText: {
fontSize: 18,
fontWeight: '600',
},
addTimeButton: {
borderWidth: 1,
borderRadius: 16,
paddingVertical: 14,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
},
addTimeLabel: {
fontSize: 15,
fontWeight: '600',
},
footerBar: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
paddingHorizontal: 20,
paddingTop: 16,
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: 'rgba(15,23,42,0.06)',
},
saveButton: {
height: 56,
borderRadius: 24,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
overflow: 'hidden',
},
fallbackSaveButton: {
backgroundColor: '#7a5af8',
shadowColor: 'rgba(122, 90, 248, 0.4)',
shadowOffset: { width: 0, height: 10 },
shadowOpacity: 1,
shadowRadius: 20,
elevation: 6,
},
saveButtonText: {
fontSize: 17,
fontWeight: '700',
color: '#fff',
},
pickerBackdrop: {
flex: 1,
backgroundColor: 'rgba(15, 23, 42, 0.4)',
},
pickerSheet: {
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,
},
pickerTitle: {
fontSize: 20,
fontWeight: '700',
marginBottom: 16,
textAlign: 'center',
},
pickerActions: {
flexDirection: 'row',
gap: 12,
marginTop: 16,
},
pickerBtn: {
flex: 1,
paddingVertical: 14,
borderRadius: 16,
alignItems: 'center',
borderWidth: 1,
},
pickerBtnPrimary: {
borderWidth: 0,
},
pickerBtnText: {
fontSize: 16,
fontWeight: '600',
},
});

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