192 Commits

Author SHA1 Message Date
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
393 changed files with 79677 additions and 5504 deletions

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.

View File

@@ -3,17 +3,42 @@
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`
## 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.).
- **Framework**: React Native (Expo Prebuild/Ejected) with TypeScript using Expo Router for file-based navigation
- **State Management**: Redux Toolkit with domain-specific slices (`store/`) and typed hooks (`hooks/redux.ts`)
- **Authentication**: Custom auth guard system with `useAuthGuard` hook for protected navigation
- **Navigation**:
- File-based routing in `app/` directory with nested layouts
- Tab-based navigation with custom styling and haptic feedback
- Route constants defined in `constants/Routes.ts`, every page should use Routes define and jump
- **UI System**:
- Themed components (`ThemedText`, `ThemedView`) with color scheme support
- Custom icon system with `IconSymbol` component for iOS symbols
- Reusable UI components in `components/ui/`
- UI Colors in `constants/Colors.ts`
- **Data Layer**:
- API services in `services/` directory with centralized API client
- AsyncStorage for local persistence
- Background task management for sync operations
- **Native Integration**:
- Health data integration with HealthKit
- Apple Authentication
- Camera and photo library access for posture assessment
- Push notifications with background task support
- Haptic feedback integration
## Key Architecture Patterns
- **Redux Auto-sync**: Listener middleware automatically syncs checkin data changes to backend
- **Type-safe Navigation**: Uses Expo Router with TypeScript for route type safety
- **Authentication Flow**: `pushIfAuthedElseLogin` function handles auth-protected navigation
- **Theme System**: Dynamic theming with light/dark mode support and color tokens
- **Service Layer**: Centralized API client with interceptors and error handling
## Development Conventions
- Use absolute imports with `@/` prefix for all internal imports
- Follow existing Redux slice patterns for state management
- Implement auth guards using `useAuthGuard` hook for protected features
- Use themed components for consistent styling
- Follow established navigation patterns with typed routes

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
expo.edgeToEdgeEnabled=true
# 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,72 @@
{
"expo": {
"name": "digital-pilates",
"name": "Out Live",
"slug": "digital-pilates",
"version": "1.0.0",
"version": "1.0.15",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "digitalpilates",
"userInterfaceStyle": "automatic",
"userInterfaceStyle": "light",
"icon": "./assets/icon.icon/Assets/icon-1756312748268.jpg",
"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": "应用需要发送通知以提醒您喝水和站立活动。",
"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/icon.icon/Assets/icon-1756312748268.jpg",
"imageWidth": 40,
"resizeMode": "contain",
"backgroundColor": "#ffffff"
}
],
[
"react-native-health",
"expo-notifications",
{
"enableHealthAPI": true,
"healthSharePermission": "应用需要访问您的健康数据(步数与能量消耗)以展示运动统计。"
"icon": "./assets/icon.icon/Assets/icon-1756312748268.jpg",
"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,206 @@
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 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;
title: string;
};
const TAB_CONFIGS: Record<string, TabConfig> = {
statistics: { icon: 'chart.pie.fill', title: '健康' },
goals: { icon: 'flag.fill', title: '习惯' },
challenges: { icon: 'trophy.fill', title: '挑战' },
personal: { icon: 'person.fill', title: '个人' },
};
export default function TabLayout() {
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,
goals: ROUTES.TAB_GOALS,
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}
>
{tabConfig.title}
</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></Label>
<Icon sf="chart.pie.fill" drawable="custom_android_drawable" />
</NativeTabs.Trigger>
<NativeTabs.Trigger name="goals">
<Icon sf="flag.fill" drawable="custom_settings_drawable" />
<Label></Label>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="challenges">
<Icon sf="trophy.fill" drawable="custom_android_drawable" />
<Label></Label>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="personal">
<Icon sf="person.fill" drawable="custom_settings_drawable" />
<Label></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: '健康' }} />
<Tabs.Screen name="goals" options={{ title: '习惯' }} />
<Tabs.Screen name="challenges" options={{ title: '挑战' }} />
<Tabs.Screen name="personal" options={{ title: '个人' }} />
</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,466 +0,0 @@
import { AnimatedNumber } from '@/components/AnimatedNumber';
import { CircularRing } from '@/components/CircularRing';
import { ProgressBar } from '@/components/ProgressBar';
import { Colors } from '@/constants/Colors';
import { getTabBarBottomPadding } from '@/constants/TabBar';
import { getMonthDaysZh, getMonthTitleZh, getTodayIndexInMonth } from '@/utils/date';
import { ensureHealthPermissions, fetchHealthDataForDate, 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);
// 用于触发动画重置的 token当日期或数据变化时更新
const [animToken, setAnimToken] = useState(0);
const [trainingProgress, setTrainingProgress] = useState(0.8); // 暂定静态80%
const loadHealthData = async (targetDate?: Date) => {
try {
console.log('=== 开始HealthKit初始化流程 ===');
setIsLoading(true);
const ok = await ensureHealthPermissions();
if (!ok) {
const errorMsg = '无法获取健康权限请确保在真实iOS设备上运行并授权应用访问健康数据';
console.warn(errorMsg);
return;
}
console.log('权限获取成功,开始获取健康数据...');
const data = targetDate ? await fetchHealthDataForDate(targetDate) : await fetchTodayHealthData();
console.log('设置UI状态:', data);
setStepCount(data.steps);
setActiveCalories(Math.round(data.activeEnergyBurned));
setAnimToken((t) => t + 1);
console.log('=== HealthKit数据获取完成 ===');
} catch (error) {
console.error('HealthKit流程出现异常:', error);
} finally {
setIsLoading(false);
}
};
useFocusEffect(
React.useCallback(() => {
loadHealthData();
}, [])
);
// 日期点击时,加载对应日期数据
const onSelectDate = (index: number) => {
setSelectedIndex(index);
scrollToIndex(index);
const target = days[index]?.date?.toDate();
if (target) {
loadHealthData(target);
}
};
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={() => onSelectDate(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>
{/* 取消卡片内 loading保持静默刷新提升体验 */}
{/* 指标行:左大卡(训练时间),右两小卡(消耗卡路里、步数) */}
<View style={styles.metricsRow}>
<View style={[styles.trainingCard, styles.metricsLeft]}>
<Text style={styles.cardTitleSecondary}></Text>
<View style={styles.trainingContent}>
<CircularRing
size={120}
strokeWidth={12}
trackColor="#E2D9FD"
progressColor="#8B74F3"
progress={trainingProgress}
resetToken={animToken}
/>
</View>
</View>
<View style={styles.metricsRight}>
<View style={[styles.metricsRightCard, styles.caloriesCard, { minHeight: 88 }]}>
<Text style={styles.cardTitleSecondary}></Text>
{activeCalories != null ? (
<AnimatedNumber
value={activeCalories}
resetToken={animToken}
style={styles.caloriesValue}
format={(v) => `${Math.round(v)} 千卡`}
/>
) : (
<Text style={styles.caloriesValue}></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>
{stepCount != null ? (
<AnimatedNumber
value={stepCount}
resetToken={animToken}
style={styles.stepsValue}
format={(v) => `${Math.round(v)}/2000`}
/>
) : (
<Text style={styles.stepsValue}>/2000</Text>
)}
<ProgressBar
progress={Math.min(1, Math.max(0, (stepCount ?? 0) / 2000))}
height={18}
trackColor="#FFEBCB"
fillColor="#FFC365"
showLabel={false}
/>
</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,
},
});

920
app/(tabs)/goals.tsx Normal file
View File

@@ -0,0 +1,920 @@
import CelebrationAnimation, { CelebrationAnimationRef } from '@/components/CelebrationAnimation';
import GoalTemplateModal from '@/components/GoalTemplateModal';
import { GoalsPageGuide } from '@/components/GoalsPageGuide';
import { GuideTestButton } from '@/components/GuideTestButton';
import { TaskCard } from '@/components/TaskCard';
import { TaskFilterTabs, TaskFilterType } from '@/components/TaskFilterTabs';
import { TaskProgressCard } from '@/components/TaskProgressCard';
import { CreateGoalModal } from '@/components/model/CreateGoalModal';
import { useGlobalDialog } from '@/components/ui/DialogProvider';
import { Colors } from '@/constants/Colors';
import { TAB_BAR_BOTTOM_OFFSET, TAB_BAR_HEIGHT } from '@/constants/TabBar';
import { GoalTemplate } from '@/constants/goalTemplates';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useAuthGuard } from '@/hooks/useAuthGuard';
import { useColorScheme } from '@/hooks/useColorScheme';
import { clearErrors, createGoal } from '@/store/goalsSlice';
import { clearErrors as clearTaskErrors, fetchTasks, loadMoreTasks } from '@/store/tasksSlice';
import { CreateGoalRequest, TaskListItem } from '@/types/goals';
import { checkGuideCompleted, markGuideCompleted } from '@/utils/guideHelpers';
import { GoalNotificationHelpers } from '@/utils/notificationHelpers';
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
import { useFocusEffect } from '@react-navigation/native';
import dayjs from 'dayjs';
import * as Haptics from 'expo-haptics';
import { LinearGradient } from 'expo-linear-gradient';
import Lottie from 'lottie-react-native';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Alert, FlatList, Image, RefreshControl, SafeAreaView, StatusBar, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
export default function GoalsScreen() {
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme];
const dispatch = useAppDispatch();
const { pushIfAuthedElseLogin, isLoggedIn } = useAuthGuard();
const { showConfirm } = useGlobalDialog();
// Redux状态
const {
tasks,
tasksLoading,
tasksError,
tasksPagination,
completeError,
skipError,
} = useAppSelector((state) => state.tasks);
const {
createLoading,
createError
} = useAppSelector((state) => state.goals);
const userProfile = useAppSelector((state) => state.user.profile);
const [showTemplateModal, setShowTemplateModal] = useState(false);
const [showCreateModal, setShowCreateModal] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [selectedFilter, setSelectedFilter] = useState<TaskFilterType>('all');
const [modalKey, setModalKey] = useState(0); // 用于强制重新渲染弹窗
const [showGuide, setShowGuide] = useState(false); // 控制引导显示
const [selectedTemplateData, setSelectedTemplateData] = useState<Partial<CreateGoalRequest> | undefined>();
// 庆祝动画引用
const celebrationAnimationRef = useRef<CelebrationAnimationRef>(null);
// 页面聚焦时重新加载数据
useFocusEffect(
useCallback(() => {
console.log('useFocusEffect - loading tasks isLoggedIn', isLoggedIn);
if (isLoggedIn) {
loadTasks();
checkAndShowGuide();
}
}, [dispatch, isLoggedIn])
);
// 检查并显示用户引导
const checkAndShowGuide = async () => {
try {
const hasCompletedGuide = await checkGuideCompleted('GOALS_PAGE');
if (!hasCompletedGuide) {
// 延迟显示引导,确保页面完全加载
setTimeout(() => {
setShowGuide(true);
}, 1000);
}
} catch (error) {
console.error('检查引导状态失败:', error);
}
};
// 加载任务列表
const loadTasks = async () => {
try {
await dispatch(fetchTasks({
startDate: dayjs().startOf('day').toISOString(),
endDate: dayjs().endOf('day').toISOString(),
})).unwrap();
} catch (error) {
console.error('Failed to load tasks:', error);
}
};
// 下拉刷新
const onRefresh = async () => {
setRefreshing(true);
try {
if (!isLoggedIn) return
await loadTasks();
} finally {
setRefreshing(false);
}
};
// 加载更多任务
const handleLoadMoreTasks = async () => {
if (!isLoggedIn) return
if (tasksPagination.hasMore && !tasksLoading) {
try {
await dispatch(loadMoreTasks()).unwrap();
} catch (error) {
console.error('Failed to load more tasks:', error);
}
}
};
// 处理错误提示
useEffect(() => {
if (tasksError) {
Alert.alert('错误', tasksError);
dispatch(clearTaskErrors());
}
if (createError) {
Alert.alert('创建失败', createError);
dispatch(clearErrors());
}
if (completeError) {
Alert.alert('完成失败', completeError);
dispatch(clearTaskErrors());
}
if (skipError) {
Alert.alert('跳过失败', skipError);
dispatch(clearTaskErrors());
}
}, [tasksError, createError, completeError, skipError, dispatch]);
// 重置弹窗表单数据
const handleModalSuccess = () => {
// 不需要在这里改变 modalKey因为弹窗已经关闭了
// 下次打开时会自动使用新的 modalKey
setSelectedTemplateData(undefined);
};
// 处理模板选择
const handleSelectTemplate = (template: GoalTemplate) => {
setSelectedTemplateData(template.data);
setShowTemplateModal(false);
setModalKey(prev => prev + 1);
setShowCreateModal(true);
};
// 处理创建自定义目标
const handleCreateCustomGoal = () => {
setSelectedTemplateData(undefined);
setShowTemplateModal(false);
setModalKey(prev => prev + 1);
setShowCreateModal(true);
};
// 打开模板选择弹窗
const handleOpenTemplateModal = () => {
setSelectedTemplateData(undefined);
setShowTemplateModal(true);
};
// 创建目标处理函数
const handleCreateGoal = async (goalData: CreateGoalRequest) => {
try {
await dispatch(createGoal(goalData)).unwrap();
setShowCreateModal(false);
// 获取用户名
const userName = userProfile?.name || '主人';
// 创建目标成功后,设置定时推送
try {
if (goalData.hasReminder) {
const notificationIds = await GoalNotificationHelpers.scheduleGoalNotifications(
{
title: goalData.title,
repeatType: goalData.repeatType,
frequency: goalData.frequency,
hasReminder: goalData.hasReminder,
reminderTime: goalData.reminderTime,
customRepeatRule: goalData.customRepeatRule,
startTime: goalData.startTime,
},
userName
);
console.log(`目标"${goalData.title}"的定时推送已创建通知ID`, notificationIds);
}
} catch (notificationError) {
console.error('创建目标定时推送失败:', notificationError);
// 通知创建失败不影响目标创建的成功
}
// 使用确认弹窗显示成功消息
showConfirm(
{
title: '目标创建成功',
message: '恭喜!您的目标已成功创建。系统将自动生成相应的任务,帮助您实现目标。',
confirmText: '确定',
cancelText: '',
icon: 'checkmark-circle',
iconColor: '#10B981',
},
() => {
// 用户点击确定后的回调
console.log('用户确认了目标创建成功');
}
);
// 创建目标后重新加载任务列表
loadTasks();
} catch (error) {
// 错误已在useEffect中处理
}
};
// 导航到任务列表页面
const handleNavigateToTasks = () => {
pushIfAuthedElseLogin('/task-list');
};
// 计算各状态的任务数量
const taskCounts = {
all: tasks.length,
pending: tasks.filter(task => task.status === 'pending').length,
completed: tasks.filter(task => task.status === 'completed').length,
skipped: tasks.filter(task => task.status === 'skipped').length,
};
// 根据筛选条件过滤任务,并将已完成的任务放到最后
const filteredTasks = React.useMemo(() => {
let filtered: TaskListItem[] = [];
switch (selectedFilter) {
case 'pending':
filtered = tasks.filter(task => task.status === 'pending');
break;
case 'completed':
filtered = tasks.filter(task => task.status === 'completed');
break;
case 'skipped':
filtered = tasks.filter(task => task.status === 'skipped');
break;
default:
filtered = tasks;
break;
}
// 对所有筛选结果进行排序:已完成的任务放到最后
return [...filtered].sort((a, b) => {
// 如果a已完成而b未完成a排在后面
if (a.status === 'completed' && b.status !== 'completed') {
return 1;
}
// 如果b已完成而a未完成b排在后面
if (b.status === 'completed' && a.status !== 'completed') {
return -1;
}
// 如果都已完成或都未完成,保持原有顺序
return 0;
});
}, [tasks, selectedFilter]);
// 处理筛选变化
const handleFilterChange = (filter: TaskFilterType) => {
setSelectedFilter(filter);
};
// 处理引导完成
const handleGuideComplete = async () => {
try {
await markGuideCompleted('GOALS_PAGE');
setShowGuide(false);
} catch (error) {
console.error('保存引导状态失败:', error);
setShowGuide(false);
}
};
// 处理任务完成
const handleTaskCompleted = (completedTask: TaskListItem) => {
// 触发震动反馈
if (process.env.EXPO_OS === 'ios') {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
}
// 播放庆祝动画
celebrationAnimationRef.current?.play();
console.log(`任务 "${completedTask.title}" 已完成,播放庆祝动画`);
};
// 渲染任务项
const renderTaskItem = ({ item }: { item: TaskListItem }) => (
<TaskCard
task={item}
onTaskCompleted={handleTaskCompleted}
/>
);
// 渲染空状态
const renderEmptyState = () => {
// 未登录状态下的引导
if (!isLoggedIn) {
return (
<View style={styles.emptyStateLogin}>
<LinearGradient
colors={['#F0F9FF', '#FEFEFE', '#F0F9FF']}
style={styles.emptyStateLoginBackground}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
<View style={styles.emptyStateLoginContent}>
{/* 清新的图标设计 */}
<View style={styles.emptyStateLoginIconContainer}>
<LinearGradient
colors={[colorTokens.primary, '#9B8AFB']}
style={styles.emptyStateLoginIconGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<MaterialIcons name="person-outline" size={32} color="#FFFFFF" />
</LinearGradient>
</View>
{/* 主标题 */}
<Text style={[styles.emptyStateLoginTitle, { color: colorTokens.text }]}>
</Text>
{/* 副标题 */}
<Text style={[styles.emptyStateLoginSubtitle, { color: colorTokens.textSecondary }]}>
</Text>
{/* 登录按钮 */}
<TouchableOpacity
style={[styles.emptyStateLoginButton, { backgroundColor: colorTokens.primary }]}
onPress={() => pushIfAuthedElseLogin('/goals')}
>
<LinearGradient
colors={[colorTokens.primary, '#9B8AFB']}
style={styles.emptyStateLoginButtonGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<Text style={styles.emptyStateLoginButtonText}></Text>
<MaterialIcons name="arrow-forward" size={18} color="#FFFFFF" />
</LinearGradient>
</TouchableOpacity>
</View>
</View>
);
}
// 已登录但无任务的状态
let title = '暂无任务';
let subtitle = '创建目标后,系统会自动生成相应的任务';
if (selectedFilter === 'pending') {
title = '暂无待完成的任务';
subtitle = '当前没有待完成的任务';
} else if (selectedFilter === 'completed') {
title = '暂无已完成的任务';
subtitle = '完成一些任务后,它们会显示在这里';
} else if (selectedFilter === 'skipped') {
title = '暂无已跳过的任务';
subtitle = '跳过一些任务后,它们会显示在这里';
}
return (
<View style={styles.emptyState}>
<Image
source={require('@/assets/images/task/ImageEmpty.png')}
style={styles.emptyStateImage}
resizeMode="contain"
/>
<Text style={[styles.emptyStateTitle, { color: colorTokens.text }]}>
{title}
</Text>
<Text style={[styles.emptyStateSubtitle, { color: colorTokens.textSecondary }]}>
{subtitle}
</Text>
</View>
);
};
// 渲染加载更多
const renderLoadMore = () => {
if (!tasksPagination.hasMore) return null;
return (
<View style={styles.loadMoreContainer}>
<Text style={[styles.loadMoreText, { color: colorTokens.textSecondary }]}>
{tasksLoading ? '加载中...' : '上拉加载更多'}
</Text>
</View>
);
};
return (
<SafeAreaView style={styles.container}>
<StatusBar
backgroundColor="transparent"
translucent
/>
{/* 背景渐变 */}
<LinearGradient
colors={['#F0F9FF', '#E0F2FE']}
style={styles.gradientBackground}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
<View style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
backgroundColor: '#7A5AF8',
height: 233,
borderBottomLeftRadius: 24,
borderBottomRightRadius: 24,
}}>
{/* 右下角Lottie动画 */}
<Lottie
source={require('@/assets/lottie/Goal.json')}
style={styles.bottomRightImage}
autoPlay
loop
/>
</View>
<View style={styles.content}>
{/* 标题区域 */}
<View style={styles.header}>
<View>
<Text style={[styles.pageTitle, { color: '#FFFFFF' }]}>
</Text>
<Text style={[styles.pageTitle2, { color: '#FFFFFF' }]}>
</Text>
</View>
</View>
{/* 任务进度卡片 */}
<View >
<TaskProgressCard
tasks={tasks}
headerButtons={
<View style={styles.cardHeaderButtons}>
<TouchableOpacity
style={[styles.cardGoalsButton, { borderColor: colorTokens.primary }]}
onPress={handleNavigateToTasks}
>
<Text style={[styles.cardGoalsButtonText, { color: colorTokens.primary }]}>
</Text>
<MaterialIcons name="list" size={16} color={colorTokens.primary} />
</TouchableOpacity>
<TouchableOpacity
style={[styles.cardAddButton, { backgroundColor: colorTokens.primary }]}
onPress={handleOpenTemplateModal}
>
<Text style={styles.cardAddButtonText}>+</Text>
</TouchableOpacity>
</View>
}
/>
</View>
{/* 任务筛选标签 */}
<TaskFilterTabs
selectedFilter={selectedFilter}
onFilterChange={handleFilterChange}
taskCounts={taskCounts}
/>
{/* 任务列表 */}
<View style={styles.taskListContainer}>
<FlatList
data={filteredTasks}
renderItem={renderTaskItem}
keyExtractor={(item) => item.id}
contentContainerStyle={styles.taskList}
showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
colors={['#0EA5E9']}
tintColor="#0EA5E9"
/>
}
onEndReached={handleLoadMoreTasks}
onEndReachedThreshold={0.1}
ListEmptyComponent={renderEmptyState}
ListFooterComponent={renderLoadMore}
/>
</View>
{/* 目标模板选择弹窗 */}
<GoalTemplateModal
visible={showTemplateModal}
onClose={() => setShowTemplateModal(false)}
onSelectTemplate={handleSelectTemplate}
onCreateCustom={handleCreateCustomGoal}
/>
{/* 创建目标弹窗 */}
<CreateGoalModal
key={modalKey}
visible={showCreateModal}
onClose={() => {
setShowCreateModal(false);
setSelectedTemplateData(undefined);
}}
onSubmit={handleCreateGoal}
onSuccess={handleModalSuccess}
loading={createLoading}
initialData={selectedTemplateData}
/>
{/* 目标页面引导 */}
<GoalsPageGuide
visible={showGuide}
onComplete={handleGuideComplete}
tasks={tasks}
/>
{/* 开发测试按钮 */}
<GuideTestButton visible={__DEV__} />
{/* 目标通知测试按钮 */}
{__DEV__ && (
<TouchableOpacity
style={styles.testButton}
onPress={() => {
// 这里可以导航到测试页面或显示测试弹窗
Alert.alert(
'目标通知测试',
'选择要测试的通知类型',
[
{ text: '取消', style: 'cancel' },
{
text: '每日目标通知',
onPress: async () => {
try {
const userName = userProfile?.name || '';
const notificationIds = await GoalNotificationHelpers.scheduleGoalNotifications(
{
title: '每日运动目标',
repeatType: 'daily',
frequency: 1,
hasReminder: true,
reminderTime: '09:00',
},
userName
);
Alert.alert('成功', `每日目标通知已创建ID: ${notificationIds.join(', ')}`);
} catch (error) {
Alert.alert('错误', `创建通知失败: ${error}`);
}
}
},
{
text: '每周目标通知',
onPress: async () => {
try {
const userName = userProfile?.name || '';
const notificationIds = await GoalNotificationHelpers.scheduleGoalNotifications(
{
title: '每周运动目标',
repeatType: 'weekly',
frequency: 1,
hasReminder: true,
reminderTime: '10:00',
customRepeatRule: {
weekdays: [1, 3, 5], // 周一、三、五
},
},
userName
);
Alert.alert('成功', `每周目标通知已创建ID: ${notificationIds.join(', ')}`);
} catch (error) {
Alert.alert('错误', `创建通知失败: ${error}`);
}
}
},
{
text: '目标达成通知',
onPress: async () => {
try {
const userName = userProfile?.name || '';
await GoalNotificationHelpers.sendGoalAchievementNotification(userName, '每日运动目标');
Alert.alert('成功', '目标达成通知已发送');
} catch (error) {
Alert.alert('错误', `发送通知失败: ${error}`);
}
}
},
{
text: '测试庆祝动画',
onPress: () => {
celebrationAnimationRef.current?.play();
}
},
]
);
}}
>
<Text style={styles.testButtonText}></Text>
</TouchableOpacity>
)}
{/* 庆祝动画组件 */}
<CelebrationAnimation ref={celebrationAnimationRef} />
</View>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
gradientBackground: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
opacity: 0.6,
},
decorativeCircle1: {
position: 'absolute',
top: -20,
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,
},
content: {
flex: 1,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 20,
paddingTop: 20,
paddingBottom: 16,
},
headerButtons: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
goalsButton: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
paddingHorizontal: 12,
paddingVertical: 8,
borderRadius: 20,
backgroundColor: 'rgba(255, 255, 255, 0.8)',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
pageTitle: {
fontSize: 28,
fontWeight: '800',
marginBottom: 4,
},
pageTitle2: {
fontSize: 16,
fontWeight: '400',
color: '#FFFFFF',
lineHeight: 24,
},
addButton: {
width: 30,
height: 30,
borderRadius: 20,
backgroundColor: '#0EA5E9',
alignItems: 'center',
justifyContent: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
addButtonText: {
color: '#FFFFFF',
fontSize: 22,
fontWeight: '600',
lineHeight: 22,
},
taskListContainer: {
flex: 1,
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
overflow: 'hidden',
},
taskList: {
paddingHorizontal: 20,
paddingTop: 20,
paddingBottom: TAB_BAR_HEIGHT + TAB_BAR_BOTTOM_OFFSET + 20, // 避让底部导航栏 + 额外间距
},
emptyState: {
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 60,
},
emptyStateImage: {
width: 223,
height: 59,
marginBottom: 20,
},
emptyStateTitle: {
fontSize: 18,
fontWeight: '600',
marginBottom: 8,
},
emptyStateSubtitle: {
fontSize: 14,
textAlign: 'center',
lineHeight: 20,
},
// 未登录空状态样式
emptyStateLogin: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 24,
paddingVertical: 80,
position: 'relative',
},
emptyStateLoginBackground: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
borderRadius: 24,
},
emptyStateLoginContent: {
alignItems: 'center',
justifyContent: 'center',
zIndex: 1,
},
emptyStateLoginIconContainer: {
marginBottom: 24,
shadowColor: '#7A5AF8',
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.15,
shadowRadius: 16,
elevation: 8,
},
emptyStateLoginIconGradient: {
width: 80,
height: 80,
borderRadius: 40,
alignItems: 'center',
justifyContent: 'center',
},
emptyStateLoginTitle: {
fontSize: 24,
fontWeight: '700',
marginBottom: 12,
textAlign: 'center',
letterSpacing: -0.5,
},
emptyStateLoginSubtitle: {
fontSize: 16,
lineHeight: 24,
textAlign: 'center',
marginBottom: 32,
paddingHorizontal: 8,
},
emptyStateLoginButton: {
borderRadius: 28,
shadowColor: '#7A5AF8',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.2,
shadowRadius: 12,
elevation: 6,
},
emptyStateLoginButtonGradient: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 32,
paddingVertical: 16,
borderRadius: 28,
gap: 8,
},
emptyStateLoginButtonText: {
color: '#FFFFFF',
fontSize: 17,
fontWeight: '600',
letterSpacing: -0.2,
},
loadMoreContainer: {
alignItems: 'center',
paddingVertical: 20,
},
loadMoreText: {
fontSize: 14,
fontWeight: '500',
},
bottomRightImage: {
position: 'absolute',
top: 40,
right: 36,
width: 120,
height: 120,
},
// 任务进度卡片中的按钮样式
cardHeaderButtons: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
cardGoalsButton: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
paddingHorizontal: 10,
paddingVertical: 6,
borderRadius: 16,
backgroundColor: '#F3F4F6',
borderWidth: 1,
},
cardGoalsListButton: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
paddingHorizontal: 10,
paddingVertical: 6,
borderRadius: 16,
backgroundColor: '#F3F4F6',
borderWidth: 1,
},
cardGoalsButtonText: {
fontSize: 12,
fontWeight: '600',
color: '#374151',
},
cardAddButton: {
width: 28,
height: 28,
borderRadius: 14,
alignItems: 'center',
justifyContent: 'center',
},
cardAddButtonText: {
color: '#FFFFFF',
fontSize: 18,
fontWeight: '600',
lineHeight: 18,
},
testButton: {
position: 'absolute',
top: 100,
right: 20,
backgroundColor: '#10B981',
paddingHorizontal: 12,
paddingVertical: 8,
borderRadius: 8,
zIndex: 1000,
},
testButtonText: {
color: '#FFFFFF',
fontSize: 12,
fontWeight: '600',
},
});

View File

@@ -1,159 +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, { useEffect, useRef } 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();
const hasOpenedLoginRef = useRef(false);
useEffect(() => {
// 仅在本次会话首次进入首页时打开登录页,可返回关闭
if (!hasOpenedLoginRef.current) {
hasOpenedLoginRef.current = true;
router.push('/auth/login');
}
}, [router]);
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,
},
});

View File

@@ -1,330 +1,398 @@
import { Colors } from '@/constants/Colors';
import ActivityHeatMap from '@/components/ActivityHeatMap';
import { PRIVACY_POLICY_URL, USER_AGREEMENT_URL } from '@/constants/Agree';
import { ROUTES } from '@/constants/Routes';
import { getTabBarBottomPadding } from '@/constants/TabBar';
import { useColorScheme } from '@/hooks/useColorScheme';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useAuthGuard } from '@/hooks/useAuthGuard';
import { useNotifications } from '@/hooks/useNotifications';
import { DEFAULT_MEMBER_NAME, fetchActivityHistory, fetchMyProfile } from '@/store/userSlice';
import { getItem, setItem } from '@/utils/kvStore';
import { log } from '@/utils/logger';
import { getNotificationEnabled, setNotificationEnabled as saveNotificationEnabled } from '@/utils/userPreferences';
import { Ionicons } from '@expo/vector-icons';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
import { useFocusEffect } from '@react-navigation/native';
import { router } from 'expo-router';
import React, { useEffect, useMemo, useState } from 'react';
import {
Alert,
SafeAreaView,
ScrollView,
StatusBar,
StyleSheet,
Switch,
Text,
TouchableOpacity,
View
} from 'react-native';
import { isLiquidGlassAvailable } from 'expo-glass-effect';
import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { Alert, Linking, ScrollView, StatusBar, StyleSheet, Switch, Text, TouchableOpacity, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
const DEFAULT_AVATAR_URL = 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/seal-avatar/2.jpeg';
export default function PersonalScreen() {
const dispatch = useAppDispatch();
const { confirmLogout, confirmDeleteAccount, isLoggedIn, pushIfAuthedElseLogin } = useAuthGuard();
const insets = useSafeAreaInsets();
const tabBarHeight = useBottomTabBarHeight();
const isLgAvaliable = isLiquidGlassAvailable()
// 推送通知相关
const {
requestPermission,
sendNotification,
} = useNotifications();
const [notificationEnabled, setNotificationEnabled] = useState(false);
// 开发者模式相关状态
const [showDeveloperSection, setShowDeveloperSection] = useState(false);
const clickTimestamps = useRef<number[]>([]);
const clickTimeoutRef = useRef<number | null>(null);
// 计算底部间距
const bottomPadding = useMemo(() => {
// 统一的页面底部留白TabBar 高度 + TabBar 与底部的额外间距 + 安全区底部
return getTabBarBottomPadding(tabBarHeight) + (insets?.bottom ?? 0);
}, [tabBarHeight, insets?.bottom]);
const [notificationEnabled, setNotificationEnabled] = useState(true);
const colorScheme = useColorScheme();
const colors = Colors[colorScheme ?? 'light'];
return getTabBarBottomPadding(60) + (insets?.bottom ?? 0);
}, [insets?.bottom]);
type UserProfile = {
fullName?: string;
email?: string;
gender?: 'male' | 'female' | '';
age?: string;
weightKg?: number;
heightCm?: number;
avatarUri?: string | null;
};
// 直接使用 Redux 中的用户信息,避免重复状态管理
const userProfile = useAppSelector((state) => state.user.profile);
const [profile, setProfile] = useState<UserProfile>({});
const load = async () => {
// 页面聚焦时获取最新用户信息
useFocusEffect(
React.useCallback(() => {
dispatch(fetchMyProfile());
dispatch(fetchActivityHistory());
// 加载用户推送偏好设置
loadNotificationPreference();
// 加载开发者模式状态
loadDeveloperModeState();
}, [dispatch])
);
// 加载用户推送偏好设置
const loadNotificationPreference = async () => {
try {
const [p, o] = await Promise.all([
AsyncStorage.getItem('@user_profile'),
AsyncStorage.getItem('@user_personal_info'),
]);
let next: UserProfile = {};
if (o) {
try {
const parsed = JSON.parse(o);
next = {
...next,
age: parsed?.age ? String(parsed.age) : undefined,
gender: parsed?.gender || '',
heightCm: parsed?.height ? parseFloat(parsed.height) : undefined,
weightKg: parsed?.weight ? parseFloat(parsed.weight) : undefined,
};
} catch { }
}
if (p) {
try { next = { ...next, ...JSON.parse(p) }; } catch { }
}
setProfile(next);
} catch (e) {
console.warn('加载用户资料失败', e);
const enabled = await getNotificationEnabled();
setNotificationEnabled(enabled);
} catch (error) {
console.error('加载推送偏好设置失败:', error);
}
};
useEffect(() => { load(); }, []);
useFocusEffect(React.useCallback(() => { load(); return () => { }; }, []));
// 加载开发者模式状态
const loadDeveloperModeState = async () => {
try {
const enabled = await getItem('developer_mode_enabled');
if (enabled === 'true') {
setShowDeveloperSection(true);
}
} catch (error) {
console.error('加载开发者模式状态失败:', error);
}
};
// 保存开发者模式状态
const saveDeveloperModeState = async (enabled: boolean) => {
try {
await setItem('developer_mode_enabled', enabled.toString());
} catch (error) {
console.error('保存开发者模式状态失败:', error);
}
};
// 数据格式化函数
const formatHeight = () => {
if (profile.heightCm == null) return '--';
return `${Math.round(profile.heightCm)}cm`;
if (userProfile.height == null) return '--';
return `${parseFloat(userProfile.height).toFixed(1)}cm`;
};
const formatWeight = () => {
if (profile.weightKg == null) return '--';
return `${round(profile.weightKg, 1)}kg`;
if (userProfile.weight == null) return '--';
return `${parseFloat(userProfile.weight).toFixed(1)}kg`;
};
const formatAge = () => (profile.age ? `${profile.age}` : '--');
const round = (n: number, d = 0) => {
const p = Math.pow(10, d); return Math.round(n * p) / p;
const formatAge = () => {
if (!userProfile.birthDate) return '--';
const birthDate = new Date(userProfile.birthDate);
const today = new Date();
const age = today.getFullYear() - birthDate.getFullYear();
return `${age}`;
};
const handleResetOnboarding = () => {
Alert.alert(
'重置引导',
'确定要重置引导流程吗?下次启动应用时将重新显示引导页面。',
[
{
text: '取消',
style: 'cancel',
},
{
text: '确定',
style: 'destructive',
onPress: async () => {
try {
await AsyncStorage.multiRemove(['@onboarding_completed', '@user_personal_info']);
Alert.alert('成功', '引导状态已重置,请重启应用查看效果。');
} catch (error) {
console.error('重置引导状态失败:', error);
Alert.alert('错误', '重置失败,请稍后重试。');
}
},
},
]
);
// 显示名称
const displayName = (userProfile.name?.trim()) ? userProfile.name : DEFAULT_MEMBER_NAME;
// 初始化时加载推送偏好设置和开发者模式状态
useEffect(() => {
loadNotificationPreference();
loadDeveloperModeState();
}, []);
// 处理用户名连续点击
const handleUserNamePress = () => {
const now = Date.now();
clickTimestamps.current.push(now);
// 清除之前的超时
if (clickTimeoutRef.current) {
clearTimeout(clickTimeoutRef.current);
}
// 只保留最近1秒内的点击
clickTimestamps.current = clickTimestamps.current.filter(timestamp => now - timestamp <= 1000);
// 检查是否有3次连续点击
if (clickTimestamps.current.length >= 3) {
setShowDeveloperSection(true);
saveDeveloperModeState(true); // 持久化保存开发者模式状态
clickTimestamps.current = []; // 清空点击记录
log.info('开发者模式已激活');
} else {
// 1秒后清空点击记录
clickTimeoutRef.current = setTimeout(() => {
clickTimestamps.current = [];
}, 1000);
}
};
// 处理通知开关变化
const handleNotificationToggle = async (value: boolean) => {
if (value) {
try {
// 先检查系统权限
const status = await requestPermission();
if (status === 'granted') {
// 系统权限获取成功,保存用户偏好设置
await saveNotificationEnabled(true);
setNotificationEnabled(true);
const UserInfoSection = () => (
<View style={styles.userInfoCard}>
<View style={styles.userInfoContainer}>
{/* 头像 */}
// 发送测试通知
await sendNotification({
title: '通知已开启',
body: '您将收到运动提醒和重要通知',
sound: true,
priority: 'normal',
});
} else {
// 系统权限被拒绝,不更新用户偏好设置
Alert.alert(
'权限被拒绝',
'请在系统设置中开启通知权限,然后再尝试开启推送功能',
[
{ text: '取消', style: 'cancel' },
{ text: '去设置', onPress: () => Linking.openSettings() }
]
);
}
} catch (error) {
console.error('开启推送通知失败:', error);
Alert.alert('错误', '请求通知权限失败');
}
} else {
try {
// 关闭推送,保存用户偏好设置
await saveNotificationEnabled(false);
setNotificationEnabled(false);
} catch (error) {
console.error('关闭推送通知失败:', error);
Alert.alert('错误', '保存设置失败');
}
}
};
// 用户信息头部
const UserHeader = () => (
<View style={[styles.sectionContainer, {
marginBottom: 0
}]}>
<View style={[styles.userInfoContainer,]}>
<View style={styles.avatarContainer}>
<View style={styles.avatar}>
<View style={styles.avatarContent}>
{/* 简单的头像图标,您可以替换为实际图片 */}
<View style={styles.avatarIcon}>
<View style={styles.avatarFace} />
<View style={styles.avatarBody} />
</View>
</View>
</View>
<Image
source={userProfile.avatar || DEFAULT_AVATAR_URL}
style={styles.avatar}
contentFit="cover"
transition={200}
cachePolicy="memory-disk"
/>
</View>
{/* 用户信息 */}
<View style={styles.userDetails}>
<Text style={styles.userName}>{profile.fullName || '未设置姓名'}</Text>
<Text style={styles.userProgram}></Text>
</View>
{/* 编辑按钮 */}
<TouchableOpacity style={dynamicStyles.editButton} onPress={() => router.push('/profile/edit')}>
<Text style={dynamicStyles.editButtonText}></Text>
</TouchableOpacity>
</View>
</View>
);
const StatsSection = () => (
<View style={styles.statsContainer}>
<View style={styles.statItem}>
<Text style={dynamicStyles.statValue}>{formatHeight()}</Text>
<Text style={styles.statLabel}></Text>
</View>
<View style={styles.statItem}>
<Text style={dynamicStyles.statValue}>{formatWeight()}</Text>
<Text style={styles.statLabel}></Text>
</View>
<View style={styles.statItem}>
<Text style={dynamicStyles.statValue}>{formatAge()}</Text>
<Text style={styles.statLabel}></Text>
</View>
</View>
);
const MenuSection = ({ title, items }: { title: string; items: any[] }) => (
<View style={styles.menuSection}>
<Text style={styles.sectionTitle}>{title}</Text>
{items.map((item, index) => (
<TouchableOpacity
key={index}
style={styles.menuItem}
onPress={item.onPress}
>
<View style={styles.menuItemLeft}>
<View style={[styles.menuIcon]}>
<Ionicons name={item.icon} size={20} color={item.iconColor || colors.primary} />
</View>
<Text style={styles.menuItemText}>{item.title}</Text>
</View>
{item.type === 'switch' ? (
<Switch
value={notificationEnabled}
onValueChange={setNotificationEnabled}
trackColor={{ false: '#E5E5E5', true: colors.primary }}
thumbColor="#FFFFFF"
style={styles.switch}
/>
) : (
<Ionicons name="chevron-forward" size={20} color="#C4C4C4" />
<TouchableOpacity onPress={handleUserNamePress} activeOpacity={0.7}>
<Text style={styles.userName}>{displayName}</Text>
</TouchableOpacity>
{userProfile.memberNumber && (
<Text style={styles.userMemberNumber}>: {userProfile.memberNumber}</Text>
)}
</View>
<TouchableOpacity style={styles.editButton} onPress={() => pushIfAuthedElseLogin('/profile/edit')}>
<Text style={styles.editButtonText}>{isLoggedIn ? '编辑' : '登录'}</Text>
</TouchableOpacity>
))}
</View>
</View>
);
// 动态创建样式
const dynamicStyles = {
editButton: {
backgroundColor: colors.primary,
paddingHorizontal: 20,
paddingVertical: 10,
borderRadius: 20,
},
editButtonText: {
color: '#192126',
fontSize: 14,
fontWeight: '600' as const,
},
statValue: {
fontSize: 18,
fontWeight: 'bold' as const,
color: colors.primary,
marginBottom: 4,
},
floatingButton: {
width: 56,
height: 56,
borderRadius: 28,
backgroundColor: colors.primary,
alignItems: 'center' as const,
justifyContent: 'center' as const,
shadowColor: colors.primary,
shadowOffset: {
width: 0,
height: 4,
},
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 8,
},
};
// 数据统计部分
const StatsSection = () => (
<View style={styles.sectionContainer}>
<View style={[styles.cardContainer, {
backgroundColor: 'unset'
}]}>
<View style={styles.statsContainer}>
<View style={styles.statItem}>
<Text style={styles.statValue}>{formatHeight()}</Text>
<Text style={styles.statLabel}></Text>
</View>
<View style={styles.statItem}>
<Text style={styles.statValue}>{formatWeight()}</Text>
<Text style={styles.statLabel}></Text>
</View>
<View style={styles.statItem}>
<Text style={styles.statValue}>{formatAge()}</Text>
<Text style={styles.statLabel}></Text>
</View>
</View>
</View>
</View>
);
const accountItems = [
{
icon: 'person-outline',
iconBg: '#E8F5E8',
iconColor: '#4ADE80',
title: '个人资料',
onPress: () => router.push('/profile/edit'),
},
{
icon: 'trophy-outline',
iconBg: '#E8F5E8',
iconColor: '#4ADE80',
title: '成就',
},
{
icon: 'time-outline',
iconBg: '#E8F5E8',
iconColor: '#4ADE80',
title: '活动历史',
},
{
icon: 'stats-chart-outline',
iconBg: '#E8F5E8',
iconColor: '#4ADE80',
title: '训练进度',
},
];
// 菜单项组件
const MenuSection = ({ title, items }: { title: string; items: any[] }) => (
<View style={styles.sectionContainer}>
<Text style={styles.sectionTitle}>{title}</Text>
<View style={styles.cardContainer}>
{items.map((item, index) => (
<TouchableOpacity
key={index}
style={[styles.menuItem, index === items.length - 1 && { borderBottomWidth: 0 }]}
onPress={item.type === 'switch' ? undefined : item.onPress}
disabled={item.type === 'switch'}
>
<View style={styles.menuItemLeft}>
<View style={[
styles.iconContainer,
{ backgroundColor: item.isDanger ? 'rgba(255,68,68,0.1)' : 'rgba(147, 112, 219, 0.1)' }
]}>
<Ionicons
name={item.icon}
size={20}
color={item.isDanger ? '#FF4444' : '#9370DB'}
/>
</View>
<Text style={styles.menuItemText}>{item.title}</Text>
</View>
{item.type === 'switch' ? (
<Switch
value={item.switchValue || false}
onValueChange={item.onSwitchChange || (() => { })}
trackColor={{ false: '#E5E5E5', true: '#9370DB' }}
thumbColor="#FFFFFF"
style={styles.switch}
/>
) : (
<Ionicons name="chevron-forward" size={20} color="#CCCCCC" />
)}
</TouchableOpacity>
))}
</View>
</View>
);
const notificationItems = [
// 菜单项配置
const menuSections = [
{
icon: 'notifications-outline',
iconBg: '#E8F5E8',
iconColor: '#4ADE80',
title: '弹窗通知',
type: 'switch',
title: '通知',
items: [
{
icon: 'notifications-outline' as const,
title: '消息推送',
type: 'switch' as const,
switchValue: notificationEnabled,
onSwitchChange: handleNotificationToggle,
},
],
},
];
const otherItems = [
// 开发者section需要连续点击三次用户名激活
...(showDeveloperSection ? [{
title: '开发者',
items: [
{
icon: 'code-slash-outline' as const,
title: '开发者选项',
onPress: () => pushIfAuthedElseLogin(ROUTES.DEVELOPER),
},
],
}] : []),
{
icon: 'mail-outline',
iconBg: '#E8F5E8',
iconColor: '#4ADE80',
title: '联系我们',
},
{
icon: 'shield-checkmark-outline',
iconBg: '#E8F5E8',
iconColor: '#4ADE80',
title: '隐私政策',
},
{
icon: 'settings-outline',
iconBg: '#E8F5E8',
iconColor: '#4ADE80',
title: '设置',
},
];
const developerItems = [
{
icon: 'refresh-outline',
iconBg: '#FFE8E8',
iconColor: '#FF4444',
title: '重置引导流程',
onPress: handleResetOnboarding,
title: '其他',
items: [
{
icon: 'shield-checkmark-outline' as const,
title: '隐私政策',
onPress: () => Linking.openURL(PRIVACY_POLICY_URL),
},
{
icon: 'document-text-outline' as const,
title: '用户协议',
onPress: () => Linking.openURL(USER_AGREEMENT_URL),
},
],
},
// 只有登录用户才显示账号与安全菜单
...(isLoggedIn ? [{
title: '账号与安全',
items: [
{
icon: 'log-out-outline' as const,
title: '退出登录',
onPress: confirmLogout,
isDanger: false,
},
{
icon: 'trash-outline' as const,
title: '注销帐号',
onPress: confirmDeleteAccount,
isDanger: true,
},
],
}] : []),
];
return (
<View style={styles.container}>
<StatusBar barStyle="dark-content" backgroundColor="transparent" translucent />
<SafeAreaView style={styles.safeArea}>
<ScrollView
style={styles.scrollView}
contentContainerStyle={{ paddingBottom: bottomPadding }}
showsVerticalScrollIndicator={false}
>
<UserInfoSection />
<StatsSection />
<MenuSection title="账户" items={accountItems} />
<MenuSection title="通知" items={notificationItems} />
<MenuSection title="其他" items={otherItems} />
<MenuSection title="开发者" items={developerItems} />
<StatusBar barStyle={'dark-content'} backgroundColor="transparent" translucent />
{/* 底部浮动按钮 */}
<View style={[styles.floatingButtonContainer, { bottom: Math.max(30, tabBarHeight / 2) + (insets?.bottom ?? 0) }]}>
<TouchableOpacity style={dynamicStyles.floatingButton}>
<Ionicons name="search" size={24} color="#192126" />
</TouchableOpacity>
</View>
</ScrollView>
</SafeAreaView>
{/* 背景渐变 */}
<LinearGradient
colors={['#f5e5fbff', '#e5fcfeff', '#eefdffff', '#e6f6fcff']}
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: bottomPadding,
paddingHorizontal: 16,
}}
showsVerticalScrollIndicator={false}
>
<UserHeader />
<StatsSection />
<View style={styles.fishRecordContainer}>
{/* <Image
source={{ uri: 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/icons/icon-profile-fish.png' }}
contentFit="cover"
style={{ width: 16, height: 16, marginLeft: 6 }}
transition={200}
cachePolicy="memory-disk"
/> */}
<Text style={styles.fishRecordText}></Text>
</View>
<ActivityHeatMap />
{menuSections.map((section, index) => (
<MenuSection key={index} title={section.title} items={section.items} />
))}
</ScrollView>
</View>
);
}
@@ -332,60 +400,74 @@ export default function PersonalScreen() {
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#F5F5F5', // 浅灰色背景
},
safeArea: {
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,
paddingHorizontal: 20,
backgroundColor: '#F5F5F5',
},
// 用户信息区域
userInfoCard: {
borderRadius: 16,
// 部分容器
sectionContainer: {
marginBottom: 20,
},
sectionTitle: {
fontSize: 16,
fontWeight: 'bold',
color: '#2C3E50',
marginBottom: 10,
paddingHorizontal: 4,
},
// 卡片容器
cardContainer: {
backgroundColor: '#FFFFFF',
borderRadius: 12,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 4,
elevation: 2,
overflow: 'hidden',
},
// 用户信息区域
userInfoContainer: {
flexDirection: 'row',
alignItems: 'center',
padding: 20,
padding: 16,
},
avatarContainer: {
marginRight: 15,
marginRight: 12,
},
avatar: {
width: 80,
height: 80,
borderRadius: 40,
backgroundColor: '#E8D4F0',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
},
avatarContent: {
width: '100%',
height: '100%',
alignItems: 'center',
justifyContent: 'center',
},
avatarIcon: {
alignItems: 'center',
justifyContent: 'center',
},
avatarFace: {
width: 25,
height: 25,
borderRadius: 12.5,
backgroundColor: '#D4A574',
marginBottom: 5,
},
avatarBody: {
width: 30,
height: 20,
borderRadius: 15,
backgroundColor: '#F4C842',
width: 60,
height: 60,
borderRadius: 30,
borderWidth: 2,
borderColor: '#9370DB',
},
userDetails: {
flex: 1,
@@ -393,91 +475,93 @@ const styles = StyleSheet.create({
userName: {
fontSize: 18,
fontWeight: 'bold',
color: '#000',
color: '#2C3E50',
marginBottom: 4,
},
userProgram: {
userRole: {
fontSize: 14,
color: '#888',
color: '#9370DB',
fontWeight: '500',
},
// 统计信息区域
userMemberNumber: {
fontSize: 10,
color: '#6C757D',
marginTop: 4,
},
editButton: {
backgroundColor: '#9370DB',
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 16,
},
editButtonText: {
color: '#FFFFFF',
fontSize: 14,
fontWeight: '600',
},
// 数据统计
statsContainer: {
flexDirection: 'row',
justifyContent: 'space-between',
backgroundColor: '#FFFFFF',
borderRadius: 16,
padding: 20,
marginBottom: 20,
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
padding: 16,
},
statItem: {
alignItems: 'center',
flex: 1,
},
statLabel: {
fontSize: 12,
color: '#888',
},
// 菜单区域
menuSection: {
marginBottom: 20,
backgroundColor: '#FFFFFF',
padding: 16,
},
sectionTitle: {
statValue: {
fontSize: 18,
fontWeight: 'bold',
color: '#000',
marginBottom: 12,
paddingHorizontal: 4,
color: '#9370DB',
marginBottom: 4,
},
statLabel: {
fontSize: 12,
color: '#6C757D',
fontWeight: '500',
},
// 菜单项
menuItem: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 16,
paddingVertical: 14,
paddingHorizontal: 16,
borderRadius: 12,
marginBottom: 8,
borderBottomWidth: 1,
borderBottomColor: '#F1F3F4',
},
menuItemLeft: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
},
menuIcon: {
width: 36,
height: 36,
borderRadius: 8,
iconContainer: {
width: 32,
height: 32,
borderRadius: 6,
alignItems: 'center',
justifyContent: 'center',
marginRight: 12,
},
menuItemText: {
fontSize: 16,
color: '#000',
flex: 1,
fontSize: 15,
color: '#2C3E50',
fontWeight: '500',
},
switch: {
transform: [{ scaleX: 0.8 }, { scaleY: 0.8 }],
},
// 浮动按钮
floatingButtonContainer: {
position: 'absolute',
bottom: 30,
left: 0,
right: 0,
fishRecordContainer: {
flexDirection: 'row',
alignItems: 'center',
pointerEvents: 'box-none',
justifyContent: 'flex-start',
marginBottom: 10,
},
fishRecordText: {
fontSize: 16,
fontWeight: 'bold',
color: '#2C3E50',
marginLeft: 4,
},
});

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

@@ -0,0 +1,855 @@
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 { Colors } from '@/constants/Colors';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useAuthGuard } from '@/hooks/useAuthGuard';
import { BackgroundTaskManager } from '@/services/backgroundTaskManager';
import { setHealthData } from '@/store/healthSlice';
import { fetchDailyMoodCheckins, selectLatestMoodRecordByDate } from '@/store/moodSlice';
import { fetchTodayWaterStats } from '@/store/waterSlice';
import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date';
import { fetchHealthDataForDate, testHRVDataFetch } from '@/utils/health';
import dayjs from 'dayjs';
import { LinearGradient } from 'expo-linear-gradient';
import { debounce } from 'lodash';
import React, { useEffect, useMemo, useRef, useState } from 'react';
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 stepGoal = useAppSelector((s) => s.user.profile?.dailyStepsGoal) ?? 2000;
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]);
useEffect(() => {
loadAllData(currentSelectedDate);
}, [])
// 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', '#e5fcfeff', '#eefdffff', '#e6f6fcff']}
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}>Out Live</Text>
</View>
{/* 开发环境调试按钮 */}
{__DEV__ && (
<View style={styles.debugButtonsContainer}>
<TouchableOpacity
style={styles.debugButton}
onPress={async () => {
console.log('🔧 手动触发后台任务测试...');
await BackgroundTaskManager.getInstance().triggerTaskForTesting();
}}
>
<Text style={styles.debugButtonText}>🔧</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.debugButton, styles.hrvTestButton]}
onPress={async () => {
console.log('🫀 测试HRV数据获取...');
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}
/>
<WeightHistoryCard />
{/* 真正瀑布流布局 */}
<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>
{/* 围度数据卡片 - 占满底部一行 */}
<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,
},
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,
},
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
},
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: 10,
},
});

View File

@@ -1,13 +1,180 @@
import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native';
import { DefaultTheme, ThemeProvider } from '@react-navigation/native';
import { useFonts } from 'expo-font';
import { Stack } 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 { clearAiCoachSessionCache } from '@/services/aiCoachSession';
import { notificationService } from '@/services/notifications';
import { setupQuickActions } from '@/services/quickActions';
import { initializeWaterRecordBridge } from '@/services/waterRecordBridge';
import { WaterRecordSource } from '@/services/waterRecords';
import { store } from '@/store';
import { fetchMyProfile, setPrivacyAgreed } from '@/store/userSlice';
import { createWaterRecordAction } from '@/store/waterSlice';
import { ensureHealthPermissions, initializeHealthPermissions } from '@/utils/health';
import { DailySummaryNotificationHelpers, MoodNotificationHelpers, NutritionNotificationHelpers } from '@/utils/notificationHelpers';
import { clearPendingWaterRecords, syncPendingWidgetChanges } from '@/utils/widgetDataSync';
import React, { useEffect } from 'react';
import { DialogProvider } from '@/components/ui/DialogProvider';
import { ToastProvider } from '@/contexts/ToastContext';
import { useAuthGuard } from '@/hooks/useAuthGuard';
import { STORAGE_KEYS } from '@/services/api';
import { BackgroundTaskManager } from '@/services/backgroundTaskManager';
import { fetchChallenges } from '@/store/challengesSlice';
import AsyncStorage from '@/utils/kvStore';
import { Provider } from 'react-redux';
function Bootstrapper({ children }: { children: React.ReactNode }) {
const dispatch = useAppDispatch();
const { profile } = useAppSelector((state) => state.user);
const [showPrivacyModal, setShowPrivacyModal] = React.useState(false);
const { isLoggedIn } = useAuthGuard()
// 初始化快捷动作处理
useQuickActions();
useEffect(() => {
if (isLoggedIn) {
dispatch(fetchChallenges());
}
}, [isLoggedIn]);
React.useEffect(() => {
const loadUserData = async () => {
// 数据已经在启动界面预加载,这里只需要快速同步到 Redux 状态
await dispatch(fetchMyProfile());
};
const initHealthPermissions = async () => {
// 初始化 HealthKit 权限管理系统
try {
console.log('初始化 HealthKit 权限管理系统...');
initializeHealthPermissions();
// 延迟请求权限,避免应用启动时弹窗
setTimeout(async () => {
try {
await ensureHealthPermissions();
console.log('HealthKit 权限请求完成');
} catch (error) {
console.warn('HealthKit 权限请求失败,可能在模拟器上运行:', error);
}
}, 2000);
console.log('HealthKit 权限管理初始化完成');
} catch (error) {
console.warn('HealthKit 权限管理初始化失败:', error);
}
}
const initializeNotifications = async () => {
try {
await BackgroundTaskManager.getInstance().initialize();
// 初始化通知服务
await notificationService.initialize();
console.log('通知服务初始化成功');
// 注册午餐提醒12:00
await NutritionNotificationHelpers.scheduleDailyLunchReminder(profile.name || '');
console.log('午餐提醒已注册');
// 注册晚餐提醒18:00
await NutritionNotificationHelpers.scheduleDailyDinnerReminder(profile.name || '');
console.log('晚餐提醒已注册');
// 注册心情提醒21:00
await MoodNotificationHelpers.scheduleDailyMoodReminder(profile.name || '');
console.log('心情提醒已注册');
await DailySummaryNotificationHelpers.scheduleDailySummaryNotification(profile.name || '')
// 初始化快捷动作
await setupQuickActions();
console.log('快捷动作初始化成功');
// 初始化喝水记录 bridge
initializeWaterRecordBridge();
console.log('喝水记录 Bridge 初始化成功');
// 检查并同步Widget数据更改
const widgetSync = await syncPendingWidgetChanges();
if (widgetSync.hasPendingChanges && widgetSync.pendingRecords) {
console.log(`检测到 ${widgetSync.pendingRecords.length} 条待同步的水记录`);
// 将待同步的记录添加到 Redux store
for (const record of widgetSync.pendingRecords) {
try {
await store.dispatch(createWaterRecordAction({
amount: record.amount,
recordedAt: record.recordedAt,
source: WaterRecordSource.Auto, // 标记为自动添加来自Widget
})).unwrap();
console.log(`成功同步水记录: ${record.amount}ml at ${record.recordedAt}`);
} catch (error) {
console.error('同步水记录失败:', error);
}
}
// 清除已同步的记录
await clearPendingWaterRecords();
console.log('所有待同步的水记录已处理完成');
}
} catch (error) {
console.error('通知服务、后台任务管理器或快捷动作初始化失败:', error);
}
};
loadUserData();
initHealthPermissions();
initializeNotifications();
// 冷启动时清空 AI 教练会话缓存
clearAiCoachSessionCache();
}, [dispatch]);
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>
{children}
<PrivacyConsentModal
visible={showPrivacyModal}
onAgree={handlePrivacyAgree}
onDisagree={handlePrivacyDisagree}
/>
</DialogProvider>
);
}
export default function RootLayout() {
const colorScheme = useColorScheme();
const [loaded] = useFonts({
SpaceMono: require('../assets/fonts/SpaceMono-Regular.ttf'),
});
@@ -18,18 +185,34 @@ export default function RootLayout() {
}
return (
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="onboarding" />
<Stack.Screen name="(tabs)" />
<Stack.Screen name="profile/edit" />
<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="+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="(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="profile/goals" options={{ headerShown: false }} />
<Stack.Screen name="goals-list" 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="+not-found" />
</Stack>
<StatusBar style="dark" />
</ThemeProvider>
</ToastProvider>
</Bootstrapper>
</Provider>
</GestureHandlerRootView>
);
}

View File

@@ -4,6 +4,7 @@ import * as ImagePicker from 'expo-image-picker';
import { useRouter } from 'expo-router';
import React, { useEffect, useMemo, useState } from 'react';
import {
ActivityIndicator,
Alert,
Image,
Linking,
@@ -12,11 +13,14 @@ import {
StyleSheet,
Text,
TouchableOpacity,
View,
View
} from 'react-native';
import ImageViewing from 'react-native-image-viewing';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { HeaderBar } from '@/components/ui/HeaderBar';
import { Colors } from '@/constants/Colors';
import { useCosUpload } from '@/hooks/useCosUpload';
type PoseView = 'front' | 'side' | 'back';
@@ -30,17 +34,17 @@ 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://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/imagedemo.jpeg', 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://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/imagedemo.jpeg', 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://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/imagedemo.jpeg', 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 },
],
@@ -49,7 +53,7 @@ const SAMPLES: Record<PoseView, Sample[]> = {
export default function AIPostureAssessmentScreen() {
const router = useRouter();
const insets = useSafeAreaInsets();
const theme = Colors.dark;
const theme = Colors.light;
const [uploadState, setUploadState] = useState<UploadState>({});
const canStart = useMemo(
@@ -57,6 +61,9 @@ export default function AIPostureAssessmentScreen() {
[uploadState]
);
const { upload, uploading } = useCosUpload();
const [uploadingKey, setUploadingKey] = useState<PoseView | null>(null);
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);
@@ -127,7 +134,25 @@ export default function AIPostureAssessmentScreen() {
aspect: [3, 4],
});
if (!result.canceled) {
setUploadState((s) => ({ ...s, [key]: result.assets[0]?.uri ?? null }));
// 设置正在上传状态
setUploadingKey(key);
try {
// 上传到 COS
const { url } = await upload(
{ uri: result.assets[0]?.uri ?? '', name: `posture-${key}.jpg`, type: 'image/jpeg' },
{ prefix: 'posture-assessment/' }
);
// 上传成功,更新状态
setUploadState((s) => ({ ...s, [key]: url }));
} catch (uploadError) {
console.warn('上传图片失败', uploadError);
Alert.alert('上传失败', '图片上传失败,请重试');
// 上传失败,清除状态
setUploadState((s) => ({ ...s, [key]: null }));
} finally {
// 清除上传状态
setUploadingKey(null);
}
}
} else {
const resp = await ImagePicker.requestMediaLibraryPermissionsAsync();
@@ -156,7 +181,25 @@ export default function AIPostureAssessmentScreen() {
aspect: [3, 4],
});
if (!result.canceled) {
setUploadState((s) => ({ ...s, [key]: result.assets[0]?.uri ?? null }));
// 设置正在上传状态
setUploadingKey(key);
try {
// 上传到 COS
const { url } = await upload(
{ uri: result.assets[0]?.uri ?? '', name: `posture-${key}.jpg`, type: 'image/jpeg' },
{ prefix: 'posture-assessment/' }
);
// 上传成功,更新状态
setUploadState((s) => ({ ...s, [key]: url }));
} catch (uploadError) {
console.warn('上传图片失败', uploadError);
Alert.alert('上传失败', '图片上传失败,请重试');
// 上传失败,清除状态
setUploadState((s) => ({ ...s, [key]: null }));
} finally {
// 清除上传状态
setUploadingKey(null);
}
}
}
} catch (e) {
@@ -166,24 +209,13 @@ export default function AIPostureAssessmentScreen() {
function handleStart() {
if (!canStart) return;
// TODO: 调用后端或进入分析页面
Alert.alert('开始测评', '已收集三视角照片准备开始AI体态分析');
// 进入评估中间页面
router.push('/ai-posture-processing');
}
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>
<View style={[styles.screen, { backgroundColor: Colors.light.pageBackgroundEmphasis }]}>
<HeaderBar title="AI体态测评" onBack={() => router.back()} tone="light" transparent />
<ScrollView
contentContainerStyle={{ paddingBottom: insets.bottom + 120 }}
@@ -217,10 +249,8 @@ export default function AIPostureAssessmentScreen() {
{/* Intro */}
<View style={styles.introBox}>
<Text style={styles.title}>姿</Text>
<Text style={styles.description}>
线
</Text>
<Text style={[styles.title, { color: '#192126' }]}>姿</Text>
<Text style={[styles.description, { color: '#5E6468' }]}>线</Text>
</View>
{/* Upload sections */}
@@ -230,6 +260,7 @@ export default function AIPostureAssessmentScreen() {
onPickCamera={() => requestPermissionAndPick('camera', 'front')}
onPickLibrary={() => requestPermissionAndPick('library', 'front')}
samples={SAMPLES.front}
uploading={uploading && uploadingKey === 'front'}
/>
<UploadTile
@@ -238,6 +269,7 @@ export default function AIPostureAssessmentScreen() {
onPickCamera={() => requestPermissionAndPick('camera', 'side')}
onPickLibrary={() => requestPermissionAndPick('library', 'side')}
samples={SAMPLES.side}
uploading={uploading && uploadingKey === 'side'}
/>
<UploadTile
@@ -246,6 +278,7 @@ export default function AIPostureAssessmentScreen() {
onPickCamera={() => requestPermissionAndPick('camera', 'back')}
onPickLibrary={() => requestPermissionAndPick('library', 'back')}
samples={SAMPLES.back}
uploading={uploading && uploadingKey === 'back'}
/>
</ScrollView>
@@ -275,13 +308,19 @@ function UploadTile({
onPickCamera,
onPickLibrary,
samples,
uploading,
}: {
label: string;
value?: string | null;
onPickCamera: () => void;
onPickLibrary: () => void;
samples: Sample[];
uploading?: boolean;
}) {
const [viewerVisible, setViewerVisible] = React.useState(false);
const [viewerIndex, setViewerIndex] = React.useState(0);
const imagesForViewer = React.useMemo(() => samples.map((s) => ({ uri: s.uri })), [samples]);
return (
<View style={styles.section}>
<View style={styles.sectionHeader}>
@@ -298,13 +337,19 @@ function UploadTile({
onLongPress={onPickLibrary}
onPress={onPickCamera}
style={styles.uploader}
disabled={uploading}
>
{value ? (
{uploading ? (
<View style={[styles.placeholder, { backgroundColor: '#f5f5f5' }]}>
<ActivityIndicator size="large" color={Colors.light.accentGreen} />
<Text style={styles.placeholderTitle}>...</Text>
</View>
) : value ? (
<Image source={{ uri: value }} style={styles.preview} />
) : (
<View style={styles.placeholder}>
<View style={styles.plusBadge}>
<Ionicons name="camera" size={16} color="#192126" />
<Ionicons name="camera" size={16} color={Colors.light.accentGreen} />
</View>
<Text style={styles.placeholderTitle}></Text>
<Text style={styles.placeholderDesc}></Text>
@@ -312,19 +357,27 @@ function UploadTile({
)}
</TouchableOpacity>
<BlurView intensity={18} tint="dark" style={styles.sampleBox}>
<BlurView intensity={12} tint="light" 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' }]}>
<TouchableOpacity activeOpacity={0.9} onPress={() => { setViewerIndex(idx); setViewerVisible(true); }}>
<Image source={{ uri: s.uri }} style={styles.sampleImg} />
</TouchableOpacity>
<View style={[styles.sampleTag, { backgroundColor: s.correct ? '#2BCC7F' : 'rgba(25,33,38,0.08)' }]}>
<Text style={styles.sampleTagText}>{s.correct ? '正确示范' : '错误示范'}</Text>
</View>
</View>
))}
</View>
</BlurView>
<ImageViewing
images={imagesForViewer}
imageIndex={viewerIndex}
visible={viewerVisible}
onRequestClose={() => setViewerVisible(false)}
/>
</View>
);
}
@@ -338,15 +391,15 @@ const styles = StyleSheet.create({
marginHorizontal: 16,
padding: 14,
borderRadius: 16,
backgroundColor: 'rgba(255,255,255,0.04)'
backgroundColor: 'rgba(25,33,38,0.06)'
},
permTitle: {
color: '#ECEDEE',
color: '#192126',
fontSize: 16,
fontWeight: '700',
},
permDesc: {
color: 'rgba(255,255,255,0.75)',
color: '#5E6468',
marginTop: 6,
fontSize: 13,
},
@@ -362,7 +415,7 @@ const styles = StyleSheet.create({
paddingHorizontal: 14,
height: 40,
borderRadius: 12,
backgroundColor: '#BBF246',
backgroundColor: Colors.light.accentGreen,
},
permPrimaryText: {
color: '#192126',
@@ -377,10 +430,10 @@ const styles = StyleSheet.create({
height: 40,
borderRadius: 12,
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.18)',
borderColor: 'rgba(25,33,38,0.14)',
},
permSecondaryText: {
color: 'rgba(255,255,255,0.85)',
color: '#384046',
fontSize: 14,
fontWeight: '700',
},
@@ -430,12 +483,12 @@ const styles = StyleSheet.create({
justifyContent: 'space-between',
},
sectionTitle: {
color: '#ECEDEE',
color: '#192126',
fontSize: 18,
fontWeight: '700',
},
retakeHint: {
color: 'rgba(255,255,255,0.55)',
color: '#888F92',
fontSize: 13,
},
uploader: {
@@ -443,8 +496,8 @@ const styles = StyleSheet.create({
borderRadius: 18,
borderWidth: 1,
borderStyle: 'dashed',
borderColor: 'rgba(255,255,255,0.18)',
backgroundColor: '#1E262C',
borderColor: 'rgba(25,33,38,0.14)',
backgroundColor: '#FFFFFF',
overflow: 'hidden',
},
preview: {
@@ -463,25 +516,27 @@ const styles = StyleSheet.create({
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#BBF246',
backgroundColor: '#FFFFFF',
borderWidth: 2,
borderColor: Colors.light.accentGreen,
},
placeholderTitle: {
color: '#ECEDEE',
color: '#192126',
fontSize: 16,
fontWeight: '700',
},
placeholderDesc: {
color: 'rgba(255,255,255,0.65)',
color: '#888F92',
fontSize: 12,
},
sampleBox: {
marginTop: 8,
borderRadius: 16,
padding: 12,
backgroundColor: 'rgba(255,255,255,0.04)',
backgroundColor: 'rgba(255,255,255,0.72)',
},
sampleTitle: {
color: 'rgba(255,255,255,0.8)',
color: '#192126',
fontSize: 14,
marginBottom: 8,
fontWeight: '600',
@@ -497,7 +552,7 @@ const styles = StyleSheet.create({
width: '100%',
height: 90,
borderRadius: 12,
backgroundColor: '#111',
backgroundColor: '#F2F4F5',
},
sampleTag: {
alignSelf: 'flex-start',

View File

@@ -0,0 +1,279 @@
import { Ionicons } from '@expo/vector-icons';
import { BlurView } from 'expo-blur';
import { LinearGradient } from 'expo-linear-gradient';
import { useRouter } from 'expo-router';
import React, { useEffect } from 'react';
import { Dimensions, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import Animated, { Easing, useAnimatedStyle, useSharedValue, withDelay, withRepeat, withSequence, withTiming } from 'react-native-reanimated';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { HeaderBar } from '@/components/ui/HeaderBar';
import { Colors } from '@/constants/Colors';
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window');
export default function AIPostureProcessingScreen() {
const insets = useSafeAreaInsets();
const router = useRouter();
const theme = Colors.dark;
// Core looping animations
const spin = useSharedValue(0);
const pulse = useSharedValue(0);
const scanY = useSharedValue(0);
const particle = useSharedValue(0);
useEffect(() => {
spin.value = withRepeat(withTiming(1, { duration: 6000, easing: Easing.linear }), -1);
pulse.value = withRepeat(withSequence(
withTiming(1, { duration: 1600, easing: Easing.inOut(Easing.quad) }),
withTiming(0, { duration: 1600, easing: Easing.inOut(Easing.quad) })
), -1, true);
scanY.value = withRepeat(withTiming(1, { duration: 3800, easing: Easing.inOut(Easing.cubic) }), -1, false);
particle.value = withDelay(400, withRepeat(withTiming(1, { duration: 5200, easing: Easing.inOut(Easing.quad) }), -1, true));
}, []);
const ringStyleOuter = useAnimatedStyle(() => ({
transform: [{ rotate: `${spin.value * 360}deg` }],
opacity: 0.8,
}));
const ringStyleInner = useAnimatedStyle(() => ({
transform: [{ rotate: `${-spin.value * 360}deg` }, { scale: 0.98 + pulse.value * 0.04 }],
}));
const scannerStyle = useAnimatedStyle(() => ({
transform: [{ translateY: (scanY.value * (SCREEN_HEIGHT * 0.45)) - (SCREEN_HEIGHT * 0.225) }],
opacity: 0.6 + Math.sin(scanY.value * Math.PI) * 0.2,
}));
const particleStyleA = useAnimatedStyle(() => ({
transform: [
{ translateX: Math.sin(particle.value * Math.PI * 2) * 40 },
{ translateY: Math.cos(particle.value * Math.PI * 2) * 24 },
{ rotate: `${particle.value * 360}deg` },
],
opacity: 0.5 + 0.5 * Math.abs(Math.sin(particle.value * Math.PI)),
}));
return (
<View style={[styles.screen, { backgroundColor: theme.background }]}>
<HeaderBar title="AI评估进行中" onBack={() => router.back()} tone="light" transparent />
{/* Layered background */}
<View style={[StyleSheet.absoluteFill, { zIndex: -1 }]} pointerEvents="none">
<LinearGradient
colors={["#F7FFE8", "#F0FBFF", "#FFF6E8"]}
start={{ x: 0.1, y: 0 }}
end={{ x: 0.9, y: 1 }}
style={StyleSheet.absoluteFill}
/>
<BlurView intensity={20} tint="light" style={styles.blurBlobA} />
<BlurView intensity={20} tint="light" style={styles.blurBlobB} />
</View>
{/* Hero visualization */}
<View style={styles.hero}>
<View style={styles.heroBackdrop} />
<Animated.View style={[styles.ringOuter, ringStyleOuter]} />
<Animated.View style={[styles.ringInner, ringStyleInner]} />
<View style={styles.grid}>
{Array.from({ length: 9 }).map((_, i) => (
<View key={`row-${i}`} style={styles.gridRow}>
{Array.from({ length: 9 }).map((__, j) => (
<View key={`cell-${i}-${j}`} style={styles.gridCell} />
))}
</View>
))}
<Animated.View style={[styles.scanner, scannerStyle]} />
</View>
<Animated.View style={[styles.particleA, particleStyleA]} />
<Animated.View style={[styles.particleB, particleStyleA, { right: undefined, left: SCREEN_WIDTH * 0.2, top: undefined, bottom: 60 }]} />
</View>
{/* Copy & actions */}
<View style={[styles.panel, { paddingBottom: insets.bottom + 16 }]}>
<Text style={styles.title}></Text>
<Text style={styles.subtitle}> 10-30 </Text>
<View style={styles.actions}>
<TouchableOpacity
style={[styles.primaryBtn, { backgroundColor: theme.primary }]}
activeOpacity={0.9}
// TODO: 评估完成后恢复为停留当前页面等待结果(不要跳转)
onPress={() => router.replace('/ai-posture-result')}
>
<Ionicons name="time-outline" size={16} color="#192126" />
<Text style={styles.primaryBtnText}></Text>
</TouchableOpacity>
<TouchableOpacity style={styles.secondaryBtn} activeOpacity={0.9} onPress={() => router.replace('/(tabs)/personal')}>
<Text style={styles.secondaryBtnText}></Text>
</TouchableOpacity>
</View>
</View>
</View>
);
}
const RING_SIZE = Math.min(SCREEN_WIDTH, SCREEN_HEIGHT) * 0.62;
const INNER_RING_SIZE = RING_SIZE * 0.72;
const styles = StyleSheet.create({
screen: {
flex: 1,
},
hero: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
blurBlobA: {
position: 'absolute',
top: -80,
right: -60,
width: 240,
height: 240,
borderRadius: 120,
backgroundColor: 'rgba(187,242,70,0.20)',
},
blurBlobB: {
position: 'absolute',
bottom: 120,
left: -40,
width: 220,
height: 220,
borderRadius: 110,
backgroundColor: 'rgba(89, 198, 255, 0.16)',
},
heroBackdrop: {
position: 'absolute',
width: RING_SIZE * 1.08,
height: RING_SIZE * 1.08,
borderRadius: (RING_SIZE * 1.08) / 2,
backgroundColor: 'rgba(25,33,38,0.25)',
},
ringOuter: {
position: 'absolute',
width: RING_SIZE,
height: RING_SIZE,
borderRadius: RING_SIZE / 2,
borderWidth: 1,
borderColor: 'rgba(25,33,38,0.16)',
},
ringInner: {
position: 'absolute',
width: INNER_RING_SIZE,
height: INNER_RING_SIZE,
borderRadius: INNER_RING_SIZE / 2,
borderWidth: 2,
borderColor: 'rgba(187,242,70,0.65)',
shadowColor: Colors.light.accentGreen,
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.35,
shadowRadius: 24,
},
grid: {
width: RING_SIZE * 0.9,
height: RING_SIZE * 0.9,
borderRadius: RING_SIZE * 0.45,
overflow: 'hidden',
padding: 10,
backgroundColor: 'rgba(25,33,38,0.08)',
},
gridRow: {
flexDirection: 'row',
},
gridCell: {
flex: 1,
aspectRatio: 1,
margin: 2,
borderRadius: 3,
backgroundColor: 'rgba(255,255,255,0.16)',
},
scanner: {
position: 'absolute',
left: 0,
right: 0,
top: '50%',
height: 60,
marginTop: -30,
backgroundColor: 'rgba(187,242,70,0.10)',
borderWidth: 1,
borderColor: 'rgba(187,242,70,0.25)',
},
particleA: {
position: 'absolute',
right: SCREEN_WIDTH * 0.18,
top: 40,
width: 14,
height: 14,
borderRadius: 7,
backgroundColor: Colors.light.accentGreen,
shadowColor: Colors.light.accentGreen,
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.4,
shadowRadius: 16,
},
particleB: {
position: 'absolute',
right: SCREEN_WIDTH * 0.08,
top: 120,
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: 'rgba(89, 198, 255, 1)',
shadowColor: 'rgba(89, 198, 255, 1)',
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.4,
shadowRadius: 12,
},
panel: {
paddingHorizontal: 20,
paddingTop: 8,
},
title: {
color: '#ECEDEE',
fontSize: 18,
fontWeight: '800',
marginBottom: 8,
},
subtitle: {
color: 'rgba(255,255,255,0.75)',
fontSize: 14,
lineHeight: 20,
},
actions: {
flexDirection: 'row',
gap: 10,
marginTop: 14,
},
primaryBtn: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 6,
height: 44,
paddingHorizontal: 16,
borderRadius: 12,
},
primaryBtnText: {
color: '#192126',
fontSize: 14,
fontWeight: '800',
},
secondaryBtn: {
flex: 1,
height: 44,
borderRadius: 12,
alignItems: 'center',
justifyContent: 'center',
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.18)',
},
secondaryBtnText: {
color: 'rgba(255,255,255,0.85)',
fontSize: 14,
fontWeight: '700',
},
});

318
app/ai-posture-result.tsx Normal file
View File

@@ -0,0 +1,318 @@
import { Ionicons } from '@expo/vector-icons';
import { BlurView } from 'expo-blur';
import { useRouter } from 'expo-router';
import React, { useMemo } from 'react';
import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import Animated, { FadeInDown } from 'react-native-reanimated';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { RadarChart } from '@/components/RadarChart';
import { HeaderBar } from '@/components/ui/HeaderBar';
import { Colors } from '@/constants/Colors';
type PoseView = 'front' | 'side' | 'back';
// 斯多特普拉提体态评估维度(示例)
const DIMENSIONS = [
{ key: 'head_neck', label: '头颈对齐' },
{ key: 'shoulder', label: '肩带稳定' },
{ key: 'ribs', label: '胸廓控制' },
{ key: 'pelvis', label: '骨盆中立' },
{ key: 'spine', label: '脊柱排列' },
{ key: 'hip_knee', label: '髋膝对线' },
];
type Issue = {
title: string;
severity: 'low' | 'medium' | 'high';
description: string;
suggestions: string[];
};
type ViewReport = {
score: number; // 0-5
issues: Issue[];
};
type ResultData = {
radar: number[]; // 与 DIMENSIONS 对应0-5
overview: string;
byView: Record<PoseView, ViewReport>;
};
// NOTE: 此处示例数据,后续可由 API 注入
const MOCK_RESULT: ResultData = {
radar: [4.2, 3.6, 3.2, 4.6, 3.8, 3.4],
overview: '整体体态较为均衡,骨盆与脊柱控制较好;肩带稳定性与胸廓控制仍有提升空间。',
byView: {
front: {
score: 3.8,
issues: [
{
title: '肩峰略前移,肩胛轻度外旋',
severity: 'medium',
description: '站立正面观察,右侧肩峰较左侧略有前移,提示肩带稳定性偏弱。',
suggestions: ['肩胛稳定训练(如天鹅摆臂分解)', '胸椎伸展与放松', '轻度弹力带外旋激活'],
},
],
},
side: {
score: 4.1,
issues: [
{
title: '骨盆接近中立,腰椎轻度前凸',
severity: 'low',
description: '侧面观察,骨盆位置接近中立位,腰椎存在轻度前凸,需注意腹压与肋骨下沉。',
suggestions: ['呼吸配合下的腹横肌激活', '猫牛流动改善胸椎灵活性'],
},
],
},
back: {
score: 3.5,
issues: [
{
title: '右侧肩胛轻度上抬',
severity: 'medium',
description: '背面观察,右肩胛较左侧轻度上抬,肩胛下回旋不足。',
suggestions: ['锯前肌激活训练', '低位划船,关注肩胛下沉与后缩'],
},
],
},
},
};
export default function AIPostureResultScreen() {
const insets = useSafeAreaInsets();
const router = useRouter();
const theme = Colors.light;
const categories = useMemo(() => DIMENSIONS.map(d => ({ key: d.key, label: d.label })), []);
const ScoreBadge = ({ score }: { score: number }) => (
<View style={styles.scoreBadge}>
<Text style={styles.scoreText}>{score.toFixed(1)}</Text>
<Text style={styles.scoreUnit}>/5</Text>
</View>
);
const IssueItem = ({ issue }: { issue: Issue }) => (
<View style={styles.issueItem}>
<View style={[styles.issueDot, issue.severity === 'high' ? styles.dotHigh : issue.severity === 'medium' ? styles.dotMedium : styles.dotLow]} />
<View style={{ flex: 1 }}>
<Text style={styles.issueTitle}>{issue.title}</Text>
<Text style={styles.issueDesc}>{issue.description}</Text>
{!!issue.suggestions?.length && (
<View style={styles.suggestRow}>
{issue.suggestions.map((s, idx) => (
<View key={idx} style={styles.suggestChip}><Text style={styles.suggestText}>{s}</Text></View>
))}
</View>
)}
</View>
</View>
);
const ViewCard = ({ title, report }: { title: string; report: ViewReport }) => (
<Animated.View entering={FadeInDown.duration(400)} style={styles.card}>
<View style={styles.cardHeader}>
<Text style={styles.cardTitle}>{title}</Text>
<ScoreBadge score={report.score} />
</View>
{report.issues.map((iss, idx) => (<IssueItem key={idx} issue={iss} />))}
</Animated.View>
);
return (
<View style={[styles.screen, { backgroundColor: theme.background }]}>
<HeaderBar title="体态评估结果" onBack={() => router.back()} tone="light" transparent />
{/* 背景装饰 */}
<View style={[StyleSheet.absoluteFill, { zIndex: -1 }]} pointerEvents="none">
<BlurView intensity={20} tint="light" style={styles.bgBlobA} />
<BlurView intensity={20} tint="light" style={styles.bgBlobB} />
</View>
<ScrollView contentContainerStyle={{ paddingBottom: insets.bottom + 40 }} showsVerticalScrollIndicator={false}>
{/* 总览与雷达图 */}
<Animated.View entering={FadeInDown.duration(400)} style={styles.card}>
<Text style={styles.sectionTitle}></Text>
<Text style={styles.overview}>{MOCK_RESULT.overview}</Text>
<View style={styles.radarWrap}>
<RadarChart categories={categories} values={MOCK_RESULT.radar} />
</View>
</Animated.View>
{/* 视图分析 */}
<ViewCard title="正面视图" report={MOCK_RESULT.byView.front} />
<ViewCard title="侧面视图" report={MOCK_RESULT.byView.side} />
<ViewCard title="背面视图" report={MOCK_RESULT.byView.back} />
{/* 底部操作 */}
<View style={styles.actions}>
<TouchableOpacity style={[styles.primaryBtn, { backgroundColor: theme.primary }]} onPress={() => router.replace('/(tabs)/personal')}>
<Ionicons name="checkmark-circle" size={18} color={theme.onPrimary} />
<Text style={[styles.primaryBtnText, { color: theme.onPrimary }]}></Text>
</TouchableOpacity>
<TouchableOpacity style={[styles.secondaryBtn, { borderColor: theme.border }]} onPress={() => router.push('/(tabs)/coach')}>
<Text style={[styles.secondaryBtnText, { color: theme.text }]}></Text>
</TouchableOpacity>
</View>
</ScrollView>
</View>
);
}
const styles = StyleSheet.create({
screen: {
flex: 1,
},
bgBlobA: {
position: 'absolute',
top: -60,
right: -40,
width: 200,
height: 200,
borderRadius: 100,
backgroundColor: 'rgba(187,242,70,0.18)',
},
bgBlobB: {
position: 'absolute',
bottom: 100,
left: -30,
width: 180,
height: 180,
borderRadius: 90,
backgroundColor: 'rgba(89, 198, 255, 0.16)',
},
card: {
marginTop: 16,
marginHorizontal: 16,
borderRadius: 16,
padding: 14,
backgroundColor: 'rgba(255,255,255,0.72)',
borderWidth: 1,
borderColor: 'rgba(25,33,38,0.08)',
},
sectionTitle: {
color: '#192126',
fontSize: 16,
fontWeight: '800',
marginBottom: 8,
},
overview: {
color: '#384046',
fontSize: 14,
lineHeight: 20,
},
radarWrap: {
marginTop: 10,
alignItems: 'center',
},
cardHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 8,
},
cardTitle: {
color: '#192126',
fontSize: 15,
fontWeight: '700',
},
scoreBadge: {
flexDirection: 'row',
alignItems: 'flex-end',
paddingHorizontal: 10,
paddingVertical: 6,
borderRadius: 10,
backgroundColor: 'rgba(187,242,70,0.16)',
},
scoreText: {
color: '#192126',
fontSize: 18,
fontWeight: '800',
},
scoreUnit: {
color: '#5E6468',
fontSize: 12,
marginLeft: 4,
},
issueItem: {
flexDirection: 'row',
gap: 10,
paddingVertical: 10,
},
issueDot: {
width: 10,
height: 10,
borderRadius: 5,
marginTop: 6,
},
dotHigh: { backgroundColor: '#E24D4D' },
dotMedium: { backgroundColor: '#F0C23C' },
dotLow: { backgroundColor: '#2BCC7F' },
issueTitle: {
color: '#192126',
fontSize: 14,
fontWeight: '700',
},
issueDesc: {
color: '#5E6468',
fontSize: 13,
marginTop: 4,
},
suggestRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
marginTop: 8,
},
suggestChip: {
paddingHorizontal: 10,
paddingVertical: 6,
borderRadius: 12,
backgroundColor: 'rgba(25,33,38,0.04)',
borderWidth: 1,
borderColor: 'rgba(25,33,38,0.08)',
},
suggestText: {
color: '#192126',
fontSize: 12,
},
actions: {
marginTop: 16,
paddingHorizontal: 16,
flexDirection: 'row',
gap: 10,
},
primaryBtn: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
height: 48,
paddingHorizontal: 16,
borderRadius: 14,
},
primaryBtnText: {
color: '#192126',
fontSize: 15,
fontWeight: '800',
},
secondaryBtn: {
flex: 1,
height: 48,
borderRadius: 14,
alignItems: 'center',
justifyContent: 'center',
borderWidth: 1,
borderColor: 'transparent',
},
secondaryBtnText: {
color: '#384046',
fontSize: 15,
fontWeight: '700',
},
});

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;

View File

@@ -1,18 +1,71 @@
import { Ionicons } from '@expo/vector-icons';
import * as AppleAuthentication from 'expo-apple-authentication';
import { useRouter } from 'expo-router';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Alert, Pressable, SafeAreaView, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import { useLocalSearchParams, useRouter } from 'expo-router';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { 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 }>();
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);
@@ -24,7 +77,21 @@ export default function LoginScreen() {
const guardAgreement = useCallback((action: () => void) => {
if (!hasAgreed) {
Alert.alert('请先阅读并同意', '勾选“我已阅读并同意用户协议与隐私政策”后才可继续登录');
Alert.alert(
'请先阅读并同意',
'继续登录前,请阅读并勾选《隐私政策》和《用户协议》。点击“同意并继续”将默认勾选并继续登录。',
[
{ text: '取消', style: 'cancel' },
{
text: '同意并继续',
onPress: () => {
setHasAgreed(true);
setTimeout(() => action(), 0);
},
},
],
{ cancelable: true }
);
return;
}
action();
@@ -40,26 +107,116 @@ export default function LoginScreen() {
AppleAuthentication.AppleAuthenticationScope.EMAIL,
],
});
// TODO: 将 credential 发送到后端换取应用会话。这里先直接返回上一页。
router.back();
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 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) {
if (err?.code === 'ERR_CANCELED') return;
Alert.alert('登录失败', '请稍后再试');
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]);
}, [appleAvailable, router, searchParams?.redirectParams, searchParams?.redirectTo]);
const onGuestLogin = useCallback(() => {
// TODO: 标记为游客身份,可在此写入本地状态/上报统计
router.back();
}, [router]);
const disabledStyle = useMemo(() => ({ opacity: hasAgreed ? 1 : 0.5 }), [hasAgreed]);
// 登录按钮不再因未勾选协议而禁用,仅在加载中禁用
return (
<SafeAreaView style={[styles.safeArea, { backgroundColor: color.background }]}>
<ThemedView style={styles.container}>
<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}>
<TouchableOpacity accessibilityRole="button" onPress={() => router.back()} style={styles.backButton}>
@@ -71,8 +228,8 @@ export default function LoginScreen() {
<ScrollView contentContainerStyle={styles.content} showsVerticalScrollIndicator={false}>
<View style={styles.headerWrap}>
<ThemedText style={[styles.title, { color: color.text }]}>Digital Pilates</ThemedText>
<ThemedText style={[styles.subtitle, { color: color.textMuted }]}></ThemedText>
<ThemedText style={[styles.title, { color: color.text }]}>Out Live</ThemedText>
<ThemedText style={[styles.subtitle, { color: color.textMuted }]}>Out Live</ThemedText>
</View>
{/* Apple 登录 */}
@@ -80,11 +237,11 @@ export default function LoginScreen() {
<Pressable
accessibilityRole="button"
onPress={() => guardAgreement(onAppleLogin)}
disabled={!hasAgreed || loading}
disabled={loading}
style={({ pressed }) => [
styles.appleButton,
{ backgroundColor: '#000000' },
disabledStyle,
loading && { opacity: 0.7 },
pressed && { transform: [{ scale: 0.98 }] },
]}
>
@@ -93,22 +250,6 @@ export default function LoginScreen() {
</Pressable>
)}
{/* 游客登录(弱化样式) */}
<Pressable
accessibilityRole="button"
onPress={() => guardAgreement(onGuestLogin)}
disabled={!hasAgreed || loading}
style={({ pressed }) => [
styles.guestButton,
{ borderColor: color.border, backgroundColor: color.surface },
disabledStyle,
pressed && { transform: [{ scale: 0.98 }] },
]}
>
<Ionicons name="person-circle-outline" size={22} color={Colors.light.neutral200} style={{ marginRight: 8 }} />
<Text style={[styles.guestText, { color: Colors.light.neutral200 }]}></Text>
</Pressable>
{/* 协议勾选 */}
<View style={styles.agreementRow}>
<Pressable onPress={() => setHasAgreed((v) => !v)} style={styles.checkboxWrap} accessibilityRole="checkbox" accessibilityState={{ checked: hasAgreed }}>
@@ -122,11 +263,11 @@ export default function LoginScreen() {
</View>
</Pressable>
<Text style={[styles.agreementText, { color: color.textMuted }]}></Text>
<Pressable onPress={() => router.push('/legal/privacy-policy')}>
<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={() => router.push('/legal/user-agreement')}>
<Pressable onPress={() => Linking.openURL(USER_AGREEMENT_URL)}>
<Text style={[styles.link, { color: color.primary }]}></Text>
</Pressable>
</View>
@@ -162,8 +303,9 @@ const styles = StyleSheet.create({
},
title: {
fontSize: 32,
fontWeight: '800',
fontWeight: '500',
letterSpacing: 0.5,
lineHeight: 38,
},
subtitle: {
marginTop: 8,
@@ -220,6 +362,45 @@ const styles = StyleSheet.create({
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,
},
});

View File

@@ -0,0 +1,820 @@
import { DateSelector } from '@/components/DateSelector';
import { HeaderBar } from '@/components/ui/HeaderBar';
import { Colors } from '@/constants/Colors';
import { useAppSelector } from '@/hooks/redux';
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 [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
}}
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,
},
});

View File

@@ -0,0 +1,822 @@
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 { useAuthGuard } from '@/hooks/useAuthGuard';
import { useColorScheme } from '@/hooks/useColorScheme';
import {
fetchChallengeDetail,
fetchChallengeRankings,
joinChallenge,
leaveChallenge,
reportChallengeProgress,
selectChallengeById,
selectChallengeDetailError,
selectChallengeDetailStatus,
selectChallengeRankingList,
selectJoinError,
selectJoinStatus,
selectLeaveError,
selectLeaveStatus,
selectProgressStatus
} from '@/store/challengesSlice';
import { Toast } from '@/utils/toast.utils';
import { Ionicons } from '@expo/vector-icons';
import { BlurView } from 'expo-blur';
import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient';
import { useLocalSearchParams, useRouter } from 'expo-router';
import LottieView from 'lottie-react-native';
import React, { useEffect, useMemo, useState } from 'react';
import {
ActivityIndicator,
Alert,
Dimensions,
Platform,
ScrollView,
Share,
StatusBar,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
const { width } = Dimensions.get('window');
const HERO_HEIGHT = width * 0.76;
const CTA_GRADIENT: [string, string] = ['#5E8BFF', '#6B6CFF'];
const CTA_DISABLED_GRADIENT: [string, string] = ['#d3d7e8', '#c1c6da'];
const isHttpUrl = (value: string) => /^https?:\/\//i.test(value);
const formatMonthDay = (value?: string): string | undefined => {
if (!value) return undefined;
const date = new Date(value);
if (Number.isNaN(date.getTime())) return undefined;
return `${date.getMonth() + 1}${date.getDate()}`;
};
const buildDateRangeLabel = (challenge?: {
startAt?: string;
endAt?: string;
periodLabel?: string;
durationLabel?: string;
}): string => {
if (!challenge) return '';
const startLabel = formatMonthDay(challenge.startAt);
const endLabel = formatMonthDay(challenge.endAt);
if (startLabel && endLabel) {
return `${startLabel} - ${endLabel}`;
}
return challenge.periodLabel ?? challenge.durationLabel ?? '';
};
const formatParticipantsLabel = (count?: number): string => {
if (typeof count !== 'number') return '持续更新中';
return `${count.toLocaleString('zh-CN')} 人正在参与`;
};
export default function ChallengeDetailScreen() {
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 { ensureLoggedIn } = useAuthGuard();
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 joinStatusSelector = useMemo(() => (id ? selectJoinStatus(id) : undefined), [id]);
const joinStatus = useAppSelector((state) => (joinStatusSelector ? joinStatusSelector(state) : 'idle'));
const joinErrorSelector = useMemo(() => (id ? selectJoinError(id) : undefined), [id]);
const joinError = useAppSelector((state) => (joinErrorSelector ? joinErrorSelector(state) : undefined));
const leaveStatusSelector = useMemo(() => (id ? selectLeaveStatus(id) : undefined), [id]);
const leaveStatus = useAppSelector((state) => (leaveStatusSelector ? leaveStatusSelector(state) : 'idle'));
const leaveErrorSelector = useMemo(() => (id ? selectLeaveError(id) : undefined), [id]);
const leaveError = useAppSelector((state) => (leaveErrorSelector ? leaveErrorSelector(state) : undefined));
const progressStatusSelector = useMemo(() => (id ? selectProgressStatus(id) : undefined), [id]);
const progressStatus = useAppSelector((state) => (progressStatusSelector ? progressStatusSelector(state) : 'idle'));
const rankingListSelector = useMemo(() => (id ? selectChallengeRankingList(id) : undefined), [id]);
const rankingList = useAppSelector((state) => (rankingListSelector ? rankingListSelector(state) : undefined));
useEffect(() => {
const getData = async (id: string) => {
try {
await dispatch(fetchChallengeDetail(id)).unwrap;
} catch (error) {
}
}
if (id) {
getData(id);
}
}, [dispatch, id]);
useEffect(() => {
if (id && !rankingList) {
void dispatch(fetchChallengeRankings({ id }));
}
}, [dispatch, id, rankingList]);
const [showCelebration, setShowCelebration] = useState(false);
useEffect(() => {
if (!showCelebration) {
return;
}
const timer = setTimeout(() => {
setShowCelebration(false);
}, 2400);
return () => {
clearTimeout(timer);
};
}, [showCelebration]);
const progress = challenge?.progress;
const rankingData = useMemo(() => {
const source = rankingList?.items ?? challenge?.rankings ?? [];
return source.slice(0, 10);
}, [challenge?.rankings, rankingList?.items]);
const participantAvatars = useMemo(
() => rankingData.filter((item) => item.avatar).map((item) => item.avatar as string).slice(0, 6),
[rankingData],
);
const handleViewAllRanking = () => {
if (!id) {
return;
}
router.push({ pathname: '/challenges/[id]/leaderboard', params: { id } });
};
const dateRangeLabel = useMemo(
() =>
buildDateRangeLabel({
startAt: challenge?.startAt,
endAt: challenge?.endAt,
periodLabel: challenge?.periodLabel,
durationLabel: challenge?.durationLabel,
}),
[challenge?.startAt, challenge?.endAt, challenge?.periodLabel, challenge?.durationLabel],
);
const handleShare = async () => {
if (!challenge) {
return;
}
try {
await Share.share({
title: challenge.title,
message: `我正在参与「${challenge.title}」,一起坚持吧!`,
url: challenge.image,
});
} catch (error) {
console.warn('分享失败', error);
}
};
const handleJoin = async () => {
if (!id || joinStatus === 'loading') {
return;
}
const isLoggedIn = await ensureLoggedIn();
if (!isLoggedIn) {
// 如果未登录,用户会被重定向到登录页面
return;
}
try {
await dispatch(joinChallenge(id));
setShowCelebration(true)
} catch (error) {
Toast.error('加入挑战失败')
}
};
const handleLeave = async () => {
if (!id || leaveStatus === 'loading') {
return;
}
try {
await dispatch(leaveChallenge(id)).unwrap();
await dispatch(fetchChallengeDetail(id)).unwrap();
} catch (error) {
Toast.error('退出挑战失败');
}
};
const handleLeaveConfirm = () => {
if (!id || leaveStatus === 'loading') {
return;
}
Alert.alert('确认退出挑战?', '退出后需要重新加入才能继续坚持。', [
{ text: '取消', style: 'cancel' },
{
text: '退出挑战',
style: 'destructive',
onPress: () => {
void handleLeave();
},
},
]);
};
const handleProgressReport = () => {
if (!id || progressStatus === 'loading') {
return;
}
dispatch(reportChallengeProgress({ id }));
};
const isJoined = challenge?.isJoined ?? false;
const isLoadingInitial = detailStatus === 'loading' && !challenge;
if (!id) {
return (
<SafeAreaView style={[styles.safeArea, { backgroundColor: colorTokens.background }]}>
<HeaderBar title="挑战详情" onBack={() => router.back()} withSafeTop transparent={false} />
<View style={styles.missingContainer}>
<Text style={[styles.missingText, { color: colorTokens.textSecondary }]}></Text>
</View>
</SafeAreaView>
);
}
if (isLoadingInitial) {
return (
<SafeAreaView style={[styles.safeArea, { backgroundColor: colorTokens.background }]}>
<HeaderBar title="挑战详情" onBack={() => router.back()} withSafeTop transparent={false} />
<View style={styles.missingContainer}>
<ActivityIndicator color={colorTokens.primary} />
<Text style={[styles.missingText, { color: colorTokens.textSecondary, marginTop: 16 }]}></Text>
</View>
</SafeAreaView>
);
}
if (!challenge) {
return (
<SafeAreaView style={[styles.safeArea, { backgroundColor: colorTokens.background }]}>
<HeaderBar title="挑战详情" onBack={() => router.back()} withSafeTop transparent={false} />
<View style={styles.missingContainer}>
<Text style={[styles.missingText, { color: colorTokens.textSecondary }]}>
{detailError ?? '未找到该挑战,稍后再试试吧。'}
</Text>
<TouchableOpacity
style={[styles.retryButton, { borderColor: colorTokens.primary }]}
activeOpacity={0.9}
onPress={() => dispatch(fetchChallengeDetail(id))}
>
<Text style={[styles.retryText, { color: colorTokens.primary }]}></Text>
</TouchableOpacity>
</View>
</SafeAreaView>
);
}
const highlightTitle = challenge.highlightTitle ?? '立即加入挑战';
const highlightSubtitle = challenge.highlightSubtitle ?? '邀请好友一起坚持,更容易收获成果';
const joinCtaLabel = joinStatus === 'loading' ? '加入中…' : challenge.ctaLabel ?? '立即加入挑战';
const isUpcoming = challenge.status === 'upcoming';
const isExpired = challenge.status === 'expired';
const upcomingStartLabel = formatMonthDay(challenge.startAt);
const upcomingHighlightTitle = '挑战即将开始';
const upcomingHighlightSubtitle = upcomingStartLabel
? `${upcomingStartLabel} 开始,敬请期待`
: '挑战即将开启,敬请期待';
const upcomingCtaLabel = '挑战即将开始';
const expiredEndLabel = formatMonthDay(challenge.endAt);
const expiredHighlightTitle = '挑战已结束';
const expiredHighlightSubtitle = expiredEndLabel
? `${expiredEndLabel} 已截止,期待下一次挑战`
: '本轮挑战已结束,期待下一次挑战';
const expiredCtaLabel = '挑战已结束';
const leaveHighlightTitle = '先别急着离开';
const leaveHighlightSubtitle = '再坚持一下,下一个里程碑就要出现了';
const leaveCtaLabel = leaveStatus === 'loading' ? '退出中…' : '退出挑战';
let floatingHighlightTitle = highlightTitle;
let floatingHighlightSubtitle = highlightSubtitle;
let floatingCtaLabel = joinCtaLabel;
let floatingOnPress: (() => void) | undefined = handleJoin;
let floatingDisabled = joinStatus === 'loading';
let floatingError = joinError;
let isDisabledButtonState = false;
if (isJoined) {
floatingHighlightTitle = leaveHighlightTitle;
floatingHighlightSubtitle = leaveHighlightSubtitle;
floatingCtaLabel = leaveCtaLabel;
floatingOnPress = handleLeaveConfirm;
floatingDisabled = leaveStatus === 'loading';
floatingError = leaveError;
}
if (isUpcoming) {
floatingHighlightTitle = upcomingHighlightTitle;
floatingHighlightSubtitle = upcomingHighlightSubtitle;
floatingCtaLabel = upcomingCtaLabel;
floatingOnPress = undefined;
floatingDisabled = true;
floatingError = undefined;
isDisabledButtonState = true;
}
if (isExpired) {
floatingHighlightTitle = expiredHighlightTitle;
floatingHighlightSubtitle = expiredHighlightSubtitle;
floatingCtaLabel = expiredCtaLabel;
floatingOnPress = undefined;
floatingDisabled = true;
floatingError = undefined;
isDisabledButtonState = true;
}
const floatingGradientColors = isDisabledButtonState ? CTA_DISABLED_GRADIENT : CTA_GRADIENT;
const participantsLabel = formatParticipantsLabel(challenge.participantsCount);
const inlineErrorMessage = detailStatus === 'failed' && detailError ? detailError : undefined;
return (
<View style={styles.safeArea}>
<StatusBar barStyle="light-content" />
<View style={styles.container}>
<View pointerEvents="box-none" style={[styles.headerOverlay, { paddingTop: insets.top }]}>
<HeaderBar
title=""
backColor="white"
tone="light"
transparent
withSafeTop={false}
// right={
// <TouchableOpacity style={styles.circularButton} activeOpacity={0.85} onPress={handleShare}>
// <Ionicons name="share-social-outline" size={20} color="#ffffff" />
// </TouchableOpacity>
// }
/>
</View>
<ScrollView
style={styles.scrollView}
bounces
showsVerticalScrollIndicator={false}
contentContainerStyle={[
styles.scrollContent,
{ paddingBottom: (Platform.OS === 'ios' ? 180 : 160) + insets.bottom },
]}
>
<View style={styles.heroContainer}>
<Image source={{ uri: challenge.image }} style={styles.heroImage} cachePolicy={'memory-disk'} />
<LinearGradient
colors={['rgba(0,0,0,0.35)', 'rgba(0,0,0,0.15)', 'rgba(244, 246, 255, 1)']}
style={StyleSheet.absoluteFillObject}
/>
</View>
<View style={styles.headerTextBlock}>
<Text style={styles.title}>{challenge.title}</Text>
{challenge.summary ? <Text style={styles.summary}>{challenge.summary}</Text> : null}
{inlineErrorMessage ? (
<View style={styles.inlineError}>
<Ionicons name="warning-outline" size={14} color="#FF6B6B" />
<Text style={styles.inlineErrorText}>{inlineErrorMessage}</Text>
</View>
) : null}
</View>
{progress ? (
<ChallengeProgressCard
title={challenge.title}
endAt={challenge.endAt}
progress={progress}
style={styles.progressCardWrapper}
/>
) : null}
<View style={styles.detailCard}>
<View style={styles.detailRow}>
<View style={styles.detailIconWrapper}>
<Ionicons name="calendar-outline" size={20} color="#4F5BD5" />
</View>
<View style={styles.detailTextWrapper}>
<Text style={styles.detailLabel}>{dateRangeLabel}</Text>
<Text style={styles.detailMeta}>{challenge.durationLabel}</Text>
</View>
</View>
<View style={styles.detailRow}>
<View style={styles.detailIconWrapper}>
<Ionicons name="flag-outline" size={20} color="#4F5BD5" />
</View>
<View style={styles.detailTextWrapper}>
<Text style={styles.detailLabel}>{challenge.requirementLabel}</Text>
<Text style={styles.detailMeta}></Text>
</View>
</View>
<View style={styles.detailRow}>
<View style={styles.detailIconWrapper}>
<Ionicons name="people-outline" size={20} color="#4F5BD5" />
</View>
<View style={[styles.detailTextWrapper, { flex: 1 }]}>
<Text style={styles.detailLabel}>{participantsLabel}</Text>
{participantAvatars.length ? (
<View style={styles.avatarRow}>
{participantAvatars.map((avatar, index) => (
<Image
key={`${avatar}-${index}`}
source={{ uri: avatar }}
style={[styles.avatar, index > 0 && styles.avatarOffset]}
cachePolicy={'memory-disk'}
/>
))}
{challenge.participantsCount && challenge.participantsCount > participantAvatars.length ? (
<TouchableOpacity style={styles.moreAvatarButton}>
<Text style={styles.moreAvatarText}></Text>
</TouchableOpacity>
) : null}
</View>
) : null}
</View>
</View>
</View>
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}></Text>
<TouchableOpacity activeOpacity={0.8} onPress={handleViewAllRanking}>
<Text style={styles.sectionAction}></Text>
</TouchableOpacity>
</View>
{challenge.rankingDescription ? (
<Text style={styles.sectionSubtitle}>{challenge.rankingDescription}</Text>
) : null}
<View style={styles.rankingCard}>
{rankingData.length ? (
rankingData.map((item, index) => (
<ChallengeRankingItem
key={item.id ?? index}
item={item}
index={index}
showDivider={index > 0}
unit={challenge?.unit}
/>
))
) : (
<View style={styles.emptyRanking}>
<Text style={styles.emptyRankingText}></Text>
</View>
)}
</View>
</ScrollView>
<View pointerEvents="box-none" style={[styles.floatingCTAContainer, { paddingBottom: insets.bottom }]}>
<BlurView intensity={10} tint="light" style={styles.floatingCTABlur}>
<View style={styles.floatingCTAContent}>
<View style={styles.highlightCopy}>
<Text style={styles.highlightTitle}>{floatingHighlightTitle}</Text>
<Text style={styles.highlightSubtitle}>{floatingHighlightSubtitle}</Text>
{floatingError ? <Text style={styles.ctaErrorText}>{floatingError}</Text> : null}
</View>
<TouchableOpacity
style={styles.highlightButton}
activeOpacity={0.9}
onPress={floatingOnPress}
disabled={floatingDisabled}
>
<LinearGradient
colors={floatingGradientColors}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.highlightButtonBackground}
>
<Text style={[styles.highlightButtonLabel, isDisabledButtonState && styles.highlightButtonLabelDisabled]}>
{floatingCtaLabel}
</Text>
</LinearGradient>
</TouchableOpacity>
</View>
</BlurView>
</View>
</View>
{showCelebration && (
<View pointerEvents="none" style={styles.celebrationOverlay}>
<LottieView
autoPlay
loop={false}
source={require('@/assets/lottie/Confetti.json')}
style={styles.celebrationAnimation}
/>
</View>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
safeArea: {
flex: 1,
backgroundColor: '#f3f4fb',
},
headerOverlay: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
zIndex: 20,
},
heroContainer: {
height: HERO_HEIGHT,
width: '100%',
overflow: 'hidden',
position: 'absolute',
top: 0
},
heroImage: {
width: '100%',
height: '100%',
},
scrollView: {
flex: 1,
},
scrollContent: {
paddingBottom: Platform.select({ ios: 40, default: 28 }),
},
progressCardWrapper: {
marginTop: 20,
marginHorizontal: 24,
},
floatingCTAContainer: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
paddingHorizontal: 20,
},
floatingCTABlur: {
borderRadius: 24,
overflow: 'hidden',
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.6)',
backgroundColor: 'rgba(243, 244, 251, 0.85)',
},
floatingCTAContent: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 16,
paddingHorizontal: 20,
},
highlightCopy: {
flex: 1,
marginRight: 16,
},
headerTextBlock: {
paddingHorizontal: 24,
marginTop: HERO_HEIGHT - 60,
alignItems: 'center',
},
periodLabel: {
fontSize: 14,
color: '#596095',
letterSpacing: 0.2,
},
title: {
marginTop: 10,
fontSize: 24,
fontWeight: '800',
color: '#1c1f3a',
textAlign: 'center',
},
summary: {
marginTop: 12,
fontSize: 14,
lineHeight: 20,
color: '#7080b4',
textAlign: 'center',
},
inlineError: {
marginTop: 12,
paddingHorizontal: 12,
paddingVertical: 8,
borderRadius: 12,
backgroundColor: 'rgba(255, 107, 107, 0.12)',
flexDirection: 'row',
alignItems: 'center',
},
inlineErrorText: {
marginLeft: 6,
fontSize: 12,
color: '#FF6B6B',
flexShrink: 1,
},
detailCard: {
marginTop: 28,
marginHorizontal: 20,
padding: 20,
borderRadius: 28,
backgroundColor: '#ffffff',
shadowColor: 'rgba(30, 41, 59, 0.18)',
shadowOpacity: 0.2,
shadowRadius: 20,
shadowOffset: { width: 0, height: 12 },
elevation: 8,
gap: 20,
},
detailRow: {
flexDirection: 'row',
alignItems: 'center',
},
detailIconWrapper: {
width: 42,
height: 42,
alignItems: 'center',
justifyContent: 'center',
},
detailTextWrapper: {
marginLeft: 14,
},
detailLabel: {
fontSize: 15,
fontWeight: '600',
color: '#1c1f3a',
},
detailMeta: {
marginTop: 4,
fontSize: 12,
color: '#6f7ba7',
},
avatarRow: {
flexDirection: 'row',
alignItems: 'center',
marginTop: 12,
},
avatar: {
width: 36,
height: 36,
borderRadius: 18,
borderWidth: 2,
borderColor: '#fff',
},
avatarOffset: {
marginLeft: -12,
},
moreAvatarButton: {
marginLeft: 12,
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 14,
backgroundColor: '#EEF0FF',
},
moreAvatarText: {
fontSize: 12,
color: '#4F5BD5',
fontWeight: '600',
},
sectionHeader: {
marginTop: 36,
marginHorizontal: 24,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
sectionTitle: {
fontSize: 18,
fontWeight: '700',
color: '#1c1f3a',
},
sectionAction: {
fontSize: 13,
fontWeight: '600',
color: '#5F6BF0',
},
sectionSubtitle: {
marginTop: 8,
marginHorizontal: 24,
fontSize: 13,
color: '#6f7ba7',
lineHeight: 18,
},
rankingCard: {
marginTop: 20,
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',
},
emptyRankingText: {
fontSize: 14,
color: '#6f7ba7',
},
highlightTitle: {
fontSize: 16,
fontWeight: '700',
color: '#1c1f3a',
},
highlightSubtitle: {
marginTop: 4,
fontSize: 12,
color: '#5f6a97',
lineHeight: 18,
},
ctaErrorText: {
marginTop: 8,
fontSize: 12,
color: '#FF6B6B',
},
highlightButton: {
borderRadius: 22,
overflow: 'hidden',
},
highlightButtonBackground: {
borderRadius: 22,
paddingVertical: 10,
paddingHorizontal: 18,
alignItems: 'center',
justifyContent: 'center',
},
highlightButtonLabel: {
fontSize: 14,
fontWeight: '700',
color: '#ffffff',
},
highlightButtonLabelDisabled: {
color: '#6f7799',
},
circularButton: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: 'rgba(255,255,255,0.24)',
alignItems: 'center',
justifyContent: 'center',
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.45)',
},
shareIcon: {
fontSize: 18,
color: '#ffffff',
fontWeight: '700',
},
missingContainer: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 32,
},
missingText: {
fontSize: 16,
textAlign: 'center',
},
retryButton: {
marginTop: 18,
paddingHorizontal: 20,
paddingVertical: 10,
borderRadius: 22,
borderWidth: 1,
},
retryText: {
fontSize: 14,
fontWeight: '600',
},
celebrationOverlay: {
...StyleSheet.absoluteFillObject,
alignItems: 'center',
justifyContent: 'center',
zIndex: 40,
},
celebrationAnimation: {
width: width * 1.3,
height: width * 1.3,
},
});

View File

@@ -0,0 +1,298 @@
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 {
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 { 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={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 }}
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,700 @@
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 { CircumferencePeriod } from '@/services/circumferenceAnalysis';
type TabType = CircumferencePeriod;
export default function CircumferenceDetailScreen() {
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
}}
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

148
app/developer.tsx Normal file
View File

@@ -0,0 +1,148 @@
import { ROUTES } from '@/constants/Routes';
import { useRouter } from 'expo-router';
import React from 'react';
import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons';
export default function DeveloperScreen() {
const router = useRouter();
const insets = useSafeAreaInsets();
const developerItems = [
{
title: '日志',
subtitle: '查看应用运行日志',
icon: 'document-text-outline',
onPress: () => router.push(ROUTES.DEVELOPER_LOGS),
},
];
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',
},
});

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

@@ -0,0 +1,312 @@
import { log, logger, LogEntry } 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 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('错误', '导出日志失败');
}
};
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={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>
}
/>
</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,
},
});

View File

@@ -0,0 +1,860 @@
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 {
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 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,
{
height: value > 0 ? height : 2, // 没有数据时显示最小高度的灰色条
backgroundColor: value > 0 ? color : '#E5E5EA',
opacity: value > 0 ? 1 : 0.5
}
]}
/>
);
})}
</View>
<View style={styles.chartLabels}>
<Text style={styles.chartLabel}>00:00</Text>
<Text style={styles.chartLabel}>06:00</Text>
<Text style={styles.chartLabel}>12:00</Text>
<Text style={styles.chartLabel}>18:00</Text>
</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}
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: 4,
justifyContent: 'space-between',
},
chartBar: {
width: 3,
borderRadius: 1.5,
marginHorizontal: 0.5,
},
chartLabels: {
flexDirection: 'row',
justifyContent: 'space-between',
paddingHorizontal: 4,
},
chartLabel: {
fontSize: 12,
color: '#8E8E93',
fontWeight: '500',
},
// 锻炼信息样式
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',
},
});

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

@@ -0,0 +1,908 @@
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 { 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 { 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 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);
}
// 记录成功后,刷新当天的营养数据
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()}
transparent={false}
variant="elevated"
right={
<TouchableOpacity style={styles.customButton} onPress={handleCreateCustomFood}>
<Text style={styles.customButtonText}></Text>
</TouchableOpacity>
}
/>
{/* 搜索框 */}
<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',
},
});

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

File diff suppressed because it is too large Load Diff

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

@@ -0,0 +1,612 @@
import { HeaderBar } from '@/components/ui/HeaderBar';
import { Colors } from '@/constants/Colors';
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,
Dimensions,
Modal,
StatusBar,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
type MealType = 'breakfast' | 'lunch' | 'dinner' | 'snack';
const { width: screenWidth, height: screenHeight } = Dimensions.get('window');
export default function FoodCameraScreen() {
const router = useRouter();
const params = useLocalSearchParams<{ mealType?: string }>();
const cameraRef = useRef<CameraView>(null);
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 (
<SafeAreaView style={styles.container}>
<HeaderBar
title="食物拍摄"
onBack={() => router.back()}
transparent={true}
/>
<View style={styles.loadingContainer}>
<Text style={styles.loadingText}>...</Text>
</View>
</SafeAreaView>
);
}
if (!permission.granted) {
// 没有相机权限
return (
<SafeAreaView style={styles.container}>
<HeaderBar
title="食物拍摄"
onBack={() => router.back()}
backColor='#ffffff'
/>
<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>
</SafeAreaView>
);
}
// 切换相机前后摄像头
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);
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: ImagePicker.MediaTypeOptions.Images,
allowsEditing: true,
aspect: [4, 3],
quality: 0.8,
});
if (!result.canceled && result.assets[0]) {
const imageUri = result.assets[0].uri;
console.log('从相册选择的照片:', imageUri);
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={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,747 @@
import { HeaderBar } from '@/components/ui/HeaderBar';
import { Colors } from '@/constants/Colors';
import { useAppDispatch } from '@/hooks/redux';
import { useCosUpload } from '@/hooks/useCosUpload';
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 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();
// 动画引用
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();
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={styles.errorContainer}>
<Text style={styles.errorText}></Text>
</View>
</SafeAreaView>
);
}
return (
<View style={styles.container}>
<HeaderBar
title={showRecognitionProcess ? "食物识别" : "确认食物"}
onBack={handleGoBack}
/>
{/* 主要内容区域 */}
<ScrollView style={styles.contentContainer} 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',
},
});

471
app/goals-list.tsx Normal file
View File

@@ -0,0 +1,471 @@
import { GoalCard } from '@/components/GoalCard';
import { CreateGoalModal } from '@/components/model/CreateGoalModal';
import { useGlobalDialog } from '@/components/ui/DialogProvider';
import { Colors } from '@/constants/Colors';
import { TAB_BAR_BOTTOM_OFFSET, TAB_BAR_HEIGHT } from '@/constants/TabBar';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useColorScheme } from '@/hooks/useColorScheme';
import { deleteGoal, fetchGoals, loadMoreGoals, updateGoal } from '@/store/goalsSlice';
import { CreateGoalRequest, GoalListItem, UpdateGoalRequest } from '@/types/goals';
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
import { useFocusEffect } from '@react-navigation/native';
import { LinearGradient } from 'expo-linear-gradient';
import { useRouter } from 'expo-router';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Alert, FlatList, RefreshControl, StatusBar, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
export default function GoalsListScreen() {
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme];
const dispatch = useAppDispatch();
const router = useRouter();
const { showConfirm } = useGlobalDialog();
// Redux状态
const {
goals,
goalsLoading,
goalsError,
goalsPagination,
updateLoading,
updateError,
} = useAppSelector((state) => state.goals);
const [refreshing, setRefreshing] = useState(false);
// 编辑目标相关状态
const [showEditModal, setShowEditModal] = useState(false);
const [editingGoal, setEditingGoal] = useState<GoalListItem | null>(null);
// 页面聚焦时重新加载数据
useFocusEffect(
useCallback(() => {
console.log('useFocusEffect - loading goals');
loadGoals();
}, [dispatch])
);
// 加载目标列表
const loadGoals = async () => {
try {
await dispatch(fetchGoals({
page: 1,
pageSize: 20,
sortBy: 'createdAt',
sortOrder: 'desc',
})).unwrap();
} catch (error) {
console.error('Failed to load goals:', error);
// 在开发模式下如果API调用失败使用模拟数据
if (__DEV__) {
console.log('Using mock data for development');
// 添加模拟数据用于测试左滑删除功能
const mockGoals: GoalListItem[] = [
{
id: 'mock-1',
userId: 'test-user-1',
title: '每日运动30分钟',
repeatType: 'daily',
frequency: 1,
status: 'active',
completedCount: 5,
targetCount: 30,
hasReminder: true,
reminderTime: '09:00',
category: '运动',
priority: 5,
startDate: '2024-01-01',
startTime: 900,
endTime: 1800,
progressPercentage: 17,
},
{
id: 'mock-2',
userId: 'test-user-1',
title: '每天喝8杯水',
repeatType: 'daily',
frequency: 8,
status: 'active',
completedCount: 6,
targetCount: 8,
hasReminder: true,
reminderTime: '10:00',
category: '健康',
priority: 8,
startDate: '2024-01-01',
startTime: 600,
endTime: 2200,
progressPercentage: 75,
},
{
id: 'mock-3',
userId: 'test-user-1',
title: '每周读书2小时',
repeatType: 'weekly',
frequency: 2,
status: 'paused',
completedCount: 1,
targetCount: 2,
hasReminder: false,
category: '学习',
priority: 3,
startDate: '2024-01-01',
startTime: 800,
endTime: 2000,
progressPercentage: 50,
},
];
// 直接更新 Redux 状态(仅用于开发测试)
dispatch({
type: 'goals/fetchGoals/fulfilled',
payload: {
query: { page: 1, pageSize: 20, sortBy: 'createdAt', sortOrder: 'desc' },
response: {
list: mockGoals,
page: 1,
pageSize: 20,
total: mockGoals.length,
}
}
});
}
}
};
// 下拉刷新
const onRefresh = async () => {
setRefreshing(true);
try {
await loadGoals();
} finally {
setRefreshing(false);
}
};
// 加载更多目标
const handleLoadMoreGoals = async () => {
if (goalsPagination.hasMore && !goalsLoading) {
try {
await dispatch(loadMoreGoals()).unwrap();
} catch (error) {
console.error('Failed to load more goals:', error);
}
}
};
// 处理删除目标
const handleDeleteGoal = async (goalId: string) => {
try {
await dispatch(deleteGoal(goalId)).unwrap();
// 删除成功Redux 会自动更新状态
} catch (error) {
console.error('Failed to delete goal:', error);
Alert.alert('错误', '删除目标失败,请重试');
}
};
// 处理错误提示
useEffect(() => {
if (goalsError) {
Alert.alert('错误', goalsError);
}
if (updateError) {
Alert.alert('更新失败', updateError);
}
}, [goalsError, updateError]);
// 根据筛选条件过滤目标
const filteredGoals = useMemo(() => {
return goals;
}, [goals]);
// 处理目标点击
const handleGoalPress = (goal: GoalListItem) => {
setEditingGoal(goal);
setShowEditModal(true);
};
// 将 GoalListItem 转换为 CreateGoalRequest 格式
const convertGoalToModalData = (goal: GoalListItem): Partial<CreateGoalRequest> => {
return {
title: goal.title,
description: goal.description,
repeatType: goal.repeatType,
frequency: goal.frequency,
category: goal.category,
priority: goal.priority,
hasReminder: goal.hasReminder,
reminderTime: goal.reminderTime,
customRepeatRule: goal.customRepeatRule,
endDate: goal.endDate,
};
};
// 处理更新目标
const handleUpdateGoal = async (goalId: string, goalData: UpdateGoalRequest) => {
try {
await dispatch(updateGoal({ goalId, goalData })).unwrap();
setShowEditModal(false);
setEditingGoal(null);
// 使用全局弹窗显示成功消息
showConfirm(
{
title: '目标更新成功',
message: '恭喜!您的目标已成功更新。',
confirmText: '确定',
cancelText: '',
icon: 'checkmark-circle',
iconColor: '#10B981',
},
() => {
console.log('用户确认了目标更新成功');
}
);
} catch (error) {
console.error('Failed to update goal:', error);
Alert.alert('错误', '更新目标失败,请重试');
// 更新失败时不关闭弹窗,保持编辑状态
}
};
// 处理编辑弹窗关闭
const handleCloseEditModal = () => {
setShowEditModal(false);
setEditingGoal(null);
};
// 渲染目标项
const renderGoalItem = ({ item }: { item: GoalListItem }) => (
<GoalCard
goal={item}
onPress={handleGoalPress}
onDelete={handleDeleteGoal}
showStatus={false}
/>
);
// 渲染空状态
const renderEmptyState = () => {
let title = '暂无目标';
let subtitle = '创建您的第一个目标,开始您的健康之旅';
return (
<View style={styles.emptyState}>
<MaterialIcons name="flag" size={64} color="#D1D5DB" />
<Text style={[styles.emptyStateTitle, { color: colorTokens.text }]}>
{title}
</Text>
<Text style={[styles.emptyStateSubtitle, { color: colorTokens.textSecondary }]}>
{subtitle}
</Text>
<TouchableOpacity
style={[styles.createButton, { backgroundColor: colorTokens.primary }]}
onPress={() => router.push('/(tabs)/goals')}
>
<Text style={styles.createButtonText}></Text>
</TouchableOpacity>
</View>
);
};
// 渲染加载更多
const renderLoadMore = () => {
if (!goalsPagination.hasMore) return null;
return (
<View style={styles.loadMoreContainer}>
<Text style={[styles.loadMoreText, { color: colorTokens.textSecondary }]}>
{goalsLoading ? '加载中...' : '上拉加载更多'}
</Text>
</View>
);
};
return (
<SafeAreaView style={styles.container}>
<StatusBar
backgroundColor="transparent"
translucent
/>
{/* 背景渐变 */}
<LinearGradient
colors={['#F8FAFC', '#F1F5F9']}
style={styles.gradientBackground}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
<View style={styles.content}>
{/* 标题区域 */}
<View style={styles.header}>
<TouchableOpacity
style={styles.backButton}
onPress={() => router.back()}
>
<MaterialIcons name="arrow-back" size={24} color={colorTokens.text} />
</TouchableOpacity>
<Text style={[styles.pageTitle, { color: colorTokens.text }]}>
</Text>
<TouchableOpacity
style={styles.addButton}
onPress={() => router.push('/(tabs)/goals')}
>
<MaterialIcons name="add" size={24} color="#FFFFFF" />
</TouchableOpacity>
</View>
{/* 目标列表 */}
<View style={styles.goalsListContainer}>
<FlatList
data={filteredGoals}
renderItem={renderGoalItem}
keyExtractor={(item) => item.id}
contentContainerStyle={styles.goalsList}
showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
colors={['#7A5AF8']}
tintColor="#7A5AF8"
/>
}
onEndReached={handleLoadMoreGoals}
onEndReachedThreshold={0.1}
ListEmptyComponent={renderEmptyState}
ListFooterComponent={renderLoadMore}
/>
</View>
{/* 编辑目标弹窗 */}
{editingGoal && (
<CreateGoalModal
visible={showEditModal}
onClose={handleCloseEditModal}
onSubmit={() => {}} // 编辑模式下不使用这个回调
onUpdate={handleUpdateGoal}
loading={updateLoading}
initialData={convertGoalToModalData(editingGoal)}
editGoalId={editingGoal.id}
/>
)}
</View>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
gradientBackground: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
},
content: {
flex: 1,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 20,
paddingTop: 20,
paddingBottom: 16,
},
backButton: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: 'rgba(255, 255, 255, 0.8)',
alignItems: 'center',
justifyContent: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
pageTitle: {
fontSize: 24,
fontWeight: '700',
flex: 1,
textAlign: 'center',
},
addButton: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: '#7A5AF8',
alignItems: 'center',
justifyContent: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
goalsListContainer: {
flex: 1,
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
overflow: 'hidden',
},
goalsList: {
paddingHorizontal: 20,
paddingTop: 20,
paddingBottom: TAB_BAR_HEIGHT + TAB_BAR_BOTTOM_OFFSET + 20,
},
emptyState: {
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 80,
},
emptyStateTitle: {
fontSize: 18,
fontWeight: '600',
marginTop: 16,
marginBottom: 8,
},
emptyStateSubtitle: {
fontSize: 14,
textAlign: 'center',
lineHeight: 20,
marginBottom: 24,
paddingHorizontal: 40,
},
createButton: {
paddingHorizontal: 24,
paddingVertical: 12,
borderRadius: 20,
},
createButtonText: {
color: '#FFFFFF',
fontSize: 16,
fontWeight: '600',
},
loadMoreContainer: {
alignItems: 'center',
paddingVertical: 20,
},
loadMoreText: {
fontSize: 14,
fontWeight: '500',
},
});

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

@@ -1,9 +1,10 @@
import { ThemedView } from '@/components/ThemedView';
import { ROUTES } from '@/constants/Routes';
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';
import { ActivityIndicator, View } from 'react-native';
const ONBOARDING_COMPLETED_KEY = '@onboarding_completed';
@@ -18,25 +19,26 @@ export default function SplashScreen() {
const checkOnboardingStatus = async () => {
try {
const onboardingCompleted = await AsyncStorage.getItem(ONBOARDING_COMPLETED_KEY);
// 先预加载用户数据,这样进入应用时就有正确的 token 状态
console.log('开始预加载用户数据...');
await preloadUserData();
console.log('用户数据预加载完成');
// 添加一个短暂的延迟以显示启动画面
setTimeout(() => {
if (onboardingCompleted === 'true') {
router.replace('/(tabs)');
} else {
router.replace('/onboarding');
}
setIsLoading(false);
}, 1000);
// const onboardingCompleted = await AsyncStorage.getItem(ONBOARDING_COMPLETED_KEY);
// if (onboardingCompleted === 'true') {
// router.replace('/(tabs)');
// } else {
// router.replace('/onboarding');
// }
// setIsLoading(false);
router.replace(ROUTES.TAB_STATISTICS);
} 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 +56,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>

289
app/mood-statistics.tsx Normal file
View File

@@ -0,0 +1,289 @@
import { MoodHistoryCard } from '@/components/MoodHistoryCard';
import { Colors } from '@/constants/Colors';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useAuthGuard } from '@/hooks/useAuthGuard';
import { useColorScheme } from '@/hooks/useColorScheme';
import {
fetchMoodHistory,
fetchMoodStatistics,
selectMoodLoading,
selectMoodRecords,
selectMoodStatistics
} from '@/store/moodSlice';
import { HeaderBar } from '@/components/ui/HeaderBar';
import dayjs from 'dayjs';
import { LinearGradient } from 'expo-linear-gradient';
import { router } from 'expo-router';
import React, { useEffect } from 'react';
import {
ActivityIndicator,
SafeAreaView,
ScrollView,
StyleSheet,
Text,
View,
} from 'react-native';
export default function MoodStatisticsScreen() {
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme];
const { isLoggedIn } = useAuthGuard();
const dispatch = useAppDispatch();
// 从 Redux 获取数据
const moodRecords = useAppSelector(selectMoodRecords);
const statistics = useAppSelector(selectMoodStatistics);
const loading = useAppSelector(selectMoodLoading);
// 获取最近30天的心情数据
const loadMoodData = async () => {
if (!isLoggedIn) return;
try {
const endDate = dayjs().format('YYYY-MM-DD');
const startDate = dayjs().subtract(30, 'days').format('YYYY-MM-DD');
// 并行加载历史记录和统计数据
await Promise.all([
dispatch(fetchMoodHistory({ startDate, endDate })),
dispatch(fetchMoodStatistics({ startDate, endDate }))
]);
} catch (error) {
console.error('加载心情数据失败:', error);
}
};
useEffect(() => {
loadMoodData();
}, [isLoggedIn, dispatch]);
// 将 moodRecords 转换为数组格式
const moodCheckins = Object.values(moodRecords).flat();
// 使用统一的渐变背景色
const backgroundGradientColors = [colorTokens.backgroundGradientStart, colorTokens.backgroundGradientEnd] as const;
if (!isLoggedIn) {
return (
<View style={styles.container}>
<LinearGradient
colors={backgroundGradientColors}
style={styles.gradientBackground}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
/>
<SafeAreaView style={styles.safeArea}>
<View style={styles.centerContainer}>
<Text style={styles.loginPrompt}></Text>
</View>
</SafeAreaView>
</View>
);
}
return (
<View style={styles.container}>
<LinearGradient
colors={backgroundGradientColors}
style={styles.gradientBackground}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
/>
<SafeAreaView style={styles.safeArea}>
<HeaderBar
title="心情统计"
onBack={() => router.back()}
withSafeTop={false}
transparent={true}
tone="light"
/>
<ScrollView style={styles.scrollView} showsVerticalScrollIndicator={false}>
{loading.history || loading.statistics ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colorTokens.primary} />
<Text style={styles.loadingText}>...</Text>
</View>
) : (
<>
{/* 统计概览 */}
{statistics && (
<View style={styles.statsOverview}>
<Text style={styles.sectionTitle}></Text>
<View style={styles.statsGrid}>
<View style={styles.statCard}>
<Text style={styles.statNumber}>{statistics.totalCheckins}</Text>
<Text style={styles.statLabel}></Text>
</View>
<View style={styles.statCard}>
<Text style={styles.statNumber}>{statistics.averageIntensity.toFixed(1)}</Text>
<Text style={styles.statLabel}></Text>
</View>
<View style={styles.statCard}>
<Text style={styles.statNumber}>
{statistics.mostFrequentMood ? statistics.moodDistribution[statistics.mostFrequentMood] || 0 : 0}
</Text>
<Text style={styles.statLabel}></Text>
</View>
</View>
</View>
)}
{/* 心情历史记录 */}
<MoodHistoryCard
moodCheckins={moodCheckins}
title="最近30天心情记录"
/>
{/* 心情分布 */}
{statistics && (
<View style={styles.distributionContainer}>
<Text style={styles.sectionTitle}></Text>
<View style={styles.distributionList}>
{Object.entries(statistics.moodDistribution)
.sort(([, a], [, b]) => b - a)
.map(([moodType, count]) => (
<View key={moodType} style={styles.distributionItem}>
<Text style={styles.moodType}>{moodType}</Text>
<View style={styles.countContainer}>
<Text style={styles.count}>{count}</Text>
<Text style={styles.percentage}>
({((count / statistics.totalCheckins) * 100).toFixed(1)}%)
</Text>
</View>
</View>
))}
</View>
</View>
)}
</>
)}
</ScrollView>
</SafeAreaView>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
gradientBackground: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
},
safeArea: {
flex: 1,
},
scrollView: {
flex: 1,
paddingHorizontal: 20,
},
centerContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
loginPrompt: {
fontSize: 16,
color: '#666',
textAlign: 'center',
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingVertical: 60,
},
loadingText: {
fontSize: 16,
color: '#666',
marginTop: 16,
},
sectionTitle: {
fontSize: 20,
fontWeight: '700',
color: '#192126',
marginBottom: 16,
},
statsOverview: {
marginBottom: 24,
},
statsGrid: {
flexDirection: 'row',
justifyContent: 'space-between',
gap: 12,
},
statCard: {
flex: 1,
backgroundColor: '#FFFFFF',
borderRadius: 16,
padding: 20,
alignItems: 'center',
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.1,
shadowRadius: 3.84,
elevation: 5,
},
statNumber: {
fontSize: 24,
fontWeight: '800',
color: '#192126',
marginBottom: 8,
},
statLabel: {
fontSize: 14,
color: '#6B7280',
textAlign: 'center',
},
distributionContainer: {
backgroundColor: '#FFFFFF',
borderRadius: 16,
padding: 16,
marginBottom: 24,
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.1,
shadowRadius: 3.84,
elevation: 5,
},
distributionList: {
gap: 12,
},
distributionItem: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingVertical: 8,
},
moodType: {
fontSize: 16,
fontWeight: '500',
color: '#192126',
},
countContainer: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
count: {
fontSize: 16,
fontWeight: '600',
color: '#192126',
},
percentage: {
fontSize: 14,
color: '#6B7280',
},
});

718
app/mood/calendar.tsx Normal file
View File

@@ -0,0 +1,718 @@
import { HeaderBar } from '@/components/ui/HeaderBar';
import { useAppSelector } from '@/hooks/redux';
import { useMoodData } from '@/hooks/useMoodData';
import { getMoodOptions } from '@/services/moodCheckins';
import { selectLatestMoodRecordByDate } from '@/store/moodSlice';
import dayjs from 'dayjs';
import { LinearGradient } from 'expo-linear-gradient';
import { router, useFocusEffect, useLocalSearchParams } from 'expo-router';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import {
Dimensions, Image, SafeAreaView,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View
} from 'react-native';
const { width } = Dimensions.get('window');
// 心情日历数据生成函数
const generateCalendarData = (targetDate: Date) => {
// 使用 dayjs 确保时区一致性
const targetDayjs = dayjs(targetDate);
const year = targetDayjs.year();
const month = targetDayjs.month(); // dayjs month is 0-based
const daysInMonth = targetDayjs.daysInMonth();
// 使用 dayjs 获取月初第一天是周几0=周日1=周一...6=周六)
const firstDayOfWeek = targetDayjs.startOf('month').day();
// 转换为中国习惯(周一为一周开始):周日(0)转为6其他减1
const firstDayAdjusted = firstDayOfWeek === 0 ? 6 : firstDayOfWeek - 1;
const calendar = [];
const weeks = [];
// 添加空白日期(基于周一开始)
for (let i = 0; i < firstDayAdjusted; i++) {
weeks.push(null);
}
// 添加实际日期
for (let day = 1; day <= daysInMonth; day++) {
weeks.push(day);
}
// 按周分组
for (let i = 0; i < weeks.length; i += 7) {
calendar.push(weeks.slice(i, i + 7));
}
// 使用 dayjs 获取今天的日期,确保时区一致
const today = dayjs();
return {
calendar,
today: today.date(),
month: month + 1, // 转回1-based用于显示
year
};
};
export default function MoodCalendarScreen() {
const params = useLocalSearchParams();
const { fetchMoodRecords, fetchMoodHistoryRecords } = useMoodData();
// 使用 useRef 来存储函数引用,避免依赖循环
const fetchMoodRecordsRef = useRef(fetchMoodRecords);
const fetchMoodHistoryRecordsRef = useRef(fetchMoodHistoryRecords);
// 更新 ref 值
fetchMoodRecordsRef.current = fetchMoodRecords;
fetchMoodHistoryRecordsRef.current = fetchMoodHistoryRecords;
const { selectedDate } = params;
const initialDate = selectedDate ? dayjs(selectedDate as string).toDate() : new Date();
const [currentMonth, setCurrentMonth] = useState(initialDate);
const [selectedDay, setSelectedDay] = useState<number | null>(null);
// 使用 Redux store 中的数据
const moodRecords = useAppSelector(state => state.mood.moodRecords);
// 获取选中日期的数据
const selectedDateMood = useAppSelector(state => {
if (!selectedDay) return null;
const selectedDateString = dayjs(currentMonth).date(selectedDay).format('YYYY-MM-DD');
return selectLatestMoodRecordByDate(selectedDateString)(state);
});
const moodOptions = getMoodOptions();
const weekDays = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
const monthNames = ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'];
// 生成当前月份的日历数据
const { calendar, today, month, year } = generateCalendarData(currentMonth);
// 加载整个月份的心情数据
const loadMonthMoodData = useCallback(async (targetMonth: Date) => {
try {
const startDate = dayjs(targetMonth).startOf('month').format('YYYY-MM-DD');
const endDate = dayjs(targetMonth).endOf('month').format('YYYY-MM-DD');
await fetchMoodHistoryRecordsRef.current({ startDate, endDate });
} catch (error) {
console.error('加载月份心情数据失败:', error);
}
}, []);
// 加载选中日期的心情记录
const loadDailyMoodCheckins = useCallback(async (dateString: string) => {
try {
await fetchMoodRecordsRef.current(dateString);
} catch (error) {
console.error('加载心情记录失败:', error);
}
}, []);
// 初始化选中日期
useEffect(() => {
if (selectedDate) {
const date = dayjs(selectedDate as string);
setCurrentMonth(date.toDate());
setSelectedDay(date.date());
const dateString = date.format('YYYY-MM-DD');
loadDailyMoodCheckins(dateString);
loadMonthMoodData(date.toDate());
} else {
const today = dayjs().toDate();
setCurrentMonth(today);
setSelectedDay(dayjs().date());
const dateString = dayjs().format('YYYY-MM-DD');
loadDailyMoodCheckins(dateString);
loadMonthMoodData(today);
}
}, [selectedDate, loadDailyMoodCheckins, loadMonthMoodData]);
// 监听页面焦点变化,当从编辑页面返回时刷新数据
useFocusEffect(
useCallback(() => {
// 当页面获得焦点时,刷新当前月份的数据和选中日期的数据
const refreshData = async () => {
if (selectedDay) {
const selectedDateString = dayjs(currentMonth).date(selectedDay).format('YYYY-MM-DD');
await fetchMoodRecordsRef.current(selectedDateString);
}
const startDate = dayjs(currentMonth).startOf('month').format('YYYY-MM-DD');
const endDate = dayjs(currentMonth).endOf('month').format('YYYY-MM-DD');
await fetchMoodHistoryRecordsRef.current({ startDate, endDate });
};
refreshData();
}, [currentMonth, selectedDay])
);
// 月份切换函数
const goToPreviousMonth = () => {
const newMonth = dayjs(currentMonth).subtract(1, 'month').toDate();
setCurrentMonth(newMonth);
setSelectedDay(null);
loadMonthMoodData(newMonth);
};
const goToNextMonth = () => {
const newMonth = dayjs(currentMonth).add(1, 'month').toDate();
setCurrentMonth(newMonth);
setSelectedDay(null);
loadMonthMoodData(newMonth);
};
// 日期选择函数
const onSelectDate = (day: number) => {
setSelectedDay(day);
const selectedDateString = dayjs(currentMonth).date(day).format('YYYY-MM-DD');
loadDailyMoodCheckins(selectedDateString);
};
// 跳转到心情编辑页面
const openMoodEdit = () => {
const selectedDateString = selectedDay ? dayjs(currentMonth).date(selectedDay).format('YYYY-MM-DD') : dayjs().format('YYYY-MM-DD');
const moodId = selectedDateMood?.id;
router.push({
pathname: '/mood/edit',
params: {
date: selectedDateString,
...(moodId && { moodId })
}
});
};
const renderMoodRing = (day: number | null, isSelected: boolean) => {
if (!day) return null;
// 检查该日期是否有心情记录 - 现在从 Redux store 中获取
const dayDateString = dayjs(currentMonth).date(day).format('YYYY-MM-DD');
const dayRecords = moodRecords[dayDateString] || [];
const moodRecord = dayRecords.length > 0 ? dayRecords[0] : null;
const isToday = day === dayjs().date() &&
month === dayjs().month() + 1 &&
year === dayjs().year();
if (moodRecord) {
const mood = moodOptions.find(m => m.type === moodRecord.moodType);
return (
<View style={isToday ? styles.todayMoodIconContainer : styles.moodIconContainer}>
<View style={styles.moodIcon}>
<Image
source={mood?.image}
style={styles.moodIconImage}
/>
</View>
</View>
);
}
return (
<View style={isToday ? styles.todayDefaultMoodIcon : styles.defaultMoodIcon}>
</View>
);
};
return (
<View style={styles.container}>
<LinearGradient
colors={['#fafaff', '#f4f3ff']} // 使用紫色主题的浅色渐变
style={styles.gradientBackground}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
/>
{/* 装饰性圆圈 */}
<View style={styles.decorativeCircle1} />
<View style={styles.decorativeCircle2} />
<SafeAreaView style={styles.safeArea}>
<HeaderBar
title="心情日历"
onBack={() => router.back()}
withSafeTop={false}
transparent={true}
tone="light"
/>
<ScrollView style={styles.content}>
{/* 日历视图 */}
<View style={styles.calendar}>
{/* 月份导航 */}
<View style={styles.monthNavigation}>
<TouchableOpacity
style={styles.navButton}
onPress={goToPreviousMonth}
>
<Text style={styles.navButtonText}></Text>
</TouchableOpacity>
<Text style={styles.monthTitle}>{year}{monthNames[month - 1]}</Text>
<TouchableOpacity
style={styles.navButton}
onPress={goToNextMonth}
>
<Text style={styles.navButtonText}></Text>
</TouchableOpacity>
</View>
<View style={styles.weekHeader}>
{weekDays.map((day, index) => (
<Text key={index} style={styles.weekDay}>{day}</Text>
))}
</View>
<View >
{calendar.map((week, weekIndex) => (
<View key={weekIndex} style={styles.weekRow}>
{week.map((day, dayIndex) => {
const isSelected = day === selectedDay;
const isToday = day === today && month === dayjs().month() + 1 && year === dayjs().year();
const isFutureDate = Boolean(day && dayjs(currentMonth).date(day).isAfter(dayjs(), 'day'));
return (
<View key={dayIndex} style={styles.dayContainer}>
{day && (
<TouchableOpacity
style={[
styles.dayButton,
isSelected && styles.dayButtonSelected,
isToday && styles.dayButtonToday
]}
onPress={() => !isFutureDate && day && onSelectDate(day)}
disabled={isFutureDate}
>
<View style={styles.dayContent}>
<Text style={[
styles.dayNumber,
isSelected && styles.dayNumberSelected,
isToday && styles.dayNumberToday,
isFutureDate && styles.dayNumberDisabled
]}>
{day.toString().padStart(2, '0')}
</Text>
{renderMoodRing(day, isSelected)}
</View>
</TouchableOpacity>
)}
</View>
);
})}
</View>
))}
</View>
</View>
{/* 选中日期的记录 */}
<View style={styles.selectedDateSection}>
<View style={styles.selectedDateHeader}>
<Text style={styles.selectedDateTitle}>
{selectedDay ? dayjs(currentMonth).date(selectedDay).format('YYYY年M月D日') : '请选择日期'}
</Text>
<TouchableOpacity
style={styles.addMoodButton}
onPress={openMoodEdit}
>
<Text style={styles.addMoodButtonText}></Text>
</TouchableOpacity>
</View>
{selectedDay ? (
selectedDateMood ? (
<TouchableOpacity
style={styles.moodRecord}
onPress={openMoodEdit}
>
<View style={styles.recordIcon}>
<View style={styles.moodIcon}>
<Image
source={moodOptions.find(m => m.type === selectedDateMood.moodType)?.image}
style={styles.moodIconImage}
/>
</View>
</View>
<View style={styles.recordContent}>
<Text style={styles.recordMood}>
{moodOptions.find(m => m.type === selectedDateMood.moodType)?.label}
</Text>
<Text style={styles.recordIntensity}>: {selectedDateMood.intensity}</Text>
{selectedDateMood.description && (
<Text style={styles.recordDescription}>{selectedDateMood.description}</Text>
)}
</View>
<View style={styles.spacer} />
<Text style={styles.recordTime}>
{dayjs(selectedDateMood.createdAt).format('HH:mm')}
</Text>
</TouchableOpacity>
) : (
<View style={styles.emptyRecord}>
<Text style={styles.emptyRecordText}></Text>
<Text style={styles.emptyRecordSubtext}>"记录"</Text>
</View>
)
) : (
<View style={styles.emptyRecord}>
<Text style={styles.emptyRecordText}></Text>
<Text style={styles.emptyRecordSubtext}>"记录"</Text>
</View>
)}
</View>
</ScrollView>
</SafeAreaView>
</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: '#7a5af8',
opacity: 0.08,
},
decorativeCircle2: {
position: 'absolute',
bottom: -15,
left: -15,
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: '#7a5af8',
opacity: 0.04,
},
safeArea: {
flex: 1,
},
content: {
flex: 1,
},
calendar: {
backgroundColor: 'rgba(255,255,255,0.95)',
margin: 16,
borderRadius: 20,
justifyContent: 'center',
alignItems: 'center',
padding: 20,
shadowColor: '#7a5af8',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.1,
shadowRadius: 12,
elevation: 6,
},
monthNavigation: {
flexDirection: 'row',
width: '100%',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 24,
},
navButton: {
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: 'rgba(122,90,248,0.1)',
justifyContent: 'center',
alignItems: 'center',
shadowColor: '#7a5af8',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 2,
},
navButtonText: {
fontSize: 24,
color: '#7a5af8',
fontWeight: '700',
},
monthTitle: {
fontSize: 20,
fontWeight: '800',
color: '#192126',
},
weekHeader: {
flexDirection: 'row',
justifyContent: 'flex-start',
marginBottom: 20,
},
weekDay: {
fontSize: 13,
color: '#5d6676',
textAlign: 'center',
width: (width - 96) / 7,
fontWeight: '600',
},
weekRow: {
flexDirection: 'row',
justifyContent: 'flex-start',
marginBottom: 16,
},
dayContainer: {
width: (width - 96) / 7,
alignItems: 'center',
},
dayButton: {
width: 44,
height: 44,
borderRadius: 22,
justifyContent: 'center',
alignItems: 'center',
marginBottom: 8,
backgroundColor: 'transparent',
},
dayButtonSelected: {
backgroundColor: '#FFFFFF',
shadowColor: '#7a5af8',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.15,
shadowRadius: 6,
elevation: 4,
},
dayButtonToday: {
borderWidth: 2,
borderColor: '#7a5af8',
},
dayContent: {
position: 'relative',
width: '100%',
height: '100%',
justifyContent: 'center',
alignItems: 'center',
},
dayNumber: {
fontSize: 14,
color: '#777f8c',
fontWeight: '600',
position: 'absolute',
top: 2,
zIndex: 1,
},
dayNumberSelected: {
color: '#192126',
fontWeight: '700',
},
dayNumberToday: {
color: '#7a5af8',
fontWeight: '700',
},
dayNumberDisabled: {
color: '#c0c4ca',
},
moodIconContainer: {
position: 'absolute',
bottom: 2,
width: 22,
height: 22,
borderRadius: 11,
justifyContent: 'center',
alignItems: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.1,
shadowRadius: 2,
elevation: 1,
},
todayMoodIconContainer: {
position: 'absolute',
bottom: 1,
width: 20,
height: 20,
borderRadius: 10,
justifyContent: 'center',
alignItems: 'center',
shadowColor: '#7a5af8',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.2,
shadowRadius: 2,
elevation: 2,
},
moodIcon: {
width: 18,
height: 18,
borderRadius: 9,
backgroundColor: 'rgba(255,255,255,0.95)',
justifyContent: 'center',
alignItems: 'center',
},
moodIconImage: {
width: 28,
height: 28,
borderRadius: 9,
},
defaultMoodIcon: {
position: 'absolute',
bottom: 2,
width: 22,
height: 22,
borderRadius: 11,
borderWidth: 1.5,
borderColor: 'rgba(122,90,248,0.3)',
borderStyle: 'dashed',
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'rgba(122,90,248,0.05)',
},
todayDefaultMoodIcon: {
position: 'absolute',
bottom: 1,
width: 20,
height: 20,
borderRadius: 10,
borderWidth: 1.5,
borderColor: 'rgba(122,90,248,0.4)',
borderStyle: 'dashed',
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'rgba(122,90,248,0.08)',
},
moodRingContainer: {
position: 'absolute',
bottom: 2,
width: 22,
height: 22,
justifyContent: 'center',
alignItems: 'center',
},
moodIntensityText: {
fontSize: 8,
fontWeight: '800',
textAlign: 'center',
position: 'absolute',
zIndex: 1,
textShadowColor: 'rgba(0,0,0,0.3)',
textShadowOffset: { width: 0, height: 0.5 },
textShadowRadius: 1,
},
selectedDateSection: {
backgroundColor: 'rgba(255,255,255,0.95)',
margin: 16,
marginTop: 0,
borderRadius: 20,
padding: 20,
shadowColor: '#7a5af8',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.1,
shadowRadius: 12,
elevation: 6,
},
selectedDateHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 20,
},
selectedDateTitle: {
fontSize: 22,
fontWeight: '800',
color: '#192126',
},
addMoodButton: {
paddingHorizontal: 20,
height: 36,
borderRadius: 18,
backgroundColor: '#7a5af8',
justifyContent: 'center',
alignItems: 'center',
shadowColor: '#7a5af8',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.2,
shadowRadius: 4,
elevation: 3,
},
addMoodButtonText: {
color: '#fff',
fontSize: 14,
fontWeight: '700',
},
moodRecord: {
flexDirection: 'row',
alignItems: 'flex-start',
paddingVertical: 16,
backgroundColor: 'rgba(122,90,248,0.05)',
borderRadius: 16,
paddingHorizontal: 16,
},
recordIcon: {
width: 52,
height: 52,
borderRadius: 26,
backgroundColor: '#e9e7f1ff',
justifyContent: 'center',
alignItems: 'center',
marginRight: 16,
shadowColor: '#7a5af8',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.15,
shadowRadius: 4,
elevation: 2,
},
recordContent: {
flex: 1,
},
recordMood: {
fontSize: 18,
color: '#192126',
fontWeight: '700',
marginBottom: 4,
},
recordIntensity: {
fontSize: 14,
color: '#5d6676',
marginTop: 2,
fontWeight: '500',
},
recordDescription: {
fontSize: 14,
color: '#5d6676',
marginTop: 6,
fontStyle: 'italic',
lineHeight: 20,
},
spacer: {
flex: 1,
},
recordTime: {
fontSize: 14,
color: '#777f8c',
fontWeight: '500',
},
emptyRecord: {
alignItems: 'center',
paddingVertical: 32,
},
emptyRecordText: {
fontSize: 16,
color: '#5d6676',
marginBottom: 8,
fontWeight: '600',
},
emptyRecordSubtext: {
fontSize: 13,
color: '#777f8c',
textAlign: 'center',
lineHeight: 18,
},
});

530
app/mood/edit.tsx Normal file
View File

@@ -0,0 +1,530 @@
import MoodIntensitySlider from '@/components/MoodIntensitySlider';
import { HeaderBar } from '@/components/ui/HeaderBar';
import { Colors } from '@/constants/Colors';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useColorScheme } from '@/hooks/useColorScheme';
import { getMoodOptions, MoodType } from '@/services/moodCheckins';
import {
createMoodRecord,
deleteMoodRecord,
fetchDailyMoodCheckins,
selectMoodRecordsByDate,
updateMoodRecord
} from '@/store/moodSlice';
import { Ionicons } from '@expo/vector-icons';
import dayjs from 'dayjs';
import { LinearGradient } from 'expo-linear-gradient';
import { router, useLocalSearchParams } from 'expo-router';
import React, { useEffect, useRef, useState } from 'react';
import {
Alert, Image,
Keyboard,
KeyboardAvoidingView,
Platform,
ScrollView,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
View
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
export default function MoodEditScreen() {
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme];
const params = useLocalSearchParams();
const dispatch = useAppDispatch();
const { date, moodId } = params;
const selectedDate = date as string || dayjs().format('YYYY-MM-DD');
const [selectedMood, setSelectedMood] = useState<MoodType | ''>('');
const [intensity, setIntensity] = useState(5);
const [description, setDescription] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [existingMood, setExistingMood] = useState<any>(null);
const scrollViewRef = useRef<ScrollView>(null);
const textInputRef = useRef<TextInput>(null);
const moodOptions = getMoodOptions();
// 从 Redux 获取数据
const moodRecords = useAppSelector(selectMoodRecordsByDate(selectedDate));
const loading = useAppSelector(state => state.mood.loading);
// 初始化数据
useEffect(() => {
// 加载当前日期的心情记录
dispatch(fetchDailyMoodCheckins(selectedDate));
}, [selectedDate, dispatch]);
// 当 moodRecords 更新时,查找现有记录
useEffect(() => {
if (moodId && moodRecords.length > 0) {
const mood = moodRecords.find((c: any) => c.id === moodId) || moodRecords[0];
setExistingMood(mood);
setSelectedMood(mood.moodType);
setIntensity(mood.intensity);
setDescription(mood.description || '');
}
}, [moodId, moodRecords]);
// 键盘事件监听器
useEffect(() => {
const keyboardDidShowListener = Keyboard.addListener('keyboardDidShow', () => {
// 键盘出现时,延迟滚动到文本输入框
setTimeout(() => {
scrollViewRef.current?.scrollToEnd({ animated: true });
}, 100);
});
const keyboardDidHideListener = Keyboard.addListener('keyboardDidHide', () => {
// 键盘隐藏时,可以进行必要的调整
});
return () => {
keyboardDidShowListener?.remove();
keyboardDidHideListener?.remove();
};
}, []);
const handleSave = async () => {
if (!selectedMood) {
Alert.alert('提示', '请选择心情');
return;
}
try {
setIsLoading(true);
if (existingMood) {
// 更新现有记录
await dispatch(updateMoodRecord({
id: existingMood.id,
moodType: selectedMood,
intensity,
description: description.trim() || undefined,
})).unwrap();
} else {
// 创建新记录
await dispatch(createMoodRecord({
moodType: selectedMood,
intensity,
description: description.trim() || undefined,
checkinDate: selectedDate,
})).unwrap();
}
Alert.alert('成功', existingMood ? '心情记录已更新' : '心情记录已保存', [
{ text: '确定', onPress: () => router.back() }
]);
} catch (error) {
console.error('保存心情失败:', error);
Alert.alert('错误', '保存心情失败,请重试');
} finally {
setIsLoading(false);
}
};
const handleDelete = async () => {
if (!existingMood) return;
Alert.alert(
'确认删除',
'确定要删除这条心情记录吗?',
[
{ text: '取消', style: 'cancel' },
{
text: '删除',
style: 'destructive',
onPress: async () => {
try {
setIsDeleting(true);
await dispatch(deleteMoodRecord({ id: existingMood.id })).unwrap();
Alert.alert('成功', '心情记录已删除', [
{ text: '确定', onPress: () => router.back() }
]);
} catch (error) {
console.error('删除心情失败:', error);
Alert.alert('错误', '删除心情失败,请重试');
} finally {
setIsDeleting(false);
}
},
},
]
);
};
const handleIntensityChange = (value: number) => {
setIntensity(value);
};
// 使用统一的渐变背景色
const backgroundGradientColors = [colorTokens.backgroundGradientStart, colorTokens.backgroundGradientEnd] as const;
return (
<View style={styles.container}>
<LinearGradient
colors={['#fafaff', '#f4f3ff']} // 使用紫色主题的浅色渐变
style={styles.gradientBackground}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
/>
{/* 装饰性圆圈 */}
<View style={styles.decorativeCircle1} />
<View style={styles.decorativeCircle2} />
<SafeAreaView style={styles.safeArea} edges={['top']}>
<HeaderBar
title={existingMood ? '编辑心情' : '记录心情'}
onBack={() => router.back()}
withSafeTop={false}
transparent={true}
tone="light"
/>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.keyboardAvoidingView}
keyboardVerticalOffset={Platform.OS === 'ios' ? 0 : 0}
>
<ScrollView
ref={scrollViewRef}
style={styles.content}
contentContainerStyle={styles.scrollContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{/* 日期显示 */}
<View style={styles.dateSection}>
<Text style={styles.dateTitle}>
{dayjs(selectedDate).format('YYYY年M月D日')}
</Text>
</View>
{/* 心情选择 */}
<View style={styles.moodSection}>
<Text style={styles.sectionTitle}></Text>
<View style={styles.moodOptions}>
{moodOptions.map((mood, index) => (
<TouchableOpacity
key={index}
style={[
styles.moodOption,
selectedMood === mood.type && styles.selectedMoodOption
]}
onPress={() => setSelectedMood(mood.type)}
>
<Image source={mood.image} style={styles.moodImage} />
<Text style={styles.moodLabel}>{mood.label}</Text>
</TouchableOpacity>
))}
</View>
</View>
{/* 心情强度选择 */}
<View style={styles.intensitySection}>
<Text style={styles.sectionTitle}></Text>
<MoodIntensitySlider
value={intensity}
onValueChange={handleIntensityChange}
min={1}
max={10}
width={280}
height={12}
/>
</View>
{/* 心情描述 */}
<View style={styles.descriptionSection}>
<Text style={styles.sectionTitle}></Text>
<Text style={styles.diarySubtitle}></Text>
<TextInput
ref={textInputRef}
style={styles.descriptionInput}
placeholder={`今天的心情如何?
你经历过什么特别的事情吗?
有什么让你开心的事?
或者,有什么让你感到困扰?
写下你的感受,让这些时刻成为你珍贵的记忆...`}
placeholderTextColor="#a8a8a8"
value={description}
onChangeText={setDescription}
multiline
maxLength={1000}
textAlignVertical="top"
onFocus={() => {
// 当文本输入框获得焦点时,滚动到输入框
setTimeout(() => {
scrollViewRef.current?.scrollToEnd({ animated: true });
}, 300);
}}
/>
<Text style={styles.characterCount}>{description.length}/1000</Text>
</View>
</ScrollView>
</KeyboardAvoidingView>
{/* 底部按钮 */}
<View style={styles.footer}>
<View style={styles.buttonRow}>
<TouchableOpacity
style={[styles.saveButton, (!selectedMood || isLoading) && styles.disabledButton]}
onPress={handleSave}
disabled={!selectedMood || isLoading}
>
<Text style={styles.saveButtonText}>
{isLoading ? '保存中...' : existingMood ? '更新心情' : '保存心情'}
</Text>
</TouchableOpacity>
{existingMood && (
<TouchableOpacity
style={[styles.deleteIconButton, isDeleting && styles.disabledButton]}
onPress={handleDelete}
disabled={isDeleting}
>
<Ionicons name="trash-outline" size={20} color="#f95555" />
</TouchableOpacity>
)}
</View>
</View>
</SafeAreaView>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
gradientBackground: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
},
decorativeCircle1: {
position: 'absolute',
top: 30,
right: 15,
width: 45,
height: 45,
borderRadius: 22.5,
backgroundColor: '#7a5af8',
opacity: 0.06,
},
decorativeCircle2: {
position: 'absolute',
bottom: -10,
left: -10,
width: 30,
height: 30,
borderRadius: 15,
backgroundColor: '#7a5af8',
opacity: 0.04,
},
safeArea: {
flex: 1,
},
keyboardAvoidingView: {
flex: 1,
},
content: {
flex: 1,
},
scrollContent: {
paddingBottom: 100, // 为底部按钮留出空间
},
dateSection: {
backgroundColor: 'rgba(255,255,255,0.95)',
margin: 12,
borderRadius: 16,
padding: 16,
alignItems: 'center',
shadowColor: '#7a5af8',
shadowOffset: { width: 0, height: 3 },
shadowOpacity: 0.08,
shadowRadius: 8,
elevation: 4,
},
dateTitle: {
fontSize: 20,
fontWeight: '700',
color: '#192126',
},
moodSection: {
backgroundColor: 'rgba(255,255,255,0.95)',
margin: 12,
marginTop: 0,
borderRadius: 16,
padding: 16,
shadowColor: '#7a5af8',
shadowOffset: { width: 0, height: 3 },
shadowOpacity: 0.08,
shadowRadius: 8,
elevation: 4,
},
sectionTitle: {
fontSize: 16,
fontWeight: '600',
color: '#192126',
marginBottom: 16,
},
moodOptions: {
flexDirection: 'row',
flexWrap: 'wrap',
justifyContent: 'space-between',
},
moodOption: {
width: '18%',
alignItems: 'center',
paddingVertical: 12,
marginBottom: 8,
borderRadius: 12,
backgroundColor: 'rgba(122,90,248,0.05)',
borderWidth: 1,
borderColor: 'rgba(122,90,248,0.1)',
},
selectedMoodOption: {
backgroundColor: 'rgba(122,90,248,0.15)',
borderWidth: 2,
borderColor: '#7a5af8',
shadowColor: '#7a5af8',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.12,
shadowRadius: 3,
elevation: 2,
},
moodImage: {
width: 32,
height: 32,
marginBottom: 6,
},
moodLabel: {
fontSize: 12,
color: '#192126',
fontWeight: '500',
},
intensitySection: {
backgroundColor: 'rgba(255,255,255,0.95)',
margin: 12,
marginTop: 0,
borderRadius: 16,
padding: 16,
shadowColor: '#7a5af8',
shadowOffset: { width: 0, height: 3 },
shadowOpacity: 0.08,
shadowRadius: 8,
elevation: 4,
},
descriptionSection: {
backgroundColor: 'rgba(255,255,255,0.95)',
margin: 12,
marginTop: 0,
borderRadius: 16,
padding: 16,
shadowColor: '#7a5af8',
shadowOffset: { width: 0, height: 3 },
shadowOpacity: 0.08,
shadowRadius: 8,
elevation: 4,
},
diarySubtitle: {
fontSize: 13,
color: '#666',
fontWeight: '500',
marginBottom: 12,
lineHeight: 18,
},
descriptionInput: {
borderWidth: 1.5,
borderColor: 'rgba(122,90,248,0.2)',
borderRadius: 10,
padding: 12,
fontSize: 14,
minHeight: 120,
textAlignVertical: 'top',
backgroundColor: 'rgba(122,90,248,0.02)',
color: '#192126',
lineHeight: 20,
},
characterCount: {
fontSize: 12,
color: '#777f8c',
textAlign: 'right',
marginTop: 8,
fontWeight: '500',
},
footer: {
padding: 12,
position: 'absolute',
bottom: 20,
right: 8,
},
buttonRow: {
flexDirection: 'row',
justifyContent: 'flex-end',
alignItems: 'center',
},
saveButton: {
backgroundColor: '#7a5af8',
borderRadius: 10,
paddingVertical: 10,
paddingHorizontal: 20,
alignItems: 'center',
marginLeft: 8,
shadowColor: '#7a5af8',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.15,
shadowRadius: 3,
elevation: 2,
},
deleteIconButton: {
width: 32,
height: 32,
borderRadius: 16,
justifyContent: 'center',
alignItems: 'center',
marginLeft: 8,
},
deleteButton: {
backgroundColor: '#f95555',
borderRadius: 12,
paddingVertical: 12,
paddingHorizontal: 24,
alignItems: 'center',
shadowColor: '#f95555',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.2,
shadowRadius: 4,
elevation: 3,
},
disabledButton: {
backgroundColor: '#c0c4ca',
shadowOpacity: 0,
elevation: 0,
},
saveButtonText: {
color: '#fff',
fontSize: 13,
fontWeight: '600',
},
deleteButtonText: {
color: '#fff',
fontSize: 14,
fontWeight: '600',
},
});

View File

@@ -0,0 +1,9 @@
import { Stack } from 'expo-router';
export default function NutritionLayout() {
return (
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="records" />
</Stack>
);
}

609
app/nutrition/records.tsx Normal file
View File

@@ -0,0 +1,609 @@
import { CalorieRingChart } from '@/components/CalorieRingChart';
import { DateSelector } from '@/components/DateSelector';
import { FloatingFoodOverlay } from '@/components/FloatingFoodOverlay';
import { NutritionRecordCard } from '@/components/NutritionRecordCard';
import { HeaderBar } from '@/components/ui/HeaderBar';
import { Colors } from '@/constants/Colors';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useColorScheme } from '@/hooks/useColorScheme';
import { DietRecord } from '@/services/dietRecords';
import { type FoodRecognitionResponse } from '@/services/foodRecognition';
import { saveRecognitionResult } from '@/store/foodRecognitionSlice';
import { selectHealthDataByDate } from '@/store/healthSlice';
import {
deleteNutritionRecord,
fetchDailyNutritionData,
fetchNutritionRecords,
selectNutritionLoading,
selectNutritionRecordsByDate,
selectNutritionSummaryByDate
} from '@/store/nutritionSlice';
import { getMonthDaysZh, getMonthTitleZh, getTodayIndexInMonth } from '@/utils/date';
import { fetchBasalEnergyBurned } from '@/utils/health';
import { Ionicons } from '@expo/vector-icons';
import { useFocusEffect } from '@react-navigation/native';
import dayjs from 'dayjs';
import { router } from 'expo-router';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
FlatList,
RefreshControl,
StyleSheet,
Text,
TouchableOpacity,
View
} from 'react-native';
type ViewMode = 'daily' | 'all';
export default function NutritionRecordsScreen() {
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme];
const dispatch = useAppDispatch();
// 日期相关状态 - 使用与统计页面相同的日期逻辑
const days = getMonthDaysZh();
const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth());
const monthTitle = getMonthTitleZh();
// 获取当前选中日期 - 使用 useMemo 避免每次渲染都创建新对象
const currentSelectedDate = useMemo(() => {
return days[selectedIndex]?.date?.toDate() ?? new Date();
}, [selectedIndex, days]);
const currentSelectedDateString = useMemo(() => {
return dayjs(currentSelectedDate).format('YYYY-MM-DD');
}, [currentSelectedDate]);
// 从 Redux 获取数据
const healthData = useAppSelector(selectHealthDataByDate(currentSelectedDateString));
const nutritionSummary = useAppSelector(selectNutritionSummaryByDate(currentSelectedDateString));
const userProfile = useAppSelector((state) => state.user.profile);
// 从 Redux 获取营养记录数据
const nutritionRecords = useAppSelector(selectNutritionRecordsByDate(currentSelectedDateString));
const nutritionLoading = useAppSelector(selectNutritionLoading);
// 视图模式:按天查看 vs 全部查看
const [viewMode, setViewMode] = useState<ViewMode>('daily');
// 全部记录模式的本地状态
const [allRecords, setAllRecords] = useState<DietRecord[]>([]);
const [allRecordsLoading, setAllRecordsLoading] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [hasMoreData, setHasMoreData] = useState(true);
const [page, setPage] = useState(1);
// 基础代谢数据状态
const [basalMetabolism, setBasalMetabolism] = useState<number>(1482);
// 食物添加弹窗状态
const [showFoodOverlay, setShowFoodOverlay] = useState(false);
// 根据视图模式选择使用的数据
const displayRecords = viewMode === 'daily' ? nutritionRecords : allRecords;
const loading = viewMode === 'daily' ? nutritionLoading.records : allRecordsLoading;
// 页面聚焦时自动刷新数据
useFocusEffect(
useCallback(() => {
console.log('营养记录页面聚焦,刷新数据...');
if (viewMode === 'daily') {
dispatch(fetchDailyNutritionData(currentSelectedDate));
} else {
// 全部记录模式:重新加载数据
const loadAllRecords = async () => {
try {
setAllRecordsLoading(true);
const response = await dispatch(fetchNutritionRecords({
page: 1,
limit: 10,
append: false,
}));
if (fetchNutritionRecords.fulfilled.match(response)) {
const { records } = response.payload;
setAllRecords(records);
setHasMoreData(records.length === 10);
setPage(1);
}
setAllRecordsLoading(false);
} catch (error) {
console.error('加载全部记录失败:', error);
setAllRecordsLoading(false);
}
};
loadAllRecords();
}
}, [viewMode, currentSelectedDateString, dispatch])
);
// 当选中日期或视图模式变化时重新加载数据
useEffect(() => {
fetchBasalMetabolismData();
if (viewMode === 'daily') {
dispatch(fetchDailyNutritionData(currentSelectedDate));
} else {
setPage(1); // 重置分页
setAllRecords([]); // 清空记录
// 全部记录模式:加载数据
const loadAllRecords = async () => {
try {
setAllRecordsLoading(true);
const response = await dispatch(fetchNutritionRecords({
page: 1,
limit: 10,
append: false,
}));
if (fetchNutritionRecords.fulfilled.match(response)) {
const { records } = response.payload;
setAllRecords(records);
setHasMoreData(records.length === 10);
}
setAllRecordsLoading(false);
} catch (error) {
console.error('加载全部记录失败:', error);
setAllRecordsLoading(false);
}
};
loadAllRecords();
}
}, [viewMode, currentSelectedDateString, dispatch]);
// 获取基础代谢数据
const fetchBasalMetabolismData = useCallback(async () => {
try {
const options = {
startDate: dayjs(currentSelectedDate).startOf('day').toDate().toISOString(),
endDate: dayjs(currentSelectedDate).endOf('day').toDate().toISOString()
};
const basalEnergy = await fetchBasalEnergyBurned(options);
setBasalMetabolism(basalEnergy || 1482);
} catch (error) {
console.error('获取基础代谢数据失败:', error);
setBasalMetabolism(1482); // 失败时使用默认值
}
}, [currentSelectedDate]);
const onRefresh = useCallback(async () => {
try {
setRefreshing(true);
if (viewMode === 'daily') {
await dispatch(fetchDailyNutritionData(currentSelectedDate));
} else {
// 全部记录模式:刷新数据
setPage(1);
const response = await dispatch(fetchNutritionRecords({
page: 1,
limit: 10,
append: false,
}));
if (fetchNutritionRecords.fulfilled.match(response)) {
const { records } = response.payload;
setAllRecords(records);
setHasMoreData(records.length === 10);
}
}
} catch (error) {
console.error('刷新数据失败:', error);
} finally {
setRefreshing(false);
}
}, [viewMode, currentSelectedDateString, dispatch]);
// 计算营养目标
const calculateNutritionGoals = () => {
const weight = parseFloat(userProfile?.weight || '70'); // 默认70kg
const height = parseFloat(userProfile?.height || '170'); // 默认170cm
const age = userProfile?.birthDate ?
dayjs().diff(dayjs(userProfile.birthDate), 'year') : 25; // 默认25岁
const isWoman = userProfile?.gender === 'female';
// 基础代谢率计算Mifflin-St Jeor Equation
let bmr;
if (isWoman) {
bmr = 10 * weight + 6.25 * height - 5 * age - 161;
} else {
bmr = 10 * weight + 6.25 * height - 5 * age + 5;
}
// 总热量需求(假设轻度活动)
const totalCalories = bmr * 1.375;
// 计算营养素目标
const proteinGoal = weight * 1.6; // 1.6g/kg
const fatGoal = totalCalories * 0.25 / 9; // 25%来自脂肪9卡/克
const carbsGoal = (totalCalories - proteinGoal * 4 - fatGoal * 9) / 4; // 剩余来自碳水
return {
proteinGoal: Math.round(proteinGoal * 10) / 10,
fatGoal: Math.round(fatGoal * 10) / 10,
carbsGoal: Math.round(carbsGoal * 10) / 10,
};
};
const nutritionGoals = calculateNutritionGoals();
const loadMoreRecords = useCallback(async () => {
if (hasMoreData && !loading && !refreshing && viewMode === 'all') {
try {
const nextPage = page + 1;
const response = await dispatch(fetchNutritionRecords({
page: nextPage,
limit: 10,
append: true,
}));
if (fetchNutritionRecords.fulfilled.match(response)) {
const { records } = response.payload;
setAllRecords(prev => [...prev, ...records]);
setHasMoreData(records.length === 10);
setPage(nextPage);
}
} catch (error) {
console.error('加载更多记录失败:', error);
}
}
}, [hasMoreData, loading, refreshing, viewMode, page, dispatch]);
// 删除记录
const handleDeleteRecord = async (recordId: number) => {
try {
if (viewMode === 'daily') {
// 按天查看模式,使用 Redux 删除
await dispatch(deleteNutritionRecord({
recordId,
dateKey: currentSelectedDateString
}));
} else {
// 全部记录模式,从本地状态中移除
await dispatch(deleteNutritionRecord({
recordId,
dateKey: currentSelectedDateString
}));
setAllRecords(prev => prev.filter(record => record.id !== recordId));
}
} catch (error) {
console.error('删除营养记录失败:', error);
}
};
// 处理营养记录卡片点击
const handleRecordPress = (record: DietRecord) => {
// 将 DietRecord 转换为 FoodRecognitionResponse 格式
const recognitionResult: FoodRecognitionResponse = {
items: [{
id: record.id.toString(),
label: record.foodName,
foodName: record.foodName,
portion: record.portionDescription || `${record.estimatedCalories || 0}g`,
calories: record.estimatedCalories || 0,
mealType: record.mealType,
nutritionData: {
proteinGrams: record.proteinGrams || 0,
carbohydrateGrams: record.carbohydrateGrams || 0,
fatGrams: record.fatGrams || 0,
fiberGrams: 0, // DietRecord 中没有纤维数据设为0
}
}],
analysisText: record.foodDescription || `${record.foodName} - ${record.portionDescription}`,
confidence: 95, // 设置一个默认置信度
isFoodDetected: true,
nonFoodMessage: undefined
};
// 生成唯一的识别ID
const recognitionId = `record-${record.id}-${Date.now()}`;
// 保存到 Redux
dispatch(saveRecognitionResult({
id: recognitionId,
result: recognitionResult
}));
// 跳转到分析结果页面
router.push({
pathname: '/food/analysis-result',
params: {
imageUri: record.imageUrl || '',
mealType: record.mealType,
recognitionId: recognitionId,
hideRecordBar: 'true'
}
});
};
// 渲染日期选择器(仅在按天查看模式下显示)
const renderDateSelector = () => {
if (viewMode !== 'daily') return null;
return (
<DateSelector
selectedIndex={selectedIndex}
onDateSelect={(index, date) => setSelectedIndex(index)}
showMonthTitle={true}
disableFutureDates={true}
showCalendarIcon={true}
containerStyle={{
paddingHorizontal: 16
}}
/>
);
};
const renderEmptyState = () => (
<View style={styles.emptyContainer}>
<View style={styles.emptyContent}>
<Ionicons name="restaurant-outline" size={48} color={colorTokens.textSecondary} />
<Text style={[styles.emptyTitle, { color: colorTokens.text }]}>
{viewMode === 'daily' ? '今天还没有记录' : '暂无营养记录'}
</Text>
<Text style={[styles.emptySubtitle, { color: colorTokens.textSecondary }]}>
{viewMode === 'daily' ? '开始记录今日营养摄入' : '开始记录你的营养摄入吧'}
</Text>
</View>
</View>
);
const renderRecord = ({ item, index }: { item: DietRecord; index: number }) => (
<NutritionRecordCard
record={item}
onPress={() => handleRecordPress(item)}
onDelete={() => handleDeleteRecord(item.id)}
/>
);
const renderFooter = () => {
if (!hasMoreData) {
return (
<View style={styles.footerContainer}>
<Text style={[styles.footerText, { color: colorTokens.textSecondary }]}>
</Text>
</View>
);
}
if (viewMode === 'all' && displayRecords.length > 0) {
return (
<TouchableOpacity style={styles.loadMoreButton} onPress={loadMoreRecords}>
<Text style={[styles.loadMoreText, { color: colorTokens.primary }]}>
</Text>
</TouchableOpacity>
);
}
return null;
};
// 根据当前时间智能判断餐次类型
const getCurrentMealType = (): 'breakfast' | 'lunch' | 'dinner' | 'snack' => {
const hour = new Date().getHours();
if (hour >= 5 && hour < 11) {
return 'breakfast'; // 5:00-10:59 早餐
} else if (hour >= 11 && hour < 14) {
return 'lunch'; // 11:00-13:59 午餐
} else if (hour >= 17 && hour < 21) {
return 'dinner'; // 17:00-20:59 晚餐
} else {
return 'snack'; // 其他时间默认为零食
}
};
// 添加食物的处理函数
const handleAddFood = () => {
setShowFoodOverlay(true);
};
// 渲染右侧添加按钮
const renderRightButton = () => (
<TouchableOpacity
style={[styles.addButton, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}
onPress={handleAddFood}
activeOpacity={0.7}
>
<Ionicons name="add" size={20} color={colorTokens.primary} />
</TouchableOpacity>
);
return (
<View style={[styles.container, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}>
<HeaderBar
title="营养记录"
onBack={() => router.back()}
right={renderRightButton()}
/>
{/* {renderViewModeToggle()} */}
{renderDateSelector()}
{/* Calorie Ring Chart */}
<CalorieRingChart
metabolism={basalMetabolism}
exercise={healthData?.activeEnergyBurned || 0}
consumed={nutritionSummary?.totalCalories || 0}
protein={nutritionSummary?.totalProtein || 0}
fat={nutritionSummary?.totalFat || 0}
carbs={nutritionSummary?.totalCarbohydrate || 0}
proteinGoal={nutritionGoals.proteinGoal}
fatGoal={nutritionGoals.fatGoal}
carbsGoal={nutritionGoals.carbsGoal}
/>
{(
<FlatList
data={displayRecords}
renderItem={({ item, index }) => renderRecord({ item, index })}
keyExtractor={(item) => item.id.toString()}
contentContainerStyle={[
styles.listContainer,
{ paddingBottom: 40, paddingTop: 16 }
]}
showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
tintColor={colorTokens.primary}
colors={[colorTokens.primary]}
/>
}
ListEmptyComponent={renderEmptyState}
ListFooterComponent={renderFooter}
onEndReached={viewMode === 'all' ? loadMoreRecords : undefined}
onEndReachedThreshold={0.1}
/>
)}
{/* 食物添加悬浮窗 */}
<FloatingFoodOverlay
visible={showFoodOverlay}
onClose={() => setShowFoodOverlay(false)}
mealType={getCurrentMealType()}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
viewModeContainer: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 12,
marginBottom: 8,
},
monthTitle: {
fontSize: 22,
fontWeight: '800',
},
toggleContainer: {
flexDirection: 'row',
borderRadius: 20,
padding: 2,
},
toggleButton: {
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 18,
minWidth: 80,
alignItems: 'center',
},
toggleText: {
fontSize: 14,
fontWeight: '600',
},
daysContainer: {
marginBottom: 12,
},
daysScrollContainer: {
paddingHorizontal: 16,
paddingVertical: 8,
},
dayPill: {
width: 48,
height: 48,
borderRadius: 34,
marginRight: 12,
alignItems: 'center',
justifyContent: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.08,
shadowRadius: 4,
elevation: 3,
},
dayNumber: {
fontSize: 18,
textAlign: 'center',
},
dayLabel: {
fontSize: 12,
marginTop: 2,
textAlign: 'center',
},
addButton: {
width: 32,
height: 32,
borderRadius: 16,
alignItems: 'center',
justifyContent: 'center',
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 2,
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
loadingText: {
marginTop: 12,
fontSize: 16,
fontWeight: '500',
},
listContainer: {
paddingHorizontal: 16,
paddingTop: 8,
},
emptyContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingVertical: 60,
paddingHorizontal: 16,
},
emptyContent: {
alignItems: 'center',
maxWidth: 320,
},
emptyTitle: {
fontSize: 18,
fontWeight: '700',
marginTop: 16,
marginBottom: 8,
textAlign: 'center',
},
emptySubtitle: {
fontSize: 14,
fontWeight: '500',
textAlign: 'center',
lineHeight: 20,
},
footerContainer: {
paddingVertical: 20,
alignItems: 'center',
},
footerText: {
fontSize: 14,
fontWeight: '500',
},
loadMoreButton: {
paddingVertical: 16,
alignItems: 'center',
},
loadMoreText: {
fontSize: 16,
fontWeight: '600',
},
});

View File

@@ -1,10 +0,0 @@
import { Stack } from 'expo-router';
export default function OnboardingLayout() {
return (
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="index" />
<Stack.Screen name="personal-info" />
</Stack>
);
}

View File

@@ -1,233 +0,0 @@
import { ThemedText } from '@/components/ThemedText';
import { ThemedView } from '@/components/ThemedView';
import { useColorScheme } from '@/hooks/useColorScheme';
import { useThemeColor } from '@/hooks/useThemeColor';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { router } from 'expo-router';
import React from 'react';
import {
Dimensions,
StatusBar,
StyleSheet,
Text,
TouchableOpacity,
View
} from 'react-native';
const { width, height } = Dimensions.get('window');
export default function WelcomeScreen() {
const colorScheme = useColorScheme();
const backgroundColor = useThemeColor({}, 'background');
const primaryColor = useThemeColor({}, 'primary');
const textColor = useThemeColor({}, 'text');
const handleGetStarted = () => {
router.push('/onboarding/personal-info');
};
const handleSkip = async () => {
try {
await AsyncStorage.setItem('@onboarding_completed', 'true');
router.replace('/(tabs)');
} catch (error) {
console.error('保存引导状态失败:', error);
router.replace('/(tabs)');
}
};
return (
<ThemedView style={[styles.container, { backgroundColor }]}>
<StatusBar
barStyle={colorScheme === 'dark' ? 'light-content' : 'dark-content'}
backgroundColor={backgroundColor}
/>
{/* 跳过按钮 */}
<TouchableOpacity style={styles.skipButton} onPress={handleSkip}>
<ThemedText style={[styles.skipText, { color: textColor }]}>
</ThemedText>
</TouchableOpacity>
{/* 主要内容区域 */}
<View style={styles.contentContainer}>
{/* Logo 或插图区域 */}
<View style={styles.imageContainer}>
<View style={[styles.logoPlaceholder, { backgroundColor: primaryColor }]}>
<Text style={styles.logoText}>🧘</Text>
</View>
</View>
{/* 标题和描述 */}
<View style={styles.textContainer}>
<ThemedText type="title" style={styles.title}>
</ThemedText>
<ThemedText style={[styles.subtitle, { color: textColor + '90' }]}>
{'\n'}
</ThemedText>
</View>
{/* 特色功能点 */}
<View style={styles.featuresContainer}>
{[
{ icon: '📊', title: '个性化训练', desc: '根据您的身体状况定制训练计划' },
{ icon: '🤖', title: 'AI 姿态分析', desc: '实时纠正您的动作姿态' },
{ icon: '📈', title: '进度追踪', desc: '记录您的每一次进步' },
].map((feature, index) => (
<View key={index} style={styles.featureItem}>
<Text style={styles.featureIcon}>{feature.icon}</Text>
<View style={styles.featureTextContainer}>
<ThemedText style={[styles.featureTitle, { color: textColor }]}>
{feature.title}
</ThemedText>
<ThemedText style={[styles.featureDesc, { color: textColor + '70' }]}>
{feature.desc}
</ThemedText>
</View>
</View>
))}
</View>
</View>
{/* 底部按钮 */}
<View style={styles.buttonContainer}>
<TouchableOpacity
style={[styles.getStartedButton, { backgroundColor: primaryColor }]}
onPress={handleGetStarted}
activeOpacity={0.8}
>
<Text style={styles.getStartedButtonText}></Text>
</TouchableOpacity>
<TouchableOpacity style={styles.laterButton} onPress={handleSkip}>
<ThemedText style={[styles.laterButtonText, { color: textColor + '70' }]}>
</ThemedText>
</TouchableOpacity>
</View>
</ThemedView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
paddingTop: StatusBar.currentHeight || 44,
},
skipButton: {
position: 'absolute',
top: StatusBar.currentHeight ? StatusBar.currentHeight + 16 : 60,
right: 20,
zIndex: 10,
padding: 8,
},
skipText: {
fontSize: 16,
fontWeight: '500',
},
contentContainer: {
flex: 1,
paddingHorizontal: 24,
justifyContent: 'center',
},
imageContainer: {
alignItems: 'center',
marginBottom: 40,
},
logoPlaceholder: {
width: 120,
height: 120,
borderRadius: 60,
justifyContent: 'center',
alignItems: 'center',
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 4,
},
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 8,
},
logoText: {
fontSize: 48,
},
textContainer: {
alignItems: 'center',
marginBottom: 48,
},
title: {
textAlign: 'center',
marginBottom: 16,
fontWeight: '700',
},
subtitle: {
fontSize: 16,
textAlign: 'center',
lineHeight: 24,
paddingHorizontal: 12,
},
featuresContainer: {
marginBottom: 40,
},
featureItem: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 24,
paddingHorizontal: 8,
},
featureIcon: {
fontSize: 32,
marginRight: 16,
width: 40,
textAlign: 'center',
},
featureTextContainer: {
flex: 1,
},
featureTitle: {
fontSize: 18,
fontWeight: '600',
marginBottom: 4,
},
featureDesc: {
fontSize: 14,
lineHeight: 20,
},
buttonContainer: {
paddingHorizontal: 24,
paddingBottom: 48,
},
getStartedButton: {
height: 56,
borderRadius: 16,
justifyContent: 'center',
alignItems: 'center',
marginBottom: 16,
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 4,
},
getStartedButtonText: {
color: '#192126',
fontSize: 18,
fontWeight: '600',
},
laterButton: {
height: 48,
justifyContent: 'center',
alignItems: 'center',
},
laterButtonText: {
fontSize: 16,
fontWeight: '500',
},
});

View File

@@ -1,426 +0,0 @@
import { ThemedText } from '@/components/ThemedText';
import { ThemedView } from '@/components/ThemedView';
import { useColorScheme } from '@/hooks/useColorScheme';
import { useThemeColor } from '@/hooks/useThemeColor';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { router } from 'expo-router';
import React, { useState } from 'react';
import {
Alert,
Dimensions,
ScrollView,
StatusBar,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
View,
} from 'react-native';
const { width } = Dimensions.get('window');
interface PersonalInfo {
gender: 'male' | 'female' | '';
age: string;
height: string;
weight: string;
}
export default function PersonalInfoScreen() {
const colorScheme = useColorScheme();
const backgroundColor = useThemeColor({}, 'background');
const primaryColor = useThemeColor({}, 'primary');
const textColor = useThemeColor({}, 'text');
const iconColor = useThemeColor({}, 'icon');
const [personalInfo, setPersonalInfo] = useState<PersonalInfo>({
gender: '',
age: '',
height: '',
weight: '',
});
const [currentStep, setCurrentStep] = useState(0);
const steps = [
{
title: '请选择您的性别',
subtitle: '这将帮助我们为您制定更合适的训练计划',
type: 'gender' as const,
},
{
title: '请输入您的年龄',
subtitle: '年龄信息有助于调整训练强度',
type: 'age' as const,
},
{
title: '请输入您的身高',
subtitle: '身高信息用于计算身体比例',
type: 'height' as const,
},
{
title: '请输入您的体重',
subtitle: '体重信息用于个性化训练方案',
type: 'weight' as const,
},
];
const handleGenderSelect = (gender: 'male' | 'female') => {
setPersonalInfo(prev => ({ ...prev, gender }));
};
const handleInputChange = (field: keyof PersonalInfo, value: string) => {
setPersonalInfo(prev => ({ ...prev, [field]: value }));
};
const handleNext = () => {
const currentStepType = steps[currentStep].type;
// 验证当前步骤是否已填写
if (currentStepType === 'gender' && !personalInfo.gender) {
Alert.alert('提示', '请选择您的性别');
return;
}
if (currentStepType === 'age' && !personalInfo.age) {
Alert.alert('提示', '请输入您的年龄');
return;
}
if (currentStepType === 'height' && !personalInfo.height) {
Alert.alert('提示', '请输入您的身高');
return;
}
if (currentStepType === 'weight' && !personalInfo.weight) {
Alert.alert('提示', '请输入您的体重');
return;
}
if (currentStep < steps.length - 1) {
setCurrentStep(currentStep + 1);
} else {
handleComplete();
}
};
const handlePrevious = () => {
if (currentStep > 0) {
setCurrentStep(currentStep - 1);
}
};
const handleSkip = async () => {
try {
await AsyncStorage.setItem('@onboarding_completed', 'true');
router.replace('/(tabs)');
} catch (error) {
console.error('保存引导状态失败:', error);
router.replace('/(tabs)');
}
};
const handleComplete = async () => {
try {
// 保存用户信息和引导完成状态
await AsyncStorage.multiSet([
['@onboarding_completed', 'true'],
['@user_personal_info', JSON.stringify(personalInfo)],
]);
console.log('用户信息:', personalInfo);
router.replace('/(tabs)');
} catch (error) {
console.error('保存用户信息失败:', error);
router.replace('/(tabs)');
}
};
const renderGenderSelection = () => (
<View style={styles.optionsContainer}>
<TouchableOpacity
style={[
styles.genderOption,
{ borderColor: primaryColor },
personalInfo.gender === 'female' && { backgroundColor: primaryColor + '20', borderWidth: 2 }
]}
onPress={() => handleGenderSelect('female')}
>
<Text style={styles.genderIcon}>👩</Text>
<ThemedText style={[styles.genderText, { color: textColor }]}></ThemedText>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.genderOption,
{ borderColor: primaryColor },
personalInfo.gender === 'male' && { backgroundColor: primaryColor + '20', borderWidth: 2 }
]}
onPress={() => handleGenderSelect('male')}
>
<Text style={styles.genderIcon}>👨</Text>
<ThemedText style={[styles.genderText, { color: textColor }]}></ThemedText>
</TouchableOpacity>
</View>
);
const renderNumberInput = (
field: 'age' | 'height' | 'weight',
placeholder: string,
unit: string
) => (
<View style={styles.inputContainer}>
<View style={[styles.inputWrapper, { borderColor: iconColor + '30' }]}>
<TextInput
style={[styles.numberInput, { color: textColor }]}
value={personalInfo[field]}
onChangeText={(value) => handleInputChange(field, value)}
placeholder={placeholder}
placeholderTextColor={iconColor}
keyboardType="numeric"
maxLength={field === 'age' ? 3 : 4}
/>
<ThemedText style={[styles.unitText, { color: iconColor }]}>{unit}</ThemedText>
</View>
</View>
);
const renderStepContent = () => {
const step = steps[currentStep];
switch (step.type) {
case 'gender':
return renderGenderSelection();
case 'age':
return renderNumberInput('age', '请输入年龄', '岁');
case 'height':
return renderNumberInput('height', '请输入身高', 'cm');
case 'weight':
return renderNumberInput('weight', '请输入体重', 'kg');
default:
return null;
}
};
const isStepCompleted = () => {
const currentStepType = steps[currentStep].type;
switch (currentStepType) {
case 'gender':
return !!personalInfo.gender;
case 'age':
return !!personalInfo.age;
case 'height':
return !!personalInfo.height;
case 'weight':
return !!personalInfo.weight;
default:
return false;
}
};
return (
<ThemedView style={[styles.container, { backgroundColor }]}>
<StatusBar
barStyle={colorScheme === 'dark' ? 'light-content' : 'dark-content'}
backgroundColor={backgroundColor}
/>
{/* 顶部导航 */}
<View style={styles.header}>
{currentStep > 0 && (
<TouchableOpacity style={styles.backButton} onPress={handlePrevious}>
<ThemedText style={[styles.backText, { color: textColor }]}> </ThemedText>
</TouchableOpacity>
)}
<TouchableOpacity style={styles.skipButton} onPress={handleSkip}>
<ThemedText style={[styles.skipText, { color: iconColor }]}></ThemedText>
</TouchableOpacity>
</View>
{/* 进度条 */}
<View style={styles.progressContainer}>
<View style={[styles.progressBackground, { backgroundColor: iconColor + '20' }]}>
<View
style={[
styles.progressBar,
{
backgroundColor: primaryColor,
width: `${((currentStep + 1) / steps.length) * 100}%`
}
]}
/>
</View>
<ThemedText style={[styles.progressText, { color: iconColor }]}>
{currentStep + 1} / {steps.length}
</ThemedText>
</View>
<ScrollView
style={styles.content}
contentContainerStyle={styles.contentContainer}
showsVerticalScrollIndicator={false}
>
{/* 标题区域 */}
<View style={styles.titleContainer}>
<ThemedText type="title" style={styles.title}>
{steps[currentStep].title}
</ThemedText>
<ThemedText style={[styles.subtitle, { color: textColor + '80' }]}>
{steps[currentStep].subtitle}
</ThemedText>
</View>
{/* 内容区域 */}
{renderStepContent()}
</ScrollView>
{/* 底部按钮 */}
<View style={styles.buttonContainer}>
<TouchableOpacity
style={[
styles.nextButton,
{ backgroundColor: isStepCompleted() ? primaryColor : iconColor + '30' }
]}
onPress={handleNext}
disabled={!isStepCompleted()}
activeOpacity={0.8}
>
<Text style={[
styles.nextButtonText,
{ color: isStepCompleted() ? '#192126' : iconColor }
]}>
{currentStep === steps.length - 1 ? '完成' : '下一步'}
</Text>
</TouchableOpacity>
</View>
</ThemedView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
paddingTop: StatusBar.currentHeight || 44,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 20,
paddingVertical: 16,
},
backButton: {
padding: 8,
},
backText: {
fontSize: 16,
fontWeight: '500',
},
skipButton: {
padding: 8,
},
skipText: {
fontSize: 16,
fontWeight: '500',
},
progressContainer: {
paddingHorizontal: 20,
marginBottom: 32,
},
progressBackground: {
height: 4,
borderRadius: 2,
marginBottom: 8,
},
progressBar: {
height: '100%',
borderRadius: 2,
},
progressText: {
fontSize: 12,
textAlign: 'right',
},
content: {
flex: 1,
},
contentContainer: {
paddingHorizontal: 24,
paddingBottom: 24,
},
titleContainer: {
marginBottom: 48,
},
title: {
textAlign: 'center',
marginBottom: 16,
fontWeight: '700',
},
subtitle: {
fontSize: 16,
textAlign: 'center',
lineHeight: 24,
},
optionsContainer: {
flexDirection: 'row',
justifyContent: 'space-around',
paddingHorizontal: 20,
},
genderOption: {
width: width * 0.35,
height: 120,
borderRadius: 16,
borderWidth: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 16,
},
genderIcon: {
fontSize: 48,
marginBottom: 8,
},
genderText: {
fontSize: 16,
fontWeight: '600',
},
inputContainer: {
alignItems: 'center',
},
inputWrapper: {
flexDirection: 'row',
alignItems: 'center',
borderWidth: 1,
borderRadius: 12,
paddingHorizontal: 16,
width: width * 0.6,
height: 56,
},
numberInput: {
flex: 1,
fontSize: 18,
fontWeight: '600',
textAlign: 'center',
},
unitText: {
fontSize: 16,
fontWeight: '500',
marginLeft: 8,
},
buttonContainer: {
paddingHorizontal: 24,
paddingBottom: 48,
},
nextButton: {
height: 56,
borderRadius: 16,
justifyContent: 'center',
alignItems: 'center',
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 4,
},
nextButtonText: {
fontSize: 18,
fontWeight: '600',
},
});

File diff suppressed because it is too large Load Diff

444
app/profile/goals.tsx Normal file
View File

@@ -0,0 +1,444 @@
import { HeaderBar } from '@/components/ui/HeaderBar';
import { Colors } from '@/constants/Colors';
import { useColorScheme } from '@/hooks/useColorScheme';
import AsyncStorage from '@/utils/kvStore';
import * as Haptics from 'expo-haptics';
import { useRouter } from 'expo-router';
import React, { useEffect, useMemo, useState } from 'react';
import {
SafeAreaView,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { ProgressBar } from '@/components/ProgressBar';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { updateUser as updateUserApi } from '@/services/users';
import { fetchMyProfile } from '@/store/userSlice';
import { useFocusEffect } from '@react-navigation/native';
const STORAGE_KEYS = {
calories: '@goal_calories_burn',
steps: '@goal_daily_steps',
purposes: '@goal_pilates_purposes',
} as const;
const CALORIES_RANGE = { min: 100, max: 1500, step: 50 };
const STEPS_RANGE = { min: 2000, max: 20000, step: 500 };
function arraysEqualUnordered(a?: string[], b?: string[]): boolean {
if (!Array.isArray(a) && !Array.isArray(b)) return true;
if (!Array.isArray(a) || !Array.isArray(b)) return false;
if (a.length !== b.length) return false;
const sa = [...a].sort();
const sb = [...b].sort();
for (let i = 0; i < sa.length; i += 1) {
if (sa[i] !== sb[i]) return false;
}
return true;
}
export default function GoalsScreen() {
const router = useRouter();
const insets = useSafeAreaInsets();
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colors = Colors[theme];
const dispatch = useAppDispatch();
const accountProfile = useAppSelector((s) => (s as any)?.user?.profile as any);
const userId: string | undefined = useMemo(() => {
return (
accountProfile?.userId ||
accountProfile?.id ||
accountProfile?._id ||
accountProfile?.uid ||
undefined
) as string | undefined;
}, [accountProfile]);
const [calories, setCalories] = useState<number>(400);
const [steps, setSteps] = useState<number>(8000);
const [purposes, setPurposes] = useState<string[]>([]);
const lastSentRef = React.useRef<{ calories?: number; steps?: number; purposes?: string[] }>({});
useEffect(() => {
const load = async () => {
try {
const [c, s, p] = await Promise.all([
AsyncStorage.getItem(STORAGE_KEYS.calories),
AsyncStorage.getItem(STORAGE_KEYS.steps),
AsyncStorage.getItem(STORAGE_KEYS.purposes),
]);
if (c) {
const v = parseInt(c, 10);
if (!Number.isNaN(v)) setCalories(v);
}
if (s) {
const v = parseInt(s, 10);
if (!Number.isNaN(v)) setSteps(v);
}
if (p) {
try {
const parsed = JSON.parse(p);
if (Array.isArray(parsed)) setPurposes(parsed.filter((x) => typeof x === 'string'));
} catch { }
}
} catch { }
};
load();
}, []);
// 页面聚焦时,从后端拉取并用全局 profile 的值覆盖 UI保证是最新
useFocusEffect(
React.useCallback(() => {
(async () => {
try {
await dispatch(fetchMyProfile() as any);
const latest = (accountProfile ?? {}) as any;
if (typeof latest?.dailyCaloriesGoal === 'number') setCalories(latest.dailyCaloriesGoal);
if (typeof latest?.dailyStepsGoal === 'number') setSteps(latest.dailyStepsGoal);
if (Array.isArray(latest?.pilatesPurposes)) setPurposes(latest.pilatesPurposes.filter((x: any) => typeof x === 'string'));
} catch { }
})();
}, [dispatch])
);
// 当全局 profile 有变化时,同步覆盖 UI
useEffect(() => {
const latest = (accountProfile ?? {}) as any;
if (typeof latest?.dailyCaloriesGoal === 'number') setCalories(latest.dailyCaloriesGoal);
if (typeof latest?.dailyStepsGoal === 'number') setSteps(latest.dailyStepsGoal);
if (Array.isArray(latest?.pilatesPurposes)) setPurposes(latest.pilatesPurposes.filter((x: any) => typeof x === 'string'));
}, [accountProfile]);
// 当全局 profile 变化(例如刚拉完或保存后刷新)时,将“已发送基线”对齐为后端值,避免重复上报
useEffect(() => {
const latest = (accountProfile ?? {}) as any;
if (typeof latest?.dailyCaloriesGoal === 'number') {
lastSentRef.current.calories = latest.dailyCaloriesGoal;
}
if (typeof latest?.dailyStepsGoal === 'number') {
lastSentRef.current.steps = latest.dailyStepsGoal;
}
if (Array.isArray(latest?.pilatesPurposes)) {
lastSentRef.current.purposes = [...latest.pilatesPurposes];
}
}, [accountProfile]);
useEffect(() => {
AsyncStorage.setItem(STORAGE_KEYS.calories, String(calories)).catch(() => { });
if (!userId) return;
if (lastSentRef.current.calories === calories) return;
lastSentRef.current.calories = calories;
(async () => {
try {
await updateUserApi({ userId, dailyCaloriesGoal: calories });
await dispatch(fetchMyProfile() as any);
} catch { }
})();
}, [calories, userId]);
useEffect(() => {
AsyncStorage.setItem(STORAGE_KEYS.steps, String(steps)).catch(() => { });
if (!userId) return;
if (lastSentRef.current.steps === steps) return;
lastSentRef.current.steps = steps;
(async () => {
try {
await updateUserApi({ userId, dailyStepsGoal: steps });
await dispatch(fetchMyProfile() as any);
} catch { }
})();
}, [steps, userId]);
useEffect(() => {
AsyncStorage.setItem(STORAGE_KEYS.purposes, JSON.stringify(purposes)).catch(() => { });
if (!userId) return;
if (arraysEqualUnordered(lastSentRef.current.purposes, purposes)) return;
lastSentRef.current.purposes = [...purposes];
(async () => {
try {
await updateUserApi({ userId, pilatesPurposes: purposes });
await dispatch(fetchMyProfile() as any);
} catch { }
})();
}, [purposes, userId]);
const caloriesPercent = useMemo(() =>
(Math.min(CALORIES_RANGE.max, Math.max(CALORIES_RANGE.min, calories)) - CALORIES_RANGE.min) /
(CALORIES_RANGE.max - CALORIES_RANGE.min),
[calories]);
const stepsPercent = useMemo(() =>
(Math.min(STEPS_RANGE.max, Math.max(STEPS_RANGE.min, steps)) - STEPS_RANGE.min) /
(STEPS_RANGE.max - STEPS_RANGE.min),
[steps]);
const changeWithHaptics = (next: number, setter: (v: number) => void) => {
if (process.env.EXPO_OS === 'ios') {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}
setter(next);
};
const inc = (value: number, range: { step: number; max: number }) => {
return Math.min(range.max, value + range.step);
};
const dec = (value: number, range: { step: number; min: number }) => {
return Math.max(range.min, value - range.step);
};
const SectionCard: React.FC<{ title: string; subtitle?: string; children: React.ReactNode }>
= ({ title, subtitle, children }) => (
<View style={[styles.card, { backgroundColor: colors.surface }]}>
<Text style={[styles.cardTitle, { color: colors.text }]}>{title}</Text>
{subtitle ? <Text style={[styles.cardSubtitle, { color: colors.textSecondary }]}>{subtitle}</Text> : null}
{children}
</View>
);
const PresetChip: React.FC<{ label: string; active?: boolean; onPress: () => void }>
= ({ label, active, onPress }) => (
<TouchableOpacity
onPress={onPress}
style={[styles.chip, { backgroundColor: active ? colors.primary : colors.card, borderColor: colors.border }]}
>
<Text style={[styles.chipText, { color: active ? colors.onPrimary : colors.text }]}>{label}</Text>
</TouchableOpacity>
);
const Stepper: React.FC<{ onDec: () => void; onInc: () => void }>
= ({ onDec, onInc }) => (
<View style={styles.stepperRow}>
<TouchableOpacity onPress={onDec} style={[styles.stepperBtn, { backgroundColor: colors.card, borderColor: colors.border }]}>
<Text style={[styles.stepperText, { color: colors.text }]}>-</Text>
</TouchableOpacity>
<TouchableOpacity onPress={onInc} style={[styles.stepperBtn, { backgroundColor: colors.card, borderColor: colors.border }]}>
<Text style={[styles.stepperText, { color: colors.text }]}>+</Text>
</TouchableOpacity>
</View>
);
const PURPOSE_OPTIONS: { id: string; label: string; icon: any }[] = [
{ id: 'core', label: '增强核心力量', icon: 'barbell-outline' },
{ id: 'posture', label: '改善姿势体态', icon: 'body-outline' },
{ id: 'flexibility', label: '提高柔韧灵活', icon: 'walk-outline' },
{ id: 'balance', label: '强化平衡稳定', icon: 'accessibility-outline' },
{ id: 'shape', label: '塑形与线条', icon: 'heart-outline' },
{ id: 'stress', label: '减压与身心放松', icon: 'leaf-outline' },
{ id: 'backpain', label: '预防/改善腰背痛', icon: 'shield-checkmark-outline' },
{ id: 'rehab', label: '术后/伤后康复', icon: 'medkit-outline' },
{ id: 'performance', label: '提升运动表现', icon: 'fitness-outline' },
];
const togglePurpose = (id: string) => {
if (process.env.EXPO_OS === 'ios') {
Haptics.selectionAsync();
}
setPurposes((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
);
};
return (
<View style={[styles.container, { backgroundColor: theme === 'light' ? '#F5F5F5' : colors.background }]}>
<SafeAreaView style={styles.safeArea}>
<HeaderBar title="目标管理" onBack={() => router.back()} withSafeTop={false} tone={theme} transparent />
<ScrollView contentContainerStyle={[styles.content, { paddingBottom: Math.max(20, insets.bottom + 16) }]}
showsVerticalScrollIndicator={false}
>
<SectionCard title="每日卡路里消耗目标" subtitle="设置你计划每天通过活动消耗的热量">
<View style={styles.rowBetween}>
<Text style={[styles.valueText, { color: colors.text }]}>{calories} kcal</Text>
<Stepper
onDec={() => changeWithHaptics(dec(calories, CALORIES_RANGE), setCalories)}
onInc={() => changeWithHaptics(inc(calories, CALORIES_RANGE), setCalories)}
/>
</View>
<ProgressBar
progress={caloriesPercent}
height={18}
style={styles.progressMargin}
trackColor={theme === 'light' ? '#EDEDED' : colors.separator}
fillColor={colors.primary}
/>
<View style={styles.chipsRow}>
{[200, 300, 400, 500, 600].map((v) => (
<PresetChip key={v}
label={`${v}`}
active={v === calories}
onPress={() => changeWithHaptics(v, setCalories)}
/>
))}
</View>
<Text style={[styles.rangeHint, { color: colors.textMuted }]}> {CALORIES_RANGE.min}-{CALORIES_RANGE.max} kcal {CALORIES_RANGE.step}</Text>
</SectionCard>
<SectionCard title="每日步数目标" subtitle="快速设置你的目标步数">
<View style={styles.rowBetween}>
<Text style={[styles.valueText, { color: colors.text }]}>{steps.toLocaleString()} </Text>
<Stepper
onDec={() => changeWithHaptics(dec(steps, STEPS_RANGE), setSteps)}
onInc={() => changeWithHaptics(inc(steps, STEPS_RANGE), setSteps)}
/>
</View>
<ProgressBar
progress={stepsPercent}
height={18}
style={styles.progressMargin}
trackColor={theme === 'light' ? '#EDEDED' : colors.separator}
fillColor={colors.primary}
/>
<View style={styles.chipsRow}>
{[6000, 8000, 10000, 12000, 15000].map((v) => (
<PresetChip key={v}
label={`${v / 1000}k`}
active={v === steps}
onPress={() => changeWithHaptics(v, setSteps)}
/>
))}
</View>
<Text style={[styles.rangeHint, { color: colors.textMuted }]}> {STEPS_RANGE.min.toLocaleString()}-{STEPS_RANGE.max.toLocaleString()} {STEPS_RANGE.step}</Text>
</SectionCard>
</ScrollView>
</SafeAreaView>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 16,
},
backButton: {
width: 32,
height: 32,
borderRadius: 16,
alignItems: 'center',
justifyContent: 'center',
},
headerTitle: {
fontSize: 20,
fontWeight: '700',
},
safeArea: {
flex: 1,
},
content: {
paddingHorizontal: 20,
paddingTop: 8,
},
card: {
borderRadius: 16,
padding: 16,
marginTop: 16,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.08,
shadowRadius: 8,
elevation: 2,
},
cardTitle: {
fontSize: 18,
fontWeight: '700',
},
cardSubtitle: {
fontSize: 13,
marginTop: 4,
},
rowBetween: {
marginTop: 12,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
valueText: {
fontSize: 24,
fontWeight: '800',
},
chipsRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
marginTop: 14,
},
chip: {
paddingHorizontal: 12,
paddingVertical: 8,
borderRadius: 20,
borderWidth: 1,
},
chipText: {
fontSize: 14,
fontWeight: '600',
},
stepperRow: {
flexDirection: 'row',
gap: 10,
},
stepperBtn: {
width: 36,
height: 36,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
borderWidth: 1,
},
stepperText: {
fontSize: 20,
fontWeight: '700',
},
rangeHint: {
fontSize: 12,
marginTop: 10,
},
progressMargin: {
marginTop: 12,
},
grid: {
marginTop: 12,
flexDirection: 'row',
flexWrap: 'wrap',
justifyContent: 'space-between',
rowGap: 12,
},
optionCard: {
width: '48%',
borderRadius: 14,
paddingVertical: 14,
paddingHorizontal: 12,
borderWidth: 1,
flexDirection: 'row',
alignItems: 'center',
gap: 10,
},
optionIconWrap: {
width: 28,
height: 28,
borderRadius: 14,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(0,0,0,0.04)',
},
optionLabel: {
flex: 1,
fontSize: 14,
fontWeight: '700',
},
selectedHint: {
marginTop: 10,
fontSize: 12,
},
});

951
app/sleep-detail.tsx Normal file
View File

@@ -0,0 +1,951 @@
import { ThemedView } from '@/components/ThemedView';
import {
fetchCompleteSleepData,
formatSleepTime,
formatTime,
getSleepStageColor,
SleepStage,
type CompleteSleepData
} from '@/utils/sleepHealthKit';
import { Ionicons } from '@expo/vector-icons';
import dayjs from 'dayjs';
import { router, useLocalSearchParams } from 'expo-router';
import React, { useCallback, useEffect, useState } from 'react';
import {
ActivityIndicator,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View
} from 'react-native';
import { InfoModal, type SleepDetailData } from '@/components/sleep/InfoModal';
import { SleepStagesInfoModal } from '@/components/sleep/SleepStagesInfoModal';
import { SleepStageTimeline } from '@/components/sleep/SleepStageTimeline';
import { HeaderBar } from '@/components/ui/HeaderBar';
import { Colors } from '@/constants/Colors';
import { useColorScheme } from '@/hooks/useColorScheme';
// SleepGradeCard 组件现在在 InfoModal 组件内部
// SleepStagesInfoModal 组件现在从独立文件导入
// InfoModal 组件现在从独立文件导入
export default function SleepDetailScreen() {
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme];
const [sleepData, setSleepData] = useState<CompleteSleepData | null>(null);
const [loading, setLoading] = useState(true);
// 从导航参数获取日期,如果没有则使用今天
const { date: dateParam } = useLocalSearchParams<{ date?: string }>();
const [selectedDate] = useState(() => {
if (dateParam) {
return dayjs(dateParam).toDate();
}
return dayjs().toDate();
});
const [infoModal, setInfoModal] = useState<{ visible: boolean; title: string; type: 'sleep-time' | 'sleep-quality' | null }>({
visible: false,
title: '',
type: null
});
const [sleepStagesModal, setSleepStagesModal] = useState({
visible: false
});
const loadSleepData = useCallback(async () => {
try {
setLoading(true);
console.log('开始加载睡眠数据...');
const data = await fetchCompleteSleepData(selectedDate);
setSleepData(data);
if (data) {
console.log('睡眠数据加载成功,得分:', data.sleepScore);
} else {
console.log('未找到睡眠数据');
}
} catch (error) {
console.error('加载睡眠数据失败:', error);
} finally {
setLoading(false);
}
}, [selectedDate]);
useEffect(() => {
loadSleepData();
}, [loadSleepData]);
if (loading) {
return (
<View style={[styles.container, styles.loadingContainer]}>
<ActivityIndicator size="large" color={Colors.light.primary} />
<Text style={styles.loadingText}>...</Text>
</View>
);
}
// 如果没有数据,使用默认数据结构
const displayData: CompleteSleepData = sleepData || {
sleepScore: 0,
totalSleepTime: 0,
sleepQualityPercentage: 0,
bedtime: '',
wakeupTime: '',
timeInBed: 0,
sleepStages: [],
rawSleepSamples: [],
averageHeartRate: null,
sleepHeartRateData: [],
sleepEfficiency: 0,
qualityDescription: '暂无睡眠数据',
recommendation: '请确保在真实iOS设备上运行并授权访问健康数据或等待有睡眠数据后再查看。'
};
return (
<ThemedView style={styles.container}>
{/* 顶部导航 */}
<HeaderBar
title={`${dayjs(selectedDate).isSame(dayjs(), 'day') ? '今天' : dayjs(selectedDate).format('M月DD日')}`}
onBack={() => router.back()}
transparent={true}
variant="default"
/>
<ScrollView
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
>
{/* 睡眠得分圆形显示 */}
<View style={styles.scoreContainer}>
<View style={styles.scoreTextContainer}>
<Text style={styles.scoreNumber}>{displayData.sleepScore}</Text>
<Text style={styles.scoreLabel}></Text>
</View>
</View>
{/* 睡眠质量描述 */}
<Text style={styles.qualityDescription}>{displayData.qualityDescription}</Text>
{/* 建议文本 */}
<Text style={styles.recommendationText}>{displayData.recommendation}</Text>
{/* 睡眠统计卡片 */}
<View style={styles.statsContainer}>
<View style={[styles.newStatCard, { backgroundColor: colorTokens.background }]}>
<View style={styles.statCardHeader}>
<View style={styles.statCardLeftGroup}>
<View style={styles.statCardIcon}>
<Ionicons name="moon-outline" size={18} color="#6B7280" />
</View>
<Text style={[styles.statLabel, { color: colorTokens.textSecondary }]}></Text>
</View>
<TouchableOpacity
style={styles.infoButton}
onPress={() => setInfoModal({
visible: true,
title: '睡眠时间',
type: 'sleep-time'
})}
>
<Ionicons name="information-circle-outline" size={18} color={colorTokens.textMuted} />
</TouchableOpacity>
</View>
<Text style={[styles.newStatValue, { color: colorTokens.text }]}>
{displayData.totalSleepTime > 0 ? formatSleepTime(displayData.totalSleepTime) : '--'}
</Text>
</View>
<View style={[styles.newStatCard, { backgroundColor: colorTokens.background }]}>
<View style={styles.statCardHeader}>
<View style={styles.statCardLeftGroup}>
<View style={styles.statCardIcon}>
<Ionicons name="star-outline" size={18} color="#6B7280" />
</View>
<Text style={[styles.statLabel, { color: colorTokens.textSecondary }]}></Text>
</View>
<TouchableOpacity
style={styles.infoButton}
onPress={() => setInfoModal({
visible: true,
title: '睡眠质量',
type: 'sleep-quality'
})}
>
<Ionicons name="information-circle-outline" size={16} color={colorTokens.textMuted} />
</TouchableOpacity>
</View>
<Text style={[styles.newStatValue, { color: colorTokens.text }]}>
{displayData.sleepQualityPercentage > 0 ? `${displayData.sleepQualityPercentage}%` : '--%'}
</Text>
</View>
</View>
{/* 睡眠阶段图表 */}
{/* <SleepStageChart
sleepData={displayData}
onInfoPress={() => setSleepStagesModal({ visible: true })}
/> */}
{/* 苹果健康风格的睡眠阶段时间轴图表 */}
<SleepStageTimeline
sleepSamples={displayData.rawSleepSamples}
bedtime={displayData.bedtime}
wakeupTime={displayData.wakeupTime}
onInfoPress={() => setSleepStagesModal({ visible: true })}
/>
{/* 睡眠阶段统计 - 2x2网格布局 */}
<View style={styles.stagesGridContainer}>
{/* 使用真实数据或默认数据确保包含所有4个阶段 */}
{(() => {
let stagesToDisplay;
if (displayData.sleepStages.length > 0) {
// 使用真实数据,确保所有阶段都存在
const existingStages = new Map(displayData.sleepStages.map(s => [s.stage, s]));
stagesToDisplay = [
existingStages.get(SleepStage.Awake) || { stage: SleepStage.Awake, duration: 0, percentage: 0, quality: 'good' as any },
existingStages.get(SleepStage.REM) || { stage: SleepStage.REM, duration: 0, percentage: 0, quality: 'good' as any },
existingStages.get(SleepStage.Core) || { stage: SleepStage.Core, duration: 0, percentage: 0, quality: 'good' as any },
existingStages.get(SleepStage.Deep) || { stage: SleepStage.Deep, duration: 0, percentage: 0, quality: 'good' as any }
];
} else {
// 使用默认数据
stagesToDisplay = [
{ stage: SleepStage.Awake, duration: 0, percentage: 0, quality: 'good' as any },
{ stage: SleepStage.REM, duration: 0, percentage: 0, quality: 'good' as any },
{ stage: SleepStage.Core, duration: 0, percentage: 0, quality: 'good' as any },
{ stage: SleepStage.Deep, duration: 0, percentage: 0, quality: 'poor' as any }
];
}
return stagesToDisplay;
})().map((stageData, index) => {
const getStageName = (stage: SleepStage) => {
switch (stage) {
case SleepStage.Awake: return '清醒时间';
case SleepStage.REM: return '快速眼动';
case SleepStage.Core: return '核心睡眠';
case SleepStage.Deep: return '深度睡眠';
default: return '未知';
}
};
const getQualityDisplay = (quality: any) => {
switch (quality) {
case 'excellent': return { text: '★ 优秀', color: '#10B981', bgColor: '#D1FAE5', progressColor: '#10B981', progressWidth: '100%' };
case 'good': return { text: '✓ 良好', color: '#065F46', bgColor: '#D1FAE5', progressColor: '#10B981', progressWidth: '85%' };
case 'fair': return { text: '○ 一般', color: '#92400E', bgColor: '#FEF3C7', progressColor: '#F59E0B', progressWidth: '65%' };
case 'poor': return { text: '⚠ 低', color: '#DC2626', bgColor: '#FECACA', progressColor: '#F59E0B', progressWidth: '45%' };
default: return { text: '✓ 正常', color: '#065F46', bgColor: '#D1FAE5', progressColor: '#10B981', progressWidth: '75%' };
}
};
const qualityInfo = getQualityDisplay(stageData.quality);
return (
<View key={index} style={[styles.stageCard, { backgroundColor: colorTokens.background }]}>
<Text style={[styles.stageCardTitle, { color: getSleepStageColor(stageData.stage) }]}>
{getStageName(stageData.stage)}
</Text>
<Text style={[styles.stageCardValue, { color: colorTokens.text }]}>
{formatSleepTime(stageData.duration)}
</Text>
<Text style={[styles.stageCardPercentage, { color: colorTokens.textSecondary }]}>
{stageData.percentage}%
</Text>
</View>
);
})}
</View>
{/* Raw Sleep Samples List - 显示所有原始睡眠数据 */}
{sleepData && sleepData.rawSleepSamples && sleepData.rawSleepSamples.length > 100 && (
<View style={[styles.rawSamplesContainer, { backgroundColor: colorTokens.background }]}>
<View style={styles.rawSamplesHeader}>
<Text style={[styles.rawSamplesTitle, { color: colorTokens.text }]}>
({sleepData.rawSleepSamples.length} )
</Text>
<Text style={[styles.rawSamplesSubtitle, { color: colorTokens.textSecondary }]}>
gap
</Text>
</View>
<ScrollView style={styles.rawSamplesScrollView} nestedScrollEnabled={true}>
{sleepData.rawSleepSamples.map((sample, index) => {
// 计算与前一个样本的时间间隔
const prevSample = index > 0 ? sleepData.rawSleepSamples[index - 1] : null;
let gapMinutes = 0;
let hasGap = false;
if (prevSample) {
const prevEndTime = new Date(prevSample.endDate).getTime();
const currentStartTime = new Date(sample.startDate).getTime();
gapMinutes = (currentStartTime - prevEndTime) / (1000 * 60);
hasGap = gapMinutes > 1; // 大于1分钟视为有间隔
}
const startTime = formatTime(sample.startDate);
const endTime = formatTime(sample.endDate);
const duration = Math.round((new Date(sample.endDate).getTime() - new Date(sample.startDate).getTime()) / (1000 * 60));
// 获取睡眠阶段中文名称
const getStageName = (value: SleepStage) => {
switch (value) {
case SleepStage.InBed: return '在床上';
case SleepStage.Awake: return '清醒';
case SleepStage.Core: return '核心睡眠';
case SleepStage.Deep: return '深度睡眠';
case SleepStage.REM: return 'REM睡眠';
case SleepStage.Asleep: return '未指定睡眠';
default: return value;
}
};
return (
<View key={index}>
{/* 显示数据间隔 */}
{hasGap && (
<View style={[styles.gapIndicator, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}>
<Ionicons name="alert-circle-outline" size={14} color="#F59E0B" />
<Text style={[styles.gapText, { color: '#F59E0B' }]}>
: {Math.round(gapMinutes)}
</Text>
</View>
)}
{/* 睡眠样本条目 */}
<View style={[styles.rawSampleItem, { borderColor: colorTokens.border }]}>
<View style={styles.sampleHeader}>
<View style={styles.sampleLeft}>
<View
style={[
styles.stageDot,
{ backgroundColor: getSleepStageColor(sample.value) }
]}
/>
<Text style={[styles.sampleStage, { color: colorTokens.text }]}>
{getStageName(sample.value)}
</Text>
</View>
<Text style={[styles.sampleDuration, { color: colorTokens.textSecondary }]}>
{duration}
</Text>
</View>
<View style={styles.sampleTimeRange}>
<Text style={[styles.sampleTime, { color: colorTokens.textSecondary }]}>
{startTime} - {endTime}
</Text>
<Text style={[styles.sampleIndex, { color: colorTokens.textMuted }]}>
#{index + 1}
</Text>
</View>
</View>
</View>
);
})}
</ScrollView>
</View>
)}
</ScrollView>
{infoModal.type && (
<InfoModal
visible={infoModal.visible}
onClose={() => setInfoModal({ ...infoModal, visible: false })}
title={infoModal.title}
type={infoModal.type}
sleepData={displayData as SleepDetailData}
/>
)}
<SleepStagesInfoModal
visible={sleepStagesModal.visible}
onClose={() => setSleepStagesModal({ visible: false })}
/>
</ThemedView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
scrollView: {
flex: 1,
},
scrollContent: {
paddingHorizontal: 20,
paddingBottom: 40,
},
scoreContainer: {
alignItems: 'center',
},
circularProgressContainer: {
position: 'relative',
alignItems: 'center',
justifyContent: 'center',
},
scoreTextContainer: {
alignItems: 'center',
justifyContent: 'center',
},
scoreNumber: {
fontSize: 48,
fontWeight: '800',
color: '#1F2937',
lineHeight: 48,
},
scoreLabel: {
fontSize: 14,
color: '#6B7280',
marginTop: 4,
},
qualityDescription: {
fontSize: 18,
fontWeight: '600',
color: '#1F2937',
textAlign: 'center',
marginBottom: 16,
lineHeight: 24,
},
recommendationText: {
fontSize: 14,
color: '#6B7280',
textAlign: 'center',
lineHeight: 20,
marginBottom: 32,
paddingHorizontal: 16,
},
statsContainer: {
flexDirection: 'row',
gap: 12,
marginBottom: 32,
paddingHorizontal: 4,
},
newStatCard: {
flex: 1,
borderRadius: 20,
padding: 16,
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.08,
shadowRadius: 12,
elevation: 4,
borderWidth: 1,
borderColor: 'rgba(0, 0, 0, 0.06)',
},
statCardHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 8,
},
statCardLeftGroup: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
statCardIcon: {
width: 20,
height: 20,
alignItems: 'center',
justifyContent: 'center',
alignSelf: 'center',
},
infoButton: {
padding: 4,
alignSelf: 'center',
},
statCard: {
flex: 1,
backgroundColor: 'rgba(255, 255, 255, 0.9)',
borderRadius: 16,
padding: 16,
alignItems: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 3,
},
statIcon: {
fontSize: 18,
},
statLabel: {
fontSize: 12,
fontWeight: '500',
letterSpacing: 0.2,
alignSelf: 'center',
},
newStatValue: {
fontSize: 20,
fontWeight: '600',
marginBottom: 12,
letterSpacing: -0.5,
},
qualityBadge: {
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 8,
alignSelf: 'flex-start',
},
goodQualityBadge: {
backgroundColor: '#D1FAE5',
},
excellentQualityBadge: {
backgroundColor: '#FEF3C7',
},
qualityBadgeText: {
fontSize: 12,
fontWeight: '600',
letterSpacing: 0.1,
},
goodQualityText: {
color: '#065F46',
},
excellentQualityText: {
color: '#92400E',
},
statValue: {
fontSize: 18,
fontWeight: '700',
color: '#1F2937',
marginBottom: 4,
},
statQuality: {
fontSize: 12,
color: '#10B981',
fontWeight: '500',
},
chartContainer: {
backgroundColor: 'rgba(255, 255, 255, 0.9)',
borderRadius: 16,
padding: 16,
marginBottom: 24,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 3,
},
chartHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 16,
},
chartTimeLabel: {
alignItems: 'center',
},
chartTimeText: {
fontSize: 12,
color: '#6B7280',
fontWeight: '500',
},
chartHeartRate: {
alignItems: 'center',
},
chartHeartRateText: {
fontSize: 12,
color: '#EF4444',
fontWeight: '600',
},
chartBars: {
flexDirection: 'row',
alignItems: 'flex-end',
height: 120,
gap: 2,
},
chartBar: {
borderRadius: 2,
minHeight: 8,
},
chartTimeScale: {
flexDirection: 'row',
justifyContent: 'space-between',
paddingHorizontal: 4,
marginTop: 8,
},
chartTimeScaleText: {
fontSize: 10,
color: '#9CA3AF',
textAlign: 'center',
},
layeredChartContainer: {
position: 'relative',
marginVertical: 16,
},
sleepBlock: {
borderRadius: 2,
borderWidth: 0.5,
borderColor: 'rgba(255, 255, 255, 0.2)',
},
baselineLine: {
height: 1,
backgroundColor: '#E5E7EB',
position: 'absolute',
},
stagesContainer: {
backgroundColor: 'rgba(255, 255, 255, 0.9)',
borderRadius: 16,
padding: 16,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 3,
},
stageRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 12,
borderBottomWidth: 1,
borderBottomColor: '#F3F4F6',
},
stageInfo: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
},
stageColorDot: {
width: 12,
height: 12,
borderRadius: 6,
marginRight: 12,
},
stageName: {
fontSize: 14,
color: '#374151',
fontWeight: '500',
},
stageStats: {
alignItems: 'flex-end',
},
stagePercentage: {
fontSize: 16,
fontWeight: '700',
color: '#1F2937',
},
stageDuration: {
fontSize: 12,
color: '#6B7280',
marginTop: 2,
},
stageQuality: {
fontSize: 11,
fontWeight: '600',
marginTop: 2,
},
loadingContainer: {
justifyContent: 'center',
alignItems: 'center',
},
loadingText: {
fontSize: 16,
color: '#6B7280',
marginTop: 16,
},
errorText: {
fontSize: 16,
color: '#6B7280',
marginBottom: 16,
},
retryButton: {
backgroundColor: Colors.light.primary,
borderRadius: 8,
paddingHorizontal: 24,
paddingVertical: 12,
},
retryButtonText: {
color: '#FFFFFF',
fontSize: 14,
fontWeight: '600',
},
noDataContainer: {
alignItems: 'center',
paddingVertical: 24,
},
noDataText: {
fontSize: 14,
color: '#9CA3AF',
fontStyle: 'italic',
},
// Info Modal 和 Grade Cards 样式已移动到独立组件中
mockDataToggle: {
paddingHorizontal: 12,
paddingVertical: 6,
backgroundColor: 'rgba(255, 255, 255, 0.2)',
borderRadius: 16,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.3)',
},
mockDataToggleText: {
fontSize: 12,
fontWeight: '600',
},
// 简化睡眠阶段图表样式
simplifiedChartContainer: {
backgroundColor: 'rgba(255, 255, 255, 0.9)',
borderRadius: 16,
padding: 16,
marginBottom: 24,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 3,
},
chartTitleContainer: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 16,
},
chartTitle: {
fontSize: 16,
fontWeight: '600',
color: '#1F2937',
},
chartInfoButton: {
padding: 4,
},
simplifiedChartBar: {
flexDirection: 'row',
height: 24,
borderRadius: 12,
overflow: 'hidden',
marginBottom: 16,
},
stageSegment: {
height: '100%',
},
chartLegend: {
gap: 8,
},
legendRow: {
flexDirection: 'row',
justifyContent: 'center',
},
legendItem: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
},
legendDot: {
width: 8,
height: 8,
borderRadius: 4,
marginRight: 6,
},
legendText: {
fontSize: 12,
color: '#6B7280',
fontWeight: '500',
},
// 睡眠阶段卡片网格样式
stagesGridContainer: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 12,
paddingHorizontal: 4,
},
stageCard: {
width: '48%',
borderRadius: 20,
padding: 16,
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.08,
shadowRadius: 12,
elevation: 4,
borderWidth: 1,
borderColor: 'rgba(0, 0, 0, 0.06)',
},
stageCardTitle: {
fontSize: 14,
fontWeight: '500',
marginBottom: 8,
},
stageCardValue: {
fontSize: 24,
fontWeight: '700',
lineHeight: 28,
marginBottom: 4,
},
stageCardPercentage: {
fontSize: 12,
marginBottom: 12,
},
stageCardQuality: {
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 8,
alignSelf: 'flex-start',
},
normalQuality: {
backgroundColor: '#D1FAE5',
},
lowQuality: {
backgroundColor: '#FECACA',
},
stageCardQualityText: {
fontSize: 12,
fontWeight: '600',
},
normalQualityText: {
color: '#065F46',
},
lowQualityText: {
color: '#DC2626',
},
stageCardProgress: {
height: 6,
backgroundColor: '#E5E7EB',
borderRadius: 3,
overflow: 'hidden',
},
stageCardProgressBar: {
height: '100%',
borderRadius: 3,
},
// Sleep Stages Modal 样式已移动到独立组件中
// 睡眠时间标签样式
sleepTimeLabels: {
flexDirection: 'row',
justifyContent: 'space-between',
marginBottom: 16,
},
sleepTimeLabel: {
alignItems: 'center',
},
sleepTimeText: {
fontSize: 12,
fontWeight: '500',
marginBottom: 4,
},
sleepTimeValue: {
fontSize: 16,
fontWeight: '700',
letterSpacing: -0.2,
},
// 调试信息样式
debugContainer: {
marginHorizontal: 20,
marginBottom: 20,
padding: 16,
borderRadius: 12,
borderWidth: 1,
borderColor: 'rgba(0, 0, 0, 0.1)',
},
debugTitle: {
fontSize: 14,
fontWeight: '600',
marginBottom: 8,
},
debugText: {
fontSize: 12,
lineHeight: 16,
marginBottom: 4,
},
// Raw Sleep Samples List 样式
rawSamplesContainer: {
borderRadius: 16,
padding: 16,
marginBottom: 24,
marginHorizontal: 4,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 3,
},
rawSamplesHeader: {
marginBottom: 16,
},
rawSamplesTitle: {
fontSize: 16,
fontWeight: '600',
marginBottom: 4,
},
rawSamplesSubtitle: {
fontSize: 12,
fontWeight: '500',
},
rawSamplesScrollView: {
maxHeight: 400, // 限制高度,避免列表过长
},
rawSampleItem: {
paddingVertical: 12,
paddingHorizontal: 16,
borderLeftWidth: 3,
borderLeftColor: 'transparent',
marginBottom: 8,
borderRadius: 8,
backgroundColor: 'rgba(248, 250, 252, 0.5)',
},
sampleHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 4,
},
sampleLeft: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
},
stageDot: {
width: 8,
height: 8,
borderRadius: 4,
marginRight: 8,
},
sampleStage: {
fontSize: 14,
fontWeight: '500',
flex: 1,
},
sampleDuration: {
fontSize: 12,
fontWeight: '600',
},
sampleTimeRange: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
sampleTime: {
fontSize: 12,
},
sampleIndex: {
fontSize: 10,
fontWeight: '500',
},
gapIndicator: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 8,
paddingHorizontal: 12,
marginVertical: 4,
borderRadius: 8,
gap: 6,
},
gapText: {
fontSize: 12,
fontWeight: '600',
},
});

732
app/steps/detail.tsx Normal file
View File

@@ -0,0 +1,732 @@
import { DateSelector } from '@/components/DateSelector';
import { HeaderBar } from '@/components/ui/HeaderBar';
import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date';
import { fetchHourlyStepSamples, fetchStepCount, HourlyStepData } from '@/utils/health';
import { logger } from '@/utils/logger';
import dayjs from 'dayjs';
import { LinearGradient } from 'expo-linear-gradient';
import { useLocalSearchParams } from 'expo-router';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import {
Animated,
ScrollView,
StyleSheet,
Text,
View
} from 'react-native';
export default function StepsDetailScreen() {
// 获取路由参数
const { date } = useLocalSearchParams<{ date?: string }>();
// 根据传入的日期参数计算初始选中索引
const getInitialSelectedIndex = () => {
if (date) {
const targetDate = dayjs(date);
const days = getMonthDaysZh();
const foundIndex = days.findIndex(day =>
day.date && dayjs(day.date.toDate()).isSame(targetDate, 'day')
);
return foundIndex >= 0 ? foundIndex : getTodayIndexInMonth();
}
return getTodayIndexInMonth();
};
// 日期选择相关状态
const [selectedIndex, setSelectedIndex] = useState(getInitialSelectedIndex());
// 步数数据状态
const [stepCount, setStepCount] = useState(0);
const [hourlySteps, setHourSteps] = useState<HourlyStepData[]>([]);
const [isLoading, setIsLoading] = useState(false);
// 获取当前选中日期
const currentSelectedDate = useMemo(() => {
const days = getMonthDaysZh();
return days[selectedIndex]?.date?.toDate() ?? new Date();
}, [selectedIndex]);
// 获取步数数据的函数,参考 StepsCard 的实现
const getStepData = async (date: Date) => {
try {
setIsLoading(true);
logger.info('获取步数详情数据...');
const [steps, hourly] = await Promise.all([
fetchStepCount(date),
fetchHourlyStepSamples(date)
]);
setStepCount(steps);
setHourSteps(hourly);
} catch (error) {
logger.error('获取步数详情数据失败:', error);
} finally {
setIsLoading(false);
}
};
// 为每个柱体创建独立的动画值
const animatedValues = useRef(
Array.from({ length: 24 }, () => new Animated.Value(0))
).current;
// 计算柱状图数据
const chartData = useMemo(() => {
if (!hourlySteps || hourlySteps.length === 0) {
return Array.from({ length: 24 }, (_, i) => ({ hour: i, steps: 0, height: 0 }));
}
// 找到最大步数用于计算高度比例
const maxSteps = Math.max(...hourlySteps.map(data => data.steps), 1);
const maxHeight = 120; // 详情页面使用更大的高度
return hourlySteps.map(data => ({
...data,
height: maxSteps > 0 ? (data.steps / maxSteps) * maxHeight : 0
}));
}, [hourlySteps]);
// 计算平均值刻度线位置
const averageLinePosition = useMemo(() => {
if (!hourlySteps || hourlySteps.length === 0 || !chartData || chartData.length === 0) return 0;
const activeHours = hourlySteps.filter(h => h.steps > 0);
if (activeHours.length === 0) return 0;
const avgSteps = activeHours.reduce((sum, h) => sum + h.steps, 0) / activeHours.length;
const maxSteps = Math.max(...hourlySteps.map(data => data.steps), 1);
const maxHeight = 120;
return maxSteps > 0 ? (avgSteps / maxSteps) * maxHeight : 0;
}, [hourlySteps, chartData]);
// 获取当前小时
const currentHour = new Date().getHours();
// 触发柱体动画
useEffect(() => {
if (chartData && chartData.length > 0) {
// 重置所有动画值
animatedValues.forEach(animValue => animValue.setValue(0));
// 延迟启动动画,创建波浪效果
chartData.forEach((data, index) => {
if (data.steps > 0) {
setTimeout(() => {
Animated.spring(animatedValues[index], {
toValue: 1,
tension: 120,
friction: 8,
useNativeDriver: false,
}).start();
}, index * 50); // 每个柱体延迟50ms
}
});
}
}, [chartData, animatedValues]);
// 日期选择处理
const onSelectDate = (index: number, date: Date) => {
setSelectedIndex(index);
getStepData(date);
};
// 当路由参数变化时更新选中索引
useEffect(() => {
const newIndex = getInitialSelectedIndex();
setSelectedIndex(newIndex);
}, [date]);
// 当选中日期变化时获取数据
useEffect(() => {
if (currentSelectedDate) {
getStepData(currentSelectedDate);
}
}, [currentSelectedDate]);
// 计算总步数和平均步数
const totalSteps = stepCount || 0;
const averageHourlySteps = useMemo(() => {
if (!hourlySteps || hourlySteps.length === 0) return 0;
const activeHours = hourlySteps.filter(h => h.steps > 0);
if (activeHours.length === 0) return 0;
return Math.round(activeHours.reduce((sum, h) => sum + h.steps, 0) / activeHours.length);
}, [hourlySteps]);
// 找出最活跃的时间段
const mostActiveHour = useMemo(() => {
if (!hourlySteps || hourlySteps.length === 0) return null;
const maxStepsData = hourlySteps.reduce((max, current) =>
current.steps > max.steps ? current : max
);
return maxStepsData.steps > 0 ? maxStepsData : null;
}, [hourlySteps]);
// 活动等级配置
const activityLevels = useMemo(() => [
{ key: 'inactive', label: '不怎么动', minSteps: 0, maxSteps: 3000, color: '#B8C8D6' },
{ key: 'light', label: '轻度活跃', minSteps: 3000, maxSteps: 7500, color: '#93C5FD' },
{ key: 'moderate', label: '中等活跃', minSteps: 7500, maxSteps: 10000, color: '#FCD34D' },
{ key: 'very_active', label: '非常活跃', minSteps: 10000, maxSteps: Infinity, color: '#FB923C' }
], []);
// 计算当前活动等级
const currentActivityLevel = useMemo(() => {
return activityLevels.find(level =>
totalSteps >= level.minSteps && totalSteps < level.maxSteps
) || activityLevels[0];
}, [totalSteps, activityLevels]);
// 计算下一等级
const nextActivityLevel = useMemo(() => {
const currentIndex = activityLevels.indexOf(currentActivityLevel);
return currentIndex < activityLevels.length - 1 ? activityLevels[currentIndex + 1] : null;
}, [currentActivityLevel, activityLevels]);
// 计算进度百分比
const progressPercentage = useMemo(() => {
if (!nextActivityLevel) return 100; // 已达到最高级
const rangeSize = nextActivityLevel.minSteps - currentActivityLevel.minSteps;
const currentProgress = totalSteps - currentActivityLevel.minSteps;
return Math.min(Math.max((currentProgress / rangeSize) * 100, 0), 100);
}, [totalSteps, currentActivityLevel, nextActivityLevel]);
// 倒序显示的活动等级(用于图例)
const reversedActivityLevels = useMemo(() => [...activityLevels].reverse(), [activityLevels]);
return (
<View style={styles.container}>
{/* 背景渐变 */}
<LinearGradient
colors={['#F0F9FF', '#E0F2FE']}
style={styles.gradientBackground}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
<HeaderBar
title="步数详情"
/>
<ScrollView
style={styles.scrollView}
contentContainerStyle={{}}
showsVerticalScrollIndicator={false}
>
{/* 日期选择器 */}
<DateSelector
selectedIndex={selectedIndex}
onDateSelect={onSelectDate}
showMonthTitle={true}
disableFutureDates={true}
/>
{/* 统计卡片 */}
<View style={styles.statsCard}>
{isLoading ? (
<View style={styles.loadingContainer}>
<Text style={styles.loadingText}>...</Text>
</View>
) : (
<View style={styles.statsRow}>
<View style={styles.statItem}>
<Text style={styles.statValue}>{totalSteps.toLocaleString()}</Text>
<Text style={styles.statLabel}></Text>
</View>
<View style={styles.statItem}>
<Text style={styles.statValue}>{averageHourlySteps}</Text>
<Text style={styles.statLabel}></Text>
</View>
<View style={styles.statItem}>
<Text style={styles.statValue}>
{mostActiveHour ? `${mostActiveHour.hour}:00` : '--'}
</Text>
<Text style={styles.statLabel}></Text>
</View>
</View>
)}
</View>
{/* 详细柱状图卡片 */}
<View style={styles.chartCard}>
<View style={styles.chartHeader}>
<Text style={styles.chartTitle}></Text>
<Text style={styles.chartSubtitle}>
{dayjs(currentSelectedDate).format('YYYY年MM月DD日')}
</Text>
</View>
{/* 柱状图容器 */}
<View style={styles.chartContainer}>
{/* 平均值刻度线 - 放在chartArea外面相对于chartContainer定位 */}
{averageLinePosition > 0 && (
<View
style={[
styles.averageLine,
{ bottom: averageLinePosition }
]}
>
<View style={styles.averageLineDashContainer}>
{/* 创建更多的虚线段来确保完整覆盖 */}
{Array.from({ length: 80 }, (_, index) => (
<View
key={index}
style={[
styles.dashSegment,
{
marginLeft: index > 0 ? 2 : 0,
flex: 0 // 防止 flex 拉伸
}
]}
/>
))}
</View>
<Text style={styles.averageLineLabel}>
{averageHourlySteps}
</Text>
</View>
)}
{/* 柱状图区域 */}
<View style={styles.chartArea}>
{chartData.map((data, index) => {
const isActive = data.steps > 0;
const isCurrent = index <= currentHour;
const isKeyTime = index === 0 || index === 12 || index === 23;
// 动画变换
const animatedHeight = animatedValues[index].interpolate({
inputRange: [0, 1],
outputRange: [0, data.height],
});
const animatedOpacity = animatedValues[index].interpolate({
inputRange: [0, 1],
outputRange: [0, 1],
});
return (
<View key={`bar-${index}`} style={styles.barContainer}>
{/* 背景柱体 */}
<View
style={[
styles.backgroundBar,
{
backgroundColor: isKeyTime ? '#FFF4E6' : '#F8FAFC',
}
]}
/>
{/* 数据柱体 */}
{isActive && (
<Animated.View
style={[
styles.dataBar,
{
height: animatedHeight,
backgroundColor: isCurrent ? '#FFC365' : '#FFEBCB',
opacity: animatedOpacity,
}
]}
/>
)}
{/* 步数标签(仅在有数据且是关键时间点时显示) */}
{/* {isActive && isKeyTime && (
<Animated.View
style={[styles.stepLabel, { opacity: animatedOpacity }]}
>
<Text style={styles.stepLabelText}>{data.steps}</Text>
</Animated.View>
)} */}
</View>
);
})}
</View>
{/* 底部时间轴标签 */}
<View style={styles.timeLabels}>
<Text style={styles.timeLabel}>0:00</Text>
<Text style={styles.timeLabel}>12:00</Text>
<Text style={styles.timeLabel}>24:00</Text>
</View>
</View>
</View>
{/* 活动等级展示卡片 */}
<View style={styles.activityLevelCard}>
{/* 活动级别文本 */}
<Text style={styles.activityMainText}></Text>
<Text style={styles.activityLevelText}>{currentActivityLevel.label}</Text>
{/* 进度条 */}
<View style={styles.progressBarContainer}>
<View style={styles.progressBarBackground}>
<View
style={[
styles.progressBarFill,
{
width: `${progressPercentage}%`,
backgroundColor: currentActivityLevel.color
}
]}
/>
</View>
</View>
{/* 步数信息 */}
<View style={styles.stepsInfoContainer}>
<View style={styles.currentStepsInfo}>
<Text style={styles.stepsValue}>{totalSteps.toLocaleString()} </Text>
<Text style={styles.stepsLabel}></Text>
</View>
<View style={styles.nextStepsInfo}>
<Text style={styles.stepsValue}>
{nextActivityLevel ? `${nextActivityLevel.minSteps.toLocaleString()}` : '--'}
</Text>
<Text style={styles.stepsLabel}>
{nextActivityLevel ? `下一级: ${nextActivityLevel.label}` : '已达最高级'}
</Text>
</View>
</View>
{/* 活动等级图例 */}
<View style={styles.activityLegendContainer}>
{reversedActivityLevels.map((level) => (
<View key={level.key} style={styles.legendItem}>
<View style={[styles.legendIcon, { backgroundColor: level.color }]}>
<Text style={styles.legendIconText}>🏃</Text>
</View>
<Text style={styles.legendLabel}>{level.label}</Text>
<Text style={styles.legendRange}>
{level.maxSteps === Infinity
? `> ${level.minSteps.toLocaleString()}`
: `${level.minSteps.toLocaleString()} - ${level.maxSteps.toLocaleString()}`}
</Text>
</View>
))}
</View>
</View>
</ScrollView>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
gradientBackground: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 20,
paddingVertical: 16,
backgroundColor: 'transparent',
},
backButton: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: 'rgba(255, 255, 255, 0.8)',
alignItems: 'center',
justifyContent: 'center',
},
headerTitle: {
fontSize: 18,
fontWeight: '600',
color: '#192126',
},
headerRight: {
width: 40,
},
scrollView: {
flex: 1,
paddingHorizontal: 20,
},
statsCard: {
backgroundColor: '#FFFFFF',
borderRadius: 16,
padding: 20,
marginVertical: 16,
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 4,
},
shadowOpacity: 0.08,
shadowRadius: 12,
elevation: 6,
},
statsRow: {
flexDirection: 'row',
justifyContent: 'space-around',
},
statItem: {
alignItems: 'center',
},
statValue: {
fontSize: 24,
fontWeight: '700',
color: '#192126',
marginBottom: 4,
},
statLabel: {
fontSize: 12,
color: '#64748B',
},
loadingContainer: {
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 20,
},
loadingText: {
fontSize: 16,
color: '#64748B',
},
chartCard: {
backgroundColor: '#FFFFFF',
borderRadius: 16,
padding: 20,
marginBottom: 16,
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 4,
},
shadowOpacity: 0.08,
shadowRadius: 12,
elevation: 6,
},
chartHeader: {
marginBottom: 20,
},
chartTitle: {
fontSize: 18,
fontWeight: '600',
color: '#192126',
marginBottom: 4,
},
chartSubtitle: {
fontSize: 14,
color: '#64748B',
},
chartContainer: {
position: 'relative',
},
timeLabels: {
flexDirection: 'row',
justifyContent: 'space-between',
marginTop: 8,
paddingHorizontal: 8,
},
timeLabel: {
fontSize: 12,
color: '#64748B',
fontWeight: '500',
},
chartArea: {
flexDirection: 'row',
alignItems: 'flex-end',
height: 120,
justifyContent: 'space-between',
paddingHorizontal: 4,
},
barContainer: {
width: 8,
height: 120,
alignItems: 'center',
justifyContent: 'flex-end',
position: 'relative',
},
backgroundBar: {
width: 8,
height: 120,
borderRadius: 2,
position: 'absolute',
bottom: 0,
},
dataBar: {
width: 8,
borderRadius: 2,
position: 'absolute',
bottom: 0,
},
stepLabel: {
position: 'absolute',
top: -20,
alignItems: 'center',
},
stepLabelText: {
fontSize: 10,
color: '#64748B',
fontWeight: '500',
},
averageLine: {
position: 'absolute',
left: 4, // 匹配 chartArea 的 paddingHorizontal
right: 4, // 匹配 chartArea 的 paddingHorizontal
flexDirection: 'row',
alignItems: 'center',
zIndex: 1,
},
averageLineDashContainer: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
marginRight: 8,
overflow: 'hidden', // 防止虚线段溢出容器
},
dashSegment: {
width: 3,
height: 1.5,
backgroundColor: '#FFA726',
opacity: 0.8,
},
averageLineLabel: {
fontSize: 10,
color: '#FFA726',
fontWeight: '600',
backgroundColor: '#FFFFFF',
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: 4,
borderWidth: 0.5,
borderColor: '#FFA726',
},
activityLevelCard: {
backgroundColor: '#FFFFFF',
borderRadius: 16,
padding: 24,
marginVertical: 16,
alignItems: 'center',
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 4,
},
shadowOpacity: 0.08,
shadowRadius: 12,
elevation: 6,
},
activityIconContainer: {
marginBottom: 16,
},
activityIcon: {
width: 80,
height: 80,
borderRadius: 40,
backgroundColor: '#E0F2FE',
alignItems: 'center',
justifyContent: 'center',
borderWidth: 2,
borderColor: '#93C5FD',
borderStyle: 'dashed',
},
meditationIcon: {
width: 50,
height: 50,
borderRadius: 25,
backgroundColor: '#93C5FD',
alignItems: 'center',
justifyContent: 'center',
},
meditationEmoji: {
fontSize: 24,
},
activityMainText: {
fontSize: 16,
color: '#64748B',
marginBottom: 4,
},
activityLevelText: {
fontSize: 24,
fontWeight: '700',
color: '#192126',
marginBottom: 20,
},
progressBarContainer: {
width: '100%',
marginBottom: 24,
},
progressBarBackground: {
width: '100%',
height: 8,
backgroundColor: '#F0F9FF',
borderRadius: 4,
overflow: 'hidden',
},
progressBarFill: {
height: '100%',
borderRadius: 4,
},
stepsInfoContainer: {
flexDirection: 'row',
justifyContent: 'space-between',
width: '100%',
marginBottom: 32,
},
currentStepsInfo: {
alignItems: 'flex-start',
},
nextStepsInfo: {
alignItems: 'flex-end',
},
stepsValue: {
fontSize: 20,
fontWeight: '700',
color: '#192126',
marginBottom: 4,
},
stepsLabel: {
fontSize: 14,
color: '#64748B',
},
activityLegendContainer: {
width: '100%',
},
legendItem: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 12,
paddingHorizontal: 16,
backgroundColor: '#F8FAFC',
marginBottom: 8,
borderRadius: 12,
},
legendIcon: {
width: 32,
height: 32,
borderRadius: 8,
alignItems: 'center',
justifyContent: 'center',
marginRight: 12,
},
legendIconText: {
fontSize: 16,
},
legendLabel: {
flex: 1,
fontSize: 14,
fontWeight: '600',
color: '#192126',
},
legendRange: {
fontSize: 14,
color: '#64748B',
fontWeight: '500',
},
});

649
app/task-detail.tsx Normal file
View File

@@ -0,0 +1,649 @@
import { useGlobalDialog } from '@/components/ui/DialogProvider';
import { HeaderBar } from '@/components/ui/HeaderBar';
import { Colors } from '@/constants/Colors';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useColorScheme } from '@/hooks/useColorScheme';
import { completeTask, skipTask } from '@/store/tasksSlice';
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
import { useLocalSearchParams, useRouter } from 'expo-router';
import React, { useState } from 'react';
import {
Alert,
Image,
ScrollView,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
View
} from 'react-native';
export default function TaskDetailScreen() {
const { taskId } = useLocalSearchParams<{ taskId: string }>();
const router = useRouter();
const theme = useColorScheme() ?? 'light';
const colorTokens = Colors[theme];
const dispatch = useAppDispatch();
const { showConfirm } = useGlobalDialog();
// 从Redux中获取任务数据
const { tasks, tasksLoading } = useAppSelector(state => state.tasks);
const task = tasks.find(t => t.id === taskId) || null;
const [comment, setComment] = useState('');
const getStatusText = (status: string) => {
switch (status) {
case 'completed':
return '已完成';
case 'in_progress':
return '进行中';
case 'overdue':
return '已过期';
case 'skipped':
return '已跳过';
default:
return '待开始';
}
};
const getStatusColor = (status: string) => {
switch (status) {
case 'completed':
return '#10B981';
case 'in_progress':
return '#7A5AF8';
case 'overdue':
return '#EF4444';
case 'skipped':
return '#6B7280';
default:
return '#6B7280';
}
};
const getDifficultyText = (difficulty: string) => {
switch (difficulty) {
case 'very_easy':
return '非常简单 (少于一天)';
case 'easy':
return '简单 (1-2天)';
case 'medium':
return '中等 (3-5天)';
case 'hard':
return '困难 (1-2周)';
case 'very_hard':
return '非常困难 (2周以上)';
default:
return '非常简单 (少于一天)';
}
};
const getDifficultyColor = (difficulty: string) => {
switch (difficulty) {
case 'very_easy':
return '#10B981';
case 'easy':
return '#34D399';
case 'medium':
return '#F59E0B';
case 'hard':
return '#F97316';
case 'very_hard':
return '#EF4444';
default:
return '#10B981';
}
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
const options: Intl.DateTimeFormatOptions = {
year: 'numeric',
month: 'long',
day: 'numeric'
};
return `创建于 ${date.toLocaleDateString('zh-CN', options)}`;
};
const handleCompleteTask = async () => {
if (!task || task.status === 'completed') {
return;
}
try {
await dispatch(completeTask({
taskId: task.id,
completionData: {
count: 1,
notes: '通过任务详情页面完成'
}
})).unwrap();
// 检查任务是否真正完成(当前完成次数是否达到目标次数)
const updatedTask = tasks.find(t => t.id === task.id);
if (updatedTask && updatedTask.currentCount >= updatedTask.targetCount) {
Alert.alert('成功', '任务已完成!');
router.back();
} else {
Alert.alert('成功', '任务进度已更新!');
}
} catch (error) {
Alert.alert('错误', '完成任务失败,请重试');
}
};
const handleSkipTask = async () => {
if (!task || task.status === 'completed' || task.status === 'skipped') {
return;
}
showConfirm(
{
title: '确认跳过任务',
message: `确定要跳过任务"${task.title}"吗?\n\n跳过后的任务将不会显示在任务列表中且无法恢复。`,
confirmText: '跳过',
cancelText: '取消',
destructive: true,
icon: 'warning',
iconColor: '#F59E0B',
},
async () => {
try {
await dispatch(skipTask({
taskId: task.id,
skipData: {
reason: '用户主动跳过'
}
})).unwrap();
Alert.alert('成功', '任务已跳过!');
router.back();
} catch (error) {
Alert.alert('错误', '跳过任务失败,请重试');
}
}
);
};
const handleSendComment = () => {
if (comment.trim()) {
// 这里应该调用API发送评论
console.log('发送评论:', comment);
setComment('');
Alert.alert('成功', '评论已发送!');
}
};
if (tasksLoading) {
return (
<View style={[styles.container, { backgroundColor: colorTokens.background }]}>
<HeaderBar
title="任务详情"
onBack={() => router.back()}
/>
<View style={styles.loadingContainer}>
<Text style={[styles.loadingText, { color: colorTokens.text }]}>...</Text>
</View>
</View>
);
}
if (!task) {
return (
<View style={[styles.container, { backgroundColor: colorTokens.background }]}>
<HeaderBar
title="任务详情"
onBack={() => router.back()}
/>
<View style={styles.errorContainer}>
<Text style={[styles.errorText, { color: colorTokens.text }]}></Text>
</View>
</View>
);
}
return (
<View style={[styles.container, { backgroundColor: colorTokens.background }]}>
{/* 使用HeaderBar组件 */}
<HeaderBar
title="任务详情"
onBack={() => router.back()}
right={
task.status !== 'completed' && task.status !== 'skipped' && task.currentCount < task.targetCount ? (
<TouchableOpacity onPress={handleCompleteTask} style={styles.completeButton}>
<Image
source={require('@/assets/images/task/iconTaskHeader.png')}
style={styles.taskIcon}
resizeMode="contain"
/>
</TouchableOpacity>
) : undefined
}
/>
<ScrollView style={styles.scrollView} showsVerticalScrollIndicator={false}>
{/* 任务标题和创建时间 */}
<View style={styles.titleSection}>
<Text style={[styles.taskTitle, { color: colorTokens.text }]}>{task.title}</Text>
<Text style={[styles.createdDate, { color: colorTokens.textSecondary }]}>
{formatDate(task.startDate)}
</Text>
</View>
{/* 状态标签 */}
<View style={styles.statusContainer}>
<View style={[styles.statusTag, { backgroundColor: getStatusColor(task.status) }]}>
<Text style={styles.statusTagText}>{getStatusText(task.status)}</Text>
</View>
</View>
{/* 描述区域 */}
<View style={styles.descriptionSection}>
<Text style={[styles.sectionTitle, { color: colorTokens.text }]}></Text>
<Text style={[styles.descriptionText, { color: colorTokens.textSecondary }]}>
{task.description || '暂无描述'}
</Text>
</View>
{/* 优先级和难度 */}
<View style={styles.infoSection}>
<View style={styles.infoItem}>
<Text style={[styles.infoLabel, { color: colorTokens.text }]}></Text>
<View style={styles.priorityTag}>
<MaterialIcons name="flag" size={16} color="#FFFFFF" />
<Text style={styles.priorityTagText}></Text>
</View>
</View>
<View style={styles.infoItem}>
<Text style={[styles.infoLabel, { color: colorTokens.text }]}></Text>
<View style={[styles.difficultyTag, { backgroundColor: getDifficultyColor('very_easy') }]}>
<MaterialIcons name="sentiment-satisfied" size={16} color="#FFFFFF" />
<Text style={styles.difficultyTagText}> ()</Text>
</View>
</View>
</View>
{/* 任务进度信息 */}
<View style={styles.progressSection}>
<Text style={[styles.sectionTitle, { color: colorTokens.text }]}></Text>
{/* 进度条 */}
<View style={styles.progressBar}>
<View
style={[
styles.progressFill,
{
width: task.progressPercentage > 0 ? `${Math.min(task.progressPercentage, 100)}%` : '2%',
backgroundColor: task.progressPercentage >= 100
? '#10B981'
: task.progressPercentage >= 50
? '#F59E0B'
: task.progressPercentage > 0
? colorTokens.primary
: '#E5E7EB',
},
]}
/>
{task.progressPercentage > 0 && task.progressPercentage < 100 && (
<View style={styles.progressGlow} />
)}
{/* 进度文本 */}
<View style={styles.progressTextContainer}>
<Text style={styles.progressText}>{task.currentCount}/{task.targetCount}</Text>
</View>
</View>
{/* 进度详细信息 */}
<View style={styles.progressInfo}>
<View style={styles.progressItem}>
<Text style={[styles.progressLabel, { color: colorTokens.textSecondary }]}></Text>
<Text style={[styles.progressValue, { color: colorTokens.text }]}>{task.targetCount}</Text>
</View>
<View style={styles.progressItem}>
<Text style={[styles.progressLabel, { color: colorTokens.textSecondary }]}></Text>
<Text style={[styles.progressValue, { color: colorTokens.text }]}>{task.currentCount}</Text>
</View>
<View style={styles.progressItem}>
<Text style={[styles.progressLabel, { color: colorTokens.textSecondary }]}></Text>
<Text style={[styles.progressValue, { color: colorTokens.text }]}>{task.daysRemaining}</Text>
</View>
</View>
</View>
{/* 底部操作按钮 */}
{task.status !== 'completed' && task.status !== 'skipped' && task.currentCount < task.targetCount && (
<View style={styles.actionButtons}>
<TouchableOpacity
style={[styles.actionButton, styles.skipButton]}
onPress={handleSkipTask}
>
<MaterialIcons name="skip-next" size={20} color="#6B7280" />
<Text style={styles.skipButtonText}></Text>
</TouchableOpacity>
</View>
)}
{/* 评论区域 */}
<View style={styles.commentSection}>
<Text style={[styles.sectionTitle, { color: colorTokens.text }]}></Text>
<View style={styles.commentInputContainer}>
<View style={styles.commentAvatar}>
<Image
source={require('@/assets/images/Sealife.jpeg')}
style={styles.commentAvatarImage}
resizeMode="cover"
/>
</View>
<View style={styles.commentInputWrapper}>
<TextInput
style={[styles.commentInput, {
color: colorTokens.text,
backgroundColor: '#F3F4F6'
}]}
placeholder="写评论..."
placeholderTextColor="#9CA3AF"
value={comment}
onChangeText={setComment}
multiline
maxLength={500}
/>
<TouchableOpacity
style={[styles.sendButton, {
backgroundColor: comment.trim() ? '#6B7280' : '#D1D5DB'
}]}
onPress={handleSendComment}
disabled={!comment.trim()}
>
<MaterialIcons name="send" size={16} color="#FFFFFF" />
</TouchableOpacity>
</View>
</View>
</View>
</ScrollView>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
loadingText: {
fontSize: 16,
fontWeight: '500',
},
errorContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
errorText: {
fontSize: 16,
fontWeight: '500',
},
completeButton: {
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: '#7A5AF8',
alignItems: 'center',
justifyContent: 'center',
},
taskIcon: {
width: 20,
height: 20,
},
scrollView: {
flex: 1,
},
titleSection: {
padding: 16,
paddingBottom: 8,
},
taskTitle: {
fontSize: 20,
fontWeight: '600',
lineHeight: 28,
marginBottom: 4,
},
createdDate: {
fontSize: 14,
fontWeight: '400',
opacity: 0.7,
},
statusContainer: {
paddingHorizontal: 16,
paddingBottom: 16,
alignItems: 'flex-end',
},
statusTag: {
alignSelf: 'flex-end',
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 16,
},
statusTagText: {
fontSize: 14,
fontWeight: '600',
color: '#FFFFFF',
},
imagePlaceholder: {
height: 240,
backgroundColor: '#F9FAFB',
marginHorizontal: 16,
marginBottom: 20,
borderRadius: 12,
borderWidth: 2,
borderColor: '#E5E7EB',
borderStyle: 'dashed',
alignItems: 'center',
justifyContent: 'center',
},
imagePlaceholderText: {
fontSize: 16,
fontWeight: '500',
color: '#9CA3AF',
marginTop: 8,
},
descriptionSection: {
paddingHorizontal: 16,
marginBottom: 20,
},
sectionTitle: {
fontSize: 16,
fontWeight: '600',
marginBottom: 12,
},
descriptionText: {
fontSize: 15,
lineHeight: 22,
fontWeight: '400',
},
infoSection: {
paddingHorizontal: 16,
marginBottom: 20,
},
infoItem: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 12,
},
infoLabel: {
fontSize: 15,
fontWeight: '500',
},
priorityTag: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 12,
backgroundColor: '#EF4444',
},
priorityTagText: {
fontSize: 13,
fontWeight: '600',
color: '#FFFFFF',
},
difficultyTag: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 12,
},
difficultyTagText: {
fontSize: 13,
fontWeight: '600',
color: '#FFFFFF',
},
progressSection: {
paddingHorizontal: 16,
marginBottom: 20,
},
progressBar: {
height: 6,
backgroundColor: '#F3F4F6',
borderRadius: 3,
marginBottom: 16,
overflow: 'visible',
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.1,
shadowRadius: 2,
elevation: 2,
position: 'relative',
},
progressFill: {
height: '100%',
borderRadius: 3,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.2,
shadowRadius: 2,
elevation: 3,
},
progressGlow: {
position: 'absolute',
right: 0,
top: 0,
width: 8,
height: '100%',
backgroundColor: 'rgba(255, 255, 255, 0.6)',
borderRadius: 3,
},
progressTextContainer: {
position: 'absolute',
right: 0,
top: -6,
backgroundColor: '#FFFFFF',
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: 8,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.1,
shadowRadius: 2,
elevation: 2,
borderWidth: 1,
borderColor: '#E5E7EB',
zIndex: 1,
},
progressText: {
fontSize: 10,
fontWeight: '600',
color: '#374151',
},
progressInfo: {
flexDirection: 'row',
justifyContent: 'space-between',
},
progressItem: {
alignItems: 'center',
flex: 1,
},
progressLabel: {
fontSize: 13,
fontWeight: '400',
marginBottom: 4,
},
progressValue: {
fontSize: 18,
fontWeight: '600',
},
commentSection: {
paddingHorizontal: 16,
marginBottom: 20,
},
commentInputContainer: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
commentAvatar: {
width: 28,
height: 28,
borderRadius: 14,
overflow: 'hidden',
},
commentAvatarImage: {
width: '100%',
height: '100%',
},
commentInputWrapper: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
commentInput: {
flex: 1,
minHeight: 36,
maxHeight: 120,
paddingHorizontal: 12,
paddingVertical: 8,
borderRadius: 18,
fontSize: 15,
textAlignVertical: 'top',
},
sendButton: {
width: 28,
height: 28,
borderRadius: 14,
alignItems: 'center',
justifyContent: 'center',
},
actionButtons: {
paddingHorizontal: 16,
paddingBottom: 20,
},
actionButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
paddingVertical: 12,
paddingHorizontal: 16,
borderRadius: 8,
borderWidth: 1,
},
skipButton: {
backgroundColor: '#F9FAFB',
borderColor: '#E5E7EB',
},
skipButtonText: {
fontSize: 16,
fontWeight: '500',
color: '#6B7280',
},
});

286
app/task-list.tsx Normal file
View File

@@ -0,0 +1,286 @@
import { DateSelector } from '@/components/DateSelector';
import { TaskCard } from '@/components/TaskCard';
import { HeaderBar } from '@/components/ui/HeaderBar';
import { Colors } from '@/constants/Colors';
import { TAB_BAR_BOTTOM_OFFSET, TAB_BAR_HEIGHT } from '@/constants/TabBar';
import { useColorScheme } from '@/hooks/useColorScheme';
import { tasksApi } from '@/services/tasksApi';
import { TaskListItem } from '@/types/goals';
import { getTodayIndexInMonth } from '@/utils/date';
import { useFocusEffect } from '@react-navigation/native';
import dayjs from 'dayjs';
import { LinearGradient } from 'expo-linear-gradient';
import { useRouter } from 'expo-router';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Alert, FlatList, RefreshControl, StatusBar, StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
export default function GoalsDetailScreen() {
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme];
const router = useRouter();
// 本地状态管理
const [tasks, setTasks] = useState<TaskListItem[]>([]);
const [tasksLoading, setTasksLoading] = useState(false);
const [tasksError, setTasksError] = useState<string | null>(null);
const [selectedDate, setSelectedDate] = useState<Date>(new Date());
const [refreshing, setRefreshing] = useState(false);
// 日期选择器相关状态
const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth());
// 加载任务列表
const loadTasks = async (targetDate?: Date) => {
try {
setTasksLoading(true);
setTasksError(null);
const dateToUse = targetDate || selectedDate;
console.log('Loading tasks for date:', dayjs(dateToUse).format('YYYY-MM-DD'));
const response = await tasksApi.getTasks({
startDate: dayjs(dateToUse).startOf('day').toISOString(),
endDate: dayjs(dateToUse).endOf('day').toISOString(),
});
console.log('Tasks API response:', response);
setTasks(response.list || []);
} catch (error: any) {
console.error('Failed to load tasks:', error);
setTasksError(error.message || '获取任务列表失败');
setTasks([]);
} finally {
setTasksLoading(false);
}
};
// 页面聚焦时重新加载数据
useFocusEffect(
useCallback(() => {
console.log('useFocusEffect - loading tasks');
loadTasks();
}, [])
);
// 下拉刷新
const onRefresh = async () => {
setRefreshing(true);
try {
await loadTasks();
} finally {
setRefreshing(false);
}
};
// 处理错误提示
useEffect(() => {
if (tasksError) {
Alert.alert('错误', tasksError);
setTasksError(null);
}
}, [tasksError]);
// 日期选择处理
const onSelectDate = async (index: number, date: Date) => {
console.log('Date selected:', dayjs(date).format('YYYY-MM-DD'));
setSelectedIndex(index);
setSelectedDate(date);
// 重新加载对应日期的任务数据
await loadTasks(date);
};
// 根据选中日期筛选任务,并将已完成的任务放到最后
const filteredTasks = useMemo(() => {
const selected = dayjs(selectedDate);
const filtered = tasks.filter(task => {
if (task.status === 'skipped') return false;
const taskDate = dayjs(task.startDate);
return taskDate.isSame(selected, 'day');
});
// 对筛选结果进行排序:已完成的任务放到最后
return [...filtered].sort((a, b) => {
const aCompleted = a.status === 'completed';
const bCompleted = b.status === 'completed';
// 如果a已完成而b未完成a排在后面
if (aCompleted && !bCompleted) {
return 1;
}
// 如果b已完成而a未完成b排在后面
if (bCompleted && !aCompleted) {
return -1;
}
// 如果都已完成或都未完成,保持原有顺序
return 0;
});
}, [selectedDate, tasks]);
const handleBackPress = () => {
router.back();
};
// 渲染任务项
const renderTaskItem = ({ item }: { item: TaskListItem }) => (
<TaskCard
task={item}
/>
);
// 渲染空状态
const renderEmptyState = () => {
const selectedDateStr = dayjs(selectedDate).format('YYYY年M月D日');
if (tasksLoading) {
return (
<View style={styles.emptyState}>
<Text style={[styles.emptyStateTitle, { color: colorTokens.text }]}>
...
</Text>
</View>
);
}
return (
<View style={styles.emptyState}>
<Text style={[styles.emptyStateTitle, { color: colorTokens.text }]}>
</Text>
<Text style={[styles.emptyStateSubtitle, { color: colorTokens.textSecondary }]}>
{selectedDateStr}
</Text>
</View>
);
};
return (
<SafeAreaView style={styles.container}>
<StatusBar
backgroundColor="transparent"
translucent
/>
{/* 背景渐变 */}
<LinearGradient
colors={['#F0F9FF', '#E0F2FE']}
style={styles.gradientBackground}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
{/* 装饰性圆圈 */}
<View style={styles.decorativeCircle1} />
<View style={styles.decorativeCircle2} />
<View style={styles.content}>
{/* 标题区域 */}
<HeaderBar
title="任务列表"
onBack={handleBackPress}
transparent={true}
withSafeTop={false}
/>
{/* 日期选择器 */}
<View style={styles.dateSelector}>
<DateSelector
selectedIndex={selectedIndex}
onDateSelect={onSelectDate}
showMonthTitle={true}
disableFutureDates={true}
/>
</View>
{/* 任务列表 */}
<View style={styles.taskListContainer}>
<FlatList
data={filteredTasks}
renderItem={renderTaskItem}
keyExtractor={(item) => item.id}
contentContainerStyle={styles.taskList}
showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
colors={['#0EA5E9']}
tintColor="#0EA5E9"
/>
}
ListEmptyComponent={renderEmptyState}
/>
</View>
</View>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
gradientBackground: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
opacity: 0.6,
},
decorativeCircle1: {
position: 'absolute',
top: -20,
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,
},
content: {
flex: 1,
},
// 日期选择器样式
dateSelector: {
paddingHorizontal: 20,
},
taskListContainer: {
flex: 1,
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
overflow: 'hidden',
},
taskList: {
paddingHorizontal: 20,
paddingBottom: TAB_BAR_HEIGHT + TAB_BAR_BOTTOM_OFFSET + 20,
},
emptyState: {
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 60,
},
emptyStateTitle: {
fontSize: 18,
fontWeight: '600',
marginBottom: 8,
},
emptyStateSubtitle: {
fontSize: 14,
textAlign: 'center',
lineHeight: 20,
},
});

1425
app/training-plan.tsx Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,618 @@
import DateTimePicker from '@react-native-community/datetimepicker';
import { useLocalSearchParams, useRouter } from 'expo-router';
import React, { useEffect, useMemo, useState } from 'react';
import { Modal, Platform, Pressable, SafeAreaView, ScrollView, StyleSheet, TextInput, View } from 'react-native';
import { ThemedText } from '@/components/ThemedText';
import { ThemedView } from '@/components/ThemedView';
import { HeaderBar } from '@/components/ui/HeaderBar';
import { palette } from '@/constants/Colors';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { PlanGoal } from '@/services/trainingPlanApi';
import {
clearError,
loadPlans,
saveDraftAsPlan,
setGoal,
setMode,
setName,
setPreferredTime,
setSessionsPerWeek,
setStartDate,
setStartDateNextMonday,
setStartWeight,
toggleDayOfWeek,
} from '@/store/trainingPlanSlice';
const WEEK_DAYS = ['日', '一', '二', '三', '四', '五', '六'];
const GOALS: { key: PlanGoal; title: string; desc: string }[] = [
{ key: 'postpartum_recovery', title: '产后恢复', desc: '温和激活,核心重建' },
{ key: 'posture_correction', title: '体态矫正', desc: '打开胸肩,改善圆肩驼背' },
{ key: 'fat_loss', title: '减脂塑形', desc: '全身燃脂,线条雕刻' },
{ key: 'core_strength', title: '核心力量', desc: '核心稳定,提升运动表现' },
{ key: 'flexibility', title: '柔韧灵活', desc: '拉伸延展,释放紧张' },
{ key: 'rehab', title: '康复保健', desc: '循序渐进,科学修复' },
{ key: 'stress_relief', title: '释压放松', desc: '舒缓身心,改善睡眠' },
];
export default function TrainingPlanCreateScreen() {
const router = useRouter();
const dispatch = useAppDispatch();
const { draft, loading, error, editingId } = useAppSelector((s) => s.trainingPlan);
const { id } = useLocalSearchParams<{ id?: string }>();
const [weightInput, setWeightInput] = useState<string>('');
const [datePickerVisible, setDatePickerVisible] = useState(false);
const [pickerDate, setPickerDate] = useState<Date>(new Date());
useEffect(() => {
dispatch(loadPlans());
}, [dispatch]);
// 如果带有 id加载详情并进入编辑模式
useEffect(() => {
if (id) {
dispatch({ type: 'trainingPlan/clearError' } as any);
dispatch((require('@/store/trainingPlanSlice') as any).loadPlanForEdit(id as string));
} else {
// 离开编辑模式
dispatch((require('@/store/trainingPlanSlice') as any).setEditingId(null));
}
}, [id, dispatch]);
useEffect(() => {
if (draft.startWeightKg && !weightInput) setWeightInput(String(draft.startWeightKg));
}, [draft.startWeightKg]);
const selectedCount = draft.mode === 'daysOfWeek' ? draft.daysOfWeek.length : draft.sessionsPerWeek;
const canSave = useMemo(() => {
if (!draft.goal) return false;
if (draft.mode === 'daysOfWeek' && draft.daysOfWeek.length === 0) return false;
if (draft.mode === 'sessionsPerWeek' && draft.sessionsPerWeek <= 0) return false;
return true;
}, [draft]);
const formattedStartDate = useMemo(() => {
const d = new Date(draft.startDate);
try {
return new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric',
weekday: 'short',
}).format(d);
} catch {
return d.toLocaleDateString('zh-CN');
}
}, [draft.startDate]);
const handleSave = async () => {
try {
if (editingId) {
await dispatch((require('@/store/trainingPlanSlice') as any).updatePlanFromDraft()).unwrap();
} else {
await dispatch(saveDraftAsPlan()).unwrap();
}
router.back();
} catch (error) {
// 错误已经在Redux中处理这里可以显示额外的用户反馈
console.error('保存训练计划失败:', error);
}
};
useEffect(() => {
if (error) {
// 3秒后自动清除错误
const timer = setTimeout(() => {
dispatch(clearError());
}, 3000);
return () => clearTimeout(timer);
}
}, [error, dispatch]);
const openDatePicker = () => {
const base = draft.startDate ? new Date(draft.startDate) : new Date();
base.setHours(0, 0, 0, 0);
setPickerDate(base);
setDatePickerVisible(true);
};
const closeDatePicker = () => setDatePickerVisible(false);
const onConfirmDate = (date: Date) => {
const today = new Date();
today.setHours(0, 0, 0, 0);
const picked = new Date(date);
picked.setHours(0, 0, 0, 0);
const finalDate = picked < today ? today : picked;
dispatch(setStartDate(finalDate.toISOString()));
closeDatePicker();
};
return (
<SafeAreaView style={styles.safeArea}>
<ThemedView style={styles.container}>
<HeaderBar title={editingId ? '编辑训练计划' : '新建训练计划'} onBack={() => router.back()} withSafeTop={false} transparent />
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={styles.content}>
<ThemedText style={styles.title}></ThemedText>
<ThemedText style={styles.subtitle}></ThemedText>
{error && (
<View style={styles.errorContainer}>
<ThemedText style={styles.errorText}> {error}</ThemedText>
</View>
)}
<View style={styles.card}>
<ThemedText style={styles.cardTitle}></ThemedText>
<TextInput
placeholder="为你的训练计划起个名字(可选)"
value={draft.name || ''}
onChangeText={(text) => dispatch(setName(text))}
style={styles.nameInput}
maxLength={50}
/>
</View>
<View style={styles.card}>
<ThemedText style={styles.cardTitle}></ThemedText>
<View style={styles.segment}>
<Pressable
onPress={() => dispatch(setMode('daysOfWeek'))}
style={[styles.segmentItem, draft.mode === 'daysOfWeek' && styles.segmentItemActive]}
>
<ThemedText style={[styles.segmentText, draft.mode === 'daysOfWeek' && styles.segmentTextActive]}></ThemedText>
</Pressable>
<Pressable
onPress={() => dispatch(setMode('sessionsPerWeek'))}
style={[styles.segmentItem, draft.mode === 'sessionsPerWeek' && styles.segmentItemActive]}
>
<ThemedText style={[styles.segmentText, draft.mode === 'sessionsPerWeek' && styles.segmentTextActive]}></ThemedText>
</Pressable>
</View>
{draft.mode === 'daysOfWeek' ? (
<View style={styles.weekRow}>
{WEEK_DAYS.map((d, i) => {
const active = draft.daysOfWeek.includes(i);
return (
<Pressable key={i} onPress={() => dispatch(toggleDayOfWeek(i))} style={[styles.dayChip, active && styles.dayChipActive]}>
<ThemedText style={[styles.dayChipText, active && styles.dayChipTextActive]}>{d}</ThemedText>
</Pressable>
);
})}
</View>
) : (
<View style={styles.sliderRow}>
<ThemedText style={styles.sliderLabel}></ThemedText>
<View style={styles.counter}>
<Pressable onPress={() => dispatch(setSessionsPerWeek(Math.max(1, draft.sessionsPerWeek - 1)))} style={styles.counterBtn}>
<ThemedText style={styles.counterBtnText}>-</ThemedText>
</Pressable>
<ThemedText style={styles.counterValue}>{draft.sessionsPerWeek}</ThemedText>
<Pressable onPress={() => dispatch(setSessionsPerWeek(Math.min(7, draft.sessionsPerWeek + 1)))} style={styles.counterBtn}>
<ThemedText style={styles.counterBtnText}>+</ThemedText>
</Pressable>
</View>
<ThemedText style={styles.sliderSuffix}></ThemedText>
</View>
)}
<ThemedText style={styles.helper}>{selectedCount} /</ThemedText>
</View>
<View style={styles.card}>
<ThemedText style={styles.cardTitle}></ThemedText>
<View style={styles.goalGrid}>
{GOALS.map((g) => {
const active = draft.goal === g.key;
return (
<Pressable key={g.key} onPress={() => dispatch(setGoal(g.key))} style={[styles.goalItem, active && styles.goalItemActive]}>
<ThemedText style={[styles.goalTitle, active && styles.goalTitleActive]}>{g.title}</ThemedText>
<ThemedText style={styles.goalDesc}>{g.desc}</ThemedText>
</Pressable>
);
})}
</View>
</View>
<View style={styles.card}>
<ThemedText style={styles.cardTitle}></ThemedText>
<View style={styles.rowBetween}>
<ThemedText style={styles.label}></ThemedText>
<View style={styles.rowRight}>
<Pressable onPress={openDatePicker} style={styles.linkBtn}>
<ThemedText style={styles.linkText}></ThemedText>
</Pressable>
<Pressable onPress={() => dispatch(setStartDateNextMonday())} style={[styles.linkBtn, { marginLeft: 8 }]}>
<ThemedText style={styles.linkText}></ThemedText>
</Pressable>
</View>
</View>
<ThemedText style={styles.dateHint}>{formattedStartDate}</ThemedText>
<View style={styles.rowBetween}>
<ThemedText style={styles.label}> (kg)</ThemedText>
<TextInput
keyboardType="numeric"
placeholder="可选"
value={weightInput}
onChangeText={(t) => {
setWeightInput(t);
const v = Number(t);
dispatch(setStartWeight(Number.isFinite(v) ? v : undefined));
}}
style={styles.input}
/>
</View>
<View style={[styles.rowBetween, { marginTop: 12 }]}>
<ThemedText style={styles.label}></ThemedText>
<View style={styles.segmentSmall}>
{(['morning', 'noon', 'evening', ''] as const).map((k) => (
<Pressable key={k || 'none'} onPress={() => dispatch(setPreferredTime(k))} style={[styles.segmentItemSmall, draft.preferredTimeOfDay === k && styles.segmentItemActiveSmall]}>
<ThemedText style={[styles.segmentTextSmall, draft.preferredTimeOfDay === k && styles.segmentTextActiveSmall]}>{k === 'morning' ? '晨练' : k === 'noon' ? '午间' : k === 'evening' ? '晚间' : '不限'}</ThemedText>
</Pressable>
))}
</View>
</View>
</View>
<Pressable disabled={!canSave || loading} onPress={handleSave} style={[styles.primaryBtn, (!canSave || loading) && styles.primaryBtnDisabled]}>
<ThemedText style={styles.primaryBtnText}>
{loading ? (editingId ? '更新中...' : '创建中...') : canSave ? (editingId ? '更新计划' : '生成计划') : '请先选择目标/频率'}
</ThemedText>
</Pressable>
<View style={{ height: 32 }} />
</ScrollView>
</ThemedView>
<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()}
{...(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]}>
<ThemedText style={styles.modalBtnText}></ThemedText>
</Pressable>
<Pressable onPress={() => { onConfirmDate(pickerDate); }} style={[styles.modalBtn, styles.modalBtnPrimary]}>
<ThemedText style={[styles.modalBtnText, styles.modalBtnTextPrimary]}></ThemedText>
</Pressable>
</View>
)}
</View>
</Modal>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safeArea: {
flex: 1,
backgroundColor: '#F7F8FA',
},
container: {
flex: 1,
backgroundColor: '#F7F8FA',
},
content: {
paddingHorizontal: 20,
paddingTop: 16,
},
title: {
fontSize: 28,
fontWeight: '800',
color: '#1A1A1A',
lineHeight: 36,
},
subtitle: {
fontSize: 14,
color: '#5E6468',
marginTop: 6,
marginBottom: 16,
lineHeight: 20,
},
card: {
backgroundColor: '#FFFFFF',
borderRadius: 16,
padding: 16,
marginTop: 14,
shadowColor: '#000',
shadowOpacity: 0.06,
shadowRadius: 12,
shadowOffset: { width: 0, height: 6 },
elevation: 3,
},
cardTitle: {
fontSize: 18,
fontWeight: '700',
color: '#0F172A',
marginBottom: 12,
},
segment: {
flexDirection: 'row',
backgroundColor: '#F1F5F9',
padding: 4,
borderRadius: 999,
},
segmentItem: {
flex: 1,
borderRadius: 999,
paddingVertical: 10,
alignItems: 'center',
},
segmentItemActive: {
backgroundColor: palette.primary,
},
segmentText: {
fontSize: 14,
color: '#475569',
fontWeight: '600',
},
segmentTextActive: {
color: palette.ink,
},
weekRow: {
flexDirection: 'row',
justifyContent: 'space-between',
marginTop: 14,
},
dayChip: {
width: 44,
height: 44,
borderRadius: 12,
backgroundColor: '#F1F5F9',
alignItems: 'center',
justifyContent: 'center',
},
dayChipActive: {
backgroundColor: '#E0F8A2',
borderWidth: 2,
borderColor: palette.primary,
},
dayChipText: {
fontSize: 16,
color: '#334155',
fontWeight: '700',
},
dayChipTextActive: {
color: '#0F172A',
},
sliderRow: {
flexDirection: 'row',
alignItems: 'center',
marginTop: 16,
},
sliderLabel: {
fontSize: 16,
color: '#334155',
fontWeight: '700',
},
counter: {
flexDirection: 'row',
alignItems: 'center',
marginLeft: 12,
},
counterBtn: {
width: 36,
height: 36,
borderRadius: 999,
backgroundColor: '#F1F5F9',
alignItems: 'center',
justifyContent: 'center',
},
counterBtnText: {
fontSize: 18,
fontWeight: '800',
color: '#0F172A',
},
counterValue: {
width: 44,
textAlign: 'center',
fontSize: 18,
fontWeight: '800',
color: '#0F172A',
},
sliderSuffix: {
marginLeft: 8,
color: '#475569',
},
helper: {
marginTop: 10,
color: '#5E6468',
},
goalGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
justifyContent: 'space-between',
},
goalItem: {
width: '48%',
backgroundColor: '#F8FAFC',
borderRadius: 14,
padding: 12,
marginBottom: 12,
},
goalItemActive: {
backgroundColor: '#E0F8A2',
borderColor: palette.primary,
borderWidth: 2,
},
goalTitle: {
fontSize: 16,
fontWeight: '800',
color: '#0F172A',
},
goalTitleActive: {
color: '#0F172A',
},
goalDesc: {
marginTop: 6,
fontSize: 12,
color: '#5E6468',
lineHeight: 16,
},
rowBetween: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginTop: 6,
},
rowRight: {
flexDirection: 'row',
alignItems: 'center',
},
label: {
fontSize: 14,
color: '#0F172A',
fontWeight: '700',
},
linkBtn: {
paddingHorizontal: 10,
paddingVertical: 6,
borderRadius: 999,
backgroundColor: '#F1F5F9',
},
linkText: {
color: '#334155',
fontWeight: '700',
},
dateHint: {
marginTop: 6,
color: '#5E6468',
},
input: {
marginLeft: 12,
backgroundColor: '#F1F5F9',
paddingHorizontal: 10,
paddingVertical: 8,
borderRadius: 8,
minWidth: 88,
textAlign: 'right',
color: '#0F172A',
},
segmentSmall: {
flexDirection: 'row',
backgroundColor: '#F1F5F9',
padding: 3,
borderRadius: 999,
},
segmentItemSmall: {
borderRadius: 999,
paddingVertical: 6,
paddingHorizontal: 10,
marginHorizontal: 3,
},
segmentItemActiveSmall: {
backgroundColor: palette.primary,
},
segmentTextSmall: {
fontSize: 12,
color: '#475569',
fontWeight: '700',
},
segmentTextActiveSmall: {
color: palette.ink,
},
primaryBtn: {
marginTop: 18,
backgroundColor: palette.primary,
paddingVertical: 14,
borderRadius: 14,
alignItems: 'center',
},
primaryBtnDisabled: {
opacity: 0.5,
},
primaryBtnText: {
color: palette.ink,
fontSize: 16,
fontWeight: '800',
},
modalBackdrop: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0,0,0,0.35)',
},
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: palette.primary,
},
modalBtnText: {
color: '#334155',
fontWeight: '700',
},
modalBtnTextPrimary: {
color: palette.ink,
},
// 计划名称输入框
nameInput: {
backgroundColor: '#F1F5F9',
paddingHorizontal: 12,
paddingVertical: 12,
borderRadius: 8,
fontSize: 16,
color: '#0F172A',
marginTop: 8,
},
// 错误状态
errorContainer: {
backgroundColor: 'rgba(237,71,71,0.1)',
borderRadius: 12,
padding: 16,
marginTop: 16,
borderWidth: 1,
borderColor: 'rgba(237,71,71,0.2)',
},
errorText: {
fontSize: 14,
color: '#ED4747',
fontWeight: '600',
textAlign: 'center',
},
});

File diff suppressed because it is too large Load Diff

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