Compare commits
45 Commits
e56ebe3636
...
feature/ri
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2597c1bc4 | ||
|
|
a014998848 | ||
|
|
badd68c039 | ||
|
|
ad98d78e18 | ||
|
|
94899fbc5c | ||
|
|
0f289fcae7 | ||
|
|
79ab354f31 | ||
|
|
83e534c4a7 | ||
|
|
6303795870 | ||
| 028ef56caf | |||
|
|
e6dfd4d59a | ||
|
|
d082c66b72 | ||
|
|
dbe460a084 | ||
|
|
fb85a5f30c | ||
|
|
9bcea25a2f | ||
|
|
ccfccca7bc | ||
| 184fb672b7 | |||
|
|
2c382ab8de | ||
|
|
6f0c872223 | ||
|
|
6b7776e51d | ||
|
|
63ed820e93 | ||
|
|
42b6b2076c | ||
|
|
281149201b | ||
|
|
2357596665 | ||
|
|
91df01bd79 | ||
| 55d133c470 | |||
| 24b144a0d1 | |||
| a9bb73e2a1 | |||
| ab87bddd51 | |||
|
|
4627cb650e | ||
|
|
edac180dd6 | ||
|
|
1b76cc305a | ||
|
|
a84c026599 | ||
| 1af0945a2f | |||
| dfe9506a7a | |||
| 0cb7e67b5e | |||
|
|
3a4a55b78e | ||
|
|
35d6b74451 | ||
|
|
62690ee3fc | ||
|
|
aee87e8900 | ||
|
|
6fbdbafa3e | ||
| 98176ee988 | |||
| b0c572c1d4 | |||
|
|
a7f5379d5a | ||
|
|
6daf9500fc |
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)
|
||||
51
app.json
@@ -2,74 +2,49 @@
|
||||
"expo": {
|
||||
"name": "Out Live",
|
||||
"slug": "digital-pilates",
|
||||
"version": "1.0.5",
|
||||
"version": "1.0.14",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/images/Sealife.jpeg",
|
||||
"scheme": "digitalpilates",
|
||||
"userInterfaceStyle": "light",
|
||||
"icon": "./assets/icon.icon/Assets/icon-1756312748268.jpg",
|
||||
"newArchEnabled": true,
|
||||
"jsEngine": "jsc",
|
||||
"ios": {
|
||||
"supportsTablet": false,
|
||||
"deploymentTarget": "16.0",
|
||||
"bundleIdentifier": "com.anonymous.digitalpilates",
|
||||
"infoPlist": {
|
||||
"ITSAppUsesNonExemptEncryption": false,
|
||||
"NSCameraUsageDescription": "应用需要使用相机以拍摄您的体态照片用于AI测评。",
|
||||
"NSPhotoLibraryUsageDescription": "应用需要访问相册以选择您的体态照片用于AI测评。",
|
||||
"NSPhotoLibraryAddUsageDescription": "应用需要写入相册以保存拍摄的体态照片(可选)。",
|
||||
"NSMicrophoneUsageDescription": "应用需要使用麦克风进行语音识别,将您的语音转换为文字记录饮食信息。",
|
||||
"NSSpeechRecognitionUsageDescription": "应用需要使用语音识别功能来转换您的语音为文字,帮助您快速记录饮食信息。",
|
||||
"NSUserNotificationsUsageDescription": "应用需要发送通知以提醒您喝水和站立活动。",
|
||||
"UIBackgroundModes": [
|
||||
"processing",
|
||||
"fetch",
|
||||
"remote-notification"
|
||||
]
|
||||
}
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/images/Sealife.jpeg",
|
||||
"backgroundColor": "#ffffff"
|
||||
},
|
||||
"edgeToEdgeEnabled": true,
|
||||
"package": "com.anonymous.digitalpilates",
|
||||
"permissions": [
|
||||
"android.permission.RECEIVE_BOOT_COMPLETED",
|
||||
"android.permission.VIBRATE",
|
||||
"android.permission.WAKE_LOCK"
|
||||
]
|
||||
},
|
||||
"web": {
|
||||
"bundler": "metro",
|
||||
"output": "static",
|
||||
"favicon": "./assets/images/Sealife.jpeg"
|
||||
"appleTeamId": "756WVXJ6MT"
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
[
|
||||
"expo-splash-screen",
|
||||
{
|
||||
"image": "./assets/images/Sealife.jpeg",
|
||||
"image": "./assets/icon.icon/Assets/icon-1756312748268.jpg",
|
||||
"imageWidth": 40,
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#ffffff"
|
||||
}
|
||||
],
|
||||
[
|
||||
"react-native-health",
|
||||
{
|
||||
"enableHealthAPI": true,
|
||||
"healthSharePermission": "应用需要访问您的健康数据(步数、能量消耗、心率变异性等)以展示运动统计和压力分析。",
|
||||
"healthUpdatePermission": "应用需要更新您的健康数据(体重信息)以记录您的健身进度。"
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-notifications",
|
||||
{
|
||||
"icon": "./assets/images/Sealife.jpeg",
|
||||
"color": "#ffffff",
|
||||
"sounds": [
|
||||
"./assets/sounds/notification.wav"
|
||||
]
|
||||
"icon": "./assets/icon.icon/Assets/icon-1756312748268.jpg",
|
||||
"color": "#ffffff"
|
||||
}
|
||||
],
|
||||
[
|
||||
@@ -84,14 +59,14 @@
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-background-task",
|
||||
{
|
||||
"minimumInterval": 15
|
||||
}
|
||||
"expo-background-task"
|
||||
]
|
||||
],
|
||||
"experiments": {
|
||||
"typedRoutes": true
|
||||
},
|
||||
"android": {
|
||||
"package": "com.anonymous.digitalpilates"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { BottomTabNavigationOptions } from '@react-navigation/bottom-tabs';
|
||||
import { GlassContainer, GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { Tabs, usePathname } from 'expo-router';
|
||||
import { Icon, Label, NativeTabs } from 'expo-router/unstable-native-tabs';
|
||||
|
||||
import React from 'react';
|
||||
import { Text, TouchableOpacity, View, ViewStyle } from 'react-native';
|
||||
|
||||
@@ -18,8 +21,8 @@ type TabConfig = {
|
||||
|
||||
const TAB_CONFIGS: Record<string, TabConfig> = {
|
||||
statistics: { icon: 'chart.pie.fill', title: '健康' },
|
||||
// explore: { icon: 'magnifyingglass.circle.fill', title: '发现' },
|
||||
goals: { icon: 'flag.fill', title: '习惯' },
|
||||
challenges: { icon: 'trophy.fill', title: '挑战' },
|
||||
personal: { icon: 'person.fill', title: '个人' },
|
||||
};
|
||||
|
||||
@@ -27,13 +30,15 @@ export default function TabLayout() {
|
||||
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||
const colorTokens = Colors[theme];
|
||||
const pathname = usePathname();
|
||||
const glassEffectAvailable = isLiquidGlassAvailable();
|
||||
|
||||
// Helper function to determine if a tab is selected
|
||||
const isTabSelected = (routeName: string): boolean => {
|
||||
const routeMap: Record<string, string> = {
|
||||
explore: ROUTES.TAB_EXPLORE,
|
||||
goals: ROUTES.TAB_GOALS,
|
||||
statistics: ROUTES.TAB_STATISTICS,
|
||||
goals: ROUTES.TAB_GOALS,
|
||||
challenges: ROUTES.TAB_CHALLENGES,
|
||||
personal: ROUTES.TAB_PERSONAL,
|
||||
};
|
||||
|
||||
return routeMap[routeName] === pathname || pathname.includes(routeName);
|
||||
@@ -59,16 +64,17 @@ export default function TabLayout() {
|
||||
<TouchableOpacity
|
||||
onPress={handlePress}
|
||||
accessibilityRole="button"
|
||||
activeOpacity={1}
|
||||
style={{
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexDirection: 'row',
|
||||
marginHorizontal: 6,
|
||||
marginHorizontal: 2,
|
||||
marginVertical: 10,
|
||||
borderRadius: 25,
|
||||
backgroundColor: isSelected ? colorTokens.tabBarActiveBackground : 'transparent',
|
||||
paddingHorizontal: isSelected ? 16 : 10,
|
||||
paddingHorizontal: isSelected ? 8 : 4,
|
||||
paddingVertical: 8,
|
||||
}}
|
||||
>
|
||||
@@ -86,7 +92,7 @@ export default function TabLayout() {
|
||||
fontWeight: '600',
|
||||
marginLeft: 6,
|
||||
}}
|
||||
numberOfLines={0 as any}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{tabConfig.title}
|
||||
</Text>
|
||||
@@ -96,29 +102,62 @@ export default function TabLayout() {
|
||||
);
|
||||
};
|
||||
|
||||
// Custom tab bar background component
|
||||
const TabBarBackground = () => {
|
||||
if (glassEffectAvailable) {
|
||||
return (
|
||||
<GlassContainer
|
||||
spacing={8}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
borderRadius: 34,
|
||||
}}
|
||||
>
|
||||
<GlassView
|
||||
isInteractive
|
||||
glassEffectStyle="regular"
|
||||
tintColor={theme === 'dark' ? 'rgba(0,0,0,0.3)' : 'rgba(255,255,255,0.3)'}
|
||||
style={{
|
||||
flex: 1,
|
||||
borderRadius: 34,
|
||||
}}
|
||||
/>
|
||||
</GlassContainer>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Common screen options
|
||||
const getScreenOptions = (routeName: string): BottomTabNavigationOptions => ({
|
||||
headerShown: false,
|
||||
tabBarActiveTintColor: colorTokens.tabIconSelected,
|
||||
tabBarButton: createTabButton(routeName),
|
||||
tabBarBackground: TabBarBackground,
|
||||
tabBarStyle: {
|
||||
position: 'absolute',
|
||||
bottom: TAB_BAR_BOTTOM_OFFSET,
|
||||
height: TAB_BAR_HEIGHT,
|
||||
borderRadius: 34,
|
||||
backgroundColor: colorTokens.tabBarBackground,
|
||||
backgroundColor: glassEffectAvailable ? 'transparent' : colorTokens.tabBarBackground,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowOpacity: glassEffectAvailable ? 0.1 : 0.2,
|
||||
shadowRadius: 10,
|
||||
elevation: 5,
|
||||
paddingHorizontal: 10,
|
||||
paddingHorizontal: 6,
|
||||
paddingTop: 0,
|
||||
paddingBottom: 0,
|
||||
marginHorizontal: 20,
|
||||
left: 20,
|
||||
right: 20,
|
||||
marginHorizontal: 16,
|
||||
left: 16,
|
||||
right: 16,
|
||||
alignSelf: 'center',
|
||||
borderWidth: glassEffectAvailable ? 1 : 0,
|
||||
borderColor: glassEffectAvailable ? (theme === 'dark' ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)') : 'transparent',
|
||||
} as ViewStyle,
|
||||
tabBarItemStyle: {
|
||||
backgroundColor: 'transparent',
|
||||
@@ -131,6 +170,27 @@ export default function TabLayout() {
|
||||
tabBarShowLabel: false,
|
||||
});
|
||||
|
||||
if (glassEffectAvailable) {
|
||||
return <NativeTabs>
|
||||
<NativeTabs.Trigger name="statistics">
|
||||
<Label>健康</Label>
|
||||
<Icon sf="chart.pie.fill" drawable="custom_android_drawable" />
|
||||
</NativeTabs.Trigger>
|
||||
<NativeTabs.Trigger name="goals">
|
||||
<Icon sf="flag.fill" drawable="custom_settings_drawable" />
|
||||
<Label>习惯</Label>
|
||||
</NativeTabs.Trigger>
|
||||
<NativeTabs.Trigger name="challenges">
|
||||
<Icon sf="trophy.fill" drawable="custom_android_drawable" />
|
||||
<Label>挑战</Label>
|
||||
</NativeTabs.Trigger>
|
||||
<NativeTabs.Trigger name="personal">
|
||||
<Icon sf="person.fill" drawable="custom_settings_drawable" />
|
||||
<Label>我的</Label>
|
||||
</NativeTabs.Trigger>
|
||||
</NativeTabs>
|
||||
}
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
initialRouteName="statistics"
|
||||
@@ -138,9 +198,8 @@ export default function TabLayout() {
|
||||
>
|
||||
|
||||
<Tabs.Screen name="statistics" options={{ title: '健康' }} />
|
||||
<Tabs.Screen name="explore" options={{ title: '发现', href: null }} />
|
||||
<Tabs.Screen name="coach" options={{ title: 'AI', href: null }} />
|
||||
<Tabs.Screen name="goals" options={{ title: '习惯' }} />
|
||||
<Tabs.Screen name="challenges" options={{ title: '挑战' }} />
|
||||
<Tabs.Screen name="personal" options={{ title: '个人' }} />
|
||||
</Tabs>
|
||||
);
|
||||
|
||||
297
app/(tabs)/challenges.tsx
Normal file
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -46,6 +46,7 @@ export default function GoalsScreen() {
|
||||
skipError,
|
||||
} = useAppSelector((state) => state.tasks);
|
||||
|
||||
|
||||
const {
|
||||
createLoading,
|
||||
createError
|
||||
@@ -67,13 +68,13 @@ export default function GoalsScreen() {
|
||||
// 页面聚焦时重新加载数据
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
console.log('useFocusEffect - loading tasks');
|
||||
console.log('useFocusEffect - loading tasks isLoggedIn', isLoggedIn);
|
||||
|
||||
if (isLoggedIn) {
|
||||
loadTasks();
|
||||
checkAndShowGuide();
|
||||
}
|
||||
}, [dispatch])
|
||||
}, [dispatch, isLoggedIn])
|
||||
);
|
||||
|
||||
// 检查并显示用户引导
|
||||
@@ -94,6 +95,7 @@ export default function GoalsScreen() {
|
||||
// 加载任务列表
|
||||
const loadTasks = async () => {
|
||||
try {
|
||||
|
||||
await dispatch(fetchTasks({
|
||||
startDate: dayjs().startOf('day').toISOString(),
|
||||
endDate: dayjs().endOf('day').toISOString(),
|
||||
@@ -107,6 +109,8 @@ export default function GoalsScreen() {
|
||||
const onRefresh = async () => {
|
||||
setRefreshing(true);
|
||||
try {
|
||||
if (!isLoggedIn) return
|
||||
|
||||
await loadTasks();
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
@@ -115,6 +119,8 @@ export default function GoalsScreen() {
|
||||
|
||||
// 加载更多任务
|
||||
const handleLoadMoreTasks = async () => {
|
||||
if (!isLoggedIn) return
|
||||
|
||||
if (tasksPagination.hasMore && !tasksLoading) {
|
||||
try {
|
||||
await dispatch(loadMoreTasks()).unwrap();
|
||||
@@ -317,6 +323,61 @@ export default function GoalsScreen() {
|
||||
|
||||
// 渲染空状态
|
||||
const renderEmptyState = () => {
|
||||
// 未登录状态下的引导
|
||||
if (!isLoggedIn) {
|
||||
return (
|
||||
<View style={styles.emptyStateLogin}>
|
||||
<LinearGradient
|
||||
colors={['#F0F9FF', '#FEFEFE', '#F0F9FF']}
|
||||
style={styles.emptyStateLoginBackground}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
/>
|
||||
|
||||
<View style={styles.emptyStateLoginContent}>
|
||||
{/* 清新的图标设计 */}
|
||||
<View style={styles.emptyStateLoginIconContainer}>
|
||||
<LinearGradient
|
||||
colors={[colorTokens.primary, '#9B8AFB']}
|
||||
style={styles.emptyStateLoginIconGradient}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
>
|
||||
<MaterialIcons name="person-outline" size={32} color="#FFFFFF" />
|
||||
</LinearGradient>
|
||||
</View>
|
||||
|
||||
{/* 主标题 */}
|
||||
<Text style={[styles.emptyStateLoginTitle, { color: colorTokens.text }]}>
|
||||
开启您的健康之旅
|
||||
</Text>
|
||||
|
||||
{/* 副标题 */}
|
||||
<Text style={[styles.emptyStateLoginSubtitle, { color: colorTokens.textSecondary }]}>
|
||||
登录后即可创建个人目标,让我们一起建立健康的生活习惯
|
||||
</Text>
|
||||
|
||||
{/* 登录按钮 */}
|
||||
<TouchableOpacity
|
||||
style={[styles.emptyStateLoginButton, { backgroundColor: colorTokens.primary }]}
|
||||
onPress={() => pushIfAuthedElseLogin('/goals')}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={[colorTokens.primary, '#9B8AFB']}
|
||||
style={styles.emptyStateLoginButtonGradient}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
>
|
||||
<Text style={styles.emptyStateLoginButtonText}>立即登录</Text>
|
||||
<MaterialIcons name="arrow-forward" size={18} color="#FFFFFF" />
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// 已登录但无任务的状态
|
||||
let title = '暂无任务';
|
||||
let subtitle = '创建目标后,系统会自动生成相应的任务';
|
||||
|
||||
@@ -510,7 +571,7 @@ export default function GoalsScreen() {
|
||||
text: '每日目标通知',
|
||||
onPress: async () => {
|
||||
try {
|
||||
const userName = userProfile?.name || '小海豹';
|
||||
const userName = userProfile?.name || '';
|
||||
const notificationIds = await GoalNotificationHelpers.scheduleGoalNotifications(
|
||||
{
|
||||
title: '每日运动目标',
|
||||
@@ -531,7 +592,7 @@ export default function GoalsScreen() {
|
||||
text: '每周目标通知',
|
||||
onPress: async () => {
|
||||
try {
|
||||
const userName = userProfile?.name || '小海豹';
|
||||
const userName = userProfile?.name || '';
|
||||
const notificationIds = await GoalNotificationHelpers.scheduleGoalNotifications(
|
||||
{
|
||||
title: '每周运动目标',
|
||||
@@ -555,7 +616,7 @@ export default function GoalsScreen() {
|
||||
text: '目标达成通知',
|
||||
onPress: async () => {
|
||||
try {
|
||||
const userName = userProfile?.name || '小海豹';
|
||||
const userName = userProfile?.name || '';
|
||||
await GoalNotificationHelpers.sendGoalAchievementNotification(userName, '每日运动目标');
|
||||
Alert.alert('成功', '目标达成通知已发送');
|
||||
} catch (error) {
|
||||
@@ -708,6 +769,80 @@ const styles = StyleSheet.create({
|
||||
textAlign: 'center',
|
||||
lineHeight: 20,
|
||||
},
|
||||
// 未登录空状态样式
|
||||
emptyStateLogin: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: 80,
|
||||
position: 'relative',
|
||||
},
|
||||
emptyStateLoginBackground: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
borderRadius: 24,
|
||||
},
|
||||
emptyStateLoginContent: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1,
|
||||
},
|
||||
emptyStateLoginIconContainer: {
|
||||
marginBottom: 24,
|
||||
shadowColor: '#7A5AF8',
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
shadowOpacity: 0.15,
|
||||
shadowRadius: 16,
|
||||
elevation: 8,
|
||||
},
|
||||
emptyStateLoginIconGradient: {
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: 40,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
emptyStateLoginTitle: {
|
||||
fontSize: 24,
|
||||
fontWeight: '700',
|
||||
marginBottom: 12,
|
||||
textAlign: 'center',
|
||||
letterSpacing: -0.5,
|
||||
},
|
||||
emptyStateLoginSubtitle: {
|
||||
fontSize: 16,
|
||||
lineHeight: 24,
|
||||
textAlign: 'center',
|
||||
marginBottom: 32,
|
||||
paddingHorizontal: 8,
|
||||
},
|
||||
emptyStateLoginButton: {
|
||||
borderRadius: 28,
|
||||
shadowColor: '#7A5AF8',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 12,
|
||||
elevation: 6,
|
||||
},
|
||||
emptyStateLoginButtonGradient: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: 32,
|
||||
paddingVertical: 16,
|
||||
borderRadius: 28,
|
||||
gap: 8,
|
||||
},
|
||||
emptyStateLoginButtonText: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 17,
|
||||
fontWeight: '600',
|
||||
letterSpacing: -0.2,
|
||||
},
|
||||
loadMoreContainer: {
|
||||
alignItems: 'center',
|
||||
paddingVertical: 20,
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import ActivityHeatMap from '@/components/ActivityHeatMap';
|
||||
import { PRIVACY_POLICY_URL, USER_AGREEMENT_URL } from '@/constants/Agree';
|
||||
import { ROUTES } from '@/constants/Routes';
|
||||
import { getTabBarBottomPadding } from '@/constants/TabBar';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||
import { useNotifications } from '@/hooks/useNotifications';
|
||||
import { DEFAULT_MEMBER_NAME, fetchActivityHistory, fetchMyProfile } from '@/store/userSlice';
|
||||
import { getItem, setItem } from '@/utils/kvStore';
|
||||
import { log } from '@/utils/logger';
|
||||
import { getNotificationEnabled, setNotificationEnabled as saveNotificationEnabled } from '@/utils/userPreferences';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
import { isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
import { Image } from 'expo-image';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Alert, Linking, ScrollView, StatusBar, StyleSheet, Switch, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
@@ -21,7 +24,8 @@ export default function PersonalScreen() {
|
||||
const dispatch = useAppDispatch();
|
||||
const { confirmLogout, confirmDeleteAccount, isLoggedIn, pushIfAuthedElseLogin } = useAuthGuard();
|
||||
const insets = useSafeAreaInsets();
|
||||
const tabBarHeight = useBottomTabBarHeight();
|
||||
|
||||
const isLgAvaliable = isLiquidGlassAvailable()
|
||||
|
||||
// 推送通知相关
|
||||
const {
|
||||
@@ -31,14 +35,20 @@ export default function PersonalScreen() {
|
||||
|
||||
const [notificationEnabled, setNotificationEnabled] = useState(false);
|
||||
|
||||
// 开发者模式相关状态
|
||||
const [showDeveloperSection, setShowDeveloperSection] = useState(false);
|
||||
const clickTimestamps = useRef<number[]>([]);
|
||||
const clickTimeoutRef = useRef<number | null>(null);
|
||||
|
||||
// 计算底部间距
|
||||
const bottomPadding = useMemo(() => {
|
||||
return getTabBarBottomPadding(tabBarHeight) + (insets?.bottom ?? 0);
|
||||
}, [tabBarHeight, insets?.bottom]);
|
||||
return getTabBarBottomPadding(60) + (insets?.bottom ?? 0);
|
||||
}, [insets?.bottom]);
|
||||
|
||||
// 直接使用 Redux 中的用户信息,避免重复状态管理
|
||||
const userProfile = useAppSelector((state) => state.user.profile);
|
||||
|
||||
|
||||
// 页面聚焦时获取最新用户信息
|
||||
useFocusEffect(
|
||||
React.useCallback(() => {
|
||||
@@ -46,6 +56,8 @@ export default function PersonalScreen() {
|
||||
dispatch(fetchActivityHistory());
|
||||
// 加载用户推送偏好设置
|
||||
loadNotificationPreference();
|
||||
// 加载开发者模式状态
|
||||
loadDeveloperModeState();
|
||||
}, [dispatch])
|
||||
);
|
||||
|
||||
@@ -59,6 +71,27 @@ export default function PersonalScreen() {
|
||||
}
|
||||
};
|
||||
|
||||
// 加载开发者模式状态
|
||||
const loadDeveloperModeState = async () => {
|
||||
try {
|
||||
const enabled = await getItem('developer_mode_enabled');
|
||||
if (enabled === 'true') {
|
||||
setShowDeveloperSection(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载开发者模式状态失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 保存开发者模式状态
|
||||
const saveDeveloperModeState = async (enabled: boolean) => {
|
||||
try {
|
||||
await setItem('developer_mode_enabled', enabled.toString());
|
||||
} catch (error) {
|
||||
console.error('保存开发者模式状态失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 数据格式化函数
|
||||
const formatHeight = () => {
|
||||
if (userProfile.height == null) return '--';
|
||||
@@ -81,11 +114,39 @@ export default function PersonalScreen() {
|
||||
// 显示名称
|
||||
const displayName = (userProfile.name?.trim()) ? userProfile.name : DEFAULT_MEMBER_NAME;
|
||||
|
||||
// 初始化时加载推送偏好设置
|
||||
// 初始化时加载推送偏好设置和开发者模式状态
|
||||
useEffect(() => {
|
||||
loadNotificationPreference();
|
||||
loadDeveloperModeState();
|
||||
}, []);
|
||||
|
||||
// 处理用户名连续点击
|
||||
const handleUserNamePress = () => {
|
||||
const now = Date.now();
|
||||
clickTimestamps.current.push(now);
|
||||
|
||||
// 清除之前的超时
|
||||
if (clickTimeoutRef.current) {
|
||||
clearTimeout(clickTimeoutRef.current);
|
||||
}
|
||||
|
||||
// 只保留最近1秒内的点击
|
||||
clickTimestamps.current = clickTimestamps.current.filter(timestamp => now - timestamp <= 1000);
|
||||
|
||||
// 检查是否有3次连续点击
|
||||
if (clickTimestamps.current.length >= 3) {
|
||||
setShowDeveloperSection(true);
|
||||
saveDeveloperModeState(true); // 持久化保存开发者模式状态
|
||||
clickTimestamps.current = []; // 清空点击记录
|
||||
log.info('开发者模式已激活');
|
||||
} else {
|
||||
// 1秒后清空点击记录
|
||||
clickTimeoutRef.current = setTimeout(() => {
|
||||
clickTimestamps.current = [];
|
||||
}, 1000);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理通知开关变化
|
||||
const handleNotificationToggle = async (value: boolean) => {
|
||||
if (value) {
|
||||
@@ -148,11 +209,17 @@ export default function PersonalScreen() {
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.userDetails}>
|
||||
<TouchableOpacity onPress={handleUserNamePress} activeOpacity={0.7}>
|
||||
<Text style={styles.userName}>{displayName}</Text>
|
||||
</TouchableOpacity>
|
||||
{userProfile.memberNumber && (
|
||||
<Text style={styles.userMemberNumber}>会员编号: {userProfile.memberNumber}</Text>
|
||||
)}
|
||||
</View>
|
||||
<TouchableOpacity style={styles.editButton} onPress={() => pushIfAuthedElseLogin('/profile/edit')}>
|
||||
<Text style={styles.editButtonText}>编辑</Text>
|
||||
<Text style={styles.editButtonText}>{isLoggedIn ? '编辑' : '登录'}</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
</View>
|
||||
|
||||
</View>
|
||||
@@ -238,6 +305,17 @@ export default function PersonalScreen() {
|
||||
},
|
||||
],
|
||||
},
|
||||
// 开发者section(需要连续点击三次用户名激活)
|
||||
...(showDeveloperSection ? [{
|
||||
title: '开发者',
|
||||
items: [
|
||||
{
|
||||
icon: 'code-slash-outline' as const,
|
||||
title: '开发者选项',
|
||||
onPress: () => pushIfAuthedElseLogin(ROUTES.DEVELOPER),
|
||||
},
|
||||
],
|
||||
}] : []),
|
||||
{
|
||||
title: '其他',
|
||||
items: [
|
||||
@@ -308,7 +386,7 @@ export default function PersonalScreen() {
|
||||
transition={200}
|
||||
cachePolicy="memory-disk"
|
||||
/> */}
|
||||
<Text style={styles.fishRecordText}>鱼干记录</Text>
|
||||
<Text style={styles.fishRecordText}>能量记录</Text>
|
||||
</View>
|
||||
<ActivityHeatMap />
|
||||
{menuSections.map((section, index) => (
|
||||
@@ -405,6 +483,11 @@ const styles = StyleSheet.create({
|
||||
color: '#9370DB',
|
||||
fontWeight: '500',
|
||||
},
|
||||
userMemberNumber: {
|
||||
fontSize: 10,
|
||||
color: '#6C757D',
|
||||
marginTop: 4,
|
||||
},
|
||||
editButton: {
|
||||
backgroundColor: '#9370DB',
|
||||
paddingHorizontal: 16,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { DateSelector } from '@/components/DateSelector';
|
||||
import { FitnessRingsCard } from '@/components/FitnessRingsCard';
|
||||
import { MoodCard } from '@/components/MoodCard';
|
||||
import { NutritionRadarCard } from '@/components/NutritionRadarCard';
|
||||
import CircumferenceCard from '@/components/statistic/CircumferenceCard';
|
||||
import OxygenSaturationCard from '@/components/statistic/OxygenSaturationCard';
|
||||
import SleepCard from '@/components/statistic/SleepCard';
|
||||
import StepsCard from '@/components/StepsCard';
|
||||
@@ -10,19 +11,14 @@ import { StressMeter } from '@/components/StressMeter';
|
||||
import WaterIntakeCard from '@/components/WaterIntakeCard';
|
||||
import { WeightHistoryCard } from '@/components/weight/WeightHistoryCard';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { getTabBarBottomPadding } from '@/constants/TabBar';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||
import { backgroundTaskManager } from '@/services/backgroundTaskManager';
|
||||
import { selectHealthDataByDate, setHealthData } from '@/store/healthSlice';
|
||||
import { BackgroundTaskManager } from '@/services/backgroundTaskManager';
|
||||
import { setHealthData } from '@/store/healthSlice';
|
||||
import { fetchDailyMoodCheckins, selectLatestMoodRecordByDate } from '@/store/moodSlice';
|
||||
import { fetchDailyNutritionData, selectNutritionSummaryByDate } from '@/store/nutritionSlice';
|
||||
import { fetchTodayWaterStats } from '@/store/waterSlice';
|
||||
import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date';
|
||||
import { ensureHealthPermissions, fetchHealthDataForDate, testHRVDataFetch } from '@/utils/health';
|
||||
import { getTestHealthData } from '@/utils/mockHealthData';
|
||||
import { calculateNutritionGoals } from '@/utils/nutrition';
|
||||
import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
|
||||
import { fetchHealthDataForDate, testHRVDataFetch } from '@/utils/health';
|
||||
import dayjs from 'dayjs';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { debounce } from 'lodash';
|
||||
@@ -39,13 +35,10 @@ import {
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
// 浮动动画组件
|
||||
const FloatingCard = ({ children, delay = 0, style }: {
|
||||
const FloatingCard = ({ children, style }: {
|
||||
children: React.ReactNode;
|
||||
delay?: number;
|
||||
style?: any;
|
||||
}) => {
|
||||
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
@@ -62,22 +55,14 @@ const FloatingCard = ({ children, delay = 0, style }: {
|
||||
|
||||
export default function ExploreScreen() {
|
||||
const stepGoal = useAppSelector((s) => s.user.profile?.dailyStepsGoal) ?? 2000;
|
||||
const userProfile = useAppSelector((s) => s.user.profile);
|
||||
|
||||
// 开发调试:设置为true来使用mock数据
|
||||
// 在真机测试时,可以暂时设置为true来验证组件显示逻辑
|
||||
const useMockData = __DEV__ || false; // 改为true来启用mock数据调试
|
||||
|
||||
const { pushIfAuthedElseLogin, isLoggedIn } = useAuthGuard();
|
||||
|
||||
|
||||
// 使用 dayjs:当月日期与默认选中"今天"
|
||||
const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth());
|
||||
const tabBarHeight = useBottomTabBarHeight();
|
||||
// const tabBarHeight = useBottomTabBarHeight();
|
||||
const insets = useSafeAreaInsets();
|
||||
const bottomPadding = useMemo(() => {
|
||||
return getTabBarBottomPadding(tabBarHeight) + (insets?.bottom ?? 0);
|
||||
}, [tabBarHeight, insets?.bottom]);
|
||||
|
||||
// 获取当前选中日期 - 使用 useMemo 缓存避免重复计算
|
||||
const currentSelectedDate = useMemo(() => {
|
||||
const days = getMonthDaysZh();
|
||||
@@ -88,70 +73,11 @@ export default function ExploreScreen() {
|
||||
return dayjs(currentSelectedDate).format('YYYY-MM-DD');
|
||||
}, [currentSelectedDate]);
|
||||
|
||||
// 从 Redux 获取指定日期的健康数据
|
||||
const healthData = useAppSelector(selectHealthDataByDate(currentSelectedDateString));
|
||||
|
||||
|
||||
// 解构健康数据(支持mock数据)
|
||||
const mockData = useMockData ? getTestHealthData('mock') : null;
|
||||
const stepCount: number | null = useMockData ? (mockData?.steps ?? null) : (healthData?.steps ?? null);
|
||||
const hourlySteps = useMockData ? (mockData?.hourlySteps ?? []) : (healthData?.hourlySteps ?? []);
|
||||
const activeCalories = useMockData ? (mockData?.activeEnergyBurned ?? null) : (healthData?.activeEnergyBurned ?? null);
|
||||
const basalMetabolism: number | null = useMockData ? (mockData?.basalEnergyBurned ?? null) : (healthData?.basalEnergyBurned ?? null);
|
||||
const sleepDuration = useMockData ? (mockData?.sleepDuration ?? null) : (healthData?.sleepDuration ?? null);
|
||||
const hrvValue = useMockData ? (mockData?.hrv ?? null) : (healthData?.hrv ?? null);
|
||||
const oxygenSaturation = useMockData ? (mockData?.oxygenSaturation ?? null) : (healthData?.oxygenSaturation ?? null);
|
||||
|
||||
// 调试HRV数据
|
||||
console.log('=== HRV数据调试 ===');
|
||||
console.log('useMockData:', useMockData);
|
||||
console.log('mockData?.hrv:', mockData?.hrv);
|
||||
console.log('healthData?.hrv:', healthData?.hrv);
|
||||
console.log('final hrvValue:', hrvValue);
|
||||
console.log('healthData:', healthData);
|
||||
console.log('==================');
|
||||
|
||||
const fitnessRingsData = useMockData ? {
|
||||
activeCalories: mockData?.activeCalories ?? 0,
|
||||
activeCaloriesGoal: mockData?.activeCaloriesGoal ?? 350,
|
||||
exerciseMinutes: mockData?.exerciseMinutes ?? 0,
|
||||
exerciseMinutesGoal: mockData?.exerciseMinutesGoal ?? 30,
|
||||
standHours: mockData?.standHours ?? 0,
|
||||
standHoursGoal: mockData?.standHoursGoal ?? 12,
|
||||
} : (healthData ? {
|
||||
activeCalories: healthData.activeEnergyBurned,
|
||||
activeCaloriesGoal: healthData.activeCaloriesGoal,
|
||||
exerciseMinutes: healthData.exerciseMinutes,
|
||||
exerciseMinutesGoal: healthData.exerciseMinutesGoal,
|
||||
standHours: healthData.standHours,
|
||||
standHoursGoal: healthData.standHoursGoal,
|
||||
} : {
|
||||
activeCalories: 0,
|
||||
activeCaloriesGoal: 350,
|
||||
exerciseMinutes: 0,
|
||||
exerciseMinutesGoal: 30,
|
||||
standHours: 0,
|
||||
standHoursGoal: 12,
|
||||
});
|
||||
|
||||
// HRV更新时间
|
||||
const [hrvUpdateTime, setHrvUpdateTime] = useState<Date>(new Date());
|
||||
|
||||
// 用于触发动画重置的 token(当日期或数据变化时更新)
|
||||
const [animToken, setAnimToken] = useState(0);
|
||||
|
||||
// 从 Redux 获取营养数据
|
||||
const nutritionSummary = useAppSelector(selectNutritionSummaryByDate(currentSelectedDateString));
|
||||
|
||||
// 计算用户的营养目标
|
||||
const nutritionGoals = useMemo(() => {
|
||||
return calculateNutritionGoals({
|
||||
weight: userProfile.weight,
|
||||
height: userProfile.height,
|
||||
birthDate: userProfile?.birthDate ? new Date(userProfile?.birthDate) : undefined,
|
||||
gender: userProfile?.gender || undefined,
|
||||
});
|
||||
}, [userProfile]);
|
||||
|
||||
// 心情相关状态
|
||||
const dispatch = useAppDispatch();
|
||||
@@ -163,7 +89,6 @@ export default function ExploreScreen() {
|
||||
// 请求状态管理,防止重复请求
|
||||
const loadingRef = useRef({
|
||||
health: false,
|
||||
nutrition: false,
|
||||
mood: false
|
||||
});
|
||||
|
||||
@@ -172,14 +97,14 @@ export default function ExploreScreen() {
|
||||
|
||||
const getDateKey = (d: Date) => `${dayjs(d).year()}-${dayjs(d).month() + 1}-${dayjs(d).date()}`;
|
||||
|
||||
// 检查数据是否需要刷新(2分钟内不重复拉取,对营养数据更严格)
|
||||
// 检查数据是否需要刷新(5分钟内不重复拉取)
|
||||
const shouldRefreshData = (dateKey: string, dataType: string) => {
|
||||
const cacheKey = `${dateKey}-${dataType}`;
|
||||
const lastUpdate = dataTimestampRef.current[cacheKey];
|
||||
const now = Date.now();
|
||||
|
||||
// 营养数据使用更短的缓存时间,其他数据使用5分钟
|
||||
const cacheTime = dataType === 'nutrition' ? 2 * 60 * 1000 : 5 * 60 * 1000;
|
||||
// 使用5分钟缓存时间
|
||||
const cacheTime = 5 * 60 * 1000;
|
||||
|
||||
return !lastUpdate || (now - lastUpdate) > cacheTime;
|
||||
};
|
||||
@@ -267,20 +192,12 @@ export default function ExploreScreen() {
|
||||
loadingRef.current.health = true;
|
||||
console.log('=== 开始HealthKit初始化流程 ===');
|
||||
|
||||
const ok = await ensureHealthPermissions();
|
||||
if (!ok) {
|
||||
const errorMsg = '无法获取健康权限,请确保在真实iOS设备上运行并授权应用访问健康数据';
|
||||
console.warn(errorMsg);
|
||||
return;
|
||||
}
|
||||
|
||||
latestRequestKeyRef.current = requestKey;
|
||||
|
||||
console.log('权限获取成功,开始获取健康数据...', derivedDate);
|
||||
const data = await fetchHealthDataForDate(derivedDate);
|
||||
|
||||
console.log('设置UI状态:', data);
|
||||
console.log('HRV数据详细信息:', data.hrv, typeof data.hrv);
|
||||
|
||||
// 仅当该请求仍是最新时,才应用结果
|
||||
if (latestRequestKeyRef.current === requestKey) {
|
||||
@@ -290,12 +207,7 @@ export default function ExploreScreen() {
|
||||
dispatch(setHealthData({
|
||||
date: dateString,
|
||||
data: {
|
||||
steps: data.steps,
|
||||
activeCalories: data.activeEnergyBurned,
|
||||
basalEnergyBurned: data.basalEnergyBurned,
|
||||
sleepDuration: data.sleepDuration,
|
||||
hrv: data.hrv,
|
||||
oxygenSaturation: data.oxygenSaturation,
|
||||
heartRate: data.heartRate,
|
||||
activeEnergyBurned: data.activeEnergyBurned,
|
||||
activeCaloriesGoal: data.activeCaloriesGoal,
|
||||
@@ -303,12 +215,9 @@ export default function ExploreScreen() {
|
||||
exerciseMinutesGoal: data.exerciseMinutesGoal,
|
||||
standHours: data.standHours,
|
||||
standHoursGoal: data.standHoursGoal,
|
||||
hourlySteps: data.hourlySteps,
|
||||
}
|
||||
}));
|
||||
|
||||
// 更新HRV数据时间
|
||||
setHrvUpdateTime(new Date());
|
||||
setAnimToken((t) => t + 1);
|
||||
|
||||
// 更新缓存时间戳
|
||||
@@ -326,45 +235,6 @@ export default function ExploreScreen() {
|
||||
};
|
||||
|
||||
// 加载营养数据
|
||||
const loadNutritionData = async (targetDate?: Date, forceRefresh = false) => {
|
||||
if (!isLoggedIn) return;
|
||||
|
||||
// 确定要查询的日期
|
||||
let derivedDate: Date;
|
||||
if (targetDate) {
|
||||
derivedDate = targetDate;
|
||||
} else {
|
||||
derivedDate = currentSelectedDate;
|
||||
}
|
||||
|
||||
const requestKey = getDateKey(derivedDate);
|
||||
|
||||
// 检查是否正在加载或不需要刷新
|
||||
if (loadingRef.current.nutrition) {
|
||||
console.log('营养数据正在加载中,跳过重复请求');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!forceRefresh && !shouldRefreshData(requestKey, 'nutrition')) {
|
||||
console.log('营养数据缓存未过期,跳过请求');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
loadingRef.current.nutrition = true;
|
||||
console.log('加载营养数据...', derivedDate);
|
||||
await dispatch(fetchDailyNutritionData(derivedDate));
|
||||
console.log('营养数据加载完成');
|
||||
|
||||
// 更新缓存时间戳
|
||||
updateDataTimestamp(requestKey, 'nutrition');
|
||||
|
||||
} catch (error) {
|
||||
console.error('营养数据加载失败:', error);
|
||||
} finally {
|
||||
loadingRef.current.nutrition = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 实际执行数据加载的方法
|
||||
const executeLoadAllData = React.useCallback((targetDate?: Date, forceRefresh = false) => {
|
||||
@@ -373,7 +243,6 @@ export default function ExploreScreen() {
|
||||
console.log('执行数据加载,日期:', dateToUse, '强制刷新:', forceRefresh);
|
||||
loadHealthData(dateToUse, forceRefresh);
|
||||
if (isLoggedIn) {
|
||||
loadNutritionData(dateToUse, forceRefresh);
|
||||
loadMoodData(dateToUse, forceRefresh);
|
||||
// 加载喝水数据(只加载今日数据用于后台检查)
|
||||
const isToday = dayjs(dateToUse).isSame(dayjs(), 'day');
|
||||
@@ -401,14 +270,10 @@ export default function ExploreScreen() {
|
||||
}
|
||||
}, [executeLoadAllData, debouncedLoadAllData]);
|
||||
|
||||
// 页面聚焦时的数据加载逻辑
|
||||
// useFocusEffect(
|
||||
// React.useCallback(() => {
|
||||
// // 页面聚焦时加载数据,使用缓存机制避免频繁请求
|
||||
// console.log('页面聚焦,检查是否需要刷新数据...');
|
||||
// loadAllData(currentSelectedDate);
|
||||
// }, [loadAllData, currentSelectedDate])
|
||||
// );
|
||||
useEffect(() => {
|
||||
loadAllData(currentSelectedDate);
|
||||
}, [])
|
||||
|
||||
|
||||
// AppState 监听:应用从后台返回前台时的处理
|
||||
useEffect(() => {
|
||||
@@ -468,7 +333,7 @@ export default function ExploreScreen() {
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={{
|
||||
paddingTop: insets.top,
|
||||
paddingBottom: bottomPadding,
|
||||
paddingBottom: 60,
|
||||
paddingHorizontal: 20
|
||||
}}
|
||||
showsVerticalScrollIndicator={false}
|
||||
@@ -478,7 +343,7 @@ export default function ExploreScreen() {
|
||||
<View style={styles.headerContent}>
|
||||
{/* 左边logo */}
|
||||
<Image
|
||||
source={require('@/assets/images/Sealife.jpeg')}
|
||||
source={require('@/assets/icon.icon/Assets/icon-1756312748268.png')}
|
||||
style={styles.logoImage}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
@@ -495,7 +360,7 @@ export default function ExploreScreen() {
|
||||
style={styles.debugButton}
|
||||
onPress={async () => {
|
||||
console.log('🔧 手动触发后台任务测试...');
|
||||
await backgroundTaskManager.triggerTaskForTesting();
|
||||
await BackgroundTaskManager.getInstance().triggerTaskForTesting();
|
||||
}}
|
||||
>
|
||||
<Text style={styles.debugButtonText}>🔧</Text>
|
||||
@@ -529,17 +394,8 @@ export default function ExploreScreen() {
|
||||
|
||||
{/* 营养摄入雷达图卡片 */}
|
||||
<NutritionRadarCard
|
||||
nutritionSummary={nutritionSummary}
|
||||
nutritionGoals={nutritionGoals}
|
||||
burnedCalories={(basalMetabolism || 0) + (activeCalories || 0)}
|
||||
basalMetabolism={basalMetabolism || 0}
|
||||
activeCalories={activeCalories || 0}
|
||||
selectedDate={currentSelectedDate}
|
||||
resetToken={animToken}
|
||||
onMealPress={(mealType: 'breakfast' | 'lunch' | 'dinner' | 'snack') => {
|
||||
console.log('选择餐次:', mealType);
|
||||
// 这里可以导航到营养记录页面
|
||||
pushIfAuthedElseLogin('/nutrition/records');
|
||||
}}
|
||||
/>
|
||||
|
||||
<WeightHistoryCard />
|
||||
@@ -549,7 +405,7 @@ export default function ExploreScreen() {
|
||||
{/* 左列 */}
|
||||
<View style={styles.masonryColumn}>
|
||||
{/* 心情卡片 */}
|
||||
<FloatingCard style={styles.masonryCard} delay={1500}>
|
||||
<FloatingCard style={styles.masonryCard}>
|
||||
<MoodCard
|
||||
moodCheckin={currentMoodCheckin}
|
||||
onPress={() => pushIfAuthedElseLogin('/mood/calendar')}
|
||||
@@ -559,21 +415,17 @@ export default function ExploreScreen() {
|
||||
|
||||
<FloatingCard style={styles.masonryCard}>
|
||||
<StepsCard
|
||||
stepCount={stepCount}
|
||||
curDate={currentSelectedDate}
|
||||
stepGoal={stepGoal}
|
||||
hourlySteps={hourlySteps}
|
||||
style={styles.stepsCardOverride}
|
||||
onPress={() => pushIfAuthedElseLogin('/steps/detail')}
|
||||
/>
|
||||
</FloatingCard>
|
||||
|
||||
|
||||
|
||||
<FloatingCard style={styles.masonryCard} delay={0}>
|
||||
<FloatingCard style={styles.masonryCard}>
|
||||
<StressMeter
|
||||
value={hrvValue}
|
||||
updateTime={hrvUpdateTime}
|
||||
hrvValue={hrvValue}
|
||||
curDate={currentSelectedDate}
|
||||
/>
|
||||
</FloatingCard>
|
||||
|
||||
@@ -588,27 +440,21 @@ export default function ExploreScreen() {
|
||||
|
||||
<FloatingCard style={styles.masonryCard}>
|
||||
<SleepCard
|
||||
sleepDuration={sleepDuration}
|
||||
onPress={() => pushIfAuthedElseLogin('/sleep-detail')}
|
||||
selectedDate={currentSelectedDate}
|
||||
/>
|
||||
</FloatingCard>
|
||||
</View>
|
||||
|
||||
{/* 右列 */}
|
||||
<View style={styles.masonryColumn}>
|
||||
<FloatingCard style={styles.masonryCard} delay={250}>
|
||||
<FloatingCard style={styles.masonryCard}>
|
||||
<FitnessRingsCard
|
||||
activeCalories={fitnessRingsData.activeCalories}
|
||||
activeCaloriesGoal={fitnessRingsData.activeCaloriesGoal}
|
||||
exerciseMinutes={fitnessRingsData.exerciseMinutes}
|
||||
exerciseMinutesGoal={fitnessRingsData.exerciseMinutesGoal}
|
||||
standHours={fitnessRingsData.standHours}
|
||||
standHoursGoal={fitnessRingsData.standHoursGoal}
|
||||
selectedDate={currentSelectedDate}
|
||||
resetToken={animToken}
|
||||
/>
|
||||
</FloatingCard>
|
||||
{/* 饮水记录卡片 */}
|
||||
<FloatingCard style={styles.masonryCard} delay={500}>
|
||||
<FloatingCard style={styles.masonryCard}>
|
||||
<WaterIntakeCard
|
||||
selectedDate={currentSelectedDateString}
|
||||
style={styles.waterCardOverride}
|
||||
@@ -617,26 +463,26 @@ export default function ExploreScreen() {
|
||||
|
||||
|
||||
{/* 基础代谢卡片 */}
|
||||
<FloatingCard style={styles.masonryCard} delay={1250}>
|
||||
<FloatingCard style={styles.masonryCard}>
|
||||
<BasalMetabolismCard
|
||||
value={basalMetabolism}
|
||||
resetToken={animToken}
|
||||
selectedDate={currentSelectedDate}
|
||||
style={styles.basalMetabolismCardOverride}
|
||||
/>
|
||||
</FloatingCard>
|
||||
|
||||
{/* 血氧饱和度卡片 */}
|
||||
<FloatingCard style={styles.masonryCard} delay={1750}>
|
||||
<FloatingCard style={styles.masonryCard}>
|
||||
<OxygenSaturationCard
|
||||
resetToken={animToken}
|
||||
style={styles.basalMetabolismCardOverride}
|
||||
oxygenSaturation={oxygenSaturation}
|
||||
/>
|
||||
</FloatingCard>
|
||||
|
||||
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 围度数据卡片 - 占满底部一行 */}
|
||||
<CircumferenceCard style={styles.circumferenceCard} />
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
@@ -688,8 +534,8 @@ const styles = StyleSheet.create({
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
logoImage: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 20,
|
||||
},
|
||||
headerTextContainer: {
|
||||
@@ -698,7 +544,7 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
fontWeight: '700',
|
||||
color: '#192126',
|
||||
},
|
||||
debugButtonsContainer: {
|
||||
@@ -938,7 +784,6 @@ const styles = StyleSheet.create({
|
||||
marginBottom: 16,
|
||||
},
|
||||
masonryContainer: {
|
||||
marginBottom: 16,
|
||||
flexDirection: 'row',
|
||||
gap: 16,
|
||||
marginTop: 6,
|
||||
@@ -1001,6 +846,10 @@ const styles = StyleSheet.create({
|
||||
top: 0,
|
||||
padding: 4,
|
||||
},
|
||||
circumferenceCard: {
|
||||
marginBottom: 36,
|
||||
marginTop: 10,
|
||||
},
|
||||
|
||||
|
||||
});
|
||||
|
||||
104
app/_layout.tsx
@@ -9,46 +9,83 @@ import PrivacyConsentModal from '@/components/PrivacyConsentModal';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { useQuickActions } from '@/hooks/useQuickActions';
|
||||
import { clearAiCoachSessionCache } from '@/services/aiCoachSession';
|
||||
import { backgroundTaskManager } from '@/services/backgroundTaskManager';
|
||||
import { notificationService } from '@/services/notifications';
|
||||
import { setupQuickActions } from '@/services/quickActions';
|
||||
import { initializeWaterRecordBridge } from '@/services/waterRecordBridge';
|
||||
import { WaterRecordSource } from '@/services/waterRecords';
|
||||
import { store } from '@/store';
|
||||
import { rehydrateUser, setPrivacyAgreed } from '@/store/userSlice';
|
||||
import { fetchMyProfile, setPrivacyAgreed } from '@/store/userSlice';
|
||||
import { createWaterRecordAction } from '@/store/waterSlice';
|
||||
import { MoodNotificationHelpers, NutritionNotificationHelpers } from '@/utils/notificationHelpers';
|
||||
import { ensureHealthPermissions, initializeHealthPermissions } from '@/utils/health';
|
||||
import { DailySummaryNotificationHelpers, MoodNotificationHelpers, NutritionNotificationHelpers } from '@/utils/notificationHelpers';
|
||||
import { clearPendingWaterRecords, syncPendingWidgetChanges } from '@/utils/widgetDataSync';
|
||||
import React from 'react';
|
||||
import RNExitApp from 'react-native-exit-app';
|
||||
|
||||
import { DialogProvider } from '@/components/ui/DialogProvider';
|
||||
import { ToastProvider } from '@/contexts/ToastContext';
|
||||
import { STORAGE_KEYS } from '@/services/api';
|
||||
import { BackgroundTaskManager } from '@/services/backgroundTaskManager';
|
||||
import AsyncStorage from '@/utils/kvStore';
|
||||
import { Provider } from 'react-redux';
|
||||
|
||||
|
||||
function Bootstrapper({ children }: { children: React.ReactNode }) {
|
||||
const dispatch = useAppDispatch();
|
||||
const { privacyAgreed, profile } = useAppSelector((state) => state.user);
|
||||
const { profile } = useAppSelector((state) => state.user);
|
||||
const [showPrivacyModal, setShowPrivacyModal] = React.useState(false);
|
||||
const [userDataLoaded, setUserDataLoaded] = React.useState(false);
|
||||
|
||||
// 初始化快捷动作处理
|
||||
useQuickActions();
|
||||
|
||||
React.useEffect(() => {
|
||||
const loadUserData = async () => {
|
||||
await dispatch(rehydrateUser());
|
||||
setUserDataLoaded(true);
|
||||
// 数据已经在启动界面预加载,这里只需要快速同步到 Redux 状态
|
||||
await dispatch(fetchMyProfile());
|
||||
};
|
||||
|
||||
const initHealthPermissions = async () => {
|
||||
// 初始化 HealthKit 权限管理系统
|
||||
try {
|
||||
console.log('初始化 HealthKit 权限管理系统...');
|
||||
initializeHealthPermissions();
|
||||
|
||||
// 延迟请求权限,避免应用启动时弹窗
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await ensureHealthPermissions();
|
||||
console.log('HealthKit 权限请求完成');
|
||||
} catch (error) {
|
||||
console.warn('HealthKit 权限请求失败,可能在模拟器上运行:', error);
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
console.log('HealthKit 权限管理初始化完成');
|
||||
} catch (error) {
|
||||
console.warn('HealthKit 权限管理初始化失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
const initializeNotifications = async () => {
|
||||
try {
|
||||
await BackgroundTaskManager.getInstance().initialize();
|
||||
// 初始化通知服务
|
||||
await notificationService.initialize();
|
||||
console.log('通知服务初始化成功');
|
||||
|
||||
// 初始化后台任务管理器
|
||||
await backgroundTaskManager.initialize();
|
||||
console.log('后台任务管理器初始化成功');
|
||||
// 注册午餐提醒(12:00)
|
||||
await NutritionNotificationHelpers.scheduleDailyLunchReminder(profile.name || '');
|
||||
console.log('午餐提醒已注册');
|
||||
|
||||
// 注册晚餐提醒(18:00)
|
||||
await NutritionNotificationHelpers.scheduleDailyDinnerReminder(profile.name || '');
|
||||
console.log('晚餐提醒已注册');
|
||||
|
||||
// 注册心情提醒(21:00)
|
||||
await MoodNotificationHelpers.scheduleDailyMoodReminder(profile.name || '');
|
||||
console.log('心情提醒已注册');
|
||||
|
||||
await DailySummaryNotificationHelpers.scheduleDailySummaryNotification(profile.name || '')
|
||||
|
||||
|
||||
// 初始化快捷动作
|
||||
await setupQuickActions();
|
||||
@@ -88,46 +125,22 @@ function Bootstrapper({ children }: { children: React.ReactNode }) {
|
||||
};
|
||||
|
||||
loadUserData();
|
||||
initHealthPermissions();
|
||||
initializeNotifications();
|
||||
// 冷启动时清空 AI 教练会话缓存
|
||||
clearAiCoachSessionCache();
|
||||
|
||||
}, [dispatch]);
|
||||
|
||||
React.useEffect(() => {
|
||||
// 当用户数据加载完成后,检查是否需要显示隐私同意弹窗
|
||||
if (userDataLoaded && !privacyAgreed) {
|
||||
setShowPrivacyModal(true);
|
||||
|
||||
const getPrivacyAgreed = async () => {
|
||||
const str = await AsyncStorage.getItem(STORAGE_KEYS.privacyAgreed)
|
||||
|
||||
setShowPrivacyModal(str !== 'true');
|
||||
}
|
||||
}, [userDataLoaded, privacyAgreed]);
|
||||
|
||||
// 当用户数据加载完成且用户名存在时,注册所有提醒
|
||||
React.useEffect(() => {
|
||||
const registerAllReminders = async () => {
|
||||
try {
|
||||
await notificationService.initialize();
|
||||
// 后台任务
|
||||
await backgroundTaskManager.initialize()
|
||||
// 注册午餐提醒(12:00)
|
||||
await NutritionNotificationHelpers.scheduleDailyLunchReminder(profile.name || '');
|
||||
console.log('午餐提醒已注册');
|
||||
|
||||
// 注册晚餐提醒(18:00)
|
||||
await NutritionNotificationHelpers.scheduleDailyDinnerReminder(profile.name || '');
|
||||
console.log('晚餐提醒已注册');
|
||||
|
||||
// 注册心情提醒(21:00)
|
||||
await MoodNotificationHelpers.scheduleDailyMoodReminder(profile.name || '');
|
||||
console.log('心情提醒已注册');
|
||||
|
||||
|
||||
console.log('喝水提醒后台任务已注册');
|
||||
} catch (error) {
|
||||
console.error('注册提醒失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
registerAllReminders();
|
||||
}, [userDataLoaded, profile?.name]);
|
||||
getPrivacyAgreed();
|
||||
}, []);
|
||||
|
||||
const handlePrivacyAgree = () => {
|
||||
dispatch(setPrivacyAgreed());
|
||||
@@ -135,7 +148,7 @@ function Bootstrapper({ children }: { children: React.ReactNode }) {
|
||||
};
|
||||
|
||||
const handlePrivacyDisagree = () => {
|
||||
RNExitApp.exitApp();
|
||||
// RNExitApp.exitApp();
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -180,6 +193,7 @@ export default function RootLayout() {
|
||||
<Stack.Screen name="legal/user-agreement" options={{ headerShown: true, title: '用户协议' }} />
|
||||
<Stack.Screen name="legal/privacy-policy" options={{ headerShown: true, title: '隐私政策' }} />
|
||||
<Stack.Screen name="article/[id]" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="water-detail" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="water-settings" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="+not-found" />
|
||||
</Stack>
|
||||
|
||||
@@ -12,7 +12,7 @@ import { PRIVACY_POLICY_URL, USER_AGREEMENT_URL } from '@/constants/Agree';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useAppDispatch } from '@/hooks/redux';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { login } from '@/store/userSlice';
|
||||
import { fetchMyProfile, login } from '@/store/userSlice';
|
||||
import Toast from 'react-native-toast-message';
|
||||
|
||||
export default function LoginScreen() {
|
||||
@@ -113,6 +113,9 @@ export default function LoginScreen() {
|
||||
}
|
||||
await dispatch(login({ appleIdentityToken: identityToken })).unwrap();
|
||||
|
||||
// 拉取用户信息
|
||||
await dispatch(fetchMyProfile())
|
||||
|
||||
Toast.show({
|
||||
text1: '登录成功',
|
||||
type: 'success',
|
||||
@@ -130,7 +133,9 @@ export default function LoginScreen() {
|
||||
router.back();
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err?.code === 'ERR_CANCELED') return;
|
||||
console.log('err.code', err.code);
|
||||
|
||||
if (err?.code === 'ERR_CANCELED' || err?.code === 'ERR_REQUEST_CANCELED') return;
|
||||
const message = err?.message || '登录失败,请稍后再试';
|
||||
Alert.alert('登录失败', message);
|
||||
} finally {
|
||||
@@ -223,8 +228,8 @@ export default function LoginScreen() {
|
||||
|
||||
<ScrollView contentContainerStyle={styles.content} showsVerticalScrollIndicator={false}>
|
||||
<View style={styles.headerWrap}>
|
||||
<ThemedText style={[styles.title, { color: color.text }]}>Sealife</ThemedText>
|
||||
<ThemedText style={[styles.subtitle, { color: color.textMuted }]}>欢迎登录Sealife</ThemedText>
|
||||
<ThemedText style={[styles.title, { color: color.text }]}>Out Live</ThemedText>
|
||||
<ThemedText style={[styles.subtitle, { color: color.textMuted }]}>欢迎登录Out Live</ThemedText>
|
||||
</View>
|
||||
|
||||
{/* Apple 登录 */}
|
||||
|
||||
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 {
|
||||
148
app/developer.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import { ROUTES } from '@/constants/Routes';
|
||||
import { useRouter } from 'expo-router';
|
||||
import React from 'react';
|
||||
import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
|
||||
export default function DeveloperScreen() {
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const developerItems = [
|
||||
{
|
||||
title: '日志',
|
||||
subtitle: '查看应用运行日志',
|
||||
icon: 'document-text-outline',
|
||||
onPress: () => router.push(ROUTES.DEVELOPER_LOGS),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { paddingTop: insets.top }]}>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity onPress={() => router.back()} style={styles.backButton}>
|
||||
<Ionicons name="chevron-back" size={24} color="#2C3E50" />
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.title}>开发者</Text>
|
||||
<View style={styles.placeholder} />
|
||||
</View>
|
||||
|
||||
<ScrollView style={styles.scrollView} showsVerticalScrollIndicator={false}>
|
||||
<View style={styles.content}>
|
||||
<View style={styles.cardContainer}>
|
||||
{developerItems.map((item, index) => (
|
||||
<TouchableOpacity
|
||||
key={index}
|
||||
style={[
|
||||
styles.menuItem,
|
||||
index === developerItems.length - 1 && { borderBottomWidth: 0 }
|
||||
]}
|
||||
onPress={item.onPress}
|
||||
>
|
||||
<View style={styles.menuItemLeft}>
|
||||
<View style={styles.iconContainer}>
|
||||
<Ionicons
|
||||
name={item.icon as any}
|
||||
size={20}
|
||||
color="#9370DB"
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.textContainer}>
|
||||
<Text style={styles.menuItemTitle}>{item.title}</Text>
|
||||
<Text style={styles.menuItemSubtitle}>{item.subtitle}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Ionicons name="chevron-forward" size={20} color="#CCCCCC" />
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#F8F9FA',
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#E5E7EB',
|
||||
},
|
||||
backButton: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
title: {
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
color: '#2C3E50',
|
||||
},
|
||||
placeholder: {
|
||||
width: 40,
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
},
|
||||
content: {
|
||||
padding: 16,
|
||||
},
|
||||
cardContainer: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: 12,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 4,
|
||||
elevation: 2,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
menuItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingVertical: 16,
|
||||
paddingHorizontal: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#F1F3F4',
|
||||
},
|
||||
menuItemLeft: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
},
|
||||
iconContainer: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 8,
|
||||
backgroundColor: 'rgba(147, 112, 219, 0.1)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: 12,
|
||||
},
|
||||
textContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
menuItemTitle: {
|
||||
fontSize: 16,
|
||||
color: '#2C3E50',
|
||||
fontWeight: '600',
|
||||
marginBottom: 2,
|
||||
},
|
||||
menuItemSubtitle: {
|
||||
fontSize: 13,
|
||||
color: '#6B7280',
|
||||
},
|
||||
});
|
||||
312
app/developer/logs.tsx
Normal file
@@ -0,0 +1,312 @@
|
||||
import { log, logger, LogEntry } from '@/utils/logger';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useRouter } from 'expo-router';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
FlatList,
|
||||
RefreshControl,
|
||||
Share,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
export default function LogsScreen() {
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
const loadLogs = async () => {
|
||||
try {
|
||||
const allLogs = await logger.getAllLogs();
|
||||
// 按时间倒序排列,最新的在前面
|
||||
setLogs(allLogs.reverse());
|
||||
} catch (error) {
|
||||
log.error('加载日志失败', error);
|
||||
}
|
||||
};
|
||||
|
||||
const onRefresh = async () => {
|
||||
setRefreshing(true);
|
||||
await loadLogs();
|
||||
setRefreshing(false);
|
||||
};
|
||||
|
||||
const handleClearLogs = () => {
|
||||
Alert.alert(
|
||||
'清除日志',
|
||||
'确定要清除所有日志吗?此操作不可恢复。',
|
||||
[
|
||||
{ text: '取消', style: 'cancel' },
|
||||
{
|
||||
text: '确定',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
await logger.clearLogs();
|
||||
setLogs([]);
|
||||
log.info('日志已清除');
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
const handleExportLogs = async () => {
|
||||
try {
|
||||
const exportData = await logger.exportLogs();
|
||||
await Share.share({
|
||||
message: exportData,
|
||||
title: '应用日志导出',
|
||||
});
|
||||
} catch (error) {
|
||||
log.error('导出日志失败', error);
|
||||
Alert.alert('错误', '导出日志失败');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadLogs();
|
||||
// 添加测试日志
|
||||
log.info('进入日志页面');
|
||||
}, []);
|
||||
|
||||
const formatTimestamp = (timestamp: number) => {
|
||||
const date = new Date(timestamp);
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
const getLevelColor = (level: string) => {
|
||||
switch (level) {
|
||||
case 'ERROR':
|
||||
return '#FF4444';
|
||||
case 'WARN':
|
||||
return '#FF8800';
|
||||
case 'INFO':
|
||||
return '#0088FF';
|
||||
case 'DEBUG':
|
||||
return '#888888';
|
||||
default:
|
||||
return '#333333';
|
||||
}
|
||||
};
|
||||
|
||||
const getLevelIcon = (level: string) => {
|
||||
switch (level) {
|
||||
case 'ERROR':
|
||||
return 'close-circle';
|
||||
case 'WARN':
|
||||
return 'warning';
|
||||
case 'INFO':
|
||||
return 'information-circle';
|
||||
case 'DEBUG':
|
||||
return 'bug';
|
||||
default:
|
||||
return 'ellipse';
|
||||
}
|
||||
};
|
||||
|
||||
const renderLogItem = ({ item }: { item: LogEntry }) => (
|
||||
<View style={styles.logItem}>
|
||||
<View style={styles.logHeader}>
|
||||
<View style={styles.logLevelContainer}>
|
||||
<Ionicons
|
||||
name={getLevelIcon(item.level) as any}
|
||||
size={16}
|
||||
color={getLevelColor(item.level)}
|
||||
style={styles.logIcon}
|
||||
/>
|
||||
<Text style={[styles.logLevel, { color: getLevelColor(item.level) }]}>
|
||||
{item.level}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={styles.timestamp}>{formatTimestamp(item.timestamp)}</Text>
|
||||
</View>
|
||||
<Text style={styles.logMessage}>{item.message}</Text>
|
||||
{item.data && (
|
||||
<Text style={styles.logData}>{JSON.stringify(item.data, null, 2)}</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { paddingTop: insets.top }]}>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity onPress={() => router.back()} style={styles.backButton}>
|
||||
<Ionicons name="chevron-back" size={24} color="#2C3E50" />
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.title}>日志 ({logs.length})</Text>
|
||||
<View style={styles.headerActions}>
|
||||
<TouchableOpacity onPress={handleExportLogs} style={styles.actionButton}>
|
||||
<Ionicons name="share-outline" size={20} color="#9370DB" />
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={handleClearLogs} style={styles.actionButton}>
|
||||
<Ionicons name="trash-outline" size={20} color="#FF4444" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Logs List */}
|
||||
<FlatList
|
||||
data={logs}
|
||||
renderItem={renderLogItem}
|
||||
keyExtractor={(item) => item.id}
|
||||
style={styles.logsList}
|
||||
contentContainerStyle={styles.logsContent}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
|
||||
}
|
||||
ListEmptyComponent={
|
||||
<View style={styles.emptyContainer}>
|
||||
<Ionicons name="document-text-outline" size={48} color="#CCCCCC" />
|
||||
<Text style={styles.emptyText}>暂无日志</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.testButton}
|
||||
onPress={() => {
|
||||
log.debug('测试调试日志');
|
||||
log.info('测试信息日志');
|
||||
log.warn('测试警告日志');
|
||||
log.error('测试错误日志');
|
||||
setTimeout(loadLogs, 100);
|
||||
}}
|
||||
>
|
||||
<Text style={styles.testButtonText}>生成测试日志</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#F8F9FA',
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#E5E7EB',
|
||||
},
|
||||
backButton: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
title: {
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
color: '#2C3E50',
|
||||
flex: 1,
|
||||
marginLeft: 8,
|
||||
},
|
||||
headerActions: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
actionButton: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginLeft: 8,
|
||||
},
|
||||
logsList: {
|
||||
flex: 1,
|
||||
},
|
||||
logsContent: {
|
||||
padding: 16,
|
||||
},
|
||||
logItem: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
padding: 12,
|
||||
marginBottom: 8,
|
||||
borderRadius: 8,
|
||||
borderLeftWidth: 3,
|
||||
borderLeftColor: '#E5E7EB',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 2,
|
||||
elevation: 1,
|
||||
},
|
||||
logHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 6,
|
||||
},
|
||||
logLevelContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
logIcon: {
|
||||
marginRight: 4,
|
||||
},
|
||||
logLevel: {
|
||||
fontSize: 12,
|
||||
fontWeight: 'bold',
|
||||
fontFamily: 'monospace',
|
||||
},
|
||||
timestamp: {
|
||||
fontSize: 11,
|
||||
color: '#9CA3AF',
|
||||
fontFamily: 'monospace',
|
||||
},
|
||||
logMessage: {
|
||||
fontSize: 14,
|
||||
color: '#374151',
|
||||
lineHeight: 20,
|
||||
fontFamily: 'monospace',
|
||||
},
|
||||
logData: {
|
||||
fontSize: 12,
|
||||
color: '#6B7280',
|
||||
marginTop: 8,
|
||||
padding: 8,
|
||||
backgroundColor: '#F3F4F6',
|
||||
borderRadius: 4,
|
||||
fontFamily: 'monospace',
|
||||
},
|
||||
emptyContainer: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 48,
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: 16,
|
||||
color: '#9CA3AF',
|
||||
marginTop: 12,
|
||||
marginBottom: 24,
|
||||
},
|
||||
testButton: {
|
||||
backgroundColor: '#9370DB',
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 8,
|
||||
},
|
||||
testButtonText: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ThemedView } from '@/components/ThemedView';
|
||||
import { ROUTES } from '@/constants/Routes';
|
||||
import { useThemeColor } from '@/hooks/useThemeColor';
|
||||
import { preloadUserData } from '@/store/userSlice';
|
||||
import { router } from 'expo-router';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { ActivityIndicator, View } from 'react-native';
|
||||
@@ -18,6 +19,11 @@ export default function SplashScreen() {
|
||||
|
||||
const checkOnboardingStatus = async () => {
|
||||
try {
|
||||
// 先预加载用户数据,这样进入应用时就有正确的 token 状态
|
||||
console.log('开始预加载用户数据...');
|
||||
await preloadUserData();
|
||||
console.log('用户数据预加载完成');
|
||||
|
||||
// const onboardingCompleted = await AsyncStorage.getItem(ONBOARDING_COMPLETED_KEY);
|
||||
|
||||
// if (onboardingCompleted === 'true') {
|
||||
@@ -28,11 +34,9 @@ export default function SplashScreen() {
|
||||
// setIsLoading(false);
|
||||
router.replace(ROUTES.TAB_STATISTICS);
|
||||
} catch (error) {
|
||||
console.error('检查引导状态失败:', error);
|
||||
// 如果出现错误,默认显示引导页面
|
||||
// setTimeout(() => {
|
||||
// router.replace('/onboarding');
|
||||
// }, 1000);
|
||||
console.error('检查引导状态或预加载用户数据失败:', error);
|
||||
// 如果出现错误,仍然进入应用,但可能会有状态更新
|
||||
router.replace(ROUTES.TAB_STATISTICS);
|
||||
}
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useCosUpload } from '@/hooks/useCosUpload';
|
||||
import { fetchMyProfile, updateUserProfile } from '@/store/userSlice';
|
||||
import { fetchMaximumHeartRate } from '@/utils/health';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import AsyncStorage from '@/utils/kvStore';
|
||||
import DateTimePicker from '@react-native-community/datetimepicker';
|
||||
import { Picker } from '@react-native-picker/picker';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
@@ -40,7 +40,6 @@ interface UserProfile {
|
||||
weight?: number; // kg
|
||||
height?: number; // cm
|
||||
avatarUri?: string | null;
|
||||
avatarBase64?: string | null; // 兼容旧逻辑(不再上报)
|
||||
activityLevel?: number; // 活动水平 1-4
|
||||
maxHeartRate?: number; // 最大心率
|
||||
}
|
||||
@@ -270,7 +269,13 @@ export default function EditProfileScreen() {
|
||||
{ prefix: 'avatars/', userId }
|
||||
);
|
||||
|
||||
setProfile((p) => ({ ...p, avatarUri: url, avatarBase64: null }));
|
||||
console.log('url', url);
|
||||
|
||||
|
||||
setProfile((p) => ({ ...p, avatarUri: url }));
|
||||
// 保存更新后的 profile
|
||||
await handleSaveWithProfile({ ...profile, avatarUri: url });
|
||||
Alert.alert('成功', '头像更新成功');
|
||||
} catch (e) {
|
||||
console.warn('上传头像失败', e);
|
||||
Alert.alert('上传失败', '头像上传失败,请重试');
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import AsyncStorage from '@/utils/kvStore';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { useRouter } from 'expo-router';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
@@ -1,37 +1,43 @@
|
||||
import { DateSelector } from '@/components/DateSelector';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { selectHealthDataByDate, setHealthData } from '@/store/healthSlice';
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date';
|
||||
import { ensureHealthPermissions, fetchHealthDataForDate } from '@/utils/health';
|
||||
import { getTestHealthData } from '@/utils/mockHealthData';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { fetchHourlyStepSamples, fetchStepCount, HourlyStepData } from '@/utils/health';
|
||||
import { logger } from '@/utils/logger';
|
||||
import dayjs from 'dayjs';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useLocalSearchParams } from 'expo-router';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Animated,
|
||||
SafeAreaView,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View
|
||||
} from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
export default function StepsDetailScreen() {
|
||||
const router = useRouter();
|
||||
const dispatch = useAppDispatch();
|
||||
const insets = useSafeAreaInsets();
|
||||
// 获取路由参数
|
||||
const { date } = useLocalSearchParams<{ date?: string }>();
|
||||
|
||||
// 开发调试:设置为true来使用mock数据
|
||||
const useMockData = __DEV__;
|
||||
// 根据传入的日期参数计算初始选中索引
|
||||
const getInitialSelectedIndex = () => {
|
||||
if (date) {
|
||||
const targetDate = dayjs(date);
|
||||
const days = getMonthDaysZh();
|
||||
const foundIndex = days.findIndex(day =>
|
||||
day.date && dayjs(day.date.toDate()).isSame(targetDate, 'day')
|
||||
);
|
||||
return foundIndex >= 0 ? foundIndex : getTodayIndexInMonth();
|
||||
}
|
||||
return getTodayIndexInMonth();
|
||||
};
|
||||
|
||||
// 日期选择相关状态
|
||||
const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth());
|
||||
const [selectedIndex, setSelectedIndex] = useState(getInitialSelectedIndex());
|
||||
|
||||
// 数据加载状态
|
||||
// 步数数据状态
|
||||
const [stepCount, setStepCount] = useState(0);
|
||||
const [hourlySteps, setHourSteps] = useState<HourlyStepData[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// 获取当前选中日期
|
||||
@@ -40,17 +46,25 @@ export default function StepsDetailScreen() {
|
||||
return days[selectedIndex]?.date?.toDate() ?? new Date();
|
||||
}, [selectedIndex]);
|
||||
|
||||
const currentSelectedDateString = useMemo(() => {
|
||||
return dayjs(currentSelectedDate).format('YYYY-MM-DD');
|
||||
}, [currentSelectedDate]);
|
||||
// 获取步数数据的函数,参考 StepsCard 的实现
|
||||
const getStepData = async (date: Date) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
logger.info('获取步数详情数据...');
|
||||
const [steps, hourly] = await Promise.all([
|
||||
fetchStepCount(date),
|
||||
fetchHourlyStepSamples(date)
|
||||
]);
|
||||
|
||||
// 从 Redux 获取指定日期的健康数据
|
||||
const healthData = useAppSelector(selectHealthDataByDate(currentSelectedDateString));
|
||||
setStepCount(steps);
|
||||
setHourSteps(hourly);
|
||||
|
||||
// 解构健康数据(支持mock数据)
|
||||
const mockData = useMockData ? getTestHealthData('mock') : null;
|
||||
const stepCount: number | null = useMockData ? (mockData?.steps ?? null) : (healthData?.steps ?? null);
|
||||
const hourlySteps = useMockData ? (mockData?.hourlySteps ?? []) : (healthData?.hourlySteps ?? []);
|
||||
} catch (error) {
|
||||
logger.error('获取步数详情数据失败:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// 为每个柱体创建独立的动画值
|
||||
@@ -113,50 +127,24 @@ export default function StepsDetailScreen() {
|
||||
}
|
||||
}, [chartData, animatedValues]);
|
||||
|
||||
// 加载健康数据
|
||||
const loadHealthData = async (targetDate: Date) => {
|
||||
if (useMockData) return; // 如果使用mock数据,不需要加载
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
console.log('加载步数详情数据...', targetDate);
|
||||
|
||||
const ok = await ensureHealthPermissions();
|
||||
if (!ok) {
|
||||
console.warn('无法获取健康权限');
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await fetchHealthDataForDate(targetDate);
|
||||
|
||||
console.log('data', data);
|
||||
|
||||
const dateString = dayjs(targetDate).format('YYYY-MM-DD');
|
||||
|
||||
// 使用 Redux 存储健康数据
|
||||
dispatch(setHealthData({
|
||||
date: dateString,
|
||||
data: data
|
||||
}));
|
||||
|
||||
console.log('步数详情数据加载完成');
|
||||
} catch (error) {
|
||||
console.error('加载步数详情数据失败:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 日期选择处理
|
||||
const onSelectDate = (index: number, date: Date) => {
|
||||
setSelectedIndex(index);
|
||||
loadHealthData(date);
|
||||
getStepData(date);
|
||||
};
|
||||
|
||||
// 页面初始化时加载当前日期数据
|
||||
// 当路由参数变化时更新选中索引
|
||||
useEffect(() => {
|
||||
loadHealthData(currentSelectedDate);
|
||||
}, []);
|
||||
const newIndex = getInitialSelectedIndex();
|
||||
setSelectedIndex(newIndex);
|
||||
}, [date]);
|
||||
|
||||
// 当选中日期变化时获取数据
|
||||
useEffect(() => {
|
||||
if (currentSelectedDate) {
|
||||
getStepData(currentSelectedDate);
|
||||
}
|
||||
}, [currentSelectedDate]);
|
||||
|
||||
// 计算总步数和平均步数
|
||||
const totalSteps = stepCount || 0;
|
||||
@@ -219,18 +207,9 @@ export default function StepsDetailScreen() {
|
||||
end={{ x: 1, y: 1 }}
|
||||
/>
|
||||
|
||||
<SafeAreaView style={styles.safeArea}>
|
||||
{/* 顶部导航栏 */}
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity
|
||||
style={styles.backButton}
|
||||
onPress={() => router.back()}
|
||||
>
|
||||
<Ionicons name="chevron-back" size={24} color="#192126" />
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.headerTitle}>步数详情</Text>
|
||||
<View style={styles.headerRight} />
|
||||
</View>
|
||||
<HeaderBar
|
||||
title="步数详情"
|
||||
/>
|
||||
|
||||
<ScrollView
|
||||
style={styles.scrollView}
|
||||
@@ -434,7 +413,6 @@ export default function StepsDetailScreen() {
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -450,9 +428,6 @@ const styles = StyleSheet.create({
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
},
|
||||
safeArea: {
|
||||
flex: 1,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
|
||||
910
app/voice-record.tsx
Normal file
@@ -0,0 +1,910 @@
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useAppDispatch } from '@/hooks/redux';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { analyzeFoodFromText } from '@/services/foodRecognition';
|
||||
import { saveRecognitionResult, setError, setLoading } from '@/store/foodRecognitionSlice';
|
||||
import { triggerHapticFeedback } from '@/utils/haptics';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import Voice from '@react-native-voice/voice';
|
||||
import { BlurView } from 'expo-blur';
|
||||
import { router, useLocalSearchParams } from 'expo-router';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Animated,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View
|
||||
} from 'react-native';
|
||||
|
||||
type VoiceRecordState = 'idle' | 'listening' | 'processing' | 'result' | 'analyzing';
|
||||
|
||||
export default function VoiceRecordScreen() {
|
||||
const theme = useColorScheme() ?? 'light';
|
||||
const colorTokens = Colors[theme];
|
||||
const { mealType = 'dinner' } = useLocalSearchParams<{ mealType?: string }>();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
// 状态管理
|
||||
const [recordState, setRecordState] = useState<VoiceRecordState>('idle');
|
||||
const [recognizedText, setRecognizedText] = useState('');
|
||||
const [isListening, setIsListening] = useState(false);
|
||||
const [analysisProgress, setAnalysisProgress] = useState(0);
|
||||
|
||||
// 用于跟踪组件是否已卸载,防止在组件卸载后设置状态
|
||||
const isMountedRef = useRef(true);
|
||||
const progressIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
// 动画相关
|
||||
const scaleAnimation = useRef(new Animated.Value(1)).current;
|
||||
const pulseAnimation = useRef(new Animated.Value(1)).current;
|
||||
const waveAnimation = useRef(new Animated.Value(0)).current;
|
||||
const glowAnimation = useRef(new Animated.Value(0)).current;
|
||||
const progressAnimation = useRef(new Animated.Value(0)).current;
|
||||
|
||||
// 启动脉动动画
|
||||
const startPulseAnimation = () => {
|
||||
Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(pulseAnimation, {
|
||||
toValue: 1.2,
|
||||
duration: 800,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(pulseAnimation, {
|
||||
toValue: 1,
|
||||
duration: 800,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
])
|
||||
).start();
|
||||
};
|
||||
|
||||
// 启动波浪动画
|
||||
const startWaveAnimation = () => {
|
||||
Animated.loop(
|
||||
Animated.timing(waveAnimation, {
|
||||
toValue: 1,
|
||||
duration: 1500,
|
||||
useNativeDriver: false,
|
||||
})
|
||||
).start();
|
||||
};
|
||||
|
||||
// 启动科幻分析动画
|
||||
const startAnalysisAnimation = () => {
|
||||
// 光环动画
|
||||
Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(glowAnimation, {
|
||||
toValue: 1,
|
||||
duration: 2000,
|
||||
useNativeDriver: false,
|
||||
}),
|
||||
Animated.timing(glowAnimation, {
|
||||
toValue: 0,
|
||||
duration: 2000,
|
||||
useNativeDriver: false,
|
||||
}),
|
||||
])
|
||||
).start();
|
||||
|
||||
// 进度条动画
|
||||
Animated.timing(progressAnimation, {
|
||||
toValue: 1,
|
||||
duration: 8000, // 8秒完成
|
||||
useNativeDriver: false,
|
||||
}).start();
|
||||
};
|
||||
|
||||
// 停止所有动画
|
||||
const stopAnimations = () => {
|
||||
pulseAnimation.stopAnimation();
|
||||
waveAnimation.stopAnimation();
|
||||
glowAnimation.stopAnimation();
|
||||
progressAnimation.stopAnimation();
|
||||
scaleAnimation.setValue(1);
|
||||
pulseAnimation.setValue(1);
|
||||
waveAnimation.setValue(0);
|
||||
glowAnimation.setValue(0);
|
||||
progressAnimation.setValue(0);
|
||||
};
|
||||
|
||||
// 语音识别回调 - 使用 useCallback 避免每次渲染重新创建
|
||||
const onSpeechStart = useCallback(() => {
|
||||
console.log('语音开始');
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
setIsListening(true);
|
||||
setRecordState('listening');
|
||||
startPulseAnimation();
|
||||
startWaveAnimation();
|
||||
}, []);
|
||||
|
||||
const onSpeechRecognized = useCallback(() => {
|
||||
console.log('语音识别中...');
|
||||
}, []);
|
||||
|
||||
const onSpeechEnd = useCallback(() => {
|
||||
console.log('语音结束');
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
setIsListening(false);
|
||||
setRecordState('processing');
|
||||
stopAnimations();
|
||||
}, []);
|
||||
|
||||
const onSpeechError = useCallback((error: any) => {
|
||||
console.log('语音识别错误:', error);
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
setIsListening(false);
|
||||
setRecordState('idle');
|
||||
stopAnimations();
|
||||
|
||||
// 显示更友好的错误信息
|
||||
if (error.error?.code === '7') {
|
||||
Alert.alert('提示', '没有检测到语音输入,请重试');
|
||||
} else if (error.error?.code === '2') {
|
||||
Alert.alert('提示', '网络连接异常,请检查网络后重试');
|
||||
} else {
|
||||
Alert.alert('提示', '语音识别出现问题,请重试');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onSpeechResults = useCallback((event: any) => {
|
||||
console.log('语音识别结果:', event);
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
const text = event.value?.[0] || '';
|
||||
if (text.trim()) {
|
||||
setRecognizedText(text);
|
||||
setRecordState('result');
|
||||
} else {
|
||||
setRecordState('idle');
|
||||
Alert.alert('提示', '未识别到有效内容,请重新录音');
|
||||
}
|
||||
stopAnimations();
|
||||
}, []);
|
||||
|
||||
const onSpeechPartialResults = useCallback((event: any) => {
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
const text = event.value?.[0] || '';
|
||||
setRecognizedText(text);
|
||||
}, []);
|
||||
|
||||
const onSpeechVolumeChanged = useCallback((event: any) => {
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
// 根据音量调整动画
|
||||
const volume = event.value || 0;
|
||||
const scale = 1 + (volume * 0.1);
|
||||
scaleAnimation.setValue(Math.min(scale, 1.5));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// 初始化语音识别
|
||||
Voice.onSpeechStart = onSpeechStart;
|
||||
Voice.onSpeechRecognized = onSpeechRecognized;
|
||||
Voice.onSpeechEnd = onSpeechEnd;
|
||||
Voice.onSpeechError = onSpeechError;
|
||||
Voice.onSpeechResults = onSpeechResults;
|
||||
Voice.onSpeechPartialResults = onSpeechPartialResults;
|
||||
Voice.onSpeechVolumeChanged = onSpeechVolumeChanged;
|
||||
|
||||
return () => {
|
||||
// 标记组件已卸载
|
||||
isMountedRef.current = false;
|
||||
|
||||
// 清理进度定时器
|
||||
if (progressIntervalRef.current) {
|
||||
clearInterval(progressIntervalRef.current);
|
||||
}
|
||||
|
||||
// 清理语音识别资源
|
||||
const cleanup = async () => {
|
||||
try {
|
||||
await Voice.stop();
|
||||
await Voice.destroy();
|
||||
Voice.removeAllListeners();
|
||||
} catch (error) {
|
||||
console.log('清理语音识别资源失败:', error);
|
||||
}
|
||||
};
|
||||
cleanup();
|
||||
};
|
||||
}, [onSpeechStart, onSpeechRecognized, onSpeechEnd, onSpeechError, onSpeechResults, onSpeechPartialResults, onSpeechVolumeChanged]);
|
||||
|
||||
// 开始录音
|
||||
const startRecording = async () => {
|
||||
try {
|
||||
// 重置状态
|
||||
setRecognizedText('');
|
||||
setRecordState('idle');
|
||||
triggerHapticFeedback('impactMedium');
|
||||
|
||||
// 确保之前的识别已停止
|
||||
await Voice.stop();
|
||||
|
||||
// 添加短暂延迟确保资源清理完成
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// 启动新的语音识别
|
||||
await Voice.start('zh-CN');
|
||||
|
||||
} catch (error) {
|
||||
console.log('启动语音识别失败:', error);
|
||||
setRecordState('idle');
|
||||
setIsListening(false);
|
||||
Alert.alert('录音失败', '无法启动语音识别,请检查麦克风权限设置');
|
||||
}
|
||||
};
|
||||
|
||||
// 停止录音
|
||||
const stopRecording = async () => {
|
||||
try {
|
||||
console.log('停止录音');
|
||||
setIsListening(false);
|
||||
await Voice.stop();
|
||||
triggerHapticFeedback('impactLight');
|
||||
} catch (error) {
|
||||
console.log('停止语音识别失败:', error);
|
||||
setIsListening(false);
|
||||
setRecordState('idle');
|
||||
}
|
||||
};
|
||||
|
||||
// 重新录音
|
||||
const retryRecording = async () => {
|
||||
try {
|
||||
// 停止所有动画
|
||||
stopAnimations();
|
||||
|
||||
// 重置所有状态
|
||||
setRecognizedText('');
|
||||
setAnalysisProgress(0);
|
||||
setIsListening(false);
|
||||
setRecordState('idle');
|
||||
|
||||
// 确保语音识别已停止
|
||||
await Voice.stop();
|
||||
|
||||
// 延迟一点再开始新的录音,确保状态完全重置
|
||||
setTimeout(() => {
|
||||
startRecording();
|
||||
}, 200);
|
||||
} catch (error) {
|
||||
console.log('重新录音失败:', error);
|
||||
setRecordState('idle');
|
||||
setIsListening(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 确认并分析食物文本
|
||||
const confirmResult = async () => {
|
||||
if (!recognizedText.trim()) {
|
||||
Alert.alert('提示', '请先进行语音识别');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
triggerHapticFeedback('impactMedium');
|
||||
setRecordState('analyzing');
|
||||
setAnalysisProgress(0);
|
||||
|
||||
// 启动科幻分析动画
|
||||
startAnalysisAnimation();
|
||||
|
||||
// 模拟进度更新
|
||||
progressIntervalRef.current = setInterval(() => {
|
||||
if (!isMountedRef.current) {
|
||||
if (progressIntervalRef.current) {
|
||||
clearInterval(progressIntervalRef.current);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setAnalysisProgress(prev => {
|
||||
if (prev >= 90) {
|
||||
if (progressIntervalRef.current) {
|
||||
clearInterval(progressIntervalRef.current);
|
||||
}
|
||||
return prev;
|
||||
}
|
||||
return prev + Math.random() * 15;
|
||||
});
|
||||
}, 500);
|
||||
|
||||
// 调用文本分析API
|
||||
dispatch(setLoading(true));
|
||||
const result = await analyzeFoodFromText({ text: recognizedText });
|
||||
|
||||
// 清理进度定时器
|
||||
if (progressIntervalRef.current) {
|
||||
clearInterval(progressIntervalRef.current);
|
||||
}
|
||||
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
setAnalysisProgress(100);
|
||||
|
||||
// 生成识别结果ID并保存到Redux
|
||||
const recognitionId = `text_${Date.now()}`;
|
||||
dispatch(saveRecognitionResult({ id: recognitionId, result }));
|
||||
|
||||
// 停止动画并导航到结果页面
|
||||
stopAnimations();
|
||||
|
||||
// 延迟一点让用户看到100%完成
|
||||
setTimeout(() => {
|
||||
router.replace({
|
||||
pathname: '/food/analysis-result',
|
||||
params: {
|
||||
recognitionId,
|
||||
mealType: mealType,
|
||||
hideRecordBar: 'false'
|
||||
}
|
||||
});
|
||||
}, 800);
|
||||
|
||||
} catch (error) {
|
||||
console.error('食物分析失败:', error);
|
||||
|
||||
// 清理进度定时器
|
||||
if (progressIntervalRef.current) {
|
||||
clearInterval(progressIntervalRef.current);
|
||||
}
|
||||
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
stopAnimations();
|
||||
setRecordState('result');
|
||||
dispatch(setLoading(false));
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : '分析失败,请重试';
|
||||
dispatch(setError(errorMessage));
|
||||
Alert.alert('分析失败', errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = async () => {
|
||||
try {
|
||||
// 如果正在录音,先停止
|
||||
if (isListening) {
|
||||
await stopRecording();
|
||||
}
|
||||
|
||||
// 停止所有动画
|
||||
stopAnimations();
|
||||
|
||||
// 确保语音识别完全停止
|
||||
await Voice.stop();
|
||||
|
||||
router.back();
|
||||
} catch (error) {
|
||||
console.log('返回时清理资源失败:', error);
|
||||
router.back();
|
||||
}
|
||||
};
|
||||
|
||||
// 获取状态对应的UI文本
|
||||
const getStatusText = () => {
|
||||
switch (recordState) {
|
||||
case 'idle':
|
||||
return '轻触麦克风开始录音';
|
||||
case 'listening':
|
||||
return '正在聆听中,请开始说话...';
|
||||
case 'processing':
|
||||
return 'AI正在处理语音内容...';
|
||||
case 'analyzing':
|
||||
return 'AI大模型深度分析营养成分中...';
|
||||
case 'result':
|
||||
return '语音识别完成,请确认结果';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
// 获取主按钮配置
|
||||
const getMainButtonConfig = () => {
|
||||
switch (recordState) {
|
||||
case 'idle':
|
||||
return {
|
||||
onPress: startRecording,
|
||||
color: '#7B68EE',
|
||||
icon: 'mic',
|
||||
size: 80,
|
||||
};
|
||||
case 'listening':
|
||||
return {
|
||||
onPress: stopRecording,
|
||||
color: '#FF6B6B',
|
||||
icon: 'stop',
|
||||
size: 80,
|
||||
};
|
||||
case 'processing':
|
||||
return {
|
||||
onPress: () => { },
|
||||
color: '#FFA07A',
|
||||
icon: 'hourglass',
|
||||
size: 80,
|
||||
};
|
||||
case 'analyzing':
|
||||
return {
|
||||
onPress: () => { },
|
||||
color: '#00D4AA',
|
||||
icon: 'analytics',
|
||||
size: 80,
|
||||
};
|
||||
case 'result':
|
||||
return {
|
||||
onPress: confirmResult,
|
||||
color: '#4ECDC4',
|
||||
icon: 'checkmark',
|
||||
size: 80,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const buttonConfig = getMainButtonConfig();
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: colorTokens.background }]}>
|
||||
<HeaderBar
|
||||
title="一句话记录"
|
||||
onBack={handleBack}
|
||||
tone={theme}
|
||||
variant="elevated"
|
||||
/>
|
||||
|
||||
<View style={styles.content}>
|
||||
{/* 上半部分:介绍 */}
|
||||
<View style={styles.topSection}>
|
||||
<View style={styles.introContainer}>
|
||||
<Text style={[styles.introDescription, { color: colorTokens.textSecondary }]}>
|
||||
通过语音描述您的饮食内容,AI将智能分析营养成分和卡路里
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 中间部分:录音动画区域 */}
|
||||
<View style={styles.middleSection}>
|
||||
<View style={styles.animationContainer}>
|
||||
{/* 背景波浪效果 */}
|
||||
{recordState === 'listening' && (
|
||||
<>
|
||||
{[1, 2, 3].map((index) => (
|
||||
<Animated.View
|
||||
key={index}
|
||||
style={[
|
||||
styles.waveRing,
|
||||
{
|
||||
transform: [
|
||||
{
|
||||
scale: waveAnimation.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [0.8, 2 + index * 0.3],
|
||||
}),
|
||||
},
|
||||
],
|
||||
opacity: waveAnimation.interpolate({
|
||||
inputRange: [0, 0.5, 1],
|
||||
outputRange: [0.6, 0.3, 0],
|
||||
}),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 科幻分析特效 */}
|
||||
{recordState === 'analyzing' && (
|
||||
<>
|
||||
{/* 外光环 */}
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.glowRing,
|
||||
{
|
||||
opacity: glowAnimation.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [0.3, 0.8],
|
||||
}),
|
||||
transform: [
|
||||
{
|
||||
scale: glowAnimation.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [1.2, 1.6],
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{/* 内光环 */}
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.innerGlowRing,
|
||||
{
|
||||
opacity: glowAnimation.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [0.5, 1],
|
||||
}),
|
||||
transform: [
|
||||
{
|
||||
scale: glowAnimation.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [0.9, 1.1],
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 主录音按钮 */}
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.recordButton,
|
||||
{
|
||||
backgroundColor: buttonConfig.color,
|
||||
transform: [
|
||||
{ scale: scaleAnimation },
|
||||
{ scale: pulseAnimation },
|
||||
],
|
||||
},
|
||||
]}
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={styles.recordButtonInner}
|
||||
onPress={buttonConfig.onPress}
|
||||
activeOpacity={0.8}
|
||||
disabled={recordState === 'processing' || recordState === 'analyzing'}
|
||||
>
|
||||
<Ionicons
|
||||
name={buttonConfig.icon as any}
|
||||
size={buttonConfig.size}
|
||||
color="white"
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 下半部分:状态文本和示例 */}
|
||||
<View style={styles.bottomSection}>
|
||||
<View style={styles.statusContainer}>
|
||||
<Text style={[styles.statusText, { color: colorTokens.text }]}>
|
||||
{getStatusText()}
|
||||
</Text>
|
||||
|
||||
{recordState === 'listening' && (
|
||||
<Text style={[styles.hintText, { color: colorTokens.textSecondary }]}>
|
||||
说出您想记录的食物内容
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* 食物记录示例 */}
|
||||
{recordState === 'idle' && (
|
||||
<BlurView intensity={20} tint={theme} style={styles.examplesContainer}>
|
||||
<View style={styles.examplesContent}>
|
||||
<Text style={[styles.examplesTitle, { color: colorTokens.text }]}>
|
||||
记录示例:
|
||||
</Text>
|
||||
<View style={styles.examplesList}>
|
||||
<Text style={[styles.exampleText, { color: colorTokens.textSecondary }]}>
|
||||
“今早吃了两个煎蛋、一片全麦面包和一杯牛奶”
|
||||
</Text>
|
||||
<Text style={[styles.exampleText, { color: colorTokens.textSecondary }]}>
|
||||
“午饭吃了红烧肉约150克、米饭一小碗、青菜一份”
|
||||
</Text>
|
||||
<Text style={[styles.exampleText, { color: colorTokens.textSecondary }]}>
|
||||
“晚饭吃了蒸蛋羹、紫菜蛋花汤、小米粥一碗”
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</BlurView>
|
||||
)}
|
||||
|
||||
{recordState === 'analyzing' && (
|
||||
<View style={styles.analysisProgressContainer}>
|
||||
<Text style={[styles.progressText, { color: colorTokens.text }]}>
|
||||
分析进度: {Math.round(analysisProgress)}%
|
||||
</Text>
|
||||
<View style={styles.progressBarContainer}>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.progressBar,
|
||||
{
|
||||
width: progressAnimation.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: ['0%', '100%'],
|
||||
}),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
<Text style={[styles.analysisHint, { color: colorTokens.textSecondary }]}>
|
||||
AI正在深度分析您的食物描述...
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 识别结果 */}
|
||||
{recognizedText && (
|
||||
<BlurView intensity={20} tint={theme} style={styles.resultContainer}>
|
||||
<View style={styles.resultContent}>
|
||||
<Text style={[styles.resultLabel, { color: colorTokens.textSecondary }]}>
|
||||
识别结果:
|
||||
</Text>
|
||||
<Text style={[styles.resultText, { color: colorTokens.text }]}>
|
||||
{recognizedText}
|
||||
</Text>
|
||||
|
||||
{recordState === 'result' && (
|
||||
<View style={styles.resultActions}>
|
||||
<TouchableOpacity
|
||||
style={[styles.actionButton, styles.retryButton]}
|
||||
onPress={retryRecording}
|
||||
>
|
||||
<Ionicons name="refresh" size={16} color="#7B68EE" />
|
||||
<Text style={styles.retryButtonText}>重新录音</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.actionButton, styles.confirmButton]}
|
||||
onPress={confirmResult}
|
||||
>
|
||||
<Ionicons name="checkmark" size={16} color="white" />
|
||||
<Text style={styles.confirmButtonText}>确认使用</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</BlurView>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: 20,
|
||||
paddingBottom: 40,
|
||||
},
|
||||
topSection: {
|
||||
alignItems: 'center',
|
||||
paddingBottom: 20,
|
||||
},
|
||||
middleSection: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: 200,
|
||||
},
|
||||
bottomSection: {
|
||||
alignItems: 'center',
|
||||
},
|
||||
introContainer: {
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 20,
|
||||
},
|
||||
introTitle: {
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 8,
|
||||
textAlign: 'center',
|
||||
},
|
||||
introDescription: {
|
||||
fontSize: 16,
|
||||
lineHeight: 24,
|
||||
textAlign: 'center',
|
||||
paddingHorizontal: 10,
|
||||
},
|
||||
animationContainer: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: 200,
|
||||
width: 200,
|
||||
flex: 1,
|
||||
},
|
||||
waveRing: {
|
||||
position: 'absolute',
|
||||
width: 160,
|
||||
height: 160,
|
||||
borderRadius: 80,
|
||||
backgroundColor: 'rgba(123, 104, 238, 0.1)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(123, 104, 238, 0.2)',
|
||||
},
|
||||
recordButton: {
|
||||
width: 160,
|
||||
height: 160,
|
||||
borderRadius: 80,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 8,
|
||||
},
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 12,
|
||||
elevation: 12,
|
||||
},
|
||||
recordButtonInner: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: 80,
|
||||
},
|
||||
statusContainer: {
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 20,
|
||||
},
|
||||
statusText: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
marginBottom: 8,
|
||||
},
|
||||
hintText: {
|
||||
fontSize: 14,
|
||||
textAlign: 'center',
|
||||
lineHeight: 20,
|
||||
},
|
||||
examplesContainer: {
|
||||
marginTop: 24,
|
||||
marginHorizontal: 20,
|
||||
borderRadius: 16,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
examplesContent: {
|
||||
padding: 20,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
||||
borderRadius: 16,
|
||||
alignItems: 'center',
|
||||
},
|
||||
examplesTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
marginBottom: 16,
|
||||
},
|
||||
examplesList: {
|
||||
paddingHorizontal: 10,
|
||||
gap: 8,
|
||||
},
|
||||
exampleText: {
|
||||
fontSize: 14,
|
||||
lineHeight: 22,
|
||||
textAlign: 'center',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
marginVertical: 4,
|
||||
},
|
||||
resultContainer: {
|
||||
position: 'absolute',
|
||||
bottom: 100,
|
||||
left: 20,
|
||||
right: 20,
|
||||
borderRadius: 16,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
resultContent: {
|
||||
padding: 20,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
||||
borderRadius: 16,
|
||||
},
|
||||
resultLabel: {
|
||||
fontSize: 12,
|
||||
fontWeight: '500',
|
||||
marginBottom: 8,
|
||||
},
|
||||
resultText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
lineHeight: 24,
|
||||
marginBottom: 16,
|
||||
},
|
||||
resultActions: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
},
|
||||
actionButton: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 16,
|
||||
borderRadius: 12,
|
||||
gap: 6,
|
||||
},
|
||||
retryButton: {
|
||||
backgroundColor: 'rgba(123, 104, 238, 0.1)',
|
||||
borderWidth: 1,
|
||||
borderColor: '#7B68EE',
|
||||
},
|
||||
confirmButton: {
|
||||
backgroundColor: '#7B68EE',
|
||||
},
|
||||
retryButtonText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
color: '#7B68EE',
|
||||
},
|
||||
confirmButtonText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
color: 'white',
|
||||
},
|
||||
// 科幻分析特效样式
|
||||
glowRing: {
|
||||
position: 'absolute',
|
||||
width: 200,
|
||||
height: 200,
|
||||
borderRadius: 100,
|
||||
backgroundColor: 'rgba(0, 212, 170, 0.1)',
|
||||
borderWidth: 2,
|
||||
borderColor: '#00D4AA',
|
||||
shadowColor: '#00D4AA',
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 0.8,
|
||||
shadowRadius: 20,
|
||||
},
|
||||
innerGlowRing: {
|
||||
position: 'absolute',
|
||||
width: 180,
|
||||
height: 180,
|
||||
borderRadius: 90,
|
||||
backgroundColor: 'rgba(0, 212, 170, 0.05)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(0, 212, 170, 0.3)',
|
||||
},
|
||||
analysisProgressContainer: {
|
||||
alignItems: 'center',
|
||||
marginTop: 20,
|
||||
paddingHorizontal: 20,
|
||||
},
|
||||
progressText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
marginBottom: 12,
|
||||
},
|
||||
progressBarContainer: {
|
||||
width: '100%',
|
||||
height: 6,
|
||||
backgroundColor: 'rgba(0, 212, 170, 0.2)',
|
||||
borderRadius: 3,
|
||||
overflow: 'hidden',
|
||||
marginBottom: 8,
|
||||
},
|
||||
progressBar: {
|
||||
height: '100%',
|
||||
backgroundColor: '#00D4AA',
|
||||
borderRadius: 3,
|
||||
shadowColor: '#00D4AA',
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 0.8,
|
||||
shadowRadius: 4,
|
||||
},
|
||||
analysisHint: {
|
||||
fontSize: 14,
|
||||
textAlign: 'center',
|
||||
lineHeight: 20,
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
});
|
||||
@@ -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,4 +1,5 @@
|
||||
import NumberKeyboard from '@/components/NumberKeyboard';
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { WeightRecordCard } from '@/components/weight/WeightRecordCard';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { getTabBarBottomPadding } from '@/constants/TabBar';
|
||||
@@ -169,21 +170,20 @@ export default function WeightRecordsPage() {
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* 背景渐变 */}
|
||||
<LinearGradient
|
||||
colors={[themeColors.backgroundGradientStart, themeColors.backgroundGradientEnd]}
|
||||
style={styles.gradient}
|
||||
colors={['#F0F9FF', '#E0F2FE']}
|
||||
style={styles.gradientBackground}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 0, y: 1 }}
|
||||
>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity onPress={handleGoBack} style={styles.backButton}>
|
||||
<Ionicons name="chevron-back" size={24} color="#192126" />
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={handleAddWeight} style={styles.addButton}>
|
||||
end={{ x: 1, y: 1 }}
|
||||
/>
|
||||
|
||||
<HeaderBar
|
||||
title="体重记录"
|
||||
right={<TouchableOpacity onPress={handleAddWeight} style={styles.addButton}>
|
||||
<Ionicons name="add" size={24} color="#192126" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</TouchableOpacity>}
|
||||
/>
|
||||
{/* Weight Statistics */}
|
||||
<View style={styles.statsContainer}>
|
||||
<View style={styles.statsRow}>
|
||||
@@ -377,7 +377,6 @@ export default function WeightRecordsPage() {
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
</LinearGradient>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -386,8 +385,12 @@ const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
gradient: {
|
||||
flex: 1,
|
||||
gradientBackground: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
|
||||
BIN
assets/icon.icon/Assets/icon-1756312748268.jpg
Normal file
|
After Width: | Height: | Size: 69 KiB |
BIN
assets/icon.icon/Assets/icon-1756312748268.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
31
assets/icon.icon/icon.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"fill": "automatic",
|
||||
"groups": [
|
||||
{
|
||||
"layers": [
|
||||
{
|
||||
"blend-mode": "normal",
|
||||
"glass": true,
|
||||
"hidden": false,
|
||||
"image-name": "icon-1756312748268.jpg",
|
||||
"name": "icon-1756312748268",
|
||||
"opacity": 1
|
||||
}
|
||||
],
|
||||
"shadow": {
|
||||
"kind": "neutral",
|
||||
"opacity": 0.5
|
||||
},
|
||||
"translucency": {
|
||||
"enabled": true,
|
||||
"value": 0.5
|
||||
}
|
||||
}
|
||||
],
|
||||
"supported-platforms": {
|
||||
"circles": [
|
||||
"watchOS"
|
||||
],
|
||||
"squares": "shared"
|
||||
}
|
||||
}
|
||||
BIN
assets/images/icons/icon-blood-oxygen.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
assets/images/icons/icon-broadcast.png
Normal file
|
After Width: | Height: | Size: 6.5 KiB |
BIN
assets/images/icons/icon-camera.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 33 KiB |
BIN
assets/images/icons/icon-food.png
Normal file
|
After Width: | Height: | Size: 9.3 KiB |
BIN
assets/images/icons/icon-healthy-diet.png
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
assets/images/icons/icon-mood.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
assets/images/icons/icon-pressure.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
assets/images/icons/icon-sleep.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
assets/images/icons/icon-step.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
assets/images/icons/icon-weight.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
@@ -100,8 +100,6 @@ const ActivityHeatMap = () => {
|
||||
return weeks;
|
||||
}, [generateActivityData, weeksToShow]);
|
||||
|
||||
console.log('organizeDataByWeeks', organizeDataByWeeks);
|
||||
|
||||
|
||||
// 获取月份标签(简化的月份标签系统)
|
||||
const getMonthLabels = useMemo(() => {
|
||||
@@ -186,23 +184,23 @@ const ActivityHeatMap = () => {
|
||||
>
|
||||
<View style={[styles.popoverContent, { backgroundColor: colors.card }]}>
|
||||
<Text style={[styles.popoverTitle, { color: colors.text }]}>
|
||||
小鱼干可以用来与小海豹进行对话
|
||||
能量值的积攒后续可以用来兑换 AI 相关权益
|
||||
</Text>
|
||||
<Text style={[styles.popoverSubtitle, { color: colors.text }]}>
|
||||
获取说明
|
||||
</Text>
|
||||
<View style={styles.popoverList}>
|
||||
<Text style={[styles.popoverItem, { color: colors.textMuted }]}>
|
||||
1. 每日登录获得小鱼干+1
|
||||
1. 每日登录获得能量值+1
|
||||
</Text>
|
||||
<Text style={[styles.popoverItem, { color: colors.textMuted }]}>
|
||||
2. 每日记录心情获得小鱼干+1
|
||||
2. 每日记录心情获得能量值+1
|
||||
</Text>
|
||||
<Text style={[styles.popoverItem, { color: colors.textMuted }]}>
|
||||
3. 记饮食获得小鱼干+1
|
||||
3. 记饮食获得能量值+1
|
||||
</Text>
|
||||
<Text style={[styles.popoverItem, { color: colors.textMuted }]}>
|
||||
4. 完成一次目标获得小鱼干+1
|
||||
4. 完成一次目标获得能量值+1
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -1,40 +1,168 @@
|
||||
import { AnimatedNumber } from '@/components/AnimatedNumber';
|
||||
import React from 'react';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
import { ROUTES } from '@/constants/Routes';
|
||||
import { useAppSelector } from '@/hooks/redux';
|
||||
import { selectUserAge, selectUserProfile } from '@/store/userSlice';
|
||||
import { fetchBasalEnergyBurned } from '@/utils/health';
|
||||
import dayjs from 'dayjs';
|
||||
import { Image } from 'expo-image';
|
||||
import { router } from 'expo-router';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
|
||||
interface BasalMetabolismCardProps {
|
||||
value: number | null;
|
||||
resetToken?: number;
|
||||
selectedDate?: Date;
|
||||
style?: any;
|
||||
}
|
||||
|
||||
export function BasalMetabolismCard({ value, resetToken, style }: BasalMetabolismCardProps) {
|
||||
// 获取基础代谢状态描述
|
||||
const getMetabolismStatus = () => {
|
||||
if (value === null || value === 0) {
|
||||
export function BasalMetabolismCard({ selectedDate, style }: BasalMetabolismCardProps) {
|
||||
const [basalMetabolism, setBasalMetabolism] = useState<number | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// 获取用户基本信息
|
||||
const userProfile = useAppSelector(selectUserProfile);
|
||||
const userAge = useAppSelector(selectUserAge);
|
||||
|
||||
// 缓存和防抖相关
|
||||
const cacheRef = useRef<Map<string, { data: number | null; timestamp: number }>>(new Map());
|
||||
const loadingRef = useRef<Map<string, Promise<number | null>>>(new Map());
|
||||
const CACHE_DURATION = 5 * 60 * 1000; // 5分钟缓存
|
||||
|
||||
// 使用 useMemo 缓存 BMR 计算,避免每次渲染重复计算
|
||||
const bmrRange = useMemo(() => {
|
||||
const { gender, weight, height } = userProfile;
|
||||
|
||||
// 检查是否有足够的信息来计算BMR
|
||||
if (!gender || !weight || !height || !userAge) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 将体重和身高转换为数字
|
||||
const weightNum = parseFloat(weight);
|
||||
const heightNum = parseFloat(height);
|
||||
|
||||
if (isNaN(weightNum) || isNaN(heightNum) || weightNum <= 0 || heightNum <= 0 || userAge <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 使用Mifflin-St Jeor公式计算BMR
|
||||
let bmr: number;
|
||||
if (gender === 'male') {
|
||||
bmr = 10 * weightNum + 6.25 * heightNum - 5 * userAge + 5;
|
||||
} else {
|
||||
bmr = 10 * weightNum + 6.25 * heightNum - 5 * userAge - 161;
|
||||
}
|
||||
|
||||
// 计算正常范围(±15%)
|
||||
const minBMR = Math.round(bmr * 0.85);
|
||||
const maxBMR = Math.round(bmr * 1.15);
|
||||
|
||||
return { min: minBMR, max: maxBMR, base: Math.round(bmr) };
|
||||
}, [userProfile.gender, userProfile.weight, userProfile.height, userAge]);
|
||||
|
||||
// 优化的数据获取函数,包含缓存和去重复请求
|
||||
const fetchBasalMetabolismData = useCallback(async (date: Date): Promise<number | null> => {
|
||||
const dateKey = dayjs(date).format('YYYY-MM-DD');
|
||||
const now = Date.now();
|
||||
|
||||
// 检查缓存
|
||||
const cached = cacheRef.current.get(dateKey);
|
||||
if (cached && (now - cached.timestamp) < CACHE_DURATION) {
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
// 检查是否已经在请求中(防止重复请求)
|
||||
const existingRequest = loadingRef.current.get(dateKey);
|
||||
if (existingRequest) {
|
||||
return existingRequest;
|
||||
}
|
||||
|
||||
// 创建新的请求
|
||||
const request = (async () => {
|
||||
try {
|
||||
const options = {
|
||||
startDate: dayjs(date).startOf('day').toDate().toISOString(),
|
||||
endDate: dayjs(date).endOf('day').toDate().toISOString()
|
||||
};
|
||||
const basalEnergy = await fetchBasalEnergyBurned(options);
|
||||
const result = basalEnergy || null;
|
||||
|
||||
// 更新缓存
|
||||
cacheRef.current.set(dateKey, { data: result, timestamp: now });
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('BasalMetabolismCard: 获取基础代谢数据失败:', error);
|
||||
return null;
|
||||
} finally {
|
||||
// 清理请求记录
|
||||
loadingRef.current.delete(dateKey);
|
||||
}
|
||||
})();
|
||||
|
||||
// 记录请求
|
||||
loadingRef.current.set(dateKey, request);
|
||||
|
||||
return request;
|
||||
}, []);
|
||||
|
||||
// 获取基础代谢数据
|
||||
useEffect(() => {
|
||||
if (!selectedDate) return;
|
||||
|
||||
let isCancelled = false;
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await fetchBasalMetabolismData(selectedDate);
|
||||
if (!isCancelled) {
|
||||
setBasalMetabolism(result);
|
||||
}
|
||||
} finally {
|
||||
if (!isCancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadData();
|
||||
|
||||
// 清理函数,防止组件卸载后的状态更新
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
};
|
||||
}, [selectedDate, fetchBasalMetabolismData]);
|
||||
// 使用 useMemo 优化状态描述计算
|
||||
const status = useMemo(() => {
|
||||
if (basalMetabolism === null || basalMetabolism === 0) {
|
||||
return { text: '未知', color: '#9AA3AE' };
|
||||
}
|
||||
|
||||
// 基于常见的基础代谢范围来判断状态
|
||||
if (value >= 1800) {
|
||||
if (basalMetabolism >= 1800) {
|
||||
return { text: '高代谢', color: '#10B981' };
|
||||
} else if (value >= 1400) {
|
||||
} else if (basalMetabolism >= 1400) {
|
||||
return { text: '正常', color: '#3B82F6' };
|
||||
} else if (value >= 1000) {
|
||||
} else if (basalMetabolism >= 1000) {
|
||||
return { text: '偏低', color: '#F59E0B' };
|
||||
} else {
|
||||
return { text: '较低', color: '#EF4444' };
|
||||
}
|
||||
};
|
||||
|
||||
const status = getMetabolismStatus();
|
||||
}, [basalMetabolism]);
|
||||
|
||||
return (
|
||||
<View style={[styles.container, style]}>
|
||||
|
||||
<>
|
||||
<TouchableOpacity
|
||||
style={[styles.container, style]}
|
||||
onPress={() => router.push(ROUTES.BASAL_METABOLISM_DETAIL)}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
{/* 头部区域 */}
|
||||
<View style={styles.header}>
|
||||
<View style={styles.leftSection}>
|
||||
<Image
|
||||
source={require('@/assets/images/icons/icon-fire.png')}
|
||||
style={styles.titleIcon}
|
||||
/>
|
||||
<Text style={styles.title}>基础代谢</Text>
|
||||
</View>
|
||||
<View style={[styles.statusBadge, { backgroundColor: status.color + '20' }]}>
|
||||
@@ -44,19 +172,13 @@ export function BasalMetabolismCard({ value, resetToken, style }: BasalMetabolis
|
||||
|
||||
{/* 数值显示区域 */}
|
||||
<View style={styles.valueSection}>
|
||||
{value != null && value > 0 ? (
|
||||
<AnimatedNumber
|
||||
value={value}
|
||||
resetToken={resetToken}
|
||||
style={styles.value}
|
||||
format={(v) => Math.round(v).toString()}
|
||||
/>
|
||||
) : (
|
||||
<Text style={styles.value}>--</Text>
|
||||
)}
|
||||
<Text style={styles.value}>
|
||||
{loading ? '加载中...' : (basalMetabolism != null && basalMetabolism > 0 ? Math.round(basalMetabolism).toString() : '--')}
|
||||
</Text>
|
||||
<Text style={styles.unit}>千卡/日</Text>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -117,6 +239,12 @@ const styles = StyleSheet.create({
|
||||
title: {
|
||||
fontSize: 14,
|
||||
color: '#0F172A',
|
||||
fontWeight: '600',
|
||||
},
|
||||
titleIcon: {
|
||||
width: 16,
|
||||
height: 16,
|
||||
marginRight: 4,
|
||||
},
|
||||
statusBadge: {
|
||||
paddingHorizontal: 8,
|
||||
|
||||
@@ -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,14 +21,21 @@ interface FloatingFoodOverlayProps {
|
||||
export function FloatingFoodOverlay({ visible, onClose, mealType = 'dinner' }: FloatingFoodOverlayProps) {
|
||||
const router = useRouter();
|
||||
|
||||
const { pushIfAuthedElseLogin } = useAuthGuard()
|
||||
|
||||
const handleFoodLibrary = () => {
|
||||
onClose();
|
||||
router.push(`${ROUTES.FOOD_LIBRARY}?mealType=${mealType}`);
|
||||
pushIfAuthedElseLogin(`${ROUTES.FOOD_LIBRARY}?mealType=${mealType}`);
|
||||
};
|
||||
|
||||
const handlePhotoRecognition = () => {
|
||||
onClose();
|
||||
router.push(`/food/camera?mealType=${mealType}`);
|
||||
pushIfAuthedElseLogin(`/food/camera?mealType=${mealType}`);
|
||||
};
|
||||
|
||||
const handleVoiceRecord = () => {
|
||||
onClose();
|
||||
pushIfAuthedElseLogin(`${ROUTES.VOICE_RECORD}?mealType=${mealType}`);
|
||||
};
|
||||
|
||||
const menuItems = [
|
||||
@@ -45,6 +53,13 @@ export function FloatingFoodOverlay({ visible, onClose, mealType = 'dinner' }: F
|
||||
backgroundColor: '#FF9500',
|
||||
onPress: handleFoodLibrary,
|
||||
},
|
||||
{
|
||||
id: 'voice-record',
|
||||
title: '一句话记录',
|
||||
icon: '🎤',
|
||||
backgroundColor: '#7B68EE',
|
||||
onPress: handleVoiceRecord,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
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;
|
||||
@@ -2,7 +2,7 @@ import { MoodCheckin, getMoodConfig } from '@/services/moodCheckins';
|
||||
import dayjs from 'dayjs';
|
||||
import LottieView from 'lottie-react-native';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { Image, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
|
||||
interface MoodCardProps {
|
||||
moodCheckin: MoodCheckin | null;
|
||||
@@ -23,7 +23,13 @@ export function MoodCard({ moodCheckin, onPress }: MoodCardProps) {
|
||||
return (
|
||||
<TouchableOpacity onPress={onPress} style={styles.moodCardContent} >
|
||||
<View style={styles.moodCardHeader}>
|
||||
<View style={styles.titleContainer}>
|
||||
<Image
|
||||
source={require('@/assets/images/icons/icon-mood.png')}
|
||||
style={styles.titleIcon}
|
||||
/>
|
||||
<Text style={styles.cardTitle}>心情</Text>
|
||||
</View>
|
||||
<LottieView
|
||||
ref={animationRef}
|
||||
source={require('@/assets/lottie/mood/mood_demo.json')}
|
||||
@@ -59,9 +65,22 @@ const styles = StyleSheet.create({
|
||||
alignItems: 'center',
|
||||
},
|
||||
|
||||
titleContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
|
||||
titleIcon: {
|
||||
width: 16,
|
||||
height: 16,
|
||||
marginRight: 6,
|
||||
resizeMode: 'contain',
|
||||
},
|
||||
|
||||
cardTitle: {
|
||||
fontSize: 14,
|
||||
color: '#192126',
|
||||
fontWeight: '600'
|
||||
},
|
||||
|
||||
lottieAnimation: {
|
||||
|
||||
@@ -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>
|
||||
|
||||
{/* 标签 */}
|
||||
|
||||