Compare commits
55 Commits
a7f5379d5a
...
feature/ch
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47c8bfc5bc | ||
|
|
3e6f55d804 | ||
|
|
b0602b0a99 | ||
|
|
d32a822604 | ||
|
|
8f847465ef | ||
|
|
d74bd214ed | ||
|
|
970a4b8568 | ||
|
|
9c86b0e565 | ||
|
|
31c4e4fafa | ||
|
|
b80af23f4f | ||
|
|
7259bd7a2c | ||
|
|
2b86ac17a6 | ||
|
|
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 |
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": {
|
"expo": {
|
||||||
"name": "Out Live",
|
"name": "Out Live",
|
||||||
"slug": "digital-pilates",
|
"slug": "digital-pilates",
|
||||||
"version": "1.0.5",
|
"version": "1.0.15",
|
||||||
"orientation": "portrait",
|
"orientation": "portrait",
|
||||||
"icon": "./assets/images/Sealife.jpeg",
|
|
||||||
"scheme": "digitalpilates",
|
"scheme": "digitalpilates",
|
||||||
"userInterfaceStyle": "light",
|
"userInterfaceStyle": "light",
|
||||||
|
"icon": "./assets/icon.icon/Assets/icon-1756312748268.jpg",
|
||||||
"newArchEnabled": true,
|
"newArchEnabled": true,
|
||||||
"jsEngine": "jsc",
|
"jsEngine": "jsc",
|
||||||
"ios": {
|
"ios": {
|
||||||
"supportsTablet": false,
|
"supportsTablet": false,
|
||||||
|
"deploymentTarget": "16.0",
|
||||||
"bundleIdentifier": "com.anonymous.digitalpilates",
|
"bundleIdentifier": "com.anonymous.digitalpilates",
|
||||||
"infoPlist": {
|
"infoPlist": {
|
||||||
"ITSAppUsesNonExemptEncryption": false,
|
"ITSAppUsesNonExemptEncryption": false,
|
||||||
"NSCameraUsageDescription": "应用需要使用相机以拍摄您的体态照片用于AI测评。",
|
"NSCameraUsageDescription": "应用需要使用相机以拍摄您的体态照片用于AI测评。",
|
||||||
"NSPhotoLibraryUsageDescription": "应用需要访问相册以选择您的体态照片用于AI测评。",
|
"NSPhotoLibraryUsageDescription": "应用需要访问相册以选择您的体态照片用于AI测评。",
|
||||||
"NSPhotoLibraryAddUsageDescription": "应用需要写入相册以保存拍摄的体态照片(可选)。",
|
"NSPhotoLibraryAddUsageDescription": "应用需要写入相册以保存拍摄的体态照片(可选)。",
|
||||||
|
"NSMicrophoneUsageDescription": "应用需要使用麦克风进行语音识别,将您的语音转换为文字记录饮食信息。",
|
||||||
|
"NSSpeechRecognitionUsageDescription": "应用需要使用语音识别功能来转换您的语音为文字,帮助您快速记录饮食信息。",
|
||||||
"NSUserNotificationsUsageDescription": "应用需要发送通知以提醒您喝水和站立活动。",
|
"NSUserNotificationsUsageDescription": "应用需要发送通知以提醒您喝水和站立活动。",
|
||||||
"UIBackgroundModes": [
|
"UIBackgroundModes": [
|
||||||
"processing",
|
"processing",
|
||||||
"fetch",
|
"fetch",
|
||||||
"remote-notification"
|
"remote-notification"
|
||||||
]
|
]
|
||||||
}
|
|
||||||
},
|
|
||||||
"android": {
|
|
||||||
"adaptiveIcon": {
|
|
||||||
"foregroundImage": "./assets/images/Sealife.jpeg",
|
|
||||||
"backgroundColor": "#ffffff"
|
|
||||||
},
|
},
|
||||||
"edgeToEdgeEnabled": true,
|
"appleTeamId": "756WVXJ6MT"
|
||||||
"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"
|
|
||||||
},
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
"expo-router",
|
"expo-router",
|
||||||
[
|
[
|
||||||
"expo-splash-screen",
|
"expo-splash-screen",
|
||||||
{
|
{
|
||||||
"image": "./assets/images/Sealife.jpeg",
|
"image": "./assets/icon.icon/Assets/icon-1756312748268.jpg",
|
||||||
"imageWidth": 40,
|
"imageWidth": 40,
|
||||||
"resizeMode": "contain",
|
"resizeMode": "contain",
|
||||||
"backgroundColor": "#ffffff"
|
"backgroundColor": "#ffffff"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
[
|
|
||||||
"react-native-health",
|
|
||||||
{
|
|
||||||
"enableHealthAPI": true,
|
|
||||||
"healthSharePermission": "应用需要访问您的健康数据(步数、能量消耗、心率变异性等)以展示运动统计和压力分析。",
|
|
||||||
"healthUpdatePermission": "应用需要更新您的健康数据(体重信息)以记录您的健身进度。"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
[
|
[
|
||||||
"expo-notifications",
|
"expo-notifications",
|
||||||
{
|
{
|
||||||
"icon": "./assets/images/Sealife.jpeg",
|
"icon": "./assets/icon.icon/Assets/icon-1756312748268.jpg",
|
||||||
"color": "#ffffff",
|
"color": "#ffffff"
|
||||||
"sounds": [
|
|
||||||
"./assets/sounds/notification.wav"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
@@ -84,14 +59,14 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"expo-background-task",
|
"expo-background-task"
|
||||||
{
|
|
||||||
"minimumInterval": 15
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
],
|
],
|
||||||
"experiments": {
|
"experiments": {
|
||||||
"typedRoutes": true
|
"typedRoutes": true
|
||||||
|
},
|
||||||
|
"android": {
|
||||||
|
"package": "com.anonymous.digitalpilates"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
import type { BottomTabNavigationOptions } from '@react-navigation/bottom-tabs';
|
import type { BottomTabNavigationOptions } from '@react-navigation/bottom-tabs';
|
||||||
|
import { GlassContainer, GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||||
import * as Haptics from 'expo-haptics';
|
import * as Haptics from 'expo-haptics';
|
||||||
import { Tabs, usePathname } from 'expo-router';
|
import { Tabs, usePathname } from 'expo-router';
|
||||||
|
import { Icon, Label, NativeTabs } from 'expo-router/unstable-native-tabs';
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Text, TouchableOpacity, View, ViewStyle } from 'react-native';
|
import { Text, TouchableOpacity, View, ViewStyle } from 'react-native';
|
||||||
|
|
||||||
@@ -18,8 +21,8 @@ type TabConfig = {
|
|||||||
|
|
||||||
const TAB_CONFIGS: Record<string, TabConfig> = {
|
const TAB_CONFIGS: Record<string, TabConfig> = {
|
||||||
statistics: { icon: 'chart.pie.fill', title: '健康' },
|
statistics: { icon: 'chart.pie.fill', title: '健康' },
|
||||||
// explore: { icon: 'magnifyingglass.circle.fill', title: '发现' },
|
|
||||||
goals: { icon: 'flag.fill', title: '习惯' },
|
goals: { icon: 'flag.fill', title: '习惯' },
|
||||||
|
challenges: { icon: 'trophy.fill', title: '挑战' },
|
||||||
personal: { icon: 'person.fill', title: '个人' },
|
personal: { icon: 'person.fill', title: '个人' },
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -27,13 +30,15 @@ export default function TabLayout() {
|
|||||||
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||||
const colorTokens = Colors[theme];
|
const colorTokens = Colors[theme];
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const glassEffectAvailable = isLiquidGlassAvailable();
|
||||||
|
|
||||||
// Helper function to determine if a tab is selected
|
// Helper function to determine if a tab is selected
|
||||||
const isTabSelected = (routeName: string): boolean => {
|
const isTabSelected = (routeName: string): boolean => {
|
||||||
const routeMap: Record<string, string> = {
|
const routeMap: Record<string, string> = {
|
||||||
explore: ROUTES.TAB_EXPLORE,
|
|
||||||
goals: ROUTES.TAB_GOALS,
|
|
||||||
statistics: ROUTES.TAB_STATISTICS,
|
statistics: ROUTES.TAB_STATISTICS,
|
||||||
|
goals: ROUTES.TAB_GOALS,
|
||||||
|
challenges: ROUTES.TAB_CHALLENGES,
|
||||||
|
personal: ROUTES.TAB_PERSONAL,
|
||||||
};
|
};
|
||||||
|
|
||||||
return routeMap[routeName] === pathname || pathname.includes(routeName);
|
return routeMap[routeName] === pathname || pathname.includes(routeName);
|
||||||
@@ -59,16 +64,17 @@ export default function TabLayout() {
|
|||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={handlePress}
|
onPress={handlePress}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
|
activeOpacity={1}
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
marginHorizontal: 6,
|
marginHorizontal: 2,
|
||||||
marginVertical: 10,
|
marginVertical: 10,
|
||||||
borderRadius: 25,
|
borderRadius: 25,
|
||||||
backgroundColor: isSelected ? colorTokens.tabBarActiveBackground : 'transparent',
|
backgroundColor: isSelected ? colorTokens.tabBarActiveBackground : 'transparent',
|
||||||
paddingHorizontal: isSelected ? 16 : 10,
|
paddingHorizontal: isSelected ? 8 : 4,
|
||||||
paddingVertical: 8,
|
paddingVertical: 8,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -86,7 +92,7 @@ export default function TabLayout() {
|
|||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
marginLeft: 6,
|
marginLeft: 6,
|
||||||
}}
|
}}
|
||||||
numberOfLines={0 as any}
|
numberOfLines={1}
|
||||||
>
|
>
|
||||||
{tabConfig.title}
|
{tabConfig.title}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -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
|
// Common screen options
|
||||||
const getScreenOptions = (routeName: string): BottomTabNavigationOptions => ({
|
const getScreenOptions = (routeName: string): BottomTabNavigationOptions => ({
|
||||||
headerShown: false,
|
headerShown: false,
|
||||||
tabBarActiveTintColor: colorTokens.tabIconSelected,
|
tabBarActiveTintColor: colorTokens.tabIconSelected,
|
||||||
tabBarButton: createTabButton(routeName),
|
tabBarButton: createTabButton(routeName),
|
||||||
|
tabBarBackground: TabBarBackground,
|
||||||
tabBarStyle: {
|
tabBarStyle: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
bottom: TAB_BAR_BOTTOM_OFFSET,
|
bottom: TAB_BAR_BOTTOM_OFFSET,
|
||||||
height: TAB_BAR_HEIGHT,
|
height: TAB_BAR_HEIGHT,
|
||||||
borderRadius: 34,
|
borderRadius: 34,
|
||||||
backgroundColor: colorTokens.tabBarBackground,
|
backgroundColor: glassEffectAvailable ? 'transparent' : colorTokens.tabBarBackground,
|
||||||
shadowColor: '#000',
|
shadowColor: '#000',
|
||||||
shadowOffset: { width: 0, height: 2 },
|
shadowOffset: { width: 0, height: 2 },
|
||||||
shadowOpacity: 0.2,
|
shadowOpacity: glassEffectAvailable ? 0.1 : 0.2,
|
||||||
shadowRadius: 10,
|
shadowRadius: 10,
|
||||||
elevation: 5,
|
elevation: 5,
|
||||||
paddingHorizontal: 10,
|
paddingHorizontal: 6,
|
||||||
paddingTop: 0,
|
paddingTop: 0,
|
||||||
paddingBottom: 0,
|
paddingBottom: 0,
|
||||||
marginHorizontal: 20,
|
marginHorizontal: 16,
|
||||||
left: 20,
|
left: 16,
|
||||||
right: 20,
|
right: 16,
|
||||||
alignSelf: 'center',
|
alignSelf: 'center',
|
||||||
|
borderWidth: glassEffectAvailable ? 1 : 0,
|
||||||
|
borderColor: glassEffectAvailable ? (theme === 'dark' ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)') : 'transparent',
|
||||||
} as ViewStyle,
|
} as ViewStyle,
|
||||||
tabBarItemStyle: {
|
tabBarItemStyle: {
|
||||||
backgroundColor: 'transparent',
|
backgroundColor: 'transparent',
|
||||||
@@ -131,6 +170,27 @@ export default function TabLayout() {
|
|||||||
tabBarShowLabel: false,
|
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 (
|
return (
|
||||||
<Tabs
|
<Tabs
|
||||||
initialRouteName="statistics"
|
initialRouteName="statistics"
|
||||||
@@ -138,9 +198,8 @@ export default function TabLayout() {
|
|||||||
>
|
>
|
||||||
|
|
||||||
<Tabs.Screen name="statistics" options={{ title: '健康' }} />
|
<Tabs.Screen name="statistics" options={{ title: '健康' }} />
|
||||||
<Tabs.Screen name="explore" options={{ title: '发现', href: null }} />
|
|
||||||
<Tabs.Screen name="coach" options={{ title: 'AI', href: null }} />
|
|
||||||
<Tabs.Screen name="goals" options={{ title: '习惯' }} />
|
<Tabs.Screen name="goals" options={{ title: '习惯' }} />
|
||||||
|
<Tabs.Screen name="challenges" options={{ title: '挑战' }} />
|
||||||
<Tabs.Screen name="personal" options={{ title: '个人' }} />
|
<Tabs.Screen name="personal" options={{ title: '个人' }} />
|
||||||
</Tabs>
|
</Tabs>
|
||||||
);
|
);
|
||||||
|
|||||||
617
app/(tabs)/challenges.tsx
Normal file
@@ -0,0 +1,617 @@
|
|||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
|
import ChallengeProgressCard from '@/components/challenges/ChallengeProgressCard';
|
||||||
|
import { Colors } from '@/constants/Colors';
|
||||||
|
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||||
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
|
import {
|
||||||
|
fetchChallenges,
|
||||||
|
selectChallengeCards,
|
||||||
|
selectChallengesListError,
|
||||||
|
selectChallengesListStatus,
|
||||||
|
type ChallengeCardViewModel,
|
||||||
|
} from '@/store/challengesSlice';
|
||||||
|
import { Image } from 'expo-image';
|
||||||
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Animated,
|
||||||
|
FlatList,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
useWindowDimensions
|
||||||
|
} from 'react-native';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
|
const AVATAR_SIZE = 36;
|
||||||
|
const CARD_IMAGE_WIDTH = 132;
|
||||||
|
const CARD_IMAGE_HEIGHT = 96;
|
||||||
|
const STATUS_LABELS: Record<'upcoming' | 'ongoing' | 'expired', string> = {
|
||||||
|
upcoming: '即将开始',
|
||||||
|
ongoing: '进行中',
|
||||||
|
expired: '已结束',
|
||||||
|
};
|
||||||
|
|
||||||
|
const CAROUSEL_ITEM_SPACING = 16;
|
||||||
|
const MIN_CAROUSEL_CARD_WIDTH = 280;
|
||||||
|
const DOT_BASE_SIZE = 6;
|
||||||
|
|
||||||
|
export default function ChallengesScreen() {
|
||||||
|
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
const colorTokens = Colors[theme];
|
||||||
|
const router = useRouter();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const challenges = useAppSelector(selectChallengeCards);
|
||||||
|
const listStatus = useAppSelector(selectChallengesListStatus);
|
||||||
|
const listError = useAppSelector(selectChallengesListError);
|
||||||
|
const ongoingChallenges = useMemo(() => {
|
||||||
|
const now = dayjs();
|
||||||
|
return challenges.filter((challenge) => {
|
||||||
|
if (challenge.status !== 'ongoing' || !challenge.isJoined || !challenge.progress) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (challenge.endAt) {
|
||||||
|
const endDate = dayjs(challenge.endAt);
|
||||||
|
if (endDate.isValid() && endDate.isBefore(now)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [challenges]);
|
||||||
|
const progressTrackColor = theme === 'dark' ? 'rgba(255, 255, 255, 0.08)' : '#eceffa';
|
||||||
|
const progressInactiveColor = theme === 'dark' ? 'rgba(255, 255, 255, 0.24)' : '#dfe4f6';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (listStatus === 'idle') {
|
||||||
|
dispatch(fetchChallenges());
|
||||||
|
}
|
||||||
|
}, [dispatch, listStatus]);
|
||||||
|
|
||||||
|
const gradientColors: [string, string] =
|
||||||
|
theme === 'dark'
|
||||||
|
? ['#1f2230', '#10131e']
|
||||||
|
: [colorTokens.backgroundGradientStart, colorTokens.backgroundGradientEnd];
|
||||||
|
|
||||||
|
const renderChallenges = () => {
|
||||||
|
if (listStatus === 'loading' && challenges.length === 0) {
|
||||||
|
return (
|
||||||
|
<View style={styles.stateContainer}>
|
||||||
|
<ActivityIndicator color={colorTokens.primary} />
|
||||||
|
<Text style={[styles.stateText, { color: colorTokens.textSecondary }]}>加载挑战中…</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (listStatus === 'failed' && challenges.length === 0) {
|
||||||
|
return (
|
||||||
|
<View style={styles.stateContainer}>
|
||||||
|
<Text style={[styles.stateText, { color: colorTokens.textSecondary }]}>
|
||||||
|
{listError ?? '加载挑战失败,请稍后重试'}
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.retryButton, { borderColor: colorTokens.primary }]}
|
||||||
|
activeOpacity={0.9}
|
||||||
|
onPress={() => dispatch(fetchChallenges())}
|
||||||
|
>
|
||||||
|
<Text style={[styles.retryText, { color: colorTokens.primary }]}>重新加载</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (challenges.length === 0) {
|
||||||
|
return (
|
||||||
|
<View style={styles.stateContainer}>
|
||||||
|
<Text style={[styles.stateText, { color: colorTokens.textSecondary }]}>暂无挑战,稍后再来探索。</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return 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 } })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.screen, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}>
|
||||||
|
<LinearGradient colors={gradientColors} style={StyleSheet.absoluteFillObject} />
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={[styles.scrollContent, {
|
||||||
|
paddingTop: insets.top,
|
||||||
|
}]}
|
||||||
|
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={18} color={colorTokens.onPrimary} />
|
||||||
|
</LinearGradient>
|
||||||
|
</TouchableOpacity> */}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{ongoingChallenges.length ? (
|
||||||
|
<OngoingChallengesCarousel
|
||||||
|
challenges={ongoingChallenges}
|
||||||
|
colorTokens={colorTokens}
|
||||||
|
trackColor={progressTrackColor}
|
||||||
|
inactiveColor={progressInactiveColor}
|
||||||
|
onPress={(challenge) =>
|
||||||
|
router.push({ pathname: '/challenges/[id]', params: { id: challenge.id } })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<View style={styles.cardsContainer}>{renderChallenges()}</View>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChallengeCardProps = {
|
||||||
|
challenge: ChallengeCardViewModel;
|
||||||
|
surfaceColor: string;
|
||||||
|
textColor: string;
|
||||||
|
mutedColor: string;
|
||||||
|
onPress: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function ChallengeCard({ challenge, surfaceColor, textColor, mutedColor, onPress }: ChallengeCardProps) {
|
||||||
|
const statusLabel = STATUS_LABELS[challenge.status] ?? challenge.status;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
activeOpacity={0.92}
|
||||||
|
onPress={onPress}
|
||||||
|
style={[
|
||||||
|
styles.card,
|
||||||
|
{
|
||||||
|
backgroundColor: surfaceColor,
|
||||||
|
shadowColor: 'rgba(15, 23, 42, 0.18)',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View style={styles.cardInner}>
|
||||||
|
<View style={styles.cardMedia}>
|
||||||
|
<Image
|
||||||
|
source={{ uri: challenge.image }}
|
||||||
|
style={styles.cardImage}
|
||||||
|
cachePolicy={'memory-disk'}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<>
|
||||||
|
<LinearGradient
|
||||||
|
pointerEvents="none"
|
||||||
|
colors={['rgba(17, 21, 32, 0.05)', 'rgba(13, 17, 28, 0.4)']}
|
||||||
|
style={styles.cardImageOverlay}
|
||||||
|
/>
|
||||||
|
<View style={styles.expiredBadge}>
|
||||||
|
<Text style={styles.expiredBadgeText}>{statusLabel}</Text>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<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}
|
||||||
|
{challenge.isJoined ? ' · 已加入' : ''}
|
||||||
|
</Text>
|
||||||
|
{challenge.avatars.length ? (
|
||||||
|
<AvatarStack avatars={challenge.avatars} borderColor={surfaceColor} />
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type ThemeColorTokens = (typeof Colors)['light'] | (typeof Colors)['dark'];
|
||||||
|
|
||||||
|
type OngoingChallengesCarouselProps = {
|
||||||
|
challenges: ChallengeCardViewModel[];
|
||||||
|
colorTokens: ThemeColorTokens;
|
||||||
|
trackColor: string;
|
||||||
|
inactiveColor: string;
|
||||||
|
onPress: (challenge: ChallengeCardViewModel) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function OngoingChallengesCarousel({
|
||||||
|
challenges,
|
||||||
|
colorTokens,
|
||||||
|
trackColor,
|
||||||
|
inactiveColor,
|
||||||
|
onPress,
|
||||||
|
}: OngoingChallengesCarouselProps) {
|
||||||
|
const { width } = useWindowDimensions();
|
||||||
|
const cardWidth = Math.max(width - 40, MIN_CAROUSEL_CARD_WIDTH);
|
||||||
|
const snapInterval = cardWidth + CAROUSEL_ITEM_SPACING;
|
||||||
|
const scrollX = useRef(new Animated.Value(0)).current;
|
||||||
|
const listRef = useRef<FlatList<ChallengeCardViewModel> | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
scrollX.setValue(0);
|
||||||
|
listRef.current?.scrollToOffset({ offset: 0, animated: false });
|
||||||
|
}, [scrollX, challenges.length]);
|
||||||
|
|
||||||
|
const onScroll = useMemo(
|
||||||
|
() =>
|
||||||
|
Animated.event(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
nativeEvent: {
|
||||||
|
contentOffset: { x: scrollX },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{ useNativeDriver: true }
|
||||||
|
),
|
||||||
|
[scrollX]
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderItem = useCallback(
|
||||||
|
({ item, index }: { item: ChallengeCardViewModel; index: number }) => {
|
||||||
|
const inputRange = [
|
||||||
|
(index - 1) * snapInterval,
|
||||||
|
index * snapInterval,
|
||||||
|
(index + 1) * snapInterval,
|
||||||
|
];
|
||||||
|
const scale = scrollX.interpolate({
|
||||||
|
inputRange,
|
||||||
|
outputRange: [0.94, 1, 0.94],
|
||||||
|
extrapolate: 'clamp',
|
||||||
|
});
|
||||||
|
const translateY = scrollX.interpolate({
|
||||||
|
inputRange,
|
||||||
|
outputRange: [10, 0, 10],
|
||||||
|
extrapolate: 'clamp',
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.carouselCard,
|
||||||
|
{
|
||||||
|
width: cardWidth,
|
||||||
|
transform: [{ scale }, { translateY }],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<TouchableOpacity
|
||||||
|
activeOpacity={0.92}
|
||||||
|
style={styles.carouselTouchable}
|
||||||
|
onPress={() => onPress(item)}
|
||||||
|
>
|
||||||
|
<ChallengeProgressCard
|
||||||
|
title={item.title}
|
||||||
|
endAt={item.endAt}
|
||||||
|
progress={item.progress}
|
||||||
|
style={styles.carouselProgressCard}
|
||||||
|
backgroundColors={[colorTokens.card, colorTokens.card]}
|
||||||
|
titleColor={colorTokens.text}
|
||||||
|
subtitleColor={colorTokens.textSecondary}
|
||||||
|
metaColor={colorTokens.primary}
|
||||||
|
metaSuffixColor={colorTokens.textSecondary}
|
||||||
|
accentColor={colorTokens.primary}
|
||||||
|
trackColor={trackColor}
|
||||||
|
inactiveColor={inactiveColor}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[cardWidth, colorTokens, inactiveColor, onPress, scrollX, snapInterval, trackColor]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.carouselContainer}>
|
||||||
|
<Animated.FlatList
|
||||||
|
ref={listRef}
|
||||||
|
data={challenges}
|
||||||
|
keyExtractor={(item) => item.id}
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
bounces
|
||||||
|
decelerationRate="fast"
|
||||||
|
snapToAlignment="start"
|
||||||
|
snapToInterval={snapInterval}
|
||||||
|
|
||||||
|
ItemSeparatorComponent={() => <View style={{ width: CAROUSEL_ITEM_SPACING }} />}
|
||||||
|
onScroll={onScroll}
|
||||||
|
scrollEventThrottle={16}
|
||||||
|
overScrollMode="never"
|
||||||
|
renderItem={renderItem}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{challenges.length > 1 ? (
|
||||||
|
<View style={styles.carouselIndicators}>
|
||||||
|
{challenges.map((challenge, index) => {
|
||||||
|
const inputRange = [
|
||||||
|
(index - 1) * snapInterval,
|
||||||
|
index * snapInterval,
|
||||||
|
(index + 1) * snapInterval,
|
||||||
|
];
|
||||||
|
const scaleX = scrollX.interpolate({
|
||||||
|
inputRange,
|
||||||
|
outputRange: [1, 2.6, 1],
|
||||||
|
extrapolate: 'clamp',
|
||||||
|
});
|
||||||
|
const dotOpacity = scrollX.interpolate({
|
||||||
|
inputRange,
|
||||||
|
outputRange: [0.35, 1, 0.35],
|
||||||
|
extrapolate: 'clamp',
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View
|
||||||
|
key={challenge.id}
|
||||||
|
style={[
|
||||||
|
styles.carouselDot,
|
||||||
|
{
|
||||||
|
opacity: dotOpacity,
|
||||||
|
backgroundColor: colorTokens.primary,
|
||||||
|
transform: [{ scaleX }],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type AvatarStackProps = {
|
||||||
|
avatars: string[];
|
||||||
|
borderColor: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function AvatarStack({ avatars, borderColor }: AvatarStackProps) {
|
||||||
|
return (
|
||||||
|
<View style={styles.avatarRow}>
|
||||||
|
{avatars
|
||||||
|
.filter(Boolean)
|
||||||
|
.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: 120,
|
||||||
|
},
|
||||||
|
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: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: 26,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
cardsContainer: {
|
||||||
|
gap: 18,
|
||||||
|
},
|
||||||
|
carouselContainer: {
|
||||||
|
marginBottom: 24,
|
||||||
|
},
|
||||||
|
carouselCard: {
|
||||||
|
width: '100%',
|
||||||
|
},
|
||||||
|
carouselTouchable: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
carouselProgressCard: {
|
||||||
|
width: '100%',
|
||||||
|
},
|
||||||
|
carouselIndicators: {
|
||||||
|
marginTop: 18,
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
carouselDot: {
|
||||||
|
width: DOT_BASE_SIZE,
|
||||||
|
height: DOT_BASE_SIZE,
|
||||||
|
borderRadius: DOT_BASE_SIZE / 2,
|
||||||
|
marginHorizontal: 4,
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
},
|
||||||
|
stateContainer: {
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingVertical: 40,
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
},
|
||||||
|
stateText: {
|
||||||
|
marginTop: 12,
|
||||||
|
fontSize: 14,
|
||||||
|
textAlign: 'center',
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
retryButton: {
|
||||||
|
marginTop: 16,
|
||||||
|
paddingHorizontal: 18,
|
||||||
|
paddingVertical: 8,
|
||||||
|
borderRadius: 18,
|
||||||
|
borderWidth: StyleSheet.hairlineWidth,
|
||||||
|
},
|
||||||
|
retryText: {
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
borderRadius: 28,
|
||||||
|
padding: 18,
|
||||||
|
shadowOffset: { width: 0, height: 16 },
|
||||||
|
shadowOpacity: 0.18,
|
||||||
|
shadowRadius: 24,
|
||||||
|
elevation: 6,
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
cardInner: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
cardImage: {
|
||||||
|
width: CARD_IMAGE_WIDTH,
|
||||||
|
height: CARD_IMAGE_HEIGHT,
|
||||||
|
borderRadius: 22,
|
||||||
|
},
|
||||||
|
cardMedia: {
|
||||||
|
borderRadius: 22,
|
||||||
|
overflow: 'hidden',
|
||||||
|
position: 'relative',
|
||||||
|
},
|
||||||
|
cardContent: {
|
||||||
|
flex: 1,
|
||||||
|
marginLeft: 16,
|
||||||
|
},
|
||||||
|
cardTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '700',
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
|
||||||
|
cardDate: {
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: '500',
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
cardParticipants: {
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
cardExpired: {
|
||||||
|
borderWidth: StyleSheet.hairlineWidth,
|
||||||
|
borderColor: 'rgba(148, 163, 184, 0.22)',
|
||||||
|
},
|
||||||
|
cardExpiredText: {
|
||||||
|
opacity: 0.7,
|
||||||
|
},
|
||||||
|
cardDimOverlay: {
|
||||||
|
...StyleSheet.absoluteFillObject,
|
||||||
|
borderRadius: 28,
|
||||||
|
},
|
||||||
|
cardImageOverlay: {
|
||||||
|
...StyleSheet.absoluteFillObject,
|
||||||
|
},
|
||||||
|
expiredBadge: {
|
||||||
|
position: 'absolute',
|
||||||
|
left: 12,
|
||||||
|
bottom: 12,
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 4,
|
||||||
|
borderRadius: 12,
|
||||||
|
backgroundColor: 'rgba(12, 16, 28, 0.45)',
|
||||||
|
},
|
||||||
|
expiredBadgeText: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#f7f9ff',
|
||||||
|
letterSpacing: 0.3,
|
||||||
|
},
|
||||||
|
cardProgress: {
|
||||||
|
marginTop: 8,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
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,
|
skipError,
|
||||||
} = useAppSelector((state) => state.tasks);
|
} = useAppSelector((state) => state.tasks);
|
||||||
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
createLoading,
|
createLoading,
|
||||||
createError
|
createError
|
||||||
@@ -67,13 +68,13 @@ export default function GoalsScreen() {
|
|||||||
// 页面聚焦时重新加载数据
|
// 页面聚焦时重新加载数据
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
console.log('useFocusEffect - loading tasks');
|
console.log('useFocusEffect - loading tasks isLoggedIn', isLoggedIn);
|
||||||
|
|
||||||
if (isLoggedIn) {
|
if (isLoggedIn) {
|
||||||
loadTasks();
|
loadTasks();
|
||||||
checkAndShowGuide();
|
checkAndShowGuide();
|
||||||
}
|
}
|
||||||
}, [dispatch])
|
}, [dispatch, isLoggedIn])
|
||||||
);
|
);
|
||||||
|
|
||||||
// 检查并显示用户引导
|
// 检查并显示用户引导
|
||||||
@@ -94,6 +95,7 @@ export default function GoalsScreen() {
|
|||||||
// 加载任务列表
|
// 加载任务列表
|
||||||
const loadTasks = async () => {
|
const loadTasks = async () => {
|
||||||
try {
|
try {
|
||||||
|
|
||||||
await dispatch(fetchTasks({
|
await dispatch(fetchTasks({
|
||||||
startDate: dayjs().startOf('day').toISOString(),
|
startDate: dayjs().startOf('day').toISOString(),
|
||||||
endDate: dayjs().endOf('day').toISOString(),
|
endDate: dayjs().endOf('day').toISOString(),
|
||||||
@@ -107,6 +109,8 @@ export default function GoalsScreen() {
|
|||||||
const onRefresh = async () => {
|
const onRefresh = async () => {
|
||||||
setRefreshing(true);
|
setRefreshing(true);
|
||||||
try {
|
try {
|
||||||
|
if (!isLoggedIn) return
|
||||||
|
|
||||||
await loadTasks();
|
await loadTasks();
|
||||||
} finally {
|
} finally {
|
||||||
setRefreshing(false);
|
setRefreshing(false);
|
||||||
@@ -115,6 +119,8 @@ export default function GoalsScreen() {
|
|||||||
|
|
||||||
// 加载更多任务
|
// 加载更多任务
|
||||||
const handleLoadMoreTasks = async () => {
|
const handleLoadMoreTasks = async () => {
|
||||||
|
if (!isLoggedIn) return
|
||||||
|
|
||||||
if (tasksPagination.hasMore && !tasksLoading) {
|
if (tasksPagination.hasMore && !tasksLoading) {
|
||||||
try {
|
try {
|
||||||
await dispatch(loadMoreTasks()).unwrap();
|
await dispatch(loadMoreTasks()).unwrap();
|
||||||
@@ -317,6 +323,61 @@ export default function GoalsScreen() {
|
|||||||
|
|
||||||
// 渲染空状态
|
// 渲染空状态
|
||||||
const renderEmptyState = () => {
|
const renderEmptyState = () => {
|
||||||
|
// 未登录状态下的引导
|
||||||
|
if (!isLoggedIn) {
|
||||||
|
return (
|
||||||
|
<View style={styles.emptyStateLogin}>
|
||||||
|
<LinearGradient
|
||||||
|
colors={['#F0F9FF', '#FEFEFE', '#F0F9FF']}
|
||||||
|
style={styles.emptyStateLoginBackground}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 1 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View style={styles.emptyStateLoginContent}>
|
||||||
|
{/* 清新的图标设计 */}
|
||||||
|
<View style={styles.emptyStateLoginIconContainer}>
|
||||||
|
<LinearGradient
|
||||||
|
colors={[colorTokens.primary, '#9B8AFB']}
|
||||||
|
style={styles.emptyStateLoginIconGradient}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 1 }}
|
||||||
|
>
|
||||||
|
<MaterialIcons name="person-outline" size={32} color="#FFFFFF" />
|
||||||
|
</LinearGradient>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 主标题 */}
|
||||||
|
<Text style={[styles.emptyStateLoginTitle, { color: colorTokens.text }]}>
|
||||||
|
开启您的健康之旅
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* 副标题 */}
|
||||||
|
<Text style={[styles.emptyStateLoginSubtitle, { color: colorTokens.textSecondary }]}>
|
||||||
|
登录后即可创建个人目标,让我们一起建立健康的生活习惯
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* 登录按钮 */}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.emptyStateLoginButton, { backgroundColor: colorTokens.primary }]}
|
||||||
|
onPress={() => pushIfAuthedElseLogin('/goals')}
|
||||||
|
>
|
||||||
|
<LinearGradient
|
||||||
|
colors={[colorTokens.primary, '#9B8AFB']}
|
||||||
|
style={styles.emptyStateLoginButtonGradient}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 1 }}
|
||||||
|
>
|
||||||
|
<Text style={styles.emptyStateLoginButtonText}>立即登录</Text>
|
||||||
|
<MaterialIcons name="arrow-forward" size={18} color="#FFFFFF" />
|
||||||
|
</LinearGradient>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 已登录但无任务的状态
|
||||||
let title = '暂无任务';
|
let title = '暂无任务';
|
||||||
let subtitle = '创建目标后,系统会自动生成相应的任务';
|
let subtitle = '创建目标后,系统会自动生成相应的任务';
|
||||||
|
|
||||||
@@ -510,7 +571,7 @@ export default function GoalsScreen() {
|
|||||||
text: '每日目标通知',
|
text: '每日目标通知',
|
||||||
onPress: async () => {
|
onPress: async () => {
|
||||||
try {
|
try {
|
||||||
const userName = userProfile?.name || '小海豹';
|
const userName = userProfile?.name || '';
|
||||||
const notificationIds = await GoalNotificationHelpers.scheduleGoalNotifications(
|
const notificationIds = await GoalNotificationHelpers.scheduleGoalNotifications(
|
||||||
{
|
{
|
||||||
title: '每日运动目标',
|
title: '每日运动目标',
|
||||||
@@ -531,7 +592,7 @@ export default function GoalsScreen() {
|
|||||||
text: '每周目标通知',
|
text: '每周目标通知',
|
||||||
onPress: async () => {
|
onPress: async () => {
|
||||||
try {
|
try {
|
||||||
const userName = userProfile?.name || '小海豹';
|
const userName = userProfile?.name || '';
|
||||||
const notificationIds = await GoalNotificationHelpers.scheduleGoalNotifications(
|
const notificationIds = await GoalNotificationHelpers.scheduleGoalNotifications(
|
||||||
{
|
{
|
||||||
title: '每周运动目标',
|
title: '每周运动目标',
|
||||||
@@ -555,7 +616,7 @@ export default function GoalsScreen() {
|
|||||||
text: '目标达成通知',
|
text: '目标达成通知',
|
||||||
onPress: async () => {
|
onPress: async () => {
|
||||||
try {
|
try {
|
||||||
const userName = userProfile?.name || '小海豹';
|
const userName = userProfile?.name || '';
|
||||||
await GoalNotificationHelpers.sendGoalAchievementNotification(userName, '每日运动目标');
|
await GoalNotificationHelpers.sendGoalAchievementNotification(userName, '每日运动目标');
|
||||||
Alert.alert('成功', '目标达成通知已发送');
|
Alert.alert('成功', '目标达成通知已发送');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -708,6 +769,80 @@ const styles = StyleSheet.create({
|
|||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
lineHeight: 20,
|
lineHeight: 20,
|
||||||
},
|
},
|
||||||
|
// 未登录空状态样式
|
||||||
|
emptyStateLogin: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
paddingVertical: 80,
|
||||||
|
position: 'relative',
|
||||||
|
},
|
||||||
|
emptyStateLoginBackground: {
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
borderRadius: 24,
|
||||||
|
},
|
||||||
|
emptyStateLoginContent: {
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
zIndex: 1,
|
||||||
|
},
|
||||||
|
emptyStateLoginIconContainer: {
|
||||||
|
marginBottom: 24,
|
||||||
|
shadowColor: '#7A5AF8',
|
||||||
|
shadowOffset: { width: 0, height: 8 },
|
||||||
|
shadowOpacity: 0.15,
|
||||||
|
shadowRadius: 16,
|
||||||
|
elevation: 8,
|
||||||
|
},
|
||||||
|
emptyStateLoginIconGradient: {
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
borderRadius: 40,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
emptyStateLoginTitle: {
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: '700',
|
||||||
|
marginBottom: 12,
|
||||||
|
textAlign: 'center',
|
||||||
|
letterSpacing: -0.5,
|
||||||
|
},
|
||||||
|
emptyStateLoginSubtitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
lineHeight: 24,
|
||||||
|
textAlign: 'center',
|
||||||
|
marginBottom: 32,
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
},
|
||||||
|
emptyStateLoginButton: {
|
||||||
|
borderRadius: 28,
|
||||||
|
shadowColor: '#7A5AF8',
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowOpacity: 0.2,
|
||||||
|
shadowRadius: 12,
|
||||||
|
elevation: 6,
|
||||||
|
},
|
||||||
|
emptyStateLoginButtonGradient: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingHorizontal: 32,
|
||||||
|
paddingVertical: 16,
|
||||||
|
borderRadius: 28,
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
emptyStateLoginButtonText: {
|
||||||
|
color: '#FFFFFF',
|
||||||
|
fontSize: 17,
|
||||||
|
fontWeight: '600',
|
||||||
|
letterSpacing: -0.2,
|
||||||
|
},
|
||||||
loadMoreContainer: {
|
loadMoreContainer: {
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
paddingVertical: 20,
|
paddingVertical: 20,
|
||||||
|
|||||||
@@ -1,17 +1,20 @@
|
|||||||
import ActivityHeatMap from '@/components/ActivityHeatMap';
|
import ActivityHeatMap from '@/components/ActivityHeatMap';
|
||||||
import { PRIVACY_POLICY_URL, USER_AGREEMENT_URL } from '@/constants/Agree';
|
import { PRIVACY_POLICY_URL, USER_AGREEMENT_URL } from '@/constants/Agree';
|
||||||
|
import { ROUTES } from '@/constants/Routes';
|
||||||
import { getTabBarBottomPadding } from '@/constants/TabBar';
|
import { getTabBarBottomPadding } from '@/constants/TabBar';
|
||||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||||
import { useNotifications } from '@/hooks/useNotifications';
|
import { useNotifications } from '@/hooks/useNotifications';
|
||||||
import { DEFAULT_MEMBER_NAME, fetchActivityHistory, fetchMyProfile } from '@/store/userSlice';
|
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 { getNotificationEnabled, setNotificationEnabled as saveNotificationEnabled } from '@/utils/userPreferences';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
|
|
||||||
import { useFocusEffect } from '@react-navigation/native';
|
import { useFocusEffect } from '@react-navigation/native';
|
||||||
|
import { isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||||
import { Image } from 'expo-image';
|
import { Image } from 'expo-image';
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
import React, { useEffect, 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 { Alert, Linking, ScrollView, StatusBar, StyleSheet, Switch, Text, TouchableOpacity, View } from 'react-native';
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
@@ -21,7 +24,8 @@ export default function PersonalScreen() {
|
|||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const { confirmLogout, confirmDeleteAccount, isLoggedIn, pushIfAuthedElseLogin } = useAuthGuard();
|
const { confirmLogout, confirmDeleteAccount, isLoggedIn, pushIfAuthedElseLogin } = useAuthGuard();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const tabBarHeight = useBottomTabBarHeight();
|
|
||||||
|
const isLgAvaliable = isLiquidGlassAvailable()
|
||||||
|
|
||||||
// 推送通知相关
|
// 推送通知相关
|
||||||
const {
|
const {
|
||||||
@@ -31,14 +35,20 @@ export default function PersonalScreen() {
|
|||||||
|
|
||||||
const [notificationEnabled, setNotificationEnabled] = useState(false);
|
const [notificationEnabled, setNotificationEnabled] = useState(false);
|
||||||
|
|
||||||
|
// 开发者模式相关状态
|
||||||
|
const [showDeveloperSection, setShowDeveloperSection] = useState(false);
|
||||||
|
const clickTimestamps = useRef<number[]>([]);
|
||||||
|
const clickTimeoutRef = useRef<number | null>(null);
|
||||||
|
|
||||||
// 计算底部间距
|
// 计算底部间距
|
||||||
const bottomPadding = useMemo(() => {
|
const bottomPadding = useMemo(() => {
|
||||||
return getTabBarBottomPadding(tabBarHeight) + (insets?.bottom ?? 0);
|
return getTabBarBottomPadding(60) + (insets?.bottom ?? 0);
|
||||||
}, [tabBarHeight, insets?.bottom]);
|
}, [insets?.bottom]);
|
||||||
|
|
||||||
// 直接使用 Redux 中的用户信息,避免重复状态管理
|
// 直接使用 Redux 中的用户信息,避免重复状态管理
|
||||||
const userProfile = useAppSelector((state) => state.user.profile);
|
const userProfile = useAppSelector((state) => state.user.profile);
|
||||||
|
|
||||||
|
|
||||||
// 页面聚焦时获取最新用户信息
|
// 页面聚焦时获取最新用户信息
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
React.useCallback(() => {
|
React.useCallback(() => {
|
||||||
@@ -46,6 +56,8 @@ export default function PersonalScreen() {
|
|||||||
dispatch(fetchActivityHistory());
|
dispatch(fetchActivityHistory());
|
||||||
// 加载用户推送偏好设置
|
// 加载用户推送偏好设置
|
||||||
loadNotificationPreference();
|
loadNotificationPreference();
|
||||||
|
// 加载开发者模式状态
|
||||||
|
loadDeveloperModeState();
|
||||||
}, [dispatch])
|
}, [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 = () => {
|
const formatHeight = () => {
|
||||||
if (userProfile.height == null) return '--';
|
if (userProfile.height == null) return '--';
|
||||||
@@ -81,11 +114,39 @@ export default function PersonalScreen() {
|
|||||||
// 显示名称
|
// 显示名称
|
||||||
const displayName = (userProfile.name?.trim()) ? userProfile.name : DEFAULT_MEMBER_NAME;
|
const displayName = (userProfile.name?.trim()) ? userProfile.name : DEFAULT_MEMBER_NAME;
|
||||||
|
|
||||||
// 初始化时加载推送偏好设置
|
// 初始化时加载推送偏好设置和开发者模式状态
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadNotificationPreference();
|
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) => {
|
const handleNotificationToggle = async (value: boolean) => {
|
||||||
if (value) {
|
if (value) {
|
||||||
@@ -148,11 +209,17 @@ export default function PersonalScreen() {
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.userDetails}>
|
<View style={styles.userDetails}>
|
||||||
<Text style={styles.userName}>{displayName}</Text>
|
<TouchableOpacity onPress={handleUserNamePress} activeOpacity={0.7}>
|
||||||
|
<Text style={styles.userName}>{displayName}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
{userProfile.memberNumber && (
|
||||||
|
<Text style={styles.userMemberNumber}>会员编号: {userProfile.memberNumber}</Text>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
<TouchableOpacity style={styles.editButton} onPress={() => pushIfAuthedElseLogin('/profile/edit')}>
|
<TouchableOpacity style={styles.editButton} onPress={() => pushIfAuthedElseLogin('/profile/edit')}>
|
||||||
<Text style={styles.editButtonText}>编辑</Text>
|
<Text style={styles.editButtonText}>{isLoggedIn ? '编辑' : '登录'}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
</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: '其他',
|
title: '其他',
|
||||||
items: [
|
items: [
|
||||||
@@ -308,7 +386,7 @@ export default function PersonalScreen() {
|
|||||||
transition={200}
|
transition={200}
|
||||||
cachePolicy="memory-disk"
|
cachePolicy="memory-disk"
|
||||||
/> */}
|
/> */}
|
||||||
<Text style={styles.fishRecordText}>鱼干记录</Text>
|
<Text style={styles.fishRecordText}>能量记录</Text>
|
||||||
</View>
|
</View>
|
||||||
<ActivityHeatMap />
|
<ActivityHeatMap />
|
||||||
{menuSections.map((section, index) => (
|
{menuSections.map((section, index) => (
|
||||||
@@ -405,6 +483,11 @@ const styles = StyleSheet.create({
|
|||||||
color: '#9370DB',
|
color: '#9370DB',
|
||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
},
|
},
|
||||||
|
userMemberNumber: {
|
||||||
|
fontSize: 10,
|
||||||
|
color: '#6C757D',
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
editButton: {
|
editButton: {
|
||||||
backgroundColor: '#9370DB',
|
backgroundColor: '#9370DB',
|
||||||
paddingHorizontal: 16,
|
paddingHorizontal: 16,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { DateSelector } from '@/components/DateSelector';
|
|||||||
import { FitnessRingsCard } from '@/components/FitnessRingsCard';
|
import { FitnessRingsCard } from '@/components/FitnessRingsCard';
|
||||||
import { MoodCard } from '@/components/MoodCard';
|
import { MoodCard } from '@/components/MoodCard';
|
||||||
import { NutritionRadarCard } from '@/components/NutritionRadarCard';
|
import { NutritionRadarCard } from '@/components/NutritionRadarCard';
|
||||||
|
import CircumferenceCard from '@/components/statistic/CircumferenceCard';
|
||||||
import OxygenSaturationCard from '@/components/statistic/OxygenSaturationCard';
|
import OxygenSaturationCard from '@/components/statistic/OxygenSaturationCard';
|
||||||
import SleepCard from '@/components/statistic/SleepCard';
|
import SleepCard from '@/components/statistic/SleepCard';
|
||||||
import StepsCard from '@/components/StepsCard';
|
import StepsCard from '@/components/StepsCard';
|
||||||
@@ -10,19 +11,14 @@ import { StressMeter } from '@/components/StressMeter';
|
|||||||
import WaterIntakeCard from '@/components/WaterIntakeCard';
|
import WaterIntakeCard from '@/components/WaterIntakeCard';
|
||||||
import { WeightHistoryCard } from '@/components/weight/WeightHistoryCard';
|
import { WeightHistoryCard } from '@/components/weight/WeightHistoryCard';
|
||||||
import { Colors } from '@/constants/Colors';
|
import { Colors } from '@/constants/Colors';
|
||||||
import { getTabBarBottomPadding } from '@/constants/TabBar';
|
|
||||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||||
import { backgroundTaskManager } from '@/services/backgroundTaskManager';
|
import { BackgroundTaskManager } from '@/services/backgroundTaskManager';
|
||||||
import { selectHealthDataByDate, setHealthData } from '@/store/healthSlice';
|
import { setHealthData } from '@/store/healthSlice';
|
||||||
import { fetchDailyMoodCheckins, selectLatestMoodRecordByDate } from '@/store/moodSlice';
|
import { fetchDailyMoodCheckins, selectLatestMoodRecordByDate } from '@/store/moodSlice';
|
||||||
import { fetchDailyNutritionData, selectNutritionSummaryByDate } from '@/store/nutritionSlice';
|
|
||||||
import { fetchTodayWaterStats } from '@/store/waterSlice';
|
import { fetchTodayWaterStats } from '@/store/waterSlice';
|
||||||
import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date';
|
import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date';
|
||||||
import { ensureHealthPermissions, fetchHealthDataForDate, testHRVDataFetch } from '@/utils/health';
|
import { fetchHealthDataForDate, testHRVDataFetch } from '@/utils/health';
|
||||||
import { getTestHealthData } from '@/utils/mockHealthData';
|
|
||||||
import { calculateNutritionGoals } from '@/utils/nutrition';
|
|
||||||
import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
|
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
import { debounce } from 'lodash';
|
import { debounce } from 'lodash';
|
||||||
@@ -39,13 +35,10 @@ import {
|
|||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
// 浮动动画组件
|
// 浮动动画组件
|
||||||
const FloatingCard = ({ children, delay = 0, style }: {
|
const FloatingCard = ({ children, style }: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
delay?: number;
|
|
||||||
style?: any;
|
style?: any;
|
||||||
}) => {
|
}) => {
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
@@ -62,22 +55,14 @@ const FloatingCard = ({ children, delay = 0, style }: {
|
|||||||
|
|
||||||
export default function ExploreScreen() {
|
export default function ExploreScreen() {
|
||||||
const stepGoal = useAppSelector((s) => s.user.profile?.dailyStepsGoal) ?? 2000;
|
const stepGoal = useAppSelector((s) => s.user.profile?.dailyStepsGoal) ?? 2000;
|
||||||
const userProfile = useAppSelector((s) => s.user.profile);
|
|
||||||
|
|
||||||
// 开发调试:设置为true来使用mock数据
|
|
||||||
// 在真机测试时,可以暂时设置为true来验证组件显示逻辑
|
|
||||||
const useMockData = __DEV__ || false; // 改为true来启用mock数据调试
|
|
||||||
|
|
||||||
const { pushIfAuthedElseLogin, isLoggedIn } = useAuthGuard();
|
const { pushIfAuthedElseLogin, isLoggedIn } = useAuthGuard();
|
||||||
|
|
||||||
|
|
||||||
// 使用 dayjs:当月日期与默认选中"今天"
|
// 使用 dayjs:当月日期与默认选中"今天"
|
||||||
const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth());
|
const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth());
|
||||||
const tabBarHeight = useBottomTabBarHeight();
|
// const tabBarHeight = useBottomTabBarHeight();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const bottomPadding = useMemo(() => {
|
|
||||||
return getTabBarBottomPadding(tabBarHeight) + (insets?.bottom ?? 0);
|
|
||||||
}, [tabBarHeight, insets?.bottom]);
|
|
||||||
|
|
||||||
// 获取当前选中日期 - 使用 useMemo 缓存避免重复计算
|
// 获取当前选中日期 - 使用 useMemo 缓存避免重复计算
|
||||||
const currentSelectedDate = useMemo(() => {
|
const currentSelectedDate = useMemo(() => {
|
||||||
const days = getMonthDaysZh();
|
const days = getMonthDaysZh();
|
||||||
@@ -88,70 +73,11 @@ export default function ExploreScreen() {
|
|||||||
return dayjs(currentSelectedDate).format('YYYY-MM-DD');
|
return dayjs(currentSelectedDate).format('YYYY-MM-DD');
|
||||||
}, [currentSelectedDate]);
|
}, [currentSelectedDate]);
|
||||||
|
|
||||||
// 从 Redux 获取指定日期的健康数据
|
|
||||||
const healthData = useAppSelector(selectHealthDataByDate(currentSelectedDateString));
|
|
||||||
|
|
||||||
|
|
||||||
// 解构健康数据(支持mock数据)
|
|
||||||
const mockData = useMockData ? getTestHealthData('mock') : null;
|
|
||||||
const 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(当日期或数据变化时更新)
|
// 用于触发动画重置的 token(当日期或数据变化时更新)
|
||||||
const [animToken, setAnimToken] = useState(0);
|
const [animToken, setAnimToken] = useState(0);
|
||||||
|
|
||||||
// 从 Redux 获取营养数据
|
|
||||||
const nutritionSummary = useAppSelector(selectNutritionSummaryByDate(currentSelectedDateString));
|
|
||||||
|
|
||||||
// 计算用户的营养目标
|
|
||||||
const nutritionGoals = useMemo(() => {
|
|
||||||
return calculateNutritionGoals({
|
|
||||||
weight: userProfile.weight,
|
|
||||||
height: userProfile.height,
|
|
||||||
birthDate: userProfile?.birthDate ? new Date(userProfile?.birthDate) : undefined,
|
|
||||||
gender: userProfile?.gender || undefined,
|
|
||||||
});
|
|
||||||
}, [userProfile]);
|
|
||||||
|
|
||||||
// 心情相关状态
|
// 心情相关状态
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
@@ -163,7 +89,6 @@ export default function ExploreScreen() {
|
|||||||
// 请求状态管理,防止重复请求
|
// 请求状态管理,防止重复请求
|
||||||
const loadingRef = useRef({
|
const loadingRef = useRef({
|
||||||
health: false,
|
health: false,
|
||||||
nutrition: false,
|
|
||||||
mood: 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()}`;
|
const getDateKey = (d: Date) => `${dayjs(d).year()}-${dayjs(d).month() + 1}-${dayjs(d).date()}`;
|
||||||
|
|
||||||
// 检查数据是否需要刷新(2分钟内不重复拉取,对营养数据更严格)
|
// 检查数据是否需要刷新(5分钟内不重复拉取)
|
||||||
const shouldRefreshData = (dateKey: string, dataType: string) => {
|
const shouldRefreshData = (dateKey: string, dataType: string) => {
|
||||||
const cacheKey = `${dateKey}-${dataType}`;
|
const cacheKey = `${dateKey}-${dataType}`;
|
||||||
const lastUpdate = dataTimestampRef.current[cacheKey];
|
const lastUpdate = dataTimestampRef.current[cacheKey];
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
// 营养数据使用更短的缓存时间,其他数据使用5分钟
|
// 使用5分钟缓存时间
|
||||||
const cacheTime = dataType === 'nutrition' ? 2 * 60 * 1000 : 5 * 60 * 1000;
|
const cacheTime = 5 * 60 * 1000;
|
||||||
|
|
||||||
return !lastUpdate || (now - lastUpdate) > cacheTime;
|
return !lastUpdate || (now - lastUpdate) > cacheTime;
|
||||||
};
|
};
|
||||||
@@ -267,20 +192,12 @@ export default function ExploreScreen() {
|
|||||||
loadingRef.current.health = true;
|
loadingRef.current.health = true;
|
||||||
console.log('=== 开始HealthKit初始化流程 ===');
|
console.log('=== 开始HealthKit初始化流程 ===');
|
||||||
|
|
||||||
const ok = await ensureHealthPermissions();
|
|
||||||
if (!ok) {
|
|
||||||
const errorMsg = '无法获取健康权限,请确保在真实iOS设备上运行并授权应用访问健康数据';
|
|
||||||
console.warn(errorMsg);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
latestRequestKeyRef.current = requestKey;
|
latestRequestKeyRef.current = requestKey;
|
||||||
|
|
||||||
console.log('权限获取成功,开始获取健康数据...', derivedDate);
|
console.log('权限获取成功,开始获取健康数据...', derivedDate);
|
||||||
const data = await fetchHealthDataForDate(derivedDate);
|
const data = await fetchHealthDataForDate(derivedDate);
|
||||||
|
|
||||||
console.log('设置UI状态:', data);
|
console.log('设置UI状态:', data);
|
||||||
console.log('HRV数据详细信息:', data.hrv, typeof data.hrv);
|
|
||||||
|
|
||||||
// 仅当该请求仍是最新时,才应用结果
|
// 仅当该请求仍是最新时,才应用结果
|
||||||
if (latestRequestKeyRef.current === requestKey) {
|
if (latestRequestKeyRef.current === requestKey) {
|
||||||
@@ -290,12 +207,7 @@ export default function ExploreScreen() {
|
|||||||
dispatch(setHealthData({
|
dispatch(setHealthData({
|
||||||
date: dateString,
|
date: dateString,
|
||||||
data: {
|
data: {
|
||||||
steps: data.steps,
|
|
||||||
activeCalories: data.activeEnergyBurned,
|
activeCalories: data.activeEnergyBurned,
|
||||||
basalEnergyBurned: data.basalEnergyBurned,
|
|
||||||
sleepDuration: data.sleepDuration,
|
|
||||||
hrv: data.hrv,
|
|
||||||
oxygenSaturation: data.oxygenSaturation,
|
|
||||||
heartRate: data.heartRate,
|
heartRate: data.heartRate,
|
||||||
activeEnergyBurned: data.activeEnergyBurned,
|
activeEnergyBurned: data.activeEnergyBurned,
|
||||||
activeCaloriesGoal: data.activeCaloriesGoal,
|
activeCaloriesGoal: data.activeCaloriesGoal,
|
||||||
@@ -303,12 +215,9 @@ export default function ExploreScreen() {
|
|||||||
exerciseMinutesGoal: data.exerciseMinutesGoal,
|
exerciseMinutesGoal: data.exerciseMinutesGoal,
|
||||||
standHours: data.standHours,
|
standHours: data.standHours,
|
||||||
standHoursGoal: data.standHoursGoal,
|
standHoursGoal: data.standHoursGoal,
|
||||||
hourlySteps: data.hourlySteps,
|
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// 更新HRV数据时间
|
|
||||||
setHrvUpdateTime(new Date());
|
|
||||||
setAnimToken((t) => t + 1);
|
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) => {
|
const executeLoadAllData = React.useCallback((targetDate?: Date, forceRefresh = false) => {
|
||||||
@@ -373,7 +243,6 @@ export default function ExploreScreen() {
|
|||||||
console.log('执行数据加载,日期:', dateToUse, '强制刷新:', forceRefresh);
|
console.log('执行数据加载,日期:', dateToUse, '强制刷新:', forceRefresh);
|
||||||
loadHealthData(dateToUse, forceRefresh);
|
loadHealthData(dateToUse, forceRefresh);
|
||||||
if (isLoggedIn) {
|
if (isLoggedIn) {
|
||||||
loadNutritionData(dateToUse, forceRefresh);
|
|
||||||
loadMoodData(dateToUse, forceRefresh);
|
loadMoodData(dateToUse, forceRefresh);
|
||||||
// 加载喝水数据(只加载今日数据用于后台检查)
|
// 加载喝水数据(只加载今日数据用于后台检查)
|
||||||
const isToday = dayjs(dateToUse).isSame(dayjs(), 'day');
|
const isToday = dayjs(dateToUse).isSame(dayjs(), 'day');
|
||||||
@@ -401,14 +270,10 @@ export default function ExploreScreen() {
|
|||||||
}
|
}
|
||||||
}, [executeLoadAllData, debouncedLoadAllData]);
|
}, [executeLoadAllData, debouncedLoadAllData]);
|
||||||
|
|
||||||
// 页面聚焦时的数据加载逻辑
|
useEffect(() => {
|
||||||
// useFocusEffect(
|
loadAllData(currentSelectedDate);
|
||||||
// React.useCallback(() => {
|
}, [])
|
||||||
// // 页面聚焦时加载数据,使用缓存机制避免频繁请求
|
|
||||||
// console.log('页面聚焦,检查是否需要刷新数据...');
|
|
||||||
// loadAllData(currentSelectedDate);
|
|
||||||
// }, [loadAllData, currentSelectedDate])
|
|
||||||
// );
|
|
||||||
|
|
||||||
// AppState 监听:应用从后台返回前台时的处理
|
// AppState 监听:应用从后台返回前台时的处理
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -468,7 +333,7 @@ export default function ExploreScreen() {
|
|||||||
style={styles.scrollView}
|
style={styles.scrollView}
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingTop: insets.top,
|
paddingTop: insets.top,
|
||||||
paddingBottom: bottomPadding,
|
paddingBottom: 60,
|
||||||
paddingHorizontal: 20
|
paddingHorizontal: 20
|
||||||
}}
|
}}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
@@ -478,7 +343,7 @@ export default function ExploreScreen() {
|
|||||||
<View style={styles.headerContent}>
|
<View style={styles.headerContent}>
|
||||||
{/* 左边logo */}
|
{/* 左边logo */}
|
||||||
<Image
|
<Image
|
||||||
source={require('@/assets/images/Sealife.jpeg')}
|
source={require('@/assets/icon.icon/Assets/icon-1756312748268.png')}
|
||||||
style={styles.logoImage}
|
style={styles.logoImage}
|
||||||
resizeMode="cover"
|
resizeMode="cover"
|
||||||
/>
|
/>
|
||||||
@@ -495,7 +360,7 @@ export default function ExploreScreen() {
|
|||||||
style={styles.debugButton}
|
style={styles.debugButton}
|
||||||
onPress={async () => {
|
onPress={async () => {
|
||||||
console.log('🔧 手动触发后台任务测试...');
|
console.log('🔧 手动触发后台任务测试...');
|
||||||
await backgroundTaskManager.triggerTaskForTesting();
|
await BackgroundTaskManager.getInstance().triggerTaskForTesting();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text style={styles.debugButtonText}>🔧</Text>
|
<Text style={styles.debugButtonText}>🔧</Text>
|
||||||
@@ -529,17 +394,8 @@ export default function ExploreScreen() {
|
|||||||
|
|
||||||
{/* 营养摄入雷达图卡片 */}
|
{/* 营养摄入雷达图卡片 */}
|
||||||
<NutritionRadarCard
|
<NutritionRadarCard
|
||||||
nutritionSummary={nutritionSummary}
|
selectedDate={currentSelectedDate}
|
||||||
nutritionGoals={nutritionGoals}
|
|
||||||
burnedCalories={(basalMetabolism || 0) + (activeCalories || 0)}
|
|
||||||
basalMetabolism={basalMetabolism || 0}
|
|
||||||
activeCalories={activeCalories || 0}
|
|
||||||
resetToken={animToken}
|
resetToken={animToken}
|
||||||
onMealPress={(mealType: 'breakfast' | 'lunch' | 'dinner' | 'snack') => {
|
|
||||||
console.log('选择餐次:', mealType);
|
|
||||||
// 这里可以导航到营养记录页面
|
|
||||||
pushIfAuthedElseLogin('/nutrition/records');
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<WeightHistoryCard />
|
<WeightHistoryCard />
|
||||||
@@ -549,7 +405,7 @@ export default function ExploreScreen() {
|
|||||||
{/* 左列 */}
|
{/* 左列 */}
|
||||||
<View style={styles.masonryColumn}>
|
<View style={styles.masonryColumn}>
|
||||||
{/* 心情卡片 */}
|
{/* 心情卡片 */}
|
||||||
<FloatingCard style={styles.masonryCard} delay={1500}>
|
<FloatingCard style={styles.masonryCard}>
|
||||||
<MoodCard
|
<MoodCard
|
||||||
moodCheckin={currentMoodCheckin}
|
moodCheckin={currentMoodCheckin}
|
||||||
onPress={() => pushIfAuthedElseLogin('/mood/calendar')}
|
onPress={() => pushIfAuthedElseLogin('/mood/calendar')}
|
||||||
@@ -559,21 +415,17 @@ export default function ExploreScreen() {
|
|||||||
|
|
||||||
<FloatingCard style={styles.masonryCard}>
|
<FloatingCard style={styles.masonryCard}>
|
||||||
<StepsCard
|
<StepsCard
|
||||||
stepCount={stepCount}
|
curDate={currentSelectedDate}
|
||||||
stepGoal={stepGoal}
|
stepGoal={stepGoal}
|
||||||
hourlySteps={hourlySteps}
|
|
||||||
style={styles.stepsCardOverride}
|
style={styles.stepsCardOverride}
|
||||||
onPress={() => pushIfAuthedElseLogin('/steps/detail')}
|
|
||||||
/>
|
/>
|
||||||
</FloatingCard>
|
</FloatingCard>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<FloatingCard style={styles.masonryCard} delay={0}>
|
<FloatingCard style={styles.masonryCard}>
|
||||||
<StressMeter
|
<StressMeter
|
||||||
value={hrvValue}
|
curDate={currentSelectedDate}
|
||||||
updateTime={hrvUpdateTime}
|
|
||||||
hrvValue={hrvValue}
|
|
||||||
/>
|
/>
|
||||||
</FloatingCard>
|
</FloatingCard>
|
||||||
|
|
||||||
@@ -587,28 +439,22 @@ export default function ExploreScreen() {
|
|||||||
</FloatingCard> */}
|
</FloatingCard> */}
|
||||||
|
|
||||||
<FloatingCard style={styles.masonryCard}>
|
<FloatingCard style={styles.masonryCard}>
|
||||||
<SleepCard
|
<SleepCard
|
||||||
sleepDuration={sleepDuration}
|
selectedDate={currentSelectedDate}
|
||||||
onPress={() => pushIfAuthedElseLogin('/sleep-detail')}
|
|
||||||
/>
|
/>
|
||||||
</FloatingCard>
|
</FloatingCard>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* 右列 */}
|
{/* 右列 */}
|
||||||
<View style={styles.masonryColumn}>
|
<View style={styles.masonryColumn}>
|
||||||
<FloatingCard style={styles.masonryCard} delay={250}>
|
<FloatingCard style={styles.masonryCard}>
|
||||||
<FitnessRingsCard
|
<FitnessRingsCard
|
||||||
activeCalories={fitnessRingsData.activeCalories}
|
selectedDate={currentSelectedDate}
|
||||||
activeCaloriesGoal={fitnessRingsData.activeCaloriesGoal}
|
|
||||||
exerciseMinutes={fitnessRingsData.exerciseMinutes}
|
|
||||||
exerciseMinutesGoal={fitnessRingsData.exerciseMinutesGoal}
|
|
||||||
standHours={fitnessRingsData.standHours}
|
|
||||||
standHoursGoal={fitnessRingsData.standHoursGoal}
|
|
||||||
resetToken={animToken}
|
resetToken={animToken}
|
||||||
/>
|
/>
|
||||||
</FloatingCard>
|
</FloatingCard>
|
||||||
{/* 饮水记录卡片 */}
|
{/* 饮水记录卡片 */}
|
||||||
<FloatingCard style={styles.masonryCard} delay={500}>
|
<FloatingCard style={styles.masonryCard}>
|
||||||
<WaterIntakeCard
|
<WaterIntakeCard
|
||||||
selectedDate={currentSelectedDateString}
|
selectedDate={currentSelectedDateString}
|
||||||
style={styles.waterCardOverride}
|
style={styles.waterCardOverride}
|
||||||
@@ -617,26 +463,26 @@ export default function ExploreScreen() {
|
|||||||
|
|
||||||
|
|
||||||
{/* 基础代谢卡片 */}
|
{/* 基础代谢卡片 */}
|
||||||
<FloatingCard style={styles.masonryCard} delay={1250}>
|
<FloatingCard style={styles.masonryCard}>
|
||||||
<BasalMetabolismCard
|
<BasalMetabolismCard
|
||||||
value={basalMetabolism}
|
selectedDate={currentSelectedDate}
|
||||||
resetToken={animToken}
|
|
||||||
style={styles.basalMetabolismCardOverride}
|
style={styles.basalMetabolismCardOverride}
|
||||||
/>
|
/>
|
||||||
</FloatingCard>
|
</FloatingCard>
|
||||||
|
|
||||||
{/* 血氧饱和度卡片 */}
|
{/* 血氧饱和度卡片 */}
|
||||||
<FloatingCard style={styles.masonryCard} delay={1750}>
|
<FloatingCard style={styles.masonryCard}>
|
||||||
<OxygenSaturationCard
|
<OxygenSaturationCard
|
||||||
resetToken={animToken}
|
|
||||||
style={styles.basalMetabolismCardOverride}
|
style={styles.basalMetabolismCardOverride}
|
||||||
oxygenSaturation={oxygenSaturation}
|
|
||||||
/>
|
/>
|
||||||
</FloatingCard>
|
</FloatingCard>
|
||||||
|
|
||||||
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{/* 围度数据卡片 - 占满底部一行 */}
|
||||||
|
<CircumferenceCard style={styles.circumferenceCard} />
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -688,8 +534,8 @@ const styles = StyleSheet.create({
|
|||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
},
|
},
|
||||||
logoImage: {
|
logoImage: {
|
||||||
width: 36,
|
width: 28,
|
||||||
height: 36,
|
height: 28,
|
||||||
borderRadius: 20,
|
borderRadius: 20,
|
||||||
},
|
},
|
||||||
headerTextContainer: {
|
headerTextContainer: {
|
||||||
@@ -698,7 +544,7 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
headerTitle: {
|
headerTitle: {
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: '500',
|
fontWeight: '700',
|
||||||
color: '#192126',
|
color: '#192126',
|
||||||
},
|
},
|
||||||
debugButtonsContainer: {
|
debugButtonsContainer: {
|
||||||
@@ -938,7 +784,6 @@ const styles = StyleSheet.create({
|
|||||||
marginBottom: 16,
|
marginBottom: 16,
|
||||||
},
|
},
|
||||||
masonryContainer: {
|
masonryContainer: {
|
||||||
marginBottom: 16,
|
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
gap: 16,
|
gap: 16,
|
||||||
marginTop: 6,
|
marginTop: 6,
|
||||||
@@ -1001,6 +846,10 @@ const styles = StyleSheet.create({
|
|||||||
top: 0,
|
top: 0,
|
||||||
padding: 4,
|
padding: 4,
|
||||||
},
|
},
|
||||||
|
circumferenceCard: {
|
||||||
|
marginBottom: 36,
|
||||||
|
marginTop: 10,
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
123
app/_layout.tsx
@@ -9,46 +9,92 @@ import PrivacyConsentModal from '@/components/PrivacyConsentModal';
|
|||||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||||
import { useQuickActions } from '@/hooks/useQuickActions';
|
import { useQuickActions } from '@/hooks/useQuickActions';
|
||||||
import { clearAiCoachSessionCache } from '@/services/aiCoachSession';
|
import { clearAiCoachSessionCache } from '@/services/aiCoachSession';
|
||||||
import { backgroundTaskManager } from '@/services/backgroundTaskManager';
|
|
||||||
import { notificationService } from '@/services/notifications';
|
import { notificationService } from '@/services/notifications';
|
||||||
import { setupQuickActions } from '@/services/quickActions';
|
import { setupQuickActions } from '@/services/quickActions';
|
||||||
import { initializeWaterRecordBridge } from '@/services/waterRecordBridge';
|
import { initializeWaterRecordBridge } from '@/services/waterRecordBridge';
|
||||||
import { WaterRecordSource } from '@/services/waterRecords';
|
import { WaterRecordSource } from '@/services/waterRecords';
|
||||||
import { store } from '@/store';
|
import { store } from '@/store';
|
||||||
import { rehydrateUser, setPrivacyAgreed } from '@/store/userSlice';
|
import { fetchMyProfile, setPrivacyAgreed } from '@/store/userSlice';
|
||||||
import { createWaterRecordAction } from '@/store/waterSlice';
|
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 { clearPendingWaterRecords, syncPendingWidgetChanges } from '@/utils/widgetDataSync';
|
||||||
import React from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import RNExitApp from 'react-native-exit-app';
|
|
||||||
|
|
||||||
import { DialogProvider } from '@/components/ui/DialogProvider';
|
import { DialogProvider } from '@/components/ui/DialogProvider';
|
||||||
import { ToastProvider } from '@/contexts/ToastContext';
|
import { ToastProvider } from '@/contexts/ToastContext';
|
||||||
|
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||||
|
import { STORAGE_KEYS } from '@/services/api';
|
||||||
|
import { BackgroundTaskManager } from '@/services/backgroundTaskManager';
|
||||||
|
import { fetchChallenges } from '@/store/challengesSlice';
|
||||||
|
import AsyncStorage from '@/utils/kvStore';
|
||||||
import { Provider } from 'react-redux';
|
import { Provider } from 'react-redux';
|
||||||
|
|
||||||
|
|
||||||
function Bootstrapper({ children }: { children: React.ReactNode }) {
|
function Bootstrapper({ children }: { children: React.ReactNode }) {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const { privacyAgreed, profile } = useAppSelector((state) => state.user);
|
const { profile } = useAppSelector((state) => state.user);
|
||||||
const [showPrivacyModal, setShowPrivacyModal] = React.useState(false);
|
const [showPrivacyModal, setShowPrivacyModal] = React.useState(false);
|
||||||
const [userDataLoaded, setUserDataLoaded] = React.useState(false);
|
const { isLoggedIn } = useAuthGuard()
|
||||||
|
|
||||||
// 初始化快捷动作处理
|
// 初始化快捷动作处理
|
||||||
useQuickActions();
|
useQuickActions();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isLoggedIn) {
|
||||||
|
dispatch(fetchChallenges());
|
||||||
|
}
|
||||||
|
}, [isLoggedIn]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const loadUserData = async () => {
|
const loadUserData = async () => {
|
||||||
await dispatch(rehydrateUser());
|
// 数据已经在启动界面预加载,这里只需要快速同步到 Redux 状态
|
||||||
setUserDataLoaded(true);
|
await dispatch(fetchMyProfile());
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const initHealthPermissions = async () => {
|
||||||
|
// 初始化 HealthKit 权限管理系统
|
||||||
|
try {
|
||||||
|
console.log('初始化 HealthKit 权限管理系统...');
|
||||||
|
initializeHealthPermissions();
|
||||||
|
|
||||||
|
// 延迟请求权限,避免应用启动时弹窗
|
||||||
|
setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
await ensureHealthPermissions();
|
||||||
|
console.log('HealthKit 权限请求完成');
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('HealthKit 权限请求失败,可能在模拟器上运行:', error);
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
|
||||||
|
console.log('HealthKit 权限管理初始化完成');
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('HealthKit 权限管理初始化失败:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const initializeNotifications = async () => {
|
const initializeNotifications = async () => {
|
||||||
try {
|
try {
|
||||||
|
await BackgroundTaskManager.getInstance().initialize();
|
||||||
// 初始化通知服务
|
// 初始化通知服务
|
||||||
await notificationService.initialize();
|
await notificationService.initialize();
|
||||||
console.log('通知服务初始化成功');
|
console.log('通知服务初始化成功');
|
||||||
|
|
||||||
// 初始化后台任务管理器
|
// 注册午餐提醒(12:00)
|
||||||
await backgroundTaskManager.initialize();
|
await NutritionNotificationHelpers.scheduleDailyLunchReminder(profile.name || '');
|
||||||
console.log('后台任务管理器初始化成功');
|
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();
|
await setupQuickActions();
|
||||||
@@ -62,7 +108,7 @@ function Bootstrapper({ children }: { children: React.ReactNode }) {
|
|||||||
const widgetSync = await syncPendingWidgetChanges();
|
const widgetSync = await syncPendingWidgetChanges();
|
||||||
if (widgetSync.hasPendingChanges && widgetSync.pendingRecords) {
|
if (widgetSync.hasPendingChanges && widgetSync.pendingRecords) {
|
||||||
console.log(`检测到 ${widgetSync.pendingRecords.length} 条待同步的水记录`);
|
console.log(`检测到 ${widgetSync.pendingRecords.length} 条待同步的水记录`);
|
||||||
|
|
||||||
// 将待同步的记录添加到 Redux store
|
// 将待同步的记录添加到 Redux store
|
||||||
for (const record of widgetSync.pendingRecords) {
|
for (const record of widgetSync.pendingRecords) {
|
||||||
try {
|
try {
|
||||||
@@ -71,13 +117,13 @@ function Bootstrapper({ children }: { children: React.ReactNode }) {
|
|||||||
recordedAt: record.recordedAt,
|
recordedAt: record.recordedAt,
|
||||||
source: WaterRecordSource.Auto, // 标记为自动添加(来自Widget)
|
source: WaterRecordSource.Auto, // 标记为自动添加(来自Widget)
|
||||||
})).unwrap();
|
})).unwrap();
|
||||||
|
|
||||||
console.log(`成功同步水记录: ${record.amount}ml at ${record.recordedAt}`);
|
console.log(`成功同步水记录: ${record.amount}ml at ${record.recordedAt}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('同步水记录失败:', error);
|
console.error('同步水记录失败:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 清除已同步的记录
|
// 清除已同步的记录
|
||||||
await clearPendingWaterRecords();
|
await clearPendingWaterRecords();
|
||||||
console.log('所有待同步的水记录已处理完成');
|
console.log('所有待同步的水记录已处理完成');
|
||||||
@@ -88,46 +134,24 @@ function Bootstrapper({ children }: { children: React.ReactNode }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
loadUserData();
|
loadUserData();
|
||||||
|
initHealthPermissions();
|
||||||
initializeNotifications();
|
initializeNotifications();
|
||||||
|
|
||||||
|
|
||||||
// 冷启动时清空 AI 教练会话缓存
|
// 冷启动时清空 AI 教练会话缓存
|
||||||
clearAiCoachSessionCache();
|
clearAiCoachSessionCache();
|
||||||
|
|
||||||
}, [dispatch]);
|
}, [dispatch]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
// 当用户数据加载完成后,检查是否需要显示隐私同意弹窗
|
|
||||||
if (userDataLoaded && !privacyAgreed) {
|
const getPrivacyAgreed = async () => {
|
||||||
setShowPrivacyModal(true);
|
const str = await AsyncStorage.getItem(STORAGE_KEYS.privacyAgreed)
|
||||||
|
|
||||||
|
setShowPrivacyModal(str !== 'true');
|
||||||
}
|
}
|
||||||
}, [userDataLoaded, privacyAgreed]);
|
getPrivacyAgreed();
|
||||||
|
}, []);
|
||||||
// 当用户数据加载完成且用户名存在时,注册所有提醒
|
|
||||||
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]);
|
|
||||||
|
|
||||||
const handlePrivacyAgree = () => {
|
const handlePrivacyAgree = () => {
|
||||||
dispatch(setPrivacyAgreed());
|
dispatch(setPrivacyAgreed());
|
||||||
@@ -135,7 +159,7 @@ function Bootstrapper({ children }: { children: React.ReactNode }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handlePrivacyDisagree = () => {
|
const handlePrivacyDisagree = () => {
|
||||||
RNExitApp.exitApp();
|
// RNExitApp.exitApp();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -180,6 +204,7 @@ export default function RootLayout() {
|
|||||||
<Stack.Screen name="legal/user-agreement" options={{ headerShown: true, title: '用户协议' }} />
|
<Stack.Screen name="legal/user-agreement" options={{ headerShown: true, title: '用户协议' }} />
|
||||||
<Stack.Screen name="legal/privacy-policy" options={{ headerShown: true, title: '隐私政策' }} />
|
<Stack.Screen name="legal/privacy-policy" options={{ headerShown: true, title: '隐私政策' }} />
|
||||||
<Stack.Screen name="article/[id]" options={{ headerShown: false }} />
|
<Stack.Screen name="article/[id]" options={{ headerShown: false }} />
|
||||||
|
<Stack.Screen name="water-detail" options={{ headerShown: false }} />
|
||||||
<Stack.Screen name="water-settings" options={{ headerShown: false }} />
|
<Stack.Screen name="water-settings" options={{ headerShown: false }} />
|
||||||
<Stack.Screen name="+not-found" />
|
<Stack.Screen name="+not-found" />
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { PRIVACY_POLICY_URL, USER_AGREEMENT_URL } from '@/constants/Agree';
|
|||||||
import { Colors } from '@/constants/Colors';
|
import { Colors } from '@/constants/Colors';
|
||||||
import { useAppDispatch } from '@/hooks/redux';
|
import { useAppDispatch } from '@/hooks/redux';
|
||||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
import { login } from '@/store/userSlice';
|
import { fetchMyProfile, login } from '@/store/userSlice';
|
||||||
import Toast from 'react-native-toast-message';
|
import Toast from 'react-native-toast-message';
|
||||||
|
|
||||||
export default function LoginScreen() {
|
export default function LoginScreen() {
|
||||||
@@ -113,6 +113,9 @@ export default function LoginScreen() {
|
|||||||
}
|
}
|
||||||
await dispatch(login({ appleIdentityToken: identityToken })).unwrap();
|
await dispatch(login({ appleIdentityToken: identityToken })).unwrap();
|
||||||
|
|
||||||
|
// 拉取用户信息
|
||||||
|
await dispatch(fetchMyProfile())
|
||||||
|
|
||||||
Toast.show({
|
Toast.show({
|
||||||
text1: '登录成功',
|
text1: '登录成功',
|
||||||
type: 'success',
|
type: 'success',
|
||||||
@@ -130,7 +133,9 @@ export default function LoginScreen() {
|
|||||||
router.back();
|
router.back();
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} 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 || '登录失败,请稍后再试';
|
const message = err?.message || '登录失败,请稍后再试';
|
||||||
Alert.alert('登录失败', message);
|
Alert.alert('登录失败', message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -223,8 +228,8 @@ export default function LoginScreen() {
|
|||||||
|
|
||||||
<ScrollView contentContainerStyle={styles.content} showsVerticalScrollIndicator={false}>
|
<ScrollView contentContainerStyle={styles.content} showsVerticalScrollIndicator={false}>
|
||||||
<View style={styles.headerWrap}>
|
<View style={styles.headerWrap}>
|
||||||
<ThemedText style={[styles.title, { color: color.text }]}>Sealife</ThemedText>
|
<ThemedText style={[styles.title, { color: color.text }]}>Out Live</ThemedText>
|
||||||
<ThemedText style={[styles.subtitle, { color: color.textMuted }]}>欢迎登录Sealife</ThemedText>
|
<ThemedText style={[styles.subtitle, { color: color.textMuted }]}>欢迎登录Out Live</ThemedText>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Apple 登录 */}
|
{/* 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import { Stack } from 'expo-router';
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
export default function ChallengeLayout() {
|
|
||||||
return (
|
|
||||||
<Stack screenOptions={{ headerShown: false }}>
|
|
||||||
<Stack.Screen name="index" />
|
|
||||||
<Stack.Screen name="day" />
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,176 +0,0 @@
|
|||||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
|
||||||
import { Colors } from '@/constants/Colors';
|
|
||||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
|
||||||
import { completeDay, setCustom } from '@/store/challengeSlice';
|
|
||||||
import type { Exercise, ExerciseCustomConfig } from '@/utils/pilatesPlan';
|
|
||||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
|
||||||
import React, { useState } from 'react';
|
|
||||||
import { FlatList, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
|
||||||
|
|
||||||
export default function ChallengeDayScreen() {
|
|
||||||
const { day } = useLocalSearchParams<{ day: string }>();
|
|
||||||
const router = useRouter();
|
|
||||||
const dispatch = useAppDispatch();
|
|
||||||
const challenge = useAppSelector((s) => (s as any).challenge);
|
|
||||||
const dayNumber = Math.max(1, Math.min(30, parseInt(String(day || '1'), 10)));
|
|
||||||
const dayState = challenge?.days?.[dayNumber - 1];
|
|
||||||
const [currentSetIndexByExercise, setCurrentSetIndexByExercise] = useState<Record<string, number>>({});
|
|
||||||
const [custom, setCustomLocal] = useState<ExerciseCustomConfig[]>(dayState?.custom || []);
|
|
||||||
|
|
||||||
const isLocked = dayState?.status === 'locked';
|
|
||||||
const isCompleted = dayState?.status === 'completed';
|
|
||||||
const plan = dayState?.plan;
|
|
||||||
|
|
||||||
// 不再强制所有动作完成,始终允许完成
|
|
||||||
const canFinish = true;
|
|
||||||
|
|
||||||
const handleNextSet = (ex: Exercise) => {
|
|
||||||
const curr = currentSetIndexByExercise[ex.key] ?? 0;
|
|
||||||
if (curr < ex.sets.length) {
|
|
||||||
setCurrentSetIndexByExercise((prev) => ({ ...prev, [ex.key]: curr + 1 }));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleComplete = async () => {
|
|
||||||
// 持久化自定义配置
|
|
||||||
await dispatch(setCustom({ dayNumber, custom: custom }));
|
|
||||||
await dispatch(completeDay(dayNumber));
|
|
||||||
router.back();
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateCustom = (key: string, partial: Partial<ExerciseCustomConfig>) => {
|
|
||||||
setCustomLocal((prev) => {
|
|
||||||
const next = prev.map((c) => (c.key === key ? { ...c, ...partial } : c));
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!plan) {
|
|
||||||
return (
|
|
||||||
<SafeAreaView style={styles.safeArea}>
|
|
||||||
<View style={styles.container}><Text>加载中...</Text></View>
|
|
||||||
</SafeAreaView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SafeAreaView style={styles.safeArea}>
|
|
||||||
<View style={styles.container}>
|
|
||||||
<HeaderBar title={`第${plan.dayNumber}天`} onBack={() => router.back()} withSafeTop={false} transparent />
|
|
||||||
<Text style={styles.title}>{plan.title}</Text>
|
|
||||||
<Text style={styles.subtitle}>{plan.focus}</Text>
|
|
||||||
|
|
||||||
<FlatList
|
|
||||||
data={plan.exercises}
|
|
||||||
keyExtractor={(item) => item.key}
|
|
||||||
contentContainerStyle={{ paddingHorizontal: 20, paddingBottom: 120 }}
|
|
||||||
renderItem={({ item }) => {
|
|
||||||
const doneSets = currentSetIndexByExercise[item.key] ?? 0;
|
|
||||||
const conf = custom.find((c) => c.key === item.key);
|
|
||||||
const targetSets = conf?.sets ?? item.sets.length;
|
|
||||||
const perSetDuration = conf?.durationSec ?? item.sets[0]?.durationSec ?? 40;
|
|
||||||
return (
|
|
||||||
<View style={styles.exerciseCard}>
|
|
||||||
<View style={styles.exerciseHeader}>
|
|
||||||
<Text style={styles.exerciseName}>{item.name}</Text>
|
|
||||||
<Text style={styles.exerciseDesc}>{item.description}</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.controlsRow}>
|
|
||||||
<TouchableOpacity style={[styles.toggleBtn, conf?.enabled === false && styles.toggleBtnOff]} onPress={() => updateCustom(item.key, { enabled: !(conf?.enabled ?? true) })}>
|
|
||||||
<Text style={styles.toggleBtnText}>{conf?.enabled === false ? '已关闭' : '已启用'}</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<View style={styles.counterBox}>
|
|
||||||
<Text style={styles.counterLabel}>组数</Text>
|
|
||||||
<View style={styles.counterRow}>
|
|
||||||
<TouchableOpacity style={styles.counterBtn} onPress={() => updateCustom(item.key, { sets: Math.max(1, (conf?.sets ?? targetSets) - 1) })}><Text style={styles.counterBtnText}>-</Text></TouchableOpacity>
|
|
||||||
<Text style={styles.counterValue}>{conf?.sets ?? targetSets}</Text>
|
|
||||||
<TouchableOpacity style={styles.counterBtn} onPress={() => updateCustom(item.key, { sets: Math.min(10, (conf?.sets ?? targetSets) + 1) })}><Text style={styles.counterBtnText}>+</Text></TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<View style={styles.counterBox}>
|
|
||||||
<Text style={styles.counterLabel}>时长/组</Text>
|
|
||||||
<View style={styles.counterRow}>
|
|
||||||
<TouchableOpacity style={styles.counterBtn} onPress={() => updateCustom(item.key, { durationSec: Math.max(10, (conf?.durationSec ?? perSetDuration) - 5) })}><Text style={styles.counterBtnText}>-</Text></TouchableOpacity>
|
|
||||||
<Text style={styles.counterValue}>{conf?.durationSec ?? perSetDuration}s</Text>
|
|
||||||
<TouchableOpacity style={styles.counterBtn} onPress={() => updateCustom(item.key, { durationSec: Math.min(180, (conf?.durationSec ?? perSetDuration) + 5) })}><Text style={styles.counterBtnText}>+</Text></TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<View style={styles.setsRow}>
|
|
||||||
{Array.from({ length: targetSets }).map((_, idx) => (
|
|
||||||
<View key={idx} style={[styles.setPill, idx < doneSets ? styles.setPillDone : styles.setPillTodo]}>
|
|
||||||
<Text style={[styles.setPillText, idx < doneSets ? styles.setPillTextDone : styles.setPillTextTodo]}>
|
|
||||||
{perSetDuration}s
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
<TouchableOpacity style={styles.nextSetBtn} onPress={() => handleNextSet(item)} disabled={doneSets >= targetSets || conf?.enabled === false}>
|
|
||||||
<Text style={styles.nextSetText}>{doneSets >= item.sets.length ? '本动作完成' : '完成一组'}</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
{item.tips && (
|
|
||||||
<View style={styles.tipsBox}>
|
|
||||||
{item.tips.map((t: string, i: number) => (
|
|
||||||
<Text key={i} style={styles.tipText}>• {t}</Text>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<View style={styles.bottomBar}>
|
|
||||||
<TouchableOpacity style={[styles.finishBtn, !canFinish && { opacity: 0.5 }]} disabled={!canFinish || isLocked || isCompleted} onPress={handleComplete}>
|
|
||||||
<Text style={styles.finishBtnText}>{isCompleted ? '已完成' : '完成今日训练'}</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</SafeAreaView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
safeArea: { flex: 1, backgroundColor: '#F7F8FA' },
|
|
||||||
container: { flex: 1, backgroundColor: '#F7F8FA' },
|
|
||||||
header: { paddingHorizontal: 20, paddingTop: 10, paddingBottom: 10 },
|
|
||||||
headerRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
|
|
||||||
backButton: { width: 32, height: 32, borderRadius: 16, alignItems: 'center', justifyContent: 'center', backgroundColor: '#E5E7EB' },
|
|
||||||
headerTitle: { fontSize: 18, fontWeight: '800', color: '#1A1A1A' },
|
|
||||||
title: { marginTop: 6, fontSize: 20, fontWeight: '800', color: '#1A1A1A' },
|
|
||||||
subtitle: { marginTop: 6, fontSize: 12, color: '#6B7280' },
|
|
||||||
exerciseCard: {
|
|
||||||
backgroundColor: '#FFFFFF', borderRadius: 16, padding: 16, marginTop: 12,
|
|
||||||
shadowColor: '#000', shadowOpacity: 0.06, shadowRadius: 12, shadowOffset: { width: 0, height: 6 }, elevation: 3,
|
|
||||||
},
|
|
||||||
exerciseHeader: { marginBottom: 8 },
|
|
||||||
exerciseName: { fontSize: 16, fontWeight: '800', color: '#111827' },
|
|
||||||
exerciseDesc: { marginTop: 4, fontSize: 12, color: '#6B7280' },
|
|
||||||
setsRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, marginTop: 8 },
|
|
||||||
controlsRow: { flexDirection: 'row', alignItems: 'center', gap: 12, flexWrap: 'wrap', marginTop: 8 },
|
|
||||||
toggleBtn: { backgroundColor: '#111827', paddingHorizontal: 12, paddingVertical: 8, borderRadius: 8 },
|
|
||||||
toggleBtnOff: { backgroundColor: '#9CA3AF' },
|
|
||||||
toggleBtnText: { color: '#FFFFFF', fontWeight: '700' },
|
|
||||||
counterBox: { backgroundColor: '#F3F4F6', borderRadius: 8, padding: 8 },
|
|
||||||
counterLabel: { fontSize: 10, color: '#6B7280' },
|
|
||||||
counterRow: { flexDirection: 'row', alignItems: 'center' },
|
|
||||||
counterBtn: { backgroundColor: '#E5E7EB', width: 28, height: 28, borderRadius: 6, alignItems: 'center', justifyContent: 'center' },
|
|
||||||
counterBtnText: { fontWeight: '800', color: '#111827' },
|
|
||||||
counterValue: { minWidth: 40, textAlign: 'center', fontWeight: '700', color: '#111827' },
|
|
||||||
setPill: { paddingHorizontal: 10, paddingVertical: 6, borderRadius: 999 },
|
|
||||||
setPillTodo: { backgroundColor: '#F3F4F6' },
|
|
||||||
setPillDone: { backgroundColor: Colors.light.accentGreen },
|
|
||||||
setPillText: { fontSize: 12, fontWeight: '700' },
|
|
||||||
setPillTextTodo: { color: '#6B7280' },
|
|
||||||
setPillTextDone: { color: '#192126' },
|
|
||||||
nextSetBtn: { marginTop: 10, alignSelf: 'flex-start', backgroundColor: '#111827', paddingHorizontal: 12, paddingVertical: 8, borderRadius: 8 },
|
|
||||||
nextSetText: { color: '#FFFFFF', fontWeight: '700' },
|
|
||||||
tipsBox: { marginTop: 10, backgroundColor: '#F9FAFB', borderRadius: 8, padding: 10 },
|
|
||||||
tipText: { fontSize: 12, color: '#6B7280', lineHeight: 18 },
|
|
||||||
bottomBar: { position: 'absolute', left: 0, right: 0, bottom: 0, padding: 20, backgroundColor: 'transparent' },
|
|
||||||
finishBtn: { backgroundColor: Colors.light.accentGreen, paddingVertical: 14, borderRadius: 999, alignItems: 'center' },
|
|
||||||
finishBtnText: { color: '#192126', fontWeight: '800', fontSize: 16 },
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
|
||||||
import { Colors } from '@/constants/Colors';
|
|
||||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
|
||||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
|
||||||
import { initChallenge } from '@/store/challengeSlice';
|
|
||||||
import { estimateSessionMinutesWithCustom } from '@/utils/pilatesPlan';
|
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
|
||||||
import { useRouter } from 'expo-router';
|
|
||||||
import React, { useEffect, useMemo } from 'react';
|
|
||||||
import { Dimensions, FlatList, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
|
||||||
|
|
||||||
export default function ChallengeHomeScreen() {
|
|
||||||
const dispatch = useAppDispatch();
|
|
||||||
const router = useRouter();
|
|
||||||
const { ensureLoggedIn } = useAuthGuard();
|
|
||||||
const challenge = useAppSelector((s) => (s as any).challenge);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
dispatch(initChallenge());
|
|
||||||
}, [dispatch]);
|
|
||||||
|
|
||||||
const progress = useMemo(() => {
|
|
||||||
const total = challenge?.days?.length || 30;
|
|
||||||
const done = challenge?.days?.filter((d: any) => d.status === 'completed').length || 0;
|
|
||||||
return total ? done / total : 0;
|
|
||||||
}, [challenge?.days]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SafeAreaView style={styles.safeArea}>
|
|
||||||
<View style={styles.container}>
|
|
||||||
<HeaderBar title="30天普拉提打卡" onBack={() => router.back()} withSafeTop={false} transparent />
|
|
||||||
<Text style={styles.subtitle}>专注核心、体态与柔韧 · 连续完成解锁徽章</Text>
|
|
||||||
|
|
||||||
{/* 进度环与统计 */}
|
|
||||||
<View style={styles.summaryCard}>
|
|
||||||
<View style={styles.summaryLeft}>
|
|
||||||
<View style={styles.progressPill}>
|
|
||||||
<View style={[styles.progressFill, { width: `${Math.round((progress || 0) * 100)}%` }]} />
|
|
||||||
</View>
|
|
||||||
<Text style={styles.progressText}>{Math.round((progress || 0) * 100)}%</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.summaryRight}>
|
|
||||||
<Text style={styles.summaryItem}><Text style={styles.summaryItemValue}>{challenge?.streak ?? 0}</Text> 天连续</Text>
|
|
||||||
<Text style={styles.summaryItem}><Text style={styles.summaryItemValue}>{(challenge?.days?.filter((d: any) => d.status === 'completed').length) ?? 0}</Text> / 30 完成</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 日历格子(简单 6x5 网格) */}
|
|
||||||
<FlatList
|
|
||||||
data={challenge?.days || []}
|
|
||||||
keyExtractor={(item) => String(item.plan.dayNumber)}
|
|
||||||
numColumns={5}
|
|
||||||
columnWrapperStyle={{ justifyContent: 'space-between', marginBottom: 12 }}
|
|
||||||
contentContainerStyle={{ paddingHorizontal: 20, paddingTop: 10, paddingBottom: 40 }}
|
|
||||||
renderItem={({ item }) => {
|
|
||||||
const { plan, status } = item;
|
|
||||||
const isLocked = status === 'locked';
|
|
||||||
const isCompleted = status === 'completed';
|
|
||||||
const minutes = estimateSessionMinutesWithCustom(plan, item.custom);
|
|
||||||
return (
|
|
||||||
<TouchableOpacity
|
|
||||||
disabled={isLocked}
|
|
||||||
onPress={async () => {
|
|
||||||
if (!(await ensureLoggedIn({ redirectTo: '/challenge', redirectParams: {} }))) return;
|
|
||||||
router.push({ pathname: '/challenge/day', params: { day: String(plan.dayNumber) } });
|
|
||||||
}}
|
|
||||||
style={[styles.dayCell, isLocked && styles.dayCellLocked, isCompleted && styles.dayCellCompleted]}
|
|
||||||
activeOpacity={0.8}
|
|
||||||
>
|
|
||||||
<Text style={[styles.dayNumber, isLocked && styles.dayNumberLocked]}>{plan.dayNumber}</Text>
|
|
||||||
<Text style={styles.dayMinutes}>{minutes}′</Text>
|
|
||||||
{isCompleted && <Ionicons name="checkmark-circle" size={18} color="#10B981" style={{ position: 'absolute', top: 6, right: 6 }} />}
|
|
||||||
{isLocked && <Ionicons name="lock-closed" size={16} color="#9CA3AF" style={{ position: 'absolute', top: 6, right: 6 }} />}
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 底部 CTA */}
|
|
||||||
<View style={styles.bottomBar}>
|
|
||||||
<TouchableOpacity style={styles.startButton} onPress={async () => {
|
|
||||||
if (!(await ensureLoggedIn({ redirectTo: '/challenge' }))) return;
|
|
||||||
router.push({ pathname: '/challenge/day', params: { day: String((challenge?.days?.find((d: any) => d.status === 'available')?.plan.dayNumber) || 1) } });
|
|
||||||
}}>
|
|
||||||
<Text style={styles.startButtonText}>开始今日训练</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</SafeAreaView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { width } = Dimensions.get('window');
|
|
||||||
const cellSize = (width - 40 - 4 * 12) / 5; // 20 padding *2, 12 spacing *4
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
safeArea: { flex: 1, backgroundColor: '#F7F8FA' },
|
|
||||||
container: { flex: 1, backgroundColor: '#F7F8FA' },
|
|
||||||
header: { paddingHorizontal: 20, paddingTop: 10 },
|
|
||||||
headerRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
|
|
||||||
backButton: { width: 32, height: 32, borderRadius: 16, alignItems: 'center', justifyContent: 'center', backgroundColor: '#E5E7EB' },
|
|
||||||
headerTitle: { fontSize: 22, fontWeight: '800', color: '#1A1A1A' },
|
|
||||||
subtitle: { marginTop: 6, fontSize: 12, color: '#6B7280' },
|
|
||||||
summaryCard: {
|
|
||||||
marginTop: 16,
|
|
||||||
marginHorizontal: 20,
|
|
||||||
backgroundColor: '#FFFFFF',
|
|
||||||
borderRadius: 16,
|
|
||||||
padding: 16,
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
shadowColor: '#000', shadowOpacity: 0.06, shadowRadius: 12, shadowOffset: { width: 0, height: 6 }, elevation: 3,
|
|
||||||
},
|
|
||||||
summaryLeft: { flexDirection: 'row', alignItems: 'center' },
|
|
||||||
progressPill: { width: 120, height: 10, borderRadius: 999, backgroundColor: '#E5E7EB', overflow: 'hidden' },
|
|
||||||
progressFill: { height: '100%', backgroundColor: Colors.light.accentGreen },
|
|
||||||
progressText: { marginLeft: 12, fontWeight: '700', color: '#111827' },
|
|
||||||
summaryRight: {},
|
|
||||||
summaryItem: { fontSize: 12, color: '#6B7280' },
|
|
||||||
summaryItemValue: { fontWeight: '800', color: '#111827' },
|
|
||||||
dayCell: {
|
|
||||||
width: cellSize,
|
|
||||||
height: cellSize,
|
|
||||||
borderRadius: 16,
|
|
||||||
backgroundColor: '#FFFFFF',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
shadowColor: '#000', shadowOpacity: 0.06, shadowRadius: 12, shadowOffset: { width: 0, height: 6 }, elevation: 3,
|
|
||||||
},
|
|
||||||
dayCellLocked: { backgroundColor: '#F3F4F6' },
|
|
||||||
dayCellCompleted: { backgroundColor: '#ECFDF5', borderWidth: 1, borderColor: '#A7F3D0' },
|
|
||||||
dayNumber: { fontWeight: '800', color: '#111827', fontSize: 16 },
|
|
||||||
dayNumberLocked: { color: '#9CA3AF' },
|
|
||||||
dayMinutes: { marginTop: 4, fontSize: 12, color: '#6B7280' },
|
|
||||||
bottomBar: { padding: 20 },
|
|
||||||
startButton: { backgroundColor: Colors.light.accentGreen, paddingVertical: 14, borderRadius: 999, alignItems: 'center' },
|
|
||||||
startButtonText: { color: '#192126', fontWeight: '800', fontSize: 16 },
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
822
app/challenges/[id]/index.tsx
Normal file
@@ -0,0 +1,822 @@
|
|||||||
|
import ChallengeProgressCard from '@/components/challenges/ChallengeProgressCard';
|
||||||
|
import { ChallengeRankingItem } from '@/components/challenges/ChallengeRankingItem';
|
||||||
|
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||||
|
import { Colors } from '@/constants/Colors';
|
||||||
|
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||||
|
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||||
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
|
import {
|
||||||
|
fetchChallengeDetail,
|
||||||
|
fetchChallengeRankings,
|
||||||
|
joinChallenge,
|
||||||
|
leaveChallenge,
|
||||||
|
reportChallengeProgress,
|
||||||
|
selectChallengeById,
|
||||||
|
selectChallengeDetailError,
|
||||||
|
selectChallengeDetailStatus,
|
||||||
|
selectChallengeRankingList,
|
||||||
|
selectJoinError,
|
||||||
|
selectJoinStatus,
|
||||||
|
selectLeaveError,
|
||||||
|
selectLeaveStatus,
|
||||||
|
selectProgressStatus
|
||||||
|
} from '@/store/challengesSlice';
|
||||||
|
import { Toast } from '@/utils/toast.utils';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { BlurView } from 'expo-blur';
|
||||||
|
import { Image } from 'expo-image';
|
||||||
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
|
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||||
|
import LottieView from 'lottie-react-native';
|
||||||
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Alert,
|
||||||
|
Dimensions,
|
||||||
|
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.76;
|
||||||
|
const CTA_GRADIENT: [string, string] = ['#5E8BFF', '#6B6CFF'];
|
||||||
|
const CTA_DISABLED_GRADIENT: [string, string] = ['#d3d7e8', '#c1c6da'];
|
||||||
|
|
||||||
|
const isHttpUrl = (value: string) => /^https?:\/\//i.test(value);
|
||||||
|
|
||||||
|
const formatMonthDay = (value?: string): string | undefined => {
|
||||||
|
if (!value) return undefined;
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return undefined;
|
||||||
|
return `${date.getMonth() + 1}月${date.getDate()}日`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildDateRangeLabel = (challenge?: {
|
||||||
|
startAt?: string;
|
||||||
|
endAt?: string;
|
||||||
|
periodLabel?: string;
|
||||||
|
durationLabel?: string;
|
||||||
|
}): string => {
|
||||||
|
if (!challenge) return '';
|
||||||
|
const startLabel = formatMonthDay(challenge.startAt);
|
||||||
|
const endLabel = formatMonthDay(challenge.endAt);
|
||||||
|
if (startLabel && endLabel) {
|
||||||
|
return `${startLabel} - ${endLabel}`;
|
||||||
|
}
|
||||||
|
return challenge.periodLabel ?? challenge.durationLabel ?? '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatParticipantsLabel = (count?: number): string => {
|
||||||
|
if (typeof count !== 'number') return '持续更新中';
|
||||||
|
return `${count.toLocaleString('zh-CN')} 人正在参与`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ChallengeDetailScreen() {
|
||||||
|
const { id } = useLocalSearchParams<{ id?: string }>();
|
||||||
|
const router = useRouter();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||||
|
const colorTokens = Colors[theme];
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
const { ensureLoggedIn } = useAuthGuard();
|
||||||
|
|
||||||
|
const challengeSelector = useMemo(() => (id ? selectChallengeById(id) : undefined), [id]);
|
||||||
|
const challenge = useAppSelector((state) => (challengeSelector ? challengeSelector(state) : undefined));
|
||||||
|
|
||||||
|
|
||||||
|
const detailStatusSelector = useMemo(() => (id ? selectChallengeDetailStatus(id) : undefined), [id]);
|
||||||
|
const detailStatus = useAppSelector((state) => (detailStatusSelector ? detailStatusSelector(state) : 'idle'));
|
||||||
|
const detailErrorSelector = useMemo(() => (id ? selectChallengeDetailError(id) : undefined), [id]);
|
||||||
|
const detailError = useAppSelector((state) => (detailErrorSelector ? detailErrorSelector(state) : undefined));
|
||||||
|
|
||||||
|
const joinStatusSelector = useMemo(() => (id ? selectJoinStatus(id) : undefined), [id]);
|
||||||
|
const joinStatus = useAppSelector((state) => (joinStatusSelector ? joinStatusSelector(state) : 'idle'));
|
||||||
|
const joinErrorSelector = useMemo(() => (id ? selectJoinError(id) : undefined), [id]);
|
||||||
|
const joinError = useAppSelector((state) => (joinErrorSelector ? joinErrorSelector(state) : undefined));
|
||||||
|
|
||||||
|
const leaveStatusSelector = useMemo(() => (id ? selectLeaveStatus(id) : undefined), [id]);
|
||||||
|
const leaveStatus = useAppSelector((state) => (leaveStatusSelector ? leaveStatusSelector(state) : 'idle'));
|
||||||
|
const leaveErrorSelector = useMemo(() => (id ? selectLeaveError(id) : undefined), [id]);
|
||||||
|
const leaveError = useAppSelector((state) => (leaveErrorSelector ? leaveErrorSelector(state) : undefined));
|
||||||
|
|
||||||
|
const progressStatusSelector = useMemo(() => (id ? selectProgressStatus(id) : undefined), [id]);
|
||||||
|
const progressStatus = useAppSelector((state) => (progressStatusSelector ? progressStatusSelector(state) : 'idle'));
|
||||||
|
|
||||||
|
const rankingListSelector = useMemo(() => (id ? selectChallengeRankingList(id) : undefined), [id]);
|
||||||
|
const rankingList = useAppSelector((state) => (rankingListSelector ? rankingListSelector(state) : undefined));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const getData = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await dispatch(fetchChallengeDetail(id)).unwrap;
|
||||||
|
} catch (error) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (id) {
|
||||||
|
getData(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
}, [dispatch, id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (id && !rankingList) {
|
||||||
|
void dispatch(fetchChallengeRankings({ id }));
|
||||||
|
}
|
||||||
|
}, [dispatch, id, rankingList]);
|
||||||
|
|
||||||
|
|
||||||
|
const [showCelebration, setShowCelebration] = useState(false);
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showCelebration) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setShowCelebration(false);
|
||||||
|
}, 2400);
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [showCelebration]);
|
||||||
|
|
||||||
|
const progress = challenge?.progress;
|
||||||
|
|
||||||
|
const rankingData = useMemo(() => {
|
||||||
|
const source = rankingList?.items ?? challenge?.rankings ?? [];
|
||||||
|
return source.slice(0, 10);
|
||||||
|
}, [challenge?.rankings, rankingList?.items]);
|
||||||
|
|
||||||
|
const participantAvatars = useMemo(
|
||||||
|
() => rankingData.filter((item) => item.avatar).map((item) => item.avatar as string).slice(0, 6),
|
||||||
|
[rankingData],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleViewAllRanking = () => {
|
||||||
|
if (!id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.push({ pathname: '/challenges/[id]/leaderboard', params: { id } });
|
||||||
|
};
|
||||||
|
|
||||||
|
const dateRangeLabel = useMemo(
|
||||||
|
() =>
|
||||||
|
buildDateRangeLabel({
|
||||||
|
startAt: challenge?.startAt,
|
||||||
|
endAt: challenge?.endAt,
|
||||||
|
periodLabel: challenge?.periodLabel,
|
||||||
|
durationLabel: challenge?.durationLabel,
|
||||||
|
}),
|
||||||
|
[challenge?.startAt, challenge?.endAt, challenge?.periodLabel, challenge?.durationLabel],
|
||||||
|
);
|
||||||
|
|
||||||
|
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 = async () => {
|
||||||
|
if (!id || joinStatus === 'loading') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isLoggedIn = await ensureLoggedIn();
|
||||||
|
if (!isLoggedIn) {
|
||||||
|
// 如果未登录,用户会被重定向到登录页面
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await dispatch(joinChallenge(id));
|
||||||
|
setShowCelebration(true)
|
||||||
|
} catch (error) {
|
||||||
|
Toast.error('加入挑战失败')
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLeave = async () => {
|
||||||
|
if (!id || leaveStatus === 'loading') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await dispatch(leaveChallenge(id)).unwrap();
|
||||||
|
await dispatch(fetchChallengeDetail(id)).unwrap();
|
||||||
|
} catch (error) {
|
||||||
|
Toast.error('退出挑战失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLeaveConfirm = () => {
|
||||||
|
if (!id || leaveStatus === 'loading') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Alert.alert('确认退出挑战?', '退出后需要重新加入才能继续坚持。', [
|
||||||
|
{ text: '取消', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: '退出挑战',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: () => {
|
||||||
|
void handleLeave();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleProgressReport = () => {
|
||||||
|
if (!id || progressStatus === 'loading') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dispatch(reportChallengeProgress({ id }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const isJoined = challenge?.isJoined ?? false;
|
||||||
|
const isLoadingInitial = detailStatus === 'loading' && !challenge;
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoadingInitial) {
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={[styles.safeArea, { backgroundColor: colorTokens.background }]}>
|
||||||
|
<HeaderBar title="挑战详情" onBack={() => router.back()} withSafeTop transparent={false} />
|
||||||
|
<View style={styles.missingContainer}>
|
||||||
|
<ActivityIndicator color={colorTokens.primary} />
|
||||||
|
<Text style={[styles.missingText, { color: colorTokens.textSecondary, marginTop: 16 }]}>加载挑战详情中…</Text>
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }]}>
|
||||||
|
{detailError ?? '未找到该挑战,稍后再试试吧。'}
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.retryButton, { borderColor: colorTokens.primary }]}
|
||||||
|
activeOpacity={0.9}
|
||||||
|
onPress={() => dispatch(fetchChallengeDetail(id))}
|
||||||
|
>
|
||||||
|
<Text style={[styles.retryText, { color: colorTokens.primary }]}>重新加载</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const highlightTitle = challenge.highlightTitle ?? '立即加入挑战';
|
||||||
|
const highlightSubtitle = challenge.highlightSubtitle ?? '邀请好友一起坚持,更容易收获成果';
|
||||||
|
const joinCtaLabel = joinStatus === 'loading' ? '加入中…' : challenge.ctaLabel ?? '立即加入挑战';
|
||||||
|
const isUpcoming = challenge.status === 'upcoming';
|
||||||
|
const isExpired = challenge.status === 'expired';
|
||||||
|
const upcomingStartLabel = formatMonthDay(challenge.startAt);
|
||||||
|
const upcomingHighlightTitle = '挑战即将开始';
|
||||||
|
const upcomingHighlightSubtitle = upcomingStartLabel
|
||||||
|
? `${upcomingStartLabel} 开始,敬请期待`
|
||||||
|
: '挑战即将开启,敬请期待';
|
||||||
|
const upcomingCtaLabel = '挑战即将开始';
|
||||||
|
const expiredEndLabel = formatMonthDay(challenge.endAt);
|
||||||
|
const expiredHighlightTitle = '挑战已结束';
|
||||||
|
const expiredHighlightSubtitle = expiredEndLabel
|
||||||
|
? `${expiredEndLabel} 已截止,期待下一次挑战`
|
||||||
|
: '本轮挑战已结束,期待下一次挑战';
|
||||||
|
const expiredCtaLabel = '挑战已结束';
|
||||||
|
const leaveHighlightTitle = '先别急着离开';
|
||||||
|
const leaveHighlightSubtitle = '再坚持一下,下一个里程碑就要出现了';
|
||||||
|
const leaveCtaLabel = leaveStatus === 'loading' ? '退出中…' : '退出挑战';
|
||||||
|
|
||||||
|
let floatingHighlightTitle = highlightTitle;
|
||||||
|
let floatingHighlightSubtitle = highlightSubtitle;
|
||||||
|
let floatingCtaLabel = joinCtaLabel;
|
||||||
|
let floatingOnPress: (() => void) | undefined = handleJoin;
|
||||||
|
let floatingDisabled = joinStatus === 'loading';
|
||||||
|
let floatingError = joinError;
|
||||||
|
let isDisabledButtonState = false;
|
||||||
|
|
||||||
|
if (isJoined) {
|
||||||
|
floatingHighlightTitle = leaveHighlightTitle;
|
||||||
|
floatingHighlightSubtitle = leaveHighlightSubtitle;
|
||||||
|
floatingCtaLabel = leaveCtaLabel;
|
||||||
|
floatingOnPress = handleLeaveConfirm;
|
||||||
|
floatingDisabled = leaveStatus === 'loading';
|
||||||
|
floatingError = leaveError;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isUpcoming) {
|
||||||
|
floatingHighlightTitle = upcomingHighlightTitle;
|
||||||
|
floatingHighlightSubtitle = upcomingHighlightSubtitle;
|
||||||
|
floatingCtaLabel = upcomingCtaLabel;
|
||||||
|
floatingOnPress = undefined;
|
||||||
|
floatingDisabled = true;
|
||||||
|
floatingError = undefined;
|
||||||
|
isDisabledButtonState = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isExpired) {
|
||||||
|
floatingHighlightTitle = expiredHighlightTitle;
|
||||||
|
floatingHighlightSubtitle = expiredHighlightSubtitle;
|
||||||
|
floatingCtaLabel = expiredCtaLabel;
|
||||||
|
floatingOnPress = undefined;
|
||||||
|
floatingDisabled = true;
|
||||||
|
floatingError = undefined;
|
||||||
|
isDisabledButtonState = true;
|
||||||
|
}
|
||||||
|
const floatingGradientColors = isDisabledButtonState ? CTA_DISABLED_GRADIENT : CTA_GRADIENT;
|
||||||
|
const participantsLabel = formatParticipantsLabel(challenge.participantsCount);
|
||||||
|
|
||||||
|
const inlineErrorMessage = detailStatus === 'failed' && detailError ? detailError : undefined;
|
||||||
|
return (
|
||||||
|
<View style={styles.safeArea}>
|
||||||
|
<StatusBar barStyle="light-content" />
|
||||||
|
<View style={styles.container}>
|
||||||
|
<View pointerEvents="box-none" style={[styles.headerOverlay, { paddingTop: insets.top }]}>
|
||||||
|
<HeaderBar
|
||||||
|
title=""
|
||||||
|
backColor="white"
|
||||||
|
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,
|
||||||
|
{ paddingBottom: (Platform.OS === 'ios' ? 180 : 160) + insets.bottom },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View style={styles.heroContainer}>
|
||||||
|
<Image source={{ uri: challenge.image }} style={styles.heroImage} cachePolicy={'memory-disk'} />
|
||||||
|
<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.headerTextBlock}>
|
||||||
|
<Text style={styles.title}>{challenge.title}</Text>
|
||||||
|
{challenge.summary ? <Text style={styles.summary}>{challenge.summary}</Text> : null}
|
||||||
|
{inlineErrorMessage ? (
|
||||||
|
<View style={styles.inlineError}>
|
||||||
|
<Ionicons name="warning-outline" size={14} color="#FF6B6B" />
|
||||||
|
<Text style={styles.inlineErrorText}>{inlineErrorMessage}</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{progress ? (
|
||||||
|
<ChallengeProgressCard
|
||||||
|
title={challenge.title}
|
||||||
|
endAt={challenge.endAt}
|
||||||
|
progress={progress}
|
||||||
|
style={styles.progressCardWrapper}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<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}>{dateRangeLabel}</Text>
|
||||||
|
<Text style={styles.detailMeta}>{challenge.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}>{challenge.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}>{participantsLabel}</Text>
|
||||||
|
{participantAvatars.length ? (
|
||||||
|
<View style={styles.avatarRow}>
|
||||||
|
{participantAvatars.map((avatar, index) => (
|
||||||
|
<Image
|
||||||
|
key={`${avatar}-${index}`}
|
||||||
|
source={{ uri: avatar }}
|
||||||
|
style={[styles.avatar, index > 0 && styles.avatarOffset]}
|
||||||
|
cachePolicy={'memory-disk'}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{challenge.participantsCount && challenge.participantsCount > participantAvatars.length ? (
|
||||||
|
<TouchableOpacity style={styles.moreAvatarButton}>
|
||||||
|
<Text style={styles.moreAvatarText}>更多</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.sectionHeader}>
|
||||||
|
<Text style={styles.sectionTitle}>排行榜</Text>
|
||||||
|
<TouchableOpacity activeOpacity={0.8} onPress={handleViewAllRanking}>
|
||||||
|
<Text style={styles.sectionAction}>查看全部</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{challenge.rankingDescription ? (
|
||||||
|
<Text style={styles.sectionSubtitle}>{challenge.rankingDescription}</Text>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<View style={styles.rankingCard}>
|
||||||
|
{rankingData.length ? (
|
||||||
|
rankingData.map((item, index) => (
|
||||||
|
<ChallengeRankingItem
|
||||||
|
key={item.id ?? index}
|
||||||
|
item={item}
|
||||||
|
index={index}
|
||||||
|
showDivider={index > 0}
|
||||||
|
unit={challenge?.unit}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<View style={styles.emptyRanking}>
|
||||||
|
<Text style={styles.emptyRankingText}>榜单即将开启,快来抢占席位。</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
<View pointerEvents="box-none" style={[styles.floatingCTAContainer, { paddingBottom: insets.bottom }]}>
|
||||||
|
<BlurView intensity={10} tint="light" style={styles.floatingCTABlur}>
|
||||||
|
<View style={styles.floatingCTAContent}>
|
||||||
|
<View style={styles.highlightCopy}>
|
||||||
|
<Text style={styles.highlightTitle}>{floatingHighlightTitle}</Text>
|
||||||
|
<Text style={styles.highlightSubtitle}>{floatingHighlightSubtitle}</Text>
|
||||||
|
{floatingError ? <Text style={styles.ctaErrorText}>{floatingError}</Text> : null}
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.highlightButton}
|
||||||
|
activeOpacity={0.9}
|
||||||
|
onPress={floatingOnPress}
|
||||||
|
disabled={floatingDisabled}
|
||||||
|
>
|
||||||
|
<LinearGradient
|
||||||
|
colors={floatingGradientColors}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 1 }}
|
||||||
|
style={styles.highlightButtonBackground}
|
||||||
|
>
|
||||||
|
<Text style={[styles.highlightButtonLabel, isDisabledButtonState && styles.highlightButtonLabelDisabled]}>
|
||||||
|
{floatingCtaLabel}
|
||||||
|
</Text>
|
||||||
|
</LinearGradient>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</BlurView>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
{showCelebration && (
|
||||||
|
<View pointerEvents="none" style={styles.celebrationOverlay}>
|
||||||
|
<LottieView
|
||||||
|
autoPlay
|
||||||
|
loop={false}
|
||||||
|
source={require('@/assets/lottie/Confetti.json')}
|
||||||
|
style={styles.celebrationAnimation}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
safeArea: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#f3f4fb',
|
||||||
|
},
|
||||||
|
headerOverlay: {
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
zIndex: 20,
|
||||||
|
},
|
||||||
|
heroContainer: {
|
||||||
|
height: HERO_HEIGHT,
|
||||||
|
width: '100%',
|
||||||
|
overflow: 'hidden',
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0
|
||||||
|
},
|
||||||
|
heroImage: {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
},
|
||||||
|
scrollView: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
scrollContent: {
|
||||||
|
paddingBottom: Platform.select({ ios: 40, default: 28 }),
|
||||||
|
},
|
||||||
|
progressCardWrapper: {
|
||||||
|
marginTop: 20,
|
||||||
|
marginHorizontal: 24,
|
||||||
|
},
|
||||||
|
floatingCTAContainer: {
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
},
|
||||||
|
floatingCTABlur: {
|
||||||
|
borderRadius: 24,
|
||||||
|
overflow: 'hidden',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(255,255,255,0.6)',
|
||||||
|
backgroundColor: 'rgba(243, 244, 251, 0.85)',
|
||||||
|
},
|
||||||
|
floatingCTAContent: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: 16,
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
},
|
||||||
|
highlightCopy: {
|
||||||
|
flex: 1,
|
||||||
|
marginRight: 16,
|
||||||
|
},
|
||||||
|
headerTextBlock: {
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
marginTop: HERO_HEIGHT - 60,
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
inlineError: {
|
||||||
|
marginTop: 12,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 8,
|
||||||
|
borderRadius: 12,
|
||||||
|
backgroundColor: 'rgba(255, 107, 107, 0.12)',
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
inlineErrorText: {
|
||||||
|
marginLeft: 6,
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#FF6B6B',
|
||||||
|
flexShrink: 1,
|
||||||
|
},
|
||||||
|
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,
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
emptyRanking: {
|
||||||
|
paddingVertical: 40,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
emptyRankingText: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#6f7ba7',
|
||||||
|
},
|
||||||
|
highlightTitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#1c1f3a',
|
||||||
|
},
|
||||||
|
highlightSubtitle: {
|
||||||
|
marginTop: 4,
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#5f6a97',
|
||||||
|
lineHeight: 18,
|
||||||
|
},
|
||||||
|
ctaErrorText: {
|
||||||
|
marginTop: 8,
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#FF6B6B',
|
||||||
|
},
|
||||||
|
highlightButton: {
|
||||||
|
borderRadius: 22,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
highlightButtonBackground: {
|
||||||
|
borderRadius: 22,
|
||||||
|
paddingVertical: 10,
|
||||||
|
paddingHorizontal: 18,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
highlightButtonLabel: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#ffffff',
|
||||||
|
},
|
||||||
|
highlightButtonLabelDisabled: {
|
||||||
|
color: '#6f7799',
|
||||||
|
},
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
retryButton: {
|
||||||
|
marginTop: 18,
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingVertical: 10,
|
||||||
|
borderRadius: 22,
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
retryText: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
celebrationOverlay: {
|
||||||
|
...StyleSheet.absoluteFillObject,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
zIndex: 40,
|
||||||
|
},
|
||||||
|
celebrationAnimation: {
|
||||||
|
width: width * 1.3,
|
||||||
|
height: width * 1.3,
|
||||||
|
},
|
||||||
|
});
|
||||||
298
app/challenges/[id]/leaderboard.tsx
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
import ChallengeProgressCard from '@/components/challenges/ChallengeProgressCard';
|
||||||
|
import { ChallengeRankingItem } from '@/components/challenges/ChallengeRankingItem';
|
||||||
|
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||||
|
import { Colors } from '@/constants/Colors';
|
||||||
|
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||||
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
|
import {
|
||||||
|
fetchChallengeDetail,
|
||||||
|
fetchChallengeRankings,
|
||||||
|
selectChallengeById,
|
||||||
|
selectChallengeDetailError,
|
||||||
|
selectChallengeDetailStatus,
|
||||||
|
selectChallengeRankingError,
|
||||||
|
selectChallengeRankingList,
|
||||||
|
selectChallengeRankingLoadMoreStatus,
|
||||||
|
selectChallengeRankingStatus,
|
||||||
|
} from '@/store/challengesSlice';
|
||||||
|
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||||
|
import React, { useEffect, useMemo } from 'react';
|
||||||
|
import type { NativeScrollEvent, NativeSyntheticEvent } from 'react-native';
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
RefreshControl,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
View
|
||||||
|
} from 'react-native';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
|
export default function ChallengeLeaderboardScreen() {
|
||||||
|
const { id } = useLocalSearchParams<{ id?: string }>();
|
||||||
|
const router = useRouter();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||||
|
const colorTokens = Colors[theme];
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
const challengeSelector = useMemo(() => (id ? selectChallengeById(id) : undefined), [id]);
|
||||||
|
const challenge = useAppSelector((state) => (challengeSelector ? challengeSelector(state) : undefined));
|
||||||
|
|
||||||
|
const detailStatusSelector = useMemo(() => (id ? selectChallengeDetailStatus(id) : undefined), [id]);
|
||||||
|
const detailStatus = useAppSelector((state) => (detailStatusSelector ? detailStatusSelector(state) : 'idle'));
|
||||||
|
const detailErrorSelector = useMemo(() => (id ? selectChallengeDetailError(id) : undefined), [id]);
|
||||||
|
const detailError = useAppSelector((state) => (detailErrorSelector ? detailErrorSelector(state) : undefined));
|
||||||
|
|
||||||
|
const rankingListSelector = useMemo(() => (id ? selectChallengeRankingList(id) : undefined), [id]);
|
||||||
|
const rankingList = useAppSelector((state) => (rankingListSelector ? rankingListSelector(state) : undefined));
|
||||||
|
const rankingStatusSelector = useMemo(() => (id ? selectChallengeRankingStatus(id) : undefined), [id]);
|
||||||
|
const rankingStatus = useAppSelector((state) => (rankingStatusSelector ? rankingStatusSelector(state) : 'idle'));
|
||||||
|
const rankingLoadMoreStatusSelector = useMemo(
|
||||||
|
() => (id ? selectChallengeRankingLoadMoreStatus(id) : undefined),
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
const rankingLoadMoreStatus = useAppSelector((state) =>
|
||||||
|
rankingLoadMoreStatusSelector ? rankingLoadMoreStatusSelector(state) : 'idle'
|
||||||
|
);
|
||||||
|
const rankingErrorSelector = useMemo(() => (id ? selectChallengeRankingError(id) : undefined), [id]);
|
||||||
|
const rankingError = useAppSelector((state) => (rankingErrorSelector ? rankingErrorSelector(state) : undefined));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (id) {
|
||||||
|
void dispatch(fetchChallengeDetail(id));
|
||||||
|
}
|
||||||
|
}, [dispatch, id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (id && !rankingList) {
|
||||||
|
void dispatch(fetchChallengeRankings({ id }));
|
||||||
|
}
|
||||||
|
}, [dispatch, id, rankingList]);
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return (
|
||||||
|
<View style={[styles.safeArea, { backgroundColor: colorTokens.background }]}>
|
||||||
|
<HeaderBar title="排行榜" onBack={() => router.back()} withSafeTop />
|
||||||
|
<View style={styles.missingContainer}>
|
||||||
|
<Text style={[styles.missingText, { color: colorTokens.textSecondary }]}>未找到该挑战。</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (detailStatus === 'loading' && !challenge) {
|
||||||
|
return (
|
||||||
|
<View style={[styles.safeArea, { backgroundColor: '#f3f4fb' }]}>
|
||||||
|
<HeaderBar title="排行榜" onBack={() => router.back()} withSafeTop />
|
||||||
|
<View style={styles.loadingContainer}>
|
||||||
|
<ActivityIndicator color={colorTokens.primary} />
|
||||||
|
<Text style={[styles.loadingText, { color: colorTokens.textSecondary }]}>加载榜单中…</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasMore = rankingList?.hasMore ?? false;
|
||||||
|
const isRefreshing = rankingStatus === 'loading';
|
||||||
|
const isLoadingMore = rankingLoadMoreStatus === 'loading';
|
||||||
|
const defaultPageSize = rankingList?.pageSize ?? 20;
|
||||||
|
const showInitialRankingLoading = isRefreshing && (!rankingList || rankingList.items.length === 0);
|
||||||
|
|
||||||
|
const handleRefresh = () => {
|
||||||
|
if (!id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void dispatch(fetchChallengeRankings({ id, page: 1, pageSize: defaultPageSize }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLoadMore = () => {
|
||||||
|
if (!id || !rankingList || !hasMore || isLoadingMore || rankingStatus === 'loading') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void dispatch(
|
||||||
|
fetchChallengeRankings({ id, page: rankingList.page + 1, pageSize: rankingList.pageSize })
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleScroll = (event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||||
|
const { layoutMeasurement, contentOffset, contentSize } = event.nativeEvent;
|
||||||
|
const paddingToBottom = 160;
|
||||||
|
if (layoutMeasurement.height + contentOffset.y >= contentSize.height - paddingToBottom) {
|
||||||
|
handleLoadMore();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!challenge) {
|
||||||
|
return (
|
||||||
|
<View style={[styles.safeArea, { backgroundColor: '#f3f4fb' }]}>
|
||||||
|
<HeaderBar title="排行榜" onBack={() => router.back()} withSafeTop />
|
||||||
|
<View style={styles.missingContainer}>
|
||||||
|
<Text style={[styles.missingText, { color: colorTokens.textSecondary }]}>
|
||||||
|
{detailError ?? '暂时无法加载榜单,请稍后再试。'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rankingData = rankingList?.items ?? challenge.rankings ?? [];
|
||||||
|
const subtitle = challenge.rankingDescription ?? challenge.summary;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.safeArea, { backgroundColor: '#f3f4fb' }]}>
|
||||||
|
<HeaderBar title="排行榜" onBack={() => router.back()} withSafeTop />
|
||||||
|
<ScrollView
|
||||||
|
style={styles.scrollView}
|
||||||
|
contentContainerStyle={{ paddingBottom: insets.bottom + 40 }}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={isRefreshing}
|
||||||
|
onRefresh={handleRefresh}
|
||||||
|
tintColor={colorTokens.primary}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
onScroll={handleScroll}
|
||||||
|
scrollEventThrottle={16}
|
||||||
|
>
|
||||||
|
<View style={styles.pageHeader}>
|
||||||
|
<Text style={styles.challengeTitle}>{challenge.title}</Text>
|
||||||
|
{subtitle ? <Text style={styles.challengeSubtitle}>{subtitle}</Text> : null}
|
||||||
|
{challenge.progress ? (
|
||||||
|
<ChallengeProgressCard
|
||||||
|
title={challenge.title}
|
||||||
|
endAt={challenge.endAt}
|
||||||
|
progress={challenge.progress}
|
||||||
|
style={styles.progressCardWrapper}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.rankingCard}>
|
||||||
|
{showInitialRankingLoading ? (
|
||||||
|
<View style={styles.rankingLoading}>
|
||||||
|
<ActivityIndicator color={colorTokens.primary} />
|
||||||
|
<Text style={[styles.loadingText, { color: colorTokens.textSecondary }]}>加载榜单中…</Text>
|
||||||
|
</View>
|
||||||
|
) : rankingData.length ? (
|
||||||
|
rankingData.map((item, index) => (
|
||||||
|
<ChallengeRankingItem
|
||||||
|
key={item.id ?? index}
|
||||||
|
item={item}
|
||||||
|
index={index}
|
||||||
|
showDivider={index > 0}
|
||||||
|
unit={challenge?.unit}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : rankingError ? (
|
||||||
|
<View style={styles.emptyRanking}>
|
||||||
|
<Text style={styles.rankingErrorText}>{rankingError}</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View style={styles.emptyRanking}>
|
||||||
|
<Text style={styles.emptyRankingText}>榜单即将开启,快来抢占席位。</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{isLoadingMore ? (
|
||||||
|
<View style={styles.loadMoreIndicator}>
|
||||||
|
<ActivityIndicator color={colorTokens.primary} size="small" />
|
||||||
|
<Text style={[styles.loadingText, { color: colorTokens.textSecondary, marginTop: 8 }]}>加载更多…</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
{rankingLoadMoreStatus === 'failed' ? (
|
||||||
|
<View style={styles.loadMoreIndicator}>
|
||||||
|
<Text style={styles.loadMoreErrorText}>加载更多失败,请下拉刷新重试</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
safeArea: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
scrollView: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
},
|
||||||
|
pageHeader: {
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
paddingTop: 24,
|
||||||
|
},
|
||||||
|
challengeTitle: {
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: '800',
|
||||||
|
color: '#1c1f3a',
|
||||||
|
},
|
||||||
|
challengeSubtitle: {
|
||||||
|
marginTop: 8,
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#6f7ba7',
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
progressCardWrapper: {
|
||||||
|
marginTop: 20,
|
||||||
|
},
|
||||||
|
rankingCard: {
|
||||||
|
marginTop: 24,
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
emptyRanking: {
|
||||||
|
paddingVertical: 40,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
emptyRankingText: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#6f7ba7',
|
||||||
|
},
|
||||||
|
rankingLoading: {
|
||||||
|
paddingVertical: 32,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
rankingErrorText: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#eb5757',
|
||||||
|
},
|
||||||
|
loadMoreIndicator: {
|
||||||
|
paddingVertical: 16,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
loadMoreErrorText: {
|
||||||
|
fontSize: 13,
|
||||||
|
color: '#eb5757',
|
||||||
|
},
|
||||||
|
loadingContainer: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
loadingText: {
|
||||||
|
marginTop: 16,
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
missingContainer: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingHorizontal: 32,
|
||||||
|
},
|
||||||
|
missingText: {
|
||||||
|
fontSize: 14,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
});
|
||||||
700
app/circumference-detail.tsx
Normal file
@@ -0,0 +1,700 @@
|
|||||||
|
import { DateSelector } from '@/components/DateSelector';
|
||||||
|
import { FloatingSelectionModal, SelectionItem } from '@/components/ui/FloatingSelectionModal';
|
||||||
|
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||||
|
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||||
|
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||||
|
import { fetchCircumferenceAnalysis, selectCircumferenceData, selectCircumferenceError, selectCircumferenceLoading } from '@/store/circumferenceSlice';
|
||||||
|
import { selectUserProfile, updateUserBodyMeasurements, UserProfile } from '@/store/userSlice';
|
||||||
|
import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import weekOfYear from 'dayjs/plugin/weekOfYear';
|
||||||
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { ActivityIndicator, Dimensions, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||||
|
import { LineChart } from 'react-native-chart-kit';
|
||||||
|
|
||||||
|
dayjs.extend(weekOfYear);
|
||||||
|
|
||||||
|
// 围度类型数据
|
||||||
|
const CIRCUMFERENCE_TYPES = [
|
||||||
|
{ key: 'chestCircumference', label: '胸围', color: '#FF6B6B' },
|
||||||
|
{ key: 'waistCircumference', label: '腰围', color: '#4ECDC4' },
|
||||||
|
{ key: 'upperHipCircumference', label: '上臀围', color: '#45B7D1' },
|
||||||
|
{ key: 'armCircumference', label: '臂围', color: '#96CEB4' },
|
||||||
|
{ key: 'thighCircumference', label: '大腿围', color: '#FFEAA7' },
|
||||||
|
{ key: 'calfCircumference', label: '小腿围', color: '#DDA0DD' },
|
||||||
|
];
|
||||||
|
|
||||||
|
import { CircumferencePeriod } from '@/services/circumferenceAnalysis';
|
||||||
|
|
||||||
|
type TabType = CircumferencePeriod;
|
||||||
|
|
||||||
|
export default function CircumferenceDetailScreen() {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const userProfile = useAppSelector(selectUserProfile);
|
||||||
|
const { ensureLoggedIn } = useAuthGuard();
|
||||||
|
|
||||||
|
// 日期相关状态
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth());
|
||||||
|
const [activeTab, setActiveTab] = useState<TabType>('week');
|
||||||
|
|
||||||
|
// 弹窗状态
|
||||||
|
const [modalVisible, setModalVisible] = useState(false);
|
||||||
|
const [selectedMeasurement, setSelectedMeasurement] = useState<{
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
currentValue?: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
// Redux状态
|
||||||
|
const chartData = useAppSelector(state => selectCircumferenceData(state, activeTab));
|
||||||
|
const isLoading = useAppSelector(state => selectCircumferenceLoading(state, activeTab));
|
||||||
|
const error = useAppSelector(selectCircumferenceError);
|
||||||
|
|
||||||
|
console.log('chartData', chartData);
|
||||||
|
|
||||||
|
|
||||||
|
// 图例显示状态 - 控制哪些维度显示在图表中
|
||||||
|
const [visibleTypes, setVisibleTypes] = useState<Set<string>>(
|
||||||
|
new Set(CIRCUMFERENCE_TYPES.map(type => type.key))
|
||||||
|
);
|
||||||
|
|
||||||
|
// 获取当前选中日期
|
||||||
|
const currentSelectedDate = useMemo(() => {
|
||||||
|
const days = getMonthDaysZh();
|
||||||
|
return days[selectedIndex]?.date?.toDate() ?? new Date();
|
||||||
|
}, [selectedIndex]);
|
||||||
|
|
||||||
|
// 判断选中日期是否是今天
|
||||||
|
const isSelectedDateToday = useMemo(() => {
|
||||||
|
const today = new Date();
|
||||||
|
const selectedDate = currentSelectedDate;
|
||||||
|
return dayjs(selectedDate).isSame(today, 'day');
|
||||||
|
}, [currentSelectedDate]);
|
||||||
|
|
||||||
|
// 当前围度数据
|
||||||
|
const measurements = [
|
||||||
|
{
|
||||||
|
key: 'chestCircumference',
|
||||||
|
label: '胸围',
|
||||||
|
value: userProfile?.chestCircumference,
|
||||||
|
color: '#FF6B6B',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'waistCircumference',
|
||||||
|
label: '腰围',
|
||||||
|
value: userProfile?.waistCircumference,
|
||||||
|
color: '#4ECDC4',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'upperHipCircumference',
|
||||||
|
label: '上臀围',
|
||||||
|
value: userProfile?.upperHipCircumference,
|
||||||
|
color: '#45B7D1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'armCircumference',
|
||||||
|
label: '臂围',
|
||||||
|
value: userProfile?.armCircumference,
|
||||||
|
color: '#96CEB4',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'thighCircumference',
|
||||||
|
label: '大腿围',
|
||||||
|
value: userProfile?.thighCircumference,
|
||||||
|
color: '#FFEAA7',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'calfCircumference',
|
||||||
|
label: '小腿围',
|
||||||
|
value: userProfile?.calfCircumference,
|
||||||
|
color: '#DDA0DD',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// 日期选择回调
|
||||||
|
const onSelectDate = (index: number) => {
|
||||||
|
setSelectedIndex(index);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Tab切换
|
||||||
|
const handleTabPress = useCallback((tab: TabType) => {
|
||||||
|
setActiveTab(tab);
|
||||||
|
// 切换tab时重新获取数据
|
||||||
|
dispatch(fetchCircumferenceAnalysis(tab));
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
// 初始化加载数据
|
||||||
|
useEffect(() => {
|
||||||
|
dispatch(fetchCircumferenceAnalysis(activeTab));
|
||||||
|
}, [dispatch, activeTab]);
|
||||||
|
|
||||||
|
// 处理图例点击,切换显示/隐藏
|
||||||
|
const handleLegendPress = (typeKey: string) => {
|
||||||
|
const newVisibleTypes = new Set(visibleTypes);
|
||||||
|
if (newVisibleTypes.has(typeKey)) {
|
||||||
|
// 至少保留一个维度显示
|
||||||
|
if (newVisibleTypes.size > 1) {
|
||||||
|
newVisibleTypes.delete(typeKey);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
newVisibleTypes.add(typeKey);
|
||||||
|
}
|
||||||
|
setVisibleTypes(newVisibleTypes);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 根据不同围度类型获取合理的默认值
|
||||||
|
const getDefaultCircumferenceValue = (measurementKey: string, userProfile?: UserProfile): number => {
|
||||||
|
// 如果用户已有该围度数据,直接使用
|
||||||
|
const existingValue = userProfile?.[measurementKey as keyof UserProfile] as number;
|
||||||
|
if (existingValue) {
|
||||||
|
return existingValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据性别设置合理的默认值
|
||||||
|
const isMale = userProfile?.gender === 'male';
|
||||||
|
|
||||||
|
switch (measurementKey) {
|
||||||
|
case 'chestCircumference':
|
||||||
|
// 胸围:男性 85-110cm,女性 75-95cm
|
||||||
|
return isMale ? 95 : 80;
|
||||||
|
case 'waistCircumference':
|
||||||
|
// 腰围:男性 70-90cm,女性 60-80cm
|
||||||
|
return isMale ? 80 : 70;
|
||||||
|
case 'upperHipCircumference':
|
||||||
|
// 上臀围:
|
||||||
|
return 30;
|
||||||
|
case 'armCircumference':
|
||||||
|
// 臂围:男性 25-35cm,女性 20-30cm
|
||||||
|
return isMale ? 30 : 25;
|
||||||
|
case 'thighCircumference':
|
||||||
|
// 大腿围:男性 45-60cm,女性 40-55cm
|
||||||
|
return isMale ? 50 : 45;
|
||||||
|
case 'calfCircumference':
|
||||||
|
// 小腿围:男性 30-40cm,女性 25-35cm
|
||||||
|
return isMale ? 35 : 30;
|
||||||
|
default:
|
||||||
|
return 70; // 默认70cm
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Generate circumference options (30-150 cm)
|
||||||
|
const circumferenceOptions: SelectionItem[] = Array.from({ length: 121 }, (_, i) => {
|
||||||
|
const value = i + 30;
|
||||||
|
return {
|
||||||
|
label: `${value} cm`,
|
||||||
|
value: value,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// 处理围度数据点击
|
||||||
|
const handleMeasurementPress = async (measurement: typeof measurements[0]) => {
|
||||||
|
// 只有选中今天日期才能编辑
|
||||||
|
if (!isSelectedDateToday) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isLoggedIn = await ensureLoggedIn();
|
||||||
|
if (!isLoggedIn) {
|
||||||
|
// 如果未登录,用户会被重定向到登录页面
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用智能默认值,如果用户已有数据则使用现有数据,否则使用基于性别的合理默认值
|
||||||
|
const defaultValue = getDefaultCircumferenceValue(measurement.key, userProfile);
|
||||||
|
|
||||||
|
setSelectedMeasurement({
|
||||||
|
key: measurement.key,
|
||||||
|
label: measurement.label,
|
||||||
|
currentValue: measurement.value || defaultValue,
|
||||||
|
});
|
||||||
|
setModalVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理围度数据更新
|
||||||
|
const handleUpdateMeasurement = (value: string | number) => {
|
||||||
|
if (!selectedMeasurement) return;
|
||||||
|
|
||||||
|
const updateData = {
|
||||||
|
[selectedMeasurement.key]: Number(value),
|
||||||
|
};
|
||||||
|
|
||||||
|
dispatch(updateUserBodyMeasurements(updateData));
|
||||||
|
setModalVisible(false);
|
||||||
|
setSelectedMeasurement(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理图表数据
|
||||||
|
const processedChartData = useMemo(() => {
|
||||||
|
if (!chartData || chartData.length === 0) {
|
||||||
|
return { labels: [], datasets: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据activeTab生成标签
|
||||||
|
const labels = chartData.map(item => {
|
||||||
|
switch (activeTab) {
|
||||||
|
case 'week':
|
||||||
|
// 将YYYY-MM-DD格式转换为星期几
|
||||||
|
const weekDay = dayjs(item.label).format('dd');
|
||||||
|
return weekDay;
|
||||||
|
case 'month':
|
||||||
|
// 将YYYY-MM-DD格式转换为第几周
|
||||||
|
const weekOfYear = dayjs(item.label).week();
|
||||||
|
const firstWeekOfMonth = dayjs(item.label).startOf('month').week();
|
||||||
|
return `第${weekOfYear - firstWeekOfMonth + 1}周`;
|
||||||
|
case 'year':
|
||||||
|
// 将YYYY-MM格式转换为月份
|
||||||
|
return dayjs(item.label).format('M月');
|
||||||
|
default:
|
||||||
|
return item.label;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 为每个可见的围度类型生成数据集
|
||||||
|
const datasets: any[] = [];
|
||||||
|
CIRCUMFERENCE_TYPES.forEach((type) => {
|
||||||
|
if (visibleTypes.has(type.key)) {
|
||||||
|
const data = chartData.map(item => {
|
||||||
|
const value = item[type.key as keyof typeof item] as number | null;
|
||||||
|
return value || 0; // null值转换为0,图表会自动处理
|
||||||
|
});
|
||||||
|
|
||||||
|
// 只有数据中至少有一个非零值才添加到数据集
|
||||||
|
if (data.some(value => value > 0)) {
|
||||||
|
datasets.push({
|
||||||
|
data,
|
||||||
|
color: () => type.color,
|
||||||
|
strokeWidth: 2,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { labels, datasets };
|
||||||
|
}, [chartData, activeTab, visibleTypes]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
{/* 背景渐变 */}
|
||||||
|
<LinearGradient
|
||||||
|
colors={['#f5e5fbff', '#e5fcfeff', '#eefdffff', '#e6f6fcff']}
|
||||||
|
style={styles.gradientBackground}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 0, y: 1 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 头部导航 */}
|
||||||
|
<HeaderBar
|
||||||
|
title="围度统计"
|
||||||
|
transparent
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
style={styles.scrollView}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingBottom: 60,
|
||||||
|
paddingHorizontal: 20
|
||||||
|
}}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
{/* 日期选择器 */}
|
||||||
|
<View style={styles.dateContainer}>
|
||||||
|
<DateSelector
|
||||||
|
selectedIndex={selectedIndex}
|
||||||
|
onDateSelect={onSelectDate}
|
||||||
|
showMonthTitle={false}
|
||||||
|
disableFutureDates={true}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 当前日期围度数据 */}
|
||||||
|
<View style={styles.currentDataCard}>
|
||||||
|
<View style={styles.measurementsContainer}>
|
||||||
|
{measurements.map((measurement, index) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={index}
|
||||||
|
style={[
|
||||||
|
styles.measurementItem,
|
||||||
|
!isSelectedDateToday && styles.measurementItemDisabled
|
||||||
|
]}
|
||||||
|
onPress={() => handleMeasurementPress(measurement)}
|
||||||
|
activeOpacity={isSelectedDateToday ? 0.7 : 1}
|
||||||
|
disabled={!isSelectedDateToday}
|
||||||
|
>
|
||||||
|
<View style={[styles.colorIndicator, { backgroundColor: measurement.color }]} />
|
||||||
|
<Text style={styles.label}>{measurement.label}</Text>
|
||||||
|
<View style={styles.valueContainer}>
|
||||||
|
<Text style={styles.value}>
|
||||||
|
{measurement.value ? measurement.value.toString() : '--'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 围度统计 */}
|
||||||
|
<View style={styles.statsCard}>
|
||||||
|
<Text style={styles.statsTitle}>围度统计</Text>
|
||||||
|
|
||||||
|
{/* Tab 切换 */}
|
||||||
|
<View style={styles.tabContainer}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.tab, activeTab === 'week' && styles.activeTab]}
|
||||||
|
onPress={() => handleTabPress('week')}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Text style={[styles.tabText, activeTab === 'week' && styles.activeTabText]}>
|
||||||
|
按周
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.tab, activeTab === 'month' && styles.activeTab]}
|
||||||
|
onPress={() => handleTabPress('month')}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Text style={[styles.tabText, activeTab === 'month' && styles.activeTabText]}>
|
||||||
|
按月
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.tab, activeTab === 'year' && styles.activeTab]}
|
||||||
|
onPress={() => handleTabPress('year')}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Text style={[styles.tabText, activeTab === 'year' && styles.activeTabText]}>
|
||||||
|
按年
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 图例 - 支持点击切换显示/隐藏 */}
|
||||||
|
<View style={styles.legendContainer}>
|
||||||
|
{CIRCUMFERENCE_TYPES.map((type, index) => {
|
||||||
|
const isVisible = visibleTypes.has(type.key);
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={index}
|
||||||
|
style={[styles.legendItem, !isVisible && styles.legendItemHidden]}
|
||||||
|
onPress={() => handleLegendPress(type.key)}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<View style={[
|
||||||
|
styles.legendColor,
|
||||||
|
{ backgroundColor: isVisible ? type.color : '#E0E0E0' }
|
||||||
|
]} />
|
||||||
|
<Text style={[
|
||||||
|
styles.legendText,
|
||||||
|
!isVisible && styles.legendTextHidden
|
||||||
|
]}>
|
||||||
|
{type.label}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 折线图 */}
|
||||||
|
{isLoading ? (
|
||||||
|
<View style={styles.loadingChart}>
|
||||||
|
<ActivityIndicator size="large" color="#4ECDC4" />
|
||||||
|
<Text style={styles.loadingText}>加载中...</Text>
|
||||||
|
</View>
|
||||||
|
) : error ? (
|
||||||
|
<View style={styles.errorChart}>
|
||||||
|
<Text style={styles.errorText}>加载失败: {error}</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.retryButton}
|
||||||
|
onPress={() => dispatch(fetchCircumferenceAnalysis(activeTab))}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Text style={styles.retryText}>重试</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
) : processedChartData.datasets.length > 0 ? (
|
||||||
|
<LineChart
|
||||||
|
data={{
|
||||||
|
labels: processedChartData.labels,
|
||||||
|
datasets: processedChartData.datasets,
|
||||||
|
}}
|
||||||
|
width={Dimensions.get('window').width - 80}
|
||||||
|
height={220}
|
||||||
|
yAxisSuffix="cm"
|
||||||
|
chartConfig={{
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
backgroundGradientFrom: '#ffffff',
|
||||||
|
backgroundGradientTo: '#ffffff',
|
||||||
|
fillShadowGradientFromOpacity: 0,
|
||||||
|
fillShadowGradientToOpacity: 0,
|
||||||
|
decimalPlaces: 0,
|
||||||
|
color: (opacity = 1) => `rgba(100, 100, 100, ${opacity * 0.8})`,
|
||||||
|
labelColor: (opacity = 1) => `rgba(60, 60, 60, ${opacity})`,
|
||||||
|
style: {
|
||||||
|
borderRadius: 16,
|
||||||
|
},
|
||||||
|
propsForDots: {
|
||||||
|
r: "3",
|
||||||
|
strokeWidth: "2",
|
||||||
|
stroke: "#ffffff"
|
||||||
|
},
|
||||||
|
propsForBackgroundLines: {
|
||||||
|
strokeDasharray: "2,2",
|
||||||
|
stroke: "#E0E0E0",
|
||||||
|
strokeWidth: 1
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
bezier
|
||||||
|
style={styles.chart}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View style={styles.emptyChart}>
|
||||||
|
<Text style={styles.emptyChartText}>
|
||||||
|
{processedChartData.datasets.length === 0 && !isLoading && !error
|
||||||
|
? '暂无数据'
|
||||||
|
: '请选择要显示的围度数据'
|
||||||
|
}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
{/* 围度编辑弹窗 */}
|
||||||
|
<FloatingSelectionModal
|
||||||
|
visible={modalVisible}
|
||||||
|
onClose={() => {
|
||||||
|
setModalVisible(false);
|
||||||
|
setSelectedMeasurement(null);
|
||||||
|
}}
|
||||||
|
title={selectedMeasurement ? `设置${selectedMeasurement.label}` : '设置围度'}
|
||||||
|
items={circumferenceOptions}
|
||||||
|
selectedValue={selectedMeasurement?.currentValue}
|
||||||
|
onValueChange={() => { }} // Real-time update not needed
|
||||||
|
onConfirm={handleUpdateMeasurement}
|
||||||
|
confirmButtonText="确认"
|
||||||
|
pickerHeight={180}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
},
|
||||||
|
gradientBackground: {
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
},
|
||||||
|
scrollView: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
dateContainer: {
|
||||||
|
marginTop: 16,
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
currentDataCard: {
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 20,
|
||||||
|
marginBottom: 20,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: {
|
||||||
|
width: 0,
|
||||||
|
height: 4,
|
||||||
|
},
|
||||||
|
shadowOpacity: 0.12,
|
||||||
|
shadowRadius: 12,
|
||||||
|
elevation: 6,
|
||||||
|
},
|
||||||
|
currentDataTitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#192126',
|
||||||
|
marginBottom: 16,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
measurementsContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
},
|
||||||
|
measurementItem: {
|
||||||
|
alignItems: 'center',
|
||||||
|
width: '16%',
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
measurementItemDisabled: {
|
||||||
|
opacity: 0.6,
|
||||||
|
},
|
||||||
|
colorIndicator: {
|
||||||
|
width: 12,
|
||||||
|
height: 12,
|
||||||
|
borderRadius: 6,
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#888',
|
||||||
|
marginBottom: 8,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
valueContainer: {
|
||||||
|
backgroundColor: '#F5F5F7',
|
||||||
|
borderRadius: 10,
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 6,
|
||||||
|
minWidth: 32,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
value: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#192126',
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
statsCard: {
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 20,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: {
|
||||||
|
width: 0,
|
||||||
|
height: 4,
|
||||||
|
},
|
||||||
|
shadowOpacity: 0.12,
|
||||||
|
shadowRadius: 12,
|
||||||
|
elevation: 6,
|
||||||
|
},
|
||||||
|
statsTitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#192126',
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
tabContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
backgroundColor: '#F5F5F7',
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 4,
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
tab: {
|
||||||
|
flex: 1,
|
||||||
|
paddingVertical: 8,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
borderRadius: 8,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
activeTab: {
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: {
|
||||||
|
width: 0,
|
||||||
|
height: 1,
|
||||||
|
},
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 2,
|
||||||
|
elevation: 2,
|
||||||
|
},
|
||||||
|
tabText: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#888',
|
||||||
|
},
|
||||||
|
activeTabText: {
|
||||||
|
color: '#192126',
|
||||||
|
},
|
||||||
|
legendContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
marginBottom: 16,
|
||||||
|
gap: 6,
|
||||||
|
},
|
||||||
|
legendItem: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: 4,
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
borderRadius: 8,
|
||||||
|
},
|
||||||
|
legendItemHidden: {
|
||||||
|
opacity: 0.5,
|
||||||
|
},
|
||||||
|
legendColor: {
|
||||||
|
width: 4,
|
||||||
|
height: 4,
|
||||||
|
borderRadius: 4,
|
||||||
|
marginRight: 4,
|
||||||
|
},
|
||||||
|
legendText: {
|
||||||
|
fontSize: 10,
|
||||||
|
color: '#666',
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
legendTextHidden: {
|
||||||
|
color: '#999',
|
||||||
|
},
|
||||||
|
chart: {
|
||||||
|
marginVertical: 8,
|
||||||
|
borderRadius: 16,
|
||||||
|
},
|
||||||
|
emptyChart: {
|
||||||
|
height: 220,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: '#F8F9FA',
|
||||||
|
borderRadius: 16,
|
||||||
|
marginVertical: 8,
|
||||||
|
},
|
||||||
|
emptyChartText: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#999',
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
loadingChart: {
|
||||||
|
height: 220,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: '#F8F9FA',
|
||||||
|
borderRadius: 16,
|
||||||
|
marginVertical: 8,
|
||||||
|
},
|
||||||
|
loadingText: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#666',
|
||||||
|
marginTop: 8,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
errorChart: {
|
||||||
|
height: 220,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: '#FFF5F5',
|
||||||
|
borderRadius: 16,
|
||||||
|
marginVertical: 8,
|
||||||
|
padding: 20,
|
||||||
|
},
|
||||||
|
errorText: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#E53E3E',
|
||||||
|
textAlign: 'center',
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
retryButton: {
|
||||||
|
backgroundColor: '#4ECDC4',
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 8,
|
||||||
|
borderRadius: 8,
|
||||||
|
},
|
||||||
|
retryText: {
|
||||||
|
color: '#FFFFFF',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -30,8 +30,8 @@ import { api, getAuthToken, postTextStream } from '@/services/api';
|
|||||||
import { selectLatestMoodRecordByDate } from '@/store/moodSlice';
|
import { selectLatestMoodRecordByDate } from '@/store/moodSlice';
|
||||||
import { generateWelcomeMessage, hasRecordedMoodToday } from '@/utils/welcomeMessage';
|
import { generateWelcomeMessage, hasRecordedMoodToday } from '@/utils/welcomeMessage';
|
||||||
import { Image } from 'expo-image';
|
import { Image } from 'expo-image';
|
||||||
import { HistoryModal } from '../../components/model/HistoryModal';
|
import { HistoryModal } from '../components/model/HistoryModal';
|
||||||
import { ActionSheet } from '../../components/ui/ActionSheet';
|
import { ActionSheet } from '../components/ui/ActionSheet';
|
||||||
|
|
||||||
// 导入新的 coach 组件
|
// 导入新的 coach 组件
|
||||||
import {
|
import {
|
||||||
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 { ThemedView } from '@/components/ThemedView';
|
||||||
import { ROUTES } from '@/constants/Routes';
|
import { ROUTES } from '@/constants/Routes';
|
||||||
import { useThemeColor } from '@/hooks/useThemeColor';
|
import { useThemeColor } from '@/hooks/useThemeColor';
|
||||||
|
import { preloadUserData } from '@/store/userSlice';
|
||||||
import { router } from 'expo-router';
|
import { router } from 'expo-router';
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { ActivityIndicator, View } from 'react-native';
|
import { ActivityIndicator, View } from 'react-native';
|
||||||
@@ -18,6 +19,11 @@ export default function SplashScreen() {
|
|||||||
|
|
||||||
const checkOnboardingStatus = async () => {
|
const checkOnboardingStatus = async () => {
|
||||||
try {
|
try {
|
||||||
|
// 先预加载用户数据,这样进入应用时就有正确的 token 状态
|
||||||
|
console.log('开始预加载用户数据...');
|
||||||
|
await preloadUserData();
|
||||||
|
console.log('用户数据预加载完成');
|
||||||
|
|
||||||
// const onboardingCompleted = await AsyncStorage.getItem(ONBOARDING_COMPLETED_KEY);
|
// const onboardingCompleted = await AsyncStorage.getItem(ONBOARDING_COMPLETED_KEY);
|
||||||
|
|
||||||
// if (onboardingCompleted === 'true') {
|
// if (onboardingCompleted === 'true') {
|
||||||
@@ -28,11 +34,9 @@ export default function SplashScreen() {
|
|||||||
// setIsLoading(false);
|
// setIsLoading(false);
|
||||||
router.replace(ROUTES.TAB_STATISTICS);
|
router.replace(ROUTES.TAB_STATISTICS);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('检查引导状态失败:', error);
|
console.error('检查引导状态或预加载用户数据失败:', error);
|
||||||
// 如果出现错误,默认显示引导页面
|
// 如果出现错误,仍然进入应用,但可能会有状态更新
|
||||||
// setTimeout(() => {
|
router.replace(ROUTES.TAB_STATISTICS);
|
||||||
// router.replace('/onboarding');
|
|
||||||
// }, 1000);
|
|
||||||
}
|
}
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -15,9 +15,12 @@ import { Ionicons } from '@expo/vector-icons';
|
|||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
import { router, useLocalSearchParams } from 'expo-router';
|
import { router, useLocalSearchParams } from 'expo-router';
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Alert, Image,
|
Alert, Image,
|
||||||
|
Keyboard,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Platform,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
Text,
|
Text,
|
||||||
@@ -43,6 +46,9 @@ export default function MoodEditScreen() {
|
|||||||
const [isDeleting, setIsDeleting] = useState(false);
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
const [existingMood, setExistingMood] = useState<any>(null);
|
const [existingMood, setExistingMood] = useState<any>(null);
|
||||||
|
|
||||||
|
const scrollViewRef = useRef<ScrollView>(null);
|
||||||
|
const textInputRef = useRef<TextInput>(null);
|
||||||
|
|
||||||
const moodOptions = getMoodOptions();
|
const moodOptions = getMoodOptions();
|
||||||
|
|
||||||
// 从 Redux 获取数据
|
// 从 Redux 获取数据
|
||||||
@@ -66,6 +72,25 @@ export default function MoodEditScreen() {
|
|||||||
}
|
}
|
||||||
}, [moodId, moodRecords]);
|
}, [moodId, moodRecords]);
|
||||||
|
|
||||||
|
// 键盘事件监听器
|
||||||
|
useEffect(() => {
|
||||||
|
const keyboardDidShowListener = Keyboard.addListener('keyboardDidShow', () => {
|
||||||
|
// 键盘出现时,延迟滚动到文本输入框
|
||||||
|
setTimeout(() => {
|
||||||
|
scrollViewRef.current?.scrollToEnd({ animated: true });
|
||||||
|
}, 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
const keyboardDidHideListener = Keyboard.addListener('keyboardDidHide', () => {
|
||||||
|
// 键盘隐藏时,可以进行必要的调整
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
keyboardDidShowListener?.remove();
|
||||||
|
keyboardDidHideListener?.remove();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!selectedMood) {
|
if (!selectedMood) {
|
||||||
Alert.alert('提示', '请选择心情');
|
Alert.alert('提示', '请选择心情');
|
||||||
@@ -163,7 +188,18 @@ export default function MoodEditScreen() {
|
|||||||
tone="light"
|
tone="light"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ScrollView style={styles.content}>
|
<KeyboardAvoidingView
|
||||||
|
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||||
|
style={styles.keyboardAvoidingView}
|
||||||
|
keyboardVerticalOffset={Platform.OS === 'ios' ? 0 : 0}
|
||||||
|
>
|
||||||
|
<ScrollView
|
||||||
|
ref={scrollViewRef}
|
||||||
|
style={styles.content}
|
||||||
|
contentContainerStyle={styles.scrollContent}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
{/* 日期显示 */}
|
{/* 日期显示 */}
|
||||||
<View style={styles.dateSection}>
|
<View style={styles.dateSection}>
|
||||||
<Text style={styles.dateTitle}>
|
<Text style={styles.dateTitle}>
|
||||||
@@ -211,6 +247,7 @@ export default function MoodEditScreen() {
|
|||||||
<Text style={styles.sectionTitle}>心情日记</Text>
|
<Text style={styles.sectionTitle}>心情日记</Text>
|
||||||
<Text style={styles.diarySubtitle}>记录你的心情,珍藏美好回忆</Text>
|
<Text style={styles.diarySubtitle}>记录你的心情,珍藏美好回忆</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
|
ref={textInputRef}
|
||||||
style={styles.descriptionInput}
|
style={styles.descriptionInput}
|
||||||
placeholder={`今天的心情如何?
|
placeholder={`今天的心情如何?
|
||||||
|
|
||||||
@@ -225,11 +262,18 @@ export default function MoodEditScreen() {
|
|||||||
multiline
|
multiline
|
||||||
maxLength={1000}
|
maxLength={1000}
|
||||||
textAlignVertical="top"
|
textAlignVertical="top"
|
||||||
|
onFocus={() => {
|
||||||
|
// 当文本输入框获得焦点时,滚动到输入框
|
||||||
|
setTimeout(() => {
|
||||||
|
scrollViewRef.current?.scrollToEnd({ animated: true });
|
||||||
|
}, 300);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<Text style={styles.characterCount}>{description.length}/1000</Text>
|
<Text style={styles.characterCount}>{description.length}/1000</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
|
||||||
{/* 底部按钮 */}
|
{/* 底部按钮 */}
|
||||||
<View style={styles.footer}>
|
<View style={styles.footer}>
|
||||||
@@ -294,10 +338,15 @@ const styles = StyleSheet.create({
|
|||||||
safeArea: {
|
safeArea: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
|
keyboardAvoidingView: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
content: {
|
content: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
|
scrollContent: {
|
||||||
|
paddingBottom: 100, // 为底部按钮留出空间
|
||||||
|
},
|
||||||
dateSection: {
|
dateSection: {
|
||||||
backgroundColor: 'rgba(255,255,255,0.95)',
|
backgroundColor: 'rgba(255,255,255,0.95)',
|
||||||
margin: 12,
|
margin: 12,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
selectNutritionSummaryByDate
|
selectNutritionSummaryByDate
|
||||||
} from '@/store/nutritionSlice';
|
} from '@/store/nutritionSlice';
|
||||||
import { getMonthDaysZh, getMonthTitleZh, getTodayIndexInMonth } from '@/utils/date';
|
import { getMonthDaysZh, getMonthTitleZh, getTodayIndexInMonth } from '@/utils/date';
|
||||||
|
import { fetchBasalEnergyBurned } from '@/utils/health';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { useFocusEffect } from '@react-navigation/native';
|
import { useFocusEffect } from '@react-navigation/native';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
@@ -73,6 +74,9 @@ export default function NutritionRecordsScreen() {
|
|||||||
const [hasMoreData, setHasMoreData] = useState(true);
|
const [hasMoreData, setHasMoreData] = useState(true);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
|
|
||||||
|
// 基础代谢数据状态
|
||||||
|
const [basalMetabolism, setBasalMetabolism] = useState<number>(1482);
|
||||||
|
|
||||||
// 食物添加弹窗状态
|
// 食物添加弹窗状态
|
||||||
const [showFoodOverlay, setShowFoodOverlay] = useState(false);
|
const [showFoodOverlay, setShowFoodOverlay] = useState(false);
|
||||||
|
|
||||||
@@ -118,6 +122,7 @@ export default function NutritionRecordsScreen() {
|
|||||||
|
|
||||||
// 当选中日期或视图模式变化时重新加载数据
|
// 当选中日期或视图模式变化时重新加载数据
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
fetchBasalMetabolismData();
|
||||||
if (viewMode === 'daily') {
|
if (viewMode === 'daily') {
|
||||||
dispatch(fetchDailyNutritionData(currentSelectedDate));
|
dispatch(fetchDailyNutritionData(currentSelectedDate));
|
||||||
} else {
|
} else {
|
||||||
@@ -150,6 +155,22 @@ export default function NutritionRecordsScreen() {
|
|||||||
}
|
}
|
||||||
}, [viewMode, currentSelectedDateString, dispatch]);
|
}, [viewMode, currentSelectedDateString, dispatch]);
|
||||||
|
|
||||||
|
// 获取基础代谢数据
|
||||||
|
const fetchBasalMetabolismData = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const options = {
|
||||||
|
startDate: dayjs(currentSelectedDate).startOf('day').toDate().toISOString(),
|
||||||
|
endDate: dayjs(currentSelectedDate).endOf('day').toDate().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
const basalEnergy = await fetchBasalEnergyBurned(options);
|
||||||
|
setBasalMetabolism(basalEnergy || 1482);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取基础代谢数据失败:', error);
|
||||||
|
setBasalMetabolism(1482); // 失败时使用默认值
|
||||||
|
}
|
||||||
|
}, [currentSelectedDate]);
|
||||||
|
|
||||||
const onRefresh = useCallback(async () => {
|
const onRefresh = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setRefreshing(true);
|
setRefreshing(true);
|
||||||
@@ -300,42 +321,6 @@ export default function NutritionRecordsScreen() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// 渲染视图模式切换器
|
|
||||||
const renderViewModeToggle = () => (
|
|
||||||
<View style={[styles.viewModeContainer, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}>
|
|
||||||
<Text style={[styles.monthTitle, { color: colorTokens.text }]}>{monthTitle}</Text>
|
|
||||||
<View style={[styles.toggleContainer, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={[
|
|
||||||
styles.toggleButton,
|
|
||||||
viewMode === 'daily' && { backgroundColor: colorTokens.primary }
|
|
||||||
]}
|
|
||||||
onPress={() => setViewMode('daily')}
|
|
||||||
>
|
|
||||||
<Text style={[
|
|
||||||
styles.toggleText,
|
|
||||||
{ color: viewMode === 'daily' ? colorTokens.onPrimary : colorTokens.textSecondary }
|
|
||||||
]}>
|
|
||||||
按天查看
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={[
|
|
||||||
styles.toggleButton,
|
|
||||||
viewMode === 'all' && { backgroundColor: colorTokens.primary }
|
|
||||||
]}
|
|
||||||
onPress={() => setViewMode('all')}
|
|
||||||
>
|
|
||||||
<Text style={[
|
|
||||||
styles.toggleText,
|
|
||||||
{ color: viewMode === 'all' ? colorTokens.onPrimary : colorTokens.textSecondary }
|
|
||||||
]}>
|
|
||||||
全部记录
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
// 渲染日期选择器(仅在按天查看模式下显示)
|
// 渲染日期选择器(仅在按天查看模式下显示)
|
||||||
const renderDateSelector = () => {
|
const renderDateSelector = () => {
|
||||||
@@ -445,7 +430,7 @@ export default function NutritionRecordsScreen() {
|
|||||||
|
|
||||||
{/* Calorie Ring Chart */}
|
{/* Calorie Ring Chart */}
|
||||||
<CalorieRingChart
|
<CalorieRingChart
|
||||||
metabolism={healthData?.basalEnergyBurned || 1482}
|
metabolism={basalMetabolism}
|
||||||
exercise={healthData?.activeEnergyBurned || 0}
|
exercise={healthData?.activeEnergyBurned || 0}
|
||||||
consumed={nutritionSummary?.totalCalories || 0}
|
consumed={nutritionSummary?.totalCalories || 0}
|
||||||
protein={nutritionSummary?.totalProtein || 0}
|
protein={nutritionSummary?.totalProtein || 0}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { useCosUpload } from '@/hooks/useCosUpload';
|
|||||||
import { fetchMyProfile, updateUserProfile } from '@/store/userSlice';
|
import { fetchMyProfile, updateUserProfile } from '@/store/userSlice';
|
||||||
import { fetchMaximumHeartRate } from '@/utils/health';
|
import { fetchMaximumHeartRate } from '@/utils/health';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
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 DateTimePicker from '@react-native-community/datetimepicker';
|
||||||
import { Picker } from '@react-native-picker/picker';
|
import { Picker } from '@react-native-picker/picker';
|
||||||
import { useFocusEffect } from '@react-navigation/native';
|
import { useFocusEffect } from '@react-navigation/native';
|
||||||
@@ -40,7 +40,6 @@ interface UserProfile {
|
|||||||
weight?: number; // kg
|
weight?: number; // kg
|
||||||
height?: number; // cm
|
height?: number; // cm
|
||||||
avatarUri?: string | null;
|
avatarUri?: string | null;
|
||||||
avatarBase64?: string | null; // 兼容旧逻辑(不再上报)
|
|
||||||
activityLevel?: number; // 活动水平 1-4
|
activityLevel?: number; // 活动水平 1-4
|
||||||
maxHeartRate?: number; // 最大心率
|
maxHeartRate?: number; // 最大心率
|
||||||
}
|
}
|
||||||
@@ -270,7 +269,13 @@ export default function EditProfileScreen() {
|
|||||||
{ prefix: 'avatars/', userId }
|
{ 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) {
|
} catch (e) {
|
||||||
console.warn('上传头像失败', e);
|
console.warn('上传头像失败', e);
|
||||||
Alert.alert('上传失败', '头像上传失败,请重试');
|
Alert.alert('上传失败', '头像上传失败,请重试');
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||||
import { Colors } from '@/constants/Colors';
|
import { Colors } from '@/constants/Colors';
|
||||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
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 * as Haptics from 'expo-haptics';
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
|||||||
1181
app/sleep-detail.tsx
@@ -1,37 +1,43 @@
|
|||||||
import { DateSelector } from '@/components/DateSelector';
|
import { DateSelector } from '@/components/DateSelector';
|
||||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||||
import { selectHealthDataByDate, setHealthData } from '@/store/healthSlice';
|
|
||||||
import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date';
|
import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date';
|
||||||
import { ensureHealthPermissions, fetchHealthDataForDate } from '@/utils/health';
|
import { fetchHourlyStepSamples, fetchStepCount, HourlyStepData } from '@/utils/health';
|
||||||
import { getTestHealthData } from '@/utils/mockHealthData';
|
import { logger } from '@/utils/logger';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
import { useRouter } from 'expo-router';
|
import { useLocalSearchParams } from 'expo-router';
|
||||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Animated,
|
Animated,
|
||||||
SafeAreaView,
|
|
||||||
ScrollView,
|
ScrollView,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
Text,
|
Text,
|
||||||
TouchableOpacity,
|
|
||||||
View
|
View
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
||||||
|
|
||||||
export default function StepsDetailScreen() {
|
export default function StepsDetailScreen() {
|
||||||
const router = useRouter();
|
// 获取路由参数
|
||||||
const dispatch = useAppDispatch();
|
const { date } = useLocalSearchParams<{ date?: string }>();
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
|
|
||||||
// 开发调试:设置为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);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
// 获取当前选中日期
|
// 获取当前选中日期
|
||||||
@@ -40,17 +46,25 @@ export default function StepsDetailScreen() {
|
|||||||
return days[selectedIndex]?.date?.toDate() ?? new Date();
|
return days[selectedIndex]?.date?.toDate() ?? new Date();
|
||||||
}, [selectedIndex]);
|
}, [selectedIndex]);
|
||||||
|
|
||||||
const currentSelectedDateString = useMemo(() => {
|
// 获取步数数据的函数,参考 StepsCard 的实现
|
||||||
return dayjs(currentSelectedDate).format('YYYY-MM-DD');
|
const getStepData = async (date: Date) => {
|
||||||
}, [currentSelectedDate]);
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
logger.info('获取步数详情数据...');
|
||||||
|
const [steps, hourly] = await Promise.all([
|
||||||
|
fetchStepCount(date),
|
||||||
|
fetchHourlyStepSamples(date)
|
||||||
|
]);
|
||||||
|
|
||||||
// 从 Redux 获取指定日期的健康数据
|
setStepCount(steps);
|
||||||
const healthData = useAppSelector(selectHealthDataByDate(currentSelectedDateString));
|
setHourSteps(hourly);
|
||||||
|
|
||||||
// 解构健康数据(支持mock数据)
|
} catch (error) {
|
||||||
const mockData = useMockData ? getTestHealthData('mock') : null;
|
logger.error('获取步数详情数据失败:', error);
|
||||||
const stepCount: number | null = useMockData ? (mockData?.steps ?? null) : (healthData?.steps ?? null);
|
} finally {
|
||||||
const hourlySteps = useMockData ? (mockData?.hourlySteps ?? []) : (healthData?.hourlySteps ?? []);
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
// 为每个柱体创建独立的动画值
|
// 为每个柱体创建独立的动画值
|
||||||
@@ -113,50 +127,24 @@ export default function StepsDetailScreen() {
|
|||||||
}
|
}
|
||||||
}, [chartData, animatedValues]);
|
}, [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) => {
|
const onSelectDate = (index: number, date: Date) => {
|
||||||
setSelectedIndex(index);
|
setSelectedIndex(index);
|
||||||
loadHealthData(date);
|
getStepData(date);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 页面初始化时加载当前日期数据
|
// 当路由参数变化时更新选中索引
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadHealthData(currentSelectedDate);
|
const newIndex = getInitialSelectedIndex();
|
||||||
}, []);
|
setSelectedIndex(newIndex);
|
||||||
|
}, [date]);
|
||||||
|
|
||||||
|
// 当选中日期变化时获取数据
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentSelectedDate) {
|
||||||
|
getStepData(currentSelectedDate);
|
||||||
|
}
|
||||||
|
}, [currentSelectedDate]);
|
||||||
|
|
||||||
// 计算总步数和平均步数
|
// 计算总步数和平均步数
|
||||||
const totalSteps = stepCount || 0;
|
const totalSteps = stepCount || 0;
|
||||||
@@ -219,222 +207,212 @@ export default function StepsDetailScreen() {
|
|||||||
end={{ x: 1, y: 1 }}
|
end={{ x: 1, y: 1 }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SafeAreaView style={styles.safeArea}>
|
<HeaderBar
|
||||||
{/* 顶部导航栏 */}
|
title="步数详情"
|
||||||
<View style={styles.header}>
|
/>
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.backButton}
|
<ScrollView
|
||||||
onPress={() => router.back()}
|
style={styles.scrollView}
|
||||||
>
|
contentContainerStyle={{}}
|
||||||
<Ionicons name="chevron-back" size={24} color="#192126" />
|
showsVerticalScrollIndicator={false}
|
||||||
</TouchableOpacity>
|
>
|
||||||
<Text style={styles.headerTitle}>步数详情</Text>
|
{/* 日期选择器 */}
|
||||||
<View style={styles.headerRight} />
|
<DateSelector
|
||||||
|
selectedIndex={selectedIndex}
|
||||||
|
onDateSelect={onSelectDate}
|
||||||
|
showMonthTitle={true}
|
||||||
|
disableFutureDates={true}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 统计卡片 */}
|
||||||
|
<View style={styles.statsCard}>
|
||||||
|
{isLoading ? (
|
||||||
|
<View style={styles.loadingContainer}>
|
||||||
|
<Text style={styles.loadingText}>加载中...</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View style={styles.statsRow}>
|
||||||
|
<View style={styles.statItem}>
|
||||||
|
<Text style={styles.statValue}>{totalSteps.toLocaleString()}</Text>
|
||||||
|
<Text style={styles.statLabel}>总步数</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.statItem}>
|
||||||
|
<Text style={styles.statValue}>{averageHourlySteps}</Text>
|
||||||
|
<Text style={styles.statLabel}>平均每小时</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.statItem}>
|
||||||
|
<Text style={styles.statValue}>
|
||||||
|
{mostActiveHour ? `${mostActiveHour.hour}:00` : '--'}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.statLabel}>最活跃时段</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<ScrollView
|
{/* 详细柱状图卡片 */}
|
||||||
style={styles.scrollView}
|
<View style={styles.chartCard}>
|
||||||
contentContainerStyle={{}}
|
<View style={styles.chartHeader}>
|
||||||
showsVerticalScrollIndicator={false}
|
<Text style={styles.chartTitle}>每小时步数分布</Text>
|
||||||
>
|
<Text style={styles.chartSubtitle}>
|
||||||
{/* 日期选择器 */}
|
{dayjs(currentSelectedDate).format('YYYY年MM月DD日')}
|
||||||
<DateSelector
|
</Text>
|
||||||
selectedIndex={selectedIndex}
|
|
||||||
onDateSelect={onSelectDate}
|
|
||||||
showMonthTitle={true}
|
|
||||||
disableFutureDates={true}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 统计卡片 */}
|
|
||||||
<View style={styles.statsCard}>
|
|
||||||
{isLoading ? (
|
|
||||||
<View style={styles.loadingContainer}>
|
|
||||||
<Text style={styles.loadingText}>加载中...</Text>
|
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
<View style={styles.statsRow}>
|
|
||||||
<View style={styles.statItem}>
|
|
||||||
<Text style={styles.statValue}>{totalSteps.toLocaleString()}</Text>
|
|
||||||
<Text style={styles.statLabel}>总步数</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.statItem}>
|
|
||||||
<Text style={styles.statValue}>{averageHourlySteps}</Text>
|
|
||||||
<Text style={styles.statLabel}>平均每小时</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.statItem}>
|
|
||||||
<Text style={styles.statValue}>
|
|
||||||
{mostActiveHour ? `${mostActiveHour.hour}:00` : '--'}
|
|
||||||
</Text>
|
|
||||||
<Text style={styles.statLabel}>最活跃时段</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* 详细柱状图卡片 */}
|
{/* 柱状图容器 */}
|
||||||
<View style={styles.chartCard}>
|
<View style={styles.chartContainer}>
|
||||||
<View style={styles.chartHeader}>
|
{/* 平均值刻度线 - 放在chartArea外面,相对于chartContainer定位 */}
|
||||||
<Text style={styles.chartTitle}>每小时步数分布</Text>
|
{averageLinePosition > 0 && (
|
||||||
<Text style={styles.chartSubtitle}>
|
<View
|
||||||
{dayjs(currentSelectedDate).format('YYYY年MM月DD日')}
|
style={[
|
||||||
</Text>
|
styles.averageLine,
|
||||||
</View>
|
{ bottom: averageLinePosition }
|
||||||
|
]}
|
||||||
{/* 柱状图容器 */}
|
>
|
||||||
<View style={styles.chartContainer}>
|
<View style={styles.averageLineDashContainer}>
|
||||||
{/* 平均值刻度线 - 放在chartArea外面,相对于chartContainer定位 */}
|
{/* 创建更多的虚线段来确保完整覆盖 */}
|
||||||
{averageLinePosition > 0 && (
|
{Array.from({ length: 80 }, (_, index) => (
|
||||||
<View
|
<View
|
||||||
style={[
|
key={index}
|
||||||
styles.averageLine,
|
style={[
|
||||||
{ bottom: averageLinePosition }
|
styles.dashSegment,
|
||||||
]}
|
{
|
||||||
>
|
marginLeft: index > 0 ? 2 : 0,
|
||||||
<View style={styles.averageLineDashContainer}>
|
flex: 0 // 防止 flex 拉伸
|
||||||
{/* 创建更多的虚线段来确保完整覆盖 */}
|
}
|
||||||
{Array.from({ length: 80 }, (_, index) => (
|
]}
|
||||||
<View
|
/>
|
||||||
key={index}
|
))}
|
||||||
style={[
|
|
||||||
styles.dashSegment,
|
|
||||||
{
|
|
||||||
marginLeft: index > 0 ? 2 : 0,
|
|
||||||
flex: 0 // 防止 flex 拉伸
|
|
||||||
}
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
<Text style={styles.averageLineLabel}>
|
|
||||||
平均 {averageHourlySteps}步
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
)}
|
<Text style={styles.averageLineLabel}>
|
||||||
|
平均 {averageHourlySteps}步
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 柱状图区域 */}
|
{/* 柱状图区域 */}
|
||||||
<View style={styles.chartArea}>
|
<View style={styles.chartArea}>
|
||||||
{chartData.map((data, index) => {
|
{chartData.map((data, index) => {
|
||||||
const isActive = data.steps > 0;
|
const isActive = data.steps > 0;
|
||||||
const isCurrent = index <= currentHour;
|
const isCurrent = index <= currentHour;
|
||||||
const isKeyTime = index === 0 || index === 12 || index === 23;
|
const isKeyTime = index === 0 || index === 12 || index === 23;
|
||||||
|
|
||||||
// 动画变换
|
// 动画变换
|
||||||
const animatedHeight = animatedValues[index].interpolate({
|
const animatedHeight = animatedValues[index].interpolate({
|
||||||
inputRange: [0, 1],
|
inputRange: [0, 1],
|
||||||
outputRange: [0, data.height],
|
outputRange: [0, data.height],
|
||||||
});
|
});
|
||||||
|
|
||||||
const animatedOpacity = animatedValues[index].interpolate({
|
const animatedOpacity = animatedValues[index].interpolate({
|
||||||
inputRange: [0, 1],
|
inputRange: [0, 1],
|
||||||
outputRange: [0, 1],
|
outputRange: [0, 1],
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View key={`bar-${index}`} style={styles.barContainer}>
|
<View key={`bar-${index}`} style={styles.barContainer}>
|
||||||
{/* 背景柱体 */}
|
{/* 背景柱体 */}
|
||||||
<View
|
<View
|
||||||
|
style={[
|
||||||
|
styles.backgroundBar,
|
||||||
|
{
|
||||||
|
backgroundColor: isKeyTime ? '#FFF4E6' : '#F8FAFC',
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 数据柱体 */}
|
||||||
|
{isActive && (
|
||||||
|
<Animated.View
|
||||||
style={[
|
style={[
|
||||||
styles.backgroundBar,
|
styles.dataBar,
|
||||||
{
|
{
|
||||||
backgroundColor: isKeyTime ? '#FFF4E6' : '#F8FAFC',
|
height: animatedHeight,
|
||||||
|
backgroundColor: isCurrent ? '#FFC365' : '#FFEBCB',
|
||||||
|
opacity: animatedOpacity,
|
||||||
}
|
}
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 数据柱体 */}
|
{/* 步数标签(仅在有数据且是关键时间点时显示) */}
|
||||||
{isActive && (
|
{/* {isActive && isKeyTime && (
|
||||||
<Animated.View
|
|
||||||
style={[
|
|
||||||
styles.dataBar,
|
|
||||||
{
|
|
||||||
height: animatedHeight,
|
|
||||||
backgroundColor: isCurrent ? '#FFC365' : '#FFEBCB',
|
|
||||||
opacity: animatedOpacity,
|
|
||||||
}
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 步数标签(仅在有数据且是关键时间点时显示) */}
|
|
||||||
{/* {isActive && isKeyTime && (
|
|
||||||
<Animated.View
|
<Animated.View
|
||||||
style={[styles.stepLabel, { opacity: animatedOpacity }]}
|
style={[styles.stepLabel, { opacity: animatedOpacity }]}
|
||||||
>
|
>
|
||||||
<Text style={styles.stepLabelText}>{data.steps}</Text>
|
<Text style={styles.stepLabelText}>{data.steps}</Text>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
)} */}
|
)} */}
|
||||||
</View>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 底部时间轴标签 */}
|
|
||||||
<View style={styles.timeLabels}>
|
|
||||||
<Text style={styles.timeLabel}>0:00</Text>
|
|
||||||
<Text style={styles.timeLabel}>12:00</Text>
|
|
||||||
<Text style={styles.timeLabel}>24:00</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 活动等级展示卡片 */}
|
|
||||||
<View style={styles.activityLevelCard}>
|
|
||||||
|
|
||||||
|
|
||||||
{/* 活动级别文本 */}
|
|
||||||
<Text style={styles.activityMainText}>你今天的活动量处于</Text>
|
|
||||||
<Text style={styles.activityLevelText}>{currentActivityLevel.label}</Text>
|
|
||||||
|
|
||||||
{/* 进度条 */}
|
|
||||||
<View style={styles.progressBarContainer}>
|
|
||||||
<View style={styles.progressBarBackground}>
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
styles.progressBarFill,
|
|
||||||
{
|
|
||||||
width: `${progressPercentage}%`,
|
|
||||||
backgroundColor: currentActivityLevel.color
|
|
||||||
}
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 步数信息 */}
|
|
||||||
<View style={styles.stepsInfoContainer}>
|
|
||||||
<View style={styles.currentStepsInfo}>
|
|
||||||
<Text style={styles.stepsValue}>{totalSteps.toLocaleString()} 步</Text>
|
|
||||||
<Text style={styles.stepsLabel}>当前</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.nextStepsInfo}>
|
|
||||||
<Text style={styles.stepsValue}>
|
|
||||||
{nextActivityLevel ? `${nextActivityLevel.minSteps.toLocaleString()} 步` : '--'}
|
|
||||||
</Text>
|
|
||||||
<Text style={styles.stepsLabel}>
|
|
||||||
{nextActivityLevel ? `下一级: ${nextActivityLevel.label}` : '已达最高级'}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 活动等级图例 */}
|
|
||||||
<View style={styles.activityLegendContainer}>
|
|
||||||
{reversedActivityLevels.map((level) => (
|
|
||||||
<View key={level.key} style={styles.legendItem}>
|
|
||||||
<View style={[styles.legendIcon, { backgroundColor: level.color }]}>
|
|
||||||
<Text style={styles.legendIconText}>🏃</Text>
|
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.legendLabel}>{level.label}</Text>
|
);
|
||||||
<Text style={styles.legendRange}>
|
})}
|
||||||
{level.maxSteps === Infinity
|
</View>
|
||||||
? `> ${level.minSteps.toLocaleString()}`
|
|
||||||
: `${level.minSteps.toLocaleString()} - ${level.maxSteps.toLocaleString()}`}
|
{/* 底部时间轴标签 */}
|
||||||
</Text>
|
<View style={styles.timeLabels}>
|
||||||
</View>
|
<Text style={styles.timeLabel}>0:00</Text>
|
||||||
))}
|
<Text style={styles.timeLabel}>12:00</Text>
|
||||||
|
<Text style={styles.timeLabel}>24:00</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</View>
|
||||||
</SafeAreaView>
|
|
||||||
|
{/* 活动等级展示卡片 */}
|
||||||
|
<View style={styles.activityLevelCard}>
|
||||||
|
|
||||||
|
|
||||||
|
{/* 活动级别文本 */}
|
||||||
|
<Text style={styles.activityMainText}>你今天的活动量处于</Text>
|
||||||
|
<Text style={styles.activityLevelText}>{currentActivityLevel.label}</Text>
|
||||||
|
|
||||||
|
{/* 进度条 */}
|
||||||
|
<View style={styles.progressBarContainer}>
|
||||||
|
<View style={styles.progressBarBackground}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.progressBarFill,
|
||||||
|
{
|
||||||
|
width: `${progressPercentage}%`,
|
||||||
|
backgroundColor: currentActivityLevel.color
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 步数信息 */}
|
||||||
|
<View style={styles.stepsInfoContainer}>
|
||||||
|
<View style={styles.currentStepsInfo}>
|
||||||
|
<Text style={styles.stepsValue}>{totalSteps.toLocaleString()} 步</Text>
|
||||||
|
<Text style={styles.stepsLabel}>当前</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.nextStepsInfo}>
|
||||||
|
<Text style={styles.stepsValue}>
|
||||||
|
{nextActivityLevel ? `${nextActivityLevel.minSteps.toLocaleString()} 步` : '--'}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.stepsLabel}>
|
||||||
|
{nextActivityLevel ? `下一级: ${nextActivityLevel.label}` : '已达最高级'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 活动等级图例 */}
|
||||||
|
<View style={styles.activityLegendContainer}>
|
||||||
|
{reversedActivityLevels.map((level) => (
|
||||||
|
<View key={level.key} style={styles.legendItem}>
|
||||||
|
<View style={[styles.legendIcon, { backgroundColor: level.color }]}>
|
||||||
|
<Text style={styles.legendIconText}>🏃</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.legendLabel}>{level.label}</Text>
|
||||||
|
<Text style={styles.legendRange}>
|
||||||
|
{level.maxSteps === Infinity
|
||||||
|
? `> ${level.minSteps.toLocaleString()}`
|
||||||
|
: `${level.minSteps.toLocaleString()} - ${level.maxSteps.toLocaleString()}`}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -450,9 +428,6 @@ const styles = StyleSheet.create({
|
|||||||
top: 0,
|
top: 0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
},
|
},
|
||||||
safeArea: {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
header: {
|
header: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
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 { Colors } from '@/constants/Colors';
|
||||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
|
||||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
import { useWaterDataByDate } from '@/hooks/useWaterData';
|
import { useWaterDataByDate } from '@/hooks/useWaterData';
|
||||||
import { getQuickWaterAmount, setQuickWaterAmount } from '@/utils/userPreferences';
|
import { WaterNotificationHelpers } from '@/utils/notificationHelpers';
|
||||||
|
import { getQuickWaterAmount, getWaterReminderSettings, setWaterReminderSettings as saveWaterReminderSettings, setQuickWaterAmount } from '@/utils/userPreferences';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { Picker } from '@react-native-picker/picker';
|
import { Picker } from '@react-native-picker/picker';
|
||||||
import { Image } from 'expo-image';
|
import { Image } from 'expo-image';
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
Pressable,
|
Pressable,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
|
Switch,
|
||||||
Text,
|
Text,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
View
|
View
|
||||||
@@ -26,85 +27,32 @@ import { Swipeable } from 'react-native-gesture-handler';
|
|||||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
interface WaterSettingsProps {
|
interface WaterDetailProps {
|
||||||
selectedDate?: string;
|
selectedDate?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const WaterSettings: React.FC<WaterSettingsProps> = () => {
|
const WaterDetail: React.FC<WaterDetailProps> = () => {
|
||||||
const { selectedDate } = useLocalSearchParams<{ selectedDate?: string }>();
|
const { selectedDate } = useLocalSearchParams<{ selectedDate?: string }>();
|
||||||
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||||
const colorTokens = Colors[theme];
|
const colorTokens = Colors[theme];
|
||||||
const { ensureLoggedIn } = useAuthGuard();
|
|
||||||
|
|
||||||
const [dailyGoal, setDailyGoal] = useState<string>('2000');
|
const [dailyGoal, setDailyGoal] = useState<string>('2000');
|
||||||
const [quickAddAmount, setQuickAddAmount] = useState<string>('250');
|
const [quickAddAmount, setQuickAddAmount] = useState<string>('250');
|
||||||
|
|
||||||
// 编辑弹窗状态
|
// Remove modal states as they are now in separate settings page
|
||||||
const [goalModalVisible, setGoalModalVisible] = useState(false);
|
|
||||||
const [quickAddModalVisible, setQuickAddModalVisible] = useState(false);
|
|
||||||
|
|
||||||
// 临时选中值
|
|
||||||
const [tempGoal, setTempGoal] = useState<number>(parseInt(dailyGoal));
|
|
||||||
const [tempQuickAdd, setTempQuickAdd] = useState<number>(parseInt(quickAddAmount));
|
|
||||||
|
|
||||||
// 使用新的 hook 来处理指定日期的饮水数据
|
// 使用新的 hook 来处理指定日期的饮水数据
|
||||||
const { waterRecords, dailyWaterGoal, updateWaterGoal, removeWaterRecord } = useWaterDataByDate(selectedDate);
|
const { waterRecords, dailyWaterGoal, updateWaterGoal, removeWaterRecord } = useWaterDataByDate(selectedDate);
|
||||||
|
|
||||||
// 检查登录状态
|
|
||||||
useEffect(() => {
|
|
||||||
const checkLoginStatus = async () => {
|
|
||||||
const isLoggedIn = await ensureLoggedIn();
|
|
||||||
if (!isLoggedIn) {
|
|
||||||
// 如果未登录,用户会被重定向到登录页面
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
checkLoginStatus();
|
|
||||||
}, [ensureLoggedIn]);
|
|
||||||
|
|
||||||
const goalPresets = [1500, 2000, 2500, 3000, 3500, 4000];
|
|
||||||
const quickAddPresets = [100, 150, 200, 250, 300, 350, 400, 500];
|
|
||||||
|
|
||||||
|
|
||||||
// 打开饮水目标弹窗时初始化临时值
|
|
||||||
const openGoalModal = () => {
|
// 处理设置按钮点击 - 跳转到设置页面
|
||||||
setTempGoal(parseInt(dailyGoal));
|
const handleSettingsPress = () => {
|
||||||
setGoalModalVisible(true);
|
router.push('/water/settings');
|
||||||
};
|
};
|
||||||
|
|
||||||
// 打开快速添加弹窗时初始化临时值
|
// Remove all modal-related functions as they are now in separate settings page
|
||||||
const openQuickAddModal = () => {
|
|
||||||
setTempQuickAdd(parseInt(quickAddAmount));
|
|
||||||
setQuickAddModalVisible(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 处理饮水目标确认
|
|
||||||
const handleGoalConfirm = async () => {
|
|
||||||
setDailyGoal(tempGoal.toString());
|
|
||||||
setGoalModalVisible(false);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const success = await updateWaterGoal(tempGoal);
|
|
||||||
if (!success) {
|
|
||||||
Alert.alert('设置失败', '无法保存饮水目标,请重试');
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
Alert.alert('设置失败', '无法保存饮水目标,请重试');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 处理快速添加默认值确认
|
|
||||||
const handleQuickAddConfirm = async () => {
|
|
||||||
setQuickAddAmount(tempQuickAdd.toString());
|
|
||||||
setQuickAddModalVisible(false);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await setQuickWaterAmount(tempQuickAdd);
|
|
||||||
} catch {
|
|
||||||
Alert.alert('设置失败', '无法保存快速添加默认值,请重试');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
// 删除饮水记录
|
// 删除饮水记录
|
||||||
@@ -131,15 +79,6 @@ const WaterSettings: React.FC<WaterSettingsProps> = () => {
|
|||||||
loadUserPreferences();
|
loadUserPreferences();
|
||||||
}, [dailyWaterGoal]);
|
}, [dailyWaterGoal]);
|
||||||
|
|
||||||
// 当dailyGoal或quickAddAmount更新时,同步更新临时状态
|
|
||||||
useEffect(() => {
|
|
||||||
setTempGoal(parseInt(dailyGoal));
|
|
||||||
}, [dailyGoal]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setTempQuickAdd(parseInt(quickAddAmount));
|
|
||||||
}, [quickAddAmount]);
|
|
||||||
|
|
||||||
// 新增:饮水记录卡片组件
|
// 新增:饮水记录卡片组件
|
||||||
const WaterRecordCard = ({ record, onDelete }: { record: any; onDelete: () => void }) => {
|
const WaterRecordCard = ({ record, onDelete }: { record: any; onDelete: () => void }) => {
|
||||||
const swipeableRef = React.useRef<Swipeable>(null);
|
const swipeableRef = React.useRef<Swipeable>(null);
|
||||||
@@ -233,11 +172,20 @@ const WaterSettings: React.FC<WaterSettingsProps> = () => {
|
|||||||
<View style={styles.decorativeCircle2} />
|
<View style={styles.decorativeCircle2} />
|
||||||
|
|
||||||
<HeaderBar
|
<HeaderBar
|
||||||
title="饮水设置"
|
title="饮水详情"
|
||||||
onBack={() => {
|
onBack={() => {
|
||||||
// 这里会通过路由自动处理返回
|
// 这里会通过路由自动处理返回
|
||||||
router.back();
|
router.back();
|
||||||
}}
|
}}
|
||||||
|
right={
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.settingsButton}
|
||||||
|
onPress={handleSettingsPress}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Ionicons name="settings-outline" size={24} color={colorTokens.text} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<KeyboardAvoidingView
|
<KeyboardAvoidingView
|
||||||
@@ -249,44 +197,6 @@ const WaterSettings: React.FC<WaterSettingsProps> = () => {
|
|||||||
contentContainerStyle={styles.scrollContent}
|
contentContainerStyle={styles.scrollContent}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
>
|
>
|
||||||
{/* 第一部分:饮水配置 */}
|
|
||||||
<View style={styles.section}>
|
|
||||||
<Text style={[styles.sectionTitle, { color: colorTokens.text }]}>饮水配置</Text>
|
|
||||||
|
|
||||||
{/* 设置目标部分 */}
|
|
||||||
<TouchableOpacity
|
|
||||||
style={[styles.settingRow, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}
|
|
||||||
onPress={openGoalModal}
|
|
||||||
activeOpacity={0.8}
|
|
||||||
>
|
|
||||||
<View style={styles.settingLeft}>
|
|
||||||
<Text style={[styles.settingTitle, { color: colorTokens.text }]}>每日饮水目标</Text>
|
|
||||||
<Text style={[styles.settingValue, { color: colorTokens.textSecondary }]}>{dailyGoal}ml</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.settingRight}>
|
|
||||||
<Ionicons name="chevron-forward" size={16} color={colorTokens.icon} />
|
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
{/* 快速添加默认值设置部分 */}
|
|
||||||
<TouchableOpacity
|
|
||||||
style={[styles.settingRow, { backgroundColor: colorTokens.pageBackgroundEmphasis, marginTop: 24 }]}
|
|
||||||
onPress={openQuickAddModal}
|
|
||||||
activeOpacity={0.8}
|
|
||||||
>
|
|
||||||
<View style={styles.settingLeft}>
|
|
||||||
<Text style={[styles.settingTitle, { color: colorTokens.text }]}>快速添加默认值</Text>
|
|
||||||
<Text style={[styles.settingSubtitle, { color: colorTokens.textSecondary }]}>
|
|
||||||
{`设置点击右上角"+"按钮时添加的默认饮水量`}
|
|
||||||
</Text>
|
|
||||||
<Text style={[styles.settingValue, { color: colorTokens.textSecondary }]}>{quickAddAmount}ml</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.settingRight}>
|
|
||||||
<Ionicons name="chevron-forward" size={16} color={colorTokens.icon} />
|
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 第二部分:饮水记录 */}
|
{/* 第二部分:饮水记录 */}
|
||||||
<View style={styles.section}>
|
<View style={styles.section}>
|
||||||
@@ -325,83 +235,7 @@ const WaterSettings: React.FC<WaterSettingsProps> = () => {
|
|||||||
</ScrollView>
|
</ScrollView>
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
|
|
||||||
{/* 饮水目标编辑弹窗 */}
|
{/* All modals have been moved to the separate water-settings page */}
|
||||||
<Modal
|
|
||||||
visible={goalModalVisible}
|
|
||||||
transparent
|
|
||||||
animationType="fade"
|
|
||||||
onRequestClose={() => setGoalModalVisible(false)}
|
|
||||||
>
|
|
||||||
<Pressable style={styles.modalBackdrop} onPress={() => setGoalModalVisible(false)} />
|
|
||||||
<View style={styles.modalSheet}>
|
|
||||||
<View style={styles.modalHandle} />
|
|
||||||
<Text style={[styles.modalTitle, { color: colorTokens.text }]}>每日饮水目标</Text>
|
|
||||||
<View style={styles.pickerContainer}>
|
|
||||||
<Picker
|
|
||||||
selectedValue={tempGoal}
|
|
||||||
onValueChange={(value) => setTempGoal(value)}
|
|
||||||
style={styles.picker}
|
|
||||||
>
|
|
||||||
{Array.from({ length: 96 }, (_, i) => 500 + i * 100).map(goal => (
|
|
||||||
<Picker.Item key={goal} label={`${goal}ml`} value={goal} />
|
|
||||||
))}
|
|
||||||
</Picker>
|
|
||||||
</View>
|
|
||||||
<View style={styles.modalActions}>
|
|
||||||
<Pressable
|
|
||||||
onPress={() => setGoalModalVisible(false)}
|
|
||||||
style={[styles.modalBtn, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}
|
|
||||||
>
|
|
||||||
<Text style={[styles.modalBtnText, { color: colorTokens.text }]}>取消</Text>
|
|
||||||
</Pressable>
|
|
||||||
<Pressable
|
|
||||||
onPress={handleGoalConfirm}
|
|
||||||
style={[styles.modalBtn, styles.modalBtnPrimary, { backgroundColor: colorTokens.primary }]}
|
|
||||||
>
|
|
||||||
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary, { color: colorTokens.onPrimary }]}>确定</Text>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
{/* 快速添加默认值编辑弹窗 */}
|
|
||||||
<Modal
|
|
||||||
visible={quickAddModalVisible}
|
|
||||||
transparent
|
|
||||||
animationType="fade"
|
|
||||||
onRequestClose={() => setQuickAddModalVisible(false)}
|
|
||||||
>
|
|
||||||
<Pressable style={styles.modalBackdrop} onPress={() => setQuickAddModalVisible(false)} />
|
|
||||||
<View style={styles.modalSheet}>
|
|
||||||
<View style={styles.modalHandle} />
|
|
||||||
<Text style={[styles.modalTitle, { color: colorTokens.text }]}>快速添加默认值</Text>
|
|
||||||
<View style={styles.pickerContainer}>
|
|
||||||
<Picker
|
|
||||||
selectedValue={tempQuickAdd}
|
|
||||||
onValueChange={(value) => setTempQuickAdd(value)}
|
|
||||||
style={styles.picker}
|
|
||||||
>
|
|
||||||
{Array.from({ length: 41 }, (_, i) => 50 + i * 10).map(amount => (
|
|
||||||
<Picker.Item key={amount} label={`${amount}ml`} value={amount} />
|
|
||||||
))}
|
|
||||||
</Picker>
|
|
||||||
</View>
|
|
||||||
<View style={styles.modalActions}>
|
|
||||||
<Pressable
|
|
||||||
onPress={() => setQuickAddModalVisible(false)}
|
|
||||||
style={[styles.modalBtn, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}
|
|
||||||
>
|
|
||||||
<Text style={[styles.modalBtnText, { color: colorTokens.text }]}>取消</Text>
|
|
||||||
</Pressable>
|
|
||||||
<Pressable
|
|
||||||
onPress={handleQuickAddConfirm}
|
|
||||||
style={[styles.modalBtn, styles.modalBtnPrimary, { backgroundColor: colorTokens.primary }]}
|
|
||||||
>
|
|
||||||
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary, { color: colorTokens.onPrimary }]}>确定</Text>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</Modal>
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -466,79 +300,6 @@ const styles = StyleSheet.create({
|
|||||||
fontWeight: '400',
|
fontWeight: '400',
|
||||||
lineHeight: 18,
|
lineHeight: 18,
|
||||||
},
|
},
|
||||||
input: {
|
|
||||||
borderRadius: 12,
|
|
||||||
paddingHorizontal: 16,
|
|
||||||
paddingVertical: 14,
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: '500',
|
|
||||||
marginBottom: 16,
|
|
||||||
},
|
|
||||||
settingRow: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
paddingVertical: 16,
|
|
||||||
paddingHorizontal: 16,
|
|
||||||
borderRadius: 12,
|
|
||||||
marginBottom: 16,
|
|
||||||
},
|
|
||||||
settingLeft: {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
settingTitle: {
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: '500',
|
|
||||||
marginBottom: 4,
|
|
||||||
},
|
|
||||||
settingSubtitle: {
|
|
||||||
fontSize: 14,
|
|
||||||
marginBottom: 8,
|
|
||||||
},
|
|
||||||
settingValue: {
|
|
||||||
fontSize: 16,
|
|
||||||
},
|
|
||||||
settingRight: {
|
|
||||||
marginLeft: 12,
|
|
||||||
},
|
|
||||||
quickAmountsContainer: {
|
|
||||||
marginBottom: 15,
|
|
||||||
},
|
|
||||||
quickAmountsWrapper: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
gap: 10,
|
|
||||||
paddingRight: 10,
|
|
||||||
},
|
|
||||||
quickAmountButton: {
|
|
||||||
paddingHorizontal: 20,
|
|
||||||
paddingVertical: 8,
|
|
||||||
borderRadius: 20,
|
|
||||||
minWidth: 70,
|
|
||||||
alignItems: 'center',
|
|
||||||
shadowColor: '#000',
|
|
||||||
shadowOffset: { width: 0, height: 1 },
|
|
||||||
shadowOpacity: 0.05,
|
|
||||||
shadowRadius: 2,
|
|
||||||
elevation: 1,
|
|
||||||
},
|
|
||||||
quickAmountText: {
|
|
||||||
fontSize: 15,
|
|
||||||
fontWeight: '500',
|
|
||||||
},
|
|
||||||
saveButton: {
|
|
||||||
paddingVertical: 14,
|
|
||||||
borderRadius: 12,
|
|
||||||
alignItems: 'center',
|
|
||||||
marginTop: 24,
|
|
||||||
shadowOffset: { width: 0, height: 2 },
|
|
||||||
shadowOpacity: 0.25,
|
|
||||||
shadowRadius: 4,
|
|
||||||
elevation: 3,
|
|
||||||
},
|
|
||||||
saveButtonText: {
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: '700',
|
|
||||||
},
|
|
||||||
// 饮水记录相关样式
|
// 饮水记录相关样式
|
||||||
recordsList: {
|
recordsList: {
|
||||||
gap: 12,
|
gap: 12,
|
||||||
@@ -714,6 +475,225 @@ const styles = StyleSheet.create({
|
|||||||
modalBtnTextPrimary: {
|
modalBtnTextPrimary: {
|
||||||
// color will be set dynamically
|
// color will be set dynamically
|
||||||
},
|
},
|
||||||
|
settingsButton: {
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
settingsModalSheet: {
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
padding: 16,
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderTopLeftRadius: 16,
|
||||||
|
borderTopRightRadius: 16,
|
||||||
|
shadowColor: '#000000',
|
||||||
|
shadowOffset: { width: 0, height: -2 },
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 16,
|
||||||
|
},
|
||||||
|
settingsModalTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '600',
|
||||||
|
textAlign: 'center',
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
settingsMenuContainer: {
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderRadius: 12,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 1 },
|
||||||
|
shadowOpacity: 0.05,
|
||||||
|
shadowRadius: 4,
|
||||||
|
elevation: 2,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
settingsMenuItem: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingVertical: 14,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: '#F1F3F4',
|
||||||
|
},
|
||||||
|
settingsMenuItemLeft: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
settingsIconContainer: {
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: 6,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginRight: 12,
|
||||||
|
},
|
||||||
|
settingsMenuItemContent: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
settingsMenuItemTitle: {
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: '500',
|
||||||
|
marginBottom: 2,
|
||||||
|
},
|
||||||
|
settingsMenuItemSubtitle: {
|
||||||
|
fontSize: 12,
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
settingsMenuItemValue: {
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
// 喝水提醒配置弹窗样式
|
||||||
|
waterReminderModalSheet: {
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
padding: 16,
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderTopLeftRadius: 16,
|
||||||
|
borderTopRightRadius: 16,
|
||||||
|
maxHeight: '80%',
|
||||||
|
shadowColor: '#000000',
|
||||||
|
shadowOffset: { width: 0, height: -2 },
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 16,
|
||||||
|
},
|
||||||
|
waterReminderContent: {
|
||||||
|
flex: 1,
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
waterReminderSection: {
|
||||||
|
marginBottom: 24,
|
||||||
|
},
|
||||||
|
waterReminderSectionHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
waterReminderSectionTitleContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
waterReminderSectionTitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
waterReminderSectionDesc: {
|
||||||
|
fontSize: 14,
|
||||||
|
lineHeight: 20,
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
timeRangeContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 16,
|
||||||
|
marginTop: 16,
|
||||||
|
},
|
||||||
|
timePickerContainer: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
timeLabel: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '500',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
timePicker: {
|
||||||
|
paddingVertical: 12,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
borderRadius: 8,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
timePickerText: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
timePickerIcon: {
|
||||||
|
opacity: 0.6,
|
||||||
|
},
|
||||||
|
intervalContainer: {
|
||||||
|
marginTop: 16,
|
||||||
|
},
|
||||||
|
intervalPickerContainer: {
|
||||||
|
backgroundColor: '#F8F9FA',
|
||||||
|
borderRadius: 8,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
intervalPicker: {
|
||||||
|
height: 120,
|
||||||
|
},
|
||||||
|
// 时间选择器弹窗样式
|
||||||
|
timePickerModalSheet: {
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
padding: 16,
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderTopLeftRadius: 16,
|
||||||
|
borderTopRightRadius: 16,
|
||||||
|
maxHeight: '60%',
|
||||||
|
shadowColor: '#000000',
|
||||||
|
shadowOffset: { width: 0, height: -2 },
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 16,
|
||||||
|
},
|
||||||
|
timePickerContent: {
|
||||||
|
flex: 1,
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
timePickerSection: {
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
timePickerLabel: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
marginBottom: 12,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
hourPickerContainer: {
|
||||||
|
backgroundColor: '#F8F9FA',
|
||||||
|
borderRadius: 8,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
hourPicker: {
|
||||||
|
height: 160,
|
||||||
|
},
|
||||||
|
timeRangePreview: {
|
||||||
|
backgroundColor: '#F0F8FF',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 16,
|
||||||
|
marginTop: 16,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
timeRangePreviewLabel: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '500',
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
timeRangePreviewText: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '600',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
timeRangeWarning: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#FF6B6B',
|
||||||
|
textAlign: 'center',
|
||||||
|
lineHeight: 18,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default WaterSettings;
|
export default WaterDetail;
|
||||||
618
app/water/reminder-settings.tsx
Normal file
@@ -0,0 +1,618 @@
|
|||||||
|
import { Colors } from '@/constants/Colors';
|
||||||
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
|
import { WaterNotificationHelpers } from '@/utils/notificationHelpers';
|
||||||
|
import { getWaterReminderSettings, setWaterReminderSettings as saveWaterReminderSettings } from '@/utils/userPreferences';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { Picker } from '@react-native-picker/picker';
|
||||||
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
|
import { router } from 'expo-router';
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Modal,
|
||||||
|
Platform,
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
Switch,
|
||||||
|
Text,
|
||||||
|
TouchableOpacity,
|
||||||
|
View
|
||||||
|
} from 'react-native';
|
||||||
|
|
||||||
|
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||||
|
|
||||||
|
const WaterReminderSettings: React.FC = () => {
|
||||||
|
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||||
|
const colorTokens = Colors[theme];
|
||||||
|
|
||||||
|
const [startTimePickerVisible, setStartTimePickerVisible] = useState(false);
|
||||||
|
const [endTimePickerVisible, setEndTimePickerVisible] = useState(false);
|
||||||
|
|
||||||
|
// 喝水提醒相关状态
|
||||||
|
const [waterReminderSettings, setWaterReminderSettings] = useState({
|
||||||
|
enabled: false,
|
||||||
|
startTime: '08:00',
|
||||||
|
endTime: '22:00',
|
||||||
|
interval: 60,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 时间选择器临时值
|
||||||
|
const [tempStartHour, setTempStartHour] = useState(8);
|
||||||
|
const [tempEndHour, setTempEndHour] = useState(22);
|
||||||
|
|
||||||
|
// 打开开始时间选择器
|
||||||
|
const openStartTimePicker = () => {
|
||||||
|
const currentHour = parseInt(waterReminderSettings.startTime.split(':')[0]);
|
||||||
|
setTempStartHour(currentHour);
|
||||||
|
setStartTimePickerVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 打开结束时间选择器
|
||||||
|
const openEndTimePicker = () => {
|
||||||
|
const currentHour = parseInt(waterReminderSettings.endTime.split(':')[0]);
|
||||||
|
setTempEndHour(currentHour);
|
||||||
|
setEndTimePickerVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 确认开始时间选择
|
||||||
|
const confirmStartTime = () => {
|
||||||
|
const newStartTime = `${String(tempStartHour).padStart(2, '0')}:00`;
|
||||||
|
|
||||||
|
// 检查时间合理性
|
||||||
|
if (isValidTimeRange(newStartTime, waterReminderSettings.endTime)) {
|
||||||
|
setWaterReminderSettings(prev => ({
|
||||||
|
...prev,
|
||||||
|
startTime: newStartTime
|
||||||
|
}));
|
||||||
|
setStartTimePickerVisible(false);
|
||||||
|
} else {
|
||||||
|
Alert.alert(
|
||||||
|
'时间设置提示',
|
||||||
|
'开始时间不能晚于或等于结束时间,请重新选择',
|
||||||
|
[{ text: '确定' }]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 确认结束时间选择
|
||||||
|
const confirmEndTime = () => {
|
||||||
|
const newEndTime = `${String(tempEndHour).padStart(2, '0')}:00`;
|
||||||
|
|
||||||
|
// 检查时间合理性
|
||||||
|
if (isValidTimeRange(waterReminderSettings.startTime, newEndTime)) {
|
||||||
|
setWaterReminderSettings(prev => ({
|
||||||
|
...prev,
|
||||||
|
endTime: newEndTime
|
||||||
|
}));
|
||||||
|
setEndTimePickerVisible(false);
|
||||||
|
} else {
|
||||||
|
Alert.alert(
|
||||||
|
'时间设置提示',
|
||||||
|
'结束时间不能早于或等于开始时间,请重新选择',
|
||||||
|
[{ text: '确定' }]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 验证时间范围是否有效
|
||||||
|
const isValidTimeRange = (startTime: string, endTime: string): boolean => {
|
||||||
|
const [startHour] = startTime.split(':').map(Number);
|
||||||
|
const [endHour] = endTime.split(':').map(Number);
|
||||||
|
|
||||||
|
// 支持跨天的情况,如果结束时间小于开始时间,认为是跨天有效的
|
||||||
|
if (endHour < startHour) {
|
||||||
|
return true; // 跨天情况,如 22:00 到 08:00
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同一天内,结束时间必须大于开始时间
|
||||||
|
return endHour > startHour;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理喝水提醒配置保存
|
||||||
|
const handleWaterReminderSave = async () => {
|
||||||
|
try {
|
||||||
|
// 保存设置到本地存储
|
||||||
|
await saveWaterReminderSettings(waterReminderSettings);
|
||||||
|
|
||||||
|
// 设置或取消通知
|
||||||
|
// 这里使用 "用户" 作为默认用户名,实际项目中应该从用户状态获取
|
||||||
|
const userName = '用户';
|
||||||
|
await WaterNotificationHelpers.scheduleCustomWaterReminders(userName, waterReminderSettings);
|
||||||
|
|
||||||
|
if (waterReminderSettings.enabled) {
|
||||||
|
const timeInfo = `${waterReminderSettings.startTime}-${waterReminderSettings.endTime}`;
|
||||||
|
const intervalInfo = `每${waterReminderSettings.interval}分钟`;
|
||||||
|
Alert.alert(
|
||||||
|
'设置成功',
|
||||||
|
`喝水提醒已开启\n\n时间段:${timeInfo}\n提醒间隔:${intervalInfo}\n\n我们将在指定时间段内定期提醒您喝水`,
|
||||||
|
[{ text: '确定', onPress: () => router.back() }]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
Alert.alert('设置成功', '喝水提醒已关闭', [{ text: '确定', onPress: () => router.back() }]);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('保存喝水提醒设置失败:', error);
|
||||||
|
Alert.alert('保存失败', '无法保存喝水提醒设置,请重试');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 加载用户偏好设置
|
||||||
|
useEffect(() => {
|
||||||
|
const loadUserPreferences = async () => {
|
||||||
|
try {
|
||||||
|
// 加载喝水提醒设置
|
||||||
|
const reminderSettings = await getWaterReminderSettings();
|
||||||
|
setWaterReminderSettings(reminderSettings);
|
||||||
|
|
||||||
|
// 初始化时间选择器临时值
|
||||||
|
const startHour = parseInt(reminderSettings.startTime.split(':')[0]);
|
||||||
|
const endHour = parseInt(reminderSettings.endTime.split(':')[0]);
|
||||||
|
setTempStartHour(startHour);
|
||||||
|
setTempEndHour(endHour);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载用户偏好设置失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadUserPreferences();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
{/* 背景渐变 */}
|
||||||
|
<LinearGradient
|
||||||
|
colors={['#f5e5fbff', '#e5fcfeff', '#eefdffff', '#e6f6fcff']}
|
||||||
|
style={styles.gradientBackground}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 0, y: 1 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 装饰性圆圈 */}
|
||||||
|
<View style={styles.decorativeCircle1} />
|
||||||
|
<View style={styles.decorativeCircle2} />
|
||||||
|
|
||||||
|
<HeaderBar
|
||||||
|
title="喝水提醒"
|
||||||
|
onBack={() => {
|
||||||
|
router.back();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
style={styles.keyboardAvoidingView}
|
||||||
|
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||||
|
>
|
||||||
|
<ScrollView
|
||||||
|
style={styles.scrollView}
|
||||||
|
contentContainerStyle={styles.scrollContent}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
{/* 开启/关闭提醒 */}
|
||||||
|
<View style={styles.waterReminderSection}>
|
||||||
|
<View style={styles.waterReminderSectionHeader}>
|
||||||
|
<View style={styles.waterReminderSectionTitleContainer}>
|
||||||
|
<Ionicons name="notifications-outline" size={20} color={colorTokens.text} />
|
||||||
|
<Text style={[styles.waterReminderSectionTitle, { color: colorTokens.text }]}>推送提醒</Text>
|
||||||
|
</View>
|
||||||
|
<Switch
|
||||||
|
value={waterReminderSettings.enabled}
|
||||||
|
onValueChange={(enabled) => setWaterReminderSettings(prev => ({ ...prev, enabled }))}
|
||||||
|
trackColor={{ false: '#E5E5E5', true: '#3498DB' }}
|
||||||
|
thumbColor={waterReminderSettings.enabled ? '#FFFFFF' : '#FFFFFF'}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Text style={[styles.waterReminderSectionDesc, { color: colorTokens.textSecondary }]}>
|
||||||
|
开启后将在指定时间段内定期推送喝水提醒
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 时间段设置 */}
|
||||||
|
{waterReminderSettings.enabled && (
|
||||||
|
<>
|
||||||
|
<View style={styles.waterReminderSection}>
|
||||||
|
<Text style={[styles.waterReminderSectionTitle, { color: colorTokens.text }]}>提醒时间段</Text>
|
||||||
|
<Text style={[styles.waterReminderSectionDesc, { color: colorTokens.textSecondary }]}>
|
||||||
|
只在指定时间段内发送提醒,避免打扰您的休息
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View style={styles.timeRangeContainer}>
|
||||||
|
{/* 开始时间 */}
|
||||||
|
<View style={styles.timePickerContainer}>
|
||||||
|
<Text style={[styles.timeLabel, { color: colorTokens.text }]}>开始时间</Text>
|
||||||
|
<Pressable
|
||||||
|
style={[styles.timePicker, { backgroundColor: 'white' }]}
|
||||||
|
onPress={openStartTimePicker}
|
||||||
|
>
|
||||||
|
<Text style={[styles.timePickerText, { color: colorTokens.text }]}>{waterReminderSettings.startTime}</Text>
|
||||||
|
<Ionicons name="chevron-down" size={16} color={colorTokens.textSecondary} style={styles.timePickerIcon} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 结束时间 */}
|
||||||
|
<View style={styles.timePickerContainer}>
|
||||||
|
<Text style={[styles.timeLabel, { color: colorTokens.text }]}>结束时间</Text>
|
||||||
|
<Pressable
|
||||||
|
style={[styles.timePicker, { backgroundColor: 'white' }]}
|
||||||
|
onPress={openEndTimePicker}
|
||||||
|
>
|
||||||
|
<Text style={[styles.timePickerText, { color: colorTokens.text }]}>{waterReminderSettings.endTime}</Text>
|
||||||
|
<Ionicons name="chevron-down" size={16} color={colorTokens.textSecondary} style={styles.timePickerIcon} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 提醒间隔设置 */}
|
||||||
|
<View style={styles.waterReminderSection}>
|
||||||
|
<Text style={[styles.waterReminderSectionTitle, { color: colorTokens.text }]}>提醒间隔</Text>
|
||||||
|
<Text style={[styles.waterReminderSectionDesc, { color: colorTokens.textSecondary }]}>
|
||||||
|
选择提醒的频率,建议30-120分钟为宜
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View style={styles.intervalContainer}>
|
||||||
|
<View style={styles.intervalPickerContainer}>
|
||||||
|
<Picker
|
||||||
|
selectedValue={waterReminderSettings.interval}
|
||||||
|
onValueChange={(interval) => setWaterReminderSettings(prev => ({ ...prev, interval }))}
|
||||||
|
style={styles.intervalPicker}
|
||||||
|
>
|
||||||
|
{[30, 45, 60, 90, 120, 150, 180].map(interval => (
|
||||||
|
<Picker.Item key={interval} label={`${interval}分钟`} value={interval} />
|
||||||
|
))}
|
||||||
|
</Picker>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 保存按钮 */}
|
||||||
|
<View style={styles.saveButtonContainer}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.saveButton, { backgroundColor: colorTokens.primary }]}
|
||||||
|
onPress={handleWaterReminderSave}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
<Text style={[styles.saveButtonText, { color: colorTokens.onPrimary }]}>保存设置</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
|
||||||
|
{/* 开始时间选择器弹窗 */}
|
||||||
|
<Modal
|
||||||
|
visible={startTimePickerVisible}
|
||||||
|
transparent
|
||||||
|
animationType="fade"
|
||||||
|
onRequestClose={() => setStartTimePickerVisible(false)}
|
||||||
|
>
|
||||||
|
<Pressable style={styles.modalBackdrop} onPress={() => setStartTimePickerVisible(false)} />
|
||||||
|
<View style={styles.timePickerModalSheet}>
|
||||||
|
<View style={styles.modalHandle} />
|
||||||
|
<Text style={[styles.modalTitle, { color: colorTokens.text }]}>选择开始时间</Text>
|
||||||
|
|
||||||
|
<View style={styles.timePickerContent}>
|
||||||
|
<View style={styles.timePickerSection}>
|
||||||
|
<Text style={[styles.timePickerLabel, { color: colorTokens.text }]}>小时</Text>
|
||||||
|
<View style={styles.hourPickerContainer}>
|
||||||
|
<Picker
|
||||||
|
selectedValue={tempStartHour}
|
||||||
|
onValueChange={(hour) => setTempStartHour(hour)}
|
||||||
|
style={styles.hourPicker}
|
||||||
|
>
|
||||||
|
{Array.from({ length: 24 }, (_, i) => (
|
||||||
|
<Picker.Item key={i} label={`${String(i).padStart(2, '0')}:00`} value={i} />
|
||||||
|
))}
|
||||||
|
</Picker>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.timeRangePreview}>
|
||||||
|
<Text style={[styles.timeRangePreviewLabel, { color: colorTokens.textSecondary }]}>时间段预览</Text>
|
||||||
|
<Text style={[styles.timeRangePreviewText, { color: colorTokens.text }]}>
|
||||||
|
{String(tempStartHour).padStart(2, '0')}:00 - {waterReminderSettings.endTime}
|
||||||
|
</Text>
|
||||||
|
{!isValidTimeRange(`${String(tempStartHour).padStart(2, '0')}:00`, waterReminderSettings.endTime) && (
|
||||||
|
<Text style={styles.timeRangeWarning}>⚠️ 开始时间不能晚于或等于结束时间</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.modalActions}>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setStartTimePickerVisible(false)}
|
||||||
|
style={[styles.modalBtn, { backgroundColor: 'white' }]}
|
||||||
|
>
|
||||||
|
<Text style={[styles.modalBtnText, { color: colorTokens.text }]}>取消</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
onPress={confirmStartTime}
|
||||||
|
style={[styles.modalBtn, styles.modalBtnPrimary, { backgroundColor: colorTokens.primary }]}
|
||||||
|
>
|
||||||
|
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary, { color: colorTokens.onPrimary }]}>确定</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* 结束时间选择器弹窗 */}
|
||||||
|
<Modal
|
||||||
|
visible={endTimePickerVisible}
|
||||||
|
transparent
|
||||||
|
animationType="fade"
|
||||||
|
onRequestClose={() => setEndTimePickerVisible(false)}
|
||||||
|
>
|
||||||
|
<Pressable style={styles.modalBackdrop} onPress={() => setEndTimePickerVisible(false)} />
|
||||||
|
<View style={styles.timePickerModalSheet}>
|
||||||
|
<View style={styles.modalHandle} />
|
||||||
|
<Text style={[styles.modalTitle, { color: colorTokens.text }]}>选择结束时间</Text>
|
||||||
|
|
||||||
|
<View style={styles.timePickerContent}>
|
||||||
|
<View style={styles.timePickerSection}>
|
||||||
|
<Text style={[styles.timePickerLabel, { color: colorTokens.text }]}>小时</Text>
|
||||||
|
<View style={styles.hourPickerContainer}>
|
||||||
|
<Picker
|
||||||
|
selectedValue={tempEndHour}
|
||||||
|
onValueChange={(hour) => setTempEndHour(hour)}
|
||||||
|
style={styles.hourPicker}
|
||||||
|
>
|
||||||
|
{Array.from({ length: 24 }, (_, i) => (
|
||||||
|
<Picker.Item key={i} label={`${String(i).padStart(2, '0')}:00`} value={i} />
|
||||||
|
))}
|
||||||
|
</Picker>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.timeRangePreview}>
|
||||||
|
<Text style={[styles.timeRangePreviewLabel, { color: colorTokens.textSecondary }]}>时间段预览</Text>
|
||||||
|
<Text style={[styles.timeRangePreviewText, { color: colorTokens.text }]}>
|
||||||
|
{waterReminderSettings.startTime} - {String(tempEndHour).padStart(2, '0')}:00
|
||||||
|
</Text>
|
||||||
|
{!isValidTimeRange(waterReminderSettings.startTime, `${String(tempEndHour).padStart(2, '0')}:00`) && (
|
||||||
|
<Text style={styles.timeRangeWarning}>⚠️ 结束时间不能早于或等于开始时间</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.modalActions}>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setEndTimePickerVisible(false)}
|
||||||
|
style={[styles.modalBtn, { backgroundColor: 'white' }]}
|
||||||
|
>
|
||||||
|
<Text style={[styles.modalBtnText, { color: colorTokens.text }]}>取消</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
onPress={confirmEndTime}
|
||||||
|
style={[styles.modalBtn, styles.modalBtnPrimary, { backgroundColor: colorTokens.primary }]}
|
||||||
|
>
|
||||||
|
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary, { color: colorTokens.onPrimary }]}>确定</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
gradientBackground: {
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
},
|
||||||
|
decorativeCircle1: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 40,
|
||||||
|
right: 20,
|
||||||
|
width: 60,
|
||||||
|
height: 60,
|
||||||
|
borderRadius: 30,
|
||||||
|
backgroundColor: '#0EA5E9',
|
||||||
|
opacity: 0.1,
|
||||||
|
},
|
||||||
|
decorativeCircle2: {
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: -15,
|
||||||
|
left: -15,
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 20,
|
||||||
|
backgroundColor: '#0EA5E9',
|
||||||
|
opacity: 0.05,
|
||||||
|
},
|
||||||
|
keyboardAvoidingView: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
scrollView: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
scrollContent: {
|
||||||
|
padding: 20,
|
||||||
|
},
|
||||||
|
waterReminderSection: {
|
||||||
|
marginBottom: 32,
|
||||||
|
},
|
||||||
|
waterReminderSectionHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
waterReminderSectionTitleContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
waterReminderSectionTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
waterReminderSectionDesc: {
|
||||||
|
fontSize: 14,
|
||||||
|
lineHeight: 20,
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
timeRangeContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 16,
|
||||||
|
marginTop: 16,
|
||||||
|
},
|
||||||
|
timePickerContainer: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
timeLabel: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '500',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
timePicker: {
|
||||||
|
paddingVertical: 12,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
borderRadius: 8,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
timePickerText: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
timePickerIcon: {
|
||||||
|
opacity: 0.6,
|
||||||
|
},
|
||||||
|
intervalContainer: {
|
||||||
|
marginTop: 16,
|
||||||
|
},
|
||||||
|
intervalPickerContainer: {
|
||||||
|
backgroundColor: 'white',
|
||||||
|
borderRadius: 8,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
intervalPicker: {
|
||||||
|
height: 200,
|
||||||
|
},
|
||||||
|
saveButtonContainer: {
|
||||||
|
marginTop: 20,
|
||||||
|
marginBottom: 40,
|
||||||
|
},
|
||||||
|
saveButton: {
|
||||||
|
paddingVertical: 16,
|
||||||
|
borderRadius: 12,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
saveButtonText: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
modalBackdrop: {
|
||||||
|
...StyleSheet.absoluteFillObject,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.4)',
|
||||||
|
},
|
||||||
|
timePickerModalSheet: {
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
padding: 16,
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderTopLeftRadius: 16,
|
||||||
|
borderTopRightRadius: 16,
|
||||||
|
maxHeight: '60%',
|
||||||
|
shadowColor: '#000000',
|
||||||
|
shadowOffset: { width: 0, height: -2 },
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 16,
|
||||||
|
},
|
||||||
|
modalHandle: {
|
||||||
|
width: 36,
|
||||||
|
height: 4,
|
||||||
|
backgroundColor: '#E0E0E0',
|
||||||
|
borderRadius: 2,
|
||||||
|
alignSelf: 'center',
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
modalTitle: {
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: '600',
|
||||||
|
textAlign: 'center',
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
timePickerContent: {
|
||||||
|
flex: 1,
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
timePickerSection: {
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
timePickerLabel: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
marginBottom: 12,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
hourPickerContainer: {
|
||||||
|
backgroundColor: '#F8F9FA',
|
||||||
|
borderRadius: 8,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
hourPicker: {
|
||||||
|
height: 160,
|
||||||
|
},
|
||||||
|
timeRangePreview: {
|
||||||
|
backgroundColor: '#F0F8FF',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 16,
|
||||||
|
marginTop: 16,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
timeRangePreviewLabel: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '500',
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
timeRangePreviewText: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '600',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
timeRangeWarning: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#FF6B6B',
|
||||||
|
textAlign: 'center',
|
||||||
|
lineHeight: 18,
|
||||||
|
},
|
||||||
|
modalActions: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
modalBtn: {
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 10,
|
||||||
|
borderRadius: 10,
|
||||||
|
minWidth: 80,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
modalBtnPrimary: {
|
||||||
|
// backgroundColor will be set dynamically
|
||||||
|
},
|
||||||
|
modalBtnText: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
modalBtnTextPrimary: {
|
||||||
|
// color will be set dynamically
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default WaterReminderSettings;
|
||||||
585
app/water/settings.tsx
Normal file
@@ -0,0 +1,585 @@
|
|||||||
|
import { Colors } from '@/constants/Colors';
|
||||||
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
|
import { getQuickWaterAmount, getWaterReminderSettings, setQuickWaterAmount } from '@/utils/userPreferences';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { Picker } from '@react-native-picker/picker';
|
||||||
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
|
import { router } from 'expo-router';
|
||||||
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { useFocusEffect } from '@react-navigation/native';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Modal,
|
||||||
|
Platform,
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
Switch,
|
||||||
|
Text,
|
||||||
|
TouchableOpacity,
|
||||||
|
View
|
||||||
|
} from 'react-native';
|
||||||
|
|
||||||
|
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||||
|
|
||||||
|
const WaterSettings: React.FC = () => {
|
||||||
|
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||||
|
const colorTokens = Colors[theme];
|
||||||
|
|
||||||
|
const [quickAddAmount, setQuickAddAmount] = useState<string>('250');
|
||||||
|
|
||||||
|
// 喝水提醒设置状态(用于显示当前设置)
|
||||||
|
const [waterReminderSettings, setWaterReminderSettings] = useState({
|
||||||
|
enabled: false,
|
||||||
|
startTime: '08:00',
|
||||||
|
endTime: '22:00',
|
||||||
|
interval: 60,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 弹窗状态
|
||||||
|
const [goalModalVisible, setGoalModalVisible] = useState(false);
|
||||||
|
const [quickAddModalVisible, setQuickAddModalVisible] = useState(false);
|
||||||
|
|
||||||
|
// 临时选中值
|
||||||
|
const [tempGoal, setTempGoal] = useState<number>(2000);
|
||||||
|
const [tempQuickAdd, setTempQuickAdd] = useState<number>(250);
|
||||||
|
|
||||||
|
|
||||||
|
// 当前饮水目标(从本地存储获取)
|
||||||
|
const [currentWaterGoal, setCurrentWaterGoal] = useState(2000);
|
||||||
|
|
||||||
|
// 打开饮水目标弹窗时初始化临时值
|
||||||
|
const openGoalModal = () => {
|
||||||
|
setTempGoal(currentWaterGoal);
|
||||||
|
setGoalModalVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 打开快速添加弹窗时初始化临时值
|
||||||
|
const openQuickAddModal = () => {
|
||||||
|
setTempQuickAdd(parseInt(quickAddAmount));
|
||||||
|
setQuickAddModalVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 打开喝水提醒页面
|
||||||
|
const openWaterReminderSettings = () => {
|
||||||
|
router.push('/water/reminder-settings');
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// 处理饮水目标确认
|
||||||
|
const handleGoalConfirm = async () => {
|
||||||
|
setCurrentWaterGoal(tempGoal);
|
||||||
|
setGoalModalVisible(false);
|
||||||
|
|
||||||
|
// 这里可以添加保存到本地存储或发送到后端的逻辑
|
||||||
|
Alert.alert('设置成功', `每日饮水目标已设置为 ${tempGoal}ml`);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理快速添加默认值确认
|
||||||
|
const handleQuickAddConfirm = async () => {
|
||||||
|
setQuickAddAmount(tempQuickAdd.toString());
|
||||||
|
setQuickAddModalVisible(false);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await setQuickWaterAmount(tempQuickAdd);
|
||||||
|
Alert.alert('设置成功', `快速添加默认值已设置为 ${tempQuickAdd}ml`);
|
||||||
|
} catch {
|
||||||
|
Alert.alert('设置失败', '无法保存快速添加默认值,请重试');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 加载用户偏好设置
|
||||||
|
const loadUserPreferences = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const amount = await getQuickWaterAmount();
|
||||||
|
setQuickAddAmount(amount.toString());
|
||||||
|
setTempQuickAdd(amount);
|
||||||
|
|
||||||
|
// 加载喝水提醒设置来显示当前设置状态
|
||||||
|
const reminderSettings = await getWaterReminderSettings();
|
||||||
|
setWaterReminderSettings(reminderSettings);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载用户偏好设置失败:', error);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 初始化加载
|
||||||
|
useEffect(() => {
|
||||||
|
loadUserPreferences();
|
||||||
|
}, [loadUserPreferences]);
|
||||||
|
|
||||||
|
// 页面聚焦时重新加载设置(从提醒设置页面返回时)
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
loadUserPreferences();
|
||||||
|
}, [loadUserPreferences])
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
{/* 背景渐变 */}
|
||||||
|
<LinearGradient
|
||||||
|
colors={['#f5e5fbff', '#e5fcfeff', '#eefdffff', '#e6f6fcff']}
|
||||||
|
style={styles.gradientBackground}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 0, y: 1 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 装饰性圆圈 */}
|
||||||
|
<View style={styles.decorativeCircle1} />
|
||||||
|
<View style={styles.decorativeCircle2} />
|
||||||
|
|
||||||
|
<HeaderBar
|
||||||
|
title="饮水设置"
|
||||||
|
onBack={() => {
|
||||||
|
router.back();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
style={styles.keyboardAvoidingView}
|
||||||
|
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||||
|
>
|
||||||
|
<ScrollView
|
||||||
|
style={styles.scrollView}
|
||||||
|
contentContainerStyle={styles.scrollContent}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
{/* 设置列表 */}
|
||||||
|
<View style={styles.section}>
|
||||||
|
<View style={styles.settingsMenuContainer}>
|
||||||
|
<TouchableOpacity style={styles.settingsMenuItem} onPress={openGoalModal}>
|
||||||
|
<View style={styles.settingsMenuItemLeft}>
|
||||||
|
<View style={[styles.settingsIconContainer, { backgroundColor: 'rgba(147, 112, 219, 0.1)' }]}>
|
||||||
|
<Ionicons name="flag-outline" size={20} color="#9370DB" />
|
||||||
|
</View>
|
||||||
|
<View style={styles.settingsMenuItemContent}>
|
||||||
|
<Text style={[styles.settingsMenuItemTitle, { color: colorTokens.text }]}>每日饮水目标</Text>
|
||||||
|
<Text style={[styles.settingsMenuItemValue, { color: colorTokens.textSecondary }]}>{currentWaterGoal}ml</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<Ionicons name="chevron-forward" size={20} color="#CCCCCC" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity style={styles.settingsMenuItem} onPress={openQuickAddModal}>
|
||||||
|
<View style={styles.settingsMenuItemLeft}>
|
||||||
|
<View style={[styles.settingsIconContainer, { backgroundColor: 'rgba(147, 112, 219, 0.1)' }]}>
|
||||||
|
<Ionicons name="add-outline" size={20} color="#9370DB" />
|
||||||
|
</View>
|
||||||
|
<View style={styles.settingsMenuItemContent}>
|
||||||
|
<Text style={[styles.settingsMenuItemTitle, { color: colorTokens.text }]}>快速添加默认值</Text>
|
||||||
|
<Text style={[styles.settingsMenuItemSubtitle, { color: colorTokens.textSecondary }]}>
|
||||||
|
设置点击"+"按钮时添加的默认饮水量
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.settingsMenuItemValue, { color: colorTokens.textSecondary }]}>{quickAddAmount}ml</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<Ionicons name="chevron-forward" size={20} color="#CCCCCC" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity style={[styles.settingsMenuItem, { borderBottomWidth: 0 }]} onPress={openWaterReminderSettings}>
|
||||||
|
<View style={styles.settingsMenuItemLeft}>
|
||||||
|
<View style={[styles.settingsIconContainer, { backgroundColor: 'rgba(52, 152, 219, 0.1)' }]}>
|
||||||
|
<Ionicons name="notifications-outline" size={20} color="#3498DB" />
|
||||||
|
</View>
|
||||||
|
<View style={styles.settingsMenuItemContent}>
|
||||||
|
<Text style={[styles.settingsMenuItemTitle, { color: colorTokens.text }]}>喝水提醒</Text>
|
||||||
|
<Text style={[styles.settingsMenuItemSubtitle, { color: colorTokens.textSecondary }]}>
|
||||||
|
设置定时提醒您补充水分
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.settingsMenuItemValue, { color: colorTokens.textSecondary }]}>
|
||||||
|
{waterReminderSettings.enabled ? `${waterReminderSettings.startTime}-${waterReminderSettings.endTime}, 每${waterReminderSettings.interval}分钟` : '已关闭'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<Ionicons name="chevron-forward" size={20} color="#CCCCCC" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
|
||||||
|
{/* 饮水目标编辑弹窗 */}
|
||||||
|
<Modal
|
||||||
|
visible={goalModalVisible}
|
||||||
|
transparent
|
||||||
|
animationType="fade"
|
||||||
|
onRequestClose={() => setGoalModalVisible(false)}
|
||||||
|
>
|
||||||
|
<Pressable style={styles.modalBackdrop} onPress={() => setGoalModalVisible(false)} />
|
||||||
|
<View style={styles.modalSheet}>
|
||||||
|
<View style={styles.modalHandle} />
|
||||||
|
<Text style={[styles.modalTitle, { color: colorTokens.text }]}>每日饮水目标</Text>
|
||||||
|
<View style={styles.pickerContainer}>
|
||||||
|
<Picker
|
||||||
|
selectedValue={tempGoal}
|
||||||
|
onValueChange={(value) => setTempGoal(value)}
|
||||||
|
style={styles.picker}
|
||||||
|
>
|
||||||
|
{Array.from({ length: 96 }, (_, i) => 500 + i * 100).map(goal => (
|
||||||
|
<Picker.Item key={goal} label={`${goal}ml`} value={goal} />
|
||||||
|
))}
|
||||||
|
</Picker>
|
||||||
|
</View>
|
||||||
|
<View style={styles.modalActions}>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setGoalModalVisible(false)}
|
||||||
|
style={[styles.modalBtn, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}
|
||||||
|
>
|
||||||
|
<Text style={[styles.modalBtnText, { color: colorTokens.text }]}>取消</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
onPress={handleGoalConfirm}
|
||||||
|
style={[styles.modalBtn, styles.modalBtnPrimary, { backgroundColor: colorTokens.primary }]}
|
||||||
|
>
|
||||||
|
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary, { color: colorTokens.onPrimary }]}>确定</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* 快速添加默认值编辑弹窗 */}
|
||||||
|
<Modal
|
||||||
|
visible={quickAddModalVisible}
|
||||||
|
transparent
|
||||||
|
animationType="fade"
|
||||||
|
onRequestClose={() => setQuickAddModalVisible(false)}
|
||||||
|
>
|
||||||
|
<Pressable style={styles.modalBackdrop} onPress={() => setQuickAddModalVisible(false)} />
|
||||||
|
<View style={styles.modalSheet}>
|
||||||
|
<View style={styles.modalHandle} />
|
||||||
|
<Text style={[styles.modalTitle, { color: colorTokens.text }]}>快速添加默认值</Text>
|
||||||
|
<View style={styles.pickerContainer}>
|
||||||
|
<Picker
|
||||||
|
selectedValue={tempQuickAdd}
|
||||||
|
onValueChange={(value) => setTempQuickAdd(value)}
|
||||||
|
style={styles.picker}
|
||||||
|
>
|
||||||
|
{Array.from({ length: 41 }, (_, i) => 50 + i * 10).map(amount => (
|
||||||
|
<Picker.Item key={amount} label={`${amount}ml`} value={amount} />
|
||||||
|
))}
|
||||||
|
</Picker>
|
||||||
|
</View>
|
||||||
|
<View style={styles.modalActions}>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setQuickAddModalVisible(false)}
|
||||||
|
style={[styles.modalBtn, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}
|
||||||
|
>
|
||||||
|
<Text style={[styles.modalBtnText, { color: colorTokens.text }]}>取消</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
onPress={handleQuickAddConfirm}
|
||||||
|
style={[styles.modalBtn, styles.modalBtnPrimary, { backgroundColor: colorTokens.primary }]}
|
||||||
|
>
|
||||||
|
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary, { color: colorTokens.onPrimary }]}>确定</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
gradientBackground: {
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
},
|
||||||
|
decorativeCircle1: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 40,
|
||||||
|
right: 20,
|
||||||
|
width: 60,
|
||||||
|
height: 60,
|
||||||
|
borderRadius: 30,
|
||||||
|
backgroundColor: '#0EA5E9',
|
||||||
|
opacity: 0.1,
|
||||||
|
},
|
||||||
|
decorativeCircle2: {
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: -15,
|
||||||
|
left: -15,
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 20,
|
||||||
|
backgroundColor: '#0EA5E9',
|
||||||
|
opacity: 0.05,
|
||||||
|
},
|
||||||
|
keyboardAvoidingView: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
scrollView: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
scrollContent: {
|
||||||
|
padding: 20,
|
||||||
|
},
|
||||||
|
section: {
|
||||||
|
marginBottom: 32,
|
||||||
|
},
|
||||||
|
settingsMenuContainer: {
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderRadius: 12,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 1 },
|
||||||
|
shadowOpacity: 0.05,
|
||||||
|
shadowRadius: 4,
|
||||||
|
elevation: 2,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
settingsMenuItem: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingVertical: 16,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: '#F1F3F4',
|
||||||
|
},
|
||||||
|
settingsMenuItemLeft: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
settingsIconContainer: {
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: 6,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginRight: 12,
|
||||||
|
},
|
||||||
|
settingsMenuItemContent: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
settingsMenuItemTitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
settingsMenuItemSubtitle: {
|
||||||
|
fontSize: 13,
|
||||||
|
marginBottom: 6,
|
||||||
|
},
|
||||||
|
settingsMenuItemValue: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
modalBackdrop: {
|
||||||
|
...StyleSheet.absoluteFillObject,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.4)',
|
||||||
|
},
|
||||||
|
modalSheet: {
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
padding: 16,
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderTopLeftRadius: 16,
|
||||||
|
borderTopRightRadius: 16,
|
||||||
|
shadowColor: '#000000',
|
||||||
|
shadowOffset: { width: 0, height: -2 },
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 16,
|
||||||
|
},
|
||||||
|
modalHandle: {
|
||||||
|
width: 36,
|
||||||
|
height: 4,
|
||||||
|
backgroundColor: '#E0E0E0',
|
||||||
|
borderRadius: 2,
|
||||||
|
alignSelf: 'center',
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
modalTitle: {
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: '600',
|
||||||
|
textAlign: 'center',
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
pickerContainer: {
|
||||||
|
height: 200,
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
picker: {
|
||||||
|
height: 200,
|
||||||
|
},
|
||||||
|
modalActions: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
modalBtn: {
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 10,
|
||||||
|
borderRadius: 10,
|
||||||
|
minWidth: 80,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
modalBtnPrimary: {
|
||||||
|
// backgroundColor will be set dynamically
|
||||||
|
},
|
||||||
|
modalBtnText: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
modalBtnTextPrimary: {
|
||||||
|
// color will be set dynamically
|
||||||
|
},
|
||||||
|
// 喝水提醒配置弹窗样式
|
||||||
|
waterReminderModalSheet: {
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
padding: 16,
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderTopLeftRadius: 16,
|
||||||
|
borderTopRightRadius: 16,
|
||||||
|
maxHeight: '80%',
|
||||||
|
shadowColor: '#000000',
|
||||||
|
shadowOffset: { width: 0, height: -2 },
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 16,
|
||||||
|
},
|
||||||
|
waterReminderContent: {
|
||||||
|
flex: 1,
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
waterReminderSection: {
|
||||||
|
marginBottom: 24,
|
||||||
|
},
|
||||||
|
waterReminderSectionHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
waterReminderSectionTitleContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
waterReminderSectionTitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
waterReminderSectionDesc: {
|
||||||
|
fontSize: 14,
|
||||||
|
lineHeight: 20,
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
timeRangeContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 16,
|
||||||
|
marginTop: 16,
|
||||||
|
},
|
||||||
|
timePickerContainer: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
timeLabel: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '500',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
timePicker: {
|
||||||
|
paddingVertical: 12,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
borderRadius: 8,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
timePickerText: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
timePickerIcon: {
|
||||||
|
opacity: 0.6,
|
||||||
|
},
|
||||||
|
intervalContainer: {
|
||||||
|
marginTop: 16,
|
||||||
|
},
|
||||||
|
intervalPickerContainer: {
|
||||||
|
backgroundColor: '#F8F9FA',
|
||||||
|
borderRadius: 8,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
intervalPicker: {
|
||||||
|
height: 120,
|
||||||
|
},
|
||||||
|
// 时间选择器弹窗样式
|
||||||
|
timePickerModalSheet: {
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
padding: 16,
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderTopLeftRadius: 16,
|
||||||
|
borderTopRightRadius: 16,
|
||||||
|
maxHeight: '60%',
|
||||||
|
shadowColor: '#000000',
|
||||||
|
shadowOffset: { width: 0, height: -2 },
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 16,
|
||||||
|
},
|
||||||
|
timePickerContent: {
|
||||||
|
flex: 1,
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
timePickerSection: {
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
timePickerLabel: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
marginBottom: 12,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
hourPickerContainer: {
|
||||||
|
backgroundColor: '#F8F9FA',
|
||||||
|
borderRadius: 8,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
hourPicker: {
|
||||||
|
height: 160,
|
||||||
|
},
|
||||||
|
timeRangePreview: {
|
||||||
|
backgroundColor: '#F0F8FF',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 16,
|
||||||
|
marginTop: 16,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
timeRangePreviewLabel: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '500',
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
timeRangePreviewText: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '600',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
timeRangeWarning: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#FF6B6B',
|
||||||
|
textAlign: 'center',
|
||||||
|
lineHeight: 18,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default WaterSettings;
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import NumberKeyboard from '@/components/NumberKeyboard';
|
import NumberKeyboard from '@/components/NumberKeyboard';
|
||||||
|
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||||
import { WeightRecordCard } from '@/components/weight/WeightRecordCard';
|
import { WeightRecordCard } from '@/components/weight/WeightRecordCard';
|
||||||
import { Colors } from '@/constants/Colors';
|
import { Colors } from '@/constants/Colors';
|
||||||
import { getTabBarBottomPadding } from '@/constants/TabBar';
|
import { getTabBarBottomPadding } from '@/constants/TabBar';
|
||||||
@@ -169,65 +170,64 @@ export default function WeightRecordsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
|
{/* 背景渐变 */}
|
||||||
<LinearGradient
|
<LinearGradient
|
||||||
colors={[themeColors.backgroundGradientStart, themeColors.backgroundGradientEnd]}
|
colors={['#F0F9FF', '#E0F2FE']}
|
||||||
style={styles.gradient}
|
style={styles.gradientBackground}
|
||||||
start={{ x: 0, y: 0 }}
|
start={{ x: 0, y: 0 }}
|
||||||
end={{ x: 0, y: 1 }}
|
end={{ x: 1, y: 1 }}
|
||||||
>
|
/>
|
||||||
{/* Header */}
|
|
||||||
<View style={styles.header}>
|
<HeaderBar
|
||||||
<TouchableOpacity onPress={handleGoBack} style={styles.backButton}>
|
title="体重记录"
|
||||||
<Ionicons name="chevron-back" size={24} color="#192126" />
|
right={<TouchableOpacity onPress={handleAddWeight} style={styles.addButton}>
|
||||||
</TouchableOpacity>
|
<Ionicons name="add" size={24} color="#192126" />
|
||||||
<TouchableOpacity onPress={handleAddWeight} style={styles.addButton}>
|
</TouchableOpacity>}
|
||||||
<Ionicons name="add" size={24} color="#192126" />
|
/>
|
||||||
</TouchableOpacity>
|
{/* Weight Statistics */}
|
||||||
</View>
|
<View style={styles.statsContainer}>
|
||||||
{/* Weight Statistics */}
|
<View style={styles.statsRow}>
|
||||||
<View style={styles.statsContainer}>
|
<View style={styles.statItem}>
|
||||||
<View style={styles.statsRow}>
|
<Text style={styles.statValue}>{totalWeightLoss.toFixed(1)}kg</Text>
|
||||||
<View style={styles.statItem}>
|
<Text style={styles.statLabel}>累计减重</Text>
|
||||||
<Text style={styles.statValue}>{totalWeightLoss.toFixed(1)}kg</Text>
|
</View>
|
||||||
<Text style={styles.statLabel}>累计减重</Text>
|
<View style={styles.statItem}>
|
||||||
|
<Text style={styles.statValue}>{currentWeight.toFixed(1)}kg</Text>
|
||||||
|
<Text style={styles.statLabel}>当前体重</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.statItem}>
|
||||||
|
<Text style={styles.statValue}>{initialWeight.toFixed(1)}kg</Text>
|
||||||
|
<View style={styles.statLabelContainer}>
|
||||||
|
<Text style={styles.statLabel}>初始体重</Text>
|
||||||
|
<TouchableOpacity onPress={handleEditInitialWeight} style={styles.editIcon}>
|
||||||
|
<Ionicons name="create-outline" size={14} color="#FF9500" />
|
||||||
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.statItem}>
|
</View>
|
||||||
<Text style={styles.statValue}>{currentWeight.toFixed(1)}kg</Text>
|
<View style={styles.statItem}>
|
||||||
<Text style={styles.statLabel}>当前体重</Text>
|
<Text style={styles.statValue}>{targetWeight.toFixed(1)}kg</Text>
|
||||||
</View>
|
<View style={styles.statLabelContainer}>
|
||||||
<View style={styles.statItem}>
|
<Text style={styles.statLabel}>目标体重</Text>
|
||||||
<Text style={styles.statValue}>{initialWeight.toFixed(1)}kg</Text>
|
<TouchableOpacity onPress={handleEditTargetWeight} style={styles.editIcon}>
|
||||||
<View style={styles.statLabelContainer}>
|
<Ionicons name="create-outline" size={14} color="#FF9500" />
|
||||||
<Text style={styles.statLabel}>初始体重</Text>
|
</TouchableOpacity>
|
||||||
<TouchableOpacity onPress={handleEditInitialWeight} style={styles.editIcon}>
|
|
||||||
<Ionicons name="create-outline" size={14} color="#FF9500" />
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<View style={styles.statItem}>
|
|
||||||
<Text style={styles.statValue}>{targetWeight.toFixed(1)}kg</Text>
|
|
||||||
<View style={styles.statLabelContainer}>
|
|
||||||
<Text style={styles.statLabel}>目标体重</Text>
|
|
||||||
<TouchableOpacity onPress={handleEditTargetWeight} style={styles.editIcon}>
|
|
||||||
<Ionicons name="create-outline" size={14} color="#FF9500" />
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
<ScrollView
|
<ScrollView
|
||||||
style={styles.content}
|
style={styles.content}
|
||||||
contentContainerStyle={[styles.contentContainer, { paddingBottom: getTabBarBottomPadding() + 20 }]}
|
contentContainerStyle={[styles.contentContainer, { paddingBottom: getTabBarBottomPadding() + 20 }]}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
>
|
>
|
||||||
|
|
||||||
{/* Monthly Records */}
|
{/* Monthly Records */}
|
||||||
{Object.keys(groupedHistory).length > 0 ? (
|
{Object.keys(groupedHistory).length > 0 ? (
|
||||||
Object.entries(groupedHistory).map(([month, records]) => (
|
Object.entries(groupedHistory).map(([month, records]) => (
|
||||||
<View key={month} style={styles.monthContainer}>
|
<View key={month} style={styles.monthContainer}>
|
||||||
{/* Month Header Card */}
|
{/* Month Header Card */}
|
||||||
{/* <View style={styles.monthHeaderCard}>
|
{/* <View style={styles.monthHeaderCard}>
|
||||||
<View style={styles.monthTitleRow}>
|
<View style={styles.monthTitleRow}>
|
||||||
<Text style={styles.monthNumber}>
|
<Text style={styles.monthNumber}>
|
||||||
{dayjs(month, 'YYYY年MM月').format('MM')}
|
{dayjs(month, 'YYYY年MM月').format('MM')}
|
||||||
@@ -245,139 +245,138 @@ export default function WeightRecordsPage() {
|
|||||||
</Text>
|
</Text>
|
||||||
</View> */}
|
</View> */}
|
||||||
|
|
||||||
{/* Individual Record Cards */}
|
{/* Individual Record Cards */}
|
||||||
{records.map((record, recordIndex) => {
|
{records.map((record, recordIndex) => {
|
||||||
// Calculate weight change from previous record
|
// Calculate weight change from previous record
|
||||||
const prevRecord = recordIndex < records.length - 1 ? records[recordIndex + 1] : null;
|
const prevRecord = recordIndex < records.length - 1 ? records[recordIndex + 1] : null;
|
||||||
const weightChange = prevRecord ?
|
const weightChange = prevRecord ?
|
||||||
parseFloat(record.weight) - parseFloat(prevRecord.weight) : 0;
|
parseFloat(record.weight) - parseFloat(prevRecord.weight) : 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<WeightRecordCard
|
<WeightRecordCard
|
||||||
key={`${record.createdAt}-${recordIndex}`}
|
key={`${record.createdAt}-${recordIndex}`}
|
||||||
record={record}
|
record={record}
|
||||||
onPress={handleEditWeightRecord}
|
onPress={handleEditWeightRecord}
|
||||||
onDelete={handleDeleteWeightRecord}
|
onDelete={handleDeleteWeightRecord}
|
||||||
weightChange={weightChange}
|
weightChange={weightChange}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</View>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<View style={styles.emptyContainer}>
|
|
||||||
<View style={styles.emptyContent}>
|
|
||||||
<Text style={styles.emptyText}>暂无体重记录</Text>
|
|
||||||
<Text style={styles.emptySubtext}>点击右上角添加按钮开始记录</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
)}
|
))
|
||||||
</ScrollView>
|
) : (
|
||||||
|
<View style={styles.emptyContainer}>
|
||||||
{/* Weight Input Modal */}
|
<View style={styles.emptyContent}>
|
||||||
<Modal
|
<Text style={styles.emptyText}>暂无体重记录</Text>
|
||||||
visible={showWeightPicker}
|
<Text style={styles.emptySubtext}>点击右上角添加按钮开始记录</Text>
|
||||||
animationType="fade"
|
|
||||||
transparent
|
|
||||||
onRequestClose={() => setShowWeightPicker(false)}
|
|
||||||
>
|
|
||||||
<View style={styles.modalContainer}>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.modalBackdrop}
|
|
||||||
activeOpacity={1}
|
|
||||||
onPress={() => setShowWeightPicker(false)}
|
|
||||||
/>
|
|
||||||
<View style={[styles.modalSheet, { backgroundColor: themeColors.background }]}>
|
|
||||||
{/* Header */}
|
|
||||||
<View style={styles.modalHeader}>
|
|
||||||
<TouchableOpacity onPress={() => setShowWeightPicker(false)}>
|
|
||||||
<Ionicons name="close" size={24} color={themeColors.text} />
|
|
||||||
</TouchableOpacity>
|
|
||||||
<Text style={[styles.modalTitle, { color: themeColors.text }]}>
|
|
||||||
{pickerType === 'current' && '记录体重'}
|
|
||||||
{pickerType === 'initial' && '编辑初始体重'}
|
|
||||||
{pickerType === 'target' && '编辑目标体重'}
|
|
||||||
{pickerType === 'edit' && '编辑体重记录'}
|
|
||||||
</Text>
|
|
||||||
<View style={{ width: 24 }} />
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<ScrollView
|
|
||||||
style={styles.modalContent}
|
|
||||||
showsVerticalScrollIndicator={false}
|
|
||||||
>
|
|
||||||
{/* Weight Display Section */}
|
|
||||||
<View style={styles.inputSection}>
|
|
||||||
<View style={styles.weightInputContainer}>
|
|
||||||
<View style={styles.weightIcon}>
|
|
||||||
<Ionicons name="scale-outline" size={20} color="#6366F1" />
|
|
||||||
</View>
|
|
||||||
<View style={styles.inputWrapper}>
|
|
||||||
<Text style={[styles.weightDisplay, { color: inputWeight ? themeColors.text : themeColors.textSecondary }]}>
|
|
||||||
{inputWeight || '输入体重'}
|
|
||||||
</Text>
|
|
||||||
<Text style={[styles.unitLabel, { color: themeColors.textSecondary }]}>kg</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Weight Range Hint */}
|
|
||||||
<Text style={[styles.hintText, { color: themeColors.textSecondary }]}>
|
|
||||||
请输入 0-500 之间的数值,支持小数
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Quick Selection */}
|
|
||||||
<View style={styles.quickSelectionSection}>
|
|
||||||
<Text style={[styles.quickSelectionTitle, { color: themeColors.text }]}>快速选择</Text>
|
|
||||||
<View style={styles.quickButtons}>
|
|
||||||
{[50, 60, 70, 80, 90].map((weight) => (
|
|
||||||
<TouchableOpacity
|
|
||||||
key={weight}
|
|
||||||
style={[
|
|
||||||
styles.quickButton,
|
|
||||||
inputWeight === weight.toString() && styles.quickButtonSelected
|
|
||||||
]}
|
|
||||||
onPress={() => setInputWeight(weight.toString())}
|
|
||||||
>
|
|
||||||
<Text style={[
|
|
||||||
styles.quickButtonText,
|
|
||||||
inputWeight === weight.toString() && styles.quickButtonTextSelected
|
|
||||||
]}>
|
|
||||||
{weight}kg
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
|
|
||||||
{/* Custom Number Keyboard */}
|
|
||||||
<NumberKeyboard
|
|
||||||
onNumberPress={handleNumberPress}
|
|
||||||
onDeletePress={handleDeletePress}
|
|
||||||
onDecimalPress={handleDecimalPress}
|
|
||||||
hasDecimal={inputWeight.includes('.')}
|
|
||||||
maxLength={6}
|
|
||||||
currentValue={inputWeight}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Save Button */}
|
|
||||||
<View style={styles.modalFooter}>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={[
|
|
||||||
styles.saveButton,
|
|
||||||
{ opacity: !inputWeight.trim() ? 0.5 : 1 }
|
|
||||||
]}
|
|
||||||
onPress={handleWeightSave}
|
|
||||||
disabled={!inputWeight.trim()}
|
|
||||||
>
|
|
||||||
<Text style={styles.saveButtonText}>确定</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</Modal>
|
)}
|
||||||
</LinearGradient>
|
</ScrollView>
|
||||||
|
|
||||||
|
{/* Weight Input Modal */}
|
||||||
|
<Modal
|
||||||
|
visible={showWeightPicker}
|
||||||
|
animationType="fade"
|
||||||
|
transparent
|
||||||
|
onRequestClose={() => setShowWeightPicker(false)}
|
||||||
|
>
|
||||||
|
<View style={styles.modalContainer}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.modalBackdrop}
|
||||||
|
activeOpacity={1}
|
||||||
|
onPress={() => setShowWeightPicker(false)}
|
||||||
|
/>
|
||||||
|
<View style={[styles.modalSheet, { backgroundColor: themeColors.background }]}>
|
||||||
|
{/* Header */}
|
||||||
|
<View style={styles.modalHeader}>
|
||||||
|
<TouchableOpacity onPress={() => setShowWeightPicker(false)}>
|
||||||
|
<Ionicons name="close" size={24} color={themeColors.text} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text style={[styles.modalTitle, { color: themeColors.text }]}>
|
||||||
|
{pickerType === 'current' && '记录体重'}
|
||||||
|
{pickerType === 'initial' && '编辑初始体重'}
|
||||||
|
{pickerType === 'target' && '编辑目标体重'}
|
||||||
|
{pickerType === 'edit' && '编辑体重记录'}
|
||||||
|
</Text>
|
||||||
|
<View style={{ width: 24 }} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
style={styles.modalContent}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
{/* Weight Display Section */}
|
||||||
|
<View style={styles.inputSection}>
|
||||||
|
<View style={styles.weightInputContainer}>
|
||||||
|
<View style={styles.weightIcon}>
|
||||||
|
<Ionicons name="scale-outline" size={20} color="#6366F1" />
|
||||||
|
</View>
|
||||||
|
<View style={styles.inputWrapper}>
|
||||||
|
<Text style={[styles.weightDisplay, { color: inputWeight ? themeColors.text : themeColors.textSecondary }]}>
|
||||||
|
{inputWeight || '输入体重'}
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.unitLabel, { color: themeColors.textSecondary }]}>kg</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Weight Range Hint */}
|
||||||
|
<Text style={[styles.hintText, { color: themeColors.textSecondary }]}>
|
||||||
|
请输入 0-500 之间的数值,支持小数
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Quick Selection */}
|
||||||
|
<View style={styles.quickSelectionSection}>
|
||||||
|
<Text style={[styles.quickSelectionTitle, { color: themeColors.text }]}>快速选择</Text>
|
||||||
|
<View style={styles.quickButtons}>
|
||||||
|
{[50, 60, 70, 80, 90].map((weight) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={weight}
|
||||||
|
style={[
|
||||||
|
styles.quickButton,
|
||||||
|
inputWeight === weight.toString() && styles.quickButtonSelected
|
||||||
|
]}
|
||||||
|
onPress={() => setInputWeight(weight.toString())}
|
||||||
|
>
|
||||||
|
<Text style={[
|
||||||
|
styles.quickButtonText,
|
||||||
|
inputWeight === weight.toString() && styles.quickButtonTextSelected
|
||||||
|
]}>
|
||||||
|
{weight}kg
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
{/* Custom Number Keyboard */}
|
||||||
|
<NumberKeyboard
|
||||||
|
onNumberPress={handleNumberPress}
|
||||||
|
onDeletePress={handleDeletePress}
|
||||||
|
onDecimalPress={handleDecimalPress}
|
||||||
|
hasDecimal={inputWeight.includes('.')}
|
||||||
|
maxLength={6}
|
||||||
|
currentValue={inputWeight}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Save Button */}
|
||||||
|
<View style={styles.modalFooter}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.saveButton,
|
||||||
|
{ opacity: !inputWeight.trim() ? 0.5 : 1 }
|
||||||
|
]}
|
||||||
|
onPress={handleWeightSave}
|
||||||
|
disabled={!inputWeight.trim()}
|
||||||
|
>
|
||||||
|
<Text style={styles.saveButtonText}>确定</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -386,8 +385,12 @@ const styles = StyleSheet.create({
|
|||||||
container: {
|
container: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
gradient: {
|
gradientBackground: {
|
||||||
flex: 1,
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
},
|
},
|
||||||
header: {
|
header: {
|
||||||
flexDirection: 'row',
|
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;
|
return weeks;
|
||||||
}, [generateActivityData, weeksToShow]);
|
}, [generateActivityData, weeksToShow]);
|
||||||
|
|
||||||
console.log('organizeDataByWeeks', organizeDataByWeeks);
|
|
||||||
|
|
||||||
|
|
||||||
// 获取月份标签(简化的月份标签系统)
|
// 获取月份标签(简化的月份标签系统)
|
||||||
const getMonthLabels = useMemo(() => {
|
const getMonthLabels = useMemo(() => {
|
||||||
@@ -186,23 +184,23 @@ const ActivityHeatMap = () => {
|
|||||||
>
|
>
|
||||||
<View style={[styles.popoverContent, { backgroundColor: colors.card }]}>
|
<View style={[styles.popoverContent, { backgroundColor: colors.card }]}>
|
||||||
<Text style={[styles.popoverTitle, { color: colors.text }]}>
|
<Text style={[styles.popoverTitle, { color: colors.text }]}>
|
||||||
小鱼干可以用来与小海豹进行对话
|
能量值的积攒后续可以用来兑换 AI 相关权益
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={[styles.popoverSubtitle, { color: colors.text }]}>
|
<Text style={[styles.popoverSubtitle, { color: colors.text }]}>
|
||||||
获取说明
|
获取说明
|
||||||
</Text>
|
</Text>
|
||||||
<View style={styles.popoverList}>
|
<View style={styles.popoverList}>
|
||||||
<Text style={[styles.popoverItem, { color: colors.textMuted }]}>
|
<Text style={[styles.popoverItem, { color: colors.textMuted }]}>
|
||||||
1. 每日登录获得小鱼干+1
|
1. 每日登录获得能量值+1
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={[styles.popoverItem, { color: colors.textMuted }]}>
|
<Text style={[styles.popoverItem, { color: colors.textMuted }]}>
|
||||||
2. 每日记录心情获得小鱼干+1
|
2. 每日记录心情获得能量值+1
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={[styles.popoverItem, { color: colors.textMuted }]}>
|
<Text style={[styles.popoverItem, { color: colors.textMuted }]}>
|
||||||
3. 记饮食获得小鱼干+1
|
3. 记饮食获得能量值+1
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={[styles.popoverItem, { color: colors.textMuted }]}>
|
<Text style={[styles.popoverItem, { color: colors.textMuted }]}>
|
||||||
4. 完成一次目标获得小鱼干+1
|
4. 完成一次目标获得能量值+1
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,62 +1,184 @@
|
|||||||
import { AnimatedNumber } from '@/components/AnimatedNumber';
|
import { ROUTES } from '@/constants/Routes';
|
||||||
import React from 'react';
|
import { useAppSelector } from '@/hooks/redux';
|
||||||
import { StyleSheet, Text, View } from 'react-native';
|
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 {
|
interface BasalMetabolismCardProps {
|
||||||
value: number | null;
|
selectedDate?: Date;
|
||||||
resetToken?: number;
|
|
||||||
style?: any;
|
style?: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BasalMetabolismCard({ value, resetToken, style }: BasalMetabolismCardProps) {
|
export function BasalMetabolismCard({ selectedDate, style }: BasalMetabolismCardProps) {
|
||||||
// 获取基础代谢状态描述
|
const [basalMetabolism, setBasalMetabolism] = useState<number | null>(null);
|
||||||
const getMetabolismStatus = () => {
|
const [loading, setLoading] = useState(false);
|
||||||
if (value === null || value === 0) {
|
|
||||||
|
// 获取用户基本信息
|
||||||
|
const userProfile = useAppSelector(selectUserProfile);
|
||||||
|
const userAge = useAppSelector(selectUserAge);
|
||||||
|
|
||||||
|
// 缓存和防抖相关
|
||||||
|
const cacheRef = useRef<Map<string, { data: number | null; timestamp: number }>>(new Map());
|
||||||
|
const loadingRef = useRef<Map<string, Promise<number | null>>>(new Map());
|
||||||
|
const CACHE_DURATION = 5 * 60 * 1000; // 5分钟缓存
|
||||||
|
|
||||||
|
// 使用 useMemo 缓存 BMR 计算,避免每次渲染重复计算
|
||||||
|
const bmrRange = useMemo(() => {
|
||||||
|
const { gender, weight, height } = userProfile;
|
||||||
|
|
||||||
|
// 检查是否有足够的信息来计算BMR
|
||||||
|
if (!gender || !weight || !height || !userAge) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将体重和身高转换为数字
|
||||||
|
const weightNum = parseFloat(weight);
|
||||||
|
const heightNum = parseFloat(height);
|
||||||
|
|
||||||
|
if (isNaN(weightNum) || isNaN(heightNum) || weightNum <= 0 || heightNum <= 0 || userAge <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用Mifflin-St Jeor公式计算BMR
|
||||||
|
let bmr: number;
|
||||||
|
if (gender === 'male') {
|
||||||
|
bmr = 10 * weightNum + 6.25 * heightNum - 5 * userAge + 5;
|
||||||
|
} else {
|
||||||
|
bmr = 10 * weightNum + 6.25 * heightNum - 5 * userAge - 161;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算正常范围(±15%)
|
||||||
|
const minBMR = Math.round(bmr * 0.85);
|
||||||
|
const maxBMR = Math.round(bmr * 1.15);
|
||||||
|
|
||||||
|
return { min: minBMR, max: maxBMR, base: Math.round(bmr) };
|
||||||
|
}, [userProfile.gender, userProfile.weight, userProfile.height, userAge]);
|
||||||
|
|
||||||
|
// 优化的数据获取函数,包含缓存和去重复请求
|
||||||
|
const fetchBasalMetabolismData = useCallback(async (date: Date): Promise<number | null> => {
|
||||||
|
const dateKey = dayjs(date).format('YYYY-MM-DD');
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// 检查缓存
|
||||||
|
const cached = cacheRef.current.get(dateKey);
|
||||||
|
if (cached && (now - cached.timestamp) < CACHE_DURATION) {
|
||||||
|
return cached.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否已经在请求中(防止重复请求)
|
||||||
|
const existingRequest = loadingRef.current.get(dateKey);
|
||||||
|
if (existingRequest) {
|
||||||
|
return existingRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建新的请求
|
||||||
|
const request = (async () => {
|
||||||
|
try {
|
||||||
|
const options = {
|
||||||
|
startDate: dayjs(date).startOf('day').toDate().toISOString(),
|
||||||
|
endDate: dayjs(date).endOf('day').toDate().toISOString()
|
||||||
|
};
|
||||||
|
const basalEnergy = await fetchBasalEnergyBurned(options);
|
||||||
|
const result = basalEnergy || null;
|
||||||
|
|
||||||
|
// 更新缓存
|
||||||
|
cacheRef.current.set(dateKey, { data: result, timestamp: now });
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('BasalMetabolismCard: 获取基础代谢数据失败:', error);
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
// 清理请求记录
|
||||||
|
loadingRef.current.delete(dateKey);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
// 记录请求
|
||||||
|
loadingRef.current.set(dateKey, request);
|
||||||
|
|
||||||
|
return request;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 获取基础代谢数据
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedDate) return;
|
||||||
|
|
||||||
|
let isCancelled = false;
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await fetchBasalMetabolismData(selectedDate);
|
||||||
|
if (!isCancelled) {
|
||||||
|
setBasalMetabolism(result);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!isCancelled) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadData();
|
||||||
|
|
||||||
|
// 清理函数,防止组件卸载后的状态更新
|
||||||
|
return () => {
|
||||||
|
isCancelled = true;
|
||||||
|
};
|
||||||
|
}, [selectedDate, fetchBasalMetabolismData]);
|
||||||
|
// 使用 useMemo 优化状态描述计算
|
||||||
|
const status = useMemo(() => {
|
||||||
|
if (basalMetabolism === null || basalMetabolism === 0) {
|
||||||
return { text: '未知', color: '#9AA3AE' };
|
return { text: '未知', color: '#9AA3AE' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// 基于常见的基础代谢范围来判断状态
|
// 基于常见的基础代谢范围来判断状态
|
||||||
if (value >= 1800) {
|
if (basalMetabolism >= 1800) {
|
||||||
return { text: '高代谢', color: '#10B981' };
|
return { text: '高代谢', color: '#10B981' };
|
||||||
} else if (value >= 1400) {
|
} else if (basalMetabolism >= 1400) {
|
||||||
return { text: '正常', color: '#3B82F6' };
|
return { text: '正常', color: '#3B82F6' };
|
||||||
} else if (value >= 1000) {
|
} else if (basalMetabolism >= 1000) {
|
||||||
return { text: '偏低', color: '#F59E0B' };
|
return { text: '偏低', color: '#F59E0B' };
|
||||||
} else {
|
} else {
|
||||||
return { text: '较低', color: '#EF4444' };
|
return { text: '较低', color: '#EF4444' };
|
||||||
}
|
}
|
||||||
};
|
}, [basalMetabolism]);
|
||||||
|
|
||||||
const status = getMetabolismStatus();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.container, style]}>
|
<>
|
||||||
|
<TouchableOpacity
|
||||||
{/* 头部区域 */}
|
style={[styles.container, style]}
|
||||||
<View style={styles.header}>
|
onPress={() => router.push(ROUTES.BASAL_METABOLISM_DETAIL)}
|
||||||
<View style={styles.leftSection}>
|
activeOpacity={0.8}
|
||||||
<Text style={styles.title}>基础代谢</Text>
|
>
|
||||||
|
{/* 头部区域 */}
|
||||||
|
<View style={styles.header}>
|
||||||
|
<View style={styles.leftSection}>
|
||||||
|
<Image
|
||||||
|
source={require('@/assets/images/icons/icon-fire.png')}
|
||||||
|
style={styles.titleIcon}
|
||||||
|
/>
|
||||||
|
<Text style={styles.title}>基础代谢</Text>
|
||||||
|
</View>
|
||||||
|
<View style={[styles.statusBadge, { backgroundColor: status.color + '20' }]}>
|
||||||
|
<Text style={[styles.statusText, { color: status.color }]}>{status.text}</Text>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<View style={[styles.statusBadge, { backgroundColor: status.color + '20' }]}>
|
|
||||||
<Text style={[styles.statusText, { color: status.color }]}>{status.text}</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 数值显示区域 */}
|
{/* 数值显示区域 */}
|
||||||
<View style={styles.valueSection}>
|
<View style={styles.valueSection}>
|
||||||
{value != null && value > 0 ? (
|
<Text style={styles.value}>
|
||||||
<AnimatedNumber
|
{loading ? '加载中...' : (basalMetabolism != null && basalMetabolism > 0 ? Math.round(basalMetabolism).toString() : '--')}
|
||||||
value={value}
|
</Text>
|
||||||
resetToken={resetToken}
|
<Text style={styles.unit}>千卡/日</Text>
|
||||||
style={styles.value}
|
</View>
|
||||||
format={(v) => Math.round(v).toString()}
|
</TouchableOpacity>
|
||||||
/>
|
</>
|
||||||
) : (
|
|
||||||
<Text style={styles.value}>--</Text>
|
|
||||||
)}
|
|
||||||
<Text style={styles.unit}>千卡/日</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,6 +239,12 @@ const styles = StyleSheet.create({
|
|||||||
title: {
|
title: {
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
color: '#0F172A',
|
color: '#0F172A',
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
titleIcon: {
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
marginRight: 4,
|
||||||
},
|
},
|
||||||
statusBadge: {
|
statusBadge: {
|
||||||
paddingHorizontal: 8,
|
paddingHorizontal: 8,
|
||||||
|
|||||||
@@ -1,20 +1,19 @@
|
|||||||
import React from 'react';
|
|
||||||
import { StyleSheet, Text, View, TouchableOpacity } from 'react-native';
|
|
||||||
import { router } from 'expo-router';
|
|
||||||
import { CircularRing } from './CircularRing';
|
|
||||||
import { ROUTES } from '@/constants/Routes';
|
import { ROUTES } from '@/constants/Routes';
|
||||||
|
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||||
|
import { ChallengeType } from '@/services/challengesApi';
|
||||||
|
import { reportChallengeProgress, selectChallengeList } from '@/store/challengesSlice';
|
||||||
|
import { ActivityRingsData, fetchActivityRingsForDate } from '@/utils/health';
|
||||||
|
import { logger } from '@/utils/logger';
|
||||||
|
import { useFocusEffect } from '@react-navigation/native';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { router } from 'expo-router';
|
||||||
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||||
|
import { CircularRing } from './CircularRing';
|
||||||
|
|
||||||
type FitnessRingsCardProps = {
|
type FitnessRingsCardProps = {
|
||||||
style?: any;
|
style?: any;
|
||||||
// 活动卡路里数据
|
selectedDate?: Date;
|
||||||
activeCalories?: number;
|
|
||||||
activeCaloriesGoal?: number;
|
|
||||||
// 锻炼分钟数据
|
|
||||||
exerciseMinutes?: number;
|
|
||||||
exerciseMinutesGoal?: number;
|
|
||||||
// 站立小时数据
|
|
||||||
standHours?: number;
|
|
||||||
standHoursGoal?: number;
|
|
||||||
// 动画重置令牌
|
// 动画重置令牌
|
||||||
resetToken?: unknown;
|
resetToken?: unknown;
|
||||||
};
|
};
|
||||||
@@ -24,14 +23,113 @@ type FitnessRingsCardProps = {
|
|||||||
*/
|
*/
|
||||||
export function FitnessRingsCard({
|
export function FitnessRingsCard({
|
||||||
style,
|
style,
|
||||||
activeCalories = 25,
|
selectedDate,
|
||||||
activeCaloriesGoal = 350,
|
|
||||||
exerciseMinutes = 1,
|
|
||||||
exerciseMinutesGoal = 5,
|
|
||||||
standHours = 2,
|
|
||||||
standHoursGoal = 13,
|
|
||||||
resetToken,
|
resetToken,
|
||||||
}: FitnessRingsCardProps) {
|
}: FitnessRingsCardProps) {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const challenges = useAppSelector(selectChallengeList);
|
||||||
|
const [activityData, setActivityData] = useState<ActivityRingsData | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const loadingRef = useRef(false);
|
||||||
|
const lastReportedRef = useRef<{ date: string } | null>(null);
|
||||||
|
|
||||||
|
const joinedExerciseChallenges = useMemo(
|
||||||
|
() => challenges.filter((challenge) => challenge.type === ChallengeType.EXERCISE && challenge.isJoined && challenge.status === 'ongoing'),
|
||||||
|
[challenges]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 获取健身圆环数据 - 在页面聚焦、日期变化、从后台切换到前台时触发
|
||||||
|
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])
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedDate || !activityData || !joinedExerciseChallenges.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!dayjs(selectedDate).isSame(dayjs(), 'day')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
activeEnergyBurned,
|
||||||
|
activeEnergyBurnedGoal,
|
||||||
|
appleExerciseTime,
|
||||||
|
appleExerciseTimeGoal,
|
||||||
|
appleStandHours,
|
||||||
|
appleStandHoursGoal,
|
||||||
|
} = activityData;
|
||||||
|
|
||||||
|
if (
|
||||||
|
activeEnergyBurnedGoal <= 0 ||
|
||||||
|
appleExerciseTimeGoal <= 0 ||
|
||||||
|
appleStandHoursGoal <= 0
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allRingsClosed =
|
||||||
|
activeEnergyBurned >= activeEnergyBurnedGoal &&
|
||||||
|
appleExerciseTime >= appleExerciseTimeGoal &&
|
||||||
|
appleStandHours >= appleStandHoursGoal;
|
||||||
|
|
||||||
|
if (!allRingsClosed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateKey = dayjs(selectedDate).format('YYYY-MM-DD');
|
||||||
|
if (lastReportedRef.current?.date === dateKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const exerciseChallenge = joinedExerciseChallenges[0];
|
||||||
|
if (!exerciseChallenge) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reportProgressAsync = async () => {
|
||||||
|
try {
|
||||||
|
await dispatch(reportChallengeProgress({ id: exerciseChallenge.id, value: 1 })).unwrap();
|
||||||
|
lastReportedRef.current = { date: dateKey };
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('FitnessRingsCard: 挑战进度上报失败', { error, challengeId: exerciseChallenge.id });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
reportProgressAsync();
|
||||||
|
}, [activityData, dispatch, joinedExerciseChallenges, selectedDate]);
|
||||||
|
|
||||||
|
// 使用获取到的数据或默认值
|
||||||
|
const activeCalories = activityData?.activeEnergyBurned ?? 0;
|
||||||
|
const activeCaloriesGoal = activityData?.activeEnergyBurnedGoal ?? 350;
|
||||||
|
const exerciseMinutes = activityData?.appleExerciseTime ?? 0;
|
||||||
|
const exerciseMinutesGoal = activityData?.appleExerciseTimeGoal ?? 30;
|
||||||
|
const standHours = activityData?.appleStandHours ?? 0;
|
||||||
|
const standHoursGoal = activityData?.appleStandHoursGoal ?? 12;
|
||||||
|
|
||||||
// 计算进度百分比
|
// 计算进度百分比
|
||||||
const caloriesProgress = Math.min(1, Math.max(0, activeCalories / activeCaloriesGoal));
|
const caloriesProgress = Math.min(1, Math.max(0, activeCalories / activeCaloriesGoal));
|
||||||
const exerciseProgress = Math.min(1, Math.max(0, exerciseMinutes / exerciseMinutesGoal));
|
const exerciseProgress = Math.min(1, Math.max(0, exerciseMinutes / exerciseMinutesGoal));
|
||||||
@@ -95,24 +193,42 @@ export function FitnessRingsCard({
|
|||||||
<View style={styles.dataContainer}>
|
<View style={styles.dataContainer}>
|
||||||
<View style={styles.dataRow}>
|
<View style={styles.dataRow}>
|
||||||
<Text style={styles.dataText}>
|
<Text style={styles.dataText}>
|
||||||
<Text style={styles.dataValue}>{Math.round(activeCalories)}</Text>
|
{loading ? (
|
||||||
<Text style={styles.dataGoal}>/{activeCaloriesGoal}</Text>
|
<Text style={styles.dataValue}>--</Text>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Text style={styles.dataValue}>{Math.round(activeCalories)}</Text>
|
||||||
|
<Text style={styles.dataGoal}>/{activeCaloriesGoal}</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={styles.dataUnit}>千卡</Text>
|
<Text style={styles.dataUnit}>千卡</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.dataRow}>
|
<View style={styles.dataRow}>
|
||||||
<Text style={styles.dataText}>
|
<Text style={styles.dataText}>
|
||||||
<Text style={styles.dataValue}>{Math.round(exerciseMinutes)}</Text>
|
{loading ? (
|
||||||
<Text style={styles.dataGoal}>/{exerciseMinutesGoal}</Text>
|
<Text style={styles.dataValue}>--</Text>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Text style={styles.dataValue}>{Math.round(exerciseMinutes)}</Text>
|
||||||
|
<Text style={styles.dataGoal}>/{exerciseMinutesGoal}</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={styles.dataUnit}>分钟</Text>
|
<Text style={styles.dataUnit}>分钟</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.dataRow}>
|
<View style={styles.dataRow}>
|
||||||
<Text style={styles.dataText}>
|
<Text style={styles.dataText}>
|
||||||
<Text style={styles.dataValue}>{Math.round(standHours)}</Text>
|
{loading ? (
|
||||||
<Text style={styles.dataGoal}>/{standHoursGoal}</Text>
|
<Text style={styles.dataValue}>--</Text>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Text style={styles.dataValue}>{Math.round(standHours)}</Text>
|
||||||
|
<Text style={styles.dataGoal}>/{standHoursGoal}</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={styles.dataUnit}>小时</Text>
|
<Text style={styles.dataUnit}>小时</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -183,4 +299,4 @@ const styles = StyleSheet.create({
|
|||||||
minWidth: 25,
|
minWidth: 25,
|
||||||
textAlign: 'right',
|
textAlign: 'right',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||