45 Commits

Author SHA1 Message Date
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
217 changed files with 20223 additions and 8232 deletions

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.

16
android/.gitignore vendored Normal file
View File

@@ -0,0 +1,16 @@
# OSX
#
.DS_Store
# Android/IntelliJ
#
build/
.idea
.gradle
local.properties
*.iml
*.hprof
.cxx/
# Bundle artifacts
*.jsbundle

182
android/app/build.gradle Normal file
View File

@@ -0,0 +1,182 @@
apply plugin: "com.android.application"
apply plugin: "org.jetbrains.kotlin.android"
apply plugin: "com.facebook.react"
def projectRoot = rootDir.getAbsoluteFile().getParentFile().getAbsolutePath()
/**
* This is the configuration block to customize your React Native Android app.
* By default you don't need to apply any configuration, just uncomment the lines you need.
*/
react {
entryFile = file(["node", "-e", "require('expo/scripts/resolveAppEntry')", projectRoot, "android", "absolute"].execute(null, rootDir).text.trim())
reactNativeDir = new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile()
hermesCommand = new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim()).getParentFile().getAbsolutePath() + "/sdks/hermesc/%OS-BIN%/hermesc"
codegenDir = new File(["node", "--print", "require.resolve('@react-native/codegen/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile()
enableBundleCompression = (findProperty('android.enableBundleCompression') ?: false).toBoolean()
// Use Expo CLI to bundle the app, this ensures the Metro config
// works correctly with Expo projects.
cliFile = new File(["node", "--print", "require.resolve('@expo/cli', { paths: [require.resolve('expo/package.json')] })"].execute(null, rootDir).text.trim())
bundleCommand = "export:embed"
/* Folders */
// The root of your project, i.e. where "package.json" lives. Default is '../..'
// root = file("../../")
// The folder where the react-native NPM package is. Default is ../../node_modules/react-native
// reactNativeDir = file("../../node_modules/react-native")
// The folder where the react-native Codegen package is. Default is ../../node_modules/@react-native/codegen
// codegenDir = file("../../node_modules/@react-native/codegen")
/* Variants */
// The list of variants to that are debuggable. For those we're going to
// skip the bundling of the JS bundle and the assets. By default is just 'debug'.
// If you add flavors like lite, prod, etc. you'll have to list your debuggableVariants.
// debuggableVariants = ["liteDebug", "prodDebug"]
/* Bundling */
// A list containing the node command and its flags. Default is just 'node'.
// nodeExecutableAndArgs = ["node"]
//
// The path to the CLI configuration file. Default is empty.
// bundleConfig = file(../rn-cli.config.js)
//
// The name of the generated asset file containing your JS bundle
// bundleAssetName = "MyApplication.android.bundle"
//
// The entry file for bundle generation. Default is 'index.android.js' or 'index.js'
// entryFile = file("../js/MyApplication.android.js")
//
// A list of extra flags to pass to the 'bundle' commands.
// See https://github.com/react-native-community/cli/blob/main/docs/commands.md#bundle
// extraPackagerArgs = []
/* Hermes Commands */
// The hermes compiler command to run. By default it is 'hermesc'
// hermesCommand = "$rootDir/my-custom-hermesc/bin/hermesc"
//
// The list of flags to pass to the Hermes compiler. By default is "-O", "-output-source-map"
// hermesFlags = ["-O", "-output-source-map"]
/* Autolinking */
autolinkLibrariesWithApp()
}
/**
* 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 enableMinifyInReleaseBuilds = (findProperty('android.enableMinifyInReleaseBuilds') ?: false).toBoolean()
/**
* The preferred build flavor of JavaScriptCore (JSC)
*
* For example, to use the international variant, you can use:
* `def jscFlavor = 'org.webkit:android-jsc-intl:+'`
*
* The international variant includes ICU i18n library and necessary data
* allowing to use e.g. `Date.toLocaleString` and `String.localeCompare` that
* give correct results when using with locales other than en-US. Note that
* this variant is about 6MiB larger per architecture than default.
*/
def jscFlavor = 'io.github.react-native-community:jsc-android:2026004.+'
android {
ndkVersion rootProject.ext.ndkVersion
buildToolsVersion rootProject.ext.buildToolsVersion
compileSdk rootProject.ext.compileSdkVersion
namespace 'com.anonymous.digitalpilates'
defaultConfig {
applicationId 'com.anonymous.digitalpilates'
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "1.0.12"
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
}
signingConfigs {
debug {
storeFile file('debug.keystore')
storePassword 'android'
keyAlias 'androiddebugkey'
keyPassword 'android'
}
}
buildTypes {
debug {
signingConfig signingConfigs.debug
}
release {
// Caution! In production, you need to generate your own keystore file.
// see https://reactnative.dev/docs/signed-apk-android.
signingConfig signingConfigs.debug
def enableShrinkResources = findProperty('android.enableShrinkResourcesInReleaseBuilds') ?: 'false'
shrinkResources enableShrinkResources.toBoolean()
minifyEnabled enableMinifyInReleaseBuilds
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
def enablePngCrunchInRelease = findProperty('android.enablePngCrunchInReleaseBuilds') ?: 'true'
crunchPngs enablePngCrunchInRelease.toBoolean()
}
}
packagingOptions {
jniLibs {
def enableLegacyPackaging = findProperty('expo.useLegacyPackaging') ?: 'false'
useLegacyPackaging enableLegacyPackaging.toBoolean()
}
}
androidResources {
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:!CVS:!thumbs.db:!picasa.ini:!*~'
}
}
// Apply static values from `gradle.properties` to the `android.packagingOptions`
// Accepts values in comma delimited lists, example:
// android.packagingOptions.pickFirsts=/LICENSE,**/picasa.ini
["pickFirsts", "excludes", "merges", "doNotStrip"].each { prop ->
// Split option: 'foo,bar' -> ['foo', 'bar']
def options = (findProperty("android.packagingOptions.$prop") ?: "").split(",");
// Trim all elements in place.
for (i in 0..<options.size()) options[i] = options[i].trim();
// `[] - ""` is essentially `[""].filter(Boolean)` removing all empty strings.
options -= ""
if (options.length > 0) {
println "android.packagingOptions.$prop += $options ($options.length)"
// Ex: android.packagingOptions.pickFirsts += '**/SCCS/**'
options.each {
android.packagingOptions[prop] += it
}
}
}
dependencies {
// The version of react-native is set by the React Native Gradle Plugin
implementation("com.facebook.react:react-android")
def isGifEnabled = (findProperty('expo.gif.enabled') ?: "") == "true";
def isWebpEnabled = (findProperty('expo.webp.enabled') ?: "") == "true";
def isWebpAnimatedEnabled = (findProperty('expo.webp.animated') ?: "") == "true";
if (isGifEnabled) {
// For animated gif support
implementation("com.facebook.fresco:animated-gif:${expoLibs.versions.fresco.get()}")
}
if (isWebpEnabled) {
// For webp support
implementation("com.facebook.fresco:webpsupport:${expoLibs.versions.fresco.get()}")
if (isWebpAnimatedEnabled) {
// Animated webp support
implementation("com.facebook.fresco:animated-webp:${expoLibs.versions.fresco.get()}")
}
}
if (hermesEnabled.toBoolean()) {
implementation("com.facebook.react:hermes-android")
} else {
implementation jscFlavor
}
}

BIN
android/app/debug.keystore Normal file

Binary file not shown.

14
android/app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,14 @@
# Add project specific ProGuard rules here.
# By default, the flags in this file are appended to flags specified
# in /usr/local/Cellar/android-sdk/24.3.3/tools/proguard/proguard-android.txt
# You can edit the include path and order by changing the proguardFiles
# directive in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# react-native-reanimated
-keep class com.swmansion.reanimated.** { *; }
-keep class com.facebook.react.turbomodule.** { *; }
# Add any project specific keep options here:

View File

@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
<application android:usesCleartextTraffic="true" tools:targetApi="28" tools:ignore="GoogleAppIndexingWarning" tools:replace="android:usesCleartextTraffic" />
</manifest>

View File

@@ -0,0 +1,37 @@
<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"/>
<queries>
<intent>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="https"/>
</intent>
</queries>
<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"/>
<activity android:name=".MainActivity" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|uiMode" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.App.SplashScreen" android:exported="true" android:screenOrientation="portrait">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="digitalpilates"/>
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -0,0 +1,65 @@
package com.anonymous.digitalpilates
import expo.modules.splashscreen.SplashScreenManager
import android.os.Build
import android.os.Bundle
import com.facebook.react.ReactActivity
import com.facebook.react.ReactActivityDelegate
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled
import com.facebook.react.defaults.DefaultReactActivityDelegate
import expo.modules.ReactActivityDelegateWrapper
class MainActivity : ReactActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
// Set the theme to AppTheme BEFORE onCreate to support
// coloring the background, status bar, and navigation bar.
// This is required for expo-splash-screen.
// setTheme(R.style.AppTheme);
// @generated begin expo-splashscreen - expo prebuild (DO NOT MODIFY) sync-f3ff59a738c56c9a6119210cb55f0b613eb8b6af
SplashScreenManager.registerOnActivity(this)
// @generated end expo-splashscreen
super.onCreate(null)
}
/**
* Returns the name of the main component registered from JavaScript. This is used to schedule
* rendering of the component.
*/
override fun getMainComponentName(): String = "main"
/**
* Returns the instance of the [ReactActivityDelegate]. We use [DefaultReactActivityDelegate]
* which allows you to enable New Architecture with a single boolean flags [fabricEnabled]
*/
override fun createReactActivityDelegate(): ReactActivityDelegate {
return ReactActivityDelegateWrapper(
this,
BuildConfig.IS_NEW_ARCHITECTURE_ENABLED,
object : DefaultReactActivityDelegate(
this,
mainComponentName,
fabricEnabled
){})
}
/**
* Align the back button behavior with Android S
* where moving root activities to background instead of finishing activities.
* @see <a href="https://developer.android.com/reference/android/app/Activity#onBackPressed()">onBackPressed</a>
*/
override fun invokeDefaultOnBackPressed() {
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.R) {
if (!moveTaskToBack(false)) {
// For non-root activities, use the default implementation to finish them.
super.invokeDefaultOnBackPressed()
}
return
}
// Use the default back button implementation on Android S
// because it's doing more than [Activity.moveTaskToBack] in fact.
super.invokeDefaultOnBackPressed()
}
}

View File

@@ -0,0 +1,56 @@
package com.anonymous.digitalpilates
import android.app.Application
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.common.ReleaseLevel
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint
import com.facebook.react.defaults.DefaultReactNativeHost
import expo.modules.ApplicationLifecycleDispatcher
import expo.modules.ReactNativeHostWrapper
class MainApplication : Application(), ReactApplication {
override val reactNativeHost: ReactNativeHost = ReactNativeHostWrapper(
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 reactHost: ReactHost
get() = ReactNativeHostWrapper.createReactHost(applicationContext, reactNativeHost)
override fun onCreate() {
super.onCreate()
DefaultNewArchitectureEntryPoint.releaseLevel = try {
ReleaseLevel.valueOf(BuildConfig.REACT_NATIVE_RELEASE_LEVEL.uppercase())
} catch (e: IllegalArgumentException) {
ReleaseLevel.STABLE
}
loadReactNative(this)
ApplicationLifecycleDispatcher.onApplicationCreate(this)
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
ApplicationLifecycleDispatcher.onConfigurationChanged(this, newConfig)
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 812 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -0,0 +1,6 @@
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@color/splashscreen_background"/>
<item>
<bitmap android:gravity="center" android:src="@drawable/splashscreen_logo"/>
</item>
</layer-list>

View File

@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright (C) 2014 The Android Open Source Project
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<inset xmlns:android="http://schemas.android.com/apk/res/android"
android:insetLeft="@dimen/abc_edit_text_inset_horizontal_material"
android:insetRight="@dimen/abc_edit_text_inset_horizontal_material"
android:insetTop="@dimen/abc_edit_text_inset_top_material"
android:insetBottom="@dimen/abc_edit_text_inset_bottom_material"
>
<selector>
<!--
This file is a copy of abc_edit_text_material (https://bit.ly/3k8fX7I).
The item below with state_pressed="false" and state_focused="false" causes a NullPointerException.
NullPointerException:tempt to invoke virtual method 'android.graphics.drawable.Drawable android.graphics.drawable.Drawable$ConstantState.newDrawable(android.content.res.Resources)'
<item android:state_pressed="false" android:state_focused="false" android:drawable="@drawable/abc_textfield_default_mtrl_alpha"/>
For more info, see https://bit.ly/3CdLStv (react-native/pull/29452) and https://bit.ly/3nxOMoR.
-->
<item android:state_enabled="false" android:drawable="@drawable/abc_textfield_default_mtrl_alpha"/>
<item android:drawable="@drawable/abc_textfield_activated_mtrl_alpha"/>
</selector>
</inset>

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.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 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.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 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.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 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.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 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.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

View File

@@ -0,0 +1 @@
<resources/>

View File

@@ -0,0 +1,7 @@
<resources>
<color name="splashscreen_background">#ffffff</color>
<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

@@ -0,0 +1,6 @@
<resources>
<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

@@ -0,0 +1,14 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<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>
</style>
<style name="Theme.App.SplashScreen" parent="Theme.SplashScreen">
<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>

24
android/build.gradle Normal file
View File

@@ -0,0 +1,24 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
repositories {
google()
mavenCentral()
}
dependencies {
classpath('com.android.tools.build:gradle')
classpath('com.facebook.react:react-native-gradle-plugin')
classpath('org.jetbrains.kotlin:kotlin-gradle-plugin')
}
}
allprojects {
repositories {
google()
mavenCentral()
maven { url 'https://www.jitpack.io' }
}
}
apply plugin: "expo-root-project"
apply plugin: "com.facebook.react.rootproject"

65
android/gradle.properties Normal file
View File

@@ -0,0 +1,65 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
# Default value: -Xmx512m -XX:MaxMetaspaceSize=256m
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
# 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
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
# Enable AAPT2 PNG crunching
android.enablePngCrunchInReleaseBuilds=true
# Use this property to specify which architecture you want to build.
# You can also override it from the CLI using
# ./gradlew <task> -PreactNativeArchitectures=x86_64
reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64
# Use this property to enable support to the new architecture.
# This will allow you to use TurboModules and the Fabric render in
# your application. You should enable this flag either if you want
# to write custom TurboModules/Fabric components OR use libraries that
# are providing them.
newArchEnabled=true
# Use this property to enable or disable the Hermes JS engine.
# If set to false, you will be using JSC instead.
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
# Enable webp support in React Native images (~85 KB increase)
expo.webp.enabled=true
# Enable animated webp support (~3.4 MB increase)
# Disabled by default because iOS doesn't support animated webp
expo.webp.animated=false
# Enable network inspector
EX_DEV_CLIENT_NETWORK_INSPECTOR=true
# Use legacy packaging to compress native libraries in the resulting APK.
expo.useLegacyPackaging=false
# 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

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

251
android/gradlew vendored Executable file
View File

@@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

94
android/gradlew.bat vendored Normal file
View File

@@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%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
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

39
android/settings.gradle Normal file
View File

@@ -0,0 +1,39 @@
pluginManagement {
def reactNativeGradlePlugin = new File(
providers.exec {
workingDir(rootDir)
commandLine("node", "--print", "require.resolve('@react-native/gradle-plugin/package.json', { paths: [require.resolve('react-native/package.json')] })")
}.standardOutput.asText.get().trim()
).getParentFile().absolutePath
includeBuild(reactNativeGradlePlugin)
def expoPluginsPath = new File(
providers.exec {
workingDir(rootDir)
commandLine("node", "--print", "require.resolve('expo-modules-autolinking/package.json', { paths: [require.resolve('expo/package.json')] })")
}.standardOutput.asText.get().trim(),
"../android/expo-gradle-plugin"
).absolutePath
includeBuild(expoPluginsPath)
}
plugins {
id("com.facebook.react.settings")
id("expo-autolinking-settings")
}
extensions.configure(com.facebook.react.ReactSettingsExtension) { ex ->
if (System.getenv('EXPO_USE_COMMUNITY_AUTOLINKING') == '1') {
ex.autolinkLibrariesFromCommand()
} else {
ex.autolinkLibrariesFromCommand(expoAutolinking.rnConfigCommand)
}
}
expoAutolinking.useExpoModules()
rootProject.name = 'Out Live'
expoAutolinking.useExpoVersionCatalog()
include ':app'
includeBuild(expoAutolinking.reactNativeGradlePlugin)

View File

@@ -2,74 +2,49 @@
"expo": {
"name": "Out Live",
"slug": "digital-pilates",
"version": "1.0.5",
"version": "1.0.14",
"orientation": "portrait",
"icon": "./assets/images/Sealife.jpeg",
"scheme": "digitalpilates",
"userInterfaceStyle": "light",
"icon": "./assets/icon.icon/Assets/icon-1756312748268.jpg",
"newArchEnabled": true,
"jsEngine": "jsc",
"ios": {
"supportsTablet": false,
"deploymentTarget": "16.0",
"bundleIdentifier": "com.anonymous.digitalpilates",
"infoPlist": {
"ITSAppUsesNonExemptEncryption": false,
"NSCameraUsageDescription": "应用需要使用相机以拍摄您的体态照片用于AI测评。",
"NSPhotoLibraryUsageDescription": "应用需要访问相册以选择您的体态照片用于AI测评。",
"NSPhotoLibraryAddUsageDescription": "应用需要写入相册以保存拍摄的体态照片(可选)。",
"NSMicrophoneUsageDescription": "应用需要使用麦克风进行语音识别,将您的语音转换为文字记录饮食信息。",
"NSSpeechRecognitionUsageDescription": "应用需要使用语音识别功能来转换您的语音为文字,帮助您快速记录饮食信息。",
"NSUserNotificationsUsageDescription": "应用需要发送通知以提醒您喝水和站立活动。",
"UIBackgroundModes": [
"processing",
"fetch",
"remote-notification"
]
}
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/images/Sealife.jpeg",
"backgroundColor": "#ffffff"
},
"edgeToEdgeEnabled": true,
"package": "com.anonymous.digitalpilates",
"permissions": [
"android.permission.RECEIVE_BOOT_COMPLETED",
"android.permission.VIBRATE",
"android.permission.WAKE_LOCK"
]
},
"web": {
"bundler": "metro",
"output": "static",
"favicon": "./assets/images/Sealife.jpeg"
"appleTeamId": "756WVXJ6MT"
},
"plugins": [
"expo-router",
[
"expo-splash-screen",
{
"image": "./assets/images/Sealife.jpeg",
"image": "./assets/icon.icon/Assets/icon-1756312748268.jpg",
"imageWidth": 40,
"resizeMode": "contain",
"backgroundColor": "#ffffff"
}
],
[
"react-native-health",
{
"enableHealthAPI": true,
"healthSharePermission": "应用需要访问您的健康数据(步数、能量消耗、心率变异性等)以展示运动统计和压力分析。",
"healthUpdatePermission": "应用需要更新您的健康数据(体重信息)以记录您的健身进度。"
}
],
[
"expo-notifications",
{
"icon": "./assets/images/Sealife.jpeg",
"color": "#ffffff",
"sounds": [
"./assets/sounds/notification.wav"
]
"icon": "./assets/icon.icon/Assets/icon-1756312748268.jpg",
"color": "#ffffff"
}
],
[
@@ -84,14 +59,14 @@
}
],
[
"expo-background-task",
{
"minimumInterval": 15
}
"expo-background-task"
]
],
"experiments": {
"typedRoutes": true
},
"android": {
"package": "com.anonymous.digitalpilates"
}
}
}

View File

@@ -1,6 +1,9 @@
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, ViewStyle } from 'react-native';
@@ -18,8 +21,8 @@ type TabConfig = {
const TAB_CONFIGS: Record<string, TabConfig> = {
statistics: { icon: 'chart.pie.fill', title: '健康' },
// explore: { icon: 'magnifyingglass.circle.fill', title: '发现' },
goals: { icon: 'flag.fill', title: '习惯' },
challenges: { icon: 'trophy.fill', title: '挑战' },
personal: { icon: 'person.fill', title: '个人' },
};
@@ -27,13 +30,15 @@ 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> = {
explore: ROUTES.TAB_EXPLORE,
goals: ROUTES.TAB_GOALS,
statistics: ROUTES.TAB_STATISTICS,
goals: ROUTES.TAB_GOALS,
challenges: ROUTES.TAB_CHALLENGES,
personal: ROUTES.TAB_PERSONAL,
};
return routeMap[routeName] === pathname || pathname.includes(routeName);
@@ -59,16 +64,17 @@ export default function TabLayout() {
<TouchableOpacity
onPress={handlePress}
accessibilityRole="button"
activeOpacity={1}
style={{
flex: 1,
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'row',
marginHorizontal: 6,
marginHorizontal: 2,
marginVertical: 10,
borderRadius: 25,
backgroundColor: isSelected ? colorTokens.tabBarActiveBackground : 'transparent',
paddingHorizontal: isSelected ? 16 : 10,
paddingHorizontal: isSelected ? 8 : 4,
paddingVertical: 8,
}}
>
@@ -86,7 +92,7 @@ export default function TabLayout() {
fontWeight: '600',
marginLeft: 6,
}}
numberOfLines={0 as any}
numberOfLines={1}
>
{tabConfig.title}
</Text>
@@ -96,29 +102,62 @@ export default function TabLayout() {
);
};
// 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: colorTokens.tabBarBackground,
backgroundColor: glassEffectAvailable ? 'transparent' : colorTokens.tabBarBackground,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.2,
shadowOpacity: glassEffectAvailable ? 0.1 : 0.2,
shadowRadius: 10,
elevation: 5,
paddingHorizontal: 10,
paddingHorizontal: 6,
paddingTop: 0,
paddingBottom: 0,
marginHorizontal: 20,
left: 20,
right: 20,
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',
@@ -131,6 +170,27 @@ export default function TabLayout() {
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
initialRouteName="statistics"
@@ -138,9 +198,8 @@ export default function TabLayout() {
>
<Tabs.Screen name="statistics" options={{ title: '健康' }} />
<Tabs.Screen name="explore" options={{ title: '发现', href: null }} />
<Tabs.Screen name="coach" options={{ title: 'AI', href: null }} />
<Tabs.Screen name="goals" options={{ title: '习惯' }} />
<Tabs.Screen name="challenges" options={{ title: '挑战' }} />
<Tabs.Screen name="personal" options={{ title: '个人' }} />
</Tabs>
);

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

@@ -0,0 +1,297 @@
import { IconSymbol } from '@/components/ui/IconSymbol';
import { Colors } from '@/constants/Colors';
import { useColorScheme } from '@/hooks/useColorScheme';
import { LinearGradient } from 'expo-linear-gradient';
import { useRouter } from 'expo-router';
import React from 'react';
import { Image, ScrollView, StatusBar, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
export const CHALLENGES = [
{
id: 'joyful-dog-run',
title: '遛狗跑步,欢乐一路',
dateRange: '9月01日 - 9月30日',
participantsLabel: '6,364 跑者',
image: 'https://images.unsplash.com/photo-1525253086316-d0c936c814f8?auto=format&fit=crop&w=1200&q=80',
avatars: [
'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?auto=format&fit=crop&w=200&q=80',
'https://images.unsplash.com/photo-1544723795-3fb6469f5b39?auto=format&fit=crop&w=200&q=80',
'https://images.unsplash.com/photo-1544723795-3fbce826f51f?auto=format&fit=crop&w=200&q=80',
'https://images.unsplash.com/photo-1502823403499-6ccfcf4fb453?auto=format&fit=crop&w=200&q=80',
],
},
{
id: 'penguin-swim',
title: '企鹅宝宝的游泳预备班',
dateRange: '9月01日 - 9月30日',
participantsLabel: '3,334 游泳者',
image: 'https://images.unsplash.com/photo-1531297484001-80022131f5a1?auto=format&fit=crop&w=1200&q=80',
avatars: [
'https://images.unsplash.com/photo-1525134479668-1bee5c7c6845?auto=format&fit=crop&w=200&q=80',
'https://images.unsplash.com/photo-1530268729831-4b0b9e170218?auto=format&fit=crop&w=200&q=80',
'https://images.unsplash.com/photo-1520813792240-56fc4a3765a7?auto=format&fit=crop&w=200&q=80',
'https://images.unsplash.com/photo-1463453091185-61582044d556?auto=format&fit=crop&w=200&q=80',
],
},
{
id: 'hydration-hippo',
title: '学河马饮,做补水人',
dateRange: '9月01日 - 9月30日',
participantsLabel: '9,009 饮水者',
image: 'https://images.unsplash.com/photo-1481931098730-318b6f776db0?auto=format&fit=crop&w=1200&q=80',
avatars: [
'https://images.unsplash.com/photo-1534528741775-53994a69daeb?auto=format&fit=crop&w=200&q=80',
'https://images.unsplash.com/photo-1544723660-4bfa6584218e?auto=format&fit=crop&w=200&q=80',
'https://images.unsplash.com/photo-1544723795-3fbfb7c6a9f1?auto=format&fit=crop&w=200&q=80',
'https://images.unsplash.com/photo-1544723795-432537f48b2b?auto=format&fit=crop&w=200&q=80',
],
},
{
id: 'autumn-cycling',
title: '炎夏渐散,踏板骑秋',
dateRange: '9月01日 - 9月30日',
participantsLabel: '4,617 骑行者',
image: 'https://images.unsplash.com/photo-1509395176047-4a66953fd231?auto=format&fit=crop&w=1200&q=80',
avatars: [
'https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&w=200&q=80',
'https://images.unsplash.com/photo-1521572267360-ee0c2909d518?auto=format&fit=crop&w=200&q=80',
'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?auto=format&fit=crop&w=200&q=80',
'https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&w=200&q=80',
],
},
{
id: 'falcon-core',
title: '燃卡加练甄秋腰',
dateRange: '9月01日 - 9月30日',
participantsLabel: '11,995 健身爱好者',
image: 'https://images.unsplash.com/photo-1494871262121-6adf66e90adf?auto=format&fit=crop&w=1200&q=80',
avatars: [
'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?auto=format&fit=crop&w=200&q=80',
'https://images.unsplash.com/photo-1520813792240-56fc4a3765a7?auto=format&fit=crop&w=200&q=80',
'https://images.unsplash.com/photo-1502685104226-ee32379fefbe?auto=format&fit=crop&w=200&q=80',
'https://images.unsplash.com/photo-1521572267360-ee0c2909d518?auto=format&fit=crop&w=200&q=80',
],
},
] as const;
export type Challenge = (typeof CHALLENGES)[number];
const AVATAR_SIZE = 36;
const CARD_IMAGE_WIDTH = 132;
const CARD_IMAGE_HEIGHT = 96;
export default function ChallengesScreen() {
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme];
const router = useRouter();
const gradientColors =
theme === 'dark'
? ['#1f2230', '#10131e']
: [colorTokens.backgroundGradientStart, colorTokens.backgroundGradientEnd];
return (
<View style={[styles.screen, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}>
<StatusBar barStyle={theme === 'dark' ? 'light-content' : 'dark-content'} />
<LinearGradient colors={gradientColors} style={StyleSheet.absoluteFillObject} />
<SafeAreaView style={styles.safeArea} edges={['top']}>
<ScrollView
contentContainerStyle={styles.scrollContent}
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={22} color={colorTokens.onPrimary} />
</LinearGradient>
</TouchableOpacity>
</View>
<View style={styles.cardsContainer}>
{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 } })
}
/>
))}
</View>
</ScrollView>
</SafeAreaView>
</View>
);
}
type ChallengeCardProps = {
challenge: Challenge;
surfaceColor: string;
textColor: string;
mutedColor: string;
onPress: () => void;
};
function ChallengeCard({ challenge, surfaceColor, textColor, mutedColor, onPress }: ChallengeCardProps) {
return (
<TouchableOpacity
activeOpacity={0.92}
onPress={onPress}
style={[
styles.card,
{
backgroundColor: surfaceColor,
shadowColor: 'rgba(15, 23, 42, 0.18)',
},
]}
>
<Image
source={{ uri: challenge.image }}
style={styles.cardImage}
resizeMode="cover"
/>
<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}</Text>
<AvatarStack avatars={challenge.avatars} borderColor={surfaceColor} />
</View>
</TouchableOpacity>
);
}
type AvatarStackProps = {
avatars: string[];
borderColor: string;
};
function AvatarStack({ avatars, borderColor }: AvatarStackProps) {
return (
<View style={styles.avatarRow}>
{avatars.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: 32,
},
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: 52,
height: 52,
borderRadius: 26,
alignItems: 'center',
justifyContent: 'center',
},
cardsContainer: {
gap: 18,
},
card: {
flexDirection: 'row',
borderRadius: 28,
padding: 18,
alignItems: 'center',
shadowOffset: { width: 0, height: 16 },
shadowOpacity: 0.18,
shadowRadius: 24,
elevation: 6,
},
cardImage: {
width: CARD_IMAGE_WIDTH,
height: CARD_IMAGE_HEIGHT,
borderRadius: 22,
},
cardContent: {
flex: 1,
marginLeft: 16,
},
cardTitle: {
fontSize: 18,
fontWeight: '700',
marginBottom: 4,
},
cardDate: {
fontSize: 13,
fontWeight: '500',
marginBottom: 4,
},
cardParticipants: {
fontSize: 13,
fontWeight: '500',
},
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

@@ -46,6 +46,7 @@ export default function GoalsScreen() {
skipError,
} = useAppSelector((state) => state.tasks);
const {
createLoading,
createError
@@ -67,13 +68,13 @@ export default function GoalsScreen() {
// 页面聚焦时重新加载数据
useFocusEffect(
useCallback(() => {
console.log('useFocusEffect - loading tasks');
console.log('useFocusEffect - loading tasks isLoggedIn', isLoggedIn);
if (isLoggedIn) {
loadTasks();
checkAndShowGuide();
}
}, [dispatch])
}, [dispatch, isLoggedIn])
);
// 检查并显示用户引导
@@ -94,6 +95,7 @@ export default function GoalsScreen() {
// 加载任务列表
const loadTasks = async () => {
try {
await dispatch(fetchTasks({
startDate: dayjs().startOf('day').toISOString(),
endDate: dayjs().endOf('day').toISOString(),
@@ -107,6 +109,8 @@ export default function GoalsScreen() {
const onRefresh = async () => {
setRefreshing(true);
try {
if (!isLoggedIn) return
await loadTasks();
} finally {
setRefreshing(false);
@@ -115,6 +119,8 @@ export default function GoalsScreen() {
// 加载更多任务
const handleLoadMoreTasks = async () => {
if (!isLoggedIn) return
if (tasksPagination.hasMore && !tasksLoading) {
try {
await dispatch(loadMoreTasks()).unwrap();
@@ -317,6 +323,61 @@ export default function GoalsScreen() {
// 渲染空状态
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 = '创建目标后,系统会自动生成相应的任务';
@@ -510,7 +571,7 @@ export default function GoalsScreen() {
text: '每日目标通知',
onPress: async () => {
try {
const userName = userProfile?.name || '小海豹';
const userName = userProfile?.name || '';
const notificationIds = await GoalNotificationHelpers.scheduleGoalNotifications(
{
title: '每日运动目标',
@@ -531,7 +592,7 @@ export default function GoalsScreen() {
text: '每周目标通知',
onPress: async () => {
try {
const userName = userProfile?.name || '小海豹';
const userName = userProfile?.name || '';
const notificationIds = await GoalNotificationHelpers.scheduleGoalNotifications(
{
title: '每周运动目标',
@@ -555,7 +616,7 @@ export default function GoalsScreen() {
text: '目标达成通知',
onPress: async () => {
try {
const userName = userProfile?.name || '小海豹';
const userName = userProfile?.name || '';
await GoalNotificationHelpers.sendGoalAchievementNotification(userName, '每日运动目标');
Alert.alert('成功', '目标达成通知已发送');
} catch (error) {
@@ -708,6 +769,80 @@ const styles = StyleSheet.create({
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,

View File

@@ -1,17 +1,20 @@
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 { 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 { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
import { useFocusEffect } from '@react-navigation/native';
import { isLiquidGlassAvailable } from 'expo-glass-effect';
import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient';
import React, { useEffect, useMemo, useState } from 'react';
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';
@@ -21,7 +24,8 @@ export default function PersonalScreen() {
const dispatch = useAppDispatch();
const { confirmLogout, confirmDeleteAccount, isLoggedIn, pushIfAuthedElseLogin } = useAuthGuard();
const insets = useSafeAreaInsets();
const tabBarHeight = useBottomTabBarHeight();
const isLgAvaliable = isLiquidGlassAvailable()
// 推送通知相关
const {
@@ -31,14 +35,20 @@ export default function PersonalScreen() {
const [notificationEnabled, setNotificationEnabled] = useState(false);
// 开发者模式相关状态
const [showDeveloperSection, setShowDeveloperSection] = useState(false);
const clickTimestamps = useRef<number[]>([]);
const clickTimeoutRef = useRef<number | null>(null);
// 计算底部间距
const bottomPadding = useMemo(() => {
return getTabBarBottomPadding(tabBarHeight) + (insets?.bottom ?? 0);
}, [tabBarHeight, insets?.bottom]);
return getTabBarBottomPadding(60) + (insets?.bottom ?? 0);
}, [insets?.bottom]);
// 直接使用 Redux 中的用户信息,避免重复状态管理
const userProfile = useAppSelector((state) => state.user.profile);
// 页面聚焦时获取最新用户信息
useFocusEffect(
React.useCallback(() => {
@@ -46,6 +56,8 @@ export default function PersonalScreen() {
dispatch(fetchActivityHistory());
// 加载用户推送偏好设置
loadNotificationPreference();
// 加载开发者模式状态
loadDeveloperModeState();
}, [dispatch])
);
@@ -59,6 +71,27 @@ export default function PersonalScreen() {
}
};
// 加载开发者模式状态
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 (userProfile.height == null) return '--';
@@ -81,11 +114,39 @@ export default function PersonalScreen() {
// 显示名称
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) {
@@ -148,11 +209,17 @@ export default function PersonalScreen() {
/>
</View>
<View style={styles.userDetails}>
<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}></Text>
<Text style={styles.editButtonText}>{isLoggedIn ? '编辑' : '登录'}</Text>
</TouchableOpacity>
</View>
</View>
@@ -238,6 +305,17 @@ export default function PersonalScreen() {
},
],
},
// 开发者section需要连续点击三次用户名激活
...(showDeveloperSection ? [{
title: '开发者',
items: [
{
icon: 'code-slash-outline' as const,
title: '开发者选项',
onPress: () => pushIfAuthedElseLogin(ROUTES.DEVELOPER),
},
],
}] : []),
{
title: '其他',
items: [
@@ -308,7 +386,7 @@ export default function PersonalScreen() {
transition={200}
cachePolicy="memory-disk"
/> */}
<Text style={styles.fishRecordText}></Text>
<Text style={styles.fishRecordText}></Text>
</View>
<ActivityHeatMap />
{menuSections.map((section, index) => (
@@ -405,6 +483,11 @@ const styles = StyleSheet.create({
color: '#9370DB',
fontWeight: '500',
},
userMemberNumber: {
fontSize: 10,
color: '#6C757D',
marginTop: 4,
},
editButton: {
backgroundColor: '#9370DB',
paddingHorizontal: 16,

View File

@@ -3,6 +3,7 @@ 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';
@@ -10,19 +11,14 @@ import { StressMeter } from '@/components/StressMeter';
import WaterIntakeCard from '@/components/WaterIntakeCard';
import { WeightHistoryCard } from '@/components/weight/WeightHistoryCard';
import { Colors } from '@/constants/Colors';
import { getTabBarBottomPadding } from '@/constants/TabBar';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useAuthGuard } from '@/hooks/useAuthGuard';
import { backgroundTaskManager } from '@/services/backgroundTaskManager';
import { selectHealthDataByDate, setHealthData } from '@/store/healthSlice';
import { BackgroundTaskManager } from '@/services/backgroundTaskManager';
import { setHealthData } from '@/store/healthSlice';
import { fetchDailyMoodCheckins, selectLatestMoodRecordByDate } from '@/store/moodSlice';
import { fetchDailyNutritionData, selectNutritionSummaryByDate } from '@/store/nutritionSlice';
import { fetchTodayWaterStats } from '@/store/waterSlice';
import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date';
import { ensureHealthPermissions, fetchHealthDataForDate, testHRVDataFetch } from '@/utils/health';
import { getTestHealthData } from '@/utils/mockHealthData';
import { calculateNutritionGoals } from '@/utils/nutrition';
import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
import { fetchHealthDataForDate, testHRVDataFetch } from '@/utils/health';
import dayjs from 'dayjs';
import { LinearGradient } from 'expo-linear-gradient';
import { debounce } from 'lodash';
@@ -39,13 +35,10 @@ import {
import { useSafeAreaInsets } from 'react-native-safe-area-context';
// 浮动动画组件
const FloatingCard = ({ children, delay = 0, style }: {
const FloatingCard = ({ children, style }: {
children: React.ReactNode;
delay?: number;
style?: any;
}) => {
return (
<View
style={[
@@ -62,22 +55,14 @@ const FloatingCard = ({ children, delay = 0, style }: {
export default function ExploreScreen() {
const stepGoal = useAppSelector((s) => s.user.profile?.dailyStepsGoal) ?? 2000;
const userProfile = useAppSelector((s) => s.user.profile);
// 开发调试设置为true来使用mock数据
// 在真机测试时可以暂时设置为true来验证组件显示逻辑
const useMockData = __DEV__ || false; // 改为true来启用mock数据调试
const { pushIfAuthedElseLogin, isLoggedIn } = useAuthGuard();
// 使用 dayjs当月日期与默认选中"今天"
const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth());
const tabBarHeight = useBottomTabBarHeight();
// const tabBarHeight = useBottomTabBarHeight();
const insets = useSafeAreaInsets();
const bottomPadding = useMemo(() => {
return getTabBarBottomPadding(tabBarHeight) + (insets?.bottom ?? 0);
}, [tabBarHeight, insets?.bottom]);
// 获取当前选中日期 - 使用 useMemo 缓存避免重复计算
const currentSelectedDate = useMemo(() => {
const days = getMonthDaysZh();
@@ -88,70 +73,11 @@ export default function ExploreScreen() {
return dayjs(currentSelectedDate).format('YYYY-MM-DD');
}, [currentSelectedDate]);
// 从 Redux 获取指定日期的健康数据
const healthData = useAppSelector(selectHealthDataByDate(currentSelectedDateString));
// 解构健康数据支持mock数据
const mockData = useMockData ? getTestHealthData('mock') : null;
const stepCount: number | null = useMockData ? (mockData?.steps ?? null) : (healthData?.steps ?? null);
const hourlySteps = useMockData ? (mockData?.hourlySteps ?? []) : (healthData?.hourlySteps ?? []);
const activeCalories = useMockData ? (mockData?.activeEnergyBurned ?? null) : (healthData?.activeEnergyBurned ?? null);
const basalMetabolism: number | null = useMockData ? (mockData?.basalEnergyBurned ?? null) : (healthData?.basalEnergyBurned ?? null);
const sleepDuration = useMockData ? (mockData?.sleepDuration ?? null) : (healthData?.sleepDuration ?? null);
const hrvValue = useMockData ? (mockData?.hrv ?? null) : (healthData?.hrv ?? null);
const oxygenSaturation = useMockData ? (mockData?.oxygenSaturation ?? null) : (healthData?.oxygenSaturation ?? null);
// 调试HRV数据
console.log('=== HRV数据调试 ===');
console.log('useMockData:', useMockData);
console.log('mockData?.hrv:', mockData?.hrv);
console.log('healthData?.hrv:', healthData?.hrv);
console.log('final hrvValue:', hrvValue);
console.log('healthData:', healthData);
console.log('==================');
const fitnessRingsData = useMockData ? {
activeCalories: mockData?.activeCalories ?? 0,
activeCaloriesGoal: mockData?.activeCaloriesGoal ?? 350,
exerciseMinutes: mockData?.exerciseMinutes ?? 0,
exerciseMinutesGoal: mockData?.exerciseMinutesGoal ?? 30,
standHours: mockData?.standHours ?? 0,
standHoursGoal: mockData?.standHoursGoal ?? 12,
} : (healthData ? {
activeCalories: healthData.activeEnergyBurned,
activeCaloriesGoal: healthData.activeCaloriesGoal,
exerciseMinutes: healthData.exerciseMinutes,
exerciseMinutesGoal: healthData.exerciseMinutesGoal,
standHours: healthData.standHours,
standHoursGoal: healthData.standHoursGoal,
} : {
activeCalories: 0,
activeCaloriesGoal: 350,
exerciseMinutes: 0,
exerciseMinutesGoal: 30,
standHours: 0,
standHoursGoal: 12,
});
// HRV更新时间
const [hrvUpdateTime, setHrvUpdateTime] = useState<Date>(new Date());
// 用于触发动画重置的 token当日期或数据变化时更新
const [animToken, setAnimToken] = useState(0);
// 从 Redux 获取营养数据
const nutritionSummary = useAppSelector(selectNutritionSummaryByDate(currentSelectedDateString));
// 计算用户的营养目标
const nutritionGoals = useMemo(() => {
return calculateNutritionGoals({
weight: userProfile.weight,
height: userProfile.height,
birthDate: userProfile?.birthDate ? new Date(userProfile?.birthDate) : undefined,
gender: userProfile?.gender || undefined,
});
}, [userProfile]);
// 心情相关状态
const dispatch = useAppDispatch();
@@ -163,7 +89,6 @@ export default function ExploreScreen() {
// 请求状态管理,防止重复请求
const loadingRef = useRef({
health: false,
nutrition: false,
mood: false
});
@@ -172,14 +97,14 @@ export default function ExploreScreen() {
const getDateKey = (d: Date) => `${dayjs(d).year()}-${dayjs(d).month() + 1}-${dayjs(d).date()}`;
// 检查数据是否需要刷新(2分钟内不重复拉取,对营养数据更严格
// 检查数据是否需要刷新(5分钟内不重复拉取)
const shouldRefreshData = (dateKey: string, dataType: string) => {
const cacheKey = `${dateKey}-${dataType}`;
const lastUpdate = dataTimestampRef.current[cacheKey];
const now = Date.now();
// 营养数据使用更短的缓存时间,其他数据使用5分钟
const cacheTime = dataType === 'nutrition' ? 2 * 60 * 1000 : 5 * 60 * 1000;
// 使用5分钟缓存时间
const cacheTime = 5 * 60 * 1000;
return !lastUpdate || (now - lastUpdate) > cacheTime;
};
@@ -267,20 +192,12 @@ export default function ExploreScreen() {
loadingRef.current.health = true;
console.log('=== 开始HealthKit初始化流程 ===');
const ok = await ensureHealthPermissions();
if (!ok) {
const errorMsg = '无法获取健康权限请确保在真实iOS设备上运行并授权应用访问健康数据';
console.warn(errorMsg);
return;
}
latestRequestKeyRef.current = requestKey;
console.log('权限获取成功,开始获取健康数据...', derivedDate);
const data = await fetchHealthDataForDate(derivedDate);
console.log('设置UI状态:', data);
console.log('HRV数据详细信息:', data.hrv, typeof data.hrv);
// 仅当该请求仍是最新时,才应用结果
if (latestRequestKeyRef.current === requestKey) {
@@ -290,12 +207,7 @@ export default function ExploreScreen() {
dispatch(setHealthData({
date: dateString,
data: {
steps: data.steps,
activeCalories: data.activeEnergyBurned,
basalEnergyBurned: data.basalEnergyBurned,
sleepDuration: data.sleepDuration,
hrv: data.hrv,
oxygenSaturation: data.oxygenSaturation,
heartRate: data.heartRate,
activeEnergyBurned: data.activeEnergyBurned,
activeCaloriesGoal: data.activeCaloriesGoal,
@@ -303,12 +215,9 @@ export default function ExploreScreen() {
exerciseMinutesGoal: data.exerciseMinutesGoal,
standHours: data.standHours,
standHoursGoal: data.standHoursGoal,
hourlySteps: data.hourlySteps,
}
}));
// 更新HRV数据时间
setHrvUpdateTime(new Date());
setAnimToken((t) => t + 1);
// 更新缓存时间戳
@@ -326,45 +235,6 @@ export default function ExploreScreen() {
};
// 加载营养数据
const loadNutritionData = 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.nutrition) {
console.log('营养数据正在加载中,跳过重复请求');
return;
}
if (!forceRefresh && !shouldRefreshData(requestKey, 'nutrition')) {
console.log('营养数据缓存未过期,跳过请求');
return;
}
try {
loadingRef.current.nutrition = true;
console.log('加载营养数据...', derivedDate);
await dispatch(fetchDailyNutritionData(derivedDate));
console.log('营养数据加载完成');
// 更新缓存时间戳
updateDataTimestamp(requestKey, 'nutrition');
} catch (error) {
console.error('营养数据加载失败:', error);
} finally {
loadingRef.current.nutrition = false;
}
};
// 实际执行数据加载的方法
const executeLoadAllData = React.useCallback((targetDate?: Date, forceRefresh = false) => {
@@ -373,7 +243,6 @@ export default function ExploreScreen() {
console.log('执行数据加载,日期:', dateToUse, '强制刷新:', forceRefresh);
loadHealthData(dateToUse, forceRefresh);
if (isLoggedIn) {
loadNutritionData(dateToUse, forceRefresh);
loadMoodData(dateToUse, forceRefresh);
// 加载喝水数据(只加载今日数据用于后台检查)
const isToday = dayjs(dateToUse).isSame(dayjs(), 'day');
@@ -401,14 +270,10 @@ export default function ExploreScreen() {
}
}, [executeLoadAllData, debouncedLoadAllData]);
// 页面聚焦时的数据加载逻辑
// useFocusEffect(
// React.useCallback(() => {
// // 页面聚焦时加载数据,使用缓存机制避免频繁请求
// console.log('页面聚焦,检查是否需要刷新数据...');
// loadAllData(currentSelectedDate);
// }, [loadAllData, currentSelectedDate])
// );
useEffect(() => {
loadAllData(currentSelectedDate);
}, [])
// AppState 监听:应用从后台返回前台时的处理
useEffect(() => {
@@ -468,7 +333,7 @@ export default function ExploreScreen() {
style={styles.scrollView}
contentContainerStyle={{
paddingTop: insets.top,
paddingBottom: bottomPadding,
paddingBottom: 60,
paddingHorizontal: 20
}}
showsVerticalScrollIndicator={false}
@@ -478,7 +343,7 @@ export default function ExploreScreen() {
<View style={styles.headerContent}>
{/* 左边logo */}
<Image
source={require('@/assets/images/Sealife.jpeg')}
source={require('@/assets/icon.icon/Assets/icon-1756312748268.png')}
style={styles.logoImage}
resizeMode="cover"
/>
@@ -495,7 +360,7 @@ export default function ExploreScreen() {
style={styles.debugButton}
onPress={async () => {
console.log('🔧 手动触发后台任务测试...');
await backgroundTaskManager.triggerTaskForTesting();
await BackgroundTaskManager.getInstance().triggerTaskForTesting();
}}
>
<Text style={styles.debugButtonText}>🔧</Text>
@@ -529,17 +394,8 @@ export default function ExploreScreen() {
{/* 营养摄入雷达图卡片 */}
<NutritionRadarCard
nutritionSummary={nutritionSummary}
nutritionGoals={nutritionGoals}
burnedCalories={(basalMetabolism || 0) + (activeCalories || 0)}
basalMetabolism={basalMetabolism || 0}
activeCalories={activeCalories || 0}
selectedDate={currentSelectedDate}
resetToken={animToken}
onMealPress={(mealType: 'breakfast' | 'lunch' | 'dinner' | 'snack') => {
console.log('选择餐次:', mealType);
// 这里可以导航到营养记录页面
pushIfAuthedElseLogin('/nutrition/records');
}}
/>
<WeightHistoryCard />
@@ -549,7 +405,7 @@ export default function ExploreScreen() {
{/* 左列 */}
<View style={styles.masonryColumn}>
{/* 心情卡片 */}
<FloatingCard style={styles.masonryCard} delay={1500}>
<FloatingCard style={styles.masonryCard}>
<MoodCard
moodCheckin={currentMoodCheckin}
onPress={() => pushIfAuthedElseLogin('/mood/calendar')}
@@ -559,21 +415,17 @@ export default function ExploreScreen() {
<FloatingCard style={styles.masonryCard}>
<StepsCard
stepCount={stepCount}
curDate={currentSelectedDate}
stepGoal={stepGoal}
hourlySteps={hourlySteps}
style={styles.stepsCardOverride}
onPress={() => pushIfAuthedElseLogin('/steps/detail')}
/>
</FloatingCard>
<FloatingCard style={styles.masonryCard} delay={0}>
<FloatingCard style={styles.masonryCard}>
<StressMeter
value={hrvValue}
updateTime={hrvUpdateTime}
hrvValue={hrvValue}
curDate={currentSelectedDate}
/>
</FloatingCard>
@@ -588,27 +440,21 @@ export default function ExploreScreen() {
<FloatingCard style={styles.masonryCard}>
<SleepCard
sleepDuration={sleepDuration}
onPress={() => pushIfAuthedElseLogin('/sleep-detail')}
selectedDate={currentSelectedDate}
/>
</FloatingCard>
</View>
{/* 右列 */}
<View style={styles.masonryColumn}>
<FloatingCard style={styles.masonryCard} delay={250}>
<FloatingCard style={styles.masonryCard}>
<FitnessRingsCard
activeCalories={fitnessRingsData.activeCalories}
activeCaloriesGoal={fitnessRingsData.activeCaloriesGoal}
exerciseMinutes={fitnessRingsData.exerciseMinutes}
exerciseMinutesGoal={fitnessRingsData.exerciseMinutesGoal}
standHours={fitnessRingsData.standHours}
standHoursGoal={fitnessRingsData.standHoursGoal}
selectedDate={currentSelectedDate}
resetToken={animToken}
/>
</FloatingCard>
{/* 饮水记录卡片 */}
<FloatingCard style={styles.masonryCard} delay={500}>
<FloatingCard style={styles.masonryCard}>
<WaterIntakeCard
selectedDate={currentSelectedDateString}
style={styles.waterCardOverride}
@@ -617,26 +463,26 @@ export default function ExploreScreen() {
{/* 基础代谢卡片 */}
<FloatingCard style={styles.masonryCard} delay={1250}>
<FloatingCard style={styles.masonryCard}>
<BasalMetabolismCard
value={basalMetabolism}
resetToken={animToken}
selectedDate={currentSelectedDate}
style={styles.basalMetabolismCardOverride}
/>
</FloatingCard>
{/* 血氧饱和度卡片 */}
<FloatingCard style={styles.masonryCard} delay={1750}>
<FloatingCard style={styles.masonryCard}>
<OxygenSaturationCard
resetToken={animToken}
style={styles.basalMetabolismCardOverride}
oxygenSaturation={oxygenSaturation}
/>
</FloatingCard>
</View>
</View>
{/* 围度数据卡片 - 占满底部一行 */}
<CircumferenceCard style={styles.circumferenceCard} />
</ScrollView>
</View>
);
@@ -688,8 +534,8 @@ const styles = StyleSheet.create({
justifyContent: 'space-between',
},
logoImage: {
width: 36,
height: 36,
width: 28,
height: 28,
borderRadius: 20,
},
headerTextContainer: {
@@ -698,7 +544,7 @@ const styles = StyleSheet.create({
},
headerTitle: {
fontSize: 16,
fontWeight: '500',
fontWeight: '700',
color: '#192126',
},
debugButtonsContainer: {
@@ -938,7 +784,6 @@ const styles = StyleSheet.create({
marginBottom: 16,
},
masonryContainer: {
marginBottom: 16,
flexDirection: 'row',
gap: 16,
marginTop: 6,
@@ -1001,6 +846,10 @@ const styles = StyleSheet.create({
top: 0,
padding: 4,
},
circumferenceCard: {
marginBottom: 36,
marginTop: 10,
},
});

View File

@@ -9,46 +9,83 @@ import PrivacyConsentModal from '@/components/PrivacyConsentModal';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useQuickActions } from '@/hooks/useQuickActions';
import { clearAiCoachSessionCache } from '@/services/aiCoachSession';
import { backgroundTaskManager } from '@/services/backgroundTaskManager';
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 { rehydrateUser, setPrivacyAgreed } from '@/store/userSlice';
import { fetchMyProfile, setPrivacyAgreed } from '@/store/userSlice';
import { createWaterRecordAction } from '@/store/waterSlice';
import { MoodNotificationHelpers, NutritionNotificationHelpers } from '@/utils/notificationHelpers';
import { ensureHealthPermissions, initializeHealthPermissions } from '@/utils/health';
import { DailySummaryNotificationHelpers, MoodNotificationHelpers, NutritionNotificationHelpers } from '@/utils/notificationHelpers';
import { clearPendingWaterRecords, syncPendingWidgetChanges } from '@/utils/widgetDataSync';
import React from 'react';
import RNExitApp from 'react-native-exit-app';
import { DialogProvider } from '@/components/ui/DialogProvider';
import { ToastProvider } from '@/contexts/ToastContext';
import { STORAGE_KEYS } from '@/services/api';
import { BackgroundTaskManager } from '@/services/backgroundTaskManager';
import AsyncStorage from '@/utils/kvStore';
import { Provider } from 'react-redux';
function Bootstrapper({ children }: { children: React.ReactNode }) {
const dispatch = useAppDispatch();
const { privacyAgreed, profile } = useAppSelector((state) => state.user);
const { profile } = useAppSelector((state) => state.user);
const [showPrivacyModal, setShowPrivacyModal] = React.useState(false);
const [userDataLoaded, setUserDataLoaded] = React.useState(false);
// 初始化快捷动作处理
useQuickActions();
React.useEffect(() => {
const loadUserData = async () => {
await dispatch(rehydrateUser());
setUserDataLoaded(true);
// 数据已经在启动界面预加载,这里只需要快速同步到 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('通知服务初始化成功');
// 初始化后台任务管理器
await backgroundTaskManager.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();
@@ -88,46 +125,22 @@ function Bootstrapper({ children }: { children: React.ReactNode }) {
};
loadUserData();
initHealthPermissions();
initializeNotifications();
// 冷启动时清空 AI 教练会话缓存
clearAiCoachSessionCache();
}, [dispatch]);
React.useEffect(() => {
// 当用户数据加载完成后,检查是否需要显示隐私同意弹窗
if (userDataLoaded && !privacyAgreed) {
setShowPrivacyModal(true);
const getPrivacyAgreed = async () => {
const str = await AsyncStorage.getItem(STORAGE_KEYS.privacyAgreed)
setShowPrivacyModal(str !== 'true');
}
}, [userDataLoaded, privacyAgreed]);
// 当用户数据加载完成且用户名存在时,注册所有提醒
React.useEffect(() => {
const registerAllReminders = async () => {
try {
await notificationService.initialize();
// 后台任务
await backgroundTaskManager.initialize()
// 注册午餐提醒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('心情提醒已注册');
console.log('喝水提醒后台任务已注册');
} catch (error) {
console.error('注册提醒失败:', error);
}
};
registerAllReminders();
}, [userDataLoaded, profile?.name]);
getPrivacyAgreed();
}, []);
const handlePrivacyAgree = () => {
dispatch(setPrivacyAgreed());
@@ -135,7 +148,7 @@ function Bootstrapper({ children }: { children: React.ReactNode }) {
};
const handlePrivacyDisagree = () => {
RNExitApp.exitApp();
// RNExitApp.exitApp();
};
return (
@@ -180,6 +193,7 @@ export default function RootLayout() {
<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>

View File

@@ -12,7 +12,7 @@ import { PRIVACY_POLICY_URL, USER_AGREEMENT_URL } from '@/constants/Agree';
import { Colors } from '@/constants/Colors';
import { useAppDispatch } from '@/hooks/redux';
import { useColorScheme } from '@/hooks/useColorScheme';
import { login } from '@/store/userSlice';
import { fetchMyProfile, login } from '@/store/userSlice';
import Toast from 'react-native-toast-message';
export default function LoginScreen() {
@@ -113,6 +113,9 @@ export default function LoginScreen() {
}
await dispatch(login({ appleIdentityToken: identityToken })).unwrap();
// 拉取用户信息
await dispatch(fetchMyProfile())
Toast.show({
text1: '登录成功',
type: 'success',
@@ -130,7 +133,9 @@ export default function LoginScreen() {
router.back();
}
} catch (err: any) {
if (err?.code === 'ERR_CANCELED') return;
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 {
@@ -223,8 +228,8 @@ export default function LoginScreen() {
<ScrollView contentContainerStyle={styles.content} showsVerticalScrollIndicator={false}>
<View style={styles.headerWrap}>
<ThemedText style={[styles.title, { color: color.text }]}>Sealife</ThemedText>
<ThemedText style={[styles.subtitle, { color: color.textMuted }]}>Sealife</ThemedText>
<ThemedText style={[styles.title, { color: color.text }]}>Out Live</ThemedText>
<ThemedText style={[styles.subtitle, { color: color.textMuted }]}>Out Live</ThemedText>
</View>
{/* Apple 登录 */}

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

683
app/challenges/[id].tsx Normal file
View File

@@ -0,0 +1,683 @@
import { CHALLENGES, type Challenge } from '@/app/(tabs)/challenges';
import { HeaderBar } from '@/components/ui/HeaderBar';
import { Colors } from '@/constants/Colors';
import { useColorScheme } from '@/hooks/useColorScheme';
import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import { useLocalSearchParams, useRouter } from 'expo-router';
import React, { useMemo, useState } from 'react';
import {
Dimensions,
Image,
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.86;
const BADGE_SIZE = 120;
type ChallengeDetail = {
badgeImage: string;
periodLabel: string;
durationLabel: string;
requirementLabel: string;
summary?: string;
participantsCount: number;
rankingDescription?: string;
rankings: Record<string, RankingItem[]>;
highlightTitle: string;
highlightSubtitle: string;
ctaLabel: string;
};
type RankingItem = {
id: string;
name: string;
avatar: string;
metric: string;
badge?: string;
};
const DETAIL_PRESETS: Record<string, ChallengeDetail> = {
'hydration-hippo': {
badgeImage:
'https://images.unsplash.com/photo-1616628182503-5ef2941510da?auto=format&fit=crop&w=240&q=80',
periodLabel: '9月01日 - 9月30日 · 剩余 4 天',
durationLabel: '30 天',
requirementLabel: '喝水 1500ml 15 天以上',
summary: '与河马一起练就最佳补水习惯,让身体如湖水般澄澈充盈。',
participantsCount: 9009,
rankingDescription: '榜单实时更新,记录每位补水达人每日平均饮水量。',
rankings: {
all: [
{
id: 'all-1',
name: '湖光暮色',
avatar: 'https://images.unsplash.com/photo-1500648767791-00dcc994a43e?auto=format&fit=crop&w=140&q=80',
metric: '平均 3,200 ml',
badge: '金冠冠军',
},
{
id: 'all-2',
name: '温柔潮汐',
avatar: 'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?auto=format&fit=crop&w=140&q=80',
metric: '平均 2,980 ml',
},
{
id: 'all-3',
name: '晨雾河岸',
avatar: 'https://images.unsplash.com/photo-1544723795-432537f48b2b?auto=format&fit=crop&w=140&q=80',
metric: '平均 2,860 ml',
},
],
male: [
{
id: 'male-1',
name: '北岸微风',
avatar: 'https://images.unsplash.com/photo-1488426862026-3ee34a7d66df?auto=format&fit=crop&w=140&q=80',
metric: '平均 3,120 ml',
},
{
id: 'male-2',
name: '静水晚霞',
avatar: 'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?auto=format&fit=crop&w=140&q=80',
metric: '平均 2,940 ml',
},
],
female: [
{
id: 'female-1',
name: '露珠初晓',
avatar: 'https://images.unsplash.com/photo-1544723795-3fb6469f5b39?auto=format&fit=crop&w=140&q=80',
metric: '平均 3,060 ml',
},
{
id: 'female-2',
name: '桔梗水语',
avatar: 'https://images.unsplash.com/photo-1521572267360-ee0c2909d518?auto=format&fit=crop&w=140&q=80',
metric: '平均 2,880 ml',
},
],
},
highlightTitle: '分享一次,免费参与',
highlightSubtitle: '解锁高级会员,无限加入挑战',
ctaLabel: '马上分享激励好友',
},
};
const DEFAULT_DETAIL: ChallengeDetail = {
badgeImage: 'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?auto=format&fit=crop&w=240&q=80',
periodLabel: '本周进行中',
durationLabel: '30 天',
requirementLabel: '保持专注完成每日任务',
participantsCount: 3200,
highlightTitle: '立即参加,点燃动力',
highlightSubtitle: '邀请好友一起坚持,更容易收获成果',
ctaLabel: '立即加入挑战',
rankings: {
all: [],
},
};
const SEGMENTS = [
{ key: 'all', label: '全部' },
{ key: 'male', label: '男生' },
{ key: 'female', label: '女生' },
] as const;
type SegmentKey = (typeof SEGMENTS)[number]['key'];
export default function ChallengeDetailScreen() {
const { id } = useLocalSearchParams<{ id?: string }>();
const router = useRouter();
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme];
const insets = useSafeAreaInsets();
const challenge = useMemo<Challenge | undefined>(() => {
if (!id) return undefined;
return CHALLENGES.find((item) => item.id === id);
}, [id]);
const detail = useMemo<ChallengeDetail>(() => {
if (!id) return DEFAULT_DETAIL;
return DETAIL_PRESETS[id] ?? {
...DEFAULT_DETAIL,
periodLabel: challenge?.dateRange ?? DEFAULT_DETAIL.periodLabel,
highlightTitle: `加入 ${challenge?.title ?? '挑战'}`,
};
}, [challenge?.dateRange, challenge?.title, id]);
const [segment, setSegment] = useState<SegmentKey>('all');
const rankingData = detail.rankings[segment] ?? detail.rankings.all ?? [];
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 = () => {
// 当前没有具体业务流程,先回退到挑战列表
router.back();
};
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 }]}></Text>
</View>
</SafeAreaView>
);
}
return (
<SafeAreaView style={styles.safeArea} edges={['bottom']} >
<StatusBar barStyle="light-content" />
<View
pointerEvents="box-none"
style={[styles.headerOverlay, { paddingTop: insets.top }]}
>
<HeaderBar
title=""
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]}
>
<View style={styles.heroContainer}>
<Image source={{ uri: challenge.image }} style={styles.heroImage} resizeMode="cover" />
<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.badgeWrapper}>
<View style={styles.badgeShadow}>
<Image source={{ uri: detail.badgeImage }} style={styles.badgeImage} resizeMode="cover" />
</View>
</View>
<View style={styles.headerTextBlock}>
<Text style={styles.periodLabel}>{detail.periodLabel}</Text>
<Text style={styles.title}>{challenge.title}</Text>
{detail.summary ? <Text style={styles.summary}>{detail.summary}</Text> : null}
</View>
<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}>{challenge.dateRange}</Text>
<Text style={styles.detailMeta}>{detail.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}>{detail.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}>{detail.participantsCount.toLocaleString('zh-CN')} </Text>
<View style={styles.avatarRow}>
{challenge.avatars.slice(0, 6).map((avatar, index) => (
<Image
key={`${avatar}-${index}`}
source={{ uri: avatar }}
style={[styles.avatar, index > 0 && styles.avatarOffset]}
/>
))}
<TouchableOpacity style={styles.moreAvatarButton}>
<Text style={styles.moreAvatarText}></Text>
</TouchableOpacity>
</View>
</View>
</View>
</View>
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}></Text>
<TouchableOpacity>
<Text style={styles.sectionAction}></Text>
</TouchableOpacity>
</View>
{detail.rankingDescription ? (
<Text style={styles.sectionSubtitle}>{detail.rankingDescription}</Text>
) : null}
<View style={styles.segmentedControl}>
{SEGMENTS.map(({ key, label }) => {
const isActive = segment === key;
const disabled = !(detail.rankings[key] && detail.rankings[key].length);
return (
<TouchableOpacity
key={key}
style={[styles.segmentButton, isActive && styles.segmentButtonActive, disabled && styles.segmentDisabled]}
activeOpacity={disabled ? 1 : 0.8}
onPress={() => {
if (disabled) return;
setSegment(key);
}}
>
<Text style={[styles.segmentLabel, isActive && styles.segmentLabelActive, disabled && styles.segmentLabelDisabled]}>
{label}
</Text>
</TouchableOpacity>
);
})}
</View>
<View style={styles.rankingCard}>
{rankingData.length ? (
rankingData.map((item, index) => (
<View key={item.id} style={[styles.rankingRow, index > 0 && styles.rankingRowDivider]}>
<View style={styles.rankingOrderCircle}>
<Text style={styles.rankingOrder}>{index + 1}</Text>
</View>
<Image source={{ uri: item.avatar }} style={styles.rankingAvatar} />
<View style={styles.rankingInfo}>
<Text style={styles.rankingName}>{item.name}</Text>
<Text style={styles.rankingMetric}>{item.metric}</Text>
</View>
{item.badge ? <Text style={styles.rankingBadge}>{item.badge}</Text> : null}
</View>
))
) : (
<View style={styles.emptyRanking}>
<Text style={styles.emptyRankingText}></Text>
</View>
)}
</View>
<View style={styles.highlightCard}>
<LinearGradient
colors={['#5E8BFF', '#6B6CFF']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={StyleSheet.absoluteFillObject}
/>
<Text style={styles.highlightTitle}>{detail.highlightTitle}</Text>
<Text style={styles.highlightSubtitle}>{detail.highlightSubtitle}</Text>
<TouchableOpacity style={styles.highlightButton} activeOpacity={0.9} onPress={handleJoin}>
<Text style={styles.highlightButtonLabel}>{detail.ctaLabel}</Text>
</TouchableOpacity>
</View>
</ScrollView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safeArea: {
flex: 1,
backgroundColor: '#f3f4fb',
},
headerOverlay: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
zIndex: 20,
},
heroContainer: {
height: HERO_HEIGHT,
width: '100%',
overflow: 'hidden',
borderBottomLeftRadius: 36,
borderBottomRightRadius: 36,
},
heroImage: {
width: '100%',
height: '100%',
},
scrollView: {
flex: 1,
},
scrollContent: {
paddingBottom: Platform.select({ ios: 40, default: 28 }),
},
badgeWrapper: {
alignItems: 'center',
marginTop: -BADGE_SIZE / 2,
},
badgeShadow: {
width: BADGE_SIZE,
height: BADGE_SIZE,
borderRadius: BADGE_SIZE / 2,
backgroundColor: '#fff',
padding: 12,
shadowColor: 'rgba(17, 24, 39, 0.2)',
shadowOpacity: 0.25,
shadowRadius: 18,
shadowOffset: { width: 0, height: 10 },
elevation: 12,
},
badgeImage: {
flex: 1,
borderRadius: BADGE_SIZE / 2,
},
headerTextBlock: {
paddingHorizontal: 24,
marginTop: 24,
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',
},
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,
borderRadius: 21,
backgroundColor: '#EFF1FF',
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,
},
segmentedControl: {
marginTop: 20,
marginHorizontal: 24,
borderRadius: 20,
backgroundColor: '#EAECFB',
padding: 4,
flexDirection: 'row',
},
segmentButton: {
flex: 1,
paddingVertical: 8,
borderRadius: 16,
alignItems: 'center',
justifyContent: 'center',
},
segmentButtonActive: {
backgroundColor: '#fff',
shadowColor: 'rgba(79, 91, 213, 0.25)',
shadowOpacity: 0.3,
shadowRadius: 10,
shadowOffset: { width: 0, height: 6 },
elevation: 4,
},
segmentDisabled: {
opacity: 0.5,
},
segmentLabel: {
fontSize: 13,
fontWeight: '600',
color: '#6372C6',
},
segmentLabelActive: {
color: '#4F5BD5',
},
segmentLabelDisabled: {
color: '#9AA3CF',
},
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,
},
rankingRow: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 12,
paddingHorizontal: 18,
},
rankingRowDivider: {
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: '#E5E7FF',
},
rankingOrderCircle: {
width: 32,
height: 32,
borderRadius: 16,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#EEF0FF',
marginRight: 12,
},
rankingOrder: {
fontSize: 15,
fontWeight: '700',
color: '#4F5BD5',
},
rankingAvatar: {
width: 44,
height: 44,
borderRadius: 22,
marginRight: 14,
},
rankingInfo: {
flex: 1,
},
rankingName: {
fontSize: 15,
fontWeight: '700',
color: '#1c1f3a',
},
rankingMetric: {
marginTop: 4,
fontSize: 13,
color: '#6f7ba7',
},
rankingBadge: {
fontSize: 12,
color: '#A67CFF',
fontWeight: '700',
},
emptyRanking: {
paddingVertical: 40,
alignItems: 'center',
},
emptyRankingText: {
fontSize: 14,
color: '#6f7ba7',
},
highlightCard: {
marginTop: 32,
marginHorizontal: 24,
borderRadius: 28,
paddingVertical: 28,
paddingHorizontal: 24,
overflow: 'hidden',
},
highlightTitle: {
fontSize: 18,
fontWeight: '800',
color: '#ffffff',
},
highlightSubtitle: {
marginTop: 10,
fontSize: 14,
color: 'rgba(255,255,255,0.85)',
lineHeight: 20,
},
highlightButton: {
marginTop: 22,
backgroundColor: 'rgba(255,255,255,0.18)',
paddingVertical: 12,
borderRadius: 22,
alignItems: 'center',
borderWidth: 1,
borderColor: 'rgba(247,248,255,0.5)',
},
highlightButtonLabel: {
fontSize: 15,
fontWeight: '700',
color: '#ffffff',
},
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',
},
});

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

View File

@@ -30,8 +30,8 @@ import { api, getAuthToken, postTextStream } from '@/services/api';
import { selectLatestMoodRecordByDate } from '@/store/moodSlice';
import { generateWelcomeMessage, hasRecordedMoodToday } from '@/utils/welcomeMessage';
import { Image } from 'expo-image';
import { HistoryModal } from '../../components/model/HistoryModal';
import { ActionSheet } from '../../components/ui/ActionSheet';
import { HistoryModal } from '../components/model/HistoryModal';
import { ActionSheet } from '../components/ui/ActionSheet';
// 导入新的 coach 组件
import {

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

View File

@@ -1,6 +1,7 @@
import { ThemedView } from '@/components/ThemedView';
import { ROUTES } from '@/constants/Routes';
import { useThemeColor } from '@/hooks/useThemeColor';
import { preloadUserData } from '@/store/userSlice';
import { router } from 'expo-router';
import React, { useEffect, useState } from 'react';
import { ActivityIndicator, View } from 'react-native';
@@ -18,6 +19,11 @@ export default function SplashScreen() {
const checkOnboardingStatus = async () => {
try {
// 先预加载用户数据,这样进入应用时就有正确的 token 状态
console.log('开始预加载用户数据...');
await preloadUserData();
console.log('用户数据预加载完成');
// const onboardingCompleted = await AsyncStorage.getItem(ONBOARDING_COMPLETED_KEY);
// if (onboardingCompleted === 'true') {
@@ -28,11 +34,9 @@ export default function SplashScreen() {
// setIsLoading(false);
router.replace(ROUTES.TAB_STATISTICS);
} catch (error) {
console.error('检查引导状态失败:', error);
// 如果出现错误,默认显示引导页面
// setTimeout(() => {
// router.replace('/onboarding');
// }, 1000);
console.error('检查引导状态或预加载用户数据失败:', error);
// 如果出现错误,仍然进入应用,但可能会有状态更新
router.replace(ROUTES.TAB_STATISTICS);
}
setIsLoading(false);
};

View File

@@ -15,9 +15,12 @@ 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, useState } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import {
Alert, Image,
Keyboard,
KeyboardAvoidingView,
Platform,
ScrollView,
StyleSheet,
Text,
@@ -43,6 +46,9 @@ export default function MoodEditScreen() {
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 获取数据
@@ -66,6 +72,25 @@ export default function MoodEditScreen() {
}
}, [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('提示', '请选择心情');
@@ -163,7 +188,18 @@ export default function MoodEditScreen() {
tone="light"
/>
<ScrollView style={styles.content}>
<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}>
@@ -211,6 +247,7 @@ export default function MoodEditScreen() {
<Text style={styles.sectionTitle}></Text>
<Text style={styles.diarySubtitle}></Text>
<TextInput
ref={textInputRef}
style={styles.descriptionInput}
placeholder={`今天的心情如何?
@@ -225,11 +262,18 @@ export default function MoodEditScreen() {
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}>
@@ -294,10 +338,15 @@ const styles = StyleSheet.create({
safeArea: {
flex: 1,
},
keyboardAvoidingView: {
flex: 1,
},
content: {
flex: 1,
},
scrollContent: {
paddingBottom: 100, // 为底部按钮留出空间
},
dateSection: {
backgroundColor: 'rgba(255,255,255,0.95)',
margin: 12,

View File

@@ -19,6 +19,7 @@ import {
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';
@@ -73,6 +74,9 @@ export default function NutritionRecordsScreen() {
const [hasMoreData, setHasMoreData] = useState(true);
const [page, setPage] = useState(1);
// 基础代谢数据状态
const [basalMetabolism, setBasalMetabolism] = useState<number>(1482);
// 食物添加弹窗状态
const [showFoodOverlay, setShowFoodOverlay] = useState(false);
@@ -118,6 +122,7 @@ export default function NutritionRecordsScreen() {
// 当选中日期或视图模式变化时重新加载数据
useEffect(() => {
fetchBasalMetabolismData();
if (viewMode === 'daily') {
dispatch(fetchDailyNutritionData(currentSelectedDate));
} else {
@@ -150,6 +155,22 @@ export default function NutritionRecordsScreen() {
}
}, [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);
@@ -300,42 +321,6 @@ export default function NutritionRecordsScreen() {
});
};
// 渲染视图模式切换器
const renderViewModeToggle = () => (
<View style={[styles.viewModeContainer, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}>
<Text style={[styles.monthTitle, { color: colorTokens.text }]}>{monthTitle}</Text>
<View style={[styles.toggleContainer, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}>
<TouchableOpacity
style={[
styles.toggleButton,
viewMode === 'daily' && { backgroundColor: colorTokens.primary }
]}
onPress={() => setViewMode('daily')}
>
<Text style={[
styles.toggleText,
{ color: viewMode === 'daily' ? colorTokens.onPrimary : colorTokens.textSecondary }
]}>
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.toggleButton,
viewMode === 'all' && { backgroundColor: colorTokens.primary }
]}
onPress={() => setViewMode('all')}
>
<Text style={[
styles.toggleText,
{ color: viewMode === 'all' ? colorTokens.onPrimary : colorTokens.textSecondary }
]}>
</Text>
</TouchableOpacity>
</View>
</View>
);
// 渲染日期选择器(仅在按天查看模式下显示)
const renderDateSelector = () => {
@@ -445,7 +430,7 @@ export default function NutritionRecordsScreen() {
{/* Calorie Ring Chart */}
<CalorieRingChart
metabolism={healthData?.basalEnergyBurned || 1482}
metabolism={basalMetabolism}
exercise={healthData?.activeEnergyBurned || 0}
consumed={nutritionSummary?.totalCalories || 0}
protein={nutritionSummary?.totalProtein || 0}

View File

@@ -6,7 +6,7 @@ import { useCosUpload } from '@/hooks/useCosUpload';
import { fetchMyProfile, updateUserProfile } from '@/store/userSlice';
import { fetchMaximumHeartRate } from '@/utils/health';
import { Ionicons } from '@expo/vector-icons';
import AsyncStorage from '@react-native-async-storage/async-storage';
import AsyncStorage from '@/utils/kvStore';
import DateTimePicker from '@react-native-community/datetimepicker';
import { Picker } from '@react-native-picker/picker';
import { useFocusEffect } from '@react-navigation/native';
@@ -40,7 +40,6 @@ interface UserProfile {
weight?: number; // kg
height?: number; // cm
avatarUri?: string | null;
avatarBase64?: string | null; // 兼容旧逻辑(不再上报)
activityLevel?: number; // 活动水平 1-4
maxHeartRate?: number; // 最大心率
}
@@ -270,7 +269,13 @@ export default function EditProfileScreen() {
{ prefix: 'avatars/', userId }
);
setProfile((p) => ({ ...p, avatarUri: url, avatarBase64: null }));
console.log('url', url);
setProfile((p) => ({ ...p, avatarUri: url }));
// 保存更新后的 profile
await handleSaveWithProfile({ ...profile, avatarUri: url });
Alert.alert('成功', '头像更新成功');
} catch (e) {
console.warn('上传头像失败', e);
Alert.alert('上传失败', '头像上传失败,请重试');

View File

@@ -1,7 +1,7 @@
import { HeaderBar } from '@/components/ui/HeaderBar';
import { Colors } from '@/constants/Colors';
import { useColorScheme } from '@/hooks/useColorScheme';
import AsyncStorage from '@react-native-async-storage/async-storage';
import AsyncStorage from '@/utils/kvStore';
import * as Haptics from 'expo-haptics';
import { useRouter } from 'expo-router';
import React, { useEffect, useMemo, useState } from 'react';

File diff suppressed because it is too large Load Diff

View File

@@ -1,37 +1,43 @@
import { DateSelector } from '@/components/DateSelector';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { selectHealthDataByDate, setHealthData } from '@/store/healthSlice';
import { HeaderBar } from '@/components/ui/HeaderBar';
import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date';
import { ensureHealthPermissions, fetchHealthDataForDate } from '@/utils/health';
import { getTestHealthData } from '@/utils/mockHealthData';
import { Ionicons } from '@expo/vector-icons';
import { fetchHourlyStepSamples, fetchStepCount, HourlyStepData } from '@/utils/health';
import { logger } from '@/utils/logger';
import dayjs from 'dayjs';
import { LinearGradient } from 'expo-linear-gradient';
import { useRouter } from 'expo-router';
import { useLocalSearchParams } from 'expo-router';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import {
Animated,
SafeAreaView,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
export default function StepsDetailScreen() {
const router = useRouter();
const dispatch = useAppDispatch();
const insets = useSafeAreaInsets();
// 获取路由参数
const { date } = useLocalSearchParams<{ date?: string }>();
// 开发调试设置为true来使用mock数据
const useMockData = __DEV__;
// 根据传入的日期参数计算初始选中索引
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(getTodayIndexInMonth());
const [selectedIndex, setSelectedIndex] = useState(getInitialSelectedIndex());
// 数据加载状态
// 步数数据状态
const [stepCount, setStepCount] = useState(0);
const [hourlySteps, setHourSteps] = useState<HourlyStepData[]>([]);
const [isLoading, setIsLoading] = useState(false);
// 获取当前选中日期
@@ -40,17 +46,25 @@ export default function StepsDetailScreen() {
return days[selectedIndex]?.date?.toDate() ?? new Date();
}, [selectedIndex]);
const currentSelectedDateString = useMemo(() => {
return dayjs(currentSelectedDate).format('YYYY-MM-DD');
}, [currentSelectedDate]);
// 获取步数数据的函数,参考 StepsCard 的实现
const getStepData = async (date: Date) => {
try {
setIsLoading(true);
logger.info('获取步数详情数据...');
const [steps, hourly] = await Promise.all([
fetchStepCount(date),
fetchHourlyStepSamples(date)
]);
// 从 Redux 获取指定日期的健康数据
const healthData = useAppSelector(selectHealthDataByDate(currentSelectedDateString));
setStepCount(steps);
setHourSteps(hourly);
// 解构健康数据支持mock数据
const mockData = useMockData ? getTestHealthData('mock') : null;
const stepCount: number | null = useMockData ? (mockData?.steps ?? null) : (healthData?.steps ?? null);
const hourlySteps = useMockData ? (mockData?.hourlySteps ?? []) : (healthData?.hourlySteps ?? []);
} catch (error) {
logger.error('获取步数详情数据失败:', error);
} finally {
setIsLoading(false);
}
};
// 为每个柱体创建独立的动画值
@@ -113,50 +127,24 @@ export default function StepsDetailScreen() {
}
}, [chartData, animatedValues]);
// 加载健康数据
const loadHealthData = async (targetDate: Date) => {
if (useMockData) return; // 如果使用mock数据不需要加载
try {
setIsLoading(true);
console.log('加载步数详情数据...', targetDate);
const ok = await ensureHealthPermissions();
if (!ok) {
console.warn('无法获取健康权限');
return;
}
const data = await fetchHealthDataForDate(targetDate);
console.log('data', data);
const dateString = dayjs(targetDate).format('YYYY-MM-DD');
// 使用 Redux 存储健康数据
dispatch(setHealthData({
date: dateString,
data: data
}));
console.log('步数详情数据加载完成');
} catch (error) {
console.error('加载步数详情数据失败:', error);
} finally {
setIsLoading(false);
}
};
// 日期选择处理
const onSelectDate = (index: number, date: Date) => {
setSelectedIndex(index);
loadHealthData(date);
getStepData(date);
};
// 页面初始化时加载当前日期数据
// 当路由参数变化时更新选中索引
useEffect(() => {
loadHealthData(currentSelectedDate);
}, []);
const newIndex = getInitialSelectedIndex();
setSelectedIndex(newIndex);
}, [date]);
// 当选中日期变化时获取数据
useEffect(() => {
if (currentSelectedDate) {
getStepData(currentSelectedDate);
}
}, [currentSelectedDate]);
// 计算总步数和平均步数
const totalSteps = stepCount || 0;
@@ -219,18 +207,9 @@ export default function StepsDetailScreen() {
end={{ x: 1, y: 1 }}
/>
<SafeAreaView style={styles.safeArea}>
{/* 顶部导航栏 */}
<View style={styles.header}>
<TouchableOpacity
style={styles.backButton}
onPress={() => router.back()}
>
<Ionicons name="chevron-back" size={24} color="#192126" />
</TouchableOpacity>
<Text style={styles.headerTitle}></Text>
<View style={styles.headerRight} />
</View>
<HeaderBar
title="步数详情"
/>
<ScrollView
style={styles.scrollView}
@@ -434,7 +413,6 @@ export default function StepsDetailScreen() {
</View>
</View>
</ScrollView>
</SafeAreaView>
</View>
);
}
@@ -450,9 +428,6 @@ const styles = StyleSheet.create({
top: 0,
bottom: 0,
},
safeArea: {
flex: 1,
},
header: {
flexDirection: 'row',
alignItems: 'center',

910
app/voice-record.tsx Normal file
View File

@@ -0,0 +1,910 @@
import { HeaderBar } from '@/components/ui/HeaderBar';
import { Colors } from '@/constants/Colors';
import { useAppDispatch } from '@/hooks/redux';
import { useColorScheme } from '@/hooks/useColorScheme';
import { analyzeFoodFromText } from '@/services/foodRecognition';
import { saveRecognitionResult, setError, setLoading } from '@/store/foodRecognitionSlice';
import { triggerHapticFeedback } from '@/utils/haptics';
import { Ionicons } from '@expo/vector-icons';
import Voice from '@react-native-voice/voice';
import { BlurView } from 'expo-blur';
import { router, useLocalSearchParams } from 'expo-router';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import {
Alert,
Animated,
StyleSheet,
Text,
TouchableOpacity,
View
} from 'react-native';
type VoiceRecordState = 'idle' | 'listening' | 'processing' | 'result' | 'analyzing';
export default function VoiceRecordScreen() {
const theme = useColorScheme() ?? 'light';
const colorTokens = Colors[theme];
const { mealType = 'dinner' } = useLocalSearchParams<{ mealType?: string }>();
const dispatch = useAppDispatch();
// 状态管理
const [recordState, setRecordState] = useState<VoiceRecordState>('idle');
const [recognizedText, setRecognizedText] = useState('');
const [isListening, setIsListening] = useState(false);
const [analysisProgress, setAnalysisProgress] = useState(0);
// 用于跟踪组件是否已卸载,防止在组件卸载后设置状态
const isMountedRef = useRef(true);
const progressIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
// 动画相关
const scaleAnimation = useRef(new Animated.Value(1)).current;
const pulseAnimation = useRef(new Animated.Value(1)).current;
const waveAnimation = useRef(new Animated.Value(0)).current;
const glowAnimation = useRef(new Animated.Value(0)).current;
const progressAnimation = useRef(new Animated.Value(0)).current;
// 启动脉动动画
const startPulseAnimation = () => {
Animated.loop(
Animated.sequence([
Animated.timing(pulseAnimation, {
toValue: 1.2,
duration: 800,
useNativeDriver: true,
}),
Animated.timing(pulseAnimation, {
toValue: 1,
duration: 800,
useNativeDriver: true,
}),
])
).start();
};
// 启动波浪动画
const startWaveAnimation = () => {
Animated.loop(
Animated.timing(waveAnimation, {
toValue: 1,
duration: 1500,
useNativeDriver: false,
})
).start();
};
// 启动科幻分析动画
const startAnalysisAnimation = () => {
// 光环动画
Animated.loop(
Animated.sequence([
Animated.timing(glowAnimation, {
toValue: 1,
duration: 2000,
useNativeDriver: false,
}),
Animated.timing(glowAnimation, {
toValue: 0,
duration: 2000,
useNativeDriver: false,
}),
])
).start();
// 进度条动画
Animated.timing(progressAnimation, {
toValue: 1,
duration: 8000, // 8秒完成
useNativeDriver: false,
}).start();
};
// 停止所有动画
const stopAnimations = () => {
pulseAnimation.stopAnimation();
waveAnimation.stopAnimation();
glowAnimation.stopAnimation();
progressAnimation.stopAnimation();
scaleAnimation.setValue(1);
pulseAnimation.setValue(1);
waveAnimation.setValue(0);
glowAnimation.setValue(0);
progressAnimation.setValue(0);
};
// 语音识别回调 - 使用 useCallback 避免每次渲染重新创建
const onSpeechStart = useCallback(() => {
console.log('语音开始');
if (!isMountedRef.current) return;
setIsListening(true);
setRecordState('listening');
startPulseAnimation();
startWaveAnimation();
}, []);
const onSpeechRecognized = useCallback(() => {
console.log('语音识别中...');
}, []);
const onSpeechEnd = useCallback(() => {
console.log('语音结束');
if (!isMountedRef.current) return;
setIsListening(false);
setRecordState('processing');
stopAnimations();
}, []);
const onSpeechError = useCallback((error: any) => {
console.log('语音识别错误:', error);
if (!isMountedRef.current) return;
setIsListening(false);
setRecordState('idle');
stopAnimations();
// 显示更友好的错误信息
if (error.error?.code === '7') {
Alert.alert('提示', '没有检测到语音输入,请重试');
} else if (error.error?.code === '2') {
Alert.alert('提示', '网络连接异常,请检查网络后重试');
} else {
Alert.alert('提示', '语音识别出现问题,请重试');
}
}, []);
const onSpeechResults = useCallback((event: any) => {
console.log('语音识别结果:', event);
if (!isMountedRef.current) return;
const text = event.value?.[0] || '';
if (text.trim()) {
setRecognizedText(text);
setRecordState('result');
} else {
setRecordState('idle');
Alert.alert('提示', '未识别到有效内容,请重新录音');
}
stopAnimations();
}, []);
const onSpeechPartialResults = useCallback((event: any) => {
if (!isMountedRef.current) return;
const text = event.value?.[0] || '';
setRecognizedText(text);
}, []);
const onSpeechVolumeChanged = useCallback((event: any) => {
if (!isMountedRef.current) return;
// 根据音量调整动画
const volume = event.value || 0;
const scale = 1 + (volume * 0.1);
scaleAnimation.setValue(Math.min(scale, 1.5));
}, []);
useEffect(() => {
// 初始化语音识别
Voice.onSpeechStart = onSpeechStart;
Voice.onSpeechRecognized = onSpeechRecognized;
Voice.onSpeechEnd = onSpeechEnd;
Voice.onSpeechError = onSpeechError;
Voice.onSpeechResults = onSpeechResults;
Voice.onSpeechPartialResults = onSpeechPartialResults;
Voice.onSpeechVolumeChanged = onSpeechVolumeChanged;
return () => {
// 标记组件已卸载
isMountedRef.current = false;
// 清理进度定时器
if (progressIntervalRef.current) {
clearInterval(progressIntervalRef.current);
}
// 清理语音识别资源
const cleanup = async () => {
try {
await Voice.stop();
await Voice.destroy();
Voice.removeAllListeners();
} catch (error) {
console.log('清理语音识别资源失败:', error);
}
};
cleanup();
};
}, [onSpeechStart, onSpeechRecognized, onSpeechEnd, onSpeechError, onSpeechResults, onSpeechPartialResults, onSpeechVolumeChanged]);
// 开始录音
const startRecording = async () => {
try {
// 重置状态
setRecognizedText('');
setRecordState('idle');
triggerHapticFeedback('impactMedium');
// 确保之前的识别已停止
await Voice.stop();
// 添加短暂延迟确保资源清理完成
await new Promise(resolve => setTimeout(resolve, 100));
// 启动新的语音识别
await Voice.start('zh-CN');
} catch (error) {
console.log('启动语音识别失败:', error);
setRecordState('idle');
setIsListening(false);
Alert.alert('录音失败', '无法启动语音识别,请检查麦克风权限设置');
}
};
// 停止录音
const stopRecording = async () => {
try {
console.log('停止录音');
setIsListening(false);
await Voice.stop();
triggerHapticFeedback('impactLight');
} catch (error) {
console.log('停止语音识别失败:', error);
setIsListening(false);
setRecordState('idle');
}
};
// 重新录音
const retryRecording = async () => {
try {
// 停止所有动画
stopAnimations();
// 重置所有状态
setRecognizedText('');
setAnalysisProgress(0);
setIsListening(false);
setRecordState('idle');
// 确保语音识别已停止
await Voice.stop();
// 延迟一点再开始新的录音,确保状态完全重置
setTimeout(() => {
startRecording();
}, 200);
} catch (error) {
console.log('重新录音失败:', error);
setRecordState('idle');
setIsListening(false);
}
};
// 确认并分析食物文本
const confirmResult = async () => {
if (!recognizedText.trim()) {
Alert.alert('提示', '请先进行语音识别');
return;
}
try {
triggerHapticFeedback('impactMedium');
setRecordState('analyzing');
setAnalysisProgress(0);
// 启动科幻分析动画
startAnalysisAnimation();
// 模拟进度更新
progressIntervalRef.current = setInterval(() => {
if (!isMountedRef.current) {
if (progressIntervalRef.current) {
clearInterval(progressIntervalRef.current);
}
return;
}
setAnalysisProgress(prev => {
if (prev >= 90) {
if (progressIntervalRef.current) {
clearInterval(progressIntervalRef.current);
}
return prev;
}
return prev + Math.random() * 15;
});
}, 500);
// 调用文本分析API
dispatch(setLoading(true));
const result = await analyzeFoodFromText({ text: recognizedText });
// 清理进度定时器
if (progressIntervalRef.current) {
clearInterval(progressIntervalRef.current);
}
if (!isMountedRef.current) return;
setAnalysisProgress(100);
// 生成识别结果ID并保存到Redux
const recognitionId = `text_${Date.now()}`;
dispatch(saveRecognitionResult({ id: recognitionId, result }));
// 停止动画并导航到结果页面
stopAnimations();
// 延迟一点让用户看到100%完成
setTimeout(() => {
router.replace({
pathname: '/food/analysis-result',
params: {
recognitionId,
mealType: mealType,
hideRecordBar: 'false'
}
});
}, 800);
} catch (error) {
console.error('食物分析失败:', error);
// 清理进度定时器
if (progressIntervalRef.current) {
clearInterval(progressIntervalRef.current);
}
if (!isMountedRef.current) return;
stopAnimations();
setRecordState('result');
dispatch(setLoading(false));
const errorMessage = error instanceof Error ? error.message : '分析失败,请重试';
dispatch(setError(errorMessage));
Alert.alert('分析失败', errorMessage);
}
};
const handleBack = async () => {
try {
// 如果正在录音,先停止
if (isListening) {
await stopRecording();
}
// 停止所有动画
stopAnimations();
// 确保语音识别完全停止
await Voice.stop();
router.back();
} catch (error) {
console.log('返回时清理资源失败:', error);
router.back();
}
};
// 获取状态对应的UI文本
const getStatusText = () => {
switch (recordState) {
case 'idle':
return '轻触麦克风开始录音';
case 'listening':
return '正在聆听中,请开始说话...';
case 'processing':
return 'AI正在处理语音内容...';
case 'analyzing':
return 'AI大模型深度分析营养成分中...';
case 'result':
return '语音识别完成,请确认结果';
default:
return '';
}
};
// 获取主按钮配置
const getMainButtonConfig = () => {
switch (recordState) {
case 'idle':
return {
onPress: startRecording,
color: '#7B68EE',
icon: 'mic',
size: 80,
};
case 'listening':
return {
onPress: stopRecording,
color: '#FF6B6B',
icon: 'stop',
size: 80,
};
case 'processing':
return {
onPress: () => { },
color: '#FFA07A',
icon: 'hourglass',
size: 80,
};
case 'analyzing':
return {
onPress: () => { },
color: '#00D4AA',
icon: 'analytics',
size: 80,
};
case 'result':
return {
onPress: confirmResult,
color: '#4ECDC4',
icon: 'checkmark',
size: 80,
};
}
};
const buttonConfig = getMainButtonConfig();
return (
<View style={[styles.container, { backgroundColor: colorTokens.background }]}>
<HeaderBar
title="一句话记录"
onBack={handleBack}
tone={theme}
variant="elevated"
/>
<View style={styles.content}>
{/* 上半部分:介绍 */}
<View style={styles.topSection}>
<View style={styles.introContainer}>
<Text style={[styles.introDescription, { color: colorTokens.textSecondary }]}>
AI将智能分析营养成分和卡路里
</Text>
</View>
</View>
{/* 中间部分:录音动画区域 */}
<View style={styles.middleSection}>
<View style={styles.animationContainer}>
{/* 背景波浪效果 */}
{recordState === 'listening' && (
<>
{[1, 2, 3].map((index) => (
<Animated.View
key={index}
style={[
styles.waveRing,
{
transform: [
{
scale: waveAnimation.interpolate({
inputRange: [0, 1],
outputRange: [0.8, 2 + index * 0.3],
}),
},
],
opacity: waveAnimation.interpolate({
inputRange: [0, 0.5, 1],
outputRange: [0.6, 0.3, 0],
}),
},
]}
/>
))}
</>
)}
{/* 科幻分析特效 */}
{recordState === 'analyzing' && (
<>
{/* 外光环 */}
<Animated.View
style={[
styles.glowRing,
{
opacity: glowAnimation.interpolate({
inputRange: [0, 1],
outputRange: [0.3, 0.8],
}),
transform: [
{
scale: glowAnimation.interpolate({
inputRange: [0, 1],
outputRange: [1.2, 1.6],
}),
},
],
},
]}
/>
{/* 内光环 */}
<Animated.View
style={[
styles.innerGlowRing,
{
opacity: glowAnimation.interpolate({
inputRange: [0, 1],
outputRange: [0.5, 1],
}),
transform: [
{
scale: glowAnimation.interpolate({
inputRange: [0, 1],
outputRange: [0.9, 1.1],
}),
},
],
},
]}
/>
</>
)}
{/* 主录音按钮 */}
<Animated.View
style={[
styles.recordButton,
{
backgroundColor: buttonConfig.color,
transform: [
{ scale: scaleAnimation },
{ scale: pulseAnimation },
],
},
]}
>
<TouchableOpacity
style={styles.recordButtonInner}
onPress={buttonConfig.onPress}
activeOpacity={0.8}
disabled={recordState === 'processing' || recordState === 'analyzing'}
>
<Ionicons
name={buttonConfig.icon as any}
size={buttonConfig.size}
color="white"
/>
</TouchableOpacity>
</Animated.View>
</View>
</View>
{/* 下半部分:状态文本和示例 */}
<View style={styles.bottomSection}>
<View style={styles.statusContainer}>
<Text style={[styles.statusText, { color: colorTokens.text }]}>
{getStatusText()}
</Text>
{recordState === 'listening' && (
<Text style={[styles.hintText, { color: colorTokens.textSecondary }]}>
</Text>
)}
{/* 食物记录示例 */}
{recordState === 'idle' && (
<BlurView intensity={20} tint={theme} style={styles.examplesContainer}>
<View style={styles.examplesContent}>
<Text style={[styles.examplesTitle, { color: colorTokens.text }]}>
</Text>
<View style={styles.examplesList}>
<Text style={[styles.exampleText, { color: colorTokens.textSecondary }]}>
&ldquo;&rdquo;
</Text>
<Text style={[styles.exampleText, { color: colorTokens.textSecondary }]}>
&ldquo;150&rdquo;
</Text>
<Text style={[styles.exampleText, { color: colorTokens.textSecondary }]}>
&ldquo;&rdquo;
</Text>
</View>
</View>
</BlurView>
)}
{recordState === 'analyzing' && (
<View style={styles.analysisProgressContainer}>
<Text style={[styles.progressText, { color: colorTokens.text }]}>
: {Math.round(analysisProgress)}%
</Text>
<View style={styles.progressBarContainer}>
<Animated.View
style={[
styles.progressBar,
{
width: progressAnimation.interpolate({
inputRange: [0, 1],
outputRange: ['0%', '100%'],
}),
},
]}
/>
</View>
<Text style={[styles.analysisHint, { color: colorTokens.textSecondary }]}>
AI正在深度分析您的食物描述...
</Text>
</View>
)}
</View>
</View>
{/* 识别结果 */}
{recognizedText && (
<BlurView intensity={20} tint={theme} style={styles.resultContainer}>
<View style={styles.resultContent}>
<Text style={[styles.resultLabel, { color: colorTokens.textSecondary }]}>
:
</Text>
<Text style={[styles.resultText, { color: colorTokens.text }]}>
{recognizedText}
</Text>
{recordState === 'result' && (
<View style={styles.resultActions}>
<TouchableOpacity
style={[styles.actionButton, styles.retryButton]}
onPress={retryRecording}
>
<Ionicons name="refresh" size={16} color="#7B68EE" />
<Text style={styles.retryButtonText}></Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.actionButton, styles.confirmButton]}
onPress={confirmResult}
>
<Ionicons name="checkmark" size={16} color="white" />
<Text style={styles.confirmButtonText}>使</Text>
</TouchableOpacity>
</View>
)}
</View>
</BlurView>
)}
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
content: {
flex: 1,
paddingHorizontal: 20,
paddingTop: 20,
paddingBottom: 40,
},
topSection: {
alignItems: 'center',
paddingBottom: 20,
},
middleSection: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
minHeight: 200,
},
bottomSection: {
alignItems: 'center',
},
introContainer: {
alignItems: 'center',
paddingHorizontal: 20,
},
introTitle: {
fontSize: 24,
fontWeight: 'bold',
marginBottom: 8,
textAlign: 'center',
},
introDescription: {
fontSize: 16,
lineHeight: 24,
textAlign: 'center',
paddingHorizontal: 10,
},
animationContainer: {
alignItems: 'center',
justifyContent: 'center',
height: 200,
width: 200,
flex: 1,
},
waveRing: {
position: 'absolute',
width: 160,
height: 160,
borderRadius: 80,
backgroundColor: 'rgba(123, 104, 238, 0.1)',
borderWidth: 1,
borderColor: 'rgba(123, 104, 238, 0.2)',
},
recordButton: {
width: 160,
height: 160,
borderRadius: 80,
alignItems: 'center',
justifyContent: 'center',
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 8,
},
shadowOpacity: 0.3,
shadowRadius: 12,
elevation: 12,
},
recordButtonInner: {
width: '100%',
height: '100%',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 80,
},
statusContainer: {
alignItems: 'center',
paddingHorizontal: 20,
},
statusText: {
fontSize: 18,
fontWeight: '600',
marginBottom: 8,
},
hintText: {
fontSize: 14,
textAlign: 'center',
lineHeight: 20,
},
examplesContainer: {
marginTop: 24,
marginHorizontal: 20,
borderRadius: 16,
overflow: 'hidden',
},
examplesContent: {
padding: 20,
backgroundColor: 'rgba(255, 255, 255, 0.9)',
borderRadius: 16,
alignItems: 'center',
},
examplesTitle: {
fontSize: 16,
fontWeight: '600',
marginBottom: 16,
},
examplesList: {
paddingHorizontal: 10,
gap: 8,
},
exampleText: {
fontSize: 14,
lineHeight: 22,
textAlign: 'center',
paddingHorizontal: 12,
paddingVertical: 8,
marginVertical: 4,
},
resultContainer: {
position: 'absolute',
bottom: 100,
left: 20,
right: 20,
borderRadius: 16,
overflow: 'hidden',
},
resultContent: {
padding: 20,
backgroundColor: 'rgba(255, 255, 255, 0.9)',
borderRadius: 16,
},
resultLabel: {
fontSize: 12,
fontWeight: '500',
marginBottom: 8,
},
resultText: {
fontSize: 16,
fontWeight: '600',
lineHeight: 24,
marginBottom: 16,
},
resultActions: {
flexDirection: 'row',
gap: 12,
},
actionButton: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 12,
paddingHorizontal: 16,
borderRadius: 12,
gap: 6,
},
retryButton: {
backgroundColor: 'rgba(123, 104, 238, 0.1)',
borderWidth: 1,
borderColor: '#7B68EE',
},
confirmButton: {
backgroundColor: '#7B68EE',
},
retryButtonText: {
fontSize: 14,
fontWeight: '500',
color: '#7B68EE',
},
confirmButtonText: {
fontSize: 14,
fontWeight: '500',
color: 'white',
},
// 科幻分析特效样式
glowRing: {
position: 'absolute',
width: 200,
height: 200,
borderRadius: 100,
backgroundColor: 'rgba(0, 212, 170, 0.1)',
borderWidth: 2,
borderColor: '#00D4AA',
shadowColor: '#00D4AA',
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.8,
shadowRadius: 20,
},
innerGlowRing: {
position: 'absolute',
width: 180,
height: 180,
borderRadius: 90,
backgroundColor: 'rgba(0, 212, 170, 0.05)',
borderWidth: 1,
borderColor: 'rgba(0, 212, 170, 0.3)',
},
analysisProgressContainer: {
alignItems: 'center',
marginTop: 20,
paddingHorizontal: 20,
},
progressText: {
fontSize: 16,
fontWeight: '600',
marginBottom: 12,
},
progressBarContainer: {
width: '100%',
height: 6,
backgroundColor: 'rgba(0, 212, 170, 0.2)',
borderRadius: 3,
overflow: 'hidden',
marginBottom: 8,
},
progressBar: {
height: '100%',
backgroundColor: '#00D4AA',
borderRadius: 3,
shadowColor: '#00D4AA',
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.8,
shadowRadius: 4,
},
analysisHint: {
fontSize: 14,
textAlign: 'center',
lineHeight: 20,
fontStyle: 'italic',
},
});

View File

@@ -1,8 +1,8 @@
import { Colors } from '@/constants/Colors';
import { useAuthGuard } from '@/hooks/useAuthGuard';
import { useColorScheme } from '@/hooks/useColorScheme';
import { useWaterDataByDate } from '@/hooks/useWaterData';
import { getQuickWaterAmount, setQuickWaterAmount } from '@/utils/userPreferences';
import { WaterNotificationHelpers } from '@/utils/notificationHelpers';
import { getQuickWaterAmount, getWaterReminderSettings, setWaterReminderSettings as saveWaterReminderSettings, setQuickWaterAmount } from '@/utils/userPreferences';
import { Ionicons } from '@expo/vector-icons';
import { Picker } from '@react-native-picker/picker';
import { Image } from 'expo-image';
@@ -17,6 +17,7 @@ import {
Pressable,
ScrollView,
StyleSheet,
Switch,
Text,
TouchableOpacity,
View
@@ -26,85 +27,32 @@ import { Swipeable } from 'react-native-gesture-handler';
import { HeaderBar } from '@/components/ui/HeaderBar';
import dayjs from 'dayjs';
interface WaterSettingsProps {
interface WaterDetailProps {
selectedDate?: string;
}
const WaterSettings: React.FC<WaterSettingsProps> = () => {
const WaterDetail: React.FC<WaterDetailProps> = () => {
const { selectedDate } = useLocalSearchParams<{ selectedDate?: string }>();
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme];
const { ensureLoggedIn } = useAuthGuard();
const [dailyGoal, setDailyGoal] = useState<string>('2000');
const [quickAddAmount, setQuickAddAmount] = useState<string>('250');
// 编辑弹窗状态
const [goalModalVisible, setGoalModalVisible] = useState(false);
const [quickAddModalVisible, setQuickAddModalVisible] = useState(false);
// 临时选中值
const [tempGoal, setTempGoal] = useState<number>(parseInt(dailyGoal));
const [tempQuickAdd, setTempQuickAdd] = useState<number>(parseInt(quickAddAmount));
// Remove modal states as they are now in separate settings page
// 使用新的 hook 来处理指定日期的饮水数据
const { waterRecords, dailyWaterGoal, updateWaterGoal, removeWaterRecord } = useWaterDataByDate(selectedDate);
// 检查登录状态
useEffect(() => {
const checkLoginStatus = async () => {
const isLoggedIn = await ensureLoggedIn();
if (!isLoggedIn) {
// 如果未登录,用户会被重定向到登录页面
return;
}
// 处理设置按钮点击 - 跳转到设置页面
const handleSettingsPress = () => {
router.push('/water/settings');
};
checkLoginStatus();
}, [ensureLoggedIn]);
const goalPresets = [1500, 2000, 2500, 3000, 3500, 4000];
const quickAddPresets = [100, 150, 200, 250, 300, 350, 400, 500];
// 打开饮水目标弹窗时初始化临时值
const openGoalModal = () => {
setTempGoal(parseInt(dailyGoal));
setGoalModalVisible(true);
};
// 打开快速添加弹窗时初始化临时值
const openQuickAddModal = () => {
setTempQuickAdd(parseInt(quickAddAmount));
setQuickAddModalVisible(true);
};
// 处理饮水目标确认
const handleGoalConfirm = async () => {
setDailyGoal(tempGoal.toString());
setGoalModalVisible(false);
try {
const success = await updateWaterGoal(tempGoal);
if (!success) {
Alert.alert('设置失败', '无法保存饮水目标,请重试');
}
} catch {
Alert.alert('设置失败', '无法保存饮水目标,请重试');
}
};
// 处理快速添加默认值确认
const handleQuickAddConfirm = async () => {
setQuickAddAmount(tempQuickAdd.toString());
setQuickAddModalVisible(false);
try {
await setQuickWaterAmount(tempQuickAdd);
} catch {
Alert.alert('设置失败', '无法保存快速添加默认值,请重试');
}
};
// Remove all modal-related functions as they are now in separate settings page
// 删除饮水记录
@@ -131,15 +79,6 @@ const WaterSettings: React.FC<WaterSettingsProps> = () => {
loadUserPreferences();
}, [dailyWaterGoal]);
// 当dailyGoal或quickAddAmount更新时同步更新临时状态
useEffect(() => {
setTempGoal(parseInt(dailyGoal));
}, [dailyGoal]);
useEffect(() => {
setTempQuickAdd(parseInt(quickAddAmount));
}, [quickAddAmount]);
// 新增:饮水记录卡片组件
const WaterRecordCard = ({ record, onDelete }: { record: any; onDelete: () => void }) => {
const swipeableRef = React.useRef<Swipeable>(null);
@@ -233,11 +172,20 @@ const WaterSettings: React.FC<WaterSettingsProps> = () => {
<View style={styles.decorativeCircle2} />
<HeaderBar
title="饮水设置"
title="饮水详情"
onBack={() => {
// 这里会通过路由自动处理返回
router.back();
}}
right={
<TouchableOpacity
style={styles.settingsButton}
onPress={handleSettingsPress}
activeOpacity={0.7}
>
<Ionicons name="settings-outline" size={24} color={colorTokens.text} />
</TouchableOpacity>
}
/>
<KeyboardAvoidingView
@@ -249,44 +197,6 @@ const WaterSettings: React.FC<WaterSettingsProps> = () => {
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
>
{/* 第一部分:饮水配置 */}
<View style={styles.section}>
<Text style={[styles.sectionTitle, { color: colorTokens.text }]}></Text>
{/* 设置目标部分 */}
<TouchableOpacity
style={[styles.settingRow, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}
onPress={openGoalModal}
activeOpacity={0.8}
>
<View style={styles.settingLeft}>
<Text style={[styles.settingTitle, { color: colorTokens.text }]}></Text>
<Text style={[styles.settingValue, { color: colorTokens.textSecondary }]}>{dailyGoal}ml</Text>
</View>
<View style={styles.settingRight}>
<Ionicons name="chevron-forward" size={16} color={colorTokens.icon} />
</View>
</TouchableOpacity>
{/* 快速添加默认值设置部分 */}
<TouchableOpacity
style={[styles.settingRow, { backgroundColor: colorTokens.pageBackgroundEmphasis, marginTop: 24 }]}
onPress={openQuickAddModal}
activeOpacity={0.8}
>
<View style={styles.settingLeft}>
<Text style={[styles.settingTitle, { color: colorTokens.text }]}></Text>
<Text style={[styles.settingSubtitle, { color: colorTokens.textSecondary }]}>
{`设置点击右上角"+"按钮时添加的默认饮水量`}
</Text>
<Text style={[styles.settingValue, { color: colorTokens.textSecondary }]}>{quickAddAmount}ml</Text>
</View>
<View style={styles.settingRight}>
<Ionicons name="chevron-forward" size={16} color={colorTokens.icon} />
</View>
</TouchableOpacity>
</View>
{/* 第二部分:饮水记录 */}
<View style={styles.section}>
@@ -325,83 +235,7 @@ const WaterSettings: React.FC<WaterSettingsProps> = () => {
</ScrollView>
</KeyboardAvoidingView>
{/* 饮水目标编辑弹窗 */}
<Modal
visible={goalModalVisible}
transparent
animationType="fade"
onRequestClose={() => setGoalModalVisible(false)}
>
<Pressable style={styles.modalBackdrop} onPress={() => setGoalModalVisible(false)} />
<View style={styles.modalSheet}>
<View style={styles.modalHandle} />
<Text style={[styles.modalTitle, { color: colorTokens.text }]}></Text>
<View style={styles.pickerContainer}>
<Picker
selectedValue={tempGoal}
onValueChange={(value) => setTempGoal(value)}
style={styles.picker}
>
{Array.from({ length: 96 }, (_, i) => 500 + i * 100).map(goal => (
<Picker.Item key={goal} label={`${goal}ml`} value={goal} />
))}
</Picker>
</View>
<View style={styles.modalActions}>
<Pressable
onPress={() => setGoalModalVisible(false)}
style={[styles.modalBtn, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}
>
<Text style={[styles.modalBtnText, { color: colorTokens.text }]}></Text>
</Pressable>
<Pressable
onPress={handleGoalConfirm}
style={[styles.modalBtn, styles.modalBtnPrimary, { backgroundColor: colorTokens.primary }]}
>
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary, { color: colorTokens.onPrimary }]}></Text>
</Pressable>
</View>
</View>
</Modal>
{/* 快速添加默认值编辑弹窗 */}
<Modal
visible={quickAddModalVisible}
transparent
animationType="fade"
onRequestClose={() => setQuickAddModalVisible(false)}
>
<Pressable style={styles.modalBackdrop} onPress={() => setQuickAddModalVisible(false)} />
<View style={styles.modalSheet}>
<View style={styles.modalHandle} />
<Text style={[styles.modalTitle, { color: colorTokens.text }]}></Text>
<View style={styles.pickerContainer}>
<Picker
selectedValue={tempQuickAdd}
onValueChange={(value) => setTempQuickAdd(value)}
style={styles.picker}
>
{Array.from({ length: 41 }, (_, i) => 50 + i * 10).map(amount => (
<Picker.Item key={amount} label={`${amount}ml`} value={amount} />
))}
</Picker>
</View>
<View style={styles.modalActions}>
<Pressable
onPress={() => setQuickAddModalVisible(false)}
style={[styles.modalBtn, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}
>
<Text style={[styles.modalBtnText, { color: colorTokens.text }]}></Text>
</Pressable>
<Pressable
onPress={handleQuickAddConfirm}
style={[styles.modalBtn, styles.modalBtnPrimary, { backgroundColor: colorTokens.primary }]}
>
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary, { color: colorTokens.onPrimary }]}></Text>
</Pressable>
</View>
</View>
</Modal>
{/* All modals have been moved to the separate water-settings page */}
</View>
);
};
@@ -466,79 +300,6 @@ const styles = StyleSheet.create({
fontWeight: '400',
lineHeight: 18,
},
input: {
borderRadius: 12,
paddingHorizontal: 16,
paddingVertical: 14,
fontSize: 16,
fontWeight: '500',
marginBottom: 16,
},
settingRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 16,
paddingHorizontal: 16,
borderRadius: 12,
marginBottom: 16,
},
settingLeft: {
flex: 1,
},
settingTitle: {
fontSize: 16,
fontWeight: '500',
marginBottom: 4,
},
settingSubtitle: {
fontSize: 14,
marginBottom: 8,
},
settingValue: {
fontSize: 16,
},
settingRight: {
marginLeft: 12,
},
quickAmountsContainer: {
marginBottom: 15,
},
quickAmountsWrapper: {
flexDirection: 'row',
gap: 10,
paddingRight: 10,
},
quickAmountButton: {
paddingHorizontal: 20,
paddingVertical: 8,
borderRadius: 20,
minWidth: 70,
alignItems: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
elevation: 1,
},
quickAmountText: {
fontSize: 15,
fontWeight: '500',
},
saveButton: {
paddingVertical: 14,
borderRadius: 12,
alignItems: 'center',
marginTop: 24,
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.25,
shadowRadius: 4,
elevation: 3,
},
saveButtonText: {
fontSize: 16,
fontWeight: '700',
},
// 饮水记录相关样式
recordsList: {
gap: 12,
@@ -714,6 +475,225 @@ const styles = StyleSheet.create({
modalBtnTextPrimary: {
// color will be set dynamically
},
settingsButton: {
width: 32,
height: 32,
alignItems: 'center',
justifyContent: 'center',
},
settingsModalSheet: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
padding: 16,
backgroundColor: '#FFFFFF',
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
shadowColor: '#000000',
shadowOffset: { width: 0, height: -2 },
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 16,
},
settingsModalTitle: {
fontSize: 18,
fontWeight: '600',
textAlign: 'center',
marginBottom: 20,
},
settingsMenuContainer: {
backgroundColor: '#FFFFFF',
borderRadius: 12,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 4,
elevation: 2,
overflow: 'hidden',
},
settingsMenuItem: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 14,
paddingHorizontal: 16,
borderBottomWidth: 1,
borderBottomColor: '#F1F3F4',
},
settingsMenuItemLeft: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
},
settingsIconContainer: {
width: 32,
height: 32,
borderRadius: 6,
alignItems: 'center',
justifyContent: 'center',
marginRight: 12,
},
settingsMenuItemContent: {
flex: 1,
},
settingsMenuItemTitle: {
fontSize: 15,
fontWeight: '500',
marginBottom: 2,
},
settingsMenuItemSubtitle: {
fontSize: 12,
marginBottom: 4,
},
settingsMenuItemValue: {
fontSize: 14,
},
// 喝水提醒配置弹窗样式
waterReminderModalSheet: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
padding: 16,
backgroundColor: '#FFFFFF',
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
maxHeight: '80%',
shadowColor: '#000000',
shadowOffset: { width: 0, height: -2 },
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 16,
},
waterReminderContent: {
flex: 1,
marginBottom: 20,
},
waterReminderSection: {
marginBottom: 24,
},
waterReminderSectionHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 8,
},
waterReminderSectionTitleContainer: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
waterReminderSectionTitle: {
fontSize: 16,
fontWeight: '600',
},
waterReminderSectionDesc: {
fontSize: 14,
lineHeight: 20,
marginTop: 4,
},
timeRangeContainer: {
flexDirection: 'row',
gap: 16,
marginTop: 16,
},
timePickerContainer: {
flex: 1,
},
timeLabel: {
fontSize: 14,
fontWeight: '500',
marginBottom: 8,
},
timePicker: {
paddingVertical: 12,
paddingHorizontal: 16,
borderRadius: 8,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
},
timePickerText: {
fontSize: 16,
fontWeight: '500',
},
timePickerIcon: {
opacity: 0.6,
},
intervalContainer: {
marginTop: 16,
},
intervalPickerContainer: {
backgroundColor: '#F8F9FA',
borderRadius: 8,
overflow: 'hidden',
},
intervalPicker: {
height: 120,
},
// 时间选择器弹窗样式
timePickerModalSheet: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
padding: 16,
backgroundColor: '#FFFFFF',
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
maxHeight: '60%',
shadowColor: '#000000',
shadowOffset: { width: 0, height: -2 },
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 16,
},
timePickerContent: {
flex: 1,
marginBottom: 20,
},
timePickerSection: {
marginBottom: 20,
},
timePickerLabel: {
fontSize: 16,
fontWeight: '600',
marginBottom: 12,
textAlign: 'center',
},
hourPickerContainer: {
backgroundColor: '#F8F9FA',
borderRadius: 8,
overflow: 'hidden',
},
hourPicker: {
height: 160,
},
timeRangePreview: {
backgroundColor: '#F0F8FF',
borderRadius: 8,
padding: 16,
marginTop: 16,
alignItems: 'center',
},
timeRangePreviewLabel: {
fontSize: 12,
fontWeight: '500',
marginBottom: 4,
},
timeRangePreviewText: {
fontSize: 18,
fontWeight: '600',
marginBottom: 8,
},
timeRangeWarning: {
fontSize: 12,
color: '#FF6B6B',
textAlign: 'center',
lineHeight: 18,
},
});
export default WaterSettings;
export default WaterDetail;

View File

@@ -0,0 +1,618 @@
import { Colors } from '@/constants/Colors';
import { useColorScheme } from '@/hooks/useColorScheme';
import { WaterNotificationHelpers } from '@/utils/notificationHelpers';
import { getWaterReminderSettings, setWaterReminderSettings as saveWaterReminderSettings } from '@/utils/userPreferences';
import { Ionicons } from '@expo/vector-icons';
import { Picker } from '@react-native-picker/picker';
import { LinearGradient } from 'expo-linear-gradient';
import { router } from 'expo-router';
import React, { useEffect, useState } from 'react';
import {
Alert,
KeyboardAvoidingView,
Modal,
Platform,
Pressable,
ScrollView,
StyleSheet,
Switch,
Text,
TouchableOpacity,
View
} from 'react-native';
import { HeaderBar } from '@/components/ui/HeaderBar';
const WaterReminderSettings: React.FC = () => {
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme];
const [startTimePickerVisible, setStartTimePickerVisible] = useState(false);
const [endTimePickerVisible, setEndTimePickerVisible] = useState(false);
// 喝水提醒相关状态
const [waterReminderSettings, setWaterReminderSettings] = useState({
enabled: false,
startTime: '08:00',
endTime: '22:00',
interval: 60,
});
// 时间选择器临时值
const [tempStartHour, setTempStartHour] = useState(8);
const [tempEndHour, setTempEndHour] = useState(22);
// 打开开始时间选择器
const openStartTimePicker = () => {
const currentHour = parseInt(waterReminderSettings.startTime.split(':')[0]);
setTempStartHour(currentHour);
setStartTimePickerVisible(true);
};
// 打开结束时间选择器
const openEndTimePicker = () => {
const currentHour = parseInt(waterReminderSettings.endTime.split(':')[0]);
setTempEndHour(currentHour);
setEndTimePickerVisible(true);
};
// 确认开始时间选择
const confirmStartTime = () => {
const newStartTime = `${String(tempStartHour).padStart(2, '0')}:00`;
// 检查时间合理性
if (isValidTimeRange(newStartTime, waterReminderSettings.endTime)) {
setWaterReminderSettings(prev => ({
...prev,
startTime: newStartTime
}));
setStartTimePickerVisible(false);
} else {
Alert.alert(
'时间设置提示',
'开始时间不能晚于或等于结束时间,请重新选择',
[{ text: '确定' }]
);
}
};
// 确认结束时间选择
const confirmEndTime = () => {
const newEndTime = `${String(tempEndHour).padStart(2, '0')}:00`;
// 检查时间合理性
if (isValidTimeRange(waterReminderSettings.startTime, newEndTime)) {
setWaterReminderSettings(prev => ({
...prev,
endTime: newEndTime
}));
setEndTimePickerVisible(false);
} else {
Alert.alert(
'时间设置提示',
'结束时间不能早于或等于开始时间,请重新选择',
[{ text: '确定' }]
);
}
};
// 验证时间范围是否有效
const isValidTimeRange = (startTime: string, endTime: string): boolean => {
const [startHour] = startTime.split(':').map(Number);
const [endHour] = endTime.split(':').map(Number);
// 支持跨天的情况,如果结束时间小于开始时间,认为是跨天有效的
if (endHour < startHour) {
return true; // 跨天情况,如 22:00 到 08:00
}
// 同一天内,结束时间必须大于开始时间
return endHour > startHour;
};
// 处理喝水提醒配置保存
const handleWaterReminderSave = async () => {
try {
// 保存设置到本地存储
await saveWaterReminderSettings(waterReminderSettings);
// 设置或取消通知
// 这里使用 "用户" 作为默认用户名,实际项目中应该从用户状态获取
const userName = '用户';
await WaterNotificationHelpers.scheduleCustomWaterReminders(userName, waterReminderSettings);
if (waterReminderSettings.enabled) {
const timeInfo = `${waterReminderSettings.startTime}-${waterReminderSettings.endTime}`;
const intervalInfo = `${waterReminderSettings.interval}分钟`;
Alert.alert(
'设置成功',
`喝水提醒已开启\n\n时间段${timeInfo}\n提醒间隔${intervalInfo}\n\n我们将在指定时间段内定期提醒您喝水`,
[{ text: '确定', onPress: () => router.back() }]
);
} else {
Alert.alert('设置成功', '喝水提醒已关闭', [{ text: '确定', onPress: () => router.back() }]);
}
} catch (error) {
console.error('保存喝水提醒设置失败:', error);
Alert.alert('保存失败', '无法保存喝水提醒设置,请重试');
}
};
// 加载用户偏好设置
useEffect(() => {
const loadUserPreferences = async () => {
try {
// 加载喝水提醒设置
const reminderSettings = await getWaterReminderSettings();
setWaterReminderSettings(reminderSettings);
// 初始化时间选择器临时值
const startHour = parseInt(reminderSettings.startTime.split(':')[0]);
const endHour = parseInt(reminderSettings.endTime.split(':')[0]);
setTempStartHour(startHour);
setTempEndHour(endHour);
} catch (error) {
console.error('加载用户偏好设置失败:', error);
}
};
loadUserPreferences();
}, []);
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} />
<HeaderBar
title="喝水提醒"
onBack={() => {
router.back();
}}
/>
<KeyboardAvoidingView
style={styles.keyboardAvoidingView}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<ScrollView
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
>
{/* 开启/关闭提醒 */}
<View style={styles.waterReminderSection}>
<View style={styles.waterReminderSectionHeader}>
<View style={styles.waterReminderSectionTitleContainer}>
<Ionicons name="notifications-outline" size={20} color={colorTokens.text} />
<Text style={[styles.waterReminderSectionTitle, { color: colorTokens.text }]}></Text>
</View>
<Switch
value={waterReminderSettings.enabled}
onValueChange={(enabled) => setWaterReminderSettings(prev => ({ ...prev, enabled }))}
trackColor={{ false: '#E5E5E5', true: '#3498DB' }}
thumbColor={waterReminderSettings.enabled ? '#FFFFFF' : '#FFFFFF'}
/>
</View>
<Text style={[styles.waterReminderSectionDesc, { color: colorTokens.textSecondary }]}>
</Text>
</View>
{/* 时间段设置 */}
{waterReminderSettings.enabled && (
<>
<View style={styles.waterReminderSection}>
<Text style={[styles.waterReminderSectionTitle, { color: colorTokens.text }]}></Text>
<Text style={[styles.waterReminderSectionDesc, { color: colorTokens.textSecondary }]}>
</Text>
<View style={styles.timeRangeContainer}>
{/* 开始时间 */}
<View style={styles.timePickerContainer}>
<Text style={[styles.timeLabel, { color: colorTokens.text }]}></Text>
<Pressable
style={[styles.timePicker, { backgroundColor: 'white' }]}
onPress={openStartTimePicker}
>
<Text style={[styles.timePickerText, { color: colorTokens.text }]}>{waterReminderSettings.startTime}</Text>
<Ionicons name="chevron-down" size={16} color={colorTokens.textSecondary} style={styles.timePickerIcon} />
</Pressable>
</View>
{/* 结束时间 */}
<View style={styles.timePickerContainer}>
<Text style={[styles.timeLabel, { color: colorTokens.text }]}></Text>
<Pressable
style={[styles.timePicker, { backgroundColor: 'white' }]}
onPress={openEndTimePicker}
>
<Text style={[styles.timePickerText, { color: colorTokens.text }]}>{waterReminderSettings.endTime}</Text>
<Ionicons name="chevron-down" size={16} color={colorTokens.textSecondary} style={styles.timePickerIcon} />
</Pressable>
</View>
</View>
</View>
{/* 提醒间隔设置 */}
<View style={styles.waterReminderSection}>
<Text style={[styles.waterReminderSectionTitle, { color: colorTokens.text }]}></Text>
<Text style={[styles.waterReminderSectionDesc, { color: colorTokens.textSecondary }]}>
30-120
</Text>
<View style={styles.intervalContainer}>
<View style={styles.intervalPickerContainer}>
<Picker
selectedValue={waterReminderSettings.interval}
onValueChange={(interval) => setWaterReminderSettings(prev => ({ ...prev, interval }))}
style={styles.intervalPicker}
>
{[30, 45, 60, 90, 120, 150, 180].map(interval => (
<Picker.Item key={interval} label={`${interval}分钟`} value={interval} />
))}
</Picker>
</View>
</View>
</View>
</>
)}
{/* 保存按钮 */}
<View style={styles.saveButtonContainer}>
<TouchableOpacity
style={[styles.saveButton, { backgroundColor: colorTokens.primary }]}
onPress={handleWaterReminderSave}
activeOpacity={0.8}
>
<Text style={[styles.saveButtonText, { color: colorTokens.onPrimary }]}></Text>
</TouchableOpacity>
</View>
</ScrollView>
</KeyboardAvoidingView>
{/* 开始时间选择器弹窗 */}
<Modal
visible={startTimePickerVisible}
transparent
animationType="fade"
onRequestClose={() => setStartTimePickerVisible(false)}
>
<Pressable style={styles.modalBackdrop} onPress={() => setStartTimePickerVisible(false)} />
<View style={styles.timePickerModalSheet}>
<View style={styles.modalHandle} />
<Text style={[styles.modalTitle, { color: colorTokens.text }]}></Text>
<View style={styles.timePickerContent}>
<View style={styles.timePickerSection}>
<Text style={[styles.timePickerLabel, { color: colorTokens.text }]}></Text>
<View style={styles.hourPickerContainer}>
<Picker
selectedValue={tempStartHour}
onValueChange={(hour) => setTempStartHour(hour)}
style={styles.hourPicker}
>
{Array.from({ length: 24 }, (_, i) => (
<Picker.Item key={i} label={`${String(i).padStart(2, '0')}:00`} value={i} />
))}
</Picker>
</View>
</View>
<View style={styles.timeRangePreview}>
<Text style={[styles.timeRangePreviewLabel, { color: colorTokens.textSecondary }]}></Text>
<Text style={[styles.timeRangePreviewText, { color: colorTokens.text }]}>
{String(tempStartHour).padStart(2, '0')}:00 - {waterReminderSettings.endTime}
</Text>
{!isValidTimeRange(`${String(tempStartHour).padStart(2, '0')}:00`, waterReminderSettings.endTime) && (
<Text style={styles.timeRangeWarning}> </Text>
)}
</View>
</View>
<View style={styles.modalActions}>
<Pressable
onPress={() => setStartTimePickerVisible(false)}
style={[styles.modalBtn, { backgroundColor: 'white' }]}
>
<Text style={[styles.modalBtnText, { color: colorTokens.text }]}></Text>
</Pressable>
<Pressable
onPress={confirmStartTime}
style={[styles.modalBtn, styles.modalBtnPrimary, { backgroundColor: colorTokens.primary }]}
>
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary, { color: colorTokens.onPrimary }]}></Text>
</Pressable>
</View>
</View>
</Modal>
{/* 结束时间选择器弹窗 */}
<Modal
visible={endTimePickerVisible}
transparent
animationType="fade"
onRequestClose={() => setEndTimePickerVisible(false)}
>
<Pressable style={styles.modalBackdrop} onPress={() => setEndTimePickerVisible(false)} />
<View style={styles.timePickerModalSheet}>
<View style={styles.modalHandle} />
<Text style={[styles.modalTitle, { color: colorTokens.text }]}></Text>
<View style={styles.timePickerContent}>
<View style={styles.timePickerSection}>
<Text style={[styles.timePickerLabel, { color: colorTokens.text }]}></Text>
<View style={styles.hourPickerContainer}>
<Picker
selectedValue={tempEndHour}
onValueChange={(hour) => setTempEndHour(hour)}
style={styles.hourPicker}
>
{Array.from({ length: 24 }, (_, i) => (
<Picker.Item key={i} label={`${String(i).padStart(2, '0')}:00`} value={i} />
))}
</Picker>
</View>
</View>
<View style={styles.timeRangePreview}>
<Text style={[styles.timeRangePreviewLabel, { color: colorTokens.textSecondary }]}></Text>
<Text style={[styles.timeRangePreviewText, { color: colorTokens.text }]}>
{waterReminderSettings.startTime} - {String(tempEndHour).padStart(2, '0')}:00
</Text>
{!isValidTimeRange(waterReminderSettings.startTime, `${String(tempEndHour).padStart(2, '0')}:00`) && (
<Text style={styles.timeRangeWarning}> </Text>
)}
</View>
</View>
<View style={styles.modalActions}>
<Pressable
onPress={() => setEndTimePickerVisible(false)}
style={[styles.modalBtn, { backgroundColor: 'white' }]}
>
<Text style={[styles.modalBtnText, { color: colorTokens.text }]}></Text>
</Pressable>
<Pressable
onPress={confirmEndTime}
style={[styles.modalBtn, styles.modalBtnPrimary, { backgroundColor: colorTokens.primary }]}
>
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary, { color: colorTokens.onPrimary }]}></Text>
</Pressable>
</View>
</View>
</Modal>
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
},
gradientBackground: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
},
decorativeCircle1: {
position: 'absolute',
top: 40,
right: 20,
width: 60,
height: 60,
borderRadius: 30,
backgroundColor: '#0EA5E9',
opacity: 0.1,
},
decorativeCircle2: {
position: 'absolute',
bottom: -15,
left: -15,
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: '#0EA5E9',
opacity: 0.05,
},
keyboardAvoidingView: {
flex: 1,
},
scrollView: {
flex: 1,
},
scrollContent: {
padding: 20,
},
waterReminderSection: {
marginBottom: 32,
},
waterReminderSectionHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 8,
},
waterReminderSectionTitleContainer: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
waterReminderSectionTitle: {
fontSize: 18,
fontWeight: '600',
},
waterReminderSectionDesc: {
fontSize: 14,
lineHeight: 20,
marginTop: 4,
},
timeRangeContainer: {
flexDirection: 'row',
gap: 16,
marginTop: 16,
},
timePickerContainer: {
flex: 1,
},
timeLabel: {
fontSize: 14,
fontWeight: '500',
marginBottom: 8,
},
timePicker: {
paddingVertical: 12,
paddingHorizontal: 16,
borderRadius: 8,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
},
timePickerText: {
fontSize: 16,
fontWeight: '500',
},
timePickerIcon: {
opacity: 0.6,
},
intervalContainer: {
marginTop: 16,
},
intervalPickerContainer: {
backgroundColor: 'white',
borderRadius: 8,
overflow: 'hidden',
},
intervalPicker: {
height: 200,
},
saveButtonContainer: {
marginTop: 20,
marginBottom: 40,
},
saveButton: {
paddingVertical: 16,
borderRadius: 12,
alignItems: 'center',
justifyContent: 'center',
},
saveButtonText: {
fontSize: 16,
fontWeight: '600',
},
modalBackdrop: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0,0,0,0.4)',
},
timePickerModalSheet: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
padding: 16,
backgroundColor: '#FFFFFF',
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
maxHeight: '60%',
shadowColor: '#000000',
shadowOffset: { width: 0, height: -2 },
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 16,
},
modalHandle: {
width: 36,
height: 4,
backgroundColor: '#E0E0E0',
borderRadius: 2,
alignSelf: 'center',
marginBottom: 20,
},
modalTitle: {
fontSize: 20,
fontWeight: '600',
textAlign: 'center',
marginBottom: 20,
},
timePickerContent: {
flex: 1,
marginBottom: 20,
},
timePickerSection: {
marginBottom: 20,
},
timePickerLabel: {
fontSize: 16,
fontWeight: '600',
marginBottom: 12,
textAlign: 'center',
},
hourPickerContainer: {
backgroundColor: '#F8F9FA',
borderRadius: 8,
overflow: 'hidden',
},
hourPicker: {
height: 160,
},
timeRangePreview: {
backgroundColor: '#F0F8FF',
borderRadius: 8,
padding: 16,
marginTop: 16,
alignItems: 'center',
},
timeRangePreviewLabel: {
fontSize: 12,
fontWeight: '500',
marginBottom: 4,
},
timeRangePreviewText: {
fontSize: 18,
fontWeight: '600',
marginBottom: 8,
},
timeRangeWarning: {
fontSize: 12,
color: '#FF6B6B',
textAlign: 'center',
lineHeight: 18,
},
modalActions: {
flexDirection: 'row',
justifyContent: 'flex-end',
gap: 12,
},
modalBtn: {
paddingHorizontal: 14,
paddingVertical: 10,
borderRadius: 10,
minWidth: 80,
alignItems: 'center',
},
modalBtnPrimary: {
// backgroundColor will be set dynamically
},
modalBtnText: {
fontSize: 16,
fontWeight: '600',
},
modalBtnTextPrimary: {
// color will be set dynamically
},
});
export default WaterReminderSettings;

585
app/water/settings.tsx Normal file
View File

@@ -0,0 +1,585 @@
import { Colors } from '@/constants/Colors';
import { useColorScheme } from '@/hooks/useColorScheme';
import { getQuickWaterAmount, getWaterReminderSettings, setQuickWaterAmount } from '@/utils/userPreferences';
import { Ionicons } from '@expo/vector-icons';
import { Picker } from '@react-native-picker/picker';
import { LinearGradient } from 'expo-linear-gradient';
import { router } from 'expo-router';
import React, { useCallback, useEffect, useState } from 'react';
import { useFocusEffect } from '@react-navigation/native';
import {
Alert,
KeyboardAvoidingView,
Modal,
Platform,
Pressable,
ScrollView,
StyleSheet,
Switch,
Text,
TouchableOpacity,
View
} from 'react-native';
import { HeaderBar } from '@/components/ui/HeaderBar';
const WaterSettings: React.FC = () => {
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme];
const [quickAddAmount, setQuickAddAmount] = useState<string>('250');
// 喝水提醒设置状态(用于显示当前设置)
const [waterReminderSettings, setWaterReminderSettings] = useState({
enabled: false,
startTime: '08:00',
endTime: '22:00',
interval: 60,
});
// 弹窗状态
const [goalModalVisible, setGoalModalVisible] = useState(false);
const [quickAddModalVisible, setQuickAddModalVisible] = useState(false);
// 临时选中值
const [tempGoal, setTempGoal] = useState<number>(2000);
const [tempQuickAdd, setTempQuickAdd] = useState<number>(250);
// 当前饮水目标(从本地存储获取)
const [currentWaterGoal, setCurrentWaterGoal] = useState(2000);
// 打开饮水目标弹窗时初始化临时值
const openGoalModal = () => {
setTempGoal(currentWaterGoal);
setGoalModalVisible(true);
};
// 打开快速添加弹窗时初始化临时值
const openQuickAddModal = () => {
setTempQuickAdd(parseInt(quickAddAmount));
setQuickAddModalVisible(true);
};
// 打开喝水提醒页面
const openWaterReminderSettings = () => {
router.push('/water/reminder-settings');
};
// 处理饮水目标确认
const handleGoalConfirm = async () => {
setCurrentWaterGoal(tempGoal);
setGoalModalVisible(false);
// 这里可以添加保存到本地存储或发送到后端的逻辑
Alert.alert('设置成功', `每日饮水目标已设置为 ${tempGoal}ml`);
};
// 处理快速添加默认值确认
const handleQuickAddConfirm = async () => {
setQuickAddAmount(tempQuickAdd.toString());
setQuickAddModalVisible(false);
try {
await setQuickWaterAmount(tempQuickAdd);
Alert.alert('设置成功', `快速添加默认值已设置为 ${tempQuickAdd}ml`);
} catch {
Alert.alert('设置失败', '无法保存快速添加默认值,请重试');
}
};
// 加载用户偏好设置
const loadUserPreferences = useCallback(async () => {
try {
const amount = await getQuickWaterAmount();
setQuickAddAmount(amount.toString());
setTempQuickAdd(amount);
// 加载喝水提醒设置来显示当前设置状态
const reminderSettings = await getWaterReminderSettings();
setWaterReminderSettings(reminderSettings);
} catch (error) {
console.error('加载用户偏好设置失败:', error);
}
}, []);
// 初始化加载
useEffect(() => {
loadUserPreferences();
}, [loadUserPreferences]);
// 页面聚焦时重新加载设置(从提醒设置页面返回时)
useFocusEffect(
useCallback(() => {
loadUserPreferences();
}, [loadUserPreferences])
);
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} />
<HeaderBar
title="饮水设置"
onBack={() => {
router.back();
}}
/>
<KeyboardAvoidingView
style={styles.keyboardAvoidingView}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<ScrollView
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
>
{/* 设置列表 */}
<View style={styles.section}>
<View style={styles.settingsMenuContainer}>
<TouchableOpacity style={styles.settingsMenuItem} onPress={openGoalModal}>
<View style={styles.settingsMenuItemLeft}>
<View style={[styles.settingsIconContainer, { backgroundColor: 'rgba(147, 112, 219, 0.1)' }]}>
<Ionicons name="flag-outline" size={20} color="#9370DB" />
</View>
<View style={styles.settingsMenuItemContent}>
<Text style={[styles.settingsMenuItemTitle, { color: colorTokens.text }]}></Text>
<Text style={[styles.settingsMenuItemValue, { color: colorTokens.textSecondary }]}>{currentWaterGoal}ml</Text>
</View>
</View>
<Ionicons name="chevron-forward" size={20} color="#CCCCCC" />
</TouchableOpacity>
<TouchableOpacity style={styles.settingsMenuItem} onPress={openQuickAddModal}>
<View style={styles.settingsMenuItemLeft}>
<View style={[styles.settingsIconContainer, { backgroundColor: 'rgba(147, 112, 219, 0.1)' }]}>
<Ionicons name="add-outline" size={20} color="#9370DB" />
</View>
<View style={styles.settingsMenuItemContent}>
<Text style={[styles.settingsMenuItemTitle, { color: colorTokens.text }]}></Text>
<Text style={[styles.settingsMenuItemSubtitle, { color: colorTokens.textSecondary }]}>
"+"
</Text>
<Text style={[styles.settingsMenuItemValue, { color: colorTokens.textSecondary }]}>{quickAddAmount}ml</Text>
</View>
</View>
<Ionicons name="chevron-forward" size={20} color="#CCCCCC" />
</TouchableOpacity>
<TouchableOpacity style={[styles.settingsMenuItem, { borderBottomWidth: 0 }]} onPress={openWaterReminderSettings}>
<View style={styles.settingsMenuItemLeft}>
<View style={[styles.settingsIconContainer, { backgroundColor: 'rgba(52, 152, 219, 0.1)' }]}>
<Ionicons name="notifications-outline" size={20} color="#3498DB" />
</View>
<View style={styles.settingsMenuItemContent}>
<Text style={[styles.settingsMenuItemTitle, { color: colorTokens.text }]}></Text>
<Text style={[styles.settingsMenuItemSubtitle, { color: colorTokens.textSecondary }]}>
</Text>
<Text style={[styles.settingsMenuItemValue, { color: colorTokens.textSecondary }]}>
{waterReminderSettings.enabled ? `${waterReminderSettings.startTime}-${waterReminderSettings.endTime}, 每${waterReminderSettings.interval}分钟` : '已关闭'}
</Text>
</View>
</View>
<Ionicons name="chevron-forward" size={20} color="#CCCCCC" />
</TouchableOpacity>
</View>
</View>
</ScrollView>
</KeyboardAvoidingView>
{/* 饮水目标编辑弹窗 */}
<Modal
visible={goalModalVisible}
transparent
animationType="fade"
onRequestClose={() => setGoalModalVisible(false)}
>
<Pressable style={styles.modalBackdrop} onPress={() => setGoalModalVisible(false)} />
<View style={styles.modalSheet}>
<View style={styles.modalHandle} />
<Text style={[styles.modalTitle, { color: colorTokens.text }]}></Text>
<View style={styles.pickerContainer}>
<Picker
selectedValue={tempGoal}
onValueChange={(value) => setTempGoal(value)}
style={styles.picker}
>
{Array.from({ length: 96 }, (_, i) => 500 + i * 100).map(goal => (
<Picker.Item key={goal} label={`${goal}ml`} value={goal} />
))}
</Picker>
</View>
<View style={styles.modalActions}>
<Pressable
onPress={() => setGoalModalVisible(false)}
style={[styles.modalBtn, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}
>
<Text style={[styles.modalBtnText, { color: colorTokens.text }]}></Text>
</Pressable>
<Pressable
onPress={handleGoalConfirm}
style={[styles.modalBtn, styles.modalBtnPrimary, { backgroundColor: colorTokens.primary }]}
>
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary, { color: colorTokens.onPrimary }]}></Text>
</Pressable>
</View>
</View>
</Modal>
{/* 快速添加默认值编辑弹窗 */}
<Modal
visible={quickAddModalVisible}
transparent
animationType="fade"
onRequestClose={() => setQuickAddModalVisible(false)}
>
<Pressable style={styles.modalBackdrop} onPress={() => setQuickAddModalVisible(false)} />
<View style={styles.modalSheet}>
<View style={styles.modalHandle} />
<Text style={[styles.modalTitle, { color: colorTokens.text }]}></Text>
<View style={styles.pickerContainer}>
<Picker
selectedValue={tempQuickAdd}
onValueChange={(value) => setTempQuickAdd(value)}
style={styles.picker}
>
{Array.from({ length: 41 }, (_, i) => 50 + i * 10).map(amount => (
<Picker.Item key={amount} label={`${amount}ml`} value={amount} />
))}
</Picker>
</View>
<View style={styles.modalActions}>
<Pressable
onPress={() => setQuickAddModalVisible(false)}
style={[styles.modalBtn, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}
>
<Text style={[styles.modalBtnText, { color: colorTokens.text }]}></Text>
</Pressable>
<Pressable
onPress={handleQuickAddConfirm}
style={[styles.modalBtn, styles.modalBtnPrimary, { backgroundColor: colorTokens.primary }]}
>
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary, { color: colorTokens.onPrimary }]}></Text>
</Pressable>
</View>
</View>
</Modal>
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
},
gradientBackground: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
},
decorativeCircle1: {
position: 'absolute',
top: 40,
right: 20,
width: 60,
height: 60,
borderRadius: 30,
backgroundColor: '#0EA5E9',
opacity: 0.1,
},
decorativeCircle2: {
position: 'absolute',
bottom: -15,
left: -15,
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: '#0EA5E9',
opacity: 0.05,
},
keyboardAvoidingView: {
flex: 1,
},
scrollView: {
flex: 1,
},
scrollContent: {
padding: 20,
},
section: {
marginBottom: 32,
},
settingsMenuContainer: {
backgroundColor: '#FFFFFF',
borderRadius: 12,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 4,
elevation: 2,
overflow: 'hidden',
},
settingsMenuItem: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 16,
paddingHorizontal: 16,
borderBottomWidth: 1,
borderBottomColor: '#F1F3F4',
},
settingsMenuItemLeft: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
},
settingsIconContainer: {
width: 32,
height: 32,
borderRadius: 6,
alignItems: 'center',
justifyContent: 'center',
marginRight: 12,
},
settingsMenuItemContent: {
flex: 1,
},
settingsMenuItemTitle: {
fontSize: 16,
fontWeight: '600',
marginBottom: 4,
},
settingsMenuItemSubtitle: {
fontSize: 13,
marginBottom: 6,
},
settingsMenuItemValue: {
fontSize: 14,
fontWeight: '500',
},
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,
shadowColor: '#000000',
shadowOffset: { width: 0, height: -2 },
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 16,
},
modalHandle: {
width: 36,
height: 4,
backgroundColor: '#E0E0E0',
borderRadius: 2,
alignSelf: 'center',
marginBottom: 20,
},
modalTitle: {
fontSize: 20,
fontWeight: '600',
textAlign: 'center',
marginBottom: 20,
},
pickerContainer: {
height: 200,
marginBottom: 20,
},
picker: {
height: 200,
},
modalActions: {
flexDirection: 'row',
justifyContent: 'flex-end',
gap: 12,
},
modalBtn: {
paddingHorizontal: 14,
paddingVertical: 10,
borderRadius: 10,
minWidth: 80,
alignItems: 'center',
},
modalBtnPrimary: {
// backgroundColor will be set dynamically
},
modalBtnText: {
fontSize: 16,
fontWeight: '600',
},
modalBtnTextPrimary: {
// color will be set dynamically
},
// 喝水提醒配置弹窗样式
waterReminderModalSheet: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
padding: 16,
backgroundColor: '#FFFFFF',
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
maxHeight: '80%',
shadowColor: '#000000',
shadowOffset: { width: 0, height: -2 },
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 16,
},
waterReminderContent: {
flex: 1,
marginBottom: 20,
},
waterReminderSection: {
marginBottom: 24,
},
waterReminderSectionHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 8,
},
waterReminderSectionTitleContainer: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
waterReminderSectionTitle: {
fontSize: 16,
fontWeight: '600',
},
waterReminderSectionDesc: {
fontSize: 14,
lineHeight: 20,
marginTop: 4,
},
timeRangeContainer: {
flexDirection: 'row',
gap: 16,
marginTop: 16,
},
timePickerContainer: {
flex: 1,
},
timeLabel: {
fontSize: 14,
fontWeight: '500',
marginBottom: 8,
},
timePicker: {
paddingVertical: 12,
paddingHorizontal: 16,
borderRadius: 8,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
},
timePickerText: {
fontSize: 16,
fontWeight: '500',
},
timePickerIcon: {
opacity: 0.6,
},
intervalContainer: {
marginTop: 16,
},
intervalPickerContainer: {
backgroundColor: '#F8F9FA',
borderRadius: 8,
overflow: 'hidden',
},
intervalPicker: {
height: 120,
},
// 时间选择器弹窗样式
timePickerModalSheet: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
padding: 16,
backgroundColor: '#FFFFFF',
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
maxHeight: '60%',
shadowColor: '#000000',
shadowOffset: { width: 0, height: -2 },
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 16,
},
timePickerContent: {
flex: 1,
marginBottom: 20,
},
timePickerSection: {
marginBottom: 20,
},
timePickerLabel: {
fontSize: 16,
fontWeight: '600',
marginBottom: 12,
textAlign: 'center',
},
hourPickerContainer: {
backgroundColor: '#F8F9FA',
borderRadius: 8,
overflow: 'hidden',
},
hourPicker: {
height: 160,
},
timeRangePreview: {
backgroundColor: '#F0F8FF',
borderRadius: 8,
padding: 16,
marginTop: 16,
alignItems: 'center',
},
timeRangePreviewLabel: {
fontSize: 12,
fontWeight: '500',
marginBottom: 4,
},
timeRangePreviewText: {
fontSize: 18,
fontWeight: '600',
marginBottom: 8,
},
timeRangeWarning: {
fontSize: 12,
color: '#FF6B6B',
textAlign: 'center',
lineHeight: 18,
},
});
export default WaterSettings;

View File

@@ -1,4 +1,5 @@
import NumberKeyboard from '@/components/NumberKeyboard';
import { HeaderBar } from '@/components/ui/HeaderBar';
import { WeightRecordCard } from '@/components/weight/WeightRecordCard';
import { Colors } from '@/constants/Colors';
import { getTabBarBottomPadding } from '@/constants/TabBar';
@@ -169,21 +170,20 @@ export default function WeightRecordsPage() {
return (
<View style={styles.container}>
{/* 背景渐变 */}
<LinearGradient
colors={[themeColors.backgroundGradientStart, themeColors.backgroundGradientEnd]}
style={styles.gradient}
colors={['#F0F9FF', '#E0F2FE']}
style={styles.gradientBackground}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
>
{/* Header */}
<View style={styles.header}>
<TouchableOpacity onPress={handleGoBack} style={styles.backButton}>
<Ionicons name="chevron-back" size={24} color="#192126" />
</TouchableOpacity>
<TouchableOpacity onPress={handleAddWeight} style={styles.addButton}>
end={{ x: 1, y: 1 }}
/>
<HeaderBar
title="体重记录"
right={<TouchableOpacity onPress={handleAddWeight} style={styles.addButton}>
<Ionicons name="add" size={24} color="#192126" />
</TouchableOpacity>
</View>
</TouchableOpacity>}
/>
{/* Weight Statistics */}
<View style={styles.statsContainer}>
<View style={styles.statsRow}>
@@ -377,7 +377,6 @@ export default function WeightRecordsPage() {
</View>
</View>
</Modal>
</LinearGradient>
</View>
);
}
@@ -386,8 +385,12 @@ const styles = StyleSheet.create({
container: {
flex: 1,
},
gradient: {
flex: 1,
gradientBackground: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
},
header: {
flexDirection: 'row',

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

View File

@@ -0,0 +1,31 @@
{
"fill": "automatic",
"groups": [
{
"layers": [
{
"blend-mode": "normal",
"glass": true,
"hidden": false,
"image-name": "icon-1756312748268.jpg",
"name": "icon-1756312748268",
"opacity": 1
}
],
"shadow": {
"kind": "neutral",
"opacity": 0.5
},
"translucency": {
"enabled": true,
"value": 0.5
}
}
],
"supported-platforms": {
"circles": [
"watchOS"
],
"squares": "shared"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

View File

@@ -100,8 +100,6 @@ const ActivityHeatMap = () => {
return weeks;
}, [generateActivityData, weeksToShow]);
console.log('organizeDataByWeeks', organizeDataByWeeks);
// 获取月份标签(简化的月份标签系统)
const getMonthLabels = useMemo(() => {
@@ -186,23 +184,23 @@ const ActivityHeatMap = () => {
>
<View style={[styles.popoverContent, { backgroundColor: colors.card }]}>
<Text style={[styles.popoverTitle, { color: colors.text }]}>
AI
</Text>
<Text style={[styles.popoverSubtitle, { color: colors.text }]}>
</Text>
<View style={styles.popoverList}>
<Text style={[styles.popoverItem, { color: colors.textMuted }]}>
1. +1
1. +1
</Text>
<Text style={[styles.popoverItem, { color: colors.textMuted }]}>
2. +1
2. +1
</Text>
<Text style={[styles.popoverItem, { color: colors.textMuted }]}>
3. +1
3. +1
</Text>
<Text style={[styles.popoverItem, { color: colors.textMuted }]}>
4. +1
4. +1
</Text>
</View>
</View>

View File

@@ -1,40 +1,168 @@
import { AnimatedNumber } from '@/components/AnimatedNumber';
import React from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { ROUTES } from '@/constants/Routes';
import { useAppSelector } from '@/hooks/redux';
import { selectUserAge, selectUserProfile } from '@/store/userSlice';
import { fetchBasalEnergyBurned } from '@/utils/health';
import dayjs from 'dayjs';
import { Image } from 'expo-image';
import { router } from 'expo-router';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
interface BasalMetabolismCardProps {
value: number | null;
resetToken?: number;
selectedDate?: Date;
style?: any;
}
export function BasalMetabolismCard({ value, resetToken, style }: BasalMetabolismCardProps) {
// 获取基础代谢状态描述
const getMetabolismStatus = () => {
if (value === null || value === 0) {
export function BasalMetabolismCard({ selectedDate, style }: BasalMetabolismCardProps) {
const [basalMetabolism, setBasalMetabolism] = useState<number | null>(null);
const [loading, setLoading] = useState(false);
// 获取用户基本信息
const userProfile = useAppSelector(selectUserProfile);
const userAge = useAppSelector(selectUserAge);
// 缓存和防抖相关
const cacheRef = useRef<Map<string, { data: number | null; timestamp: number }>>(new Map());
const loadingRef = useRef<Map<string, Promise<number | null>>>(new Map());
const CACHE_DURATION = 5 * 60 * 1000; // 5分钟缓存
// 使用 useMemo 缓存 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 fetchBasalMetabolismData = useCallback(async (date: Date): Promise<number | null> => {
const dateKey = dayjs(date).format('YYYY-MM-DD');
const now = Date.now();
// 检查缓存
const cached = cacheRef.current.get(dateKey);
if (cached && (now - cached.timestamp) < CACHE_DURATION) {
return cached.data;
}
// 检查是否已经在请求中(防止重复请求)
const existingRequest = loadingRef.current.get(dateKey);
if (existingRequest) {
return existingRequest;
}
// 创建新的请求
const request = (async () => {
try {
const options = {
startDate: dayjs(date).startOf('day').toDate().toISOString(),
endDate: dayjs(date).endOf('day').toDate().toISOString()
};
const basalEnergy = await fetchBasalEnergyBurned(options);
const result = basalEnergy || null;
// 更新缓存
cacheRef.current.set(dateKey, { data: result, timestamp: now });
return result;
} catch (error) {
console.error('BasalMetabolismCard: 获取基础代谢数据失败:', error);
return null;
} finally {
// 清理请求记录
loadingRef.current.delete(dateKey);
}
})();
// 记录请求
loadingRef.current.set(dateKey, request);
return request;
}, []);
// 获取基础代谢数据
useEffect(() => {
if (!selectedDate) return;
let isCancelled = false;
const loadData = async () => {
setLoading(true);
try {
const result = await fetchBasalMetabolismData(selectedDate);
if (!isCancelled) {
setBasalMetabolism(result);
}
} finally {
if (!isCancelled) {
setLoading(false);
}
}
};
loadData();
// 清理函数,防止组件卸载后的状态更新
return () => {
isCancelled = true;
};
}, [selectedDate, fetchBasalMetabolismData]);
// 使用 useMemo 优化状态描述计算
const status = useMemo(() => {
if (basalMetabolism === null || basalMetabolism === 0) {
return { text: '未知', color: '#9AA3AE' };
}
// 基于常见的基础代谢范围来判断状态
if (value >= 1800) {
if (basalMetabolism >= 1800) {
return { text: '高代谢', color: '#10B981' };
} else if (value >= 1400) {
} else if (basalMetabolism >= 1400) {
return { text: '正常', color: '#3B82F6' };
} else if (value >= 1000) {
} else if (basalMetabolism >= 1000) {
return { text: '偏低', color: '#F59E0B' };
} else {
return { text: '较低', color: '#EF4444' };
}
};
const status = getMetabolismStatus();
}, [basalMetabolism]);
return (
<View style={[styles.container, style]}>
<>
<TouchableOpacity
style={[styles.container, style]}
onPress={() => router.push(ROUTES.BASAL_METABOLISM_DETAIL)}
activeOpacity={0.8}
>
{/* 头部区域 */}
<View style={styles.header}>
<View style={styles.leftSection}>
<Image
source={require('@/assets/images/icons/icon-fire.png')}
style={styles.titleIcon}
/>
<Text style={styles.title}></Text>
</View>
<View style={[styles.statusBadge, { backgroundColor: status.color + '20' }]}>
@@ -44,19 +172,13 @@ export function BasalMetabolismCard({ value, resetToken, style }: BasalMetabolis
{/* 数值显示区域 */}
<View style={styles.valueSection}>
{value != null && value > 0 ? (
<AnimatedNumber
value={value}
resetToken={resetToken}
style={styles.value}
format={(v) => Math.round(v).toString()}
/>
) : (
<Text style={styles.value}>--</Text>
)}
<Text style={styles.value}>
{loading ? '加载中...' : (basalMetabolism != null && basalMetabolism > 0 ? Math.round(basalMetabolism).toString() : '--')}
</Text>
<Text style={styles.unit}>/</Text>
</View>
</View>
</TouchableOpacity>
</>
);
}
@@ -117,6 +239,12 @@ const styles = StyleSheet.create({
title: {
fontSize: 14,
color: '#0F172A',
fontWeight: '600',
},
titleIcon: {
width: 16,
height: 16,
marginRight: 4,
},
statusBadge: {
paddingHorizontal: 8,

View File

@@ -1,20 +1,14 @@
import React from 'react';
import React, { useState, useCallback, useRef } from 'react';
import { StyleSheet, Text, View, TouchableOpacity } from 'react-native';
import { router } from 'expo-router';
import { useFocusEffect } from '@react-navigation/native';
import { CircularRing } from './CircularRing';
import { ROUTES } from '@/constants/Routes';
import { fetchActivityRingsForDate, ActivityRingsData } from '@/utils/health';
type FitnessRingsCardProps = {
style?: any;
// 活动卡路里数据
activeCalories?: number;
activeCaloriesGoal?: number;
// 锻炼分钟数据
exerciseMinutes?: number;
exerciseMinutesGoal?: number;
// 站立小时数据
standHours?: number;
standHoursGoal?: number;
selectedDate?: Date;
// 动画重置令牌
resetToken?: unknown;
};
@@ -24,14 +18,48 @@ type FitnessRingsCardProps = {
*/
export function FitnessRingsCard({
style,
activeCalories = 25,
activeCaloriesGoal = 350,
exerciseMinutes = 1,
exerciseMinutesGoal = 5,
standHours = 2,
standHoursGoal = 13,
selectedDate,
resetToken,
}: FitnessRingsCardProps) {
const [activityData, setActivityData] = useState<ActivityRingsData | null>(null);
const [loading, setLoading] = useState(false);
const loadingRef = useRef(false);
// 获取健身圆环数据 - 在页面聚焦、日期变化、从后台切换到前台时触发
useFocusEffect(
useCallback(() => {
const loadActivityData = async () => {
if (!selectedDate) return;
// 防止重复请求
if (loadingRef.current) return;
try {
loadingRef.current = true;
setLoading(true);
const data = await fetchActivityRingsForDate(selectedDate);
setActivityData(data);
} catch (error) {
console.error('FitnessRingsCard: 获取健身圆环数据失败:', error);
setActivityData(null);
} finally {
setLoading(false);
loadingRef.current = false;
}
};
loadActivityData();
}, [selectedDate])
);
// 使用获取到的数据或默认值
const activeCalories = activityData?.activeEnergyBurned ?? 0;
const activeCaloriesGoal = activityData?.activeEnergyBurnedGoal ?? 350;
const exerciseMinutes = activityData?.appleExerciseTime ?? 0;
const exerciseMinutesGoal = activityData?.appleExerciseTimeGoal ?? 30;
const standHours = activityData?.appleStandHours ?? 0;
const standHoursGoal = activityData?.appleStandHoursGoal ?? 12;
// 计算进度百分比
const caloriesProgress = Math.min(1, Math.max(0, activeCalories / activeCaloriesGoal));
const exerciseProgress = Math.min(1, Math.max(0, exerciseMinutes / exerciseMinutesGoal));
@@ -95,24 +123,42 @@ export function FitnessRingsCard({
<View style={styles.dataContainer}>
<View style={styles.dataRow}>
<Text style={styles.dataText}>
{loading ? (
<Text style={styles.dataValue}>--</Text>
) : (
<>
<Text style={styles.dataValue}>{Math.round(activeCalories)}</Text>
<Text style={styles.dataGoal}>/{activeCaloriesGoal}</Text>
</>
)}
</Text>
<Text style={styles.dataUnit}></Text>
</View>
<View style={styles.dataRow}>
<Text style={styles.dataText}>
{loading ? (
<Text style={styles.dataValue}>--</Text>
) : (
<>
<Text style={styles.dataValue}>{Math.round(exerciseMinutes)}</Text>
<Text style={styles.dataGoal}>/{exerciseMinutesGoal}</Text>
</>
)}
</Text>
<Text style={styles.dataUnit}></Text>
</View>
<View style={styles.dataRow}>
<Text style={styles.dataText}>
{loading ? (
<Text style={styles.dataValue}>--</Text>
) : (
<>
<Text style={styles.dataValue}>{Math.round(standHours)}</Text>
<Text style={styles.dataGoal}>/{standHoursGoal}</Text>
</>
)}
</Text>
<Text style={styles.dataUnit}></Text>
</View>

View File

@@ -1,4 +1,5 @@
import { ROUTES } from '@/constants/Routes';
import { useAuthGuard } from '@/hooks/useAuthGuard';
import { Ionicons } from '@expo/vector-icons';
import { BlurView } from 'expo-blur';
import { useRouter } from 'expo-router';
@@ -20,14 +21,21 @@ interface FloatingFoodOverlayProps {
export function FloatingFoodOverlay({ visible, onClose, mealType = 'dinner' }: FloatingFoodOverlayProps) {
const router = useRouter();
const { pushIfAuthedElseLogin } = useAuthGuard()
const handleFoodLibrary = () => {
onClose();
router.push(`${ROUTES.FOOD_LIBRARY}?mealType=${mealType}`);
pushIfAuthedElseLogin(`${ROUTES.FOOD_LIBRARY}?mealType=${mealType}`);
};
const handlePhotoRecognition = () => {
onClose();
router.push(`/food/camera?mealType=${mealType}`);
pushIfAuthedElseLogin(`/food/camera?mealType=${mealType}`);
};
const handleVoiceRecord = () => {
onClose();
pushIfAuthedElseLogin(`${ROUTES.VOICE_RECORD}?mealType=${mealType}`);
};
const menuItems = [
@@ -45,6 +53,13 @@ export function FloatingFoodOverlay({ visible, onClose, mealType = 'dinner' }: F
backgroundColor: '#FF9500',
onPress: handleFoodLibrary,
},
{
id: 'voice-record',
title: '一句话记录',
icon: '🎤',
backgroundColor: '#7B68EE',
onPress: handleVoiceRecord,
},
];
return (

View File

@@ -0,0 +1,431 @@
/**
* HealthKit测试组件
* 用于测试和演示HealthKit native module的功能
*/
import React, { useEffect, useState } from 'react';
import {
Alert,
Platform,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native';
import HealthKitManager, { HealthKitUtils, SleepDataSample } from '../utils/healthKit';
interface HealthKitTestState {
isAvailable: boolean;
isAuthorized: boolean;
sleepData: SleepDataSample[];
lastNightSleep: any;
loading: boolean;
error: string | null;
}
const HealthKitTest: React.FC = () => {
const [state, setState] = useState<HealthKitTestState>({
isAvailable: false,
isAuthorized: false,
sleepData: [],
lastNightSleep: null,
loading: false,
error: null,
});
useEffect(() => {
// 检查HealthKit可用性
const available = HealthKitUtils.isAvailable();
setState(prev => ({ ...prev, isAvailable: available }));
if (!available && Platform.OS === 'ios') {
Alert.alert('提示', 'HealthKit在当前设备上不可用可能是因为运行在模拟器上。');
}
}, []);
const handleRequestAuthorization = async () => {
if (!state.isAvailable) {
Alert.alert('错误', 'HealthKit不可用');
return;
}
setState(prev => ({ ...prev, loading: true, error: null }));
try {
const result = await HealthKitManager.requestAuthorization();
if (result.success) {
const sleepPermission = result.permissions['HKCategoryTypeIdentifierSleepAnalysis'];
const authorized = sleepPermission === 'authorized';
setState(prev => ({ ...prev, isAuthorized: authorized, loading: false }));
Alert.alert(
'授权结果',
authorized ? '已获得睡眠数据访问权限' : `睡眠数据权限状态: ${sleepPermission}`,
[{ text: '确定' }]
);
} else {
setState(prev => ({ ...prev, loading: false }));
Alert.alert('授权失败', '用户拒绝了HealthKit权限请求');
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
setState(prev => ({ ...prev, loading: false, error: errorMessage }));
Alert.alert('错误', `授权失败: ${errorMessage}`);
}
};
const handleCheckAuthorizationStatus = async () => {
if (!state.isAvailable) {
Alert.alert('错误', 'HealthKit不可用');
return;
}
setState(prev => ({ ...prev, loading: true, error: null }));
try {
const result = await HealthKitManager.getAuthorizationStatus();
if (result.success) {
const permissions = result.permissions;
const sleepPermission = permissions['HKCategoryTypeIdentifierSleepAnalysis'];
const authorized = sleepPermission === 'authorized';
setState(prev => ({ ...prev, isAuthorized: authorized, loading: false }));
const permissionDetails = Object.entries(permissions)
.map(([key, value]) => `${key}: ${value}`)
.join('\n');
Alert.alert(
'权限状态',
`当前权限状态:\n${permissionDetails}`,
[{ text: '确定' }]
);
} else {
setState(prev => ({ ...prev, loading: false }));
Alert.alert('查询失败', '无法获取权限状态');
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
setState(prev => ({ ...prev, loading: false, error: errorMessage }));
Alert.alert('错误', `查询权限状态失败: ${errorMessage}`);
}
};
const handleGetSleepData = async () => {
if (!state.isAuthorized) {
Alert.alert('错误', '请先获取HealthKit授权');
return;
}
setState(prev => ({ ...prev, loading: true, error: null }));
try {
const endDate = new Date();
const startDate = new Date();
startDate.setDate(endDate.getDate() - 7); // 获取最近7天的数据
const result = await HealthKitManager.getSleepData({
startDate: startDate.toISOString(),
endDate: endDate.toISOString(),
limit: 50,
});
setState(prev => ({
...prev,
sleepData: result.data,
loading: false,
}));
Alert.alert('成功', `获取到 ${result.count} 条睡眠记录`);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
setState(prev => ({ ...prev, loading: false, error: errorMessage }));
Alert.alert('错误', `获取睡眠数据失败: ${errorMessage}`);
}
};
const handleGetLastNightSleep = async () => {
if (!state.isAuthorized) {
Alert.alert('错误', '请先获取HealthKit授权');
return;
}
setState(prev => ({ ...prev, loading: true, error: null }));
try {
const today = new Date();
const yesterday = new Date(today);
yesterday.setDate(today.getDate() - 1);
const startDate = new Date(yesterday);
startDate.setHours(18, 0, 0, 0);
const endDate = new Date(today);
endDate.setHours(12, 0, 0, 0);
const result = await HealthKitManager.getSleepData({
startDate: startDate.toISOString(),
endDate: endDate.toISOString(),
limit: 20,
});
const sleepSamples = result.data.filter(sample =>
['asleep', 'core', 'deep', 'rem'].includes(sample.categoryType)
);
if (sleepSamples.length > 0) {
const sleepStart = new Date(Math.min(...sleepSamples.map(s => new Date(s.startDate).getTime())));
const sleepEnd = new Date(Math.max(...sleepSamples.map(s => new Date(s.endDate).getTime())));
const totalDuration = sleepSamples.reduce((sum, s) => sum + s.duration, 0);
const lastNightData = {
hasData: true,
sleepStart: sleepStart.toISOString(),
sleepEnd: sleepEnd.toISOString(),
totalDuration,
totalDurationFormatted: HealthKitUtils.formatDuration(totalDuration),
samples: sleepSamples,
bedTime: sleepStart.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }),
wakeTime: sleepEnd.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }),
};
setState(prev => ({ ...prev, lastNightSleep: lastNightData, loading: false }));
Alert.alert('昨晚睡眠', `睡眠时间: ${lastNightData.bedTime} - ${lastNightData.wakeTime}\n睡眠时长: ${lastNightData.totalDurationFormatted}`);
} else {
setState(prev => ({
...prev,
lastNightSleep: { hasData: false, message: '未找到昨晚的睡眠数据' },
loading: false
}));
Alert.alert('提示', '未找到昨晚的睡眠数据');
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
setState(prev => ({ ...prev, loading: false, error: errorMessage }));
Alert.alert('错误', `获取昨晚睡眠数据失败: ${errorMessage}`);
}
};
const renderSleepSample = (sample: SleepDataSample, index: number) => (
<View key={sample.id} style={styles.sampleItem}>
<Text style={styles.sampleTitle}> #{index + 1}</Text>
<Text style={styles.sampleText}>: {sample.categoryType}</Text>
<Text style={styles.sampleText}>: {HealthKitUtils.formatDuration(sample.duration)}</Text>
<Text style={styles.sampleText}>
: {new Date(sample.startDate).toLocaleString('zh-CN')} - {new Date(sample.endDate).toLocaleTimeString('zh-CN')}
</Text>
<Text style={styles.sampleText}>: {sample.source.name}</Text>
</View>
);
return (
<ScrollView style={styles.container}>
<Text style={styles.title}>HealthKit </Text>
{/* 状态显示 */}
<View style={styles.statusContainer}>
<Text style={styles.statusTitle}></Text>
<Text style={styles.statusText}>: {Platform.OS}</Text>
<Text style={styles.statusText}>HealthKit可用: {state.isAvailable ? '是' : '否'}</Text>
<Text style={styles.statusText}>: {state.isAuthorized ? '是' : '否'}</Text>
<Text style={styles.statusText}>: {state.sleepData.length}</Text>
{state.error && <Text style={styles.errorText}>: {state.error}</Text>}
</View>
{/* 操作按钮 */}
<View style={styles.buttonContainer}>
<TouchableOpacity
style={[styles.button, !state.isAvailable && styles.buttonDisabled]}
onPress={handleRequestAuthorization}
disabled={!state.isAvailable || state.loading}
>
<Text style={styles.buttonText}>
{state.loading ? '请求中...' : '请求HealthKit授权'}
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.button, !state.isAvailable && styles.buttonDisabled]}
onPress={handleCheckAuthorizationStatus}
disabled={!state.isAvailable || state.loading}
>
<Text style={styles.buttonText}>
{state.loading ? '查询中...' : '检查权限状态'}
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.button, (!state.isAuthorized || state.loading) && styles.buttonDisabled]}
onPress={handleGetSleepData}
disabled={!state.isAuthorized || state.loading}
>
<Text style={styles.buttonText}>
{state.loading ? '获取中...' : '获取睡眠数据(7天)'}
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.button, (!state.isAuthorized || state.loading) && styles.buttonDisabled]}
onPress={handleGetLastNightSleep}
disabled={!state.isAuthorized || state.loading}
>
<Text style={styles.buttonText}>
{state.loading ? '获取中...' : '获取昨晚睡眠'}
</Text>
</TouchableOpacity>
</View>
{/* 昨晚睡眠数据 */}
{state.lastNightSleep?.hasData && (
<View style={styles.resultContainer}>
<Text style={styles.resultTitle}></Text>
<Text style={styles.resultText}>: {state.lastNightSleep.bedTime} - {state.lastNightSleep.wakeTime}</Text>
<Text style={styles.resultText}>: {state.lastNightSleep.totalDurationFormatted}</Text>
<Text style={styles.resultText}>: {state.lastNightSleep.samples.length}</Text>
</View>
)}
{/* 睡眠数据列表 */}
{state.sleepData.length > 0 && (
<View style={styles.dataContainer}>
<Text style={styles.dataTitle}> ({state.sleepData.length})</Text>
{state.sleepData.slice(0, 10).map(renderSleepSample)}
{state.sleepData.length > 10 && (
<Text style={styles.moreText}> {state.sleepData.length - 10} ...</Text>
)}
</View>
)}
</ScrollView>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
padding: 16,
backgroundColor: '#f5f5f5',
},
title: {
fontSize: 24,
fontWeight: 'bold',
textAlign: 'center',
marginBottom: 20,
color: '#333',
},
statusContainer: {
backgroundColor: 'white',
padding: 16,
borderRadius: 8,
marginBottom: 16,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
statusTitle: {
fontSize: 18,
fontWeight: 'bold',
marginBottom: 8,
color: '#333',
},
statusText: {
fontSize: 14,
marginBottom: 4,
color: '#666',
},
errorText: {
fontSize: 14,
color: '#e74c3c',
marginTop: 8,
},
buttonContainer: {
marginBottom: 16,
},
button: {
backgroundColor: '#007AFF',
padding: 16,
borderRadius: 8,
marginBottom: 12,
alignItems: 'center',
},
buttonDisabled: {
backgroundColor: '#ccc',
},
buttonText: {
color: 'white',
fontSize: 16,
fontWeight: 'bold',
},
resultContainer: {
backgroundColor: 'white',
padding: 16,
borderRadius: 8,
marginBottom: 16,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
resultTitle: {
fontSize: 18,
fontWeight: 'bold',
marginBottom: 8,
color: '#333',
},
resultText: {
fontSize: 14,
marginBottom: 4,
color: '#666',
},
dataContainer: {
backgroundColor: 'white',
padding: 16,
borderRadius: 8,
marginBottom: 16,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
dataTitle: {
fontSize: 18,
fontWeight: 'bold',
marginBottom: 12,
color: '#333',
},
sampleItem: {
backgroundColor: '#f8f9fa',
padding: 12,
borderRadius: 6,
marginBottom: 8,
borderLeftWidth: 3,
borderLeftColor: '#007AFF',
},
sampleTitle: {
fontSize: 16,
fontWeight: 'bold',
marginBottom: 4,
color: '#333',
},
sampleText: {
fontSize: 12,
marginBottom: 2,
color: '#666',
},
moreText: {
textAlign: 'center',
fontSize: 14,
color: '#999',
fontStyle: 'italic',
marginTop: 8,
},
});
export default HealthKitTest;

View File

@@ -2,7 +2,7 @@ import { MoodCheckin, getMoodConfig } from '@/services/moodCheckins';
import dayjs from 'dayjs';
import LottieView from 'lottie-react-native';
import React, { useEffect, useRef } from 'react';
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { Image, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
interface MoodCardProps {
moodCheckin: MoodCheckin | null;
@@ -23,7 +23,13 @@ export function MoodCard({ moodCheckin, onPress }: MoodCardProps) {
return (
<TouchableOpacity onPress={onPress} style={styles.moodCardContent} >
<View style={styles.moodCardHeader}>
<View style={styles.titleContainer}>
<Image
source={require('@/assets/images/icons/icon-mood.png')}
style={styles.titleIcon}
/>
<Text style={styles.cardTitle}></Text>
</View>
<LottieView
ref={animationRef}
source={require('@/assets/lottie/mood/mood_demo.json')}
@@ -59,9 +65,22 @@ const styles = StyleSheet.create({
alignItems: 'center',
},
titleContainer: {
flexDirection: 'row',
alignItems: 'center',
},
titleIcon: {
width: 16,
height: 16,
marginRight: 6,
resizeMode: 'contain',
},
cardTitle: {
fontSize: 14,
color: '#192126',
fontWeight: '600'
},
lottieAnimation: {

View File

@@ -7,14 +7,13 @@ import {
View,
} from 'react-native';
import {
PanGestureHandler,
PanGestureHandlerGestureEvent,
Gesture,
GestureDetector,
} from 'react-native-gesture-handler';
import Animated, {
interpolate,
interpolateColor,
runOnJS,
useAnimatedGestureHandler,
useAnimatedStyle,
useSharedValue,
withSpring,
@@ -54,18 +53,18 @@ export default function MoodIntensitySlider({
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
};
const gestureHandler = useAnimatedGestureHandler<
PanGestureHandlerGestureEvent,
{ startX: number; lastValue: number }
>({
onStart: (_, context) => {
context.startX = translateX.value;
context.lastValue = value;
const startX = useSharedValue(0);
const lastValue = useSharedValue(value);
const gestureHandler = Gesture.Pan()
.onBegin(() => {
startX.value = translateX.value;
lastValue.value = value;
isDragging.value = withSpring(1);
runOnJS(triggerHaptics)();
},
onActive: (event, context) => {
const newX = context.startX + event.translationX;
})
.onUpdate((event) => {
const newX = startX.value + event.translationX;
const clampedX = Math.max(0, Math.min(sliderWidth, newX));
translateX.value = clampedX;
@@ -73,13 +72,13 @@ export default function MoodIntensitySlider({
const currentValue = Math.round((clampedX / sliderWidth) * (max - min) + min);
// 当值改变时触发震动和回调
if (currentValue !== context.lastValue) {
context.lastValue = currentValue;
if (currentValue !== lastValue.value) {
lastValue.value = currentValue;
runOnJS(triggerHaptics)();
runOnJS(onValueChange)(currentValue);
}
},
onEnd: () => {
})
.onEnd(() => {
// 计算最终值并吸附到最近的步长
const currentValue = Math.round((translateX.value / sliderWidth) * (max - min) + min);
const snapPosition = ((currentValue - min) / (max - min)) * sliderWidth;
@@ -88,7 +87,6 @@ export default function MoodIntensitySlider({
isDragging.value = withSpring(0);
runOnJS(triggerHaptics)();
runOnJS(onValueChange)(currentValue);
},
});
const thumbStyle = useAnimatedStyle(() => {
@@ -136,29 +134,6 @@ export default function MoodIntensitySlider({
};
});
// 动态颜色配置 - 根据进度变化颜色
const getProgressColors = (progress: number) => {
if (progress <= 0.25) {
return ['#22c55e', '#84cc16'] as const; // 绿色到浅绿色
} else if (progress <= 0.5) {
return ['#84cc16', '#eab308'] as const; // 浅绿色到黄色
} else if (progress <= 0.75) {
return ['#eab308', '#f97316'] as const; // 黄色到橙色
} else {
return ['#f97316', '#ef4444'] as const; // 橙色到红色
}
};
const progressColorsStyle = useAnimatedStyle(() => {
const progress = translateX.value / sliderWidth;
return {
backgroundColor: interpolateColor(
progress,
[0, 0.25, 0.5, 0.75, 1],
['#22c55e', '#84cc16', '#eab308', '#f97316', '#ef4444']
),
};
});
return (
<View style={styles.container}>
@@ -173,18 +148,19 @@ export default function MoodIntensitySlider({
/>
</View>
{/* 进度条 - 动态颜色 */}
<Animated.View style={[styles.progress, { height }, progressStyle, progressColorsStyle]}>
{/* 进度条 - 动态渐变颜色 */}
<Animated.View style={[styles.progress, { height }, progressStyle]}>
<LinearGradient
colors={getProgressColors(translateX.value / sliderWidth)}
colors={['#22c55e', '#84cc16', '#eab308', '#f97316', '#ef4444']}
locations={[0, 0.25, 0.5, 0.75, 1]}
style={[styles.progressGradient, { height }]}
start={{ x: 1, y: 0 }}
end={{ x: 0, y: 0 }}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
/>
</Animated.View>
{/* 可拖拽的thumb */}
<PanGestureHandler onGestureEvent={gestureHandler}>
<GestureDetector gesture={gestureHandler}>
<Animated.View style={[styles.thumb, { width: thumbSize, height: thumbSize }, thumbStyle]}>
{/* <LinearGradient
colors={['#ffffff', '#f8fafc']}
@@ -194,7 +170,7 @@ export default function MoodIntensitySlider({
/> */}
<Animated.View style={[styles.thumbInner, thumbInnerStyle]} />
</Animated.View>
</PanGestureHandler>
</GestureDetector>
</View>
{/* 标签 */}

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