20 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
164 changed files with 12873 additions and 5350 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,14 +2,16 @@
"expo": {
"name": "Out Live",
"slug": "digital-pilates",
"version": "1.0.12",
"version": "1.0.14",
"orientation": "portrait",
"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,
@@ -25,31 +27,23 @@
"remote-notification"
]
},
"icon": "./assets/icon.icon"
"appleTeamId": "756WVXJ6MT"
},
"plugins": [
"expo-router",
[
"expo-splash-screen",
{
"image": "./assets/icon.icon",
"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/icon.icon",
"icon": "./assets/icon.icon/Assets/icon-1756312748268.jpg",
"color": "#ffffff"
}
],
@@ -70,6 +64,9 @@
],
"experiments": {
"typedRoutes": true
},
"android": {
"package": "com.anonymous.digitalpilates"
}
}
}

View File

@@ -21,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: '个人' },
};
@@ -35,9 +35,10 @@ export default function TabLayout() {
// 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);
@@ -69,11 +70,11 @@ export default function TabLayout() {
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,
}}
>
@@ -91,7 +92,7 @@ export default function TabLayout() {
fontWeight: '600',
marginLeft: 6,
}}
numberOfLines={0 as any}
numberOfLines={1}
>
{tabConfig.title}
</Text>
@@ -148,12 +149,12 @@ export default function TabLayout() {
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',
@@ -177,7 +178,11 @@ export default function TabLayout() {
</NativeTabs.Trigger>
<NativeTabs.Trigger name="goals">
<Icon sf="flag.fill" drawable="custom_settings_drawable" />
<Label></Label>
<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" />
@@ -193,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

@@ -109,6 +109,8 @@ export default function GoalsScreen() {
const onRefresh = async () => {
setRefreshing(true);
try {
if (!isLoggedIn) return
await loadTasks();
} finally {
setRefreshing(false);
@@ -117,6 +119,8 @@ export default function GoalsScreen() {
// 加载更多任务
const handleLoadMoreTasks = async () => {
if (!isLoggedIn) return
if (tasksPagination.hasMore && !tasksLoading) {
try {
await dispatch(loadMoreTasks()).unwrap();
@@ -319,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 = '创建目标后,系统会自动生成相应的任务';
@@ -710,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

@@ -9,8 +9,6 @@ import { DEFAULT_MEMBER_NAME, fetchActivityHistory, fetchMyProfile } from '@/sto
import { getItem, setItem } from '@/utils/kvStore';
import { log } from '@/utils/logger';
import { getNotificationEnabled, setNotificationEnabled as saveNotificationEnabled } from '@/utils/userPreferences';
import { Button, Host, Text as SwiftText } from '@expo/ui/swift-ui';
import { frame, glassEffect } from '@expo/ui/swift-ui/modifiers';
import { Ionicons } from '@expo/vector-icons';
import { useFocusEffect } from '@react-navigation/native';
import { isLiquidGlassAvailable } from 'expo-glass-effect';
@@ -214,35 +212,13 @@ export default function PersonalScreen() {
<TouchableOpacity onPress={handleUserNamePress} activeOpacity={0.7}>
<Text style={styles.userName}>{displayName}</Text>
</TouchableOpacity>
{userProfile.memberNumber && (
<Text style={styles.userMemberNumber}>: {userProfile.memberNumber}</Text>
)}
</View>
{isLgAvaliable ? <Host style={{
marginRight: 18,
}}>
<Button
variant='default'
onPress={() => {
console.log(111111);
// pushIfAuthedElseLogin('/profile/edit')
}}
modifiers={[
frame({
width: 60,
height: 30,
}),
glassEffect({
glass: {
variant: 'regular',
interactive: true
}
})
]} >
<SwiftText size={14} color='black' weight={'medium'}></SwiftText>
</Button>
</Host> : <TouchableOpacity style={styles.editButton} onPress={() => pushIfAuthedElseLogin('/profile/edit')}>
<Text style={styles.editButtonText}></Text>
</TouchableOpacity>}
<TouchableOpacity style={styles.editButton} onPress={() => pushIfAuthedElseLogin('/profile/edit')}>
<Text style={styles.editButtonText}>{isLoggedIn ? '编辑' : '登录'}</Text>
</TouchableOpacity>
</View>
@@ -507,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';
@@ -13,14 +14,11 @@ import { Colors } from '@/constants/Colors';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useAuthGuard } from '@/hooks/useAuthGuard';
import { BackgroundTaskManager } from '@/services/backgroundTaskManager';
import { selectHealthDataByDate, setHealthData } from '@/store/healthSlice';
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 { fetchHealthDataForDate, testHRVDataFetch } from '@/utils/health';
import dayjs from 'dayjs';
import { LinearGradient } from 'expo-linear-gradient';
import { debounce } from 'lodash';
@@ -37,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={[
@@ -60,11 +55,6 @@ 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 = false; // 改为true来启用mock数据调试
const { pushIfAuthedElseLogin, isLoggedIn } = useAuthGuard();
@@ -83,56 +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 activeCalories = useMockData ? (mockData?.activeEnergyBurned ?? null) : (healthData?.activeEnergyBurned ?? null);
const basalMetabolism: number | null = useMockData ? (mockData?.basalEnergyBurned ?? null) : (healthData?.basalEnergyBurned ?? null);
const oxygenSaturation = useMockData ? (mockData?.oxygenSaturation ?? null) : (healthData?.oxygenSaturation ?? null);
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,
});
// 用于触发动画重置的 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();
@@ -144,7 +89,6 @@ export default function ExploreScreen() {
// 请求状态管理,防止重复请求
const loadingRef = useRef({
health: false,
nutrition: false,
mood: false
});
@@ -153,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;
};
@@ -248,13 +192,6 @@ 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);
@@ -271,9 +208,6 @@ export default function ExploreScreen() {
date: dateString,
data: {
activeCalories: data.activeEnergyBurned,
basalEnergyBurned: data.basalEnergyBurned,
hrv: data.hrv,
oxygenSaturation: data.oxygenSaturation,
heartRate: data.heartRate,
activeEnergyBurned: data.activeEnergyBurned,
activeCaloriesGoal: data.activeCaloriesGoal,
@@ -301,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) => {
@@ -348,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');
@@ -380,14 +274,6 @@ export default function ExploreScreen() {
loadAllData(currentSelectedDate);
}, [])
// 页面聚焦时的数据加载逻辑
// useFocusEffect(
// React.useCallback(() => {
// // 页面聚焦时加载数据,使用缓存机制避免频繁请求
// console.log('页面聚焦,检查是否需要刷新数据...');
// loadAllData(currentSelectedDate);
// }, [loadAllData, currentSelectedDate])
// );
// AppState 监听:应用从后台返回前台时的处理
useEffect(() => {
@@ -508,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 />
@@ -528,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')}
@@ -546,7 +423,7 @@ export default function ExploreScreen() {
<FloatingCard style={styles.masonryCard} delay={0}>
<FloatingCard style={styles.masonryCard}>
<StressMeter
curDate={currentSelectedDate}
/>
@@ -564,26 +441,20 @@ export default function ExploreScreen() {
<FloatingCard style={styles.masonryCard}>
<SleepCard
selectedDate={currentSelectedDate}
onPress={() => pushIfAuthedElseLogin(`/sleep-detail?date=${dayjs(currentSelectedDate).format('YYYY-MM-DD')}`)}
/>
</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}
@@ -592,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>
);
@@ -913,7 +784,6 @@ const styles = StyleSheet.create({
marginBottom: 16,
},
masonryContainer: {
marginBottom: 16,
flexDirection: 'row',
gap: 16,
marginTop: 6,
@@ -976,6 +846,10 @@ const styles = StyleSheet.create({
top: 0,
padding: 4,
},
circumferenceCard: {
marginBottom: 36,
marginTop: 10,
},
});

View File

@@ -14,23 +14,25 @@ import { setupQuickActions } from '@/services/quickActions';
import { initializeWaterRecordBridge } from '@/services/waterRecordBridge';
import { WaterRecordSource } from '@/services/waterRecords';
import { store } from '@/store';
import { rehydrateUserSync, setPrivacyAgreed } from '@/store/userSlice';
import { fetchMyProfile, setPrivacyAgreed } from '@/store/userSlice';
import { createWaterRecordAction } from '@/store/waterSlice';
import { ensureHealthPermissions, initializeHealthPermissions } from '@/utils/health';
import { DailySummaryNotificationHelpers, MoodNotificationHelpers, NutritionNotificationHelpers } from '@/utils/notificationHelpers';
import { clearPendingWaterRecords, syncPendingWidgetChanges } from '@/utils/widgetDataSync';
import React from 'react';
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();
@@ -38,12 +40,33 @@ function Bootstrapper({ children }: { children: React.ReactNode }) {
React.useEffect(() => {
const loadUserData = async () => {
// 数据已经在启动界面预加载,这里只需要快速同步到 Redux 状态
await dispatch(rehydrateUserSync());
setUserDataLoaded(true);
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();
@@ -102,17 +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]);
getPrivacyAgreed();
}, []);
const handlePrivacyAgree = () => {
dispatch(setPrivacyAgreed());
@@ -165,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

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

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

@@ -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;
}
};
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 handleSettingsPress = () => {
router.push('/water/settings');
};
// 打开快速添加弹窗时初始化临时值
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,67 +1,184 @@
import { AnimatedNumber } from '@/components/AnimatedNumber';
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 React from 'react';
import { StyleSheet, Text, View } from 'react-native';
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]}>
{/* 头部区域 */}
<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>
<>
<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' }]}>
<Text style={[styles.statusText, { color: status.color }]}>{status.text}</Text>
</View>
</View>
<View style={[styles.statusBadge, { backgroundColor: status.color + '20' }]}>
<Text style={[styles.statusText, { color: status.color }]}>{status.text}</Text>
</View>
</View>
{/* 数值显示区域 */}
<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.unit}>/</Text>
</View>
</View>
{/* 数值显示区域 */}
<View style={styles.valueSection}>
<Text style={styles.value}>
{loading ? '加载中...' : (basalMetabolism != null && basalMetabolism > 0 ? Math.round(basalMetabolism).toString() : '--')}
</Text>
<Text style={styles.unit}>/</Text>
</View>
</TouchableOpacity>
</>
);
}

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}>
<Text style={styles.dataValue}>{Math.round(activeCalories)}</Text>
<Text style={styles.dataGoal}>/{activeCaloriesGoal}</Text>
{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}>
<Text style={styles.dataValue}>{Math.round(exerciseMinutes)}</Text>
<Text style={styles.dataGoal}>/{exerciseMinutesGoal}</Text>
{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}>
<Text style={styles.dataValue}>{Math.round(standHours)}</Text>
<Text style={styles.dataGoal}>/{standHoursGoal}</Text>
{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,19 +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();
router.push(`${ROUTES.VOICE_RECORD}?mealType=${mealType}`);
pushIfAuthedElseLogin(`${ROUTES.VOICE_RECORD}?mealType=${mealType}`);
};
const menuItems = [

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

@@ -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,8 +87,7 @@ export default function MoodIntensitySlider({
isDragging.value = withSpring(0);
runOnJS(triggerHaptics)();
runOnJS(onValueChange)(currentValue);
},
});
});
const thumbStyle = useAnimatedStyle(() => {
const positionScale = interpolate(
@@ -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>
{/* 标签 */}

View File

@@ -1,8 +1,11 @@
import { AnimatedNumber } from '@/components/AnimatedNumber';
import { ROUTES } from '@/constants/Routes';
import { NutritionSummary } from '@/services/dietRecords';
import { useActiveCalories } from '@/hooks/useActiveCalories';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useAuthGuard } from '@/hooks/useAuthGuard';
import { fetchDailyBasalMetabolism, fetchDailyNutritionData, selectBasalMetabolismByDate, selectNutritionSummaryByDate } from '@/store/nutritionSlice';
import { triggerLightHaptic } from '@/utils/haptics';
import { NutritionGoals, calculateRemainingCalories } from '@/utils/nutrition';
import { calculateRemainingCalories } from '@/utils/nutrition';
import dayjs from 'dayjs';
import { router } from 'expo-router';
import React, { useEffect, useMemo, useRef, useState } from 'react';
@@ -13,20 +16,10 @@ const AnimatedCircle = Animated.createAnimatedComponent(Circle);
export type NutritionRadarCardProps = {
nutritionSummary: NutritionSummary | null;
/** 营养目标 */
nutritionGoals?: NutritionGoals;
/** 基础代谢消耗的卡路里 */
burnedCalories?: number;
/** 基础代谢率 */
basalMetabolism?: number;
/** 运动消耗卡路里 */
activeCalories?: number;
selectedDate?: Date;
style?: object;
/** 动画重置令牌 */
resetToken?: number;
/** 餐次点击回调 */
onMealPress?: (mealType: 'breakfast' | 'lunch' | 'dinner' | 'snack') => void;
};
// 简化的圆环进度组件
@@ -96,16 +89,47 @@ const SimpleRingProgress = ({
};
export function NutritionRadarCard({
nutritionSummary,
nutritionGoals,
burnedCalories = 1618,
basalMetabolism,
activeCalories,
selectedDate,
style,
resetToken,
onMealPress
}: NutritionRadarCardProps) {
const [currentMealType] = useState<'breakfast' | 'lunch' | 'dinner' | 'snack'>('breakfast');
const [loading, setLoading] = useState(false);
const { pushIfAuthedElseLogin } = useAuthGuard();
const dispatch = useAppDispatch();
const dateKey = useMemo(() => {
return selectedDate ? dayjs(selectedDate).format('YYYY-MM-DD') : dayjs().format('YYYY-MM-DD');
}, [selectedDate]);
// 使用专用的选择器获取营养数据和基础代谢
const nutritionSummary = useAppSelector(selectNutritionSummaryByDate(dateKey));
const basalMetabolism = useAppSelector(selectBasalMetabolismByDate(dateKey));
// 使用专用的hook获取运动消耗卡路里
const { activeCalories: effectiveActiveCalories, loading: activeCaloriesLoading } = useActiveCalories(selectedDate);
// 获取营养数据和基础代谢数据
useEffect(() => {
const loadNutritionCardData = async () => {
const targetDate = selectedDate || new Date();
try {
setLoading(true);
await Promise.all([
dispatch(fetchDailyNutritionData(targetDate)).unwrap(),
dispatch(fetchDailyBasalMetabolism(targetDate)).unwrap(),
]);
} catch (error) {
console.error('NutritionRadarCard: 获取营养卡片数据失败:', error);
} finally {
setLoading(false);
}
};
loadNutritionCardData();
}, [selectedDate, dispatch]);
const nutritionStats = useMemo(() => {
return [
@@ -121,9 +145,8 @@ export function NutritionRadarCard({
// 计算还能吃的卡路里
const consumedCalories = nutritionSummary?.totalCalories || 0;
// 使用分离的代谢和运动数据如果没有提供则从burnedCalories推算
const effectiveBasalMetabolism = basalMetabolism ?? (burnedCalories * 0.7); // 假设70%是基础代谢
const effectiveActiveCalories = activeCalories ?? (burnedCalories * 0.3); // 假设30%是运动消耗
// 使用从HealthKit获取的数据如果没有则使用默认值
const effectiveBasalMetabolism = basalMetabolism || 0; // 基础代谢默认值
const remainingCalories = calculateRemainingCalories({
basalMetabolism: effectiveBasalMetabolism,
@@ -138,7 +161,7 @@ export function NutritionRadarCard({
return (
<TouchableOpacity style={styles.card} onPress={handleNavigateToRecords} activeOpacity={0.8}>
<TouchableOpacity style={[styles.card, style]} onPress={handleNavigateToRecords} activeOpacity={0.8}>
<View style={styles.cardHeader}>
<View style={styles.titleContainer}>
<Image
@@ -147,14 +170,16 @@ export function NutritionRadarCard({
/>
<Text style={styles.cardTitle}></Text>
</View>
<Text style={styles.cardSubtitle}>: {dayjs(nutritionSummary?.updatedAt).format('MM-DD HH:mm')}</Text>
<Text style={styles.cardSubtitle}>
{loading ? '加载中...' : `更新: ${dayjs(nutritionSummary?.updatedAt).format('MM-DD HH:mm')}`}
</Text>
</View>
<View style={styles.contentContainer}>
<View style={styles.radarContainer}>
<SimpleRingProgress
remainingCalories={remainingCalories}
totalAvailable={effectiveBasalMetabolism + effectiveActiveCalories}
remainingCalories={(loading || activeCaloriesLoading) ? 0 : remainingCalories}
totalAvailable={(loading || activeCaloriesLoading) ? 0 : effectiveBasalMetabolism + effectiveActiveCalories}
/>
</View>
@@ -177,10 +202,10 @@ export function NutritionRadarCard({
<Text style={styles.calorieSubtitle}></Text>
<View style={styles.remainingCaloriesContainer}>
<AnimatedNumber
value={remainingCalories}
value={(loading || activeCaloriesLoading) ? 0 : remainingCalories}
resetToken={resetToken}
style={styles.mainValue}
format={(v) => Math.round(v).toString()}
format={(v) => (loading || activeCaloriesLoading) ? '--' : Math.round(v).toString()}
/>
<Text style={styles.calorieUnit}></Text>
</View>
@@ -189,30 +214,30 @@ export function NutritionRadarCard({
<Text style={styles.calculationLabel}></Text>
</View>
<AnimatedNumber
value={effectiveBasalMetabolism}
value={loading ? 0 : effectiveBasalMetabolism}
resetToken={resetToken}
style={styles.calculationValue}
format={(v) => Math.round(v).toString()}
format={(v) => loading ? '--' : Math.round(v).toString()}
/>
<Text style={styles.calculationText}> + </Text>
<View style={styles.calculationItem}>
<Text style={styles.calculationLabel}></Text>
</View>
<AnimatedNumber
value={effectiveActiveCalories}
value={activeCaloriesLoading ? 0 : effectiveActiveCalories}
resetToken={resetToken}
style={styles.calculationValue}
format={(v) => Math.round(v).toString()}
format={(v) => activeCaloriesLoading ? '--' : Math.round(v).toString()}
/>
<Text style={styles.calculationText}> - </Text>
<View style={styles.calculationItem}>
<Text style={styles.calculationLabel}></Text>
</View>
<AnimatedNumber
value={consumedCalories}
value={loading ? 0 : consumedCalories}
resetToken={resetToken}
style={styles.calculationValue}
format={(v) => Math.round(v).toString()}
format={(v) => loading ? '--' : Math.round(v).toString()}
/>
</View>
@@ -225,7 +250,7 @@ export function NutritionRadarCard({
style={styles.foodOptionItem}
onPress={() => {
triggerLightHaptic();
router.push(`/food/camera?mealType=${currentMealType}`);
pushIfAuthedElseLogin(`/food/camera?mealType=${currentMealType}`);
}}
activeOpacity={0.7}
>
@@ -242,7 +267,7 @@ export function NutritionRadarCard({
style={styles.foodOptionItem}
onPress={() => {
triggerLightHaptic();
router.push(`${ROUTES.FOOD_LIBRARY}?mealType=${currentMealType}`);
pushIfAuthedElseLogin(`${ROUTES.FOOD_LIBRARY}?mealType=${currentMealType}`);
}}
activeOpacity={0.7}
>
@@ -259,7 +284,7 @@ export function NutritionRadarCard({
style={styles.foodOptionItem}
onPress={() => {
triggerLightHaptic();
router.push(`${ROUTES.VOICE_RECORD}?mealType=${currentMealType}`);
pushIfAuthedElseLogin(`${ROUTES.VOICE_RECORD}?mealType=${currentMealType}`);
}}
activeOpacity={0.7}
>

View File

@@ -1,6 +1,7 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Animated,
InteractionManager,
StyleSheet,
Text,
TouchableOpacity,
@@ -33,21 +34,22 @@ const StepsCard: React.FC<StepsCardProps> = ({
const [hourlySteps, setHourSteps] = useState<HourlyStepData[]>([])
const getStepData = async (date: Date) => {
const getStepData = useCallback(async (date: Date) => {
try {
logger.info('获取步数数据...');
// 先获取步数立即更新UI
const [steps, hourly] = await Promise.all([
fetchStepCount(date),
fetchHourlyStepSamples(date)
])
setStepCount(steps)
setHourSteps(hourly)
]);
setStepCount(steps);
setHourSteps(hourly);
} catch (error) {
logger.error('获取步数数据失败:', error);
}
}
}, []);
useEffect(() => {
if (curDate) {
@@ -55,55 +57,60 @@ const StepsCard: React.FC<StepsCardProps> = ({
}
}, [curDate]);
// 为每个柱体创建独立的动画
const animatedValues = useRef(
Array.from({ length: 24 }, () => new Animated.Value(0))
).current;
// 优化:减少动画值数量,只为有数据的小时创建动画
const animatedValues = useRef<Map<number, Animated.Value>>(new Map()).current;
// 计算柱状图数据
// 优化:简化柱状图数据计算,减少计算量
const chartData = useMemo(() => {
if (!hourlySteps || hourlySteps.length === 0) {
return Array.from({ length: 24 }, (_, i) => ({ hour: i, steps: 0, height: 0 }));
}
// 找到最大步数用于计算高度比例
const maxSteps = Math.max(...hourlySteps.map(data => data.steps), 1);
const maxHeight = 20; // 柱状图最大高度(缩小一半)
// 优化:只计算有数据的小时的最大步数
const activeSteps = hourlySteps.filter(data => data.steps > 0);
if (activeSteps.length === 0) {
return Array.from({ length: 24 }, (_, i) => ({ hour: i, steps: 0, height: 0 }));
}
const maxSteps = Math.max(...activeSteps.map(data => data.steps));
const maxHeight = 20;
return hourlySteps.map(data => ({
...data,
height: maxSteps > 0 ? (data.steps / maxSteps) * maxHeight : 0
height: data.steps > 0 ? (data.steps / maxSteps) * maxHeight : 0
}));
}, [hourlySteps]);
// 获取当前小时
const currentHour = new Date().getHours();
// 触发柱体动画
// 优化延迟执行动画减少UI阻塞
useEffect(() => {
// 检查是否有实际数据(不只是空数组)
const hasData = chartData && chartData.length > 0 && chartData.some(data => data.steps > 0);
if (hasData) {
// 重置所有动画值
animatedValues.forEach(animValue => animValue.setValue(0));
// 使用 setTimeout 确保在下一个事件循环中执行动画,保证组件已完全渲染
const timeoutId = setTimeout(() => {
// 同时启动所有柱体的弹性动画,有步数的柱体才执行动画
// 使用 InteractionManager 确保动画不会阻塞用户交互
InteractionManager.runAfterInteractions(() => {
// 只为有数据的小时创建和执行动画
chartData.forEach((data, index) => {
if (data.steps > 0) {
Animated.spring(animatedValues[index], {
// 懒创建动画值
if (!animatedValues.has(index)) {
animatedValues.set(index, new Animated.Value(0));
}
const animValue = animatedValues.get(index)!;
animValue.setValue(0);
// 使用更高性能的timing动画替代spring
Animated.timing(animValue, {
toValue: 1,
tension: 150,
friction: 8,
duration: 300,
useNativeDriver: false,
}).start();
}
});
}, 50); // 添加小延迟确保渲染完成
return () => clearTimeout(timeoutId);
});
}
}, [chartData, animatedValues]);
@@ -127,17 +134,22 @@ const StepsCard: React.FC<StepsCardProps> = ({
const isActive = data.steps > 0;
const isCurrent = index <= currentHour;
// 动画变换缩放从0到实际高度
const animatedScale = animatedValues[index].interpolate({
inputRange: [0, 1],
outputRange: [0, 1],
});
// 优化:只为有数据的柱体创建动画插值
const animValue = animatedValues.get(index);
let animatedScale: Animated.AnimatedInterpolation<number> | undefined;
let animatedOpacity: Animated.AnimatedInterpolation<number> | undefined;
// 动画变换透明度从0到1
const animatedOpacity = animatedValues[index].interpolate({
inputRange: [0, 1],
outputRange: [0, 1],
});
if (animValue && isActive) {
animatedScale = animValue.interpolate({
inputRange: [0, 1],
outputRange: [0, 1],
});
animatedOpacity = animValue.interpolate({
inputRange: [0, 1],
outputRange: [0, 1],
});
}
return (
<View key={`bar-container-${index}`} style={styles.barContainer}>
@@ -160,8 +172,8 @@ const StepsCard: React.FC<StepsCardProps> = ({
{
height: data.height,
backgroundColor: isCurrent ? '#FFC365' : '#FFEBCB',
transform: [{ scaleY: animatedScale }],
opacity: animatedOpacity,
transform: animatedScale ? [{ scaleY: animatedScale }] : undefined,
opacity: animatedOpacity || 1,
}
]}
/>

View File

@@ -0,0 +1,323 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Animated,
StyleSheet,
Text,
TouchableOpacity,
View,
ViewStyle,
InteractionManager
} from 'react-native';
import { fetchHourlyStepSamples, fetchStepCount, HourlyStepData } from '@/utils/health';
import { logger } from '@/utils/logger';
import dayjs from 'dayjs';
import { Image } from 'expo-image';
import { useRouter } from 'expo-router';
import { AnimatedNumber } from './AnimatedNumber';
interface StepsCardProps {
curDate: Date
stepGoal: number;
style?: ViewStyle;
}
const StepsCardOptimized: React.FC<StepsCardProps> = ({
curDate,
style,
}) => {
const router = useRouter();
const [stepCount, setStepCount] = useState(0)
const [hourlySteps, setHourSteps] = useState<HourlyStepData[]>([])
const [isLoading, setIsLoading] = useState(false)
// 优化使用debounce减少频繁的数据获取
const debounceTimer = useRef<NodeJS.Timeout | null>(null);
const getStepData = useCallback(async (date: Date) => {
try {
setIsLoading(true);
logger.info('获取步数数据...');
// 先获取步数立即更新UI
const steps = await fetchStepCount(date);
setStepCount(steps);
// 清除之前的定时器
if (debounceTimer.current) {
clearTimeout(debounceTimer.current);
}
// 使用 InteractionManager 在空闲时获取更复杂的小时数据
InteractionManager.runAfterInteractions(async () => {
try {
const hourly = await fetchHourlyStepSamples(date);
setHourSteps(hourly);
} catch (error) {
logger.error('获取小时步数数据失败:', error);
} finally {
setIsLoading(false);
}
});
} catch (error) {
logger.error('获取步数数据失败:', error);
setIsLoading(false);
}
}, []);
useEffect(() => {
if (curDate) {
getStepData(curDate);
}
}, [curDate, getStepData]);
// 优化:减少动画值数量,只为有数据的小时创建动画
const animatedValues = useRef<Map<number, Animated.Value>>(new Map()).current;
// 优化:简化柱状图数据计算,减少计算量
const chartData = useMemo(() => {
if (!hourlySteps || hourlySteps.length === 0) {
return Array.from({ length: 24 }, (_, i) => ({ hour: i, steps: 0, height: 0 }));
}
// 优化:只计算有数据的小时的最大步数
const activeSteps = hourlySteps.filter(data => data.steps > 0);
if (activeSteps.length === 0) {
return Array.from({ length: 24 }, (_, i) => ({ hour: i, steps: 0, height: 0 }));
}
const maxSteps = Math.max(...activeSteps.map(data => data.steps));
const maxHeight = 20;
return hourlySteps.map(data => ({
...data,
height: data.steps > 0 ? (data.steps / maxSteps) * maxHeight : 0
}));
}, [hourlySteps]);
// 获取当前小时
const currentHour = new Date().getHours();
// 优化延迟执行动画减少UI阻塞
useEffect(() => {
const hasData = chartData && chartData.length > 0 && chartData.some(data => data.steps > 0);
if (hasData && !isLoading) {
// 使用 InteractionManager 确保动画不会阻塞用户交互
InteractionManager.runAfterInteractions(() => {
// 只为有数据的小时创建和执行动画
const animations = chartData
.map((data, index) => {
if (data.steps > 0) {
// 懒创建动画值
if (!animatedValues.has(index)) {
animatedValues.set(index, new Animated.Value(0));
}
const animValue = animatedValues.get(index)!;
animValue.setValue(0);
// 使用更高性能的timing动画替代spring
return Animated.timing(animValue, {
toValue: 1,
duration: 200, // 减少动画时长
useNativeDriver: false,
});
}
return null;
})
.filter(Boolean) as Animated.CompositeAnimation[];
// 批量执行动画,提高性能
if (animations.length > 0) {
Animated.stagger(50, animations).start();
}
});
}
}, [chartData, animatedValues, isLoading]);
// 优化使用React.memo包装复杂的渲染组件
const ChartBars = useMemo(() => {
return chartData.map((data, index) => {
// 判断是否是当前小时或者有活动的小时
const isActive = data.steps > 0;
const isCurrent = index <= currentHour;
// 优化:只为有数据的柱体创建动画插值
const animValue = animatedValues.get(index);
let animatedScale: Animated.AnimatedInterpolation<number> | undefined;
let animatedOpacity: Animated.AnimatedInterpolation<number> | undefined;
if (animValue && isActive) {
animatedScale = animValue.interpolate({
inputRange: [0, 1],
outputRange: [0, 1],
});
animatedOpacity = animValue.interpolate({
inputRange: [0, 1],
outputRange: [0, 1],
});
}
return (
<View key={`bar-container-${index}`} style={styles.barContainer}>
{/* 背景柱体 - 始终显示,使用相似色系的淡色 */}
<View
style={[
styles.chartBar,
{
height: 20, // 背景柱体占满整个高度
backgroundColor: isCurrent ? '#FFF4E6' : '#FFF8F0', // 更淡的相似色系
}
]}
/>
{/* 数据柱体 - 只有当有数据时才显示并执行动画 */}
{isActive && (
<Animated.View
style={[
styles.chartBar,
{
height: data.height,
backgroundColor: isCurrent ? '#FFC365' : '#FFEBCB',
transform: animatedScale ? [{ scaleY: animatedScale }] : undefined,
opacity: animatedOpacity || 1,
}
]}
/>
)}
</View>
);
});
}, [chartData, currentHour, animatedValues]);
const CardContent = () => (
<>
{/* 标题和步数显示 */}
<View style={styles.header}>
<Image
source={require('@/assets/images/icons/icon-step.png')}
style={styles.titleIcon}
/>
<Text style={styles.title}></Text>
{isLoading && <Text style={styles.loadingText}>...</Text>}
</View>
{/* 柱状图 */}
<View style={styles.chartContainer}>
<View style={styles.chartWrapper}>
<View style={styles.chartArea}>
{ChartBars}
</View>
</View>
</View>
{/* 步数和目标显示 */}
<View style={styles.statsContainer}>
<AnimatedNumber
value={stepCount || 0}
style={styles.stepCount}
format={(v) => stepCount !== null ? `${Math.round(v)}` : '——'}
resetToken={stepCount}
/>
</View>
</>
);
return (
<TouchableOpacity
style={[styles.container, style]}
onPress={() => {
// 传递当前日期参数到详情页
const dateParam = dayjs(curDate).format('YYYY-MM-DD');
router.push(`/steps/detail?date=${dateParam}`);
}}
activeOpacity={0.8}
>
<CardContent />
</TouchableOpacity>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'space-between',
borderRadius: 20,
padding: 16,
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 4,
},
shadowOpacity: 0.08,
shadowRadius: 20,
elevation: 8,
},
header: {
flexDirection: 'row',
justifyContent: 'flex-start',
alignItems: 'center',
},
titleIcon: {
width: 16,
height: 16,
marginRight: 6,
resizeMode: 'contain',
},
title: {
fontSize: 14,
color: '#192126',
fontWeight: '600'
},
loadingText: {
fontSize: 10,
color: '#666',
marginLeft: 8,
},
chartContainer: {
flex: 1,
justifyContent: 'center',
marginTop: 6
},
chartWrapper: {
width: '100%',
alignItems: 'center',
},
chartArea: {
flexDirection: 'row',
alignItems: 'flex-end',
height: 20,
width: '100%',
maxWidth: 240,
justifyContent: 'space-between',
paddingHorizontal: 4,
},
barContainer: {
width: 4,
height: 20,
alignItems: 'center',
justifyContent: 'flex-end',
position: 'relative',
},
chartBar: {
width: 4,
borderRadius: 1,
position: 'absolute',
bottom: 0,
},
statsContainer: {
alignItems: 'flex-start',
marginTop: 6
},
stepCount: {
fontSize: 18,
fontWeight: '600',
color: '#192126',
},
});
export default StepsCardOptimized;

View File

@@ -1,5 +1,4 @@
import { fetchHRVForDate } from '@/utils/health';
import dayjs from 'dayjs';
import { fetchHRVWithStatus } from '@/utils/health';
import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient';
import React, { useEffect, useState } from 'react';
@@ -11,26 +10,6 @@ interface StressMeterProps {
}
export function StressMeter({ curDate }: StressMeterProps) {
// 格式化更新时间显示
const formatUpdateTime = (date: Date): string => {
const now = dayjs();
const updateTime = dayjs(date);
const diffMinutes = now.diff(updateTime, 'minute');
const diffHours = now.diff(updateTime, 'hour');
const diffDays = now.diff(updateTime, 'day');
if (diffMinutes < 1) {
return '刚刚更新';
} else if (diffMinutes < 60) {
return `${diffMinutes}分钟前更新`;
} else if (diffHours < 24) {
return `${diffHours}小时前更新`;
} else if (diffDays < 7) {
return `${diffDays}天前更新`;
} else {
return updateTime.format('MM-DD HH:mm');
}
};
// 将HRV值转换为压力指数0-100
// HRV值范围30-110ms映射到压力指数100-0
@@ -55,13 +34,24 @@ export function StressMeter({ curDate }: StressMeterProps) {
const getHrvData = async () => {
try {
const data = await fetchHRVForDate(curDate)
console.log('StressMeter: 开始获取HRV数据...', curDate);
if (data) {
setHrvValue(data)
// 使用智能HRV数据获取功能
const result = await fetchHRVWithStatus(curDate);
console.log('StressMeter: HRV数据获取结果:', result);
if (result.hrvData) {
setHrvValue(Math.round(result.hrvData.value));
console.log(`StressMeter: 使用${result.message}HRV值: ${result.hrvData.value}ms`);
} else {
console.log('StressMeter: 未获取到HRV数据');
// 可以设置一个默认值或者显示无数据状态
setHrvValue(0);
}
} catch (error) {
console.error('StressMeter: 获取HRV数据失败:', error);
setHrvValue(0);
}
}
@@ -138,7 +128,7 @@ export function StressMeter({ curDate }: StressMeterProps) {
visible={showStressModal}
onClose={() => setShowStressModal(false)}
hrvValue={hrvValue}
// updateTime={updateTime || new Date()}
updateTime={new Date()}
/>
</>
);

View File

@@ -1,4 +1,3 @@
import { useAuthGuard } from '@/hooks/useAuthGuard';
import { useWaterDataByDate } from '@/hooks/useWaterData';
import { getQuickWaterAmount } from '@/utils/userPreferences';
import { useFocusEffect } from '@react-navigation/native';
@@ -28,8 +27,7 @@ const WaterIntakeCard: React.FC<WaterIntakeCardProps> = ({
selectedDate
}) => {
const router = useRouter();
const { ensureLoggedIn } = useAuthGuard();
const { waterStats, dailyWaterGoal, waterRecords, addWaterRecord } = useWaterDataByDate(selectedDate);
const { waterStats, dailyWaterGoal, waterRecords, addWaterRecord, getWaterRecordsByDate } = useWaterDataByDate(selectedDate);
const [quickWaterAmount, setQuickWaterAmount] = useState(150); // 默认值,将从用户偏好中加载
// 计算当前饮水量和目标
@@ -78,21 +76,25 @@ const WaterIntakeCard: React.FC<WaterIntakeCardProps> = ({
// 判断是否是今天
const isToday = selectedDate === dayjs().format('YYYY-MM-DD') || !selectedDate;
// 加载用户偏好的快速添加饮水默认值
// 页面聚焦时重新加载数据
useFocusEffect(
useCallback(() => {
const loadQuickWaterAmount = async () => {
const loadDataOnFocus = async () => {
try {
// 重新加载快速添加饮水默认值
const amount = await getQuickWaterAmount();
setQuickWaterAmount(amount);
// 重新获取水数据以刷新显示
const targetDate = selectedDate || dayjs().format('YYYY-MM-DD');
await getWaterRecordsByDate(targetDate);
} catch (error) {
console.error('加载快速添加饮水默认值失败:', error);
// 保持默认值 250ml
console.error('页面聚焦时加载数据失败:', error);
}
};
loadQuickWaterAmount();
}, [])
loadDataOnFocus();
}, [selectedDate, getWaterRecordsByDate])
);
// 触发柱体动画
@@ -123,12 +125,6 @@ const WaterIntakeCard: React.FC<WaterIntakeCardProps> = ({
// 处理添加喝水 - 右上角按钮直接添加
const handleQuickAddWater = async () => {
// 检查用户是否已登录
const isLoggedIn = await ensureLoggedIn();
if (!isLoggedIn) {
return;
}
// 触发震动反馈
if (process.env.EXPO_OS === 'ios') {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
@@ -139,31 +135,26 @@ const WaterIntakeCard: React.FC<WaterIntakeCardProps> = ({
// 使用用户配置的快速添加饮水量
const waterAmount = quickWaterAmount;
// 如果有选中日期,则为该日期添加记录;否则为今天添加记录
const recordedAt = selectedDate ? dayjs(selectedDate).toISOString() : dayjs().toISOString();
const recordedAt = dayjs().toISOString()
await addWaterRecord(waterAmount, recordedAt);
};
// 处理卡片点击 - 跳转到饮水设置页面
// 处理卡片点击 - 跳转到饮水详情页面
const handleCardPress = async () => {
// 检查用户是否已登录
const isLoggedIn = await ensureLoggedIn();
if (!isLoggedIn) {
return;
}
// 触发震动反馈
if (process.env.EXPO_OS === 'ios') {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}
// 跳转到饮水设置页面,传递选中的日期参数
// 跳转到饮水详情页面,传递选中的日期参数
router.push({
pathname: '/water-settings',
pathname: '/water/detail',
params: selectedDate ? { selectedDate } : undefined
});
};
return (
<TouchableOpacity
style={[styles.container, style]}
@@ -274,6 +265,7 @@ const WaterIntakeCard: React.FC<WaterIntakeCardProps> = ({
</Text>
</View>
</TouchableOpacity>
);
};

View File

@@ -0,0 +1,240 @@
import { FloatingSelectionModal, SelectionItem } from '@/components/ui/FloatingSelectionModal';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useAuthGuard } from '@/hooks/useAuthGuard';
import { selectUserProfile, updateUserBodyMeasurements, UserProfile } from '@/store/userSlice';
import { router } from 'expo-router';
import React, { useState } from 'react';
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
interface CircumferenceCardProps {
style?: any;
}
const CircumferenceCard: React.FC<CircumferenceCardProps> = ({ style }) => {
const dispatch = useAppDispatch();
const userProfile = useAppSelector(selectUserProfile);
console.log('userProfile', userProfile);
const { ensureLoggedIn } = useAuthGuard()
const [modalVisible, setModalVisible] = useState(false);
const [selectedMeasurement, setSelectedMeasurement] = useState<{
key: string;
label: string;
currentValue?: number;
} | null>(null);
const measurements = [
{
key: 'chestCircumference',
label: '胸围',
value: userProfile?.chestCircumference,
},
{
key: 'waistCircumference',
label: '腰围',
value: userProfile?.waistCircumference,
},
{
key: 'upperHipCircumference',
label: '上臀围',
value: userProfile?.upperHipCircumference,
},
{
key: 'armCircumference',
label: '臂围',
value: userProfile?.armCircumference,
},
{
key: 'thighCircumference',
label: '大腿围',
value: userProfile?.thighCircumference,
},
{
key: 'calfCircumference',
label: '小腿围',
value: userProfile?.calfCircumference,
},
];
// 根据不同围度类型获取合理的默认值
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]) => {
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 handleCardPress = () => {
router.push('/circumference-detail');
};
return (
<TouchableOpacity
style={[styles.container, style]}
onPress={handleCardPress}
activeOpacity={0.8}
>
<Text style={styles.title}> (cm)</Text>
<View style={styles.measurementsContainer}>
{measurements.map((measurement, index) => (
<TouchableOpacity
key={index}
style={styles.measurementItem}
onPress={(e) => {
e.stopPropagation(); // 阻止事件冒泡
handleMeasurementPress(measurement);
}}
activeOpacity={0.7}
>
<Text style={styles.label}>{measurement.label}</Text>
<View style={styles.valueContainer}>
<Text style={styles.value}>
{measurement.value ? measurement.value.toString() : '--'}
</Text>
</View>
</TouchableOpacity>
))}
</View>
<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}
/>
</TouchableOpacity>
);
};
const styles = StyleSheet.create({
container: {
backgroundColor: '#FFFFFF',
borderRadius: 16,
padding: 20,
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 4,
},
shadowOpacity: 0.12,
shadowRadius: 12,
elevation: 6,
},
title: {
fontSize: 14,
fontWeight: '600',
color: '#192126',
marginBottom: 16,
},
measurementsContainer: {
flexDirection: 'row',
justifyContent: 'space-between',
flexWrap: 'nowrap',
},
measurementItem: {
alignItems: 'center',
},
label: {
fontSize: 12,
color: '#888',
marginBottom: 8,
textAlign: 'center',
},
valueContainer: {
backgroundColor: '#F5F5F7',
borderRadius: 10,
paddingHorizontal: 8,
paddingVertical: 6,
minWidth: 20,
alignItems: 'center',
justifyContent: 'center',
},
value: {
fontSize: 14,
fontWeight: '600',
color: '#192126',
textAlign: 'center',
},
});
export default CircumferenceCard;

View File

@@ -1,32 +1,63 @@
import React from 'react';
import { StyleSheet } from 'react-native';
import React, { useState, useCallback, useRef } from 'react';
import { useFocusEffect } from '@react-navigation/native';
import HealthDataCard from './HealthDataCard';
import { fetchOxygenSaturation } from '@/utils/health';
import dayjs from 'dayjs';
interface OxygenSaturationCardProps {
resetToken: number;
style?: object;
oxygenSaturation?: number | null;
selectedDate?: Date;
}
const OxygenSaturationCard: React.FC<OxygenSaturationCardProps> = ({
resetToken,
style,
oxygenSaturation
selectedDate
}) => {
const [oxygenSaturation, setOxygenSaturation] = useState<number | null>(null);
const [loading, setLoading] = useState(false);
const loadingRef = useRef(false);
// 获取血氧饱和度数据 - 在页面聚焦、日期变化时触发
useFocusEffect(
useCallback(() => {
const loadOxygenSaturationData = async () => {
const dateToUse = selectedDate || new Date();
// 防止重复请求
if (loadingRef.current) return;
try {
loadingRef.current = true;
setLoading(true);
const options = {
startDate: dayjs(dateToUse).startOf('day').toDate().toISOString(),
endDate: dayjs(dateToUse).endOf('day').toDate().toISOString()
};
const data = await fetchOxygenSaturation(options);
setOxygenSaturation(data);
} catch (error) {
console.error('OxygenSaturationCard: 获取血氧饱和度数据失败:', error);
setOxygenSaturation(null);
} finally {
setLoading(false);
loadingRef.current = false;
}
};
loadOxygenSaturationData();
}, [selectedDate])
);
return (
<HealthDataCard
title="血氧饱和度"
value={oxygenSaturation !== null && oxygenSaturation !== undefined ? oxygenSaturation.toFixed(1) : '--'}
value={loading ? '--' : (oxygenSaturation !== null && oxygenSaturation !== undefined ? oxygenSaturation.toFixed(1) : '--')}
unit="%"
style={style}
/>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
},
});
export default OxygenSaturationCard;

View File

@@ -1,18 +1,19 @@
import { fetchCompleteSleepData, formatSleepTime } from '@/utils/sleepHealthKit';
import dayjs from 'dayjs';
import { Image } from 'expo-image';
import { router } from 'expo-router';
import React, { useEffect, useState } from 'react';
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
interface SleepCardProps {
selectedDate?: Date;
style?: object;
onPress?: () => void;
}
const SleepCard: React.FC<SleepCardProps> = ({
selectedDate,
style,
onPress
}) => {
const [sleepDuration, setSleepDuration] = useState<number | null>(null);
const [loading, setLoading] = useState(false);
@@ -52,15 +53,11 @@ const SleepCard: React.FC<SleepCardProps> = ({
</View>
);
if (onPress) {
return (
<TouchableOpacity onPress={onPress} activeOpacity={0.7}>
{CardContent}
</TouchableOpacity>
);
}
return CardContent;
return (
<TouchableOpacity onPress={() => router.push(`/sleep-detail?date=${dayjs(selectedDate).format('YYYY-MM-DD')}`)} activeOpacity={0.7}>
{CardContent}
</TouchableOpacity>
);
};
const styles = StyleSheet.create({

View File

@@ -0,0 +1,122 @@
import { Ionicons } from '@expo/vector-icons';
import { BlurView } from 'expo-blur';
import React from 'react';
import {
Modal,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native';
interface FloatingSelectionCardProps {
visible: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
}
export function FloatingSelectionCard({
visible,
onClose,
title,
children
}: FloatingSelectionCardProps) {
return (
<Modal
visible={visible}
transparent={true}
animationType="fade"
onRequestClose={onClose}
>
<BlurView intensity={20} tint="dark" style={styles.overlay}>
<TouchableOpacity
style={styles.backdrop}
activeOpacity={1}
onPress={onClose}
/>
<View style={styles.container}>
<BlurView intensity={80} tint="light" style={styles.blurContainer}>
<View style={styles.header}>
<Text style={styles.title}>{title}</Text>
</View>
<View style={styles.content}>
{children}
</View>
</BlurView>
<TouchableOpacity
style={styles.closeButton}
onPress={onClose}
activeOpacity={0.7}
>
<View style={styles.closeButtonInner}>
<Ionicons name="close" size={24} color="#666" />
</View>
</TouchableOpacity>
</View>
</BlurView>
</Modal>
);
}
const styles = StyleSheet.create({
overlay: {
flex: 1,
justifyContent: 'flex-end',
alignItems: 'center',
},
backdrop: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
},
container: {
alignItems: 'center',
marginBottom: 40,
},
blurContainer: {
borderRadius: 20,
overflow: 'hidden',
backgroundColor: 'rgba(255, 255, 255, 0.95)',
minWidth: 340,
paddingVertical: 20,
paddingHorizontal: 16,
minHeight: 100,
},
header: {
paddingBottom: 20,
alignItems: 'center',
},
title: {
fontSize: 14,
fontWeight: '600',
color: '#636161ff',
},
content: {
alignItems: 'center',
},
closeButton: {
marginTop: 20,
},
closeButtonInner: {
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: 'rgba(255, 255, 255, 0.9)',
alignItems: 'center',
justifyContent: 'center',
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
});

View File

@@ -0,0 +1,57 @@
import React from 'react';
import { FloatingSelectionCard } from './FloatingSelectionCard';
import { SlidingSelection, SelectionItem } from './SlidingSelection';
interface FloatingSelectionModalProps {
visible: boolean;
onClose: () => void;
title: string;
items: SelectionItem[];
selectedValue?: string | number;
onValueChange: (value: string | number, index: number) => void;
onConfirm?: (value: string | number, index: number) => void;
showConfirmButton?: boolean;
confirmButtonText?: string;
pickerHeight?: number;
}
export function FloatingSelectionModal({
visible,
onClose,
title,
items,
selectedValue,
onValueChange,
onConfirm,
showConfirmButton = true,
confirmButtonText = '确认',
pickerHeight = 150,
}: FloatingSelectionModalProps) {
const handleConfirm = (value: string | number, index: number) => {
if (onConfirm) {
onConfirm(value, index);
}
onClose();
};
return (
<FloatingSelectionCard
visible={visible}
onClose={onClose}
title={title}
>
<SlidingSelection
items={items}
selectedValue={selectedValue}
onValueChange={onValueChange}
onConfirm={handleConfirm}
showConfirmButton={showConfirmButton}
confirmButtonText={confirmButtonText}
height={pickerHeight}
/>
</FloatingSelectionCard>
);
}
// Export types for convenience
export type { SelectionItem } from './SlidingSelection';

View File

@@ -0,0 +1,131 @@
import React, { useState } from 'react';
import {
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native';
import WheelPickerExpo from 'react-native-wheel-picker-expo';
export interface SelectionItem {
label: string;
value: string | number;
}
interface SlidingSelectionProps {
items: SelectionItem[];
selectedValue?: string | number;
onValueChange: (value: string | number, index: number) => void;
onConfirm?: (value: string | number, index: number) => void;
showConfirmButton?: boolean;
confirmButtonText?: string;
height?: number;
itemTextStyle?: any;
selectedIndicatorStyle?: any;
}
export function SlidingSelection({
items,
selectedValue,
onValueChange,
onConfirm,
showConfirmButton = true,
confirmButtonText = '确认',
height = 150,
itemTextStyle,
selectedIndicatorStyle
}: SlidingSelectionProps) {
const [currentIndex, setCurrentIndex] = useState(() => {
if (selectedValue !== undefined) {
const index = items.findIndex(item => item.value === selectedValue);
return index >= 0 ? index : 0;
}
return 0;
});
const handleValueChange = (index: number) => {
setCurrentIndex(index);
const selectedItem = items[index];
if (selectedItem) {
onValueChange(selectedItem.value, index);
}
};
const handleConfirm = () => {
const selectedItem = items[currentIndex];
if (selectedItem && onConfirm) {
onConfirm(selectedItem.value, currentIndex);
}
};
return (
<View style={styles.container}>
<View style={[styles.pickerContainer, { height }]}>
<WheelPickerExpo
height={height}
width={300}
initialSelectedIndex={currentIndex}
items={items.map(item => ({ label: item.label, value: item.value }))}
onChange={({ item, index }) => handleValueChange(index)}
backgroundColor="transparent"
haptics
/>
</View>
{showConfirmButton && (
<TouchableOpacity
style={styles.confirmButton}
onPress={handleConfirm}
activeOpacity={0.8}
>
<Text style={styles.confirmButtonText}>{confirmButtonText}</Text>
</TouchableOpacity>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
alignItems: 'center',
width: '100%',
},
pickerContainer: {
width: '100%',
justifyContent: 'center',
alignItems: 'center',
},
picker: {
width: '100%',
height: '100%',
},
itemText: {
fontSize: 16,
color: '#333',
fontWeight: '500',
},
selectedIndicator: {
backgroundColor: 'rgba(74, 144, 226, 0.1)',
borderRadius: 8,
},
confirmButton: {
backgroundColor: '#4A90E2',
paddingHorizontal: 32,
paddingVertical: 12,
borderRadius: 20,
marginTop: 16,
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.15,
shadowRadius: 4,
elevation: 4,
},
confirmButtonText: {
color: 'white',
fontSize: 16,
fontWeight: '600',
},
});

View File

@@ -43,7 +43,9 @@ export function WeightHistoryCard() {
useEffect(() => {
loadWeightHistory();
if (isLoggedIn) {
loadWeightHistory();
}
}, [userProfile?.weight, isLoggedIn]);
const loadWeightHistory = async () => {
@@ -67,71 +69,36 @@ export function WeightHistoryCard() {
};
// 如果没有体重数据,显示引导卡片
if (!hasWeight) {
return (
<TouchableOpacity style={styles.card} onPress={navigateToWeightRecords} activeOpacity={0.8}>
<View style={styles.cardHeader}>
<Image
source={require('@/assets/images/icons/icon-weight.png')}
style={styles.iconSquare}
/>
<Text style={styles.cardTitle}></Text>
</View>
<View style={styles.emptyContent}>
<Text style={styles.emptyTitle}></Text>
<Text style={styles.emptyDescription}>
</Text>
<TouchableOpacity
style={styles.recordButton}
onPress={(e) => {
e.stopPropagation();
navigateToCoach();
}}
activeOpacity={0.8}
>
<Ionicons name="add" size={18} color="#192126" />
<Text style={styles.recordButtonText}></Text>
</TouchableOpacity>
</View>
</TouchableOpacity>
);
}
// 处理体重历史数据
const sortedHistory = [...weightHistory]
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
.slice(-7); // 只显示最近7条记录
if (sortedHistory.length === 0) {
return (
<TouchableOpacity style={styles.card} onPress={navigateToWeightRecords} activeOpacity={0.8}>
<View style={styles.cardHeader}>
<Text style={styles.cardTitle}></Text>
</View>
// return (
// <TouchableOpacity style={styles.card} onPress={navigateToWeightRecords} activeOpacity={0.8}>
// <View style={styles.cardHeader}>
// <Text style={styles.cardTitle}>体重记录</Text>
// </View>
<View style={styles.emptyContent}>
<Text style={styles.emptyDescription}>
</Text>
<TouchableOpacity
style={styles.recordButton}
onPress={(e) => {
e.stopPropagation();
navigateToCoach();
}}
activeOpacity={0.8}
>
<Ionicons name="add" size={18} color="#FFFFFF" />
<Text style={styles.recordButtonText}></Text>
</TouchableOpacity>
</View>
</TouchableOpacity>
);
}
// <View style={styles.emptyContent}>
// <Text style={styles.emptyDescription}>
// 暂无体重记录,点击下方按钮开始记录
// </Text>
// <TouchableOpacity
// style={styles.recordButton}
// onPress={(e) => {
// e.stopPropagation();
// navigateToCoach();
// }}
// activeOpacity={0.8}
// >
// <Ionicons name="add" size={18} color="#FFFFFF" />
// <Text style={styles.recordButtonText}>记录体重</Text>
// </TouchableOpacity>
// </View>
// </TouchableOpacity>
// );
// }
// 生成图表数据
const weights = sortedHistory.map(item => parseFloat(item.weight));

View File

@@ -5,6 +5,7 @@ export const ROUTES = {
TAB_COACH: '/coach',
TAB_GOALS: '/goals',
TAB_STATISTICS: '/statistics',
TAB_CHALLENGES: '/challenges',
TAB_PERSONAL: '/personal',
// 训练相关路由
@@ -45,6 +46,12 @@ export const ROUTES = {
// 健康相关路由
FITNESS_RINGS_DETAIL: '/fitness-rings-detail',
SLEEP_DETAIL: '/sleep-detail',
BASAL_METABOLISM_DETAIL: '/basal-metabolism-detail',
// 饮水相关路由
WATER_DETAIL: '/water/detail',
WATER_SETTINGS: '/water/settings',
WATER_REMINDER_SETTINGS: '/water/reminder-settings',
// 任务相关路由
TASK_DETAIL: '/task-detail',
@@ -87,4 +94,4 @@ export const QUERY_PARAMS = {
// 教练页面参数
COACH_NAME: 'name',
} as const;
} as const;

View File

@@ -0,0 +1,229 @@
# HealthKit Native Module 实现文档
本文档描述了为React Native应用添加HealthKit支持的完整实现包括授权和睡眠数据获取功能。
## 功能概述
这个native module提供了以下功能
1. **HealthKit授权** - 请求用户授权访问健康数据
2. **睡眠数据获取** - 从HealthKit读取用户的睡眠分析数据
## 文件结构
```
ios/digitalpilates/
├── HealthKitManager.swift # Swift native module实现
├── HealthKitManager.m # Objective-C桥接文件
├── digitalpilates.entitlements # HealthKit权限配置
└── Info.plist # 权限描述
utils/
├── healthKit.ts # TypeScript接口定义
├── healthKitExample.ts # 使用示例
└── health.ts # 现有健康相关工具
```
## 权限配置
### 1. Entitlements文件
`ios/digitalpilates/digitalpilates.entitlements` 已包含:
```xml
<key>com.apple.developer.healthkit</key>
<true/>
<key>com.apple.developer.healthkit.background-delivery</key>
<true/>
```
### 2. Info.plist权限描述
`ios/digitalpilates/Info.plist` 已包含:
```xml
<key>NSHealthShareUsageDescription</key>
<string>应用需要访问您的健康数据(步数、能量消耗、心率变异性等)以展示运动统计和压力分析。</string>
<key>NSHealthUpdateUsageDescription</key>
<string>应用需要更新您的健康数据(体重信息)以记录您的健身进度。</string>
```
## Swift实现详情
### HealthKitManager.swift
核心功能实现:
#### 授权方法
```swift
@objc func requestAuthorization(
_ resolver: @escaping RCTPromiseResolveBlock,
rejecter: @escaping RCTPromiseRejectBlock
)
```
请求的权限包括:
- 睡眠分析 (SleepAnalysis)
- 步数 (StepCount)
- 心率 (HeartRate)
- 静息心率 (RestingHeartRate)
- 心率变异性 (HeartRateVariabilitySDNN)
- 活动能量消耗 (ActiveEnergyBurned)
- 体重 (BodyMass) - 写入权限
#### 睡眠数据获取方法
```swift
@objc func getSleepData(
_ options: NSDictionary,
resolver: @escaping RCTPromiseResolveBlock,
rejecter: @escaping RCTPromiseRejectBlock
)
```
支持的睡眠阶段:
- `inBed` - 在床上
- `asleep` - 睡眠(未分类)
- `awake` - 清醒
- `core` - 核心睡眠
- `deep` - 深度睡眠
- `rem` - REM睡眠
## TypeScript接口
### 主要接口
```typescript
interface HealthKitManagerInterface {
requestAuthorization(): Promise<HealthKitAuthorizationResult>;
getSleepData(options?: SleepDataOptions): Promise<SleepDataResult>;
}
```
### 数据类型
```typescript
interface SleepDataSample {
id: string;
startDate: string; // ISO8601格式
endDate: string; // ISO8601格式
value: number;
categoryType: 'inBed' | 'asleep' | 'awake' | 'core' | 'deep' | 'rem' | 'unknown';
duration: number; // 持续时间(秒)
source: SleepDataSource;
metadata: Record<string, any>;
}
```
## 使用示例
### 基本用法
```typescript
import HealthKitManager, { HealthKitUtils } from './utils/healthKit';
// 1. 检查可用性并请求授权
const initHealthKit = async () => {
if (!HealthKitUtils.isAvailable()) {
console.log('HealthKit不可用');
return false;
}
try {
const result = await HealthKitManager.requestAuthorization();
console.log('授权结果:', result);
return result.success;
} catch (error) {
console.error('授权失败:', error);
return false;
}
};
// 2. 获取睡眠数据
const getSleepData = async () => {
try {
const result = await HealthKitManager.getSleepData({
startDate: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(), // 7天前
endDate: new Date().toISOString(), // 现在
limit: 100
});
console.log(`获取到 ${result.count} 条睡眠记录`);
return result.data;
} catch (error) {
console.error('获取睡眠数据失败:', error);
return [];
}
};
```
### 高级用法
使用提供的 `HealthKitService` 类:
```typescript
import { HealthKitService } from './utils/healthKitExample';
// 初始化并获取昨晚睡眠数据
const checkLastNightSleep = async () => {
const initialized = await HealthKitService.initializeHealthKit();
if (!initialized) return;
const sleepData = await HealthKitService.getLastNightSleep();
if (sleepData.hasData) {
console.log(`睡眠时间: ${sleepData.bedTime} - ${sleepData.wakeTime}`);
console.log(`睡眠时长: ${sleepData.totalDurationFormatted}`);
}
};
// 分析一周睡眠质量
const analyzeSleep = async () => {
const analysis = await HealthKitService.analyzeSleepQuality(7);
if (analysis.hasData) {
console.log(`平均睡眠: ${analysis.summary.averageSleepFormatted}`);
}
};
```
## 工具函数
`HealthKitUtils` 类提供了实用的工具方法:
- `formatDuration(seconds)` - 格式化时长显示
- `getTotalSleepDuration(samples, date)` - 计算特定日期的总睡眠时长
- `groupSamplesByDate(samples)` - 按日期分组睡眠数据
- `getSleepQualityMetrics(samples)` - 分析睡眠质量指标
- `isAvailable()` - 检查HealthKit是否可用
## 注意事项
1. **仅iOS支持** - HealthKit仅在iOS设备上可用Android设备会返回不可用状态
2. **用户权限** - 用户可以拒绝或部分授权,需要优雅处理权限被拒绝的情况
3. **数据可用性** - 并非所有用户都有睡眠数据特别是没有Apple Watch的用户
4. **隐私保护** - 严格遵循Apple的隐私指南只请求必要的权限
5. **后台更新** - 已配置后台HealthKit数据传输权限
## 错误处理
常见错误类型:
- `HEALTHKIT_NOT_AVAILABLE` - HealthKit不可用
- `AUTHORIZATION_ERROR` - 授权过程出错
- `AUTHORIZATION_DENIED` - 用户拒绝授权
- `NOT_AUTHORIZED` - 未授权访问特定数据类型
- `QUERY_ERROR` - 数据查询失败
## 扩展功能
如需添加更多HealthKit数据类型可以
1. 在Swift文件中的 `readTypes` 数组添加新的数据类型
2. 实现对应的查询方法
3. 在TypeScript接口中定义新的方法和数据类型
4. 更新Objective-C桥接文件暴露新方法
## 测试建议
1. 在真实iOS设备上测试模拟器不支持HealthKit
2. 使用不同的授权状态测试
3. 测试没有睡眠数据的情况
4. 验证数据格式和时区处理
5. 测试错误场景的处理
## 相关资源
- [Apple HealthKit文档](https://developer.apple.com/documentation/healthkit)
- [React Native Native Modules](https://reactnative.dev/docs/native-modules-ios)
- [iOS应用权限指南](https://developer.apple.com/documentation/bundleresources/information_property_list/protected_resources)

View File

@@ -0,0 +1,58 @@
import dayjs from 'dayjs';
import { useCallback, useEffect, useState } from 'react';
import { NativeModules } from 'react-native';
const { HealthKitManager } = NativeModules;
type HealthDataOptions = {
startDate: string;
endDate: string;
};
/**
* 专用于获取运动消耗卡路里的hook
* 避免使用完整的healthData对象提升性能
*/
export function useActiveCalories(selectedDate?: Date) {
const [activeCalories, setActiveCalories] = useState<number>(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchActiveCalories = useCallback(async (date: Date) => {
try {
setLoading(true);
setError(null);
const options: HealthDataOptions = {
startDate: dayjs(date).startOf('day').toDate().toISOString(),
endDate: dayjs(date).endOf('day').toDate().toISOString()
};
const result = await HealthKitManager.getActiveEnergyBurned(options);
if (result && result.totalValue !== undefined) {
setActiveCalories(Math.round(result.totalValue));
} else {
setActiveCalories(0);
}
} catch (err) {
console.error('获取运动消耗卡路里失败:', err);
setError(err instanceof Error ? err.message : '获取运动消耗卡路里失败');
setActiveCalories(0);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
const targetDate = selectedDate || new Date();
fetchActiveCalories(targetDate);
}, [selectedDate, fetchActiveCalories]);
return {
activeCalories,
loading,
error,
refetch: () => fetchActiveCalories(selectedDate || new Date())
};
}

View File

@@ -19,9 +19,9 @@ export function useAuthGuard() {
const router = useRouter();
const dispatch = useAppDispatch();
const currentPath = usePathname();
const token = useAppSelector((s) => (s as any)?.user?.token as string | null);
const user = useAppSelector(state => state.user);
const isLoggedIn = !!token;
const isLoggedIn = !!user?.profile?.id;
const ensureLoggedIn = useCallback(async (options?: EnsureOptions): Promise<boolean> => {
if (isLoggedIn) return true;

View File

@@ -0,0 +1,236 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import {
healthPermissionManager,
HealthPermissionStatus,
ensureHealthPermissions,
checkHealthPermissionStatus,
fetchTodayHealthData,
fetchHealthDataForDate,
TodayHealthData
} from '@/utils/health';
export interface UseHealthPermissionsReturn {
// 权限状态
permissionStatus: HealthPermissionStatus;
isLoading: boolean;
// 权限操作
requestPermissions: () => Promise<boolean>;
checkPermissions: (forceCheck?: boolean) => Promise<HealthPermissionStatus>;
// 数据刷新
refreshHealthData: () => Promise<void>;
refreshHealthDataForDate: (date: Date) => Promise<void>;
// 健康数据
healthData: TodayHealthData | null;
// 状态检查
hasPermission: boolean;
needsPermission: boolean;
}
/**
* HealthKit权限状态管理Hook
*
* 功能:
* 1. 监听权限状态变化
* 2. 自动刷新数据当权限状态改变时
* 3. 提供权限请求和数据刷新方法
* 4. 缓存健康数据状态
*/
export function useHealthPermissions(): UseHealthPermissionsReturn {
const [permissionStatus, setPermissionStatus] = useState<HealthPermissionStatus>(
healthPermissionManager.getPermissionStatus()
);
const [isLoading, setIsLoading] = useState(false);
const [healthData, setHealthData] = useState<TodayHealthData | null>(null);
// 使用ref避免闭包问题
const isLoadingRef = useRef(false);
const lastRefreshTime = useRef(0);
const refreshThrottle = 2000; // 2秒内避免重复刷新
// 刷新健康数据
const refreshHealthData = useCallback(async () => {
const now = Date.now();
// 防抖:避免短时间内重复刷新
if (isLoadingRef.current || (now - lastRefreshTime.current) < refreshThrottle) {
console.log('健康数据刷新被节流,跳过本次刷新');
return;
}
if (permissionStatus !== HealthPermissionStatus.Authorized) {
console.log('没有HealthKit权限跳过数据刷新');
return;
}
isLoadingRef.current = true;
setIsLoading(true);
lastRefreshTime.current = now;
try {
console.log('开始刷新今日健康数据...');
const data = await fetchTodayHealthData();
setHealthData(data);
console.log('健康数据刷新成功:', data);
} catch (error) {
console.error('刷新健康数据失败:', error);
} finally {
isLoadingRef.current = false;
setIsLoading(false);
}
}, [permissionStatus]);
// 刷新指定日期的健康数据
const refreshHealthDataForDate = useCallback(async (date: Date) => {
if (permissionStatus !== HealthPermissionStatus.Authorized) {
console.log('没有HealthKit权限跳过数据刷新');
return;
}
setIsLoading(true);
try {
console.log('开始刷新指定日期健康数据...', date);
const data = await fetchHealthDataForDate(date);
// 只有是今天的数据才更新state
const today = new Date();
if (date.toDateString() === today.toDateString()) {
setHealthData(data);
}
console.log('指定日期健康数据刷新成功:', data);
} catch (error) {
console.error('刷新指定日期健康数据失败:', error);
} finally {
setIsLoading(false);
}
}, [permissionStatus]);
// 请求权限
const requestPermissions = useCallback(async (): Promise<boolean> => {
setIsLoading(true);
try {
console.log('开始请求HealthKit权限...');
const granted = await ensureHealthPermissions();
if (granted) {
console.log('权限请求成功,准备刷新数据');
// 权限获取成功后,稍微延迟刷新数据
setTimeout(() => {
refreshHealthData();
}, 500);
}
return granted;
} catch (error) {
console.error('请求HealthKit权限失败:', error);
return false;
} finally {
setIsLoading(false);
}
}, [refreshHealthData]);
// 检查权限状态
const checkPermissions = useCallback(async (forceCheck: boolean = false): Promise<HealthPermissionStatus> => {
try {
const status = await checkHealthPermissionStatus(forceCheck);
return status;
} catch (error) {
console.error('检查权限状态失败:', error);
return HealthPermissionStatus.Unknown;
}
}, []);
// 监听权限状态变化
useEffect(() => {
console.log('设置HealthKit权限状态监听器...');
// 权限状态变化监听
const handlePermissionStatusChanged = (newStatus: HealthPermissionStatus, oldStatus: HealthPermissionStatus) => {
console.log(`权限状态变化: ${oldStatus} -> ${newStatus}`);
setPermissionStatus(newStatus);
// 如果从无权限变为有权限,自动刷新数据
if (oldStatus !== HealthPermissionStatus.Authorized && newStatus === HealthPermissionStatus.Authorized) {
console.log('权限状态变为已授权,准备刷新健康数据...');
setTimeout(() => {
refreshHealthData();
}, 500);
}
};
// 权限获取成功监听
const handlePermissionGranted = () => {
console.log('权限获取成功事件触发,准备刷新数据...');
setTimeout(() => {
refreshHealthData();
}, 500);
};
healthPermissionManager.on('permissionStatusChanged', handlePermissionStatusChanged);
healthPermissionManager.on('permissionGranted', handlePermissionGranted);
// 组件挂载时检查一次权限状态
checkPermissions(true);
// 如果已经有权限,立即刷新数据
if (permissionStatus === HealthPermissionStatus.Authorized) {
refreshHealthData();
}
return () => {
console.log('清理HealthKit权限状态监听器...');
healthPermissionManager.off('permissionStatusChanged', handlePermissionStatusChanged);
healthPermissionManager.off('permissionGranted', handlePermissionGranted);
};
}, [checkPermissions, refreshHealthData, permissionStatus]);
// 计算派生状态
const hasPermission = permissionStatus === HealthPermissionStatus.Authorized;
const needsPermission = permissionStatus === HealthPermissionStatus.NotDetermined ||
permissionStatus === HealthPermissionStatus.Unknown;
return {
permissionStatus,
isLoading,
requestPermissions,
checkPermissions,
refreshHealthData,
refreshHealthDataForDate,
healthData,
hasPermission,
needsPermission
};
}
/**
* 简化版Hook只关注权限状态
*/
export function useHealthPermissionStatus() {
const [permissionStatus, setPermissionStatus] = useState<HealthPermissionStatus>(
healthPermissionManager.getPermissionStatus()
);
useEffect(() => {
const handlePermissionStatusChanged = (newStatus: HealthPermissionStatus) => {
setPermissionStatus(newStatus);
};
healthPermissionManager.on('permissionStatusChanged', handlePermissionStatusChanged);
// 检查一次当前状态
checkHealthPermissionStatus(true);
return () => {
healthPermissionManager.off('permissionStatusChanged', handlePermissionStatusChanged);
};
}, []);
return {
permissionStatus,
hasPermission: permissionStatus === HealthPermissionStatus.Authorized,
needsPermission: permissionStatus === HealthPermissionStatus.NotDetermined ||
permissionStatus === HealthPermissionStatus.Unknown
};
}

File diff suppressed because it is too large Load Diff

View File

@@ -3,146 +3,93 @@
archiveVersion = 1;
classes = {
};
objectVersion = 60;
objectVersion = 54;
objects = {
/* Begin PBXBuildFile section */
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; };
2C9C524987451393B76B9C7E /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 7EC44F9488C227087AA8DF97 /* PrivacyInfo.xcprivacy */; };
32476CAEFFCE691C1634B0A4 /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EA3641BAC6078512F41509D /* ExpoModulesProvider.swift */; };
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; };
6B6021A2D1EB466803BE19D7 /* libPods-digitalpilates.a in Frameworks */ = {isa = PBXBuildFile; fileRef = F1A078ADDB1BCB06E0DBEFDA /* libPods-digitalpilates.a */; };
7996A1192E6FB82300371142 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7996A1182E6FB82300371142 /* WidgetKit.framework */; };
7996A11B2E6FB82300371142 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7996A11A2E6FB82300371142 /* SwiftUI.framework */; };
7996A12C2E6FB82300371142 /* WaterWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 7996A1172E6FB82300371142 /* WaterWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
646189797DBE7937221347A9 /* libPods-OutLive.a in Frameworks */ = {isa = PBXBuildFile; fileRef = C411D1CEBC225A6F92F136BA /* libPods-OutLive.a */; };
79B2CB702E7B954600B51753 /* OutLive-Bridging-Header.h in Sources */ = {isa = PBXBuildFile; fileRef = F11748442D0722820044C1D9 /* OutLive-Bridging-Header.h */; };
79B2CB732E7B954F00B51753 /* HealthKitManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 79B2CB712E7B954F00B51753 /* HealthKitManager.m */; };
79B2CB742E7B954F00B51753 /* HealthKitManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79B2CB722E7B954F00B51753 /* HealthKitManager.swift */; };
91B7BA17B50D328546B5B4B8 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = B7F23062EE59F61E6260DBA8 /* PrivacyInfo.xcprivacy */; };
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; };
DC3BFC72D3A68C7493D5B44A /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83D1B5F0EC906D7A2F599549 /* ExpoModulesProvider.swift */; };
F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11748412D0307B40044C1D9 /* AppDelegate.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
7996A12A2E6FB82300371142 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 83CBB9F71A601CBA00E9B192 /* Project object */;
proxyType = 1;
remoteGlobalIDString = 7996A1162E6FB82300371142;
remoteInfo = WaterWidgetExtension;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
7996A12D2E6FB82300371142 /* Embed Foundation Extensions */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 13;
files = (
7996A12C2E6FB82300371142 /* WaterWidgetExtension.appex in Embed Foundation Extensions */,
);
name = "Embed Foundation Extensions";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
13B07F961A680F5B00A75B9A /* digitalpilates.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = digitalpilates.app; sourceTree = BUILT_PRODUCTS_DIR; };
13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = digitalpilates/Images.xcassets; sourceTree = "<group>"; };
13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = digitalpilates/Info.plist; sourceTree = "<group>"; };
4D6B8E20DD8E5677F8B2EAA1 /* Pods-digitalpilates.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-digitalpilates.debug.xcconfig"; path = "Target Support Files/Pods-digitalpilates/Pods-digitalpilates.debug.xcconfig"; sourceTree = "<group>"; };
7996A1172E6FB82300371142 /* WaterWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = WaterWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
7996A1182E6FB82300371142 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; };
7996A11A2E6FB82300371142 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; };
7996A1322E6FB84A00371142 /* WaterWidgetExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = WaterWidgetExtension.entitlements; sourceTree = "<group>"; };
7EC44F9488C227087AA8DF97 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = digitalpilates/PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
83D1B5F0EC906D7A2F599549 /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-digitalpilates/ExpoModulesProvider.swift"; sourceTree = "<group>"; };
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = digitalpilates/SplashScreen.storyboard; sourceTree = "<group>"; };
0FF78E2879F14B0D75DF41B4 /* Pods-OutLive.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-OutLive.release.xcconfig"; path = "Target Support Files/Pods-OutLive/Pods-OutLive.release.xcconfig"; sourceTree = "<group>"; };
13B07F961A680F5B00A75B9A /* OutLive.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = OutLive.app; sourceTree = BUILT_PRODUCTS_DIR; };
13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = OutLive/Images.xcassets; sourceTree = "<group>"; };
13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = OutLive/Info.plist; sourceTree = "<group>"; };
1EA3641BAC6078512F41509D /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-OutLive/ExpoModulesProvider.swift"; sourceTree = "<group>"; };
4FAA45EB21D2C45B94943F48 /* Pods-OutLive.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-OutLive.debug.xcconfig"; path = "Target Support Files/Pods-OutLive/Pods-OutLive.debug.xcconfig"; sourceTree = "<group>"; };
79B2CB712E7B954F00B51753 /* HealthKitManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = HealthKitManager.m; path = OutLive/HealthKitManager.m; sourceTree = "<group>"; };
79B2CB722E7B954F00B51753 /* HealthKitManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = HealthKitManager.swift; path = OutLive/HealthKitManager.swift; sourceTree = "<group>"; };
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = OutLive/SplashScreen.storyboard; sourceTree = "<group>"; };
B7F23062EE59F61E6260DBA8 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; name = PrivacyInfo.xcprivacy; path = OutLive/PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = "<group>"; };
EA6A757B2DE1747F7B3664B4 /* Pods-digitalpilates.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-digitalpilates.release.xcconfig"; path = "Target Support Files/Pods-digitalpilates/Pods-digitalpilates.release.xcconfig"; sourceTree = "<group>"; };
C411D1CEBC225A6F92F136BA /* libPods-OutLive.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-OutLive.a"; sourceTree = BUILT_PRODUCTS_DIR; };
ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; };
F11748412D0307B40044C1D9 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = digitalpilates/AppDelegate.swift; sourceTree = "<group>"; };
F11748442D0722820044C1D9 /* digitalpilates-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = "digitalpilates-Bridging-Header.h"; path = "digitalpilates/digitalpilates-Bridging-Header.h"; sourceTree = "<group>"; };
F1A078ADDB1BCB06E0DBEFDA /* libPods-digitalpilates.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-digitalpilates.a"; sourceTree = BUILT_PRODUCTS_DIR; };
F11748412D0307B40044C1D9 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = OutLive/AppDelegate.swift; sourceTree = "<group>"; };
F11748442D0722820044C1D9 /* OutLive-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = "OutLive-Bridging-Header.h"; path = "OutLive/OutLive-Bridging-Header.h"; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
7996A1302E6FB82300371142 /* Exceptions for "WaterWidget" folder in "WaterWidgetExtension" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
);
target = 7996A1162E6FB82300371142 /* WaterWidgetExtension */;
};
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
7996A11C2E6FB82300371142 /* WaterWidget */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
7996A1302E6FB82300371142 /* Exceptions for "WaterWidget" folder in "WaterWidgetExtension" target */,
);
explicitFileTypes = {
};
explicitFolders = (
);
path = WaterWidget;
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
13B07F8C1A680F5B00A75B9A /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
6B6021A2D1EB466803BE19D7 /* libPods-digitalpilates.a in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
7996A1142E6FB82300371142 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
7996A11B2E6FB82300371142 /* SwiftUI.framework in Frameworks */,
7996A1192E6FB82300371142 /* WidgetKit.framework in Frameworks */,
646189797DBE7937221347A9 /* libPods-OutLive.a in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
13B07FAE1A68108700A75B9A /* digitalpilates */ = {
13B07FAE1A68108700A75B9A /* OutLive */ = {
isa = PBXGroup;
children = (
F11748412D0307B40044C1D9 /* AppDelegate.swift */,
F11748442D0722820044C1D9 /* digitalpilates-Bridging-Header.h */,
F11748442D0722820044C1D9 /* OutLive-Bridging-Header.h */,
BB2F792B24A3F905000567C9 /* Supporting */,
13B07FB51A68108700A75B9A /* Images.xcassets */,
13B07FB61A68108700A75B9A /* Info.plist */,
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */,
7EC44F9488C227087AA8DF97 /* PrivacyInfo.xcprivacy */,
B7F23062EE59F61E6260DBA8 /* PrivacyInfo.xcprivacy */,
);
name = digitalpilates;
name = OutLive;
sourceTree = "<group>";
};
2D16E6871FA4F8E400B85C8A /* Frameworks */ = {
isa = PBXGroup;
children = (
ED297162215061F000B7C4FE /* JavaScriptCore.framework */,
F1A078ADDB1BCB06E0DBEFDA /* libPods-digitalpilates.a */,
7996A1182E6FB82300371142 /* WidgetKit.framework */,
7996A11A2E6FB82300371142 /* SwiftUI.framework */,
C411D1CEBC225A6F92F136BA /* libPods-OutLive.a */,
);
name = Frameworks;
sourceTree = "<group>";
};
3EE8D66219D64F4A63E8298D /* Pods */ = {
7B63456AB81271603E0039A3 /* Pods */ = {
isa = PBXGroup;
children = (
4D6B8E20DD8E5677F8B2EAA1 /* Pods-digitalpilates.debug.xcconfig */,
EA6A757B2DE1747F7B3664B4 /* Pods-digitalpilates.release.xcconfig */,
4FAA45EB21D2C45B94943F48 /* Pods-OutLive.debug.xcconfig */,
0FF78E2879F14B0D75DF41B4 /* Pods-OutLive.release.xcconfig */,
);
name = Pods;
path = Pods;
sourceTree = "<group>";
};
80E2A1E8ECA8777F7264D855 /* ExpoModulesProviders */ = {
isa = PBXGroup;
children = (
BF89779EFCFC7E852B943187 /* OutLive */,
);
name = ExpoModulesProviders;
sourceTree = "<group>";
};
832341AE1AAA6A7D00B99B32 /* Libraries */ = {
isa = PBXGroup;
children = (
@@ -153,14 +100,14 @@
83CBB9F61A601CBA00E9B192 = {
isa = PBXGroup;
children = (
7996A1322E6FB84A00371142 /* WaterWidgetExtension.entitlements */,
13B07FAE1A68108700A75B9A /* digitalpilates */,
79B2CB712E7B954F00B51753 /* HealthKitManager.m */,
79B2CB722E7B954F00B51753 /* HealthKitManager.swift */,
13B07FAE1A68108700A75B9A /* OutLive */,
832341AE1AAA6A7D00B99B32 /* Libraries */,
7996A11C2E6FB82300371142 /* WaterWidget */,
83CBBA001A601CBA00E9B192 /* Products */,
2D16E6871FA4F8E400B85C8A /* Frameworks */,
3EE8D66219D64F4A63E8298D /* Pods */,
F899CC3CCA86CFEC0C4F53F7 /* ExpoModulesProviders */,
80E2A1E8ECA8777F7264D855 /* ExpoModulesProviders */,
7B63456AB81271603E0039A3 /* Pods */,
);
indentWidth = 2;
sourceTree = "<group>";
@@ -170,8 +117,7 @@
83CBBA001A601CBA00E9B192 /* Products */ = {
isa = PBXGroup;
children = (
13B07F961A680F5B00A75B9A /* digitalpilates.app */,
7996A1172E6FB82300371142 /* WaterWidgetExtension.appex */,
13B07F961A680F5B00A75B9A /* OutLive.app */,
);
name = Products;
sourceTree = "<group>";
@@ -182,104 +128,69 @@
BB2F792C24A3F905000567C9 /* Expo.plist */,
);
name = Supporting;
path = digitalpilates/Supporting;
path = OutLive/Supporting;
sourceTree = "<group>";
};
DFAD2B7142CEC38E9ED66053 /* digitalpilates */ = {
BF89779EFCFC7E852B943187 /* OutLive */ = {
isa = PBXGroup;
children = (
83D1B5F0EC906D7A2F599549 /* ExpoModulesProvider.swift */,
1EA3641BAC6078512F41509D /* ExpoModulesProvider.swift */,
);
name = digitalpilates;
sourceTree = "<group>";
};
F899CC3CCA86CFEC0C4F53F7 /* ExpoModulesProviders */ = {
isa = PBXGroup;
children = (
DFAD2B7142CEC38E9ED66053 /* digitalpilates */,
);
name = ExpoModulesProviders;
name = OutLive;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
13B07F861A680F5B00A75B9A /* digitalpilates */ = {
13B07F861A680F5B00A75B9A /* OutLive */ = {
isa = PBXNativeTarget;
buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "digitalpilates" */;
buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "OutLive" */;
buildPhases = (
08A4A3CD28434E44B6B9DE2E /* [CP] Check Pods Manifest.lock */,
60F566376E07CDAA8138E40B /* [Expo] Configure project */,
0B29745394A2F51EC4C9CBC7 /* [CP] Check Pods Manifest.lock */,
FED23F24D8115FB0D63DF986 /* [Expo] Configure project */,
13B07F871A680F5B00A75B9A /* Sources */,
13B07F8C1A680F5B00A75B9A /* Frameworks */,
13B07F8E1A680F5B00A75B9A /* Resources */,
00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */,
800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */,
7996A12D2E6FB82300371142 /* Embed Foundation Extensions */,
CD8A4C026AF644A41E91C9E8 /* [CP] Embed Pods Frameworks */,
EF8950FE7A620E6B790F3042 /* [CP] Embed Pods Frameworks */,
2266B6A6AD27779BC2D49E87 /* [CP] Copy Pods Resources */,
);
buildRules = (
);
dependencies = (
7996A12B2E6FB82300371142 /* PBXTargetDependency */,
);
name = digitalpilates;
productName = digitalpilates;
productReference = 13B07F961A680F5B00A75B9A /* digitalpilates.app */;
name = OutLive;
productName = OutLive;
productReference = 13B07F961A680F5B00A75B9A /* OutLive.app */;
productType = "com.apple.product-type.application";
};
7996A1162E6FB82300371142 /* WaterWidgetExtension */ = {
isa = PBXNativeTarget;
buildConfigurationList = 7996A1312E6FB82300371142 /* Build configuration list for PBXNativeTarget "WaterWidgetExtension" */;
buildPhases = (
7996A1132E6FB82300371142 /* Sources */,
7996A1142E6FB82300371142 /* Frameworks */,
7996A1152E6FB82300371142 /* Resources */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
7996A11C2E6FB82300371142 /* WaterWidget */,
);
name = WaterWidgetExtension;
productName = WaterWidgetExtension;
productReference = 7996A1172E6FB82300371142 /* WaterWidgetExtension.appex */;
productType = "com.apple.product-type.app-extension";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
83CBB9F71A601CBA00E9B192 /* Project object */ = {
isa = PBXProject;
attributes = {
LastSwiftUpdateCheck = 1640;
LastUpgradeCheck = 1130;
TargetAttributes = {
13B07F861A680F5B00A75B9A = {
LastSwiftMigration = 1250;
};
7996A1162E6FB82300371142 = {
CreatedOnToolsVersion = 16.4;
};
};
};
buildConfigurationList = 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "digitalpilates" */;
buildConfigurationList = 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "OutLive" */;
compatibilityVersion = "Xcode 3.2";
developmentRegion = "zh-Hans";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
"zh-Hans",
);
mainGroup = 83CBB9F61A601CBA00E9B192;
productRefGroup = 83CBBA001A601CBA00E9B192 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
13B07F861A680F5B00A75B9A /* digitalpilates */,
7996A1162E6FB82300371142 /* WaterWidgetExtension */,
13B07F861A680F5B00A75B9A /* OutLive */,
);
};
/* End PBXProject section */
@@ -292,14 +203,7 @@
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */,
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */,
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */,
2C9C524987451393B76B9C7E /* PrivacyInfo.xcprivacy in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
7996A1152E6FB82300371142 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
91B7BA17B50D328546B5B4B8 /* PrivacyInfo.xcprivacy in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -313,6 +217,8 @@
files = (
);
inputPaths = (
"$(SRCROOT)/.xcode.env",
"$(SRCROOT)/.xcode.env.local",
);
name = "Bundle React Native code and images";
outputPaths = (
@@ -321,7 +227,7 @@
shellPath = /bin/sh;
shellScript = "if [[ -f \"$PODS_ROOT/../.xcode.env\" ]]; then\n source \"$PODS_ROOT/../.xcode.env\"\nfi\nif [[ -f \"$PODS_ROOT/../.xcode.env.local\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.local\"\nfi\n\n# The project root by default is one level up from the ios directory\nexport PROJECT_ROOT=\"$PROJECT_DIR\"/..\n\nif [[ \"$CONFIGURATION\" = *Debug* ]]; then\n export SKIP_BUNDLING=1\nfi\nif [[ -z \"$ENTRY_FILE\" ]]; then\n # Set the entry JS file using the bundler's entry resolution.\n export ENTRY_FILE=\"$(\"$NODE_BINARY\" -e \"require('expo/scripts/resolveAppEntry')\" \"$PROJECT_ROOT\" ios absolute | tail -n 1)\"\nfi\n\nif [[ -z \"$CLI_PATH\" ]]; then\n # Use Expo CLI\n export CLI_PATH=\"$(\"$NODE_BINARY\" --print \"require.resolve('@expo/cli', { paths: [require.resolve('expo/package.json')] })\")\"\nfi\nif [[ -z \"$BUNDLE_COMMAND\" ]]; then\n # Default Expo CLI command for bundling\n export BUNDLE_COMMAND=\"export:embed\"\nfi\n\n# Source .xcode.env.updates if it exists to allow\n# SKIP_BUNDLING to be unset if needed\nif [[ -f \"$PODS_ROOT/../.xcode.env.updates\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.updates\"\nfi\n# Source local changes to allow overrides\n# if needed\nif [[ -f \"$PODS_ROOT/../.xcode.env.local\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.local\"\nfi\n\n`\"$NODE_BINARY\" --print \"require('path').dirname(require.resolve('react-native/package.json')) + '/scripts/react-native-xcode.sh'\"`\n\n";
};
08A4A3CD28434E44B6B9DE2E /* [CP] Check Pods Manifest.lock */ = {
0B29745394A2F51EC4C9CBC7 /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
@@ -336,44 +242,20 @@
outputFileListPaths = (
);
outputPaths = (
"$(DERIVED_FILE_DIR)/Pods-digitalpilates-checkManifestLockResult.txt",
"$(DERIVED_FILE_DIR)/Pods-OutLive-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
60F566376E07CDAA8138E40B /* [Expo] Configure project */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"$(SRCROOT)/.xcode.env",
"$(SRCROOT)/.xcode.env.local",
"$(SRCROOT)/digitalpilates/digitalpilates.entitlements",
"$(SRCROOT)/Pods/Target Support Files/Pods-digitalpilates/expo-configure-project.sh",
);
name = "[Expo] Configure project";
outputFileListPaths = (
);
outputPaths = (
"$(SRCROOT)/Pods/Target Support Files/Pods-digitalpilates/ExpoModulesProvider.swift",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "# This script configures Expo modules and generates the modules provider file.\nbash -l -c \"./Pods/Target\\ Support\\ Files/Pods-digitalpilates/expo-configure-project.sh\"\n";
};
800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */ = {
2266B6A6AD27779BC2D49E87 /* [CP] Copy Pods Resources */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
"${PODS_ROOT}/Target Support Files/Pods-digitalpilates/Pods-digitalpilates-resources.sh",
"${PODS_ROOT}/Target Support Files/Pods-OutLive/Pods-OutLive-resources.sh",
"${PODS_CONFIGURATION_BUILD_DIR}/EXApplication/ExpoApplication_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/EXConstants.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/ExpoConstants_privacy.bundle",
@@ -382,7 +264,6 @@
"${PODS_CONFIGURATION_BUILD_DIR}/ExpoFileSystem/ExpoFileSystem_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/ExpoSystemUI/ExpoSystemUI_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/PurchasesHybridCommon/PurchasesHybridCommon.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/RCT-Folly/RCT-Folly_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/RNCAsyncStorage/RNCAsyncStorage_resources.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/RNDeviceInfo/RNDeviceInfoPrivacyInfo.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/RNSVG/RNSVGFilters.bundle",
@@ -391,8 +272,6 @@
"${PODS_CONFIGURATION_BUILD_DIR}/RevenueCat/RevenueCat.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/SDWebImage/SDWebImage.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/Sentry/Sentry.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/boost/boost_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/glog/glog_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/lottie-ios/LottiePrivacyInfo.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/lottie-react-native/Lottie_React_Native_Privacy.bundle",
);
@@ -406,7 +285,6 @@
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoFileSystem_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoSystemUI_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/PurchasesHybridCommon.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RCT-Folly_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNCAsyncStorage_resources.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNDeviceInfoPrivacyInfo.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNSVGFilters.bundle",
@@ -415,34 +293,60 @@
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RevenueCat.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/SDWebImage.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Sentry.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/boost_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/glog_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/LottiePrivacyInfo.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Lottie_React_Native_Privacy.bundle",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-digitalpilates/Pods-digitalpilates-resources.sh\"\n";
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-OutLive/Pods-OutLive-resources.sh\"\n";
showEnvVarsInLog = 0;
};
CD8A4C026AF644A41E91C9E8 /* [CP] Embed Pods Frameworks */ = {
EF8950FE7A620E6B790F3042 /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
"${PODS_ROOT}/Target Support Files/Pods-digitalpilates/Pods-digitalpilates-frameworks.sh",
"${PODS_ROOT}/Target Support Files/Pods-OutLive/Pods-OutLive-frameworks.sh",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/React-Core-prebuilt/React.framework/React",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/ReactNativeDependencies/ReactNativeDependencies.framework/ReactNativeDependencies",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/hermes-engine/Pre-built/hermes.framework/hermes",
);
name = "[CP] Embed Pods Frameworks";
outputPaths = (
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/React.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/ReactNativeDependencies.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/hermes.framework",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-digitalpilates/Pods-digitalpilates-frameworks.sh\"\n";
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-OutLive/Pods-OutLive-frameworks.sh\"\n";
showEnvVarsInLog = 0;
};
FED23F24D8115FB0D63DF986 /* [Expo] Configure project */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"$(SRCROOT)/.xcode.env",
"$(SRCROOT)/.xcode.env.local",
"$(SRCROOT)/OutLive/OutLive.entitlements",
"$(SRCROOT)/Pods/Target Support Files/Pods-OutLive/expo-configure-project.sh",
);
name = "[Expo] Configure project";
outputFileListPaths = (
);
outputPaths = (
"$(SRCROOT)/Pods/Target Support Files/Pods-OutLive/ExpoModulesProvider.swift",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "# This script configures Expo modules and generates the modules provider file.\nbash -l -c \"./Pods/Target\\ Support\\ Files/Pods-OutLive/expo-configure-project.sh\"\n";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
@@ -450,52 +354,38 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
79B2CB732E7B954F00B51753 /* HealthKitManager.m in Sources */,
79B2CB742E7B954F00B51753 /* HealthKitManager.swift in Sources */,
79B2CB702E7B954600B51753 /* OutLive-Bridging-Header.h in Sources */,
F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */,
DC3BFC72D3A68C7493D5B44A /* ExpoModulesProvider.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
7996A1132E6FB82300371142 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
32476CAEFFCE691C1634B0A4 /* ExpoModulesProvider.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
7996A12B2E6FB82300371142 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 7996A1162E6FB82300371142 /* WaterWidgetExtension */;
targetProxy = 7996A12A2E6FB82300371142 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
13B07F941A680F5B00A75B9A /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 4D6B8E20DD8E5677F8B2EAA1 /* Pods-digitalpilates.debug.xcconfig */;
baseConfigurationReference = 4FAA45EB21D2C45B94943F48 /* Pods-OutLive.debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = digitalpilates/digitalpilates.entitlements;
CODE_SIGN_ENTITLEMENTS = OutLive/OutLive.entitlements;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 756WVXJ6MT;
ENABLE_BITCODE = NO;
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = x86_64;
GCC_PREPROCESSOR_DEFINITIONS = (
"$(inherited)",
"FB_SONARKIT_ENABLED=1",
);
INFOPLIST_FILE = digitalpilates/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Out Live";
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
INFOPLIST_FILE = OutLive/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 16.6;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.7;
MARKETING_VERSION = 1.0;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
@@ -503,12 +393,8 @@
);
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
PRODUCT_BUNDLE_IDENTIFIER = com.anonymous.digitalpilates;
PRODUCT_NAME = digitalpilates;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_OBJC_BRIDGING_HEADER = "digitalpilates/digitalpilates-Bridging-Header.h";
PRODUCT_NAME = OutLive;
SWIFT_OBJC_BRIDGING_HEADER = "OutLive/OutLive-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1;
@@ -518,22 +404,20 @@
};
13B07F951A680F5B00A75B9A /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = EA6A757B2DE1747F7B3664B4 /* Pods-digitalpilates.release.xcconfig */;
baseConfigurationReference = 0FF78E2879F14B0D75DF41B4 /* Pods-OutLive.release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = digitalpilates/digitalpilates.entitlements;
CODE_SIGN_ENTITLEMENTS = OutLive/OutLive.entitlements;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 756WVXJ6MT;
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = x86_64;
INFOPLIST_FILE = digitalpilates/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Out Live";
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
INFOPLIST_FILE = OutLive/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 16.6;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.7;
MARKETING_VERSION = 1.0;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
@@ -541,109 +425,14 @@
);
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
PRODUCT_BUNDLE_IDENTIFIER = com.anonymous.digitalpilates;
PRODUCT_NAME = digitalpilates;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_OBJC_BRIDGING_HEADER = "digitalpilates/digitalpilates-Bridging-Header.h";
PRODUCT_NAME = OutLive;
SWIFT_OBJC_BRIDGING_HEADER = "OutLive/OutLive-Bridging-Header.h";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1;
VERSIONING_SYSTEM = "apple-generic";
};
name = Release;
};
7996A12E2E6FB82300371142 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_ENTITLEMENTS = WaterWidgetExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = 756WVXJ6MT;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = WaterWidget/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = WaterWidget;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 18.5;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
PRODUCT_BUNDLE_IDENTIFIER = com.anonymous.digitalpilates.WaterWidget;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
7996A12F2E6FB82300371142 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_ENTITLEMENTS = WaterWidgetExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 1;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = 756WVXJ6MT;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = WaterWidget/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = WaterWidget;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 18.5;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.0;
MTL_FAST_MATH = YES;
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
PRODUCT_BUNDLE_IDENTIFIER = com.anonymous.digitalpilates.WaterWidget;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
83CBBA201A601CBA00E9B192 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
@@ -699,13 +488,10 @@
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\"";
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
OTHER_LDFLAGS = (
"$(inherited)",
" ",
);
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG";
SWIFT_ENABLE_EXPLICIT_MODULES = NO;
USE_HERMES = true;
};
name = Debug;
@@ -757,12 +543,9 @@
);
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\"";
MTL_ENABLE_DEBUG_INFO = NO;
OTHER_LDFLAGS = (
"$(inherited)",
" ",
);
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
SDKROOT = iphoneos;
SWIFT_ENABLE_EXPLICIT_MODULES = NO;
USE_HERMES = true;
VALIDATE_PRODUCT = YES;
};
@@ -771,7 +554,7 @@
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "digitalpilates" */ = {
13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "OutLive" */ = {
isa = XCConfigurationList;
buildConfigurations = (
13B07F941A680F5B00A75B9A /* Debug */,
@@ -780,16 +563,7 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
7996A1312E6FB82300371142 /* Build configuration list for PBXNativeTarget "WaterWidgetExtension" */ = {
isa = XCConfigurationList;
buildConfigurations = (
7996A12E2E6FB82300371142 /* Debug */,
7996A12F2E6FB82300371142 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "digitalpilates" */ = {
83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "OutLive" */ = {
isa = XCConfigurationList;
buildConfigurations = (
83CBBA201A601CBA00E9B192 /* Debug */,

View File

@@ -15,9 +15,9 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
BuildableName = "digitalpilates.app"
BlueprintName = "digitalpilates"
ReferencedContainer = "container:digitalpilates.xcodeproj">
BuildableName = "OutLive.app"
BlueprintName = "OutLive"
ReferencedContainer = "container:OutLive.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
@@ -33,9 +33,9 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "00E356ED1AD99517003FC87E"
BuildableName = "digitalpilatesTests.xctest"
BlueprintName = "digitalpilatesTests"
ReferencedContainer = "container:digitalpilates.xcodeproj">
BuildableName = "OutLiveTests.xctest"
BlueprintName = "OutLiveTests"
ReferencedContainer = "container:OutLive.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
@@ -55,9 +55,9 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
BuildableName = "digitalpilates.app"
BlueprintName = "digitalpilates"
ReferencedContainer = "container:digitalpilates.xcodeproj">
BuildableName = "OutLive.app"
BlueprintName = "OutLive"
ReferencedContainer = "container:OutLive.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
@@ -72,9 +72,9 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
BuildableName = "digitalpilates.app"
BlueprintName = "digitalpilates"
ReferencedContainer = "container:digitalpilates.xcodeproj">
BuildableName = "OutLive.app"
BlueprintName = "OutLive"
ReferencedContainer = "container:OutLive.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>

View File

@@ -2,7 +2,7 @@
<Workspace
version = "1.0">
<FileRef
location = "group:digitalpilates.xcodeproj">
location = "group:OutLive.xcodeproj">
</FileRef>
<FileRef
location = "group:Pods/Pods.xcodeproj">

View File

@@ -0,0 +1,80 @@
#import <React/RCTBridgeModule.h>
@interface RCT_EXTERN_MODULE(HealthKitManager, NSObject)
RCT_EXTERN_METHOD(requestAuthorization:(RCTPromiseResolveBlock)resolver
rejecter:(RCTPromiseRejectBlock)rejecter)
RCT_EXTERN_METHOD(getAuthorizationStatus:(RCTPromiseResolveBlock)resolver
rejecter:(RCTPromiseRejectBlock)rejecter)
RCT_EXTERN_METHOD(getSleepData:(NSDictionary *)options
resolver:(RCTPromiseResolveBlock)resolver
rejecter:(RCTPromiseRejectBlock)rejecter)
// Fitness Data Methods
RCT_EXTERN_METHOD(getActiveEnergyBurned:(NSDictionary *)options
resolver:(RCTPromiseResolveBlock)resolver
rejecter:(RCTPromiseRejectBlock)rejecter)
RCT_EXTERN_METHOD(getBasalEnergyBurned:(NSDictionary *)options
resolver:(RCTPromiseResolveBlock)resolver
rejecter:(RCTPromiseRejectBlock)rejecter)
RCT_EXTERN_METHOD(getAppleExerciseTime:(NSDictionary *)options
resolver:(RCTPromiseResolveBlock)resolver
rejecter:(RCTPromiseRejectBlock)rejecter)
RCT_EXTERN_METHOD(getAppleStandTime:(NSDictionary *)options
resolver:(RCTPromiseResolveBlock)resolver
rejecter:(RCTPromiseRejectBlock)rejecter)
RCT_EXTERN_METHOD(getActivitySummary:(NSDictionary *)options
resolver:(RCTPromiseResolveBlock)resolver
rejecter:(RCTPromiseRejectBlock)rejecter)
// Health Data Methods
RCT_EXTERN_METHOD(getHeartRateVariabilitySamples:(NSDictionary *)options
resolver:(RCTPromiseResolveBlock)resolver
rejecter:(RCTPromiseRejectBlock)rejecter)
RCT_EXTERN_METHOD(getOxygenSaturationSamples:(NSDictionary *)options
resolver:(RCTPromiseResolveBlock)resolver
rejecter:(RCTPromiseRejectBlock)rejecter)
RCT_EXTERN_METHOD(getHeartRateSamples:(NSDictionary *)options
resolver:(RCTPromiseResolveBlock)resolver
rejecter:(RCTPromiseRejectBlock)rejecter)
// Step Count Methods
RCT_EXTERN_METHOD(getStepCount:(NSDictionary *)options
resolver:(RCTPromiseResolveBlock)resolver
rejecter:(RCTPromiseRejectBlock)rejecter)
RCT_EXTERN_METHOD(getDailyStepCountSamples:(NSDictionary *)options
resolver:(RCTPromiseResolveBlock)resolver
rejecter:(RCTPromiseRejectBlock)rejecter)
// Hourly Data Methods
RCT_EXTERN_METHOD(getHourlyActiveEnergyBurned:(NSDictionary *)options
resolver:(RCTPromiseResolveBlock)resolver
rejecter:(RCTPromiseRejectBlock)rejecter)
RCT_EXTERN_METHOD(getHourlyExerciseTime:(NSDictionary *)options
resolver:(RCTPromiseResolveBlock)resolver
rejecter:(RCTPromiseRejectBlock)rejecter)
RCT_EXTERN_METHOD(getHourlyStandHours:(NSDictionary *)options
resolver:(RCTPromiseResolveBlock)resolver
rejecter:(RCTPromiseRejectBlock)rejecter)
// Water Intake Methods
RCT_EXTERN_METHOD(saveWaterIntakeToHealthKit:(NSDictionary *)options
resolver:(RCTPromiseResolveBlock)resolver
rejecter:(RCTPromiseRejectBlock)rejecter)
RCT_EXTERN_METHOD(getWaterIntakeFromHealthKit:(NSDictionary *)options
resolver:(RCTPromiseResolveBlock)resolver
rejecter:(RCTPromiseRejectBlock)rejecter)
@end

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

View File

@@ -0,0 +1,14 @@
{
"images": [
{
"filename": "App-Icon-1024x1024@1x.png",
"idiom": "universal",
"platform": "ios",
"size": "1024x1024"
}
],
"info": {
"version": 1,
"author": "expo"
}
}

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