Compare commits
20 Commits
63ed820e93
...
e2597c1bc4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2597c1bc4 | ||
|
|
a014998848 | ||
|
|
badd68c039 | ||
|
|
ad98d78e18 | ||
|
|
94899fbc5c | ||
|
|
0f289fcae7 | ||
|
|
79ab354f31 | ||
|
|
83e534c4a7 | ||
|
|
6303795870 | ||
| 028ef56caf | |||
|
|
e6dfd4d59a | ||
|
|
d082c66b72 | ||
|
|
dbe460a084 | ||
|
|
fb85a5f30c | ||
|
|
9bcea25a2f | ||
|
|
ccfccca7bc | ||
| 184fb672b7 | |||
|
|
2c382ab8de | ||
|
|
6f0c872223 | ||
|
|
6b7776e51d |
32
AGENTS.md
Normal 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
@@ -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
@@ -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
14
android/app/proguard-rules.pro
vendored
Normal 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:
|
||||||
7
android/app/src/debug/AndroidManifest.xml
Normal 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>
|
||||||
37
android/app/src/main/AndroidManifest.xml
Normal 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>
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
android/app/src/main/res/drawable-hdpi/notification_icon.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
android/app/src/main/res/drawable-hdpi/splashscreen_logo.png
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
BIN
android/app/src/main/res/drawable-mdpi/notification_icon.png
Normal file
|
After Width: | Height: | Size: 812 B |
BIN
android/app/src/main/res/drawable-mdpi/splashscreen_logo.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
android/app/src/main/res/drawable-xhdpi/notification_icon.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
android/app/src/main/res/drawable-xhdpi/splashscreen_logo.png
Normal file
|
After Width: | Height: | Size: 6.9 KiB |
BIN
android/app/src/main/res/drawable-xxhdpi/notification_icon.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
android/app/src/main/res/drawable-xxhdpi/splashscreen_logo.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
android/app/src/main/res/drawable-xxxhdpi/notification_icon.png
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
android/app/src/main/res/drawable-xxxhdpi/splashscreen_logo.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
@@ -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>
|
||||||
37
android/app/src/main/res/drawable/rn_edit_text_material.xml
Normal 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>
|
||||||
BIN
android/app/src/main/res/mipmap-hdpi/drink_water.png
Normal file
|
After Width: | Height: | Size: 7.7 KiB |
BIN
android/app/src/main/res/mipmap-hdpi/drink_water_foreground.png
Normal file
|
After Width: | Height: | Size: 7.7 KiB |
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/drink_water.png
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/drink_water_foreground.png
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp
Normal file
|
After Width: | Height: | Size: 6.0 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/drink_water.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/drink_water_foreground.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 19 KiB |
BIN
android/app/src/main/res/mipmap-xxhdpi/drink_water.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 17 KiB |
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 39 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/drink_water.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 25 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 65 KiB |
1
android/app/src/main/res/values-night/colors.xml
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<resources/>
|
||||||
7
android/app/src/main/res/values/colors.xml
Normal 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>
|
||||||
6
android/app/src/main/res/values/strings.xml
Normal 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>
|
||||||
14
android/app/src/main/res/values/styles.xml
Normal 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
@@ -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
@@ -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
|
||||||
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
7
android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal 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
@@ -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
@@ -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
@@ -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)
|
||||||
21
app.json
@@ -2,14 +2,16 @@
|
|||||||
"expo": {
|
"expo": {
|
||||||
"name": "Out Live",
|
"name": "Out Live",
|
||||||
"slug": "digital-pilates",
|
"slug": "digital-pilates",
|
||||||
"version": "1.0.12",
|
"version": "1.0.14",
|
||||||
"orientation": "portrait",
|
"orientation": "portrait",
|
||||||
"scheme": "digitalpilates",
|
"scheme": "digitalpilates",
|
||||||
"userInterfaceStyle": "light",
|
"userInterfaceStyle": "light",
|
||||||
|
"icon": "./assets/icon.icon/Assets/icon-1756312748268.jpg",
|
||||||
"newArchEnabled": true,
|
"newArchEnabled": true,
|
||||||
"jsEngine": "jsc",
|
"jsEngine": "jsc",
|
||||||
"ios": {
|
"ios": {
|
||||||
"supportsTablet": false,
|
"supportsTablet": false,
|
||||||
|
"deploymentTarget": "16.0",
|
||||||
"bundleIdentifier": "com.anonymous.digitalpilates",
|
"bundleIdentifier": "com.anonymous.digitalpilates",
|
||||||
"infoPlist": {
|
"infoPlist": {
|
||||||
"ITSAppUsesNonExemptEncryption": false,
|
"ITSAppUsesNonExemptEncryption": false,
|
||||||
@@ -25,31 +27,23 @@
|
|||||||
"remote-notification"
|
"remote-notification"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"icon": "./assets/icon.icon"
|
"appleTeamId": "756WVXJ6MT"
|
||||||
},
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
"expo-router",
|
"expo-router",
|
||||||
[
|
[
|
||||||
"expo-splash-screen",
|
"expo-splash-screen",
|
||||||
{
|
{
|
||||||
"image": "./assets/icon.icon",
|
"image": "./assets/icon.icon/Assets/icon-1756312748268.jpg",
|
||||||
"imageWidth": 40,
|
"imageWidth": 40,
|
||||||
"resizeMode": "contain",
|
"resizeMode": "contain",
|
||||||
"backgroundColor": "#ffffff"
|
"backgroundColor": "#ffffff"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
[
|
|
||||||
"react-native-health",
|
|
||||||
{
|
|
||||||
"enableHealthAPI": true,
|
|
||||||
"healthSharePermission": "应用需要访问您的健康数据(步数、能量消耗、心率变异性等)以展示运动统计和压力分析。",
|
|
||||||
"healthUpdatePermission": "应用需要更新您的健康数据(体重信息)以记录您的健身进度。"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
[
|
[
|
||||||
"expo-notifications",
|
"expo-notifications",
|
||||||
{
|
{
|
||||||
"icon": "./assets/icon.icon",
|
"icon": "./assets/icon.icon/Assets/icon-1756312748268.jpg",
|
||||||
"color": "#ffffff"
|
"color": "#ffffff"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -70,6 +64,9 @@
|
|||||||
],
|
],
|
||||||
"experiments": {
|
"experiments": {
|
||||||
"typedRoutes": true
|
"typedRoutes": true
|
||||||
|
},
|
||||||
|
"android": {
|
||||||
|
"package": "com.anonymous.digitalpilates"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -21,8 +21,8 @@ type TabConfig = {
|
|||||||
|
|
||||||
const TAB_CONFIGS: Record<string, TabConfig> = {
|
const TAB_CONFIGS: Record<string, TabConfig> = {
|
||||||
statistics: { icon: 'chart.pie.fill', title: '健康' },
|
statistics: { icon: 'chart.pie.fill', title: '健康' },
|
||||||
// explore: { icon: 'magnifyingglass.circle.fill', title: '发现' },
|
|
||||||
goals: { icon: 'flag.fill', title: '习惯' },
|
goals: { icon: 'flag.fill', title: '习惯' },
|
||||||
|
challenges: { icon: 'trophy.fill', title: '挑战' },
|
||||||
personal: { icon: 'person.fill', title: '个人' },
|
personal: { icon: 'person.fill', title: '个人' },
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -35,9 +35,10 @@ export default function TabLayout() {
|
|||||||
// Helper function to determine if a tab is selected
|
// Helper function to determine if a tab is selected
|
||||||
const isTabSelected = (routeName: string): boolean => {
|
const isTabSelected = (routeName: string): boolean => {
|
||||||
const routeMap: Record<string, string> = {
|
const routeMap: Record<string, string> = {
|
||||||
explore: ROUTES.TAB_EXPLORE,
|
|
||||||
goals: ROUTES.TAB_GOALS,
|
|
||||||
statistics: ROUTES.TAB_STATISTICS,
|
statistics: ROUTES.TAB_STATISTICS,
|
||||||
|
goals: ROUTES.TAB_GOALS,
|
||||||
|
challenges: ROUTES.TAB_CHALLENGES,
|
||||||
|
personal: ROUTES.TAB_PERSONAL,
|
||||||
};
|
};
|
||||||
|
|
||||||
return routeMap[routeName] === pathname || pathname.includes(routeName);
|
return routeMap[routeName] === pathname || pathname.includes(routeName);
|
||||||
@@ -69,11 +70,11 @@ export default function TabLayout() {
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
marginHorizontal: 6,
|
marginHorizontal: 2,
|
||||||
marginVertical: 10,
|
marginVertical: 10,
|
||||||
borderRadius: 25,
|
borderRadius: 25,
|
||||||
backgroundColor: isSelected ? colorTokens.tabBarActiveBackground : 'transparent',
|
backgroundColor: isSelected ? colorTokens.tabBarActiveBackground : 'transparent',
|
||||||
paddingHorizontal: isSelected ? 16 : 10,
|
paddingHorizontal: isSelected ? 8 : 4,
|
||||||
paddingVertical: 8,
|
paddingVertical: 8,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -91,7 +92,7 @@ export default function TabLayout() {
|
|||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
marginLeft: 6,
|
marginLeft: 6,
|
||||||
}}
|
}}
|
||||||
numberOfLines={0 as any}
|
numberOfLines={1}
|
||||||
>
|
>
|
||||||
{tabConfig.title}
|
{tabConfig.title}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -148,12 +149,12 @@ export default function TabLayout() {
|
|||||||
shadowOpacity: glassEffectAvailable ? 0.1 : 0.2,
|
shadowOpacity: glassEffectAvailable ? 0.1 : 0.2,
|
||||||
shadowRadius: 10,
|
shadowRadius: 10,
|
||||||
elevation: 5,
|
elevation: 5,
|
||||||
paddingHorizontal: 10,
|
paddingHorizontal: 6,
|
||||||
paddingTop: 0,
|
paddingTop: 0,
|
||||||
paddingBottom: 0,
|
paddingBottom: 0,
|
||||||
marginHorizontal: 20,
|
marginHorizontal: 16,
|
||||||
left: 20,
|
left: 16,
|
||||||
right: 20,
|
right: 16,
|
||||||
alignSelf: 'center',
|
alignSelf: 'center',
|
||||||
borderWidth: glassEffectAvailable ? 1 : 0,
|
borderWidth: glassEffectAvailable ? 1 : 0,
|
||||||
borderColor: glassEffectAvailable ? (theme === 'dark' ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)') : 'transparent',
|
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>
|
||||||
<NativeTabs.Trigger name="goals">
|
<NativeTabs.Trigger name="goals">
|
||||||
<Icon sf="flag.fill" drawable="custom_settings_drawable" />
|
<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>
|
||||||
<NativeTabs.Trigger name="personal">
|
<NativeTabs.Trigger name="personal">
|
||||||
<Icon sf="person.fill" drawable="custom_settings_drawable" />
|
<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="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="goals" options={{ title: '习惯' }} />
|
||||||
|
<Tabs.Screen name="challenges" options={{ title: '挑战' }} />
|
||||||
<Tabs.Screen name="personal" options={{ title: '个人' }} />
|
<Tabs.Screen name="personal" options={{ title: '个人' }} />
|
||||||
</Tabs>
|
</Tabs>
|
||||||
);
|
);
|
||||||
|
|||||||
297
app/(tabs)/challenges.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -109,6 +109,8 @@ export default function GoalsScreen() {
|
|||||||
const onRefresh = async () => {
|
const onRefresh = async () => {
|
||||||
setRefreshing(true);
|
setRefreshing(true);
|
||||||
try {
|
try {
|
||||||
|
if (!isLoggedIn) return
|
||||||
|
|
||||||
await loadTasks();
|
await loadTasks();
|
||||||
} finally {
|
} finally {
|
||||||
setRefreshing(false);
|
setRefreshing(false);
|
||||||
@@ -117,6 +119,8 @@ export default function GoalsScreen() {
|
|||||||
|
|
||||||
// 加载更多任务
|
// 加载更多任务
|
||||||
const handleLoadMoreTasks = async () => {
|
const handleLoadMoreTasks = async () => {
|
||||||
|
if (!isLoggedIn) return
|
||||||
|
|
||||||
if (tasksPagination.hasMore && !tasksLoading) {
|
if (tasksPagination.hasMore && !tasksLoading) {
|
||||||
try {
|
try {
|
||||||
await dispatch(loadMoreTasks()).unwrap();
|
await dispatch(loadMoreTasks()).unwrap();
|
||||||
@@ -319,6 +323,61 @@ export default function GoalsScreen() {
|
|||||||
|
|
||||||
// 渲染空状态
|
// 渲染空状态
|
||||||
const renderEmptyState = () => {
|
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 title = '暂无任务';
|
||||||
let subtitle = '创建目标后,系统会自动生成相应的任务';
|
let subtitle = '创建目标后,系统会自动生成相应的任务';
|
||||||
|
|
||||||
@@ -710,6 +769,80 @@ const styles = StyleSheet.create({
|
|||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
lineHeight: 20,
|
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: {
|
loadMoreContainer: {
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
paddingVertical: 20,
|
paddingVertical: 20,
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ import { DEFAULT_MEMBER_NAME, fetchActivityHistory, fetchMyProfile } from '@/sto
|
|||||||
import { getItem, setItem } from '@/utils/kvStore';
|
import { getItem, setItem } from '@/utils/kvStore';
|
||||||
import { log } from '@/utils/logger';
|
import { log } from '@/utils/logger';
|
||||||
import { getNotificationEnabled, setNotificationEnabled as saveNotificationEnabled } from '@/utils/userPreferences';
|
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 { Ionicons } from '@expo/vector-icons';
|
||||||
import { useFocusEffect } from '@react-navigation/native';
|
import { useFocusEffect } from '@react-navigation/native';
|
||||||
import { isLiquidGlassAvailable } from 'expo-glass-effect';
|
import { isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||||
@@ -214,35 +212,13 @@ export default function PersonalScreen() {
|
|||||||
<TouchableOpacity onPress={handleUserNamePress} activeOpacity={0.7}>
|
<TouchableOpacity onPress={handleUserNamePress} activeOpacity={0.7}>
|
||||||
<Text style={styles.userName}>{displayName}</Text>
|
<Text style={styles.userName}>{displayName}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
{userProfile.memberNumber && (
|
||||||
|
<Text style={styles.userMemberNumber}>会员编号: {userProfile.memberNumber}</Text>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
{isLgAvaliable ? <Host style={{
|
<TouchableOpacity style={styles.editButton} onPress={() => pushIfAuthedElseLogin('/profile/edit')}>
|
||||||
marginRight: 18,
|
<Text style={styles.editButtonText}>{isLoggedIn ? '编辑' : '登录'}</Text>
|
||||||
}}>
|
</TouchableOpacity>
|
||||||
<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>}
|
|
||||||
|
|
||||||
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -507,6 +483,11 @@ const styles = StyleSheet.create({
|
|||||||
color: '#9370DB',
|
color: '#9370DB',
|
||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
},
|
},
|
||||||
|
userMemberNumber: {
|
||||||
|
fontSize: 10,
|
||||||
|
color: '#6C757D',
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
editButton: {
|
editButton: {
|
||||||
backgroundColor: '#9370DB',
|
backgroundColor: '#9370DB',
|
||||||
paddingHorizontal: 16,
|
paddingHorizontal: 16,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { DateSelector } from '@/components/DateSelector';
|
|||||||
import { FitnessRingsCard } from '@/components/FitnessRingsCard';
|
import { FitnessRingsCard } from '@/components/FitnessRingsCard';
|
||||||
import { MoodCard } from '@/components/MoodCard';
|
import { MoodCard } from '@/components/MoodCard';
|
||||||
import { NutritionRadarCard } from '@/components/NutritionRadarCard';
|
import { NutritionRadarCard } from '@/components/NutritionRadarCard';
|
||||||
|
import CircumferenceCard from '@/components/statistic/CircumferenceCard';
|
||||||
import OxygenSaturationCard from '@/components/statistic/OxygenSaturationCard';
|
import OxygenSaturationCard from '@/components/statistic/OxygenSaturationCard';
|
||||||
import SleepCard from '@/components/statistic/SleepCard';
|
import SleepCard from '@/components/statistic/SleepCard';
|
||||||
import StepsCard from '@/components/StepsCard';
|
import StepsCard from '@/components/StepsCard';
|
||||||
@@ -13,14 +14,11 @@ import { Colors } from '@/constants/Colors';
|
|||||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||||
import { BackgroundTaskManager } from '@/services/backgroundTaskManager';
|
import { BackgroundTaskManager } from '@/services/backgroundTaskManager';
|
||||||
import { selectHealthDataByDate, setHealthData } from '@/store/healthSlice';
|
import { setHealthData } from '@/store/healthSlice';
|
||||||
import { fetchDailyMoodCheckins, selectLatestMoodRecordByDate } from '@/store/moodSlice';
|
import { fetchDailyMoodCheckins, selectLatestMoodRecordByDate } from '@/store/moodSlice';
|
||||||
import { fetchDailyNutritionData, selectNutritionSummaryByDate } from '@/store/nutritionSlice';
|
|
||||||
import { fetchTodayWaterStats } from '@/store/waterSlice';
|
import { fetchTodayWaterStats } from '@/store/waterSlice';
|
||||||
import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date';
|
import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date';
|
||||||
import { ensureHealthPermissions, fetchHealthDataForDate, testHRVDataFetch } from '@/utils/health';
|
import { fetchHealthDataForDate, testHRVDataFetch } from '@/utils/health';
|
||||||
import { getTestHealthData } from '@/utils/mockHealthData';
|
|
||||||
import { calculateNutritionGoals } from '@/utils/nutrition';
|
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
import { debounce } from 'lodash';
|
import { debounce } from 'lodash';
|
||||||
@@ -37,13 +35,10 @@ import {
|
|||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
// 浮动动画组件
|
// 浮动动画组件
|
||||||
const FloatingCard = ({ children, delay = 0, style }: {
|
const FloatingCard = ({ children, style }: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
delay?: number;
|
|
||||||
style?: any;
|
style?: any;
|
||||||
}) => {
|
}) => {
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
@@ -60,11 +55,6 @@ const FloatingCard = ({ children, delay = 0, style }: {
|
|||||||
|
|
||||||
export default function ExploreScreen() {
|
export default function ExploreScreen() {
|
||||||
const stepGoal = useAppSelector((s) => s.user.profile?.dailyStepsGoal) ?? 2000;
|
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();
|
const { pushIfAuthedElseLogin, isLoggedIn } = useAuthGuard();
|
||||||
|
|
||||||
@@ -83,56 +73,11 @@ export default function ExploreScreen() {
|
|||||||
return dayjs(currentSelectedDate).format('YYYY-MM-DD');
|
return dayjs(currentSelectedDate).format('YYYY-MM-DD');
|
||||||
}, [currentSelectedDate]);
|
}, [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(当日期或数据变化时更新)
|
// 用于触发动画重置的 token(当日期或数据变化时更新)
|
||||||
const [animToken, setAnimToken] = useState(0);
|
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();
|
const dispatch = useAppDispatch();
|
||||||
@@ -144,7 +89,6 @@ export default function ExploreScreen() {
|
|||||||
// 请求状态管理,防止重复请求
|
// 请求状态管理,防止重复请求
|
||||||
const loadingRef = useRef({
|
const loadingRef = useRef({
|
||||||
health: false,
|
health: false,
|
||||||
nutrition: false,
|
|
||||||
mood: 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()}`;
|
const getDateKey = (d: Date) => `${dayjs(d).year()}-${dayjs(d).month() + 1}-${dayjs(d).date()}`;
|
||||||
|
|
||||||
// 检查数据是否需要刷新(2分钟内不重复拉取,对营养数据更严格)
|
// 检查数据是否需要刷新(5分钟内不重复拉取)
|
||||||
const shouldRefreshData = (dateKey: string, dataType: string) => {
|
const shouldRefreshData = (dateKey: string, dataType: string) => {
|
||||||
const cacheKey = `${dateKey}-${dataType}`;
|
const cacheKey = `${dateKey}-${dataType}`;
|
||||||
const lastUpdate = dataTimestampRef.current[cacheKey];
|
const lastUpdate = dataTimestampRef.current[cacheKey];
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
// 营养数据使用更短的缓存时间,其他数据使用5分钟
|
// 使用5分钟缓存时间
|
||||||
const cacheTime = dataType === 'nutrition' ? 2 * 60 * 1000 : 5 * 60 * 1000;
|
const cacheTime = 5 * 60 * 1000;
|
||||||
|
|
||||||
return !lastUpdate || (now - lastUpdate) > cacheTime;
|
return !lastUpdate || (now - lastUpdate) > cacheTime;
|
||||||
};
|
};
|
||||||
@@ -248,13 +192,6 @@ export default function ExploreScreen() {
|
|||||||
loadingRef.current.health = true;
|
loadingRef.current.health = true;
|
||||||
console.log('=== 开始HealthKit初始化流程 ===');
|
console.log('=== 开始HealthKit初始化流程 ===');
|
||||||
|
|
||||||
const ok = await ensureHealthPermissions();
|
|
||||||
if (!ok) {
|
|
||||||
const errorMsg = '无法获取健康权限,请确保在真实iOS设备上运行并授权应用访问健康数据';
|
|
||||||
console.warn(errorMsg);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
latestRequestKeyRef.current = requestKey;
|
latestRequestKeyRef.current = requestKey;
|
||||||
|
|
||||||
console.log('权限获取成功,开始获取健康数据...', derivedDate);
|
console.log('权限获取成功,开始获取健康数据...', derivedDate);
|
||||||
@@ -271,9 +208,6 @@ export default function ExploreScreen() {
|
|||||||
date: dateString,
|
date: dateString,
|
||||||
data: {
|
data: {
|
||||||
activeCalories: data.activeEnergyBurned,
|
activeCalories: data.activeEnergyBurned,
|
||||||
basalEnergyBurned: data.basalEnergyBurned,
|
|
||||||
hrv: data.hrv,
|
|
||||||
oxygenSaturation: data.oxygenSaturation,
|
|
||||||
heartRate: data.heartRate,
|
heartRate: data.heartRate,
|
||||||
activeEnergyBurned: data.activeEnergyBurned,
|
activeEnergyBurned: data.activeEnergyBurned,
|
||||||
activeCaloriesGoal: data.activeCaloriesGoal,
|
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) => {
|
const executeLoadAllData = React.useCallback((targetDate?: Date, forceRefresh = false) => {
|
||||||
@@ -348,7 +243,6 @@ export default function ExploreScreen() {
|
|||||||
console.log('执行数据加载,日期:', dateToUse, '强制刷新:', forceRefresh);
|
console.log('执行数据加载,日期:', dateToUse, '强制刷新:', forceRefresh);
|
||||||
loadHealthData(dateToUse, forceRefresh);
|
loadHealthData(dateToUse, forceRefresh);
|
||||||
if (isLoggedIn) {
|
if (isLoggedIn) {
|
||||||
loadNutritionData(dateToUse, forceRefresh);
|
|
||||||
loadMoodData(dateToUse, forceRefresh);
|
loadMoodData(dateToUse, forceRefresh);
|
||||||
// 加载喝水数据(只加载今日数据用于后台检查)
|
// 加载喝水数据(只加载今日数据用于后台检查)
|
||||||
const isToday = dayjs(dateToUse).isSame(dayjs(), 'day');
|
const isToday = dayjs(dateToUse).isSame(dayjs(), 'day');
|
||||||
@@ -380,14 +274,6 @@ export default function ExploreScreen() {
|
|||||||
loadAllData(currentSelectedDate);
|
loadAllData(currentSelectedDate);
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// 页面聚焦时的数据加载逻辑
|
|
||||||
// useFocusEffect(
|
|
||||||
// React.useCallback(() => {
|
|
||||||
// // 页面聚焦时加载数据,使用缓存机制避免频繁请求
|
|
||||||
// console.log('页面聚焦,检查是否需要刷新数据...');
|
|
||||||
// loadAllData(currentSelectedDate);
|
|
||||||
// }, [loadAllData, currentSelectedDate])
|
|
||||||
// );
|
|
||||||
|
|
||||||
// AppState 监听:应用从后台返回前台时的处理
|
// AppState 监听:应用从后台返回前台时的处理
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -508,17 +394,8 @@ export default function ExploreScreen() {
|
|||||||
|
|
||||||
{/* 营养摄入雷达图卡片 */}
|
{/* 营养摄入雷达图卡片 */}
|
||||||
<NutritionRadarCard
|
<NutritionRadarCard
|
||||||
nutritionSummary={nutritionSummary}
|
selectedDate={currentSelectedDate}
|
||||||
nutritionGoals={nutritionGoals}
|
|
||||||
burnedCalories={(basalMetabolism || 0) + (activeCalories || 0)}
|
|
||||||
basalMetabolism={basalMetabolism || 0}
|
|
||||||
activeCalories={activeCalories || 0}
|
|
||||||
resetToken={animToken}
|
resetToken={animToken}
|
||||||
onMealPress={(mealType: 'breakfast' | 'lunch' | 'dinner' | 'snack') => {
|
|
||||||
console.log('选择餐次:', mealType);
|
|
||||||
// 这里可以导航到营养记录页面
|
|
||||||
pushIfAuthedElseLogin('/nutrition/records');
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<WeightHistoryCard />
|
<WeightHistoryCard />
|
||||||
@@ -528,7 +405,7 @@ export default function ExploreScreen() {
|
|||||||
{/* 左列 */}
|
{/* 左列 */}
|
||||||
<View style={styles.masonryColumn}>
|
<View style={styles.masonryColumn}>
|
||||||
{/* 心情卡片 */}
|
{/* 心情卡片 */}
|
||||||
<FloatingCard style={styles.masonryCard} delay={1500}>
|
<FloatingCard style={styles.masonryCard}>
|
||||||
<MoodCard
|
<MoodCard
|
||||||
moodCheckin={currentMoodCheckin}
|
moodCheckin={currentMoodCheckin}
|
||||||
onPress={() => pushIfAuthedElseLogin('/mood/calendar')}
|
onPress={() => pushIfAuthedElseLogin('/mood/calendar')}
|
||||||
@@ -546,7 +423,7 @@ export default function ExploreScreen() {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
<FloatingCard style={styles.masonryCard} delay={0}>
|
<FloatingCard style={styles.masonryCard}>
|
||||||
<StressMeter
|
<StressMeter
|
||||||
curDate={currentSelectedDate}
|
curDate={currentSelectedDate}
|
||||||
/>
|
/>
|
||||||
@@ -564,26 +441,20 @@ export default function ExploreScreen() {
|
|||||||
<FloatingCard style={styles.masonryCard}>
|
<FloatingCard style={styles.masonryCard}>
|
||||||
<SleepCard
|
<SleepCard
|
||||||
selectedDate={currentSelectedDate}
|
selectedDate={currentSelectedDate}
|
||||||
onPress={() => pushIfAuthedElseLogin(`/sleep-detail?date=${dayjs(currentSelectedDate).format('YYYY-MM-DD')}`)}
|
|
||||||
/>
|
/>
|
||||||
</FloatingCard>
|
</FloatingCard>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* 右列 */}
|
{/* 右列 */}
|
||||||
<View style={styles.masonryColumn}>
|
<View style={styles.masonryColumn}>
|
||||||
<FloatingCard style={styles.masonryCard} delay={250}>
|
<FloatingCard style={styles.masonryCard}>
|
||||||
<FitnessRingsCard
|
<FitnessRingsCard
|
||||||
activeCalories={fitnessRingsData.activeCalories}
|
selectedDate={currentSelectedDate}
|
||||||
activeCaloriesGoal={fitnessRingsData.activeCaloriesGoal}
|
|
||||||
exerciseMinutes={fitnessRingsData.exerciseMinutes}
|
|
||||||
exerciseMinutesGoal={fitnessRingsData.exerciseMinutesGoal}
|
|
||||||
standHours={fitnessRingsData.standHours}
|
|
||||||
standHoursGoal={fitnessRingsData.standHoursGoal}
|
|
||||||
resetToken={animToken}
|
resetToken={animToken}
|
||||||
/>
|
/>
|
||||||
</FloatingCard>
|
</FloatingCard>
|
||||||
{/* 饮水记录卡片 */}
|
{/* 饮水记录卡片 */}
|
||||||
<FloatingCard style={styles.masonryCard} delay={500}>
|
<FloatingCard style={styles.masonryCard}>
|
||||||
<WaterIntakeCard
|
<WaterIntakeCard
|
||||||
selectedDate={currentSelectedDateString}
|
selectedDate={currentSelectedDateString}
|
||||||
style={styles.waterCardOverride}
|
style={styles.waterCardOverride}
|
||||||
@@ -592,26 +463,26 @@ export default function ExploreScreen() {
|
|||||||
|
|
||||||
|
|
||||||
{/* 基础代谢卡片 */}
|
{/* 基础代谢卡片 */}
|
||||||
<FloatingCard style={styles.masonryCard} delay={1250}>
|
<FloatingCard style={styles.masonryCard}>
|
||||||
<BasalMetabolismCard
|
<BasalMetabolismCard
|
||||||
value={basalMetabolism}
|
selectedDate={currentSelectedDate}
|
||||||
resetToken={animToken}
|
|
||||||
style={styles.basalMetabolismCardOverride}
|
style={styles.basalMetabolismCardOverride}
|
||||||
/>
|
/>
|
||||||
</FloatingCard>
|
</FloatingCard>
|
||||||
|
|
||||||
{/* 血氧饱和度卡片 */}
|
{/* 血氧饱和度卡片 */}
|
||||||
<FloatingCard style={styles.masonryCard} delay={1750}>
|
<FloatingCard style={styles.masonryCard}>
|
||||||
<OxygenSaturationCard
|
<OxygenSaturationCard
|
||||||
resetToken={animToken}
|
|
||||||
style={styles.basalMetabolismCardOverride}
|
style={styles.basalMetabolismCardOverride}
|
||||||
oxygenSaturation={oxygenSaturation}
|
|
||||||
/>
|
/>
|
||||||
</FloatingCard>
|
</FloatingCard>
|
||||||
|
|
||||||
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{/* 围度数据卡片 - 占满底部一行 */}
|
||||||
|
<CircumferenceCard style={styles.circumferenceCard} />
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -913,7 +784,6 @@ const styles = StyleSheet.create({
|
|||||||
marginBottom: 16,
|
marginBottom: 16,
|
||||||
},
|
},
|
||||||
masonryContainer: {
|
masonryContainer: {
|
||||||
marginBottom: 16,
|
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
gap: 16,
|
gap: 16,
|
||||||
marginTop: 6,
|
marginTop: 6,
|
||||||
@@ -976,6 +846,10 @@ const styles = StyleSheet.create({
|
|||||||
top: 0,
|
top: 0,
|
||||||
padding: 4,
|
padding: 4,
|
||||||
},
|
},
|
||||||
|
circumferenceCard: {
|
||||||
|
marginBottom: 36,
|
||||||
|
marginTop: 10,
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,23 +14,25 @@ import { setupQuickActions } from '@/services/quickActions';
|
|||||||
import { initializeWaterRecordBridge } from '@/services/waterRecordBridge';
|
import { initializeWaterRecordBridge } from '@/services/waterRecordBridge';
|
||||||
import { WaterRecordSource } from '@/services/waterRecords';
|
import { WaterRecordSource } from '@/services/waterRecords';
|
||||||
import { store } from '@/store';
|
import { store } from '@/store';
|
||||||
import { rehydrateUserSync, setPrivacyAgreed } from '@/store/userSlice';
|
import { fetchMyProfile, setPrivacyAgreed } from '@/store/userSlice';
|
||||||
import { createWaterRecordAction } from '@/store/waterSlice';
|
import { createWaterRecordAction } from '@/store/waterSlice';
|
||||||
|
import { ensureHealthPermissions, initializeHealthPermissions } from '@/utils/health';
|
||||||
import { DailySummaryNotificationHelpers, MoodNotificationHelpers, NutritionNotificationHelpers } from '@/utils/notificationHelpers';
|
import { DailySummaryNotificationHelpers, MoodNotificationHelpers, NutritionNotificationHelpers } from '@/utils/notificationHelpers';
|
||||||
import { clearPendingWaterRecords, syncPendingWidgetChanges } from '@/utils/widgetDataSync';
|
import { clearPendingWaterRecords, syncPendingWidgetChanges } from '@/utils/widgetDataSync';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { DialogProvider } from '@/components/ui/DialogProvider';
|
import { DialogProvider } from '@/components/ui/DialogProvider';
|
||||||
import { ToastProvider } from '@/contexts/ToastContext';
|
import { ToastProvider } from '@/contexts/ToastContext';
|
||||||
|
import { STORAGE_KEYS } from '@/services/api';
|
||||||
import { BackgroundTaskManager } from '@/services/backgroundTaskManager';
|
import { BackgroundTaskManager } from '@/services/backgroundTaskManager';
|
||||||
|
import AsyncStorage from '@/utils/kvStore';
|
||||||
import { Provider } from 'react-redux';
|
import { Provider } from 'react-redux';
|
||||||
|
|
||||||
|
|
||||||
function Bootstrapper({ children }: { children: React.ReactNode }) {
|
function Bootstrapper({ children }: { children: React.ReactNode }) {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const { privacyAgreed, profile } = useAppSelector((state) => state.user);
|
const { profile } = useAppSelector((state) => state.user);
|
||||||
const [showPrivacyModal, setShowPrivacyModal] = React.useState(false);
|
const [showPrivacyModal, setShowPrivacyModal] = React.useState(false);
|
||||||
const [userDataLoaded, setUserDataLoaded] = React.useState(false);
|
|
||||||
|
|
||||||
// 初始化快捷动作处理
|
// 初始化快捷动作处理
|
||||||
useQuickActions();
|
useQuickActions();
|
||||||
@@ -38,12 +40,33 @@ function Bootstrapper({ children }: { children: React.ReactNode }) {
|
|||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const loadUserData = async () => {
|
const loadUserData = async () => {
|
||||||
// 数据已经在启动界面预加载,这里只需要快速同步到 Redux 状态
|
// 数据已经在启动界面预加载,这里只需要快速同步到 Redux 状态
|
||||||
await dispatch(rehydrateUserSync());
|
await dispatch(fetchMyProfile());
|
||||||
setUserDataLoaded(true);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 () => {
|
const initializeNotifications = async () => {
|
||||||
try {
|
try {
|
||||||
|
|
||||||
await BackgroundTaskManager.getInstance().initialize();
|
await BackgroundTaskManager.getInstance().initialize();
|
||||||
// 初始化通知服务
|
// 初始化通知服务
|
||||||
await notificationService.initialize();
|
await notificationService.initialize();
|
||||||
@@ -102,17 +125,22 @@ function Bootstrapper({ children }: { children: React.ReactNode }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
loadUserData();
|
loadUserData();
|
||||||
|
initHealthPermissions();
|
||||||
initializeNotifications();
|
initializeNotifications();
|
||||||
// 冷启动时清空 AI 教练会话缓存
|
// 冷启动时清空 AI 教练会话缓存
|
||||||
clearAiCoachSessionCache();
|
clearAiCoachSessionCache();
|
||||||
|
|
||||||
}, [dispatch]);
|
}, [dispatch]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
// 当用户数据加载完成后,检查是否需要显示隐私同意弹窗
|
|
||||||
if (userDataLoaded && !privacyAgreed) {
|
const getPrivacyAgreed = async () => {
|
||||||
setShowPrivacyModal(true);
|
const str = await AsyncStorage.getItem(STORAGE_KEYS.privacyAgreed)
|
||||||
|
|
||||||
|
setShowPrivacyModal(str !== 'true');
|
||||||
}
|
}
|
||||||
}, [userDataLoaded, privacyAgreed]);
|
getPrivacyAgreed();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handlePrivacyAgree = () => {
|
const handlePrivacyAgree = () => {
|
||||||
dispatch(setPrivacyAgreed());
|
dispatch(setPrivacyAgreed());
|
||||||
@@ -165,6 +193,7 @@ export default function RootLayout() {
|
|||||||
<Stack.Screen name="legal/user-agreement" options={{ headerShown: true, title: '用户协议' }} />
|
<Stack.Screen name="legal/user-agreement" options={{ headerShown: true, title: '用户协议' }} />
|
||||||
<Stack.Screen name="legal/privacy-policy" 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="article/[id]" options={{ headerShown: false }} />
|
||||||
|
<Stack.Screen name="water-detail" options={{ headerShown: false }} />
|
||||||
<Stack.Screen name="water-settings" options={{ headerShown: false }} />
|
<Stack.Screen name="water-settings" options={{ headerShown: false }} />
|
||||||
<Stack.Screen name="+not-found" />
|
<Stack.Screen name="+not-found" />
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
820
app/basal-metabolism-detail.tsx
Normal 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
@@ -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',
|
||||||
|
},
|
||||||
|
});
|
||||||
700
app/circumference-detail.tsx
Normal 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',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -30,8 +30,8 @@ import { api, getAuthToken, postTextStream } from '@/services/api';
|
|||||||
import { selectLatestMoodRecordByDate } from '@/store/moodSlice';
|
import { selectLatestMoodRecordByDate } from '@/store/moodSlice';
|
||||||
import { generateWelcomeMessage, hasRecordedMoodToday } from '@/utils/welcomeMessage';
|
import { generateWelcomeMessage, hasRecordedMoodToday } from '@/utils/welcomeMessage';
|
||||||
import { Image } from 'expo-image';
|
import { Image } from 'expo-image';
|
||||||
import { HistoryModal } from '../../components/model/HistoryModal';
|
import { HistoryModal } from '../components/model/HistoryModal';
|
||||||
import { ActionSheet } from '../../components/ui/ActionSheet';
|
import { ActionSheet } from '../components/ui/ActionSheet';
|
||||||
|
|
||||||
// 导入新的 coach 组件
|
// 导入新的 coach 组件
|
||||||
import {
|
import {
|
||||||
@@ -15,9 +15,12 @@ import { Ionicons } from '@expo/vector-icons';
|
|||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
import { router, useLocalSearchParams } from 'expo-router';
|
import { router, useLocalSearchParams } from 'expo-router';
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Alert, Image,
|
Alert, Image,
|
||||||
|
Keyboard,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Platform,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
Text,
|
Text,
|
||||||
@@ -43,6 +46,9 @@ export default function MoodEditScreen() {
|
|||||||
const [isDeleting, setIsDeleting] = useState(false);
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
const [existingMood, setExistingMood] = useState<any>(null);
|
const [existingMood, setExistingMood] = useState<any>(null);
|
||||||
|
|
||||||
|
const scrollViewRef = useRef<ScrollView>(null);
|
||||||
|
const textInputRef = useRef<TextInput>(null);
|
||||||
|
|
||||||
const moodOptions = getMoodOptions();
|
const moodOptions = getMoodOptions();
|
||||||
|
|
||||||
// 从 Redux 获取数据
|
// 从 Redux 获取数据
|
||||||
@@ -66,6 +72,25 @@ export default function MoodEditScreen() {
|
|||||||
}
|
}
|
||||||
}, [moodId, moodRecords]);
|
}, [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 () => {
|
const handleSave = async () => {
|
||||||
if (!selectedMood) {
|
if (!selectedMood) {
|
||||||
Alert.alert('提示', '请选择心情');
|
Alert.alert('提示', '请选择心情');
|
||||||
@@ -163,7 +188,18 @@ export default function MoodEditScreen() {
|
|||||||
tone="light"
|
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}>
|
<View style={styles.dateSection}>
|
||||||
<Text style={styles.dateTitle}>
|
<Text style={styles.dateTitle}>
|
||||||
@@ -211,6 +247,7 @@ export default function MoodEditScreen() {
|
|||||||
<Text style={styles.sectionTitle}>心情日记</Text>
|
<Text style={styles.sectionTitle}>心情日记</Text>
|
||||||
<Text style={styles.diarySubtitle}>记录你的心情,珍藏美好回忆</Text>
|
<Text style={styles.diarySubtitle}>记录你的心情,珍藏美好回忆</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
|
ref={textInputRef}
|
||||||
style={styles.descriptionInput}
|
style={styles.descriptionInput}
|
||||||
placeholder={`今天的心情如何?
|
placeholder={`今天的心情如何?
|
||||||
|
|
||||||
@@ -225,11 +262,18 @@ export default function MoodEditScreen() {
|
|||||||
multiline
|
multiline
|
||||||
maxLength={1000}
|
maxLength={1000}
|
||||||
textAlignVertical="top"
|
textAlignVertical="top"
|
||||||
|
onFocus={() => {
|
||||||
|
// 当文本输入框获得焦点时,滚动到输入框
|
||||||
|
setTimeout(() => {
|
||||||
|
scrollViewRef.current?.scrollToEnd({ animated: true });
|
||||||
|
}, 300);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<Text style={styles.characterCount}>{description.length}/1000</Text>
|
<Text style={styles.characterCount}>{description.length}/1000</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
|
||||||
{/* 底部按钮 */}
|
{/* 底部按钮 */}
|
||||||
<View style={styles.footer}>
|
<View style={styles.footer}>
|
||||||
@@ -294,10 +338,15 @@ const styles = StyleSheet.create({
|
|||||||
safeArea: {
|
safeArea: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
|
keyboardAvoidingView: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
content: {
|
content: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
|
scrollContent: {
|
||||||
|
paddingBottom: 100, // 为底部按钮留出空间
|
||||||
|
},
|
||||||
dateSection: {
|
dateSection: {
|
||||||
backgroundColor: 'rgba(255,255,255,0.95)',
|
backgroundColor: 'rgba(255,255,255,0.95)',
|
||||||
margin: 12,
|
margin: 12,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
selectNutritionSummaryByDate
|
selectNutritionSummaryByDate
|
||||||
} from '@/store/nutritionSlice';
|
} from '@/store/nutritionSlice';
|
||||||
import { getMonthDaysZh, getMonthTitleZh, getTodayIndexInMonth } from '@/utils/date';
|
import { getMonthDaysZh, getMonthTitleZh, getTodayIndexInMonth } from '@/utils/date';
|
||||||
|
import { fetchBasalEnergyBurned } from '@/utils/health';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { useFocusEffect } from '@react-navigation/native';
|
import { useFocusEffect } from '@react-navigation/native';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
@@ -73,6 +74,9 @@ export default function NutritionRecordsScreen() {
|
|||||||
const [hasMoreData, setHasMoreData] = useState(true);
|
const [hasMoreData, setHasMoreData] = useState(true);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
|
|
||||||
|
// 基础代谢数据状态
|
||||||
|
const [basalMetabolism, setBasalMetabolism] = useState<number>(1482);
|
||||||
|
|
||||||
// 食物添加弹窗状态
|
// 食物添加弹窗状态
|
||||||
const [showFoodOverlay, setShowFoodOverlay] = useState(false);
|
const [showFoodOverlay, setShowFoodOverlay] = useState(false);
|
||||||
|
|
||||||
@@ -118,6 +122,7 @@ export default function NutritionRecordsScreen() {
|
|||||||
|
|
||||||
// 当选中日期或视图模式变化时重新加载数据
|
// 当选中日期或视图模式变化时重新加载数据
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
fetchBasalMetabolismData();
|
||||||
if (viewMode === 'daily') {
|
if (viewMode === 'daily') {
|
||||||
dispatch(fetchDailyNutritionData(currentSelectedDate));
|
dispatch(fetchDailyNutritionData(currentSelectedDate));
|
||||||
} else {
|
} else {
|
||||||
@@ -150,6 +155,22 @@ export default function NutritionRecordsScreen() {
|
|||||||
}
|
}
|
||||||
}, [viewMode, currentSelectedDateString, dispatch]);
|
}, [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 () => {
|
const onRefresh = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setRefreshing(true);
|
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 = () => {
|
const renderDateSelector = () => {
|
||||||
@@ -445,7 +430,7 @@ export default function NutritionRecordsScreen() {
|
|||||||
|
|
||||||
{/* Calorie Ring Chart */}
|
{/* Calorie Ring Chart */}
|
||||||
<CalorieRingChart
|
<CalorieRingChart
|
||||||
metabolism={healthData?.basalEnergyBurned || 1482}
|
metabolism={basalMetabolism}
|
||||||
exercise={healthData?.activeEnergyBurned || 0}
|
exercise={healthData?.activeEnergyBurned || 0}
|
||||||
consumed={nutritionSummary?.totalCalories || 0}
|
consumed={nutritionSummary?.totalCalories || 0}
|
||||||
protein={nutritionSummary?.totalProtein || 0}
|
protein={nutritionSummary?.totalProtein || 0}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Colors } from '@/constants/Colors';
|
import { Colors } from '@/constants/Colors';
|
||||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
|
||||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
import { useWaterDataByDate } from '@/hooks/useWaterData';
|
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 { Ionicons } from '@expo/vector-icons';
|
||||||
import { Picker } from '@react-native-picker/picker';
|
import { Picker } from '@react-native-picker/picker';
|
||||||
import { Image } from 'expo-image';
|
import { Image } from 'expo-image';
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
Pressable,
|
Pressable,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
|
Switch,
|
||||||
Text,
|
Text,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
View
|
View
|
||||||
@@ -26,85 +27,32 @@ import { Swipeable } from 'react-native-gesture-handler';
|
|||||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
interface WaterSettingsProps {
|
interface WaterDetailProps {
|
||||||
selectedDate?: string;
|
selectedDate?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const WaterSettings: React.FC<WaterSettingsProps> = () => {
|
const WaterDetail: React.FC<WaterDetailProps> = () => {
|
||||||
const { selectedDate } = useLocalSearchParams<{ selectedDate?: string }>();
|
const { selectedDate } = useLocalSearchParams<{ selectedDate?: string }>();
|
||||||
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||||
const colorTokens = Colors[theme];
|
const colorTokens = Colors[theme];
|
||||||
const { ensureLoggedIn } = useAuthGuard();
|
|
||||||
|
|
||||||
const [dailyGoal, setDailyGoal] = useState<string>('2000');
|
const [dailyGoal, setDailyGoal] = useState<string>('2000');
|
||||||
const [quickAddAmount, setQuickAddAmount] = useState<string>('250');
|
const [quickAddAmount, setQuickAddAmount] = useState<string>('250');
|
||||||
|
|
||||||
// 编辑弹窗状态
|
// Remove modal states as they are now in separate settings page
|
||||||
const [goalModalVisible, setGoalModalVisible] = useState(false);
|
|
||||||
const [quickAddModalVisible, setQuickAddModalVisible] = useState(false);
|
|
||||||
|
|
||||||
// 临时选中值
|
|
||||||
const [tempGoal, setTempGoal] = useState<number>(parseInt(dailyGoal));
|
|
||||||
const [tempQuickAdd, setTempQuickAdd] = useState<number>(parseInt(quickAddAmount));
|
|
||||||
|
|
||||||
// 使用新的 hook 来处理指定日期的饮水数据
|
// 使用新的 hook 来处理指定日期的饮水数据
|
||||||
const { waterRecords, dailyWaterGoal, updateWaterGoal, removeWaterRecord } = useWaterDataByDate(selectedDate);
|
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));
|
const handleSettingsPress = () => {
|
||||||
setGoalModalVisible(true);
|
router.push('/water/settings');
|
||||||
};
|
};
|
||||||
|
|
||||||
// 打开快速添加弹窗时初始化临时值
|
// Remove all modal-related functions as they are now in separate settings page
|
||||||
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('设置失败', '无法保存快速添加默认值,请重试');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
// 删除饮水记录
|
// 删除饮水记录
|
||||||
@@ -131,15 +79,6 @@ const WaterSettings: React.FC<WaterSettingsProps> = () => {
|
|||||||
loadUserPreferences();
|
loadUserPreferences();
|
||||||
}, [dailyWaterGoal]);
|
}, [dailyWaterGoal]);
|
||||||
|
|
||||||
// 当dailyGoal或quickAddAmount更新时,同步更新临时状态
|
|
||||||
useEffect(() => {
|
|
||||||
setTempGoal(parseInt(dailyGoal));
|
|
||||||
}, [dailyGoal]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setTempQuickAdd(parseInt(quickAddAmount));
|
|
||||||
}, [quickAddAmount]);
|
|
||||||
|
|
||||||
// 新增:饮水记录卡片组件
|
// 新增:饮水记录卡片组件
|
||||||
const WaterRecordCard = ({ record, onDelete }: { record: any; onDelete: () => void }) => {
|
const WaterRecordCard = ({ record, onDelete }: { record: any; onDelete: () => void }) => {
|
||||||
const swipeableRef = React.useRef<Swipeable>(null);
|
const swipeableRef = React.useRef<Swipeable>(null);
|
||||||
@@ -233,11 +172,20 @@ const WaterSettings: React.FC<WaterSettingsProps> = () => {
|
|||||||
<View style={styles.decorativeCircle2} />
|
<View style={styles.decorativeCircle2} />
|
||||||
|
|
||||||
<HeaderBar
|
<HeaderBar
|
||||||
title="饮水设置"
|
title="饮水详情"
|
||||||
onBack={() => {
|
onBack={() => {
|
||||||
// 这里会通过路由自动处理返回
|
// 这里会通过路由自动处理返回
|
||||||
router.back();
|
router.back();
|
||||||
}}
|
}}
|
||||||
|
right={
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.settingsButton}
|
||||||
|
onPress={handleSettingsPress}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Ionicons name="settings-outline" size={24} color={colorTokens.text} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<KeyboardAvoidingView
|
<KeyboardAvoidingView
|
||||||
@@ -249,44 +197,6 @@ const WaterSettings: React.FC<WaterSettingsProps> = () => {
|
|||||||
contentContainerStyle={styles.scrollContent}
|
contentContainerStyle={styles.scrollContent}
|
||||||
showsVerticalScrollIndicator={false}
|
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}>
|
<View style={styles.section}>
|
||||||
@@ -325,83 +235,7 @@ const WaterSettings: React.FC<WaterSettingsProps> = () => {
|
|||||||
</ScrollView>
|
</ScrollView>
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
|
|
||||||
{/* 饮水目标编辑弹窗 */}
|
{/* All modals have been moved to the separate water-settings page */}
|
||||||
<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>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -466,79 +300,6 @@ const styles = StyleSheet.create({
|
|||||||
fontWeight: '400',
|
fontWeight: '400',
|
||||||
lineHeight: 18,
|
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: {
|
recordsList: {
|
||||||
gap: 12,
|
gap: 12,
|
||||||
@@ -714,6 +475,225 @@ const styles = StyleSheet.create({
|
|||||||
modalBtnTextPrimary: {
|
modalBtnTextPrimary: {
|
||||||
// color will be set dynamically
|
// 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;
|
||||||
618
app/water/reminder-settings.tsx
Normal 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
@@ -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;
|
||||||
@@ -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 { Image } from 'expo-image';
|
||||||
import React from 'react';
|
import { router } from 'expo-router';
|
||||||
import { StyleSheet, Text, View } from 'react-native';
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||||
|
|
||||||
interface BasalMetabolismCardProps {
|
interface BasalMetabolismCardProps {
|
||||||
value: number | null;
|
selectedDate?: Date;
|
||||||
resetToken?: number;
|
|
||||||
style?: any;
|
style?: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BasalMetabolismCard({ value, resetToken, style }: BasalMetabolismCardProps) {
|
export function BasalMetabolismCard({ selectedDate, style }: BasalMetabolismCardProps) {
|
||||||
// 获取基础代谢状态描述
|
const [basalMetabolism, setBasalMetabolism] = useState<number | null>(null);
|
||||||
const getMetabolismStatus = () => {
|
const [loading, setLoading] = useState(false);
|
||||||
if (value === null || value === 0) {
|
|
||||||
|
// 获取用户基本信息
|
||||||
|
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' };
|
return { text: '未知', color: '#9AA3AE' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// 基于常见的基础代谢范围来判断状态
|
// 基于常见的基础代谢范围来判断状态
|
||||||
if (value >= 1800) {
|
if (basalMetabolism >= 1800) {
|
||||||
return { text: '高代谢', color: '#10B981' };
|
return { text: '高代谢', color: '#10B981' };
|
||||||
} else if (value >= 1400) {
|
} else if (basalMetabolism >= 1400) {
|
||||||
return { text: '正常', color: '#3B82F6' };
|
return { text: '正常', color: '#3B82F6' };
|
||||||
} else if (value >= 1000) {
|
} else if (basalMetabolism >= 1000) {
|
||||||
return { text: '偏低', color: '#F59E0B' };
|
return { text: '偏低', color: '#F59E0B' };
|
||||||
} else {
|
} else {
|
||||||
return { text: '较低', color: '#EF4444' };
|
return { text: '较低', color: '#EF4444' };
|
||||||
}
|
}
|
||||||
};
|
}, [basalMetabolism]);
|
||||||
|
|
||||||
const status = getMetabolismStatus();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.container, style]}>
|
<>
|
||||||
|
<TouchableOpacity
|
||||||
{/* 头部区域 */}
|
style={[styles.container, style]}
|
||||||
<View style={styles.header}>
|
onPress={() => router.push(ROUTES.BASAL_METABOLISM_DETAIL)}
|
||||||
<View style={styles.leftSection}>
|
activeOpacity={0.8}
|
||||||
<Image
|
>
|
||||||
source={require('@/assets/images/icons/icon-fire.png')}
|
{/* 头部区域 */}
|
||||||
style={styles.titleIcon}
|
<View style={styles.header}>
|
||||||
/>
|
<View style={styles.leftSection}>
|
||||||
<Text style={styles.title}>基础代谢</Text>
|
<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>
|
||||||
<View style={[styles.statusBadge, { backgroundColor: status.color + '20' }]}>
|
|
||||||
<Text style={[styles.statusText, { color: status.color }]}>{status.text}</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 数值显示区域 */}
|
{/* 数值显示区域 */}
|
||||||
<View style={styles.valueSection}>
|
<View style={styles.valueSection}>
|
||||||
{value != null && value > 0 ? (
|
<Text style={styles.value}>
|
||||||
<AnimatedNumber
|
{loading ? '加载中...' : (basalMetabolism != null && basalMetabolism > 0 ? Math.round(basalMetabolism).toString() : '--')}
|
||||||
value={value}
|
</Text>
|
||||||
resetToken={resetToken}
|
<Text style={styles.unit}>千卡/日</Text>
|
||||||
style={styles.value}
|
</View>
|
||||||
format={(v) => Math.round(v).toString()}
|
</TouchableOpacity>
|
||||||
/>
|
</>
|
||||||
) : (
|
|
||||||
<Text style={styles.value}>--</Text>
|
|
||||||
)}
|
|
||||||
<Text style={styles.unit}>千卡/日</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,14 @@
|
|||||||
import React from 'react';
|
import React, { useState, useCallback, useRef } from 'react';
|
||||||
import { StyleSheet, Text, View, TouchableOpacity } from 'react-native';
|
import { StyleSheet, Text, View, TouchableOpacity } from 'react-native';
|
||||||
import { router } from 'expo-router';
|
import { router } from 'expo-router';
|
||||||
|
import { useFocusEffect } from '@react-navigation/native';
|
||||||
import { CircularRing } from './CircularRing';
|
import { CircularRing } from './CircularRing';
|
||||||
import { ROUTES } from '@/constants/Routes';
|
import { ROUTES } from '@/constants/Routes';
|
||||||
|
import { fetchActivityRingsForDate, ActivityRingsData } from '@/utils/health';
|
||||||
|
|
||||||
type FitnessRingsCardProps = {
|
type FitnessRingsCardProps = {
|
||||||
style?: any;
|
style?: any;
|
||||||
// 活动卡路里数据
|
selectedDate?: Date;
|
||||||
activeCalories?: number;
|
|
||||||
activeCaloriesGoal?: number;
|
|
||||||
// 锻炼分钟数据
|
|
||||||
exerciseMinutes?: number;
|
|
||||||
exerciseMinutesGoal?: number;
|
|
||||||
// 站立小时数据
|
|
||||||
standHours?: number;
|
|
||||||
standHoursGoal?: number;
|
|
||||||
// 动画重置令牌
|
// 动画重置令牌
|
||||||
resetToken?: unknown;
|
resetToken?: unknown;
|
||||||
};
|
};
|
||||||
@@ -24,14 +18,48 @@ type FitnessRingsCardProps = {
|
|||||||
*/
|
*/
|
||||||
export function FitnessRingsCard({
|
export function FitnessRingsCard({
|
||||||
style,
|
style,
|
||||||
activeCalories = 25,
|
selectedDate,
|
||||||
activeCaloriesGoal = 350,
|
|
||||||
exerciseMinutes = 1,
|
|
||||||
exerciseMinutesGoal = 5,
|
|
||||||
standHours = 2,
|
|
||||||
standHoursGoal = 13,
|
|
||||||
resetToken,
|
resetToken,
|
||||||
}: FitnessRingsCardProps) {
|
}: 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 caloriesProgress = Math.min(1, Math.max(0, activeCalories / activeCaloriesGoal));
|
||||||
const exerciseProgress = Math.min(1, Math.max(0, exerciseMinutes / exerciseMinutesGoal));
|
const exerciseProgress = Math.min(1, Math.max(0, exerciseMinutes / exerciseMinutesGoal));
|
||||||
@@ -95,24 +123,42 @@ export function FitnessRingsCard({
|
|||||||
<View style={styles.dataContainer}>
|
<View style={styles.dataContainer}>
|
||||||
<View style={styles.dataRow}>
|
<View style={styles.dataRow}>
|
||||||
<Text style={styles.dataText}>
|
<Text style={styles.dataText}>
|
||||||
<Text style={styles.dataValue}>{Math.round(activeCalories)}</Text>
|
{loading ? (
|
||||||
<Text style={styles.dataGoal}>/{activeCaloriesGoal}</Text>
|
<Text style={styles.dataValue}>--</Text>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Text style={styles.dataValue}>{Math.round(activeCalories)}</Text>
|
||||||
|
<Text style={styles.dataGoal}>/{activeCaloriesGoal}</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={styles.dataUnit}>千卡</Text>
|
<Text style={styles.dataUnit}>千卡</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.dataRow}>
|
<View style={styles.dataRow}>
|
||||||
<Text style={styles.dataText}>
|
<Text style={styles.dataText}>
|
||||||
<Text style={styles.dataValue}>{Math.round(exerciseMinutes)}</Text>
|
{loading ? (
|
||||||
<Text style={styles.dataGoal}>/{exerciseMinutesGoal}</Text>
|
<Text style={styles.dataValue}>--</Text>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Text style={styles.dataValue}>{Math.round(exerciseMinutes)}</Text>
|
||||||
|
<Text style={styles.dataGoal}>/{exerciseMinutesGoal}</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={styles.dataUnit}>分钟</Text>
|
<Text style={styles.dataUnit}>分钟</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.dataRow}>
|
<View style={styles.dataRow}>
|
||||||
<Text style={styles.dataText}>
|
<Text style={styles.dataText}>
|
||||||
<Text style={styles.dataValue}>{Math.round(standHours)}</Text>
|
{loading ? (
|
||||||
<Text style={styles.dataGoal}>/{standHoursGoal}</Text>
|
<Text style={styles.dataValue}>--</Text>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Text style={styles.dataValue}>{Math.round(standHours)}</Text>
|
||||||
|
<Text style={styles.dataGoal}>/{standHoursGoal}</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={styles.dataUnit}>小时</Text>
|
<Text style={styles.dataUnit}>小时</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ROUTES } from '@/constants/Routes';
|
import { ROUTES } from '@/constants/Routes';
|
||||||
|
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { BlurView } from 'expo-blur';
|
import { BlurView } from 'expo-blur';
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
@@ -20,19 +21,21 @@ interface FloatingFoodOverlayProps {
|
|||||||
export function FloatingFoodOverlay({ visible, onClose, mealType = 'dinner' }: FloatingFoodOverlayProps) {
|
export function FloatingFoodOverlay({ visible, onClose, mealType = 'dinner' }: FloatingFoodOverlayProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
const { pushIfAuthedElseLogin } = useAuthGuard()
|
||||||
|
|
||||||
const handleFoodLibrary = () => {
|
const handleFoodLibrary = () => {
|
||||||
onClose();
|
onClose();
|
||||||
router.push(`${ROUTES.FOOD_LIBRARY}?mealType=${mealType}`);
|
pushIfAuthedElseLogin(`${ROUTES.FOOD_LIBRARY}?mealType=${mealType}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePhotoRecognition = () => {
|
const handlePhotoRecognition = () => {
|
||||||
onClose();
|
onClose();
|
||||||
router.push(`/food/camera?mealType=${mealType}`);
|
pushIfAuthedElseLogin(`/food/camera?mealType=${mealType}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleVoiceRecord = () => {
|
const handleVoiceRecord = () => {
|
||||||
onClose();
|
onClose();
|
||||||
router.push(`${ROUTES.VOICE_RECORD}?mealType=${mealType}`);
|
pushIfAuthedElseLogin(`${ROUTES.VOICE_RECORD}?mealType=${mealType}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const menuItems = [
|
const menuItems = [
|
||||||
|
|||||||
431
components/HealthKitTest.tsx
Normal 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;
|
||||||
@@ -7,14 +7,13 @@ import {
|
|||||||
View,
|
View,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import {
|
import {
|
||||||
PanGestureHandler,
|
Gesture,
|
||||||
PanGestureHandlerGestureEvent,
|
GestureDetector,
|
||||||
} from 'react-native-gesture-handler';
|
} from 'react-native-gesture-handler';
|
||||||
import Animated, {
|
import Animated, {
|
||||||
interpolate,
|
interpolate,
|
||||||
interpolateColor,
|
interpolateColor,
|
||||||
runOnJS,
|
runOnJS,
|
||||||
useAnimatedGestureHandler,
|
|
||||||
useAnimatedStyle,
|
useAnimatedStyle,
|
||||||
useSharedValue,
|
useSharedValue,
|
||||||
withSpring,
|
withSpring,
|
||||||
@@ -54,18 +53,18 @@ export default function MoodIntensitySlider({
|
|||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
};
|
};
|
||||||
|
|
||||||
const gestureHandler = useAnimatedGestureHandler<
|
const startX = useSharedValue(0);
|
||||||
PanGestureHandlerGestureEvent,
|
const lastValue = useSharedValue(value);
|
||||||
{ startX: number; lastValue: number }
|
|
||||||
>({
|
const gestureHandler = Gesture.Pan()
|
||||||
onStart: (_, context) => {
|
.onBegin(() => {
|
||||||
context.startX = translateX.value;
|
startX.value = translateX.value;
|
||||||
context.lastValue = value;
|
lastValue.value = value;
|
||||||
isDragging.value = withSpring(1);
|
isDragging.value = withSpring(1);
|
||||||
runOnJS(triggerHaptics)();
|
runOnJS(triggerHaptics)();
|
||||||
},
|
})
|
||||||
onActive: (event, context) => {
|
.onUpdate((event) => {
|
||||||
const newX = context.startX + event.translationX;
|
const newX = startX.value + event.translationX;
|
||||||
const clampedX = Math.max(0, Math.min(sliderWidth, newX));
|
const clampedX = Math.max(0, Math.min(sliderWidth, newX));
|
||||||
translateX.value = clampedX;
|
translateX.value = clampedX;
|
||||||
|
|
||||||
@@ -73,13 +72,13 @@ export default function MoodIntensitySlider({
|
|||||||
const currentValue = Math.round((clampedX / sliderWidth) * (max - min) + min);
|
const currentValue = Math.round((clampedX / sliderWidth) * (max - min) + min);
|
||||||
|
|
||||||
// 当值改变时触发震动和回调
|
// 当值改变时触发震动和回调
|
||||||
if (currentValue !== context.lastValue) {
|
if (currentValue !== lastValue.value) {
|
||||||
context.lastValue = currentValue;
|
lastValue.value = currentValue;
|
||||||
runOnJS(triggerHaptics)();
|
runOnJS(triggerHaptics)();
|
||||||
runOnJS(onValueChange)(currentValue);
|
runOnJS(onValueChange)(currentValue);
|
||||||
}
|
}
|
||||||
},
|
})
|
||||||
onEnd: () => {
|
.onEnd(() => {
|
||||||
// 计算最终值并吸附到最近的步长
|
// 计算最终值并吸附到最近的步长
|
||||||
const currentValue = Math.round((translateX.value / sliderWidth) * (max - min) + min);
|
const currentValue = Math.round((translateX.value / sliderWidth) * (max - min) + min);
|
||||||
const snapPosition = ((currentValue - min) / (max - min)) * sliderWidth;
|
const snapPosition = ((currentValue - min) / (max - min)) * sliderWidth;
|
||||||
@@ -88,8 +87,7 @@ export default function MoodIntensitySlider({
|
|||||||
isDragging.value = withSpring(0);
|
isDragging.value = withSpring(0);
|
||||||
runOnJS(triggerHaptics)();
|
runOnJS(triggerHaptics)();
|
||||||
runOnJS(onValueChange)(currentValue);
|
runOnJS(onValueChange)(currentValue);
|
||||||
},
|
});
|
||||||
});
|
|
||||||
|
|
||||||
const thumbStyle = useAnimatedStyle(() => {
|
const thumbStyle = useAnimatedStyle(() => {
|
||||||
const positionScale = interpolate(
|
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 (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
@@ -173,18 +148,19 @@ export default function MoodIntensitySlider({
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* 进度条 - 动态颜色 */}
|
{/* 进度条 - 动态渐变颜色 */}
|
||||||
<Animated.View style={[styles.progress, { height }, progressStyle, progressColorsStyle]}>
|
<Animated.View style={[styles.progress, { height }, progressStyle]}>
|
||||||
<LinearGradient
|
<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 }]}
|
style={[styles.progressGradient, { height }]}
|
||||||
start={{ x: 1, y: 0 }}
|
start={{ x: 0, y: 0 }}
|
||||||
end={{ x: 0, y: 0 }}
|
end={{ x: 1, y: 0 }}
|
||||||
/>
|
/>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
||||||
{/* 可拖拽的thumb */}
|
{/* 可拖拽的thumb */}
|
||||||
<PanGestureHandler onGestureEvent={gestureHandler}>
|
<GestureDetector gesture={gestureHandler}>
|
||||||
<Animated.View style={[styles.thumb, { width: thumbSize, height: thumbSize }, thumbStyle]}>
|
<Animated.View style={[styles.thumb, { width: thumbSize, height: thumbSize }, thumbStyle]}>
|
||||||
{/* <LinearGradient
|
{/* <LinearGradient
|
||||||
colors={['#ffffff', '#f8fafc']}
|
colors={['#ffffff', '#f8fafc']}
|
||||||
@@ -194,7 +170,7 @@ export default function MoodIntensitySlider({
|
|||||||
/> */}
|
/> */}
|
||||||
<Animated.View style={[styles.thumbInner, thumbInnerStyle]} />
|
<Animated.View style={[styles.thumbInner, thumbInnerStyle]} />
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</PanGestureHandler>
|
</GestureDetector>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* 标签 */}
|
{/* 标签 */}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { AnimatedNumber } from '@/components/AnimatedNumber';
|
import { AnimatedNumber } from '@/components/AnimatedNumber';
|
||||||
import { ROUTES } from '@/constants/Routes';
|
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 { triggerLightHaptic } from '@/utils/haptics';
|
||||||
import { NutritionGoals, calculateRemainingCalories } from '@/utils/nutrition';
|
import { calculateRemainingCalories } from '@/utils/nutrition';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { router } from 'expo-router';
|
import { router } from 'expo-router';
|
||||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
@@ -13,20 +16,10 @@ const AnimatedCircle = Animated.createAnimatedComponent(Circle);
|
|||||||
|
|
||||||
|
|
||||||
export type NutritionRadarCardProps = {
|
export type NutritionRadarCardProps = {
|
||||||
nutritionSummary: NutritionSummary | null;
|
selectedDate?: Date;
|
||||||
/** 营养目标 */
|
style?: object;
|
||||||
nutritionGoals?: NutritionGoals;
|
|
||||||
/** 基础代谢消耗的卡路里 */
|
|
||||||
burnedCalories?: number;
|
|
||||||
/** 基础代谢率 */
|
|
||||||
basalMetabolism?: number;
|
|
||||||
/** 运动消耗卡路里 */
|
|
||||||
activeCalories?: number;
|
|
||||||
|
|
||||||
/** 动画重置令牌 */
|
/** 动画重置令牌 */
|
||||||
resetToken?: number;
|
resetToken?: number;
|
||||||
/** 餐次点击回调 */
|
|
||||||
onMealPress?: (mealType: 'breakfast' | 'lunch' | 'dinner' | 'snack') => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 简化的圆环进度组件
|
// 简化的圆环进度组件
|
||||||
@@ -96,16 +89,47 @@ const SimpleRingProgress = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function NutritionRadarCard({
|
export function NutritionRadarCard({
|
||||||
nutritionSummary,
|
selectedDate,
|
||||||
nutritionGoals,
|
style,
|
||||||
burnedCalories = 1618,
|
|
||||||
basalMetabolism,
|
|
||||||
activeCalories,
|
|
||||||
|
|
||||||
resetToken,
|
resetToken,
|
||||||
onMealPress
|
|
||||||
}: NutritionRadarCardProps) {
|
}: NutritionRadarCardProps) {
|
||||||
const [currentMealType] = useState<'breakfast' | 'lunch' | 'dinner' | 'snack'>('breakfast');
|
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(() => {
|
const nutritionStats = useMemo(() => {
|
||||||
return [
|
return [
|
||||||
@@ -121,9 +145,8 @@ export function NutritionRadarCard({
|
|||||||
// 计算还能吃的卡路里
|
// 计算还能吃的卡路里
|
||||||
const consumedCalories = nutritionSummary?.totalCalories || 0;
|
const consumedCalories = nutritionSummary?.totalCalories || 0;
|
||||||
|
|
||||||
// 使用分离的代谢和运动数据,如果没有提供则从burnedCalories推算
|
// 使用从HealthKit获取的数据,如果没有则使用默认值
|
||||||
const effectiveBasalMetabolism = basalMetabolism ?? (burnedCalories * 0.7); // 假设70%是基础代谢
|
const effectiveBasalMetabolism = basalMetabolism || 0; // 基础代谢默认值
|
||||||
const effectiveActiveCalories = activeCalories ?? (burnedCalories * 0.3); // 假设30%是运动消耗
|
|
||||||
|
|
||||||
const remainingCalories = calculateRemainingCalories({
|
const remainingCalories = calculateRemainingCalories({
|
||||||
basalMetabolism: effectiveBasalMetabolism,
|
basalMetabolism: effectiveBasalMetabolism,
|
||||||
@@ -138,7 +161,7 @@ export function NutritionRadarCard({
|
|||||||
|
|
||||||
|
|
||||||
return (
|
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.cardHeader}>
|
||||||
<View style={styles.titleContainer}>
|
<View style={styles.titleContainer}>
|
||||||
<Image
|
<Image
|
||||||
@@ -147,14 +170,16 @@ export function NutritionRadarCard({
|
|||||||
/>
|
/>
|
||||||
<Text style={styles.cardTitle}>饮食分析</Text>
|
<Text style={styles.cardTitle}>饮食分析</Text>
|
||||||
</View>
|
</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>
|
||||||
|
|
||||||
<View style={styles.contentContainer}>
|
<View style={styles.contentContainer}>
|
||||||
<View style={styles.radarContainer}>
|
<View style={styles.radarContainer}>
|
||||||
<SimpleRingProgress
|
<SimpleRingProgress
|
||||||
remainingCalories={remainingCalories}
|
remainingCalories={(loading || activeCaloriesLoading) ? 0 : remainingCalories}
|
||||||
totalAvailable={effectiveBasalMetabolism + effectiveActiveCalories}
|
totalAvailable={(loading || activeCaloriesLoading) ? 0 : effectiveBasalMetabolism + effectiveActiveCalories}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -177,10 +202,10 @@ export function NutritionRadarCard({
|
|||||||
<Text style={styles.calorieSubtitle}>还能吃</Text>
|
<Text style={styles.calorieSubtitle}>还能吃</Text>
|
||||||
<View style={styles.remainingCaloriesContainer}>
|
<View style={styles.remainingCaloriesContainer}>
|
||||||
<AnimatedNumber
|
<AnimatedNumber
|
||||||
value={remainingCalories}
|
value={(loading || activeCaloriesLoading) ? 0 : remainingCalories}
|
||||||
resetToken={resetToken}
|
resetToken={resetToken}
|
||||||
style={styles.mainValue}
|
style={styles.mainValue}
|
||||||
format={(v) => Math.round(v).toString()}
|
format={(v) => (loading || activeCaloriesLoading) ? '--' : Math.round(v).toString()}
|
||||||
/>
|
/>
|
||||||
<Text style={styles.calorieUnit}>千卡</Text>
|
<Text style={styles.calorieUnit}>千卡</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -189,30 +214,30 @@ export function NutritionRadarCard({
|
|||||||
<Text style={styles.calculationLabel}>基代</Text>
|
<Text style={styles.calculationLabel}>基代</Text>
|
||||||
</View>
|
</View>
|
||||||
<AnimatedNumber
|
<AnimatedNumber
|
||||||
value={effectiveBasalMetabolism}
|
value={loading ? 0 : effectiveBasalMetabolism}
|
||||||
resetToken={resetToken}
|
resetToken={resetToken}
|
||||||
style={styles.calculationValue}
|
style={styles.calculationValue}
|
||||||
format={(v) => Math.round(v).toString()}
|
format={(v) => loading ? '--' : Math.round(v).toString()}
|
||||||
/>
|
/>
|
||||||
<Text style={styles.calculationText}> + </Text>
|
<Text style={styles.calculationText}> + </Text>
|
||||||
<View style={styles.calculationItem}>
|
<View style={styles.calculationItem}>
|
||||||
<Text style={styles.calculationLabel}>运动</Text>
|
<Text style={styles.calculationLabel}>运动</Text>
|
||||||
</View>
|
</View>
|
||||||
<AnimatedNumber
|
<AnimatedNumber
|
||||||
value={effectiveActiveCalories}
|
value={activeCaloriesLoading ? 0 : effectiveActiveCalories}
|
||||||
resetToken={resetToken}
|
resetToken={resetToken}
|
||||||
style={styles.calculationValue}
|
style={styles.calculationValue}
|
||||||
format={(v) => Math.round(v).toString()}
|
format={(v) => activeCaloriesLoading ? '--' : Math.round(v).toString()}
|
||||||
/>
|
/>
|
||||||
<Text style={styles.calculationText}> - </Text>
|
<Text style={styles.calculationText}> - </Text>
|
||||||
<View style={styles.calculationItem}>
|
<View style={styles.calculationItem}>
|
||||||
<Text style={styles.calculationLabel}>饮食</Text>
|
<Text style={styles.calculationLabel}>饮食</Text>
|
||||||
</View>
|
</View>
|
||||||
<AnimatedNumber
|
<AnimatedNumber
|
||||||
value={consumedCalories}
|
value={loading ? 0 : consumedCalories}
|
||||||
resetToken={resetToken}
|
resetToken={resetToken}
|
||||||
style={styles.calculationValue}
|
style={styles.calculationValue}
|
||||||
format={(v) => Math.round(v).toString()}
|
format={(v) => loading ? '--' : Math.round(v).toString()}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
</View>
|
</View>
|
||||||
@@ -225,7 +250,7 @@ export function NutritionRadarCard({
|
|||||||
style={styles.foodOptionItem}
|
style={styles.foodOptionItem}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
triggerLightHaptic();
|
triggerLightHaptic();
|
||||||
router.push(`/food/camera?mealType=${currentMealType}`);
|
pushIfAuthedElseLogin(`/food/camera?mealType=${currentMealType}`);
|
||||||
}}
|
}}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
@@ -242,7 +267,7 @@ export function NutritionRadarCard({
|
|||||||
style={styles.foodOptionItem}
|
style={styles.foodOptionItem}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
triggerLightHaptic();
|
triggerLightHaptic();
|
||||||
router.push(`${ROUTES.FOOD_LIBRARY}?mealType=${currentMealType}`);
|
pushIfAuthedElseLogin(`${ROUTES.FOOD_LIBRARY}?mealType=${currentMealType}`);
|
||||||
}}
|
}}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
@@ -259,7 +284,7 @@ export function NutritionRadarCard({
|
|||||||
style={styles.foodOptionItem}
|
style={styles.foodOptionItem}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
triggerLightHaptic();
|
triggerLightHaptic();
|
||||||
router.push(`${ROUTES.VOICE_RECORD}?mealType=${currentMealType}`);
|
pushIfAuthedElseLogin(`${ROUTES.VOICE_RECORD}?mealType=${currentMealType}`);
|
||||||
}}
|
}}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Animated,
|
Animated,
|
||||||
|
InteractionManager,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
Text,
|
Text,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
@@ -33,21 +34,22 @@ const StepsCard: React.FC<StepsCardProps> = ({
|
|||||||
const [hourlySteps, setHourSteps] = useState<HourlyStepData[]>([])
|
const [hourlySteps, setHourSteps] = useState<HourlyStepData[]>([])
|
||||||
|
|
||||||
|
|
||||||
const getStepData = async (date: Date) => {
|
const getStepData = useCallback(async (date: Date) => {
|
||||||
try {
|
try {
|
||||||
logger.info('获取步数数据...');
|
logger.info('获取步数数据...');
|
||||||
|
|
||||||
|
// 先获取步数,立即更新UI
|
||||||
const [steps, hourly] = await Promise.all([
|
const [steps, hourly] = await Promise.all([
|
||||||
fetchStepCount(date),
|
fetchStepCount(date),
|
||||||
fetchHourlyStepSamples(date)
|
fetchHourlyStepSamples(date)
|
||||||
])
|
]);
|
||||||
|
setStepCount(steps);
|
||||||
setStepCount(steps)
|
setHourSteps(hourly);
|
||||||
setHourSteps(hourly)
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('获取步数数据失败:', error);
|
logger.error('获取步数数据失败:', error);
|
||||||
}
|
}
|
||||||
}
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (curDate) {
|
if (curDate) {
|
||||||
@@ -55,55 +57,60 @@ const StepsCard: React.FC<StepsCardProps> = ({
|
|||||||
}
|
}
|
||||||
}, [curDate]);
|
}, [curDate]);
|
||||||
|
|
||||||
// 为每个柱体创建独立的动画值
|
// 优化:减少动画值数量,只为有数据的小时创建动画
|
||||||
const animatedValues = useRef(
|
const animatedValues = useRef<Map<number, Animated.Value>>(new Map()).current;
|
||||||
Array.from({ length: 24 }, () => new Animated.Value(0))
|
|
||||||
).current;
|
|
||||||
|
|
||||||
// 计算柱状图数据
|
// 优化:简化柱状图数据计算,减少计算量
|
||||||
const chartData = useMemo(() => {
|
const chartData = useMemo(() => {
|
||||||
if (!hourlySteps || hourlySteps.length === 0) {
|
if (!hourlySteps || hourlySteps.length === 0) {
|
||||||
return Array.from({ length: 24 }, (_, i) => ({ hour: i, steps: 0, height: 0 }));
|
return Array.from({ length: 24 }, (_, i) => ({ hour: i, steps: 0, height: 0 }));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 找到最大步数用于计算高度比例
|
// 优化:只计算有数据的小时的最大步数
|
||||||
const maxSteps = Math.max(...hourlySteps.map(data => data.steps), 1);
|
const activeSteps = hourlySteps.filter(data => data.steps > 0);
|
||||||
const maxHeight = 20; // 柱状图最大高度(缩小一半)
|
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 => ({
|
return hourlySteps.map(data => ({
|
||||||
...data,
|
...data,
|
||||||
height: maxSteps > 0 ? (data.steps / maxSteps) * maxHeight : 0
|
height: data.steps > 0 ? (data.steps / maxSteps) * maxHeight : 0
|
||||||
}));
|
}));
|
||||||
}, [hourlySteps]);
|
}, [hourlySteps]);
|
||||||
|
|
||||||
// 获取当前小时
|
// 获取当前小时
|
||||||
const currentHour = new Date().getHours();
|
const currentHour = new Date().getHours();
|
||||||
|
|
||||||
// 触发柱体动画
|
// 优化:延迟执行动画,减少UI阻塞
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 检查是否有实际数据(不只是空数组)
|
|
||||||
const hasData = chartData && chartData.length > 0 && chartData.some(data => data.steps > 0);
|
const hasData = chartData && chartData.length > 0 && chartData.some(data => data.steps > 0);
|
||||||
|
|
||||||
if (hasData) {
|
if (hasData) {
|
||||||
// 重置所有动画值
|
// 使用 InteractionManager 确保动画不会阻塞用户交互
|
||||||
animatedValues.forEach(animValue => animValue.setValue(0));
|
InteractionManager.runAfterInteractions(() => {
|
||||||
|
// 只为有数据的小时创建和执行动画
|
||||||
// 使用 setTimeout 确保在下一个事件循环中执行动画,保证组件已完全渲染
|
|
||||||
const timeoutId = setTimeout(() => {
|
|
||||||
// 同时启动所有柱体的弹性动画,有步数的柱体才执行动画
|
|
||||||
chartData.forEach((data, index) => {
|
chartData.forEach((data, index) => {
|
||||||
if (data.steps > 0) {
|
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,
|
toValue: 1,
|
||||||
tension: 150,
|
duration: 300,
|
||||||
friction: 8,
|
|
||||||
useNativeDriver: false,
|
useNativeDriver: false,
|
||||||
}).start();
|
}).start();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, 50); // 添加小延迟确保渲染完成
|
});
|
||||||
|
|
||||||
return () => clearTimeout(timeoutId);
|
|
||||||
}
|
}
|
||||||
}, [chartData, animatedValues]);
|
}, [chartData, animatedValues]);
|
||||||
|
|
||||||
@@ -127,17 +134,22 @@ const StepsCard: React.FC<StepsCardProps> = ({
|
|||||||
const isActive = data.steps > 0;
|
const isActive = data.steps > 0;
|
||||||
const isCurrent = index <= currentHour;
|
const isCurrent = index <= currentHour;
|
||||||
|
|
||||||
// 动画变换:缩放从0到实际高度
|
// 优化:只为有数据的柱体创建动画插值
|
||||||
const animatedScale = animatedValues[index].interpolate({
|
const animValue = animatedValues.get(index);
|
||||||
inputRange: [0, 1],
|
let animatedScale: Animated.AnimatedInterpolation<number> | undefined;
|
||||||
outputRange: [0, 1],
|
let animatedOpacity: Animated.AnimatedInterpolation<number> | undefined;
|
||||||
});
|
|
||||||
|
|
||||||
// 动画变换:透明度从0到1
|
if (animValue && isActive) {
|
||||||
const animatedOpacity = animatedValues[index].interpolate({
|
animatedScale = animValue.interpolate({
|
||||||
inputRange: [0, 1],
|
inputRange: [0, 1],
|
||||||
outputRange: [0, 1],
|
outputRange: [0, 1],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
animatedOpacity = animValue.interpolate({
|
||||||
|
inputRange: [0, 1],
|
||||||
|
outputRange: [0, 1],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View key={`bar-container-${index}`} style={styles.barContainer}>
|
<View key={`bar-container-${index}`} style={styles.barContainer}>
|
||||||
@@ -160,8 +172,8 @@ const StepsCard: React.FC<StepsCardProps> = ({
|
|||||||
{
|
{
|
||||||
height: data.height,
|
height: data.height,
|
||||||
backgroundColor: isCurrent ? '#FFC365' : '#FFEBCB',
|
backgroundColor: isCurrent ? '#FFC365' : '#FFEBCB',
|
||||||
transform: [{ scaleY: animatedScale }],
|
transform: animatedScale ? [{ scaleY: animatedScale }] : undefined,
|
||||||
opacity: animatedOpacity,
|
opacity: animatedOpacity || 1,
|
||||||
}
|
}
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|||||||
323
components/StepsCardOptimized.tsx
Normal 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;
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { fetchHRVForDate } from '@/utils/health';
|
import { fetchHRVWithStatus } from '@/utils/health';
|
||||||
import dayjs from 'dayjs';
|
|
||||||
import { Image } from 'expo-image';
|
import { Image } from 'expo-image';
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
@@ -11,26 +10,6 @@ interface StressMeterProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function StressMeter({ curDate }: 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值转换为压力指数(0-100)
|
||||||
// HRV值范围:30-110ms,映射到压力指数100-0
|
// HRV值范围:30-110ms,映射到压力指数100-0
|
||||||
@@ -55,13 +34,24 @@ export function StressMeter({ curDate }: StressMeterProps) {
|
|||||||
|
|
||||||
const getHrvData = async () => {
|
const getHrvData = async () => {
|
||||||
try {
|
try {
|
||||||
const data = await fetchHRVForDate(curDate)
|
console.log('StressMeter: 开始获取HRV数据...', curDate);
|
||||||
|
|
||||||
if (data) {
|
// 使用智能HRV数据获取功能
|
||||||
setHrvValue(data)
|
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) {
|
} catch (error) {
|
||||||
|
console.error('StressMeter: 获取HRV数据失败:', error);
|
||||||
|
setHrvValue(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,7 +128,7 @@ export function StressMeter({ curDate }: StressMeterProps) {
|
|||||||
visible={showStressModal}
|
visible={showStressModal}
|
||||||
onClose={() => setShowStressModal(false)}
|
onClose={() => setShowStressModal(false)}
|
||||||
hrvValue={hrvValue}
|
hrvValue={hrvValue}
|
||||||
// updateTime={updateTime || new Date()}
|
updateTime={new Date()}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
|
||||||
import { useWaterDataByDate } from '@/hooks/useWaterData';
|
import { useWaterDataByDate } from '@/hooks/useWaterData';
|
||||||
import { getQuickWaterAmount } from '@/utils/userPreferences';
|
import { getQuickWaterAmount } from '@/utils/userPreferences';
|
||||||
import { useFocusEffect } from '@react-navigation/native';
|
import { useFocusEffect } from '@react-navigation/native';
|
||||||
@@ -28,8 +27,7 @@ const WaterIntakeCard: React.FC<WaterIntakeCardProps> = ({
|
|||||||
selectedDate
|
selectedDate
|
||||||
}) => {
|
}) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { ensureLoggedIn } = useAuthGuard();
|
const { waterStats, dailyWaterGoal, waterRecords, addWaterRecord, getWaterRecordsByDate } = useWaterDataByDate(selectedDate);
|
||||||
const { waterStats, dailyWaterGoal, waterRecords, addWaterRecord } = useWaterDataByDate(selectedDate);
|
|
||||||
const [quickWaterAmount, setQuickWaterAmount] = useState(150); // 默认值,将从用户偏好中加载
|
const [quickWaterAmount, setQuickWaterAmount] = useState(150); // 默认值,将从用户偏好中加载
|
||||||
|
|
||||||
// 计算当前饮水量和目标
|
// 计算当前饮水量和目标
|
||||||
@@ -78,21 +76,25 @@ const WaterIntakeCard: React.FC<WaterIntakeCardProps> = ({
|
|||||||
// 判断是否是今天
|
// 判断是否是今天
|
||||||
const isToday = selectedDate === dayjs().format('YYYY-MM-DD') || !selectedDate;
|
const isToday = selectedDate === dayjs().format('YYYY-MM-DD') || !selectedDate;
|
||||||
|
|
||||||
// 加载用户偏好的快速添加饮水默认值
|
// 页面聚焦时重新加载数据
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
const loadQuickWaterAmount = async () => {
|
const loadDataOnFocus = async () => {
|
||||||
try {
|
try {
|
||||||
|
// 重新加载快速添加饮水默认值
|
||||||
const amount = await getQuickWaterAmount();
|
const amount = await getQuickWaterAmount();
|
||||||
setQuickWaterAmount(amount);
|
setQuickWaterAmount(amount);
|
||||||
|
|
||||||
|
// 重新获取水数据以刷新显示
|
||||||
|
const targetDate = selectedDate || dayjs().format('YYYY-MM-DD');
|
||||||
|
await getWaterRecordsByDate(targetDate);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('加载快速添加饮水默认值失败:', error);
|
console.error('页面聚焦时加载数据失败:', error);
|
||||||
// 保持默认值 250ml
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
loadQuickWaterAmount();
|
loadDataOnFocus();
|
||||||
}, [])
|
}, [selectedDate, getWaterRecordsByDate])
|
||||||
);
|
);
|
||||||
|
|
||||||
// 触发柱体动画
|
// 触发柱体动画
|
||||||
@@ -123,12 +125,6 @@ const WaterIntakeCard: React.FC<WaterIntakeCardProps> = ({
|
|||||||
|
|
||||||
// 处理添加喝水 - 右上角按钮直接添加
|
// 处理添加喝水 - 右上角按钮直接添加
|
||||||
const handleQuickAddWater = async () => {
|
const handleQuickAddWater = async () => {
|
||||||
// 检查用户是否已登录
|
|
||||||
const isLoggedIn = await ensureLoggedIn();
|
|
||||||
if (!isLoggedIn) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 触发震动反馈
|
// 触发震动反馈
|
||||||
if (process.env.EXPO_OS === 'ios') {
|
if (process.env.EXPO_OS === 'ios') {
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
||||||
@@ -139,31 +135,26 @@ const WaterIntakeCard: React.FC<WaterIntakeCardProps> = ({
|
|||||||
// 使用用户配置的快速添加饮水量
|
// 使用用户配置的快速添加饮水量
|
||||||
const waterAmount = quickWaterAmount;
|
const waterAmount = quickWaterAmount;
|
||||||
// 如果有选中日期,则为该日期添加记录;否则为今天添加记录
|
// 如果有选中日期,则为该日期添加记录;否则为今天添加记录
|
||||||
const recordedAt = selectedDate ? dayjs(selectedDate).toISOString() : dayjs().toISOString();
|
const recordedAt = dayjs().toISOString()
|
||||||
await addWaterRecord(waterAmount, recordedAt);
|
await addWaterRecord(waterAmount, recordedAt);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 处理卡片点击 - 跳转到饮水设置页面
|
// 处理卡片点击 - 跳转到饮水详情页面
|
||||||
const handleCardPress = async () => {
|
const handleCardPress = async () => {
|
||||||
// 检查用户是否已登录
|
|
||||||
const isLoggedIn = await ensureLoggedIn();
|
|
||||||
if (!isLoggedIn) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 触发震动反馈
|
// 触发震动反馈
|
||||||
if (process.env.EXPO_OS === 'ios') {
|
if (process.env.EXPO_OS === 'ios') {
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 跳转到饮水设置页面,传递选中的日期参数
|
// 跳转到饮水详情页面,传递选中的日期参数
|
||||||
router.push({
|
router.push({
|
||||||
pathname: '/water-settings',
|
pathname: '/water/detail',
|
||||||
params: selectedDate ? { selectedDate } : undefined
|
params: selectedDate ? { selectedDate } : undefined
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.container, style]}
|
style={[styles.container, style]}
|
||||||
@@ -274,6 +265,7 @@ const WaterIntakeCard: React.FC<WaterIntakeCardProps> = ({
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
240
components/statistic/CircumferenceCard.tsx
Normal 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;
|
||||||
@@ -1,32 +1,63 @@
|
|||||||
import React from 'react';
|
import React, { useState, useCallback, useRef } from 'react';
|
||||||
import { StyleSheet } from 'react-native';
|
import { useFocusEffect } from '@react-navigation/native';
|
||||||
import HealthDataCard from './HealthDataCard';
|
import HealthDataCard from './HealthDataCard';
|
||||||
|
import { fetchOxygenSaturation } from '@/utils/health';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
interface OxygenSaturationCardProps {
|
interface OxygenSaturationCardProps {
|
||||||
resetToken: number;
|
|
||||||
style?: object;
|
style?: object;
|
||||||
oxygenSaturation?: number | null;
|
selectedDate?: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
const OxygenSaturationCard: React.FC<OxygenSaturationCardProps> = ({
|
const OxygenSaturationCard: React.FC<OxygenSaturationCardProps> = ({
|
||||||
resetToken,
|
|
||||||
style,
|
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 (
|
return (
|
||||||
<HealthDataCard
|
<HealthDataCard
|
||||||
title="血氧饱和度"
|
title="血氧饱和度"
|
||||||
value={oxygenSaturation !== null && oxygenSaturation !== undefined ? oxygenSaturation.toFixed(1) : '--'}
|
value={loading ? '--' : (oxygenSaturation !== null && oxygenSaturation !== undefined ? oxygenSaturation.toFixed(1) : '--')}
|
||||||
unit="%"
|
unit="%"
|
||||||
style={style}
|
style={style}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default OxygenSaturationCard;
|
export default OxygenSaturationCard;
|
||||||
@@ -1,18 +1,19 @@
|
|||||||
import { fetchCompleteSleepData, formatSleepTime } from '@/utils/sleepHealthKit';
|
import { fetchCompleteSleepData, formatSleepTime } from '@/utils/sleepHealthKit';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
import { Image } from 'expo-image';
|
import { Image } from 'expo-image';
|
||||||
|
import { router } from 'expo-router';
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||||
|
|
||||||
|
|
||||||
interface SleepCardProps {
|
interface SleepCardProps {
|
||||||
selectedDate?: Date;
|
selectedDate?: Date;
|
||||||
style?: object;
|
style?: object;
|
||||||
onPress?: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const SleepCard: React.FC<SleepCardProps> = ({
|
const SleepCard: React.FC<SleepCardProps> = ({
|
||||||
selectedDate,
|
selectedDate,
|
||||||
style,
|
style,
|
||||||
onPress
|
|
||||||
}) => {
|
}) => {
|
||||||
const [sleepDuration, setSleepDuration] = useState<number | null>(null);
|
const [sleepDuration, setSleepDuration] = useState<number | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -52,15 +53,11 @@ const SleepCard: React.FC<SleepCardProps> = ({
|
|||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (onPress) {
|
return (
|
||||||
return (
|
<TouchableOpacity onPress={() => router.push(`/sleep-detail?date=${dayjs(selectedDate).format('YYYY-MM-DD')}`)} activeOpacity={0.7}>
|
||||||
<TouchableOpacity onPress={onPress} activeOpacity={0.7}>
|
{CardContent}
|
||||||
{CardContent}
|
</TouchableOpacity>
|
||||||
</TouchableOpacity>
|
);
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return CardContent;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
|
|||||||
122
components/ui/FloatingSelectionCard.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
57
components/ui/FloatingSelectionModal.tsx
Normal 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';
|
||||||
131
components/ui/SlidingSelection.tsx
Normal 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',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -43,7 +43,9 @@ export function WeightHistoryCard() {
|
|||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadWeightHistory();
|
if (isLoggedIn) {
|
||||||
|
loadWeightHistory();
|
||||||
|
}
|
||||||
}, [userProfile?.weight, isLoggedIn]);
|
}, [userProfile?.weight, isLoggedIn]);
|
||||||
|
|
||||||
const loadWeightHistory = async () => {
|
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]
|
const sortedHistory = [...weightHistory]
|
||||||
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
|
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
|
||||||
.slice(-7); // 只显示最近7条记录
|
.slice(-7); // 只显示最近7条记录
|
||||||
|
|
||||||
if (sortedHistory.length === 0) {
|
// return (
|
||||||
return (
|
// <TouchableOpacity style={styles.card} onPress={navigateToWeightRecords} activeOpacity={0.8}>
|
||||||
<TouchableOpacity style={styles.card} onPress={navigateToWeightRecords} activeOpacity={0.8}>
|
// <View style={styles.cardHeader}>
|
||||||
<View style={styles.cardHeader}>
|
// <Text style={styles.cardTitle}>体重记录</Text>
|
||||||
<Text style={styles.cardTitle}>体重记录</Text>
|
// </View>
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={styles.emptyContent}>
|
// <View style={styles.emptyContent}>
|
||||||
<Text style={styles.emptyDescription}>
|
// <Text style={styles.emptyDescription}>
|
||||||
暂无体重记录,点击下方按钮开始记录
|
// 暂无体重记录,点击下方按钮开始记录
|
||||||
</Text>
|
// </Text>
|
||||||
<TouchableOpacity
|
// <TouchableOpacity
|
||||||
style={styles.recordButton}
|
// style={styles.recordButton}
|
||||||
onPress={(e) => {
|
// onPress={(e) => {
|
||||||
e.stopPropagation();
|
// e.stopPropagation();
|
||||||
navigateToCoach();
|
// navigateToCoach();
|
||||||
}}
|
// }}
|
||||||
activeOpacity={0.8}
|
// activeOpacity={0.8}
|
||||||
>
|
// >
|
||||||
<Ionicons name="add" size={18} color="#FFFFFF" />
|
// <Ionicons name="add" size={18} color="#FFFFFF" />
|
||||||
<Text style={styles.recordButtonText}>记录体重</Text>
|
// <Text style={styles.recordButtonText}>记录体重</Text>
|
||||||
</TouchableOpacity>
|
// </TouchableOpacity>
|
||||||
</View>
|
// </View>
|
||||||
</TouchableOpacity>
|
// </TouchableOpacity>
|
||||||
);
|
// );
|
||||||
}
|
// }
|
||||||
|
|
||||||
// 生成图表数据
|
// 生成图表数据
|
||||||
const weights = sortedHistory.map(item => parseFloat(item.weight));
|
const weights = sortedHistory.map(item => parseFloat(item.weight));
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export const ROUTES = {
|
|||||||
TAB_COACH: '/coach',
|
TAB_COACH: '/coach',
|
||||||
TAB_GOALS: '/goals',
|
TAB_GOALS: '/goals',
|
||||||
TAB_STATISTICS: '/statistics',
|
TAB_STATISTICS: '/statistics',
|
||||||
|
TAB_CHALLENGES: '/challenges',
|
||||||
TAB_PERSONAL: '/personal',
|
TAB_PERSONAL: '/personal',
|
||||||
|
|
||||||
// 训练相关路由
|
// 训练相关路由
|
||||||
@@ -45,6 +46,12 @@ export const ROUTES = {
|
|||||||
// 健康相关路由
|
// 健康相关路由
|
||||||
FITNESS_RINGS_DETAIL: '/fitness-rings-detail',
|
FITNESS_RINGS_DETAIL: '/fitness-rings-detail',
|
||||||
SLEEP_DETAIL: '/sleep-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',
|
TASK_DETAIL: '/task-detail',
|
||||||
|
|||||||
229
docs/healthkit-implementation.md
Normal 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)
|
||||||
58
hooks/useActiveCalories.ts
Normal 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())
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -19,9 +19,9 @@ export function useAuthGuard() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const currentPath = usePathname();
|
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> => {
|
const ensureLoggedIn = useCallback(async (options?: EnsureOptions): Promise<boolean> => {
|
||||||
if (isLoggedIn) return true;
|
if (isLoggedIn) return true;
|
||||||
|
|||||||
236
hooks/useHealthPermissions.ts
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -3,146 +3,93 @@
|
|||||||
archiveVersion = 1;
|
archiveVersion = 1;
|
||||||
classes = {
|
classes = {
|
||||||
};
|
};
|
||||||
objectVersion = 60;
|
objectVersion = 54;
|
||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; };
|
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 */; };
|
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; };
|
||||||
6B6021A2D1EB466803BE19D7 /* libPods-digitalpilates.a in Frameworks */ = {isa = PBXBuildFile; fileRef = F1A078ADDB1BCB06E0DBEFDA /* libPods-digitalpilates.a */; };
|
646189797DBE7937221347A9 /* libPods-OutLive.a in Frameworks */ = {isa = PBXBuildFile; fileRef = C411D1CEBC225A6F92F136BA /* libPods-OutLive.a */; };
|
||||||
7996A1192E6FB82300371142 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7996A1182E6FB82300371142 /* WidgetKit.framework */; };
|
79B2CB702E7B954600B51753 /* OutLive-Bridging-Header.h in Sources */ = {isa = PBXBuildFile; fileRef = F11748442D0722820044C1D9 /* OutLive-Bridging-Header.h */; };
|
||||||
7996A11B2E6FB82300371142 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7996A11A2E6FB82300371142 /* SwiftUI.framework */; };
|
79B2CB732E7B954F00B51753 /* HealthKitManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 79B2CB712E7B954F00B51753 /* HealthKitManager.m */; };
|
||||||
7996A12C2E6FB82300371142 /* WaterWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 7996A1172E6FB82300371142 /* WaterWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
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 */; };
|
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 */; };
|
F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11748412D0307B40044C1D9 /* AppDelegate.swift */; };
|
||||||
/* End PBXBuildFile section */
|
/* 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 */
|
/* Begin PBXFileReference section */
|
||||||
13B07F961A680F5B00A75B9A /* digitalpilates.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = digitalpilates.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
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>"; };
|
||||||
13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = digitalpilates/Images.xcassets; sourceTree = "<group>"; };
|
13B07F961A680F5B00A75B9A /* OutLive.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = OutLive.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = digitalpilates/Info.plist; sourceTree = "<group>"; };
|
13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = OutLive/Images.xcassets; 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>"; };
|
13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = OutLive/Info.plist; sourceTree = "<group>"; };
|
||||||
7996A1172E6FB82300371142 /* WaterWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = WaterWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
1EA3641BAC6078512F41509D /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-OutLive/ExpoModulesProvider.swift"; sourceTree = "<group>"; };
|
||||||
7996A1182E6FB82300371142 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; };
|
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>"; };
|
||||||
7996A11A2E6FB82300371142 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; };
|
79B2CB712E7B954F00B51753 /* HealthKitManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = HealthKitManager.m; path = OutLive/HealthKitManager.m; sourceTree = "<group>"; };
|
||||||
7996A1322E6FB84A00371142 /* WaterWidgetExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = WaterWidgetExtension.entitlements; sourceTree = "<group>"; };
|
79B2CB722E7B954F00B51753 /* HealthKitManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = HealthKitManager.swift; path = OutLive/HealthKitManager.swift; sourceTree = "<group>"; };
|
||||||
7EC44F9488C227087AA8DF97 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = digitalpilates/PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
|
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = OutLive/SplashScreen.storyboard; 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>"; };
|
B7F23062EE59F61E6260DBA8 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; name = PrivacyInfo.xcprivacy; path = OutLive/PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
|
||||||
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = digitalpilates/SplashScreen.storyboard; sourceTree = "<group>"; };
|
|
||||||
BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; 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; };
|
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>"; };
|
F11748412D0307B40044C1D9 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = OutLive/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>"; };
|
F11748442D0722820044C1D9 /* OutLive-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = "OutLive-Bridging-Header.h"; path = "OutLive/OutLive-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||||
F1A078ADDB1BCB06E0DBEFDA /* libPods-digitalpilates.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-digitalpilates.a"; sourceTree = BUILT_PRODUCTS_DIR; };
|
|
||||||
/* End PBXFileReference section */
|
/* 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 */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
13B07F8C1A680F5B00A75B9A /* Frameworks */ = {
|
13B07F8C1A680F5B00A75B9A /* Frameworks */ = {
|
||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
6B6021A2D1EB466803BE19D7 /* libPods-digitalpilates.a in Frameworks */,
|
646189797DBE7937221347A9 /* libPods-OutLive.a in Frameworks */,
|
||||||
);
|
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
|
||||||
};
|
|
||||||
7996A1142E6FB82300371142 /* Frameworks */ = {
|
|
||||||
isa = PBXFrameworksBuildPhase;
|
|
||||||
buildActionMask = 2147483647;
|
|
||||||
files = (
|
|
||||||
7996A11B2E6FB82300371142 /* SwiftUI.framework in Frameworks */,
|
|
||||||
7996A1192E6FB82300371142 /* WidgetKit.framework in Frameworks */,
|
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
/* End PBXFrameworksBuildPhase section */
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXGroup section */
|
/* Begin PBXGroup section */
|
||||||
13B07FAE1A68108700A75B9A /* digitalpilates */ = {
|
13B07FAE1A68108700A75B9A /* OutLive */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
F11748412D0307B40044C1D9 /* AppDelegate.swift */,
|
F11748412D0307B40044C1D9 /* AppDelegate.swift */,
|
||||||
F11748442D0722820044C1D9 /* digitalpilates-Bridging-Header.h */,
|
F11748442D0722820044C1D9 /* OutLive-Bridging-Header.h */,
|
||||||
BB2F792B24A3F905000567C9 /* Supporting */,
|
BB2F792B24A3F905000567C9 /* Supporting */,
|
||||||
13B07FB51A68108700A75B9A /* Images.xcassets */,
|
13B07FB51A68108700A75B9A /* Images.xcassets */,
|
||||||
13B07FB61A68108700A75B9A /* Info.plist */,
|
13B07FB61A68108700A75B9A /* Info.plist */,
|
||||||
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */,
|
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */,
|
||||||
7EC44F9488C227087AA8DF97 /* PrivacyInfo.xcprivacy */,
|
B7F23062EE59F61E6260DBA8 /* PrivacyInfo.xcprivacy */,
|
||||||
);
|
);
|
||||||
name = digitalpilates;
|
name = OutLive;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
2D16E6871FA4F8E400B85C8A /* Frameworks */ = {
|
2D16E6871FA4F8E400B85C8A /* Frameworks */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
ED297162215061F000B7C4FE /* JavaScriptCore.framework */,
|
ED297162215061F000B7C4FE /* JavaScriptCore.framework */,
|
||||||
F1A078ADDB1BCB06E0DBEFDA /* libPods-digitalpilates.a */,
|
C411D1CEBC225A6F92F136BA /* libPods-OutLive.a */,
|
||||||
7996A1182E6FB82300371142 /* WidgetKit.framework */,
|
|
||||||
7996A11A2E6FB82300371142 /* SwiftUI.framework */,
|
|
||||||
);
|
);
|
||||||
name = Frameworks;
|
name = Frameworks;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
3EE8D66219D64F4A63E8298D /* Pods */ = {
|
7B63456AB81271603E0039A3 /* Pods */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
4D6B8E20DD8E5677F8B2EAA1 /* Pods-digitalpilates.debug.xcconfig */,
|
4FAA45EB21D2C45B94943F48 /* Pods-OutLive.debug.xcconfig */,
|
||||||
EA6A757B2DE1747F7B3664B4 /* Pods-digitalpilates.release.xcconfig */,
|
0FF78E2879F14B0D75DF41B4 /* Pods-OutLive.release.xcconfig */,
|
||||||
);
|
);
|
||||||
|
name = Pods;
|
||||||
path = Pods;
|
path = Pods;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
80E2A1E8ECA8777F7264D855 /* ExpoModulesProviders */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
BF89779EFCFC7E852B943187 /* OutLive */,
|
||||||
|
);
|
||||||
|
name = ExpoModulesProviders;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
832341AE1AAA6A7D00B99B32 /* Libraries */ = {
|
832341AE1AAA6A7D00B99B32 /* Libraries */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@@ -153,14 +100,14 @@
|
|||||||
83CBB9F61A601CBA00E9B192 = {
|
83CBB9F61A601CBA00E9B192 = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
7996A1322E6FB84A00371142 /* WaterWidgetExtension.entitlements */,
|
79B2CB712E7B954F00B51753 /* HealthKitManager.m */,
|
||||||
13B07FAE1A68108700A75B9A /* digitalpilates */,
|
79B2CB722E7B954F00B51753 /* HealthKitManager.swift */,
|
||||||
|
13B07FAE1A68108700A75B9A /* OutLive */,
|
||||||
832341AE1AAA6A7D00B99B32 /* Libraries */,
|
832341AE1AAA6A7D00B99B32 /* Libraries */,
|
||||||
7996A11C2E6FB82300371142 /* WaterWidget */,
|
|
||||||
83CBBA001A601CBA00E9B192 /* Products */,
|
83CBBA001A601CBA00E9B192 /* Products */,
|
||||||
2D16E6871FA4F8E400B85C8A /* Frameworks */,
|
2D16E6871FA4F8E400B85C8A /* Frameworks */,
|
||||||
3EE8D66219D64F4A63E8298D /* Pods */,
|
80E2A1E8ECA8777F7264D855 /* ExpoModulesProviders */,
|
||||||
F899CC3CCA86CFEC0C4F53F7 /* ExpoModulesProviders */,
|
7B63456AB81271603E0039A3 /* Pods */,
|
||||||
);
|
);
|
||||||
indentWidth = 2;
|
indentWidth = 2;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -170,8 +117,7 @@
|
|||||||
83CBBA001A601CBA00E9B192 /* Products */ = {
|
83CBBA001A601CBA00E9B192 /* Products */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
13B07F961A680F5B00A75B9A /* digitalpilates.app */,
|
13B07F961A680F5B00A75B9A /* OutLive.app */,
|
||||||
7996A1172E6FB82300371142 /* WaterWidgetExtension.appex */,
|
|
||||||
);
|
);
|
||||||
name = Products;
|
name = Products;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -182,104 +128,69 @@
|
|||||||
BB2F792C24A3F905000567C9 /* Expo.plist */,
|
BB2F792C24A3F905000567C9 /* Expo.plist */,
|
||||||
);
|
);
|
||||||
name = Supporting;
|
name = Supporting;
|
||||||
path = digitalpilates/Supporting;
|
path = OutLive/Supporting;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
DFAD2B7142CEC38E9ED66053 /* digitalpilates */ = {
|
BF89779EFCFC7E852B943187 /* OutLive */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
83D1B5F0EC906D7A2F599549 /* ExpoModulesProvider.swift */,
|
1EA3641BAC6078512F41509D /* ExpoModulesProvider.swift */,
|
||||||
);
|
);
|
||||||
name = digitalpilates;
|
name = OutLive;
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
F899CC3CCA86CFEC0C4F53F7 /* ExpoModulesProviders */ = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
DFAD2B7142CEC38E9ED66053 /* digitalpilates */,
|
|
||||||
);
|
|
||||||
name = ExpoModulesProviders;
|
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
/* End PBXGroup section */
|
/* End PBXGroup section */
|
||||||
|
|
||||||
/* Begin PBXNativeTarget section */
|
/* Begin PBXNativeTarget section */
|
||||||
13B07F861A680F5B00A75B9A /* digitalpilates */ = {
|
13B07F861A680F5B00A75B9A /* OutLive */ = {
|
||||||
isa = PBXNativeTarget;
|
isa = PBXNativeTarget;
|
||||||
buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "digitalpilates" */;
|
buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "OutLive" */;
|
||||||
buildPhases = (
|
buildPhases = (
|
||||||
08A4A3CD28434E44B6B9DE2E /* [CP] Check Pods Manifest.lock */,
|
0B29745394A2F51EC4C9CBC7 /* [CP] Check Pods Manifest.lock */,
|
||||||
60F566376E07CDAA8138E40B /* [Expo] Configure project */,
|
FED23F24D8115FB0D63DF986 /* [Expo] Configure project */,
|
||||||
13B07F871A680F5B00A75B9A /* Sources */,
|
13B07F871A680F5B00A75B9A /* Sources */,
|
||||||
13B07F8C1A680F5B00A75B9A /* Frameworks */,
|
13B07F8C1A680F5B00A75B9A /* Frameworks */,
|
||||||
13B07F8E1A680F5B00A75B9A /* Resources */,
|
13B07F8E1A680F5B00A75B9A /* Resources */,
|
||||||
00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */,
|
00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */,
|
||||||
800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */,
|
EF8950FE7A620E6B790F3042 /* [CP] Embed Pods Frameworks */,
|
||||||
7996A12D2E6FB82300371142 /* Embed Foundation Extensions */,
|
2266B6A6AD27779BC2D49E87 /* [CP] Copy Pods Resources */,
|
||||||
CD8A4C026AF644A41E91C9E8 /* [CP] Embed Pods Frameworks */,
|
|
||||||
);
|
);
|
||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
dependencies = (
|
dependencies = (
|
||||||
7996A12B2E6FB82300371142 /* PBXTargetDependency */,
|
|
||||||
);
|
);
|
||||||
name = digitalpilates;
|
name = OutLive;
|
||||||
productName = digitalpilates;
|
productName = OutLive;
|
||||||
productReference = 13B07F961A680F5B00A75B9A /* digitalpilates.app */;
|
productReference = 13B07F961A680F5B00A75B9A /* OutLive.app */;
|
||||||
productType = "com.apple.product-type.application";
|
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 */
|
/* End PBXNativeTarget section */
|
||||||
|
|
||||||
/* Begin PBXProject section */
|
/* Begin PBXProject section */
|
||||||
83CBB9F71A601CBA00E9B192 /* Project object */ = {
|
83CBB9F71A601CBA00E9B192 /* Project object */ = {
|
||||||
isa = PBXProject;
|
isa = PBXProject;
|
||||||
attributes = {
|
attributes = {
|
||||||
LastSwiftUpdateCheck = 1640;
|
|
||||||
LastUpgradeCheck = 1130;
|
LastUpgradeCheck = 1130;
|
||||||
TargetAttributes = {
|
TargetAttributes = {
|
||||||
13B07F861A680F5B00A75B9A = {
|
13B07F861A680F5B00A75B9A = {
|
||||||
LastSwiftMigration = 1250;
|
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";
|
compatibilityVersion = "Xcode 3.2";
|
||||||
developmentRegion = "zh-Hans";
|
developmentRegion = en;
|
||||||
hasScannedForEncodings = 0;
|
hasScannedForEncodings = 0;
|
||||||
knownRegions = (
|
knownRegions = (
|
||||||
|
en,
|
||||||
Base,
|
Base,
|
||||||
"zh-Hans",
|
|
||||||
);
|
);
|
||||||
mainGroup = 83CBB9F61A601CBA00E9B192;
|
mainGroup = 83CBB9F61A601CBA00E9B192;
|
||||||
productRefGroup = 83CBBA001A601CBA00E9B192 /* Products */;
|
productRefGroup = 83CBBA001A601CBA00E9B192 /* Products */;
|
||||||
projectDirPath = "";
|
projectDirPath = "";
|
||||||
projectRoot = "";
|
projectRoot = "";
|
||||||
targets = (
|
targets = (
|
||||||
13B07F861A680F5B00A75B9A /* digitalpilates */,
|
13B07F861A680F5B00A75B9A /* OutLive */,
|
||||||
7996A1162E6FB82300371142 /* WaterWidgetExtension */,
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
/* End PBXProject section */
|
/* End PBXProject section */
|
||||||
@@ -292,14 +203,7 @@
|
|||||||
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */,
|
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */,
|
||||||
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */,
|
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */,
|
||||||
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */,
|
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */,
|
||||||
2C9C524987451393B76B9C7E /* PrivacyInfo.xcprivacy in Resources */,
|
91B7BA17B50D328546B5B4B8 /* PrivacyInfo.xcprivacy in Resources */,
|
||||||
);
|
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
|
||||||
};
|
|
||||||
7996A1152E6FB82300371142 /* Resources */ = {
|
|
||||||
isa = PBXResourcesBuildPhase;
|
|
||||||
buildActionMask = 2147483647;
|
|
||||||
files = (
|
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -313,6 +217,8 @@
|
|||||||
files = (
|
files = (
|
||||||
);
|
);
|
||||||
inputPaths = (
|
inputPaths = (
|
||||||
|
"$(SRCROOT)/.xcode.env",
|
||||||
|
"$(SRCROOT)/.xcode.env.local",
|
||||||
);
|
);
|
||||||
name = "Bundle React Native code and images";
|
name = "Bundle React Native code and images";
|
||||||
outputPaths = (
|
outputPaths = (
|
||||||
@@ -321,7 +227,7 @@
|
|||||||
shellPath = /bin/sh;
|
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";
|
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;
|
isa = PBXShellScriptBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
@@ -336,44 +242,20 @@
|
|||||||
outputFileListPaths = (
|
outputFileListPaths = (
|
||||||
);
|
);
|
||||||
outputPaths = (
|
outputPaths = (
|
||||||
"$(DERIVED_FILE_DIR)/Pods-digitalpilates-checkManifestLockResult.txt",
|
"$(DERIVED_FILE_DIR)/Pods-OutLive-checkManifestLockResult.txt",
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
shellPath = /bin/sh;
|
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";
|
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;
|
showEnvVarsInLog = 0;
|
||||||
};
|
};
|
||||||
60F566376E07CDAA8138E40B /* [Expo] Configure project */ = {
|
2266B6A6AD27779BC2D49E87 /* [CP] Copy Pods Resources */ = {
|
||||||
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 */ = {
|
|
||||||
isa = PBXShellScriptBuildPhase;
|
isa = PBXShellScriptBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
);
|
);
|
||||||
inputPaths = (
|
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}/EXApplication/ExpoApplication_privacy.bundle",
|
||||||
"${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/EXConstants.bundle",
|
"${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/EXConstants.bundle",
|
||||||
"${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/ExpoConstants_privacy.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}/ExpoFileSystem/ExpoFileSystem_privacy.bundle",
|
||||||
"${PODS_CONFIGURATION_BUILD_DIR}/ExpoSystemUI/ExpoSystemUI_privacy.bundle",
|
"${PODS_CONFIGURATION_BUILD_DIR}/ExpoSystemUI/ExpoSystemUI_privacy.bundle",
|
||||||
"${PODS_CONFIGURATION_BUILD_DIR}/PurchasesHybridCommon/PurchasesHybridCommon.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}/RNCAsyncStorage/RNCAsyncStorage_resources.bundle",
|
||||||
"${PODS_CONFIGURATION_BUILD_DIR}/RNDeviceInfo/RNDeviceInfoPrivacyInfo.bundle",
|
"${PODS_CONFIGURATION_BUILD_DIR}/RNDeviceInfo/RNDeviceInfoPrivacyInfo.bundle",
|
||||||
"${PODS_CONFIGURATION_BUILD_DIR}/RNSVG/RNSVGFilters.bundle",
|
"${PODS_CONFIGURATION_BUILD_DIR}/RNSVG/RNSVGFilters.bundle",
|
||||||
@@ -391,8 +272,6 @@
|
|||||||
"${PODS_CONFIGURATION_BUILD_DIR}/RevenueCat/RevenueCat.bundle",
|
"${PODS_CONFIGURATION_BUILD_DIR}/RevenueCat/RevenueCat.bundle",
|
||||||
"${PODS_CONFIGURATION_BUILD_DIR}/SDWebImage/SDWebImage.bundle",
|
"${PODS_CONFIGURATION_BUILD_DIR}/SDWebImage/SDWebImage.bundle",
|
||||||
"${PODS_CONFIGURATION_BUILD_DIR}/Sentry/Sentry.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-ios/LottiePrivacyInfo.bundle",
|
||||||
"${PODS_CONFIGURATION_BUILD_DIR}/lottie-react-native/Lottie_React_Native_Privacy.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}/ExpoFileSystem_privacy.bundle",
|
||||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoSystemUI_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}/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}/RNCAsyncStorage_resources.bundle",
|
||||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNDeviceInfoPrivacyInfo.bundle",
|
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNDeviceInfoPrivacyInfo.bundle",
|
||||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNSVGFilters.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}/RevenueCat.bundle",
|
||||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/SDWebImage.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}/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}/LottiePrivacyInfo.bundle",
|
||||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Lottie_React_Native_Privacy.bundle",
|
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Lottie_React_Native_Privacy.bundle",
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
shellPath = /bin/sh;
|
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;
|
showEnvVarsInLog = 0;
|
||||||
};
|
};
|
||||||
CD8A4C026AF644A41E91C9E8 /* [CP] Embed Pods Frameworks */ = {
|
EF8950FE7A620E6B790F3042 /* [CP] Embed Pods Frameworks */ = {
|
||||||
isa = PBXShellScriptBuildPhase;
|
isa = PBXShellScriptBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
);
|
);
|
||||||
inputPaths = (
|
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",
|
"${PODS_XCFRAMEWORKS_BUILD_DIR}/hermes-engine/Pre-built/hermes.framework/hermes",
|
||||||
);
|
);
|
||||||
name = "[CP] Embed Pods Frameworks";
|
name = "[CP] Embed Pods Frameworks";
|
||||||
outputPaths = (
|
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",
|
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/hermes.framework",
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
shellPath = /bin/sh;
|
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;
|
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 */
|
/* End PBXShellScriptBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXSourcesBuildPhase section */
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
@@ -450,52 +354,38 @@
|
|||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
79B2CB732E7B954F00B51753 /* HealthKitManager.m in Sources */,
|
||||||
|
79B2CB742E7B954F00B51753 /* HealthKitManager.swift in Sources */,
|
||||||
|
79B2CB702E7B954600B51753 /* OutLive-Bridging-Header.h in Sources */,
|
||||||
F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */,
|
F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */,
|
||||||
DC3BFC72D3A68C7493D5B44A /* ExpoModulesProvider.swift in Sources */,
|
32476CAEFFCE691C1634B0A4 /* ExpoModulesProvider.swift in Sources */,
|
||||||
);
|
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
|
||||||
};
|
|
||||||
7996A1132E6FB82300371142 /* Sources */ = {
|
|
||||||
isa = PBXSourcesBuildPhase;
|
|
||||||
buildActionMask = 2147483647;
|
|
||||||
files = (
|
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
/* End PBXSourcesBuildPhase section */
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXTargetDependency section */
|
|
||||||
7996A12B2E6FB82300371142 /* PBXTargetDependency */ = {
|
|
||||||
isa = PBXTargetDependency;
|
|
||||||
target = 7996A1162E6FB82300371142 /* WaterWidgetExtension */;
|
|
||||||
targetProxy = 7996A12A2E6FB82300371142 /* PBXContainerItemProxy */;
|
|
||||||
};
|
|
||||||
/* End PBXTargetDependency section */
|
|
||||||
|
|
||||||
/* Begin XCBuildConfiguration section */
|
/* Begin XCBuildConfiguration section */
|
||||||
13B07F941A680F5B00A75B9A /* Debug */ = {
|
13B07F941A680F5B00A75B9A /* Debug */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
baseConfigurationReference = 4D6B8E20DD8E5677F8B2EAA1 /* Pods-digitalpilates.debug.xcconfig */;
|
baseConfigurationReference = 4FAA45EB21D2C45B94943F48 /* Pods-OutLive.debug.xcconfig */;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = digitalpilates/digitalpilates.entitlements;
|
CODE_SIGN_ENTITLEMENTS = OutLive/OutLive.entitlements;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = 756WVXJ6MT;
|
DEVELOPMENT_TEAM = 756WVXJ6MT;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = x86_64;
|
|
||||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"FB_SONARKIT_ENABLED=1",
|
"FB_SONARKIT_ENABLED=1",
|
||||||
);
|
);
|
||||||
INFOPLIST_FILE = digitalpilates/Info.plist;
|
INFOPLIST_FILE = OutLive/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = "Out Live";
|
IPHONEOS_DEPLOYMENT_TARGET = 16.6;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
|
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0.7;
|
MARKETING_VERSION = 1.0;
|
||||||
OTHER_LDFLAGS = (
|
OTHER_LDFLAGS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"-ObjC",
|
"-ObjC",
|
||||||
@@ -503,12 +393,8 @@
|
|||||||
);
|
);
|
||||||
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
|
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.anonymous.digitalpilates;
|
PRODUCT_BUNDLE_IDENTIFIER = com.anonymous.digitalpilates;
|
||||||
PRODUCT_NAME = digitalpilates;
|
PRODUCT_NAME = OutLive;
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
SWIFT_OBJC_BRIDGING_HEADER = "OutLive/OutLive-Bridging-Header.h";
|
||||||
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";
|
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = 1;
|
TARGETED_DEVICE_FAMILY = 1;
|
||||||
@@ -518,22 +404,20 @@
|
|||||||
};
|
};
|
||||||
13B07F951A680F5B00A75B9A /* Release */ = {
|
13B07F951A680F5B00A75B9A /* Release */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
baseConfigurationReference = EA6A757B2DE1747F7B3664B4 /* Pods-digitalpilates.release.xcconfig */;
|
baseConfigurationReference = 0FF78E2879F14B0D75DF41B4 /* Pods-OutLive.release.xcconfig */;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = digitalpilates/digitalpilates.entitlements;
|
CODE_SIGN_ENTITLEMENTS = OutLive/OutLive.entitlements;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = 756WVXJ6MT;
|
DEVELOPMENT_TEAM = 756WVXJ6MT;
|
||||||
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = x86_64;
|
INFOPLIST_FILE = OutLive/Info.plist;
|
||||||
INFOPLIST_FILE = digitalpilates/Info.plist;
|
IPHONEOS_DEPLOYMENT_TARGET = 16.6;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = "Out Live";
|
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
|
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0.7;
|
MARKETING_VERSION = 1.0;
|
||||||
OTHER_LDFLAGS = (
|
OTHER_LDFLAGS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"-ObjC",
|
"-ObjC",
|
||||||
@@ -541,109 +425,14 @@
|
|||||||
);
|
);
|
||||||
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
|
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.anonymous.digitalpilates;
|
PRODUCT_BUNDLE_IDENTIFIER = com.anonymous.digitalpilates;
|
||||||
PRODUCT_NAME = digitalpilates;
|
PRODUCT_NAME = OutLive;
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
SWIFT_OBJC_BRIDGING_HEADER = "OutLive/OutLive-Bridging-Header.h";
|
||||||
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";
|
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = 1;
|
TARGETED_DEVICE_FAMILY = 1;
|
||||||
VERSIONING_SYSTEM = "apple-generic";
|
VERSIONING_SYSTEM = "apple-generic";
|
||||||
};
|
};
|
||||||
name = Release;
|
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 */ = {
|
83CBBA201A601CBA00E9B192 /* Debug */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
@@ -699,13 +488,10 @@
|
|||||||
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\"";
|
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\"";
|
||||||
MTL_ENABLE_DEBUG_INFO = YES;
|
MTL_ENABLE_DEBUG_INFO = YES;
|
||||||
ONLY_ACTIVE_ARCH = YES;
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
OTHER_LDFLAGS = (
|
|
||||||
"$(inherited)",
|
|
||||||
" ",
|
|
||||||
);
|
|
||||||
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
|
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG";
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG";
|
||||||
|
SWIFT_ENABLE_EXPLICIT_MODULES = NO;
|
||||||
USE_HERMES = true;
|
USE_HERMES = true;
|
||||||
};
|
};
|
||||||
name = Debug;
|
name = Debug;
|
||||||
@@ -757,12 +543,9 @@
|
|||||||
);
|
);
|
||||||
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\"";
|
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\"";
|
||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
OTHER_LDFLAGS = (
|
|
||||||
"$(inherited)",
|
|
||||||
" ",
|
|
||||||
);
|
|
||||||
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
|
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
|
SWIFT_ENABLE_EXPLICIT_MODULES = NO;
|
||||||
USE_HERMES = true;
|
USE_HERMES = true;
|
||||||
VALIDATE_PRODUCT = YES;
|
VALIDATE_PRODUCT = YES;
|
||||||
};
|
};
|
||||||
@@ -771,7 +554,7 @@
|
|||||||
/* End XCBuildConfiguration section */
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
/* Begin XCConfigurationList section */
|
/* Begin XCConfigurationList section */
|
||||||
13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "digitalpilates" */ = {
|
13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "OutLive" */ = {
|
||||||
isa = XCConfigurationList;
|
isa = XCConfigurationList;
|
||||||
buildConfigurations = (
|
buildConfigurations = (
|
||||||
13B07F941A680F5B00A75B9A /* Debug */,
|
13B07F941A680F5B00A75B9A /* Debug */,
|
||||||
@@ -780,16 +563,7 @@
|
|||||||
defaultConfigurationIsVisible = 0;
|
defaultConfigurationIsVisible = 0;
|
||||||
defaultConfigurationName = Release;
|
defaultConfigurationName = Release;
|
||||||
};
|
};
|
||||||
7996A1312E6FB82300371142 /* Build configuration list for PBXNativeTarget "WaterWidgetExtension" */ = {
|
83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "OutLive" */ = {
|
||||||
isa = XCConfigurationList;
|
|
||||||
buildConfigurations = (
|
|
||||||
7996A12E2E6FB82300371142 /* Debug */,
|
|
||||||
7996A12F2E6FB82300371142 /* Release */,
|
|
||||||
);
|
|
||||||
defaultConfigurationIsVisible = 0;
|
|
||||||
defaultConfigurationName = Release;
|
|
||||||
};
|
|
||||||
83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "digitalpilates" */ = {
|
|
||||||
isa = XCConfigurationList;
|
isa = XCConfigurationList;
|
||||||
buildConfigurations = (
|
buildConfigurations = (
|
||||||
83CBBA201A601CBA00E9B192 /* Debug */,
|
83CBBA201A601CBA00E9B192 /* Debug */,
|
||||||
@@ -15,9 +15,9 @@
|
|||||||
<BuildableReference
|
<BuildableReference
|
||||||
BuildableIdentifier = "primary"
|
BuildableIdentifier = "primary"
|
||||||
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
|
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
|
||||||
BuildableName = "digitalpilates.app"
|
BuildableName = "OutLive.app"
|
||||||
BlueprintName = "digitalpilates"
|
BlueprintName = "OutLive"
|
||||||
ReferencedContainer = "container:digitalpilates.xcodeproj">
|
ReferencedContainer = "container:OutLive.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</BuildActionEntry>
|
</BuildActionEntry>
|
||||||
</BuildActionEntries>
|
</BuildActionEntries>
|
||||||
@@ -33,9 +33,9 @@
|
|||||||
<BuildableReference
|
<BuildableReference
|
||||||
BuildableIdentifier = "primary"
|
BuildableIdentifier = "primary"
|
||||||
BlueprintIdentifier = "00E356ED1AD99517003FC87E"
|
BlueprintIdentifier = "00E356ED1AD99517003FC87E"
|
||||||
BuildableName = "digitalpilatesTests.xctest"
|
BuildableName = "OutLiveTests.xctest"
|
||||||
BlueprintName = "digitalpilatesTests"
|
BlueprintName = "OutLiveTests"
|
||||||
ReferencedContainer = "container:digitalpilates.xcodeproj">
|
ReferencedContainer = "container:OutLive.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</TestableReference>
|
</TestableReference>
|
||||||
</Testables>
|
</Testables>
|
||||||
@@ -55,9 +55,9 @@
|
|||||||
<BuildableReference
|
<BuildableReference
|
||||||
BuildableIdentifier = "primary"
|
BuildableIdentifier = "primary"
|
||||||
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
|
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
|
||||||
BuildableName = "digitalpilates.app"
|
BuildableName = "OutLive.app"
|
||||||
BlueprintName = "digitalpilates"
|
BlueprintName = "OutLive"
|
||||||
ReferencedContainer = "container:digitalpilates.xcodeproj">
|
ReferencedContainer = "container:OutLive.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</BuildableProductRunnable>
|
</BuildableProductRunnable>
|
||||||
</LaunchAction>
|
</LaunchAction>
|
||||||
@@ -72,9 +72,9 @@
|
|||||||
<BuildableReference
|
<BuildableReference
|
||||||
BuildableIdentifier = "primary"
|
BuildableIdentifier = "primary"
|
||||||
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
|
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
|
||||||
BuildableName = "digitalpilates.app"
|
BuildableName = "OutLive.app"
|
||||||
BlueprintName = "digitalpilates"
|
BlueprintName = "OutLive"
|
||||||
ReferencedContainer = "container:digitalpilates.xcodeproj">
|
ReferencedContainer = "container:OutLive.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</BuildableProductRunnable>
|
</BuildableProductRunnable>
|
||||||
</ProfileAction>
|
</ProfileAction>
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
<Workspace
|
<Workspace
|
||||||
version = "1.0">
|
version = "1.0">
|
||||||
<FileRef
|
<FileRef
|
||||||
location = "group:digitalpilates.xcodeproj">
|
location = "group:OutLive.xcodeproj">
|
||||||
</FileRef>
|
</FileRef>
|
||||||
<FileRef
|
<FileRef
|
||||||
location = "group:Pods/Pods.xcodeproj">
|
location = "group:Pods/Pods.xcodeproj">
|
||||||
80
ios/OutLive/HealthKitManager.m
Normal 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
|
||||||
1580
ios/OutLive/HealthKitManager.swift
Normal file
|
After Width: | Height: | Size: 248 KiB |
14
ios/OutLive/Images.xcassets/AppIcon.appiconset/Contents.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"images": [
|
||||||
|
{
|
||||||
|
"filename": "App-Icon-1024x1024@1x.png",
|
||||||
|
"idiom": "universal",
|
||||||
|
"platform": "ios",
|
||||||
|
"size": "1024x1024"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info": {
|
||||||
|
"version": 1,
|
||||||
|
"author": "expo"
|
||||||
|
}
|
||||||
|
}
|
||||||