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": {
|
||||
"name": "Out Live",
|
||||
"slug": "digital-pilates",
|
||||
"version": "1.0.12",
|
||||
"version": "1.0.14",
|
||||
"orientation": "portrait",
|
||||
"scheme": "digitalpilates",
|
||||
"userInterfaceStyle": "light",
|
||||
"icon": "./assets/icon.icon/Assets/icon-1756312748268.jpg",
|
||||
"newArchEnabled": true,
|
||||
"jsEngine": "jsc",
|
||||
"ios": {
|
||||
"supportsTablet": false,
|
||||
"deploymentTarget": "16.0",
|
||||
"bundleIdentifier": "com.anonymous.digitalpilates",
|
||||
"infoPlist": {
|
||||
"ITSAppUsesNonExemptEncryption": false,
|
||||
@@ -25,31 +27,23 @@
|
||||
"remote-notification"
|
||||
]
|
||||
},
|
||||
"icon": "./assets/icon.icon"
|
||||
"appleTeamId": "756WVXJ6MT"
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
[
|
||||
"expo-splash-screen",
|
||||
{
|
||||
"image": "./assets/icon.icon",
|
||||
"image": "./assets/icon.icon/Assets/icon-1756312748268.jpg",
|
||||
"imageWidth": 40,
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#ffffff"
|
||||
}
|
||||
],
|
||||
[
|
||||
"react-native-health",
|
||||
{
|
||||
"enableHealthAPI": true,
|
||||
"healthSharePermission": "应用需要访问您的健康数据(步数、能量消耗、心率变异性等)以展示运动统计和压力分析。",
|
||||
"healthUpdatePermission": "应用需要更新您的健康数据(体重信息)以记录您的健身进度。"
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-notifications",
|
||||
{
|
||||
"icon": "./assets/icon.icon",
|
||||
"icon": "./assets/icon.icon/Assets/icon-1756312748268.jpg",
|
||||
"color": "#ffffff"
|
||||
}
|
||||
],
|
||||
@@ -70,6 +64,9 @@
|
||||
],
|
||||
"experiments": {
|
||||
"typedRoutes": true
|
||||
},
|
||||
"android": {
|
||||
"package": "com.anonymous.digitalpilates"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,8 +21,8 @@ type TabConfig = {
|
||||
|
||||
const TAB_CONFIGS: Record<string, TabConfig> = {
|
||||
statistics: { icon: 'chart.pie.fill', title: '健康' },
|
||||
// explore: { icon: 'magnifyingglass.circle.fill', title: '发现' },
|
||||
goals: { icon: 'flag.fill', title: '习惯' },
|
||||
challenges: { icon: 'trophy.fill', title: '挑战' },
|
||||
personal: { icon: 'person.fill', title: '个人' },
|
||||
};
|
||||
|
||||
@@ -35,9 +35,10 @@ export default function TabLayout() {
|
||||
// Helper function to determine if a tab is selected
|
||||
const isTabSelected = (routeName: string): boolean => {
|
||||
const routeMap: Record<string, string> = {
|
||||
explore: ROUTES.TAB_EXPLORE,
|
||||
goals: ROUTES.TAB_GOALS,
|
||||
statistics: ROUTES.TAB_STATISTICS,
|
||||
goals: ROUTES.TAB_GOALS,
|
||||
challenges: ROUTES.TAB_CHALLENGES,
|
||||
personal: ROUTES.TAB_PERSONAL,
|
||||
};
|
||||
|
||||
return routeMap[routeName] === pathname || pathname.includes(routeName);
|
||||
@@ -69,11 +70,11 @@ export default function TabLayout() {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexDirection: 'row',
|
||||
marginHorizontal: 6,
|
||||
marginHorizontal: 2,
|
||||
marginVertical: 10,
|
||||
borderRadius: 25,
|
||||
backgroundColor: isSelected ? colorTokens.tabBarActiveBackground : 'transparent',
|
||||
paddingHorizontal: isSelected ? 16 : 10,
|
||||
paddingHorizontal: isSelected ? 8 : 4,
|
||||
paddingVertical: 8,
|
||||
}}
|
||||
>
|
||||
@@ -91,7 +92,7 @@ export default function TabLayout() {
|
||||
fontWeight: '600',
|
||||
marginLeft: 6,
|
||||
}}
|
||||
numberOfLines={0 as any}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{tabConfig.title}
|
||||
</Text>
|
||||
@@ -148,12 +149,12 @@ export default function TabLayout() {
|
||||
shadowOpacity: glassEffectAvailable ? 0.1 : 0.2,
|
||||
shadowRadius: 10,
|
||||
elevation: 5,
|
||||
paddingHorizontal: 10,
|
||||
paddingHorizontal: 6,
|
||||
paddingTop: 0,
|
||||
paddingBottom: 0,
|
||||
marginHorizontal: 20,
|
||||
left: 20,
|
||||
right: 20,
|
||||
marginHorizontal: 16,
|
||||
left: 16,
|
||||
right: 16,
|
||||
alignSelf: 'center',
|
||||
borderWidth: glassEffectAvailable ? 1 : 0,
|
||||
borderColor: glassEffectAvailable ? (theme === 'dark' ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)') : 'transparent',
|
||||
@@ -177,7 +178,11 @@ export default function TabLayout() {
|
||||
</NativeTabs.Trigger>
|
||||
<NativeTabs.Trigger name="goals">
|
||||
<Icon sf="flag.fill" drawable="custom_settings_drawable" />
|
||||
<Label>目标</Label>
|
||||
<Label>习惯</Label>
|
||||
</NativeTabs.Trigger>
|
||||
<NativeTabs.Trigger name="challenges">
|
||||
<Icon sf="trophy.fill" drawable="custom_android_drawable" />
|
||||
<Label>挑战</Label>
|
||||
</NativeTabs.Trigger>
|
||||
<NativeTabs.Trigger name="personal">
|
||||
<Icon sf="person.fill" drawable="custom_settings_drawable" />
|
||||
@@ -193,9 +198,8 @@ export default function TabLayout() {
|
||||
>
|
||||
|
||||
<Tabs.Screen name="statistics" options={{ title: '健康' }} />
|
||||
<Tabs.Screen name="explore" options={{ title: '发现', href: null }} />
|
||||
<Tabs.Screen name="coach" options={{ title: 'AI', href: null }} />
|
||||
<Tabs.Screen name="goals" options={{ title: '习惯' }} />
|
||||
<Tabs.Screen name="challenges" options={{ title: '挑战' }} />
|
||||
<Tabs.Screen name="personal" options={{ title: '个人' }} />
|
||||
</Tabs>
|
||||
);
|
||||
|
||||
297
app/(tabs)/challenges.tsx
Normal file
@@ -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 () => {
|
||||
setRefreshing(true);
|
||||
try {
|
||||
if (!isLoggedIn) return
|
||||
|
||||
await loadTasks();
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
@@ -117,6 +119,8 @@ export default function GoalsScreen() {
|
||||
|
||||
// 加载更多任务
|
||||
const handleLoadMoreTasks = async () => {
|
||||
if (!isLoggedIn) return
|
||||
|
||||
if (tasksPagination.hasMore && !tasksLoading) {
|
||||
try {
|
||||
await dispatch(loadMoreTasks()).unwrap();
|
||||
@@ -319,6 +323,61 @@ export default function GoalsScreen() {
|
||||
|
||||
// 渲染空状态
|
||||
const renderEmptyState = () => {
|
||||
// 未登录状态下的引导
|
||||
if (!isLoggedIn) {
|
||||
return (
|
||||
<View style={styles.emptyStateLogin}>
|
||||
<LinearGradient
|
||||
colors={['#F0F9FF', '#FEFEFE', '#F0F9FF']}
|
||||
style={styles.emptyStateLoginBackground}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
/>
|
||||
|
||||
<View style={styles.emptyStateLoginContent}>
|
||||
{/* 清新的图标设计 */}
|
||||
<View style={styles.emptyStateLoginIconContainer}>
|
||||
<LinearGradient
|
||||
colors={[colorTokens.primary, '#9B8AFB']}
|
||||
style={styles.emptyStateLoginIconGradient}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
>
|
||||
<MaterialIcons name="person-outline" size={32} color="#FFFFFF" />
|
||||
</LinearGradient>
|
||||
</View>
|
||||
|
||||
{/* 主标题 */}
|
||||
<Text style={[styles.emptyStateLoginTitle, { color: colorTokens.text }]}>
|
||||
开启您的健康之旅
|
||||
</Text>
|
||||
|
||||
{/* 副标题 */}
|
||||
<Text style={[styles.emptyStateLoginSubtitle, { color: colorTokens.textSecondary }]}>
|
||||
登录后即可创建个人目标,让我们一起建立健康的生活习惯
|
||||
</Text>
|
||||
|
||||
{/* 登录按钮 */}
|
||||
<TouchableOpacity
|
||||
style={[styles.emptyStateLoginButton, { backgroundColor: colorTokens.primary }]}
|
||||
onPress={() => pushIfAuthedElseLogin('/goals')}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={[colorTokens.primary, '#9B8AFB']}
|
||||
style={styles.emptyStateLoginButtonGradient}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
>
|
||||
<Text style={styles.emptyStateLoginButtonText}>立即登录</Text>
|
||||
<MaterialIcons name="arrow-forward" size={18} color="#FFFFFF" />
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// 已登录但无任务的状态
|
||||
let title = '暂无任务';
|
||||
let subtitle = '创建目标后,系统会自动生成相应的任务';
|
||||
|
||||
@@ -710,6 +769,80 @@ const styles = StyleSheet.create({
|
||||
textAlign: 'center',
|
||||
lineHeight: 20,
|
||||
},
|
||||
// 未登录空状态样式
|
||||
emptyStateLogin: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: 80,
|
||||
position: 'relative',
|
||||
},
|
||||
emptyStateLoginBackground: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
borderRadius: 24,
|
||||
},
|
||||
emptyStateLoginContent: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1,
|
||||
},
|
||||
emptyStateLoginIconContainer: {
|
||||
marginBottom: 24,
|
||||
shadowColor: '#7A5AF8',
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
shadowOpacity: 0.15,
|
||||
shadowRadius: 16,
|
||||
elevation: 8,
|
||||
},
|
||||
emptyStateLoginIconGradient: {
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: 40,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
emptyStateLoginTitle: {
|
||||
fontSize: 24,
|
||||
fontWeight: '700',
|
||||
marginBottom: 12,
|
||||
textAlign: 'center',
|
||||
letterSpacing: -0.5,
|
||||
},
|
||||
emptyStateLoginSubtitle: {
|
||||
fontSize: 16,
|
||||
lineHeight: 24,
|
||||
textAlign: 'center',
|
||||
marginBottom: 32,
|
||||
paddingHorizontal: 8,
|
||||
},
|
||||
emptyStateLoginButton: {
|
||||
borderRadius: 28,
|
||||
shadowColor: '#7A5AF8',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 12,
|
||||
elevation: 6,
|
||||
},
|
||||
emptyStateLoginButtonGradient: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: 32,
|
||||
paddingVertical: 16,
|
||||
borderRadius: 28,
|
||||
gap: 8,
|
||||
},
|
||||
emptyStateLoginButtonText: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 17,
|
||||
fontWeight: '600',
|
||||
letterSpacing: -0.2,
|
||||
},
|
||||
loadMoreContainer: {
|
||||
alignItems: 'center',
|
||||
paddingVertical: 20,
|
||||
|
||||
@@ -9,8 +9,6 @@ import { DEFAULT_MEMBER_NAME, fetchActivityHistory, fetchMyProfile } from '@/sto
|
||||
import { getItem, setItem } from '@/utils/kvStore';
|
||||
import { log } from '@/utils/logger';
|
||||
import { getNotificationEnabled, setNotificationEnabled as saveNotificationEnabled } from '@/utils/userPreferences';
|
||||
import { Button, Host, Text as SwiftText } from '@expo/ui/swift-ui';
|
||||
import { frame, glassEffect } from '@expo/ui/swift-ui/modifiers';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
import { isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
@@ -214,35 +212,13 @@ export default function PersonalScreen() {
|
||||
<TouchableOpacity onPress={handleUserNamePress} activeOpacity={0.7}>
|
||||
<Text style={styles.userName}>{displayName}</Text>
|
||||
</TouchableOpacity>
|
||||
{userProfile.memberNumber && (
|
||||
<Text style={styles.userMemberNumber}>会员编号: {userProfile.memberNumber}</Text>
|
||||
)}
|
||||
</View>
|
||||
{isLgAvaliable ? <Host style={{
|
||||
marginRight: 18,
|
||||
}}>
|
||||
<Button
|
||||
variant='default'
|
||||
onPress={() => {
|
||||
console.log(111111);
|
||||
|
||||
// pushIfAuthedElseLogin('/profile/edit')
|
||||
}}
|
||||
modifiers={[
|
||||
frame({
|
||||
width: 60,
|
||||
height: 30,
|
||||
}),
|
||||
glassEffect({
|
||||
glass: {
|
||||
variant: 'regular',
|
||||
interactive: true
|
||||
}
|
||||
})
|
||||
]} >
|
||||
<SwiftText size={14} color='black' weight={'medium'}>编辑</SwiftText>
|
||||
</Button>
|
||||
</Host> : <TouchableOpacity style={styles.editButton} onPress={() => pushIfAuthedElseLogin('/profile/edit')}>
|
||||
<Text style={styles.editButtonText}>编辑</Text>
|
||||
</TouchableOpacity>}
|
||||
|
||||
<TouchableOpacity style={styles.editButton} onPress={() => pushIfAuthedElseLogin('/profile/edit')}>
|
||||
<Text style={styles.editButtonText}>{isLoggedIn ? '编辑' : '登录'}</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
</View>
|
||||
|
||||
@@ -507,6 +483,11 @@ const styles = StyleSheet.create({
|
||||
color: '#9370DB',
|
||||
fontWeight: '500',
|
||||
},
|
||||
userMemberNumber: {
|
||||
fontSize: 10,
|
||||
color: '#6C757D',
|
||||
marginTop: 4,
|
||||
},
|
||||
editButton: {
|
||||
backgroundColor: '#9370DB',
|
||||
paddingHorizontal: 16,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { DateSelector } from '@/components/DateSelector';
|
||||
import { FitnessRingsCard } from '@/components/FitnessRingsCard';
|
||||
import { MoodCard } from '@/components/MoodCard';
|
||||
import { NutritionRadarCard } from '@/components/NutritionRadarCard';
|
||||
import CircumferenceCard from '@/components/statistic/CircumferenceCard';
|
||||
import OxygenSaturationCard from '@/components/statistic/OxygenSaturationCard';
|
||||
import SleepCard from '@/components/statistic/SleepCard';
|
||||
import StepsCard from '@/components/StepsCard';
|
||||
@@ -13,14 +14,11 @@ import { Colors } from '@/constants/Colors';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||
import { BackgroundTaskManager } from '@/services/backgroundTaskManager';
|
||||
import { selectHealthDataByDate, setHealthData } from '@/store/healthSlice';
|
||||
import { setHealthData } from '@/store/healthSlice';
|
||||
import { fetchDailyMoodCheckins, selectLatestMoodRecordByDate } from '@/store/moodSlice';
|
||||
import { fetchDailyNutritionData, selectNutritionSummaryByDate } from '@/store/nutritionSlice';
|
||||
import { fetchTodayWaterStats } from '@/store/waterSlice';
|
||||
import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date';
|
||||
import { ensureHealthPermissions, fetchHealthDataForDate, testHRVDataFetch } from '@/utils/health';
|
||||
import { getTestHealthData } from '@/utils/mockHealthData';
|
||||
import { calculateNutritionGoals } from '@/utils/nutrition';
|
||||
import { fetchHealthDataForDate, testHRVDataFetch } from '@/utils/health';
|
||||
import dayjs from 'dayjs';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { debounce } from 'lodash';
|
||||
@@ -37,13 +35,10 @@ import {
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
// 浮动动画组件
|
||||
const FloatingCard = ({ children, delay = 0, style }: {
|
||||
const FloatingCard = ({ children, style }: {
|
||||
children: React.ReactNode;
|
||||
delay?: number;
|
||||
style?: any;
|
||||
}) => {
|
||||
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
@@ -60,11 +55,6 @@ const FloatingCard = ({ children, delay = 0, style }: {
|
||||
|
||||
export default function ExploreScreen() {
|
||||
const stepGoal = useAppSelector((s) => s.user.profile?.dailyStepsGoal) ?? 2000;
|
||||
const userProfile = useAppSelector((s) => s.user.profile);
|
||||
|
||||
// 开发调试:设置为true来使用mock数据
|
||||
// 在真机测试时,可以暂时设置为true来验证组件显示逻辑
|
||||
const useMockData = false; // 改为true来启用mock数据调试
|
||||
|
||||
const { pushIfAuthedElseLogin, isLoggedIn } = useAuthGuard();
|
||||
|
||||
@@ -83,56 +73,11 @@ export default function ExploreScreen() {
|
||||
return dayjs(currentSelectedDate).format('YYYY-MM-DD');
|
||||
}, [currentSelectedDate]);
|
||||
|
||||
// 从 Redux 获取指定日期的健康数据
|
||||
const healthData = useAppSelector(selectHealthDataByDate(currentSelectedDateString));
|
||||
|
||||
|
||||
// 解构健康数据(支持mock数据)
|
||||
const mockData = useMockData ? getTestHealthData('mock') : null;
|
||||
const activeCalories = useMockData ? (mockData?.activeEnergyBurned ?? null) : (healthData?.activeEnergyBurned ?? null);
|
||||
const basalMetabolism: number | null = useMockData ? (mockData?.basalEnergyBurned ?? null) : (healthData?.basalEnergyBurned ?? null);
|
||||
|
||||
const oxygenSaturation = useMockData ? (mockData?.oxygenSaturation ?? null) : (healthData?.oxygenSaturation ?? null);
|
||||
|
||||
|
||||
const fitnessRingsData = useMockData ? {
|
||||
activeCalories: mockData?.activeCalories ?? 0,
|
||||
activeCaloriesGoal: mockData?.activeCaloriesGoal ?? 350,
|
||||
exerciseMinutes: mockData?.exerciseMinutes ?? 0,
|
||||
exerciseMinutesGoal: mockData?.exerciseMinutesGoal ?? 30,
|
||||
standHours: mockData?.standHours ?? 0,
|
||||
standHoursGoal: mockData?.standHoursGoal ?? 12,
|
||||
} : (healthData ? {
|
||||
activeCalories: healthData.activeEnergyBurned,
|
||||
activeCaloriesGoal: healthData.activeCaloriesGoal,
|
||||
exerciseMinutes: healthData.exerciseMinutes,
|
||||
exerciseMinutesGoal: healthData.exerciseMinutesGoal,
|
||||
standHours: healthData.standHours,
|
||||
standHoursGoal: healthData.standHoursGoal,
|
||||
} : {
|
||||
activeCalories: 0,
|
||||
activeCaloriesGoal: 350,
|
||||
exerciseMinutes: 0,
|
||||
exerciseMinutesGoal: 30,
|
||||
standHours: 0,
|
||||
standHoursGoal: 12,
|
||||
});
|
||||
|
||||
// 用于触发动画重置的 token(当日期或数据变化时更新)
|
||||
const [animToken, setAnimToken] = useState(0);
|
||||
|
||||
// 从 Redux 获取营养数据
|
||||
const nutritionSummary = useAppSelector(selectNutritionSummaryByDate(currentSelectedDateString));
|
||||
|
||||
// 计算用户的营养目标
|
||||
const nutritionGoals = useMemo(() => {
|
||||
return calculateNutritionGoals({
|
||||
weight: userProfile.weight,
|
||||
height: userProfile.height,
|
||||
birthDate: userProfile?.birthDate ? new Date(userProfile?.birthDate) : undefined,
|
||||
gender: userProfile?.gender || undefined,
|
||||
});
|
||||
}, [userProfile]);
|
||||
|
||||
// 心情相关状态
|
||||
const dispatch = useAppDispatch();
|
||||
@@ -144,7 +89,6 @@ export default function ExploreScreen() {
|
||||
// 请求状态管理,防止重复请求
|
||||
const loadingRef = useRef({
|
||||
health: false,
|
||||
nutrition: false,
|
||||
mood: false
|
||||
});
|
||||
|
||||
@@ -153,14 +97,14 @@ export default function ExploreScreen() {
|
||||
|
||||
const getDateKey = (d: Date) => `${dayjs(d).year()}-${dayjs(d).month() + 1}-${dayjs(d).date()}`;
|
||||
|
||||
// 检查数据是否需要刷新(2分钟内不重复拉取,对营养数据更严格)
|
||||
// 检查数据是否需要刷新(5分钟内不重复拉取)
|
||||
const shouldRefreshData = (dateKey: string, dataType: string) => {
|
||||
const cacheKey = `${dateKey}-${dataType}`;
|
||||
const lastUpdate = dataTimestampRef.current[cacheKey];
|
||||
const now = Date.now();
|
||||
|
||||
// 营养数据使用更短的缓存时间,其他数据使用5分钟
|
||||
const cacheTime = dataType === 'nutrition' ? 2 * 60 * 1000 : 5 * 60 * 1000;
|
||||
// 使用5分钟缓存时间
|
||||
const cacheTime = 5 * 60 * 1000;
|
||||
|
||||
return !lastUpdate || (now - lastUpdate) > cacheTime;
|
||||
};
|
||||
@@ -248,13 +192,6 @@ export default function ExploreScreen() {
|
||||
loadingRef.current.health = true;
|
||||
console.log('=== 开始HealthKit初始化流程 ===');
|
||||
|
||||
const ok = await ensureHealthPermissions();
|
||||
if (!ok) {
|
||||
const errorMsg = '无法获取健康权限,请确保在真实iOS设备上运行并授权应用访问健康数据';
|
||||
console.warn(errorMsg);
|
||||
return;
|
||||
}
|
||||
|
||||
latestRequestKeyRef.current = requestKey;
|
||||
|
||||
console.log('权限获取成功,开始获取健康数据...', derivedDate);
|
||||
@@ -271,9 +208,6 @@ export default function ExploreScreen() {
|
||||
date: dateString,
|
||||
data: {
|
||||
activeCalories: data.activeEnergyBurned,
|
||||
basalEnergyBurned: data.basalEnergyBurned,
|
||||
hrv: data.hrv,
|
||||
oxygenSaturation: data.oxygenSaturation,
|
||||
heartRate: data.heartRate,
|
||||
activeEnergyBurned: data.activeEnergyBurned,
|
||||
activeCaloriesGoal: data.activeCaloriesGoal,
|
||||
@@ -301,45 +235,6 @@ export default function ExploreScreen() {
|
||||
};
|
||||
|
||||
// 加载营养数据
|
||||
const loadNutritionData = async (targetDate?: Date, forceRefresh = false) => {
|
||||
if (!isLoggedIn) return;
|
||||
|
||||
// 确定要查询的日期
|
||||
let derivedDate: Date;
|
||||
if (targetDate) {
|
||||
derivedDate = targetDate;
|
||||
} else {
|
||||
derivedDate = currentSelectedDate;
|
||||
}
|
||||
|
||||
const requestKey = getDateKey(derivedDate);
|
||||
|
||||
// 检查是否正在加载或不需要刷新
|
||||
if (loadingRef.current.nutrition) {
|
||||
console.log('营养数据正在加载中,跳过重复请求');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!forceRefresh && !shouldRefreshData(requestKey, 'nutrition')) {
|
||||
console.log('营养数据缓存未过期,跳过请求');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
loadingRef.current.nutrition = true;
|
||||
console.log('加载营养数据...', derivedDate);
|
||||
await dispatch(fetchDailyNutritionData(derivedDate));
|
||||
console.log('营养数据加载完成');
|
||||
|
||||
// 更新缓存时间戳
|
||||
updateDataTimestamp(requestKey, 'nutrition');
|
||||
|
||||
} catch (error) {
|
||||
console.error('营养数据加载失败:', error);
|
||||
} finally {
|
||||
loadingRef.current.nutrition = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 实际执行数据加载的方法
|
||||
const executeLoadAllData = React.useCallback((targetDate?: Date, forceRefresh = false) => {
|
||||
@@ -348,7 +243,6 @@ export default function ExploreScreen() {
|
||||
console.log('执行数据加载,日期:', dateToUse, '强制刷新:', forceRefresh);
|
||||
loadHealthData(dateToUse, forceRefresh);
|
||||
if (isLoggedIn) {
|
||||
loadNutritionData(dateToUse, forceRefresh);
|
||||
loadMoodData(dateToUse, forceRefresh);
|
||||
// 加载喝水数据(只加载今日数据用于后台检查)
|
||||
const isToday = dayjs(dateToUse).isSame(dayjs(), 'day');
|
||||
@@ -380,14 +274,6 @@ export default function ExploreScreen() {
|
||||
loadAllData(currentSelectedDate);
|
||||
}, [])
|
||||
|
||||
// 页面聚焦时的数据加载逻辑
|
||||
// useFocusEffect(
|
||||
// React.useCallback(() => {
|
||||
// // 页面聚焦时加载数据,使用缓存机制避免频繁请求
|
||||
// console.log('页面聚焦,检查是否需要刷新数据...');
|
||||
// loadAllData(currentSelectedDate);
|
||||
// }, [loadAllData, currentSelectedDate])
|
||||
// );
|
||||
|
||||
// AppState 监听:应用从后台返回前台时的处理
|
||||
useEffect(() => {
|
||||
@@ -508,17 +394,8 @@ export default function ExploreScreen() {
|
||||
|
||||
{/* 营养摄入雷达图卡片 */}
|
||||
<NutritionRadarCard
|
||||
nutritionSummary={nutritionSummary}
|
||||
nutritionGoals={nutritionGoals}
|
||||
burnedCalories={(basalMetabolism || 0) + (activeCalories || 0)}
|
||||
basalMetabolism={basalMetabolism || 0}
|
||||
activeCalories={activeCalories || 0}
|
||||
selectedDate={currentSelectedDate}
|
||||
resetToken={animToken}
|
||||
onMealPress={(mealType: 'breakfast' | 'lunch' | 'dinner' | 'snack') => {
|
||||
console.log('选择餐次:', mealType);
|
||||
// 这里可以导航到营养记录页面
|
||||
pushIfAuthedElseLogin('/nutrition/records');
|
||||
}}
|
||||
/>
|
||||
|
||||
<WeightHistoryCard />
|
||||
@@ -528,7 +405,7 @@ export default function ExploreScreen() {
|
||||
{/* 左列 */}
|
||||
<View style={styles.masonryColumn}>
|
||||
{/* 心情卡片 */}
|
||||
<FloatingCard style={styles.masonryCard} delay={1500}>
|
||||
<FloatingCard style={styles.masonryCard}>
|
||||
<MoodCard
|
||||
moodCheckin={currentMoodCheckin}
|
||||
onPress={() => pushIfAuthedElseLogin('/mood/calendar')}
|
||||
@@ -546,7 +423,7 @@ export default function ExploreScreen() {
|
||||
|
||||
|
||||
|
||||
<FloatingCard style={styles.masonryCard} delay={0}>
|
||||
<FloatingCard style={styles.masonryCard}>
|
||||
<StressMeter
|
||||
curDate={currentSelectedDate}
|
||||
/>
|
||||
@@ -564,26 +441,20 @@ export default function ExploreScreen() {
|
||||
<FloatingCard style={styles.masonryCard}>
|
||||
<SleepCard
|
||||
selectedDate={currentSelectedDate}
|
||||
onPress={() => pushIfAuthedElseLogin(`/sleep-detail?date=${dayjs(currentSelectedDate).format('YYYY-MM-DD')}`)}
|
||||
/>
|
||||
</FloatingCard>
|
||||
</View>
|
||||
|
||||
{/* 右列 */}
|
||||
<View style={styles.masonryColumn}>
|
||||
<FloatingCard style={styles.masonryCard} delay={250}>
|
||||
<FloatingCard style={styles.masonryCard}>
|
||||
<FitnessRingsCard
|
||||
activeCalories={fitnessRingsData.activeCalories}
|
||||
activeCaloriesGoal={fitnessRingsData.activeCaloriesGoal}
|
||||
exerciseMinutes={fitnessRingsData.exerciseMinutes}
|
||||
exerciseMinutesGoal={fitnessRingsData.exerciseMinutesGoal}
|
||||
standHours={fitnessRingsData.standHours}
|
||||
standHoursGoal={fitnessRingsData.standHoursGoal}
|
||||
selectedDate={currentSelectedDate}
|
||||
resetToken={animToken}
|
||||
/>
|
||||
</FloatingCard>
|
||||
{/* 饮水记录卡片 */}
|
||||
<FloatingCard style={styles.masonryCard} delay={500}>
|
||||
<FloatingCard style={styles.masonryCard}>
|
||||
<WaterIntakeCard
|
||||
selectedDate={currentSelectedDateString}
|
||||
style={styles.waterCardOverride}
|
||||
@@ -592,26 +463,26 @@ export default function ExploreScreen() {
|
||||
|
||||
|
||||
{/* 基础代谢卡片 */}
|
||||
<FloatingCard style={styles.masonryCard} delay={1250}>
|
||||
<FloatingCard style={styles.masonryCard}>
|
||||
<BasalMetabolismCard
|
||||
value={basalMetabolism}
|
||||
resetToken={animToken}
|
||||
selectedDate={currentSelectedDate}
|
||||
style={styles.basalMetabolismCardOverride}
|
||||
/>
|
||||
</FloatingCard>
|
||||
|
||||
{/* 血氧饱和度卡片 */}
|
||||
<FloatingCard style={styles.masonryCard} delay={1750}>
|
||||
<FloatingCard style={styles.masonryCard}>
|
||||
<OxygenSaturationCard
|
||||
resetToken={animToken}
|
||||
style={styles.basalMetabolismCardOverride}
|
||||
oxygenSaturation={oxygenSaturation}
|
||||
/>
|
||||
</FloatingCard>
|
||||
|
||||
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 围度数据卡片 - 占满底部一行 */}
|
||||
<CircumferenceCard style={styles.circumferenceCard} />
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
@@ -913,7 +784,6 @@ const styles = StyleSheet.create({
|
||||
marginBottom: 16,
|
||||
},
|
||||
masonryContainer: {
|
||||
marginBottom: 16,
|
||||
flexDirection: 'row',
|
||||
gap: 16,
|
||||
marginTop: 6,
|
||||
@@ -976,6 +846,10 @@ const styles = StyleSheet.create({
|
||||
top: 0,
|
||||
padding: 4,
|
||||
},
|
||||
circumferenceCard: {
|
||||
marginBottom: 36,
|
||||
marginTop: 10,
|
||||
},
|
||||
|
||||
|
||||
});
|
||||
|
||||
@@ -14,23 +14,25 @@ import { setupQuickActions } from '@/services/quickActions';
|
||||
import { initializeWaterRecordBridge } from '@/services/waterRecordBridge';
|
||||
import { WaterRecordSource } from '@/services/waterRecords';
|
||||
import { store } from '@/store';
|
||||
import { rehydrateUserSync, setPrivacyAgreed } from '@/store/userSlice';
|
||||
import { fetchMyProfile, setPrivacyAgreed } from '@/store/userSlice';
|
||||
import { createWaterRecordAction } from '@/store/waterSlice';
|
||||
import { ensureHealthPermissions, initializeHealthPermissions } from '@/utils/health';
|
||||
import { DailySummaryNotificationHelpers, MoodNotificationHelpers, NutritionNotificationHelpers } from '@/utils/notificationHelpers';
|
||||
import { clearPendingWaterRecords, syncPendingWidgetChanges } from '@/utils/widgetDataSync';
|
||||
import React from 'react';
|
||||
|
||||
import { DialogProvider } from '@/components/ui/DialogProvider';
|
||||
import { ToastProvider } from '@/contexts/ToastContext';
|
||||
import { STORAGE_KEYS } from '@/services/api';
|
||||
import { BackgroundTaskManager } from '@/services/backgroundTaskManager';
|
||||
import AsyncStorage from '@/utils/kvStore';
|
||||
import { Provider } from 'react-redux';
|
||||
|
||||
|
||||
function Bootstrapper({ children }: { children: React.ReactNode }) {
|
||||
const dispatch = useAppDispatch();
|
||||
const { privacyAgreed, profile } = useAppSelector((state) => state.user);
|
||||
const { profile } = useAppSelector((state) => state.user);
|
||||
const [showPrivacyModal, setShowPrivacyModal] = React.useState(false);
|
||||
const [userDataLoaded, setUserDataLoaded] = React.useState(false);
|
||||
|
||||
// 初始化快捷动作处理
|
||||
useQuickActions();
|
||||
@@ -38,12 +40,33 @@ function Bootstrapper({ children }: { children: React.ReactNode }) {
|
||||
React.useEffect(() => {
|
||||
const loadUserData = async () => {
|
||||
// 数据已经在启动界面预加载,这里只需要快速同步到 Redux 状态
|
||||
await dispatch(rehydrateUserSync());
|
||||
setUserDataLoaded(true);
|
||||
await dispatch(fetchMyProfile());
|
||||
};
|
||||
|
||||
const initHealthPermissions = async () => {
|
||||
// 初始化 HealthKit 权限管理系统
|
||||
try {
|
||||
console.log('初始化 HealthKit 权限管理系统...');
|
||||
initializeHealthPermissions();
|
||||
|
||||
// 延迟请求权限,避免应用启动时弹窗
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await ensureHealthPermissions();
|
||||
console.log('HealthKit 权限请求完成');
|
||||
} catch (error) {
|
||||
console.warn('HealthKit 权限请求失败,可能在模拟器上运行:', error);
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
console.log('HealthKit 权限管理初始化完成');
|
||||
} catch (error) {
|
||||
console.warn('HealthKit 权限管理初始化失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
const initializeNotifications = async () => {
|
||||
try {
|
||||
|
||||
await BackgroundTaskManager.getInstance().initialize();
|
||||
// 初始化通知服务
|
||||
await notificationService.initialize();
|
||||
@@ -102,17 +125,22 @@ function Bootstrapper({ children }: { children: React.ReactNode }) {
|
||||
};
|
||||
|
||||
loadUserData();
|
||||
initHealthPermissions();
|
||||
initializeNotifications();
|
||||
// 冷启动时清空 AI 教练会话缓存
|
||||
clearAiCoachSessionCache();
|
||||
|
||||
}, [dispatch]);
|
||||
|
||||
React.useEffect(() => {
|
||||
// 当用户数据加载完成后,检查是否需要显示隐私同意弹窗
|
||||
if (userDataLoaded && !privacyAgreed) {
|
||||
setShowPrivacyModal(true);
|
||||
|
||||
const getPrivacyAgreed = async () => {
|
||||
const str = await AsyncStorage.getItem(STORAGE_KEYS.privacyAgreed)
|
||||
|
||||
setShowPrivacyModal(str !== 'true');
|
||||
}
|
||||
}, [userDataLoaded, privacyAgreed]);
|
||||
getPrivacyAgreed();
|
||||
}, []);
|
||||
|
||||
const handlePrivacyAgree = () => {
|
||||
dispatch(setPrivacyAgreed());
|
||||
@@ -165,6 +193,7 @@ export default function RootLayout() {
|
||||
<Stack.Screen name="legal/user-agreement" options={{ headerShown: true, title: '用户协议' }} />
|
||||
<Stack.Screen name="legal/privacy-policy" options={{ headerShown: true, title: '隐私政策' }} />
|
||||
<Stack.Screen name="article/[id]" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="water-detail" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="water-settings" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="+not-found" />
|
||||
</Stack>
|
||||
|
||||
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 { generateWelcomeMessage, hasRecordedMoodToday } from '@/utils/welcomeMessage';
|
||||
import { Image } from 'expo-image';
|
||||
import { HistoryModal } from '../../components/model/HistoryModal';
|
||||
import { ActionSheet } from '../../components/ui/ActionSheet';
|
||||
import { HistoryModal } from '../components/model/HistoryModal';
|
||||
import { ActionSheet } from '../components/ui/ActionSheet';
|
||||
|
||||
// 导入新的 coach 组件
|
||||
import {
|
||||
@@ -15,9 +15,12 @@ import { Ionicons } from '@expo/vector-icons';
|
||||
import dayjs from 'dayjs';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { router, useLocalSearchParams } from 'expo-router';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Alert, Image,
|
||||
Keyboard,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
@@ -43,6 +46,9 @@ export default function MoodEditScreen() {
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [existingMood, setExistingMood] = useState<any>(null);
|
||||
|
||||
const scrollViewRef = useRef<ScrollView>(null);
|
||||
const textInputRef = useRef<TextInput>(null);
|
||||
|
||||
const moodOptions = getMoodOptions();
|
||||
|
||||
// 从 Redux 获取数据
|
||||
@@ -66,6 +72,25 @@ export default function MoodEditScreen() {
|
||||
}
|
||||
}, [moodId, moodRecords]);
|
||||
|
||||
// 键盘事件监听器
|
||||
useEffect(() => {
|
||||
const keyboardDidShowListener = Keyboard.addListener('keyboardDidShow', () => {
|
||||
// 键盘出现时,延迟滚动到文本输入框
|
||||
setTimeout(() => {
|
||||
scrollViewRef.current?.scrollToEnd({ animated: true });
|
||||
}, 100);
|
||||
});
|
||||
|
||||
const keyboardDidHideListener = Keyboard.addListener('keyboardDidHide', () => {
|
||||
// 键盘隐藏时,可以进行必要的调整
|
||||
});
|
||||
|
||||
return () => {
|
||||
keyboardDidShowListener?.remove();
|
||||
keyboardDidHideListener?.remove();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!selectedMood) {
|
||||
Alert.alert('提示', '请选择心情');
|
||||
@@ -163,7 +188,18 @@ export default function MoodEditScreen() {
|
||||
tone="light"
|
||||
/>
|
||||
|
||||
<ScrollView style={styles.content}>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
style={styles.keyboardAvoidingView}
|
||||
keyboardVerticalOffset={Platform.OS === 'ios' ? 0 : 0}
|
||||
>
|
||||
<ScrollView
|
||||
ref={scrollViewRef}
|
||||
style={styles.content}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{/* 日期显示 */}
|
||||
<View style={styles.dateSection}>
|
||||
<Text style={styles.dateTitle}>
|
||||
@@ -211,6 +247,7 @@ export default function MoodEditScreen() {
|
||||
<Text style={styles.sectionTitle}>心情日记</Text>
|
||||
<Text style={styles.diarySubtitle}>记录你的心情,珍藏美好回忆</Text>
|
||||
<TextInput
|
||||
ref={textInputRef}
|
||||
style={styles.descriptionInput}
|
||||
placeholder={`今天的心情如何?
|
||||
|
||||
@@ -225,11 +262,18 @@ export default function MoodEditScreen() {
|
||||
multiline
|
||||
maxLength={1000}
|
||||
textAlignVertical="top"
|
||||
onFocus={() => {
|
||||
// 当文本输入框获得焦点时,滚动到输入框
|
||||
setTimeout(() => {
|
||||
scrollViewRef.current?.scrollToEnd({ animated: true });
|
||||
}, 300);
|
||||
}}
|
||||
/>
|
||||
<Text style={styles.characterCount}>{description.length}/1000</Text>
|
||||
</View>
|
||||
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
|
||||
{/* 底部按钮 */}
|
||||
<View style={styles.footer}>
|
||||
@@ -294,10 +338,15 @@ const styles = StyleSheet.create({
|
||||
safeArea: {
|
||||
flex: 1,
|
||||
},
|
||||
|
||||
keyboardAvoidingView: {
|
||||
flex: 1,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingBottom: 100, // 为底部按钮留出空间
|
||||
},
|
||||
dateSection: {
|
||||
backgroundColor: 'rgba(255,255,255,0.95)',
|
||||
margin: 12,
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
selectNutritionSummaryByDate
|
||||
} from '@/store/nutritionSlice';
|
||||
import { getMonthDaysZh, getMonthTitleZh, getTodayIndexInMonth } from '@/utils/date';
|
||||
import { fetchBasalEnergyBurned } from '@/utils/health';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
import dayjs from 'dayjs';
|
||||
@@ -73,6 +74,9 @@ export default function NutritionRecordsScreen() {
|
||||
const [hasMoreData, setHasMoreData] = useState(true);
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
// 基础代谢数据状态
|
||||
const [basalMetabolism, setBasalMetabolism] = useState<number>(1482);
|
||||
|
||||
// 食物添加弹窗状态
|
||||
const [showFoodOverlay, setShowFoodOverlay] = useState(false);
|
||||
|
||||
@@ -118,6 +122,7 @@ export default function NutritionRecordsScreen() {
|
||||
|
||||
// 当选中日期或视图模式变化时重新加载数据
|
||||
useEffect(() => {
|
||||
fetchBasalMetabolismData();
|
||||
if (viewMode === 'daily') {
|
||||
dispatch(fetchDailyNutritionData(currentSelectedDate));
|
||||
} else {
|
||||
@@ -150,6 +155,22 @@ export default function NutritionRecordsScreen() {
|
||||
}
|
||||
}, [viewMode, currentSelectedDateString, dispatch]);
|
||||
|
||||
// 获取基础代谢数据
|
||||
const fetchBasalMetabolismData = useCallback(async () => {
|
||||
try {
|
||||
const options = {
|
||||
startDate: dayjs(currentSelectedDate).startOf('day').toDate().toISOString(),
|
||||
endDate: dayjs(currentSelectedDate).endOf('day').toDate().toISOString()
|
||||
};
|
||||
|
||||
const basalEnergy = await fetchBasalEnergyBurned(options);
|
||||
setBasalMetabolism(basalEnergy || 1482);
|
||||
} catch (error) {
|
||||
console.error('获取基础代谢数据失败:', error);
|
||||
setBasalMetabolism(1482); // 失败时使用默认值
|
||||
}
|
||||
}, [currentSelectedDate]);
|
||||
|
||||
const onRefresh = useCallback(async () => {
|
||||
try {
|
||||
setRefreshing(true);
|
||||
@@ -300,42 +321,6 @@ export default function NutritionRecordsScreen() {
|
||||
});
|
||||
};
|
||||
|
||||
// 渲染视图模式切换器
|
||||
const renderViewModeToggle = () => (
|
||||
<View style={[styles.viewModeContainer, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}>
|
||||
<Text style={[styles.monthTitle, { color: colorTokens.text }]}>{monthTitle}</Text>
|
||||
<View style={[styles.toggleContainer, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.toggleButton,
|
||||
viewMode === 'daily' && { backgroundColor: colorTokens.primary }
|
||||
]}
|
||||
onPress={() => setViewMode('daily')}
|
||||
>
|
||||
<Text style={[
|
||||
styles.toggleText,
|
||||
{ color: viewMode === 'daily' ? colorTokens.onPrimary : colorTokens.textSecondary }
|
||||
]}>
|
||||
按天查看
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.toggleButton,
|
||||
viewMode === 'all' && { backgroundColor: colorTokens.primary }
|
||||
]}
|
||||
onPress={() => setViewMode('all')}
|
||||
>
|
||||
<Text style={[
|
||||
styles.toggleText,
|
||||
{ color: viewMode === 'all' ? colorTokens.onPrimary : colorTokens.textSecondary }
|
||||
]}>
|
||||
全部记录
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
// 渲染日期选择器(仅在按天查看模式下显示)
|
||||
const renderDateSelector = () => {
|
||||
@@ -445,7 +430,7 @@ export default function NutritionRecordsScreen() {
|
||||
|
||||
{/* Calorie Ring Chart */}
|
||||
<CalorieRingChart
|
||||
metabolism={healthData?.basalEnergyBurned || 1482}
|
||||
metabolism={basalMetabolism}
|
||||
exercise={healthData?.activeEnergyBurned || 0}
|
||||
consumed={nutritionSummary?.totalCalories || 0}
|
||||
protein={nutritionSummary?.totalProtein || 0}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { useWaterDataByDate } from '@/hooks/useWaterData';
|
||||
import { getQuickWaterAmount, setQuickWaterAmount } from '@/utils/userPreferences';
|
||||
import { WaterNotificationHelpers } from '@/utils/notificationHelpers';
|
||||
import { getQuickWaterAmount, getWaterReminderSettings, setWaterReminderSettings as saveWaterReminderSettings, setQuickWaterAmount } from '@/utils/userPreferences';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { Picker } from '@react-native-picker/picker';
|
||||
import { Image } from 'expo-image';
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Switch,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View
|
||||
@@ -26,85 +27,32 @@ import { Swipeable } from 'react-native-gesture-handler';
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
interface WaterSettingsProps {
|
||||
interface WaterDetailProps {
|
||||
selectedDate?: string;
|
||||
}
|
||||
|
||||
const WaterSettings: React.FC<WaterSettingsProps> = () => {
|
||||
const WaterDetail: React.FC<WaterDetailProps> = () => {
|
||||
const { selectedDate } = useLocalSearchParams<{ selectedDate?: string }>();
|
||||
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||
const colorTokens = Colors[theme];
|
||||
const { ensureLoggedIn } = useAuthGuard();
|
||||
|
||||
const [dailyGoal, setDailyGoal] = useState<string>('2000');
|
||||
const [quickAddAmount, setQuickAddAmount] = useState<string>('250');
|
||||
|
||||
// 编辑弹窗状态
|
||||
const [goalModalVisible, setGoalModalVisible] = useState(false);
|
||||
const [quickAddModalVisible, setQuickAddModalVisible] = useState(false);
|
||||
|
||||
// 临时选中值
|
||||
const [tempGoal, setTempGoal] = useState<number>(parseInt(dailyGoal));
|
||||
const [tempQuickAdd, setTempQuickAdd] = useState<number>(parseInt(quickAddAmount));
|
||||
// Remove modal states as they are now in separate settings page
|
||||
|
||||
// 使用新的 hook 来处理指定日期的饮水数据
|
||||
const { waterRecords, dailyWaterGoal, updateWaterGoal, removeWaterRecord } = useWaterDataByDate(selectedDate);
|
||||
|
||||
// 检查登录状态
|
||||
useEffect(() => {
|
||||
const checkLoginStatus = async () => {
|
||||
const isLoggedIn = await ensureLoggedIn();
|
||||
if (!isLoggedIn) {
|
||||
// 如果未登录,用户会被重定向到登录页面
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 处理设置按钮点击 - 跳转到设置页面
|
||||
const handleSettingsPress = () => {
|
||||
router.push('/water/settings');
|
||||
};
|
||||
|
||||
checkLoginStatus();
|
||||
}, [ensureLoggedIn]);
|
||||
|
||||
const goalPresets = [1500, 2000, 2500, 3000, 3500, 4000];
|
||||
const quickAddPresets = [100, 150, 200, 250, 300, 350, 400, 500];
|
||||
|
||||
|
||||
// 打开饮水目标弹窗时初始化临时值
|
||||
const openGoalModal = () => {
|
||||
setTempGoal(parseInt(dailyGoal));
|
||||
setGoalModalVisible(true);
|
||||
};
|
||||
|
||||
// 打开快速添加弹窗时初始化临时值
|
||||
const openQuickAddModal = () => {
|
||||
setTempQuickAdd(parseInt(quickAddAmount));
|
||||
setQuickAddModalVisible(true);
|
||||
};
|
||||
|
||||
// 处理饮水目标确认
|
||||
const handleGoalConfirm = async () => {
|
||||
setDailyGoal(tempGoal.toString());
|
||||
setGoalModalVisible(false);
|
||||
|
||||
try {
|
||||
const success = await updateWaterGoal(tempGoal);
|
||||
if (!success) {
|
||||
Alert.alert('设置失败', '无法保存饮水目标,请重试');
|
||||
}
|
||||
} catch {
|
||||
Alert.alert('设置失败', '无法保存饮水目标,请重试');
|
||||
}
|
||||
};
|
||||
|
||||
// 处理快速添加默认值确认
|
||||
const handleQuickAddConfirm = async () => {
|
||||
setQuickAddAmount(tempQuickAdd.toString());
|
||||
setQuickAddModalVisible(false);
|
||||
|
||||
try {
|
||||
await setQuickWaterAmount(tempQuickAdd);
|
||||
} catch {
|
||||
Alert.alert('设置失败', '无法保存快速添加默认值,请重试');
|
||||
}
|
||||
};
|
||||
// Remove all modal-related functions as they are now in separate settings page
|
||||
|
||||
|
||||
// 删除饮水记录
|
||||
@@ -131,15 +79,6 @@ const WaterSettings: React.FC<WaterSettingsProps> = () => {
|
||||
loadUserPreferences();
|
||||
}, [dailyWaterGoal]);
|
||||
|
||||
// 当dailyGoal或quickAddAmount更新时,同步更新临时状态
|
||||
useEffect(() => {
|
||||
setTempGoal(parseInt(dailyGoal));
|
||||
}, [dailyGoal]);
|
||||
|
||||
useEffect(() => {
|
||||
setTempQuickAdd(parseInt(quickAddAmount));
|
||||
}, [quickAddAmount]);
|
||||
|
||||
// 新增:饮水记录卡片组件
|
||||
const WaterRecordCard = ({ record, onDelete }: { record: any; onDelete: () => void }) => {
|
||||
const swipeableRef = React.useRef<Swipeable>(null);
|
||||
@@ -233,11 +172,20 @@ const WaterSettings: React.FC<WaterSettingsProps> = () => {
|
||||
<View style={styles.decorativeCircle2} />
|
||||
|
||||
<HeaderBar
|
||||
title="饮水设置"
|
||||
title="饮水详情"
|
||||
onBack={() => {
|
||||
// 这里会通过路由自动处理返回
|
||||
router.back();
|
||||
}}
|
||||
right={
|
||||
<TouchableOpacity
|
||||
style={styles.settingsButton}
|
||||
onPress={handleSettingsPress}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Ionicons name="settings-outline" size={24} color={colorTokens.text} />
|
||||
</TouchableOpacity>
|
||||
}
|
||||
/>
|
||||
|
||||
<KeyboardAvoidingView
|
||||
@@ -249,44 +197,6 @@ const WaterSettings: React.FC<WaterSettingsProps> = () => {
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{/* 第一部分:饮水配置 */}
|
||||
<View style={styles.section}>
|
||||
<Text style={[styles.sectionTitle, { color: colorTokens.text }]}>饮水配置</Text>
|
||||
|
||||
{/* 设置目标部分 */}
|
||||
<TouchableOpacity
|
||||
style={[styles.settingRow, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}
|
||||
onPress={openGoalModal}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<View style={styles.settingLeft}>
|
||||
<Text style={[styles.settingTitle, { color: colorTokens.text }]}>每日饮水目标</Text>
|
||||
<Text style={[styles.settingValue, { color: colorTokens.textSecondary }]}>{dailyGoal}ml</Text>
|
||||
</View>
|
||||
<View style={styles.settingRight}>
|
||||
<Ionicons name="chevron-forward" size={16} color={colorTokens.icon} />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* 快速添加默认值设置部分 */}
|
||||
<TouchableOpacity
|
||||
style={[styles.settingRow, { backgroundColor: colorTokens.pageBackgroundEmphasis, marginTop: 24 }]}
|
||||
onPress={openQuickAddModal}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<View style={styles.settingLeft}>
|
||||
<Text style={[styles.settingTitle, { color: colorTokens.text }]}>快速添加默认值</Text>
|
||||
<Text style={[styles.settingSubtitle, { color: colorTokens.textSecondary }]}>
|
||||
{`设置点击右上角"+"按钮时添加的默认饮水量`}
|
||||
</Text>
|
||||
<Text style={[styles.settingValue, { color: colorTokens.textSecondary }]}>{quickAddAmount}ml</Text>
|
||||
</View>
|
||||
<View style={styles.settingRight}>
|
||||
<Ionicons name="chevron-forward" size={16} color={colorTokens.icon} />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
</View>
|
||||
|
||||
{/* 第二部分:饮水记录 */}
|
||||
<View style={styles.section}>
|
||||
@@ -325,83 +235,7 @@ const WaterSettings: React.FC<WaterSettingsProps> = () => {
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
|
||||
{/* 饮水目标编辑弹窗 */}
|
||||
<Modal
|
||||
visible={goalModalVisible}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={() => setGoalModalVisible(false)}
|
||||
>
|
||||
<Pressable style={styles.modalBackdrop} onPress={() => setGoalModalVisible(false)} />
|
||||
<View style={styles.modalSheet}>
|
||||
<View style={styles.modalHandle} />
|
||||
<Text style={[styles.modalTitle, { color: colorTokens.text }]}>每日饮水目标</Text>
|
||||
<View style={styles.pickerContainer}>
|
||||
<Picker
|
||||
selectedValue={tempGoal}
|
||||
onValueChange={(value) => setTempGoal(value)}
|
||||
style={styles.picker}
|
||||
>
|
||||
{Array.from({ length: 96 }, (_, i) => 500 + i * 100).map(goal => (
|
||||
<Picker.Item key={goal} label={`${goal}ml`} value={goal} />
|
||||
))}
|
||||
</Picker>
|
||||
</View>
|
||||
<View style={styles.modalActions}>
|
||||
<Pressable
|
||||
onPress={() => setGoalModalVisible(false)}
|
||||
style={[styles.modalBtn, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}
|
||||
>
|
||||
<Text style={[styles.modalBtnText, { color: colorTokens.text }]}>取消</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={handleGoalConfirm}
|
||||
style={[styles.modalBtn, styles.modalBtnPrimary, { backgroundColor: colorTokens.primary }]}
|
||||
>
|
||||
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary, { color: colorTokens.onPrimary }]}>确定</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
|
||||
{/* 快速添加默认值编辑弹窗 */}
|
||||
<Modal
|
||||
visible={quickAddModalVisible}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={() => setQuickAddModalVisible(false)}
|
||||
>
|
||||
<Pressable style={styles.modalBackdrop} onPress={() => setQuickAddModalVisible(false)} />
|
||||
<View style={styles.modalSheet}>
|
||||
<View style={styles.modalHandle} />
|
||||
<Text style={[styles.modalTitle, { color: colorTokens.text }]}>快速添加默认值</Text>
|
||||
<View style={styles.pickerContainer}>
|
||||
<Picker
|
||||
selectedValue={tempQuickAdd}
|
||||
onValueChange={(value) => setTempQuickAdd(value)}
|
||||
style={styles.picker}
|
||||
>
|
||||
{Array.from({ length: 41 }, (_, i) => 50 + i * 10).map(amount => (
|
||||
<Picker.Item key={amount} label={`${amount}ml`} value={amount} />
|
||||
))}
|
||||
</Picker>
|
||||
</View>
|
||||
<View style={styles.modalActions}>
|
||||
<Pressable
|
||||
onPress={() => setQuickAddModalVisible(false)}
|
||||
style={[styles.modalBtn, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}
|
||||
>
|
||||
<Text style={[styles.modalBtnText, { color: colorTokens.text }]}>取消</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={handleQuickAddConfirm}
|
||||
style={[styles.modalBtn, styles.modalBtnPrimary, { backgroundColor: colorTokens.primary }]}
|
||||
>
|
||||
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary, { color: colorTokens.onPrimary }]}>确定</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
{/* All modals have been moved to the separate water-settings page */}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -466,79 +300,6 @@ const styles = StyleSheet.create({
|
||||
fontWeight: '400',
|
||||
lineHeight: 18,
|
||||
},
|
||||
input: {
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 14,
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
marginBottom: 16,
|
||||
},
|
||||
settingRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingVertical: 16,
|
||||
paddingHorizontal: 16,
|
||||
borderRadius: 12,
|
||||
marginBottom: 16,
|
||||
},
|
||||
settingLeft: {
|
||||
flex: 1,
|
||||
},
|
||||
settingTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
marginBottom: 4,
|
||||
},
|
||||
settingSubtitle: {
|
||||
fontSize: 14,
|
||||
marginBottom: 8,
|
||||
},
|
||||
settingValue: {
|
||||
fontSize: 16,
|
||||
},
|
||||
settingRight: {
|
||||
marginLeft: 12,
|
||||
},
|
||||
quickAmountsContainer: {
|
||||
marginBottom: 15,
|
||||
},
|
||||
quickAmountsWrapper: {
|
||||
flexDirection: 'row',
|
||||
gap: 10,
|
||||
paddingRight: 10,
|
||||
},
|
||||
quickAmountButton: {
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 20,
|
||||
minWidth: 70,
|
||||
alignItems: 'center',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 2,
|
||||
elevation: 1,
|
||||
},
|
||||
quickAmountText: {
|
||||
fontSize: 15,
|
||||
fontWeight: '500',
|
||||
},
|
||||
saveButton: {
|
||||
paddingVertical: 14,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
marginTop: 24,
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 4,
|
||||
elevation: 3,
|
||||
},
|
||||
saveButtonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
},
|
||||
// 饮水记录相关样式
|
||||
recordsList: {
|
||||
gap: 12,
|
||||
@@ -714,6 +475,225 @@ const styles = StyleSheet.create({
|
||||
modalBtnTextPrimary: {
|
||||
// color will be set dynamically
|
||||
},
|
||||
settingsButton: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
settingsModalSheet: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
padding: 16,
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderTopLeftRadius: 16,
|
||||
borderTopRightRadius: 16,
|
||||
shadowColor: '#000000',
|
||||
shadowOffset: { width: 0, height: -2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 8,
|
||||
elevation: 16,
|
||||
},
|
||||
settingsModalTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
textAlign: 'center',
|
||||
marginBottom: 20,
|
||||
},
|
||||
settingsMenuContainer: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: 12,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 4,
|
||||
elevation: 2,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
settingsMenuItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingVertical: 14,
|
||||
paddingHorizontal: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#F1F3F4',
|
||||
},
|
||||
settingsMenuItemLeft: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
},
|
||||
settingsIconContainer: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 6,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: 12,
|
||||
},
|
||||
settingsMenuItemContent: {
|
||||
flex: 1,
|
||||
},
|
||||
settingsMenuItemTitle: {
|
||||
fontSize: 15,
|
||||
fontWeight: '500',
|
||||
marginBottom: 2,
|
||||
},
|
||||
settingsMenuItemSubtitle: {
|
||||
fontSize: 12,
|
||||
marginBottom: 4,
|
||||
},
|
||||
settingsMenuItemValue: {
|
||||
fontSize: 14,
|
||||
},
|
||||
// 喝水提醒配置弹窗样式
|
||||
waterReminderModalSheet: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
padding: 16,
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderTopLeftRadius: 16,
|
||||
borderTopRightRadius: 16,
|
||||
maxHeight: '80%',
|
||||
shadowColor: '#000000',
|
||||
shadowOffset: { width: 0, height: -2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 8,
|
||||
elevation: 16,
|
||||
},
|
||||
waterReminderContent: {
|
||||
flex: 1,
|
||||
marginBottom: 20,
|
||||
},
|
||||
waterReminderSection: {
|
||||
marginBottom: 24,
|
||||
},
|
||||
waterReminderSectionHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 8,
|
||||
},
|
||||
waterReminderSectionTitleContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
waterReminderSectionTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
waterReminderSectionDesc: {
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
marginTop: 4,
|
||||
},
|
||||
timeRangeContainer: {
|
||||
flexDirection: 'row',
|
||||
gap: 16,
|
||||
marginTop: 16,
|
||||
},
|
||||
timePickerContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
timeLabel: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
marginBottom: 8,
|
||||
},
|
||||
timePicker: {
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 16,
|
||||
borderRadius: 8,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
timePickerText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
},
|
||||
timePickerIcon: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
intervalContainer: {
|
||||
marginTop: 16,
|
||||
},
|
||||
intervalPickerContainer: {
|
||||
backgroundColor: '#F8F9FA',
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
intervalPicker: {
|
||||
height: 120,
|
||||
},
|
||||
// 时间选择器弹窗样式
|
||||
timePickerModalSheet: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
padding: 16,
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderTopLeftRadius: 16,
|
||||
borderTopRightRadius: 16,
|
||||
maxHeight: '60%',
|
||||
shadowColor: '#000000',
|
||||
shadowOffset: { width: 0, height: -2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 8,
|
||||
elevation: 16,
|
||||
},
|
||||
timePickerContent: {
|
||||
flex: 1,
|
||||
marginBottom: 20,
|
||||
},
|
||||
timePickerSection: {
|
||||
marginBottom: 20,
|
||||
},
|
||||
timePickerLabel: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
marginBottom: 12,
|
||||
textAlign: 'center',
|
||||
},
|
||||
hourPickerContainer: {
|
||||
backgroundColor: '#F8F9FA',
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
hourPicker: {
|
||||
height: 160,
|
||||
},
|
||||
timeRangePreview: {
|
||||
backgroundColor: '#F0F8FF',
|
||||
borderRadius: 8,
|
||||
padding: 16,
|
||||
marginTop: 16,
|
||||
alignItems: 'center',
|
||||
},
|
||||
timeRangePreviewLabel: {
|
||||
fontSize: 12,
|
||||
fontWeight: '500',
|
||||
marginBottom: 4,
|
||||
},
|
||||
timeRangePreviewText: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
marginBottom: 8,
|
||||
},
|
||||
timeRangeWarning: {
|
||||
fontSize: 12,
|
||||
color: '#FF6B6B',
|
||||
textAlign: 'center',
|
||||
lineHeight: 18,
|
||||
},
|
||||
});
|
||||
|
||||
export default WaterSettings;
|
||||
export default WaterDetail;
|
||||
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,38 +1,161 @@
|
||||
import { AnimatedNumber } from '@/components/AnimatedNumber';
|
||||
import { ROUTES } from '@/constants/Routes';
|
||||
import { useAppSelector } from '@/hooks/redux';
|
||||
import { selectUserAge, selectUserProfile } from '@/store/userSlice';
|
||||
import { fetchBasalEnergyBurned } from '@/utils/health';
|
||||
import dayjs from 'dayjs';
|
||||
import { Image } from 'expo-image';
|
||||
import React from 'react';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
import { router } from 'expo-router';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
|
||||
interface BasalMetabolismCardProps {
|
||||
value: number | null;
|
||||
resetToken?: number;
|
||||
selectedDate?: Date;
|
||||
style?: any;
|
||||
}
|
||||
|
||||
export function BasalMetabolismCard({ value, resetToken, style }: BasalMetabolismCardProps) {
|
||||
// 获取基础代谢状态描述
|
||||
const getMetabolismStatus = () => {
|
||||
if (value === null || value === 0) {
|
||||
export function BasalMetabolismCard({ selectedDate, style }: BasalMetabolismCardProps) {
|
||||
const [basalMetabolism, setBasalMetabolism] = useState<number | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// 获取用户基本信息
|
||||
const userProfile = useAppSelector(selectUserProfile);
|
||||
const userAge = useAppSelector(selectUserAge);
|
||||
|
||||
// 缓存和防抖相关
|
||||
const cacheRef = useRef<Map<string, { data: number | null; timestamp: number }>>(new Map());
|
||||
const loadingRef = useRef<Map<string, Promise<number | null>>>(new Map());
|
||||
const CACHE_DURATION = 5 * 60 * 1000; // 5分钟缓存
|
||||
|
||||
// 使用 useMemo 缓存 BMR 计算,避免每次渲染重复计算
|
||||
const bmrRange = useMemo(() => {
|
||||
const { gender, weight, height } = userProfile;
|
||||
|
||||
// 检查是否有足够的信息来计算BMR
|
||||
if (!gender || !weight || !height || !userAge) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 将体重和身高转换为数字
|
||||
const weightNum = parseFloat(weight);
|
||||
const heightNum = parseFloat(height);
|
||||
|
||||
if (isNaN(weightNum) || isNaN(heightNum) || weightNum <= 0 || heightNum <= 0 || userAge <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 使用Mifflin-St Jeor公式计算BMR
|
||||
let bmr: number;
|
||||
if (gender === 'male') {
|
||||
bmr = 10 * weightNum + 6.25 * heightNum - 5 * userAge + 5;
|
||||
} else {
|
||||
bmr = 10 * weightNum + 6.25 * heightNum - 5 * userAge - 161;
|
||||
}
|
||||
|
||||
// 计算正常范围(±15%)
|
||||
const minBMR = Math.round(bmr * 0.85);
|
||||
const maxBMR = Math.round(bmr * 1.15);
|
||||
|
||||
return { min: minBMR, max: maxBMR, base: Math.round(bmr) };
|
||||
}, [userProfile.gender, userProfile.weight, userProfile.height, userAge]);
|
||||
|
||||
// 优化的数据获取函数,包含缓存和去重复请求
|
||||
const fetchBasalMetabolismData = useCallback(async (date: Date): Promise<number | null> => {
|
||||
const dateKey = dayjs(date).format('YYYY-MM-DD');
|
||||
const now = Date.now();
|
||||
|
||||
// 检查缓存
|
||||
const cached = cacheRef.current.get(dateKey);
|
||||
if (cached && (now - cached.timestamp) < CACHE_DURATION) {
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
// 检查是否已经在请求中(防止重复请求)
|
||||
const existingRequest = loadingRef.current.get(dateKey);
|
||||
if (existingRequest) {
|
||||
return existingRequest;
|
||||
}
|
||||
|
||||
// 创建新的请求
|
||||
const request = (async () => {
|
||||
try {
|
||||
const options = {
|
||||
startDate: dayjs(date).startOf('day').toDate().toISOString(),
|
||||
endDate: dayjs(date).endOf('day').toDate().toISOString()
|
||||
};
|
||||
const basalEnergy = await fetchBasalEnergyBurned(options);
|
||||
const result = basalEnergy || null;
|
||||
|
||||
// 更新缓存
|
||||
cacheRef.current.set(dateKey, { data: result, timestamp: now });
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('BasalMetabolismCard: 获取基础代谢数据失败:', error);
|
||||
return null;
|
||||
} finally {
|
||||
// 清理请求记录
|
||||
loadingRef.current.delete(dateKey);
|
||||
}
|
||||
})();
|
||||
|
||||
// 记录请求
|
||||
loadingRef.current.set(dateKey, request);
|
||||
|
||||
return request;
|
||||
}, []);
|
||||
|
||||
// 获取基础代谢数据
|
||||
useEffect(() => {
|
||||
if (!selectedDate) return;
|
||||
|
||||
let isCancelled = false;
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await fetchBasalMetabolismData(selectedDate);
|
||||
if (!isCancelled) {
|
||||
setBasalMetabolism(result);
|
||||
}
|
||||
} finally {
|
||||
if (!isCancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadData();
|
||||
|
||||
// 清理函数,防止组件卸载后的状态更新
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
};
|
||||
}, [selectedDate, fetchBasalMetabolismData]);
|
||||
// 使用 useMemo 优化状态描述计算
|
||||
const status = useMemo(() => {
|
||||
if (basalMetabolism === null || basalMetabolism === 0) {
|
||||
return { text: '未知', color: '#9AA3AE' };
|
||||
}
|
||||
|
||||
// 基于常见的基础代谢范围来判断状态
|
||||
if (value >= 1800) {
|
||||
if (basalMetabolism >= 1800) {
|
||||
return { text: '高代谢', color: '#10B981' };
|
||||
} else if (value >= 1400) {
|
||||
} else if (basalMetabolism >= 1400) {
|
||||
return { text: '正常', color: '#3B82F6' };
|
||||
} else if (value >= 1000) {
|
||||
} else if (basalMetabolism >= 1000) {
|
||||
return { text: '偏低', color: '#F59E0B' };
|
||||
} else {
|
||||
return { text: '较低', color: '#EF4444' };
|
||||
}
|
||||
};
|
||||
|
||||
const status = getMetabolismStatus();
|
||||
}, [basalMetabolism]);
|
||||
|
||||
return (
|
||||
<View style={[styles.container, style]}>
|
||||
|
||||
<>
|
||||
<TouchableOpacity
|
||||
style={[styles.container, style]}
|
||||
onPress={() => router.push(ROUTES.BASAL_METABOLISM_DETAIL)}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
{/* 头部区域 */}
|
||||
<View style={styles.header}>
|
||||
<View style={styles.leftSection}>
|
||||
@@ -49,19 +172,13 @@ export function BasalMetabolismCard({ value, resetToken, style }: BasalMetabolis
|
||||
|
||||
{/* 数值显示区域 */}
|
||||
<View style={styles.valueSection}>
|
||||
{value != null && value > 0 ? (
|
||||
<AnimatedNumber
|
||||
value={value}
|
||||
resetToken={resetToken}
|
||||
style={styles.value}
|
||||
format={(v) => Math.round(v).toString()}
|
||||
/>
|
||||
) : (
|
||||
<Text style={styles.value}>--</Text>
|
||||
)}
|
||||
<Text style={styles.value}>
|
||||
{loading ? '加载中...' : (basalMetabolism != null && basalMetabolism > 0 ? Math.round(basalMetabolism).toString() : '--')}
|
||||
</Text>
|
||||
<Text style={styles.unit}>千卡/日</Text>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,20 +1,14 @@
|
||||
import React from 'react';
|
||||
import React, { useState, useCallback, useRef } from 'react';
|
||||
import { StyleSheet, Text, View, TouchableOpacity } from 'react-native';
|
||||
import { router } from 'expo-router';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
import { CircularRing } from './CircularRing';
|
||||
import { ROUTES } from '@/constants/Routes';
|
||||
import { fetchActivityRingsForDate, ActivityRingsData } from '@/utils/health';
|
||||
|
||||
type FitnessRingsCardProps = {
|
||||
style?: any;
|
||||
// 活动卡路里数据
|
||||
activeCalories?: number;
|
||||
activeCaloriesGoal?: number;
|
||||
// 锻炼分钟数据
|
||||
exerciseMinutes?: number;
|
||||
exerciseMinutesGoal?: number;
|
||||
// 站立小时数据
|
||||
standHours?: number;
|
||||
standHoursGoal?: number;
|
||||
selectedDate?: Date;
|
||||
// 动画重置令牌
|
||||
resetToken?: unknown;
|
||||
};
|
||||
@@ -24,14 +18,48 @@ type FitnessRingsCardProps = {
|
||||
*/
|
||||
export function FitnessRingsCard({
|
||||
style,
|
||||
activeCalories = 25,
|
||||
activeCaloriesGoal = 350,
|
||||
exerciseMinutes = 1,
|
||||
exerciseMinutesGoal = 5,
|
||||
standHours = 2,
|
||||
standHoursGoal = 13,
|
||||
selectedDate,
|
||||
resetToken,
|
||||
}: FitnessRingsCardProps) {
|
||||
const [activityData, setActivityData] = useState<ActivityRingsData | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const loadingRef = useRef(false);
|
||||
|
||||
// 获取健身圆环数据 - 在页面聚焦、日期变化、从后台切换到前台时触发
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
const loadActivityData = async () => {
|
||||
if (!selectedDate) return;
|
||||
|
||||
// 防止重复请求
|
||||
if (loadingRef.current) return;
|
||||
|
||||
try {
|
||||
loadingRef.current = true;
|
||||
setLoading(true);
|
||||
const data = await fetchActivityRingsForDate(selectedDate);
|
||||
setActivityData(data);
|
||||
} catch (error) {
|
||||
console.error('FitnessRingsCard: 获取健身圆环数据失败:', error);
|
||||
setActivityData(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
loadingRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
loadActivityData();
|
||||
}, [selectedDate])
|
||||
);
|
||||
|
||||
// 使用获取到的数据或默认值
|
||||
const activeCalories = activityData?.activeEnergyBurned ?? 0;
|
||||
const activeCaloriesGoal = activityData?.activeEnergyBurnedGoal ?? 350;
|
||||
const exerciseMinutes = activityData?.appleExerciseTime ?? 0;
|
||||
const exerciseMinutesGoal = activityData?.appleExerciseTimeGoal ?? 30;
|
||||
const standHours = activityData?.appleStandHours ?? 0;
|
||||
const standHoursGoal = activityData?.appleStandHoursGoal ?? 12;
|
||||
|
||||
// 计算进度百分比
|
||||
const caloriesProgress = Math.min(1, Math.max(0, activeCalories / activeCaloriesGoal));
|
||||
const exerciseProgress = Math.min(1, Math.max(0, exerciseMinutes / exerciseMinutesGoal));
|
||||
@@ -95,24 +123,42 @@ export function FitnessRingsCard({
|
||||
<View style={styles.dataContainer}>
|
||||
<View style={styles.dataRow}>
|
||||
<Text style={styles.dataText}>
|
||||
{loading ? (
|
||||
<Text style={styles.dataValue}>--</Text>
|
||||
) : (
|
||||
<>
|
||||
<Text style={styles.dataValue}>{Math.round(activeCalories)}</Text>
|
||||
<Text style={styles.dataGoal}>/{activeCaloriesGoal}</Text>
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
<Text style={styles.dataUnit}>千卡</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.dataRow}>
|
||||
<Text style={styles.dataText}>
|
||||
{loading ? (
|
||||
<Text style={styles.dataValue}>--</Text>
|
||||
) : (
|
||||
<>
|
||||
<Text style={styles.dataValue}>{Math.round(exerciseMinutes)}</Text>
|
||||
<Text style={styles.dataGoal}>/{exerciseMinutesGoal}</Text>
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
<Text style={styles.dataUnit}>分钟</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.dataRow}>
|
||||
<Text style={styles.dataText}>
|
||||
{loading ? (
|
||||
<Text style={styles.dataValue}>--</Text>
|
||||
) : (
|
||||
<>
|
||||
<Text style={styles.dataValue}>{Math.round(standHours)}</Text>
|
||||
<Text style={styles.dataGoal}>/{standHoursGoal}</Text>
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
<Text style={styles.dataUnit}>小时</Text>
|
||||
</View>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ROUTES } from '@/constants/Routes';
|
||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { BlurView } from 'expo-blur';
|
||||
import { useRouter } from 'expo-router';
|
||||
@@ -20,19 +21,21 @@ interface FloatingFoodOverlayProps {
|
||||
export function FloatingFoodOverlay({ visible, onClose, mealType = 'dinner' }: FloatingFoodOverlayProps) {
|
||||
const router = useRouter();
|
||||
|
||||
const { pushIfAuthedElseLogin } = useAuthGuard()
|
||||
|
||||
const handleFoodLibrary = () => {
|
||||
onClose();
|
||||
router.push(`${ROUTES.FOOD_LIBRARY}?mealType=${mealType}`);
|
||||
pushIfAuthedElseLogin(`${ROUTES.FOOD_LIBRARY}?mealType=${mealType}`);
|
||||
};
|
||||
|
||||
const handlePhotoRecognition = () => {
|
||||
onClose();
|
||||
router.push(`/food/camera?mealType=${mealType}`);
|
||||
pushIfAuthedElseLogin(`/food/camera?mealType=${mealType}`);
|
||||
};
|
||||
|
||||
const handleVoiceRecord = () => {
|
||||
onClose();
|
||||
router.push(`${ROUTES.VOICE_RECORD}?mealType=${mealType}`);
|
||||
pushIfAuthedElseLogin(`${ROUTES.VOICE_RECORD}?mealType=${mealType}`);
|
||||
};
|
||||
|
||||
const menuItems = [
|
||||
|
||||
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,
|
||||
} from 'react-native';
|
||||
import {
|
||||
PanGestureHandler,
|
||||
PanGestureHandlerGestureEvent,
|
||||
Gesture,
|
||||
GestureDetector,
|
||||
} from 'react-native-gesture-handler';
|
||||
import Animated, {
|
||||
interpolate,
|
||||
interpolateColor,
|
||||
runOnJS,
|
||||
useAnimatedGestureHandler,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withSpring,
|
||||
@@ -54,18 +53,18 @@ export default function MoodIntensitySlider({
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
};
|
||||
|
||||
const gestureHandler = useAnimatedGestureHandler<
|
||||
PanGestureHandlerGestureEvent,
|
||||
{ startX: number; lastValue: number }
|
||||
>({
|
||||
onStart: (_, context) => {
|
||||
context.startX = translateX.value;
|
||||
context.lastValue = value;
|
||||
const startX = useSharedValue(0);
|
||||
const lastValue = useSharedValue(value);
|
||||
|
||||
const gestureHandler = Gesture.Pan()
|
||||
.onBegin(() => {
|
||||
startX.value = translateX.value;
|
||||
lastValue.value = value;
|
||||
isDragging.value = withSpring(1);
|
||||
runOnJS(triggerHaptics)();
|
||||
},
|
||||
onActive: (event, context) => {
|
||||
const newX = context.startX + event.translationX;
|
||||
})
|
||||
.onUpdate((event) => {
|
||||
const newX = startX.value + event.translationX;
|
||||
const clampedX = Math.max(0, Math.min(sliderWidth, newX));
|
||||
translateX.value = clampedX;
|
||||
|
||||
@@ -73,13 +72,13 @@ export default function MoodIntensitySlider({
|
||||
const currentValue = Math.round((clampedX / sliderWidth) * (max - min) + min);
|
||||
|
||||
// 当值改变时触发震动和回调
|
||||
if (currentValue !== context.lastValue) {
|
||||
context.lastValue = currentValue;
|
||||
if (currentValue !== lastValue.value) {
|
||||
lastValue.value = currentValue;
|
||||
runOnJS(triggerHaptics)();
|
||||
runOnJS(onValueChange)(currentValue);
|
||||
}
|
||||
},
|
||||
onEnd: () => {
|
||||
})
|
||||
.onEnd(() => {
|
||||
// 计算最终值并吸附到最近的步长
|
||||
const currentValue = Math.round((translateX.value / sliderWidth) * (max - min) + min);
|
||||
const snapPosition = ((currentValue - min) / (max - min)) * sliderWidth;
|
||||
@@ -88,7 +87,6 @@ export default function MoodIntensitySlider({
|
||||
isDragging.value = withSpring(0);
|
||||
runOnJS(triggerHaptics)();
|
||||
runOnJS(onValueChange)(currentValue);
|
||||
},
|
||||
});
|
||||
|
||||
const thumbStyle = useAnimatedStyle(() => {
|
||||
@@ -136,29 +134,6 @@ export default function MoodIntensitySlider({
|
||||
};
|
||||
});
|
||||
|
||||
// 动态颜色配置 - 根据进度变化颜色
|
||||
const getProgressColors = (progress: number) => {
|
||||
if (progress <= 0.25) {
|
||||
return ['#22c55e', '#84cc16'] as const; // 绿色到浅绿色
|
||||
} else if (progress <= 0.5) {
|
||||
return ['#84cc16', '#eab308'] as const; // 浅绿色到黄色
|
||||
} else if (progress <= 0.75) {
|
||||
return ['#eab308', '#f97316'] as const; // 黄色到橙色
|
||||
} else {
|
||||
return ['#f97316', '#ef4444'] as const; // 橙色到红色
|
||||
}
|
||||
};
|
||||
|
||||
const progressColorsStyle = useAnimatedStyle(() => {
|
||||
const progress = translateX.value / sliderWidth;
|
||||
return {
|
||||
backgroundColor: interpolateColor(
|
||||
progress,
|
||||
[0, 0.25, 0.5, 0.75, 1],
|
||||
['#22c55e', '#84cc16', '#eab308', '#f97316', '#ef4444']
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
@@ -173,18 +148,19 @@ export default function MoodIntensitySlider({
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* 进度条 - 动态颜色 */}
|
||||
<Animated.View style={[styles.progress, { height }, progressStyle, progressColorsStyle]}>
|
||||
{/* 进度条 - 动态渐变颜色 */}
|
||||
<Animated.View style={[styles.progress, { height }, progressStyle]}>
|
||||
<LinearGradient
|
||||
colors={getProgressColors(translateX.value / sliderWidth)}
|
||||
colors={['#22c55e', '#84cc16', '#eab308', '#f97316', '#ef4444']}
|
||||
locations={[0, 0.25, 0.5, 0.75, 1]}
|
||||
style={[styles.progressGradient, { height }]}
|
||||
start={{ x: 1, y: 0 }}
|
||||
end={{ x: 0, y: 0 }}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
/>
|
||||
</Animated.View>
|
||||
|
||||
{/* 可拖拽的thumb */}
|
||||
<PanGestureHandler onGestureEvent={gestureHandler}>
|
||||
<GestureDetector gesture={gestureHandler}>
|
||||
<Animated.View style={[styles.thumb, { width: thumbSize, height: thumbSize }, thumbStyle]}>
|
||||
{/* <LinearGradient
|
||||
colors={['#ffffff', '#f8fafc']}
|
||||
@@ -194,7 +170,7 @@ export default function MoodIntensitySlider({
|
||||
/> */}
|
||||
<Animated.View style={[styles.thumbInner, thumbInnerStyle]} />
|
||||
</Animated.View>
|
||||
</PanGestureHandler>
|
||||
</GestureDetector>
|
||||
</View>
|
||||
|
||||
{/* 标签 */}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { AnimatedNumber } from '@/components/AnimatedNumber';
|
||||
import { ROUTES } from '@/constants/Routes';
|
||||
import { NutritionSummary } from '@/services/dietRecords';
|
||||
import { useActiveCalories } from '@/hooks/useActiveCalories';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||
import { fetchDailyBasalMetabolism, fetchDailyNutritionData, selectBasalMetabolismByDate, selectNutritionSummaryByDate } from '@/store/nutritionSlice';
|
||||
import { triggerLightHaptic } from '@/utils/haptics';
|
||||
import { NutritionGoals, calculateRemainingCalories } from '@/utils/nutrition';
|
||||
import { calculateRemainingCalories } from '@/utils/nutrition';
|
||||
import dayjs from 'dayjs';
|
||||
import { router } from 'expo-router';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
@@ -13,20 +16,10 @@ const AnimatedCircle = Animated.createAnimatedComponent(Circle);
|
||||
|
||||
|
||||
export type NutritionRadarCardProps = {
|
||||
nutritionSummary: NutritionSummary | null;
|
||||
/** 营养目标 */
|
||||
nutritionGoals?: NutritionGoals;
|
||||
/** 基础代谢消耗的卡路里 */
|
||||
burnedCalories?: number;
|
||||
/** 基础代谢率 */
|
||||
basalMetabolism?: number;
|
||||
/** 运动消耗卡路里 */
|
||||
activeCalories?: number;
|
||||
|
||||
selectedDate?: Date;
|
||||
style?: object;
|
||||
/** 动画重置令牌 */
|
||||
resetToken?: number;
|
||||
/** 餐次点击回调 */
|
||||
onMealPress?: (mealType: 'breakfast' | 'lunch' | 'dinner' | 'snack') => void;
|
||||
};
|
||||
|
||||
// 简化的圆环进度组件
|
||||
@@ -96,16 +89,47 @@ const SimpleRingProgress = ({
|
||||
};
|
||||
|
||||
export function NutritionRadarCard({
|
||||
nutritionSummary,
|
||||
nutritionGoals,
|
||||
burnedCalories = 1618,
|
||||
basalMetabolism,
|
||||
activeCalories,
|
||||
|
||||
selectedDate,
|
||||
style,
|
||||
resetToken,
|
||||
onMealPress
|
||||
}: NutritionRadarCardProps) {
|
||||
const [currentMealType] = useState<'breakfast' | 'lunch' | 'dinner' | 'snack'>('breakfast');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const { pushIfAuthedElseLogin } = useAuthGuard();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const dateKey = useMemo(() => {
|
||||
return selectedDate ? dayjs(selectedDate).format('YYYY-MM-DD') : dayjs().format('YYYY-MM-DD');
|
||||
}, [selectedDate]);
|
||||
|
||||
// 使用专用的选择器获取营养数据和基础代谢
|
||||
const nutritionSummary = useAppSelector(selectNutritionSummaryByDate(dateKey));
|
||||
const basalMetabolism = useAppSelector(selectBasalMetabolismByDate(dateKey));
|
||||
|
||||
// 使用专用的hook获取运动消耗卡路里
|
||||
const { activeCalories: effectiveActiveCalories, loading: activeCaloriesLoading } = useActiveCalories(selectedDate);
|
||||
|
||||
// 获取营养数据和基础代谢数据
|
||||
useEffect(() => {
|
||||
const loadNutritionCardData = async () => {
|
||||
const targetDate = selectedDate || new Date();
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
await Promise.all([
|
||||
dispatch(fetchDailyNutritionData(targetDate)).unwrap(),
|
||||
dispatch(fetchDailyBasalMetabolism(targetDate)).unwrap(),
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error('NutritionRadarCard: 获取营养卡片数据失败:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadNutritionCardData();
|
||||
}, [selectedDate, dispatch]);
|
||||
|
||||
const nutritionStats = useMemo(() => {
|
||||
return [
|
||||
@@ -121,9 +145,8 @@ export function NutritionRadarCard({
|
||||
// 计算还能吃的卡路里
|
||||
const consumedCalories = nutritionSummary?.totalCalories || 0;
|
||||
|
||||
// 使用分离的代谢和运动数据,如果没有提供则从burnedCalories推算
|
||||
const effectiveBasalMetabolism = basalMetabolism ?? (burnedCalories * 0.7); // 假设70%是基础代谢
|
||||
const effectiveActiveCalories = activeCalories ?? (burnedCalories * 0.3); // 假设30%是运动消耗
|
||||
// 使用从HealthKit获取的数据,如果没有则使用默认值
|
||||
const effectiveBasalMetabolism = basalMetabolism || 0; // 基础代谢默认值
|
||||
|
||||
const remainingCalories = calculateRemainingCalories({
|
||||
basalMetabolism: effectiveBasalMetabolism,
|
||||
@@ -138,7 +161,7 @@ export function NutritionRadarCard({
|
||||
|
||||
|
||||
return (
|
||||
<TouchableOpacity style={styles.card} onPress={handleNavigateToRecords} activeOpacity={0.8}>
|
||||
<TouchableOpacity style={[styles.card, style]} onPress={handleNavigateToRecords} activeOpacity={0.8}>
|
||||
<View style={styles.cardHeader}>
|
||||
<View style={styles.titleContainer}>
|
||||
<Image
|
||||
@@ -147,14 +170,16 @@ export function NutritionRadarCard({
|
||||
/>
|
||||
<Text style={styles.cardTitle}>饮食分析</Text>
|
||||
</View>
|
||||
<Text style={styles.cardSubtitle}>更新: {dayjs(nutritionSummary?.updatedAt).format('MM-DD HH:mm')}</Text>
|
||||
<Text style={styles.cardSubtitle}>
|
||||
{loading ? '加载中...' : `更新: ${dayjs(nutritionSummary?.updatedAt).format('MM-DD HH:mm')}`}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.contentContainer}>
|
||||
<View style={styles.radarContainer}>
|
||||
<SimpleRingProgress
|
||||
remainingCalories={remainingCalories}
|
||||
totalAvailable={effectiveBasalMetabolism + effectiveActiveCalories}
|
||||
remainingCalories={(loading || activeCaloriesLoading) ? 0 : remainingCalories}
|
||||
totalAvailable={(loading || activeCaloriesLoading) ? 0 : effectiveBasalMetabolism + effectiveActiveCalories}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -177,10 +202,10 @@ export function NutritionRadarCard({
|
||||
<Text style={styles.calorieSubtitle}>还能吃</Text>
|
||||
<View style={styles.remainingCaloriesContainer}>
|
||||
<AnimatedNumber
|
||||
value={remainingCalories}
|
||||
value={(loading || activeCaloriesLoading) ? 0 : remainingCalories}
|
||||
resetToken={resetToken}
|
||||
style={styles.mainValue}
|
||||
format={(v) => Math.round(v).toString()}
|
||||
format={(v) => (loading || activeCaloriesLoading) ? '--' : Math.round(v).toString()}
|
||||
/>
|
||||
<Text style={styles.calorieUnit}>千卡</Text>
|
||||
</View>
|
||||
@@ -189,30 +214,30 @@ export function NutritionRadarCard({
|
||||
<Text style={styles.calculationLabel}>基代</Text>
|
||||
</View>
|
||||
<AnimatedNumber
|
||||
value={effectiveBasalMetabolism}
|
||||
value={loading ? 0 : effectiveBasalMetabolism}
|
||||
resetToken={resetToken}
|
||||
style={styles.calculationValue}
|
||||
format={(v) => Math.round(v).toString()}
|
||||
format={(v) => loading ? '--' : Math.round(v).toString()}
|
||||
/>
|
||||
<Text style={styles.calculationText}> + </Text>
|
||||
<View style={styles.calculationItem}>
|
||||
<Text style={styles.calculationLabel}>运动</Text>
|
||||
</View>
|
||||
<AnimatedNumber
|
||||
value={effectiveActiveCalories}
|
||||
value={activeCaloriesLoading ? 0 : effectiveActiveCalories}
|
||||
resetToken={resetToken}
|
||||
style={styles.calculationValue}
|
||||
format={(v) => Math.round(v).toString()}
|
||||
format={(v) => activeCaloriesLoading ? '--' : Math.round(v).toString()}
|
||||
/>
|
||||
<Text style={styles.calculationText}> - </Text>
|
||||
<View style={styles.calculationItem}>
|
||||
<Text style={styles.calculationLabel}>饮食</Text>
|
||||
</View>
|
||||
<AnimatedNumber
|
||||
value={consumedCalories}
|
||||
value={loading ? 0 : consumedCalories}
|
||||
resetToken={resetToken}
|
||||
style={styles.calculationValue}
|
||||
format={(v) => Math.round(v).toString()}
|
||||
format={(v) => loading ? '--' : Math.round(v).toString()}
|
||||
/>
|
||||
|
||||
</View>
|
||||
@@ -225,7 +250,7 @@ export function NutritionRadarCard({
|
||||
style={styles.foodOptionItem}
|
||||
onPress={() => {
|
||||
triggerLightHaptic();
|
||||
router.push(`/food/camera?mealType=${currentMealType}`);
|
||||
pushIfAuthedElseLogin(`/food/camera?mealType=${currentMealType}`);
|
||||
}}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
@@ -242,7 +267,7 @@ export function NutritionRadarCard({
|
||||
style={styles.foodOptionItem}
|
||||
onPress={() => {
|
||||
triggerLightHaptic();
|
||||
router.push(`${ROUTES.FOOD_LIBRARY}?mealType=${currentMealType}`);
|
||||
pushIfAuthedElseLogin(`${ROUTES.FOOD_LIBRARY}?mealType=${currentMealType}`);
|
||||
}}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
@@ -259,7 +284,7 @@ export function NutritionRadarCard({
|
||||
style={styles.foodOptionItem}
|
||||
onPress={() => {
|
||||
triggerLightHaptic();
|
||||
router.push(`${ROUTES.VOICE_RECORD}?mealType=${currentMealType}`);
|
||||
pushIfAuthedElseLogin(`${ROUTES.VOICE_RECORD}?mealType=${currentMealType}`);
|
||||
}}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Animated,
|
||||
InteractionManager,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
@@ -33,21 +34,22 @@ const StepsCard: React.FC<StepsCardProps> = ({
|
||||
const [hourlySteps, setHourSteps] = useState<HourlyStepData[]>([])
|
||||
|
||||
|
||||
const getStepData = async (date: Date) => {
|
||||
const getStepData = useCallback(async (date: Date) => {
|
||||
try {
|
||||
logger.info('获取步数数据...');
|
||||
|
||||
// 先获取步数,立即更新UI
|
||||
const [steps, hourly] = await Promise.all([
|
||||
fetchStepCount(date),
|
||||
fetchHourlyStepSamples(date)
|
||||
])
|
||||
|
||||
setStepCount(steps)
|
||||
setHourSteps(hourly)
|
||||
]);
|
||||
setStepCount(steps);
|
||||
setHourSteps(hourly);
|
||||
|
||||
} catch (error) {
|
||||
logger.error('获取步数数据失败:', error);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (curDate) {
|
||||
@@ -55,55 +57,60 @@ const StepsCard: React.FC<StepsCardProps> = ({
|
||||
}
|
||||
}, [curDate]);
|
||||
|
||||
// 为每个柱体创建独立的动画值
|
||||
const animatedValues = useRef(
|
||||
Array.from({ length: 24 }, () => new Animated.Value(0))
|
||||
).current;
|
||||
// 优化:减少动画值数量,只为有数据的小时创建动画
|
||||
const animatedValues = useRef<Map<number, Animated.Value>>(new Map()).current;
|
||||
|
||||
// 计算柱状图数据
|
||||
// 优化:简化柱状图数据计算,减少计算量
|
||||
const chartData = useMemo(() => {
|
||||
if (!hourlySteps || hourlySteps.length === 0) {
|
||||
return Array.from({ length: 24 }, (_, i) => ({ hour: i, steps: 0, height: 0 }));
|
||||
}
|
||||
|
||||
// 找到最大步数用于计算高度比例
|
||||
const maxSteps = Math.max(...hourlySteps.map(data => data.steps), 1);
|
||||
const maxHeight = 20; // 柱状图最大高度(缩小一半)
|
||||
// 优化:只计算有数据的小时的最大步数
|
||||
const activeSteps = hourlySteps.filter(data => data.steps > 0);
|
||||
if (activeSteps.length === 0) {
|
||||
return Array.from({ length: 24 }, (_, i) => ({ hour: i, steps: 0, height: 0 }));
|
||||
}
|
||||
|
||||
const maxSteps = Math.max(...activeSteps.map(data => data.steps));
|
||||
const maxHeight = 20;
|
||||
|
||||
return hourlySteps.map(data => ({
|
||||
...data,
|
||||
height: maxSteps > 0 ? (data.steps / maxSteps) * maxHeight : 0
|
||||
height: data.steps > 0 ? (data.steps / maxSteps) * maxHeight : 0
|
||||
}));
|
||||
}, [hourlySteps]);
|
||||
|
||||
// 获取当前小时
|
||||
const currentHour = new Date().getHours();
|
||||
|
||||
// 触发柱体动画
|
||||
// 优化:延迟执行动画,减少UI阻塞
|
||||
useEffect(() => {
|
||||
// 检查是否有实际数据(不只是空数组)
|
||||
const hasData = chartData && chartData.length > 0 && chartData.some(data => data.steps > 0);
|
||||
|
||||
if (hasData) {
|
||||
// 重置所有动画值
|
||||
animatedValues.forEach(animValue => animValue.setValue(0));
|
||||
|
||||
// 使用 setTimeout 确保在下一个事件循环中执行动画,保证组件已完全渲染
|
||||
const timeoutId = setTimeout(() => {
|
||||
// 同时启动所有柱体的弹性动画,有步数的柱体才执行动画
|
||||
// 使用 InteractionManager 确保动画不会阻塞用户交互
|
||||
InteractionManager.runAfterInteractions(() => {
|
||||
// 只为有数据的小时创建和执行动画
|
||||
chartData.forEach((data, index) => {
|
||||
if (data.steps > 0) {
|
||||
Animated.spring(animatedValues[index], {
|
||||
// 懒创建动画值
|
||||
if (!animatedValues.has(index)) {
|
||||
animatedValues.set(index, new Animated.Value(0));
|
||||
}
|
||||
|
||||
const animValue = animatedValues.get(index)!;
|
||||
animValue.setValue(0);
|
||||
|
||||
// 使用更高性能的timing动画替代spring
|
||||
Animated.timing(animValue, {
|
||||
toValue: 1,
|
||||
tension: 150,
|
||||
friction: 8,
|
||||
duration: 300,
|
||||
useNativeDriver: false,
|
||||
}).start();
|
||||
}
|
||||
});
|
||||
}, 50); // 添加小延迟确保渲染完成
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
});
|
||||
}
|
||||
}, [chartData, animatedValues]);
|
||||
|
||||
@@ -127,17 +134,22 @@ const StepsCard: React.FC<StepsCardProps> = ({
|
||||
const isActive = data.steps > 0;
|
||||
const isCurrent = index <= currentHour;
|
||||
|
||||
// 动画变换:缩放从0到实际高度
|
||||
const animatedScale = animatedValues[index].interpolate({
|
||||
// 优化:只为有数据的柱体创建动画插值
|
||||
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],
|
||||
});
|
||||
|
||||
// 动画变换:透明度从0到1
|
||||
const animatedOpacity = animatedValues[index].interpolate({
|
||||
animatedOpacity = animValue.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [0, 1],
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<View key={`bar-container-${index}`} style={styles.barContainer}>
|
||||
@@ -160,8 +172,8 @@ const StepsCard: React.FC<StepsCardProps> = ({
|
||||
{
|
||||
height: data.height,
|
||||
backgroundColor: isCurrent ? '#FFC365' : '#FFEBCB',
|
||||
transform: [{ scaleY: animatedScale }],
|
||||
opacity: animatedOpacity,
|
||||
transform: animatedScale ? [{ scaleY: animatedScale }] : undefined,
|
||||
opacity: animatedOpacity || 1,
|
||||
}
|
||||
]}
|
||||
/>
|
||||
|
||||
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 dayjs from 'dayjs';
|
||||
import { fetchHRVWithStatus } from '@/utils/health';
|
||||
import { Image } from 'expo-image';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
@@ -11,26 +10,6 @@ interface StressMeterProps {
|
||||
}
|
||||
|
||||
export function StressMeter({ curDate }: StressMeterProps) {
|
||||
// 格式化更新时间显示
|
||||
const formatUpdateTime = (date: Date): string => {
|
||||
const now = dayjs();
|
||||
const updateTime = dayjs(date);
|
||||
const diffMinutes = now.diff(updateTime, 'minute');
|
||||
const diffHours = now.diff(updateTime, 'hour');
|
||||
const diffDays = now.diff(updateTime, 'day');
|
||||
|
||||
if (diffMinutes < 1) {
|
||||
return '刚刚更新';
|
||||
} else if (diffMinutes < 60) {
|
||||
return `${diffMinutes}分钟前更新`;
|
||||
} else if (diffHours < 24) {
|
||||
return `${diffHours}小时前更新`;
|
||||
} else if (diffDays < 7) {
|
||||
return `${diffDays}天前更新`;
|
||||
} else {
|
||||
return updateTime.format('MM-DD HH:mm');
|
||||
}
|
||||
};
|
||||
|
||||
// 将HRV值转换为压力指数(0-100)
|
||||
// HRV值范围:30-110ms,映射到压力指数100-0
|
||||
@@ -55,13 +34,24 @@ export function StressMeter({ curDate }: StressMeterProps) {
|
||||
|
||||
const getHrvData = async () => {
|
||||
try {
|
||||
const data = await fetchHRVForDate(curDate)
|
||||
console.log('StressMeter: 开始获取HRV数据...', curDate);
|
||||
|
||||
if (data) {
|
||||
setHrvValue(data)
|
||||
// 使用智能HRV数据获取功能
|
||||
const result = await fetchHRVWithStatus(curDate);
|
||||
|
||||
console.log('StressMeter: HRV数据获取结果:', result);
|
||||
|
||||
if (result.hrvData) {
|
||||
setHrvValue(Math.round(result.hrvData.value));
|
||||
console.log(`StressMeter: 使用${result.message},HRV值: ${result.hrvData.value}ms`);
|
||||
} else {
|
||||
console.log('StressMeter: 未获取到HRV数据');
|
||||
// 可以设置一个默认值或者显示无数据状态
|
||||
setHrvValue(0);
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
console.error('StressMeter: 获取HRV数据失败:', error);
|
||||
setHrvValue(0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,7 +128,7 @@ export function StressMeter({ curDate }: StressMeterProps) {
|
||||
visible={showStressModal}
|
||||
onClose={() => setShowStressModal(false)}
|
||||
hrvValue={hrvValue}
|
||||
// updateTime={updateTime || new Date()}
|
||||
updateTime={new Date()}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||
import { useWaterDataByDate } from '@/hooks/useWaterData';
|
||||
import { getQuickWaterAmount } from '@/utils/userPreferences';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
@@ -28,8 +27,7 @@ const WaterIntakeCard: React.FC<WaterIntakeCardProps> = ({
|
||||
selectedDate
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const { ensureLoggedIn } = useAuthGuard();
|
||||
const { waterStats, dailyWaterGoal, waterRecords, addWaterRecord } = useWaterDataByDate(selectedDate);
|
||||
const { waterStats, dailyWaterGoal, waterRecords, addWaterRecord, getWaterRecordsByDate } = useWaterDataByDate(selectedDate);
|
||||
const [quickWaterAmount, setQuickWaterAmount] = useState(150); // 默认值,将从用户偏好中加载
|
||||
|
||||
// 计算当前饮水量和目标
|
||||
@@ -78,21 +76,25 @@ const WaterIntakeCard: React.FC<WaterIntakeCardProps> = ({
|
||||
// 判断是否是今天
|
||||
const isToday = selectedDate === dayjs().format('YYYY-MM-DD') || !selectedDate;
|
||||
|
||||
// 加载用户偏好的快速添加饮水默认值
|
||||
// 页面聚焦时重新加载数据
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
const loadQuickWaterAmount = async () => {
|
||||
const loadDataOnFocus = async () => {
|
||||
try {
|
||||
// 重新加载快速添加饮水默认值
|
||||
const amount = await getQuickWaterAmount();
|
||||
setQuickWaterAmount(amount);
|
||||
|
||||
// 重新获取水数据以刷新显示
|
||||
const targetDate = selectedDate || dayjs().format('YYYY-MM-DD');
|
||||
await getWaterRecordsByDate(targetDate);
|
||||
} catch (error) {
|
||||
console.error('加载快速添加饮水默认值失败:', error);
|
||||
// 保持默认值 250ml
|
||||
console.error('页面聚焦时加载数据失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
loadQuickWaterAmount();
|
||||
}, [])
|
||||
loadDataOnFocus();
|
||||
}, [selectedDate, getWaterRecordsByDate])
|
||||
);
|
||||
|
||||
// 触发柱体动画
|
||||
@@ -123,12 +125,6 @@ const WaterIntakeCard: React.FC<WaterIntakeCardProps> = ({
|
||||
|
||||
// 处理添加喝水 - 右上角按钮直接添加
|
||||
const handleQuickAddWater = async () => {
|
||||
// 检查用户是否已登录
|
||||
const isLoggedIn = await ensureLoggedIn();
|
||||
if (!isLoggedIn) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 触发震动反馈
|
||||
if (process.env.EXPO_OS === 'ios') {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
||||
@@ -139,31 +135,26 @@ const WaterIntakeCard: React.FC<WaterIntakeCardProps> = ({
|
||||
// 使用用户配置的快速添加饮水量
|
||||
const waterAmount = quickWaterAmount;
|
||||
// 如果有选中日期,则为该日期添加记录;否则为今天添加记录
|
||||
const recordedAt = selectedDate ? dayjs(selectedDate).toISOString() : dayjs().toISOString();
|
||||
const recordedAt = dayjs().toISOString()
|
||||
await addWaterRecord(waterAmount, recordedAt);
|
||||
};
|
||||
|
||||
// 处理卡片点击 - 跳转到饮水设置页面
|
||||
// 处理卡片点击 - 跳转到饮水详情页面
|
||||
const handleCardPress = async () => {
|
||||
// 检查用户是否已登录
|
||||
const isLoggedIn = await ensureLoggedIn();
|
||||
if (!isLoggedIn) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 触发震动反馈
|
||||
if (process.env.EXPO_OS === 'ios') {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
}
|
||||
|
||||
// 跳转到饮水设置页面,传递选中的日期参数
|
||||
// 跳转到饮水详情页面,传递选中的日期参数
|
||||
router.push({
|
||||
pathname: '/water-settings',
|
||||
pathname: '/water/detail',
|
||||
params: selectedDate ? { selectedDate } : undefined
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[styles.container, style]}
|
||||
@@ -274,6 +265,7 @@ const WaterIntakeCard: React.FC<WaterIntakeCardProps> = ({
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
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 { StyleSheet } from 'react-native';
|
||||
import React, { useState, useCallback, useRef } from 'react';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
import HealthDataCard from './HealthDataCard';
|
||||
import { fetchOxygenSaturation } from '@/utils/health';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
interface OxygenSaturationCardProps {
|
||||
resetToken: number;
|
||||
style?: object;
|
||||
oxygenSaturation?: number | null;
|
||||
selectedDate?: Date;
|
||||
}
|
||||
|
||||
const OxygenSaturationCard: React.FC<OxygenSaturationCardProps> = ({
|
||||
resetToken,
|
||||
style,
|
||||
oxygenSaturation
|
||||
selectedDate
|
||||
}) => {
|
||||
const [oxygenSaturation, setOxygenSaturation] = useState<number | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const loadingRef = useRef(false);
|
||||
|
||||
// 获取血氧饱和度数据 - 在页面聚焦、日期变化时触发
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
const loadOxygenSaturationData = async () => {
|
||||
const dateToUse = selectedDate || new Date();
|
||||
|
||||
// 防止重复请求
|
||||
if (loadingRef.current) return;
|
||||
|
||||
try {
|
||||
loadingRef.current = true;
|
||||
setLoading(true);
|
||||
|
||||
const options = {
|
||||
startDate: dayjs(dateToUse).startOf('day').toDate().toISOString(),
|
||||
endDate: dayjs(dateToUse).endOf('day').toDate().toISOString()
|
||||
};
|
||||
|
||||
const data = await fetchOxygenSaturation(options);
|
||||
setOxygenSaturation(data);
|
||||
} catch (error) {
|
||||
console.error('OxygenSaturationCard: 获取血氧饱和度数据失败:', error);
|
||||
setOxygenSaturation(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
loadingRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
loadOxygenSaturationData();
|
||||
}, [selectedDate])
|
||||
);
|
||||
|
||||
return (
|
||||
<HealthDataCard
|
||||
title="血氧饱和度"
|
||||
value={oxygenSaturation !== null && oxygenSaturation !== undefined ? oxygenSaturation.toFixed(1) : '--'}
|
||||
value={loading ? '--' : (oxygenSaturation !== null && oxygenSaturation !== undefined ? oxygenSaturation.toFixed(1) : '--')}
|
||||
unit="%"
|
||||
style={style}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
});
|
||||
|
||||
export default OxygenSaturationCard;
|
||||
@@ -1,18 +1,19 @@
|
||||
import { fetchCompleteSleepData, formatSleepTime } from '@/utils/sleepHealthKit';
|
||||
import dayjs from 'dayjs';
|
||||
import { Image } from 'expo-image';
|
||||
import { router } from 'expo-router';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
|
||||
|
||||
interface SleepCardProps {
|
||||
selectedDate?: Date;
|
||||
style?: object;
|
||||
onPress?: () => void;
|
||||
}
|
||||
|
||||
const SleepCard: React.FC<SleepCardProps> = ({
|
||||
selectedDate,
|
||||
style,
|
||||
onPress
|
||||
}) => {
|
||||
const [sleepDuration, setSleepDuration] = useState<number | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -52,15 +53,11 @@ const SleepCard: React.FC<SleepCardProps> = ({
|
||||
</View>
|
||||
);
|
||||
|
||||
if (onPress) {
|
||||
return (
|
||||
<TouchableOpacity onPress={onPress} activeOpacity={0.7}>
|
||||
<TouchableOpacity onPress={() => router.push(`/sleep-detail?date=${dayjs(selectedDate).format('YYYY-MM-DD')}`)} activeOpacity={0.7}>
|
||||
{CardContent}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
return CardContent;
|
||||
};
|
||||
|
||||
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(() => {
|
||||
if (isLoggedIn) {
|
||||
loadWeightHistory();
|
||||
}
|
||||
}, [userProfile?.weight, isLoggedIn]);
|
||||
|
||||
const loadWeightHistory = async () => {
|
||||
@@ -67,71 +69,36 @@ export function WeightHistoryCard() {
|
||||
};
|
||||
|
||||
|
||||
|
||||
// 如果没有体重数据,显示引导卡片
|
||||
if (!hasWeight) {
|
||||
return (
|
||||
<TouchableOpacity style={styles.card} onPress={navigateToWeightRecords} activeOpacity={0.8}>
|
||||
<View style={styles.cardHeader}>
|
||||
<Image
|
||||
source={require('@/assets/images/icons/icon-weight.png')}
|
||||
style={styles.iconSquare}
|
||||
/>
|
||||
<Text style={styles.cardTitle}>体重记录</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.emptyContent}>
|
||||
<Text style={styles.emptyTitle}>开始记录你的体重变化</Text>
|
||||
<Text style={styles.emptyDescription}>
|
||||
记录体重变化,追踪你的健康进展
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.recordButton}
|
||||
onPress={(e) => {
|
||||
e.stopPropagation();
|
||||
navigateToCoach();
|
||||
}}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Ionicons name="add" size={18} color="#192126" />
|
||||
<Text style={styles.recordButtonText}>记录</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
// 处理体重历史数据
|
||||
const sortedHistory = [...weightHistory]
|
||||
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
|
||||
.slice(-7); // 只显示最近7条记录
|
||||
|
||||
if (sortedHistory.length === 0) {
|
||||
return (
|
||||
<TouchableOpacity style={styles.card} onPress={navigateToWeightRecords} activeOpacity={0.8}>
|
||||
<View style={styles.cardHeader}>
|
||||
<Text style={styles.cardTitle}>体重记录</Text>
|
||||
</View>
|
||||
// return (
|
||||
// <TouchableOpacity style={styles.card} onPress={navigateToWeightRecords} activeOpacity={0.8}>
|
||||
// <View style={styles.cardHeader}>
|
||||
// <Text style={styles.cardTitle}>体重记录</Text>
|
||||
// </View>
|
||||
|
||||
<View style={styles.emptyContent}>
|
||||
<Text style={styles.emptyDescription}>
|
||||
暂无体重记录,点击下方按钮开始记录
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.recordButton}
|
||||
onPress={(e) => {
|
||||
e.stopPropagation();
|
||||
navigateToCoach();
|
||||
}}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Ionicons name="add" size={18} color="#FFFFFF" />
|
||||
<Text style={styles.recordButtonText}>记录体重</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
// <View style={styles.emptyContent}>
|
||||
// <Text style={styles.emptyDescription}>
|
||||
// 暂无体重记录,点击下方按钮开始记录
|
||||
// </Text>
|
||||
// <TouchableOpacity
|
||||
// style={styles.recordButton}
|
||||
// onPress={(e) => {
|
||||
// e.stopPropagation();
|
||||
// navigateToCoach();
|
||||
// }}
|
||||
// activeOpacity={0.8}
|
||||
// >
|
||||
// <Ionicons name="add" size={18} color="#FFFFFF" />
|
||||
// <Text style={styles.recordButtonText}>记录体重</Text>
|
||||
// </TouchableOpacity>
|
||||
// </View>
|
||||
// </TouchableOpacity>
|
||||
// );
|
||||
// }
|
||||
|
||||
// 生成图表数据
|
||||
const weights = sortedHistory.map(item => parseFloat(item.weight));
|
||||
|
||||
@@ -5,6 +5,7 @@ export const ROUTES = {
|
||||
TAB_COACH: '/coach',
|
||||
TAB_GOALS: '/goals',
|
||||
TAB_STATISTICS: '/statistics',
|
||||
TAB_CHALLENGES: '/challenges',
|
||||
TAB_PERSONAL: '/personal',
|
||||
|
||||
// 训练相关路由
|
||||
@@ -45,6 +46,12 @@ export const ROUTES = {
|
||||
// 健康相关路由
|
||||
FITNESS_RINGS_DETAIL: '/fitness-rings-detail',
|
||||
SLEEP_DETAIL: '/sleep-detail',
|
||||
BASAL_METABOLISM_DETAIL: '/basal-metabolism-detail',
|
||||
|
||||
// 饮水相关路由
|
||||
WATER_DETAIL: '/water/detail',
|
||||
WATER_SETTINGS: '/water/settings',
|
||||
WATER_REMINDER_SETTINGS: '/water/reminder-settings',
|
||||
|
||||
// 任务相关路由
|
||||
TASK_DETAIL: '/task-detail',
|
||||
|
||||
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 dispatch = useAppDispatch();
|
||||
const currentPath = usePathname();
|
||||
const token = useAppSelector((s) => (s as any)?.user?.token as string | null);
|
||||
const user = useAppSelector(state => state.user);
|
||||
|
||||
const isLoggedIn = !!token;
|
||||
const isLoggedIn = !!user?.profile?.id;
|
||||
|
||||
const ensureLoggedIn = useCallback(async (options?: EnsureOptions): Promise<boolean> => {
|
||||
if (isLoggedIn) return true;
|
||||
|
||||
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;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 60;
|
||||
objectVersion = 54;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; };
|
||||
2C9C524987451393B76B9C7E /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 7EC44F9488C227087AA8DF97 /* PrivacyInfo.xcprivacy */; };
|
||||
32476CAEFFCE691C1634B0A4 /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EA3641BAC6078512F41509D /* ExpoModulesProvider.swift */; };
|
||||
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; };
|
||||
6B6021A2D1EB466803BE19D7 /* libPods-digitalpilates.a in Frameworks */ = {isa = PBXBuildFile; fileRef = F1A078ADDB1BCB06E0DBEFDA /* libPods-digitalpilates.a */; };
|
||||
7996A1192E6FB82300371142 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7996A1182E6FB82300371142 /* WidgetKit.framework */; };
|
||||
7996A11B2E6FB82300371142 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7996A11A2E6FB82300371142 /* SwiftUI.framework */; };
|
||||
7996A12C2E6FB82300371142 /* WaterWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 7996A1172E6FB82300371142 /* WaterWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
646189797DBE7937221347A9 /* libPods-OutLive.a in Frameworks */ = {isa = PBXBuildFile; fileRef = C411D1CEBC225A6F92F136BA /* libPods-OutLive.a */; };
|
||||
79B2CB702E7B954600B51753 /* OutLive-Bridging-Header.h in Sources */ = {isa = PBXBuildFile; fileRef = F11748442D0722820044C1D9 /* OutLive-Bridging-Header.h */; };
|
||||
79B2CB732E7B954F00B51753 /* HealthKitManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 79B2CB712E7B954F00B51753 /* HealthKitManager.m */; };
|
||||
79B2CB742E7B954F00B51753 /* HealthKitManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79B2CB722E7B954F00B51753 /* HealthKitManager.swift */; };
|
||||
91B7BA17B50D328546B5B4B8 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = B7F23062EE59F61E6260DBA8 /* PrivacyInfo.xcprivacy */; };
|
||||
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; };
|
||||
DC3BFC72D3A68C7493D5B44A /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83D1B5F0EC906D7A2F599549 /* ExpoModulesProvider.swift */; };
|
||||
F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11748412D0307B40044C1D9 /* AppDelegate.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
7996A12A2E6FB82300371142 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 83CBB9F71A601CBA00E9B192 /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 7996A1162E6FB82300371142;
|
||||
remoteInfo = WaterWidgetExtension;
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
7996A12D2E6FB82300371142 /* Embed Foundation Extensions */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
dstPath = "";
|
||||
dstSubfolderSpec = 13;
|
||||
files = (
|
||||
7996A12C2E6FB82300371142 /* WaterWidgetExtension.appex in Embed Foundation Extensions */,
|
||||
);
|
||||
name = "Embed Foundation Extensions";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
13B07F961A680F5B00A75B9A /* digitalpilates.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = digitalpilates.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = digitalpilates/Images.xcassets; sourceTree = "<group>"; };
|
||||
13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = digitalpilates/Info.plist; sourceTree = "<group>"; };
|
||||
4D6B8E20DD8E5677F8B2EAA1 /* Pods-digitalpilates.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-digitalpilates.debug.xcconfig"; path = "Target Support Files/Pods-digitalpilates/Pods-digitalpilates.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
7996A1172E6FB82300371142 /* WaterWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = WaterWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
7996A1182E6FB82300371142 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; };
|
||||
7996A11A2E6FB82300371142 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; };
|
||||
7996A1322E6FB84A00371142 /* WaterWidgetExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = WaterWidgetExtension.entitlements; sourceTree = "<group>"; };
|
||||
7EC44F9488C227087AA8DF97 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = digitalpilates/PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
|
||||
83D1B5F0EC906D7A2F599549 /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-digitalpilates/ExpoModulesProvider.swift"; sourceTree = "<group>"; };
|
||||
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = digitalpilates/SplashScreen.storyboard; sourceTree = "<group>"; };
|
||||
0FF78E2879F14B0D75DF41B4 /* Pods-OutLive.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-OutLive.release.xcconfig"; path = "Target Support Files/Pods-OutLive/Pods-OutLive.release.xcconfig"; sourceTree = "<group>"; };
|
||||
13B07F961A680F5B00A75B9A /* OutLive.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = OutLive.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = OutLive/Images.xcassets; sourceTree = "<group>"; };
|
||||
13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = OutLive/Info.plist; sourceTree = "<group>"; };
|
||||
1EA3641BAC6078512F41509D /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-OutLive/ExpoModulesProvider.swift"; sourceTree = "<group>"; };
|
||||
4FAA45EB21D2C45B94943F48 /* Pods-OutLive.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-OutLive.debug.xcconfig"; path = "Target Support Files/Pods-OutLive/Pods-OutLive.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
79B2CB712E7B954F00B51753 /* HealthKitManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = HealthKitManager.m; path = OutLive/HealthKitManager.m; sourceTree = "<group>"; };
|
||||
79B2CB722E7B954F00B51753 /* HealthKitManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = HealthKitManager.swift; path = OutLive/HealthKitManager.swift; sourceTree = "<group>"; };
|
||||
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = OutLive/SplashScreen.storyboard; sourceTree = "<group>"; };
|
||||
B7F23062EE59F61E6260DBA8 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; name = PrivacyInfo.xcprivacy; path = OutLive/PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
|
||||
BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = "<group>"; };
|
||||
EA6A757B2DE1747F7B3664B4 /* Pods-digitalpilates.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-digitalpilates.release.xcconfig"; path = "Target Support Files/Pods-digitalpilates/Pods-digitalpilates.release.xcconfig"; sourceTree = "<group>"; };
|
||||
C411D1CEBC225A6F92F136BA /* libPods-OutLive.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-OutLive.a"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; };
|
||||
F11748412D0307B40044C1D9 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = digitalpilates/AppDelegate.swift; sourceTree = "<group>"; };
|
||||
F11748442D0722820044C1D9 /* digitalpilates-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = "digitalpilates-Bridging-Header.h"; path = "digitalpilates/digitalpilates-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||
F1A078ADDB1BCB06E0DBEFDA /* libPods-digitalpilates.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-digitalpilates.a"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
F11748412D0307B40044C1D9 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = OutLive/AppDelegate.swift; sourceTree = "<group>"; };
|
||||
F11748442D0722820044C1D9 /* OutLive-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = "OutLive-Bridging-Header.h"; path = "OutLive/OutLive-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
7996A1302E6FB82300371142 /* Exceptions for "WaterWidget" folder in "WaterWidgetExtension" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
Info.plist,
|
||||
);
|
||||
target = 7996A1162E6FB82300371142 /* WaterWidgetExtension */;
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
7996A11C2E6FB82300371142 /* WaterWidget */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
7996A1302E6FB82300371142 /* Exceptions for "WaterWidget" folder in "WaterWidgetExtension" target */,
|
||||
);
|
||||
explicitFileTypes = {
|
||||
};
|
||||
explicitFolders = (
|
||||
);
|
||||
path = WaterWidget;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
13B07F8C1A680F5B00A75B9A /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
6B6021A2D1EB466803BE19D7 /* libPods-digitalpilates.a in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
7996A1142E6FB82300371142 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
7996A11B2E6FB82300371142 /* SwiftUI.framework in Frameworks */,
|
||||
7996A1192E6FB82300371142 /* WidgetKit.framework in Frameworks */,
|
||||
646189797DBE7937221347A9 /* libPods-OutLive.a in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
13B07FAE1A68108700A75B9A /* digitalpilates */ = {
|
||||
13B07FAE1A68108700A75B9A /* OutLive */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
F11748412D0307B40044C1D9 /* AppDelegate.swift */,
|
||||
F11748442D0722820044C1D9 /* digitalpilates-Bridging-Header.h */,
|
||||
F11748442D0722820044C1D9 /* OutLive-Bridging-Header.h */,
|
||||
BB2F792B24A3F905000567C9 /* Supporting */,
|
||||
13B07FB51A68108700A75B9A /* Images.xcassets */,
|
||||
13B07FB61A68108700A75B9A /* Info.plist */,
|
||||
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */,
|
||||
7EC44F9488C227087AA8DF97 /* PrivacyInfo.xcprivacy */,
|
||||
B7F23062EE59F61E6260DBA8 /* PrivacyInfo.xcprivacy */,
|
||||
);
|
||||
name = digitalpilates;
|
||||
name = OutLive;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
2D16E6871FA4F8E400B85C8A /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
ED297162215061F000B7C4FE /* JavaScriptCore.framework */,
|
||||
F1A078ADDB1BCB06E0DBEFDA /* libPods-digitalpilates.a */,
|
||||
7996A1182E6FB82300371142 /* WidgetKit.framework */,
|
||||
7996A11A2E6FB82300371142 /* SwiftUI.framework */,
|
||||
C411D1CEBC225A6F92F136BA /* libPods-OutLive.a */,
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
3EE8D66219D64F4A63E8298D /* Pods */ = {
|
||||
7B63456AB81271603E0039A3 /* Pods */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
4D6B8E20DD8E5677F8B2EAA1 /* Pods-digitalpilates.debug.xcconfig */,
|
||||
EA6A757B2DE1747F7B3664B4 /* Pods-digitalpilates.release.xcconfig */,
|
||||
4FAA45EB21D2C45B94943F48 /* Pods-OutLive.debug.xcconfig */,
|
||||
0FF78E2879F14B0D75DF41B4 /* Pods-OutLive.release.xcconfig */,
|
||||
);
|
||||
name = Pods;
|
||||
path = Pods;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
80E2A1E8ECA8777F7264D855 /* ExpoModulesProviders */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
BF89779EFCFC7E852B943187 /* OutLive */,
|
||||
);
|
||||
name = ExpoModulesProviders;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
832341AE1AAA6A7D00B99B32 /* Libraries */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -153,14 +100,14 @@
|
||||
83CBB9F61A601CBA00E9B192 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
7996A1322E6FB84A00371142 /* WaterWidgetExtension.entitlements */,
|
||||
13B07FAE1A68108700A75B9A /* digitalpilates */,
|
||||
79B2CB712E7B954F00B51753 /* HealthKitManager.m */,
|
||||
79B2CB722E7B954F00B51753 /* HealthKitManager.swift */,
|
||||
13B07FAE1A68108700A75B9A /* OutLive */,
|
||||
832341AE1AAA6A7D00B99B32 /* Libraries */,
|
||||
7996A11C2E6FB82300371142 /* WaterWidget */,
|
||||
83CBBA001A601CBA00E9B192 /* Products */,
|
||||
2D16E6871FA4F8E400B85C8A /* Frameworks */,
|
||||
3EE8D66219D64F4A63E8298D /* Pods */,
|
||||
F899CC3CCA86CFEC0C4F53F7 /* ExpoModulesProviders */,
|
||||
80E2A1E8ECA8777F7264D855 /* ExpoModulesProviders */,
|
||||
7B63456AB81271603E0039A3 /* Pods */,
|
||||
);
|
||||
indentWidth = 2;
|
||||
sourceTree = "<group>";
|
||||
@@ -170,8 +117,7 @@
|
||||
83CBBA001A601CBA00E9B192 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
13B07F961A680F5B00A75B9A /* digitalpilates.app */,
|
||||
7996A1172E6FB82300371142 /* WaterWidgetExtension.appex */,
|
||||
13B07F961A680F5B00A75B9A /* OutLive.app */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
@@ -182,104 +128,69 @@
|
||||
BB2F792C24A3F905000567C9 /* Expo.plist */,
|
||||
);
|
||||
name = Supporting;
|
||||
path = digitalpilates/Supporting;
|
||||
path = OutLive/Supporting;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
DFAD2B7142CEC38E9ED66053 /* digitalpilates */ = {
|
||||
BF89779EFCFC7E852B943187 /* OutLive */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
83D1B5F0EC906D7A2F599549 /* ExpoModulesProvider.swift */,
|
||||
1EA3641BAC6078512F41509D /* ExpoModulesProvider.swift */,
|
||||
);
|
||||
name = digitalpilates;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
F899CC3CCA86CFEC0C4F53F7 /* ExpoModulesProviders */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
DFAD2B7142CEC38E9ED66053 /* digitalpilates */,
|
||||
);
|
||||
name = ExpoModulesProviders;
|
||||
name = OutLive;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
13B07F861A680F5B00A75B9A /* digitalpilates */ = {
|
||||
13B07F861A680F5B00A75B9A /* OutLive */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "digitalpilates" */;
|
||||
buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "OutLive" */;
|
||||
buildPhases = (
|
||||
08A4A3CD28434E44B6B9DE2E /* [CP] Check Pods Manifest.lock */,
|
||||
60F566376E07CDAA8138E40B /* [Expo] Configure project */,
|
||||
0B29745394A2F51EC4C9CBC7 /* [CP] Check Pods Manifest.lock */,
|
||||
FED23F24D8115FB0D63DF986 /* [Expo] Configure project */,
|
||||
13B07F871A680F5B00A75B9A /* Sources */,
|
||||
13B07F8C1A680F5B00A75B9A /* Frameworks */,
|
||||
13B07F8E1A680F5B00A75B9A /* Resources */,
|
||||
00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */,
|
||||
800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */,
|
||||
7996A12D2E6FB82300371142 /* Embed Foundation Extensions */,
|
||||
CD8A4C026AF644A41E91C9E8 /* [CP] Embed Pods Frameworks */,
|
||||
EF8950FE7A620E6B790F3042 /* [CP] Embed Pods Frameworks */,
|
||||
2266B6A6AD27779BC2D49E87 /* [CP] Copy Pods Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
7996A12B2E6FB82300371142 /* PBXTargetDependency */,
|
||||
);
|
||||
name = digitalpilates;
|
||||
productName = digitalpilates;
|
||||
productReference = 13B07F961A680F5B00A75B9A /* digitalpilates.app */;
|
||||
name = OutLive;
|
||||
productName = OutLive;
|
||||
productReference = 13B07F961A680F5B00A75B9A /* OutLive.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
7996A1162E6FB82300371142 /* WaterWidgetExtension */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 7996A1312E6FB82300371142 /* Build configuration list for PBXNativeTarget "WaterWidgetExtension" */;
|
||||
buildPhases = (
|
||||
7996A1132E6FB82300371142 /* Sources */,
|
||||
7996A1142E6FB82300371142 /* Frameworks */,
|
||||
7996A1152E6FB82300371142 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
7996A11C2E6FB82300371142 /* WaterWidget */,
|
||||
);
|
||||
name = WaterWidgetExtension;
|
||||
productName = WaterWidgetExtension;
|
||||
productReference = 7996A1172E6FB82300371142 /* WaterWidgetExtension.appex */;
|
||||
productType = "com.apple.product-type.app-extension";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
83CBB9F71A601CBA00E9B192 /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
LastSwiftUpdateCheck = 1640;
|
||||
LastUpgradeCheck = 1130;
|
||||
TargetAttributes = {
|
||||
13B07F861A680F5B00A75B9A = {
|
||||
LastSwiftMigration = 1250;
|
||||
};
|
||||
7996A1162E6FB82300371142 = {
|
||||
CreatedOnToolsVersion = 16.4;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "digitalpilates" */;
|
||||
buildConfigurationList = 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "OutLive" */;
|
||||
compatibilityVersion = "Xcode 3.2";
|
||||
developmentRegion = "zh-Hans";
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
en,
|
||||
Base,
|
||||
"zh-Hans",
|
||||
);
|
||||
mainGroup = 83CBB9F61A601CBA00E9B192;
|
||||
productRefGroup = 83CBBA001A601CBA00E9B192 /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
13B07F861A680F5B00A75B9A /* digitalpilates */,
|
||||
7996A1162E6FB82300371142 /* WaterWidgetExtension */,
|
||||
13B07F861A680F5B00A75B9A /* OutLive */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
@@ -292,14 +203,7 @@
|
||||
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */,
|
||||
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */,
|
||||
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */,
|
||||
2C9C524987451393B76B9C7E /* PrivacyInfo.xcprivacy in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
7996A1152E6FB82300371142 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
91B7BA17B50D328546B5B4B8 /* PrivacyInfo.xcprivacy in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -313,6 +217,8 @@
|
||||
files = (
|
||||
);
|
||||
inputPaths = (
|
||||
"$(SRCROOT)/.xcode.env",
|
||||
"$(SRCROOT)/.xcode.env.local",
|
||||
);
|
||||
name = "Bundle React Native code and images";
|
||||
outputPaths = (
|
||||
@@ -321,7 +227,7 @@
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "if [[ -f \"$PODS_ROOT/../.xcode.env\" ]]; then\n source \"$PODS_ROOT/../.xcode.env\"\nfi\nif [[ -f \"$PODS_ROOT/../.xcode.env.local\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.local\"\nfi\n\n# The project root by default is one level up from the ios directory\nexport PROJECT_ROOT=\"$PROJECT_DIR\"/..\n\nif [[ \"$CONFIGURATION\" = *Debug* ]]; then\n export SKIP_BUNDLING=1\nfi\nif [[ -z \"$ENTRY_FILE\" ]]; then\n # Set the entry JS file using the bundler's entry resolution.\n export ENTRY_FILE=\"$(\"$NODE_BINARY\" -e \"require('expo/scripts/resolveAppEntry')\" \"$PROJECT_ROOT\" ios absolute | tail -n 1)\"\nfi\n\nif [[ -z \"$CLI_PATH\" ]]; then\n # Use Expo CLI\n export CLI_PATH=\"$(\"$NODE_BINARY\" --print \"require.resolve('@expo/cli', { paths: [require.resolve('expo/package.json')] })\")\"\nfi\nif [[ -z \"$BUNDLE_COMMAND\" ]]; then\n # Default Expo CLI command for bundling\n export BUNDLE_COMMAND=\"export:embed\"\nfi\n\n# Source .xcode.env.updates if it exists to allow\n# SKIP_BUNDLING to be unset if needed\nif [[ -f \"$PODS_ROOT/../.xcode.env.updates\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.updates\"\nfi\n# Source local changes to allow overrides\n# if needed\nif [[ -f \"$PODS_ROOT/../.xcode.env.local\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.local\"\nfi\n\n`\"$NODE_BINARY\" --print \"require('path').dirname(require.resolve('react-native/package.json')) + '/scripts/react-native-xcode.sh'\"`\n\n";
|
||||
};
|
||||
08A4A3CD28434E44B6B9DE2E /* [CP] Check Pods Manifest.lock */ = {
|
||||
0B29745394A2F51EC4C9CBC7 /* [CP] Check Pods Manifest.lock */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
@@ -336,44 +242,20 @@
|
||||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
"$(DERIVED_FILE_DIR)/Pods-digitalpilates-checkManifestLockResult.txt",
|
||||
"$(DERIVED_FILE_DIR)/Pods-OutLive-checkManifestLockResult.txt",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
60F566376E07CDAA8138E40B /* [Expo] Configure project */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
alwaysOutOfDate = 1;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
);
|
||||
inputPaths = (
|
||||
"$(SRCROOT)/.xcode.env",
|
||||
"$(SRCROOT)/.xcode.env.local",
|
||||
"$(SRCROOT)/digitalpilates/digitalpilates.entitlements",
|
||||
"$(SRCROOT)/Pods/Target Support Files/Pods-digitalpilates/expo-configure-project.sh",
|
||||
);
|
||||
name = "[Expo] Configure project";
|
||||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
"$(SRCROOT)/Pods/Target Support Files/Pods-digitalpilates/ExpoModulesProvider.swift",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "# This script configures Expo modules and generates the modules provider file.\nbash -l -c \"./Pods/Target\\ Support\\ Files/Pods-digitalpilates/expo-configure-project.sh\"\n";
|
||||
};
|
||||
800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */ = {
|
||||
2266B6A6AD27779BC2D49E87 /* [CP] Copy Pods Resources */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-digitalpilates/Pods-digitalpilates-resources.sh",
|
||||
"${PODS_ROOT}/Target Support Files/Pods-OutLive/Pods-OutLive-resources.sh",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/EXApplication/ExpoApplication_privacy.bundle",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/EXConstants.bundle",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/ExpoConstants_privacy.bundle",
|
||||
@@ -382,7 +264,6 @@
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/ExpoFileSystem/ExpoFileSystem_privacy.bundle",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/ExpoSystemUI/ExpoSystemUI_privacy.bundle",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/PurchasesHybridCommon/PurchasesHybridCommon.bundle",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/RCT-Folly/RCT-Folly_privacy.bundle",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/RNCAsyncStorage/RNCAsyncStorage_resources.bundle",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/RNDeviceInfo/RNDeviceInfoPrivacyInfo.bundle",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/RNSVG/RNSVGFilters.bundle",
|
||||
@@ -391,8 +272,6 @@
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/RevenueCat/RevenueCat.bundle",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/SDWebImage/SDWebImage.bundle",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/Sentry/Sentry.bundle",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/boost/boost_privacy.bundle",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/glog/glog_privacy.bundle",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/lottie-ios/LottiePrivacyInfo.bundle",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/lottie-react-native/Lottie_React_Native_Privacy.bundle",
|
||||
);
|
||||
@@ -406,7 +285,6 @@
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoFileSystem_privacy.bundle",
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoSystemUI_privacy.bundle",
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/PurchasesHybridCommon.bundle",
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RCT-Folly_privacy.bundle",
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNCAsyncStorage_resources.bundle",
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNDeviceInfoPrivacyInfo.bundle",
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNSVGFilters.bundle",
|
||||
@@ -415,34 +293,60 @@
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RevenueCat.bundle",
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/SDWebImage.bundle",
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Sentry.bundle",
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/boost_privacy.bundle",
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/glog_privacy.bundle",
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/LottiePrivacyInfo.bundle",
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Lottie_React_Native_Privacy.bundle",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-digitalpilates/Pods-digitalpilates-resources.sh\"\n";
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-OutLive/Pods-OutLive-resources.sh\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
CD8A4C026AF644A41E91C9E8 /* [CP] Embed Pods Frameworks */ = {
|
||||
EF8950FE7A620E6B790F3042 /* [CP] Embed Pods Frameworks */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-digitalpilates/Pods-digitalpilates-frameworks.sh",
|
||||
"${PODS_ROOT}/Target Support Files/Pods-OutLive/Pods-OutLive-frameworks.sh",
|
||||
"${PODS_XCFRAMEWORKS_BUILD_DIR}/React-Core-prebuilt/React.framework/React",
|
||||
"${PODS_XCFRAMEWORKS_BUILD_DIR}/ReactNativeDependencies/ReactNativeDependencies.framework/ReactNativeDependencies",
|
||||
"${PODS_XCFRAMEWORKS_BUILD_DIR}/hermes-engine/Pre-built/hermes.framework/hermes",
|
||||
);
|
||||
name = "[CP] Embed Pods Frameworks";
|
||||
outputPaths = (
|
||||
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/React.framework",
|
||||
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/ReactNativeDependencies.framework",
|
||||
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/hermes.framework",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-digitalpilates/Pods-digitalpilates-frameworks.sh\"\n";
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-OutLive/Pods-OutLive-frameworks.sh\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
FED23F24D8115FB0D63DF986 /* [Expo] Configure project */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
alwaysOutOfDate = 1;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
);
|
||||
inputPaths = (
|
||||
"$(SRCROOT)/.xcode.env",
|
||||
"$(SRCROOT)/.xcode.env.local",
|
||||
"$(SRCROOT)/OutLive/OutLive.entitlements",
|
||||
"$(SRCROOT)/Pods/Target Support Files/Pods-OutLive/expo-configure-project.sh",
|
||||
);
|
||||
name = "[Expo] Configure project";
|
||||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
"$(SRCROOT)/Pods/Target Support Files/Pods-OutLive/ExpoModulesProvider.swift",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "# This script configures Expo modules and generates the modules provider file.\nbash -l -c \"./Pods/Target\\ Support\\ Files/Pods-OutLive/expo-configure-project.sh\"\n";
|
||||
};
|
||||
/* End PBXShellScriptBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
@@ -450,52 +354,38 @@
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
79B2CB732E7B954F00B51753 /* HealthKitManager.m in Sources */,
|
||||
79B2CB742E7B954F00B51753 /* HealthKitManager.swift in Sources */,
|
||||
79B2CB702E7B954600B51753 /* OutLive-Bridging-Header.h in Sources */,
|
||||
F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */,
|
||||
DC3BFC72D3A68C7493D5B44A /* ExpoModulesProvider.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
7996A1132E6FB82300371142 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
32476CAEFFCE691C1634B0A4 /* ExpoModulesProvider.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
7996A12B2E6FB82300371142 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = 7996A1162E6FB82300371142 /* WaterWidgetExtension */;
|
||||
targetProxy = 7996A12A2E6FB82300371142 /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
13B07F941A680F5B00A75B9A /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 4D6B8E20DD8E5677F8B2EAA1 /* Pods-digitalpilates.debug.xcconfig */;
|
||||
baseConfigurationReference = 4FAA45EB21D2C45B94943F48 /* Pods-OutLive.debug.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = digitalpilates/digitalpilates.entitlements;
|
||||
CODE_SIGN_ENTITLEMENTS = OutLive/OutLive.entitlements;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = 756WVXJ6MT;
|
||||
ENABLE_BITCODE = NO;
|
||||
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = x86_64;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"$(inherited)",
|
||||
"FB_SONARKIT_ENABLED=1",
|
||||
);
|
||||
INFOPLIST_FILE = digitalpilates/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Out Live";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
|
||||
INFOPLIST_FILE = OutLive/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.6;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0.7;
|
||||
MARKETING_VERSION = 1.0;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"-ObjC",
|
||||
@@ -503,12 +393,8 @@
|
||||
);
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.anonymous.digitalpilates;
|
||||
PRODUCT_NAME = digitalpilates;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SUPPORTS_MACCATALYST = NO;
|
||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "digitalpilates/digitalpilates-Bridging-Header.h";
|
||||
PRODUCT_NAME = OutLive;
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "OutLive/OutLive-Bridging-Header.h";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 1;
|
||||
@@ -518,22 +404,20 @@
|
||||
};
|
||||
13B07F951A680F5B00A75B9A /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = EA6A757B2DE1747F7B3664B4 /* Pods-digitalpilates.release.xcconfig */;
|
||||
baseConfigurationReference = 0FF78E2879F14B0D75DF41B4 /* Pods-OutLive.release.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = digitalpilates/digitalpilates.entitlements;
|
||||
CODE_SIGN_ENTITLEMENTS = OutLive/OutLive.entitlements;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = 756WVXJ6MT;
|
||||
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = x86_64;
|
||||
INFOPLIST_FILE = digitalpilates/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Out Live";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
|
||||
INFOPLIST_FILE = OutLive/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.6;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0.7;
|
||||
MARKETING_VERSION = 1.0;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"-ObjC",
|
||||
@@ -541,109 +425,14 @@
|
||||
);
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.anonymous.digitalpilates;
|
||||
PRODUCT_NAME = digitalpilates;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SUPPORTS_MACCATALYST = NO;
|
||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "digitalpilates/digitalpilates-Bridging-Header.h";
|
||||
PRODUCT_NAME = OutLive;
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "OutLive/OutLive-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 1;
|
||||
VERSIONING_SYSTEM = "apple-generic";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
7996A12E2E6FB82300371142 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CODE_SIGN_ENTITLEMENTS = WaterWidgetExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
DEVELOPMENT_TEAM = 756WVXJ6MT;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = WaterWidget/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = WaterWidget;
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.5;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.anonymous.digitalpilates.WaterWidget;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
7996A12F2E6FB82300371142 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CODE_SIGN_ENTITLEMENTS = WaterWidgetExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_TEAM = 756WVXJ6MT;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = WaterWidget/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = WaterWidget;
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.5;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
MTL_FAST_MATH = YES;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.anonymous.digitalpilates.WaterWidget;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
83CBBA201A601CBA00E9B192 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
@@ -699,13 +488,10 @@
|
||||
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\"";
|
||||
MTL_ENABLE_DEBUG_INFO = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
" ",
|
||||
);
|
||||
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG";
|
||||
SWIFT_ENABLE_EXPLICIT_MODULES = NO;
|
||||
USE_HERMES = true;
|
||||
};
|
||||
name = Debug;
|
||||
@@ -757,12 +543,9 @@
|
||||
);
|
||||
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\"";
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
" ",
|
||||
);
|
||||
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_ENABLE_EXPLICIT_MODULES = NO;
|
||||
USE_HERMES = true;
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
@@ -771,7 +554,7 @@
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "digitalpilates" */ = {
|
||||
13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "OutLive" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
13B07F941A680F5B00A75B9A /* Debug */,
|
||||
@@ -780,16 +563,7 @@
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
7996A1312E6FB82300371142 /* Build configuration list for PBXNativeTarget "WaterWidgetExtension" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
7996A12E2E6FB82300371142 /* Debug */,
|
||||
7996A12F2E6FB82300371142 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "digitalpilates" */ = {
|
||||
83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "OutLive" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
83CBBA201A601CBA00E9B192 /* Debug */,
|
||||
@@ -15,9 +15,9 @@
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
|
||||
BuildableName = "digitalpilates.app"
|
||||
BlueprintName = "digitalpilates"
|
||||
ReferencedContainer = "container:digitalpilates.xcodeproj">
|
||||
BuildableName = "OutLive.app"
|
||||
BlueprintName = "OutLive"
|
||||
ReferencedContainer = "container:OutLive.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
@@ -33,9 +33,9 @@
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "00E356ED1AD99517003FC87E"
|
||||
BuildableName = "digitalpilatesTests.xctest"
|
||||
BlueprintName = "digitalpilatesTests"
|
||||
ReferencedContainer = "container:digitalpilates.xcodeproj">
|
||||
BuildableName = "OutLiveTests.xctest"
|
||||
BlueprintName = "OutLiveTests"
|
||||
ReferencedContainer = "container:OutLive.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
@@ -55,9 +55,9 @@
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
|
||||
BuildableName = "digitalpilates.app"
|
||||
BlueprintName = "digitalpilates"
|
||||
ReferencedContainer = "container:digitalpilates.xcodeproj">
|
||||
BuildableName = "OutLive.app"
|
||||
BlueprintName = "OutLive"
|
||||
ReferencedContainer = "container:OutLive.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</LaunchAction>
|
||||
@@ -72,9 +72,9 @@
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
|
||||
BuildableName = "digitalpilates.app"
|
||||
BlueprintName = "digitalpilates"
|
||||
ReferencedContainer = "container:digitalpilates.xcodeproj">
|
||||
BuildableName = "OutLive.app"
|
||||
BlueprintName = "OutLive"
|
||||
ReferencedContainer = "container:OutLive.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
@@ -2,7 +2,7 @@
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "group:digitalpilates.xcodeproj">
|
||||
location = "group:OutLive.xcodeproj">
|
||||
</FileRef>
|
||||
<FileRef
|
||||
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"
|
||||
}
|
||||
}
|
||||