Compare commits
194 Commits
2fac3f899c
...
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 | |||
|
|
a7f5379d5a | ||
|
|
6daf9500fc | ||
|
|
e56ebe3636 | ||
|
|
cacfde064f | ||
|
|
9ccd15319e | ||
|
|
1de4b9fe4c | ||
|
|
bf3304eb06 | ||
|
|
f9a175d76c | ||
|
|
e91283fe4e | ||
| df7f04808e | |||
| aaa34a7a07 | |||
| 2e7daae519 | |||
| 2df747109c | |||
| 8d6a848918 | |||
| c37c3a16b1 | |||
| e6708e68c2 | |||
| 3c416545db | |||
|
|
aee291bb69 | ||
|
|
6af86800f2 | ||
|
|
8d71d751d6 | ||
|
|
83805a4b07 | ||
|
|
460a7e4289 | ||
|
|
acb3907344 | ||
|
|
cb89ee7bc2 | ||
|
|
6c21c4b448 | ||
|
|
a4a0e07227 | ||
|
|
05a643a9e6 | ||
|
|
5e00cb7788 | ||
|
|
4ae419754a | ||
|
|
6cb0435b30 | ||
|
|
0b75087855 | ||
|
|
02883869fe | ||
|
|
45f8415a38 | ||
|
|
8b9689b269 | ||
|
|
16b4fc8816 | ||
|
|
951c02f644 | ||
|
|
8b6ef378d0 | ||
|
|
e33a690a36 | ||
|
|
a70cb1e407 | ||
|
|
70e3152158 | ||
|
|
ccbc3417bc | ||
|
|
ac748dc339 | ||
|
|
85a3c742df | ||
|
|
ed694f6142 | ||
|
|
73ca11e68f | ||
|
|
a34ca556e8 | ||
|
|
fe634ba258 | ||
| 4bb0576d92 | |||
| 6bdfda9fd3 | |||
|
|
f4dd40ed46 | ||
|
|
741688065d | ||
| 465d5350f3 | |||
| 3fdd2acaf2 | |||
|
|
e9b593a07e | ||
|
|
93db9e2928 | ||
|
|
f38f495008 | ||
|
|
8d567fb4cb | ||
|
|
c15a9176f4 | ||
|
|
6551757ca8 | ||
|
|
5a59508b88 | ||
|
|
ba2d829e02 | ||
|
|
aaa462d476 | ||
|
|
9bb924202f | ||
|
|
37d33b28e5 | ||
|
|
a6dbe7c723 | ||
|
|
5e3203f1ce | ||
|
|
533b40a12d | ||
| 0a8b20f0ec | |||
|
|
0610f287ee | ||
|
|
3f89023447 | ||
|
|
7f2afdf671 | ||
|
|
e6bbda9d0f | ||
|
|
91b7b0cb99 | ||
|
|
be0a8e7393 | ||
|
|
ee84a801fb | ||
|
|
4f2d47c23f | ||
| 23aa15f76e | |||
| 4f2bd76b8f | |||
| 20a244e375 | |||
| 4382fb804f | |||
| 8a7599f630 | |||
| b807e498ed | |||
| 75806df660 | |||
| 7d28b79d86 | |||
| c12329bc96 | |||
| 9e719a9eda | |||
|
|
259f10540e | ||
|
|
231620d778 | ||
|
|
136c800084 | ||
| 22142d587d | |||
| f10b7a0fb5 | |||
|
|
098c65b23e | ||
|
|
72e75b602e | ||
|
|
a7607e0f74 | ||
|
|
b93a863e25 | ||
|
|
b396a7d101 | ||
|
|
78620f18ee | ||
|
|
19b92547e1 | ||
|
|
3d47073d2f | ||
|
|
1c44c3083b | ||
|
|
d76ba48424 | ||
| 37f8c3c78d | |||
| 7d7d233bbb | |||
|
|
63b1c52909 | ||
|
|
35cd320ea7 | ||
|
|
260546ff46 | ||
|
|
df2afeb5a1 | ||
|
|
9aa0a692a8 | ||
|
|
c7d7255312 | ||
|
|
d52981ab29 | ||
|
|
05a00236bc | ||
|
|
f8730a90e9 | ||
|
|
27267c2f7f | ||
|
|
849447c5da | ||
|
|
93918366a9 | ||
| 6a67fb21f7 | |||
| b2c4f76c39 | |||
| 4c6a0e0399 | |||
| 5a4d86ff7d | |||
| 3312250f2d | |||
| 97e89b9bf0 | |||
|
|
6b6c4fdbad | ||
|
|
dacbee197c | ||
|
|
f95401c1ce | ||
| 807e185761 | |||
|
|
56d4c7fd7f | ||
|
|
5d09cc05dc | ||
|
|
532cf251e2 | ||
|
|
e3e2f1b8c6 | ||
|
|
7ad26590e5 | ||
|
|
ebc74eb1c8 | ||
|
|
321947db98 | ||
|
|
5814044cee | ||
|
|
f3e6250505 | ||
| e0e000b64f | |||
| 5f05abc3d5 | |||
| 00ddec25c5 | |||
|
|
c3d4630801 | ||
|
|
8ffebfb297 |
3
.vscode/settings.json
vendored
@@ -3,5 +3,6 @@
|
|||||||
"source.fixAll": "explicit",
|
"source.fixAll": "explicit",
|
||||||
"source.organizeImports": "explicit",
|
"source.organizeImports": "explicit",
|
||||||
"source.sortMembers": "explicit"
|
"source.sortMembers": "explicit"
|
||||||
}
|
},
|
||||||
|
"kiroAgent.configureMCP": "Enabled"
|
||||||
}
|
}
|
||||||
|
|||||||
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.
|
||||||
47
CLAUDE.md
@@ -3,17 +3,42 @@
|
|||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
- **Start development server**: `npm start`
|
|
||||||
- **Run on Android**: `npm run android`
|
|
||||||
- **Run on iOS**: `npm run ios`
|
- **Run on iOS**: `npm run ios`
|
||||||
- **Run on Web**: `npm run web`
|
|
||||||
- **Lint**: `npm run lint`
|
|
||||||
- **Reset project**: `npm run reset-project`
|
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
- **Framework**: React Native (Expo) with TypeScript.
|
- **Framework**: React Native (Expo Prebuild/Ejected) with TypeScript using Expo Router for file-based navigation
|
||||||
- **Navigation**: Expo Router for file-based routing (`app/` directory).
|
- **State Management**: Redux Toolkit with domain-specific slices (`store/`) and typed hooks (`hooks/redux.ts`)
|
||||||
- **UI**: Themed components (`ThemedText`, `ThemedView`) and reusable UI elements (`Collapsible`, `ParallaxScrollView`).
|
- **Authentication**: Custom auth guard system with `useAuthGuard` hook for protected navigation
|
||||||
- **Platform-Specific**: Android (`android/`) and iOS (`ios/`) configurations with native modules.
|
- **Navigation**:
|
||||||
- **Hooks**: Custom hooks for color scheme (`useColorScheme`) and theme management (`useThemeColor`).
|
- File-based routing in `app/` directory with nested layouts
|
||||||
- **Dependencies**: React Navigation for tab-based navigation, Expo modules for native features (haptics, blur, etc.).
|
- Tab-based navigation with custom styling and haptic feedback
|
||||||
|
- Route constants defined in `constants/Routes.ts`, every page should use Routes define and jump
|
||||||
|
- **UI System**:
|
||||||
|
- Themed components (`ThemedText`, `ThemedView`) with color scheme support
|
||||||
|
- Custom icon system with `IconSymbol` component for iOS symbols
|
||||||
|
- Reusable UI components in `components/ui/`
|
||||||
|
- UI Colors in `constants/Colors.ts`
|
||||||
|
- **Data Layer**:
|
||||||
|
- API services in `services/` directory with centralized API client
|
||||||
|
- AsyncStorage for local persistence
|
||||||
|
- Background task management for sync operations
|
||||||
|
- **Native Integration**:
|
||||||
|
- Health data integration with HealthKit
|
||||||
|
- Apple Authentication
|
||||||
|
- Camera and photo library access for posture assessment
|
||||||
|
- Push notifications with background task support
|
||||||
|
- Haptic feedback integration
|
||||||
|
|
||||||
|
## Key Architecture Patterns
|
||||||
|
- **Redux Auto-sync**: Listener middleware automatically syncs checkin data changes to backend
|
||||||
|
- **Type-safe Navigation**: Uses Expo Router with TypeScript for route type safety
|
||||||
|
- **Authentication Flow**: `pushIfAuthedElseLogin` function handles auth-protected navigation
|
||||||
|
- **Theme System**: Dynamic theming with light/dark mode support and color tokens
|
||||||
|
- **Service Layer**: Centralized API client with interceptors and error handling
|
||||||
|
|
||||||
|
## Development Conventions
|
||||||
|
- Use absolute imports with `@/` prefix for all internal imports
|
||||||
|
- Follow existing Redux slice patterns for state management
|
||||||
|
- Implement auth guards using `useAuthGuard` hook for protected features
|
||||||
|
- Use themed components for consistent styling
|
||||||
|
- Follow established navigation patterns with typed routes
|
||||||
@@ -64,9 +64,9 @@ react {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set this to true to Run Proguard on Release builds to minify the Java bytecode.
|
* 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 enableProguardInReleaseBuilds = (findProperty('android.enableProguardInReleaseBuilds') ?: false).toBoolean()
|
def enableMinifyInReleaseBuilds = (findProperty('android.enableMinifyInReleaseBuilds') ?: false).toBoolean()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The preferred build flavor of JavaScriptCore (JSC)
|
* The preferred build flavor of JavaScriptCore (JSC)
|
||||||
@@ -93,7 +93,9 @@ android {
|
|||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 1
|
versionCode 1
|
||||||
versionName "1.0.0"
|
versionName "1.0.12"
|
||||||
|
|
||||||
|
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
|
||||||
}
|
}
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
debug {
|
debug {
|
||||||
@@ -111,15 +113,18 @@ android {
|
|||||||
// Caution! In production, you need to generate your own keystore file.
|
// Caution! In production, you need to generate your own keystore file.
|
||||||
// see https://reactnative.dev/docs/signed-apk-android.
|
// see https://reactnative.dev/docs/signed-apk-android.
|
||||||
signingConfig signingConfigs.debug
|
signingConfig signingConfigs.debug
|
||||||
shrinkResources (findProperty('android.enableShrinkResourcesInReleaseBuilds')?.toBoolean() ?: false)
|
def enableShrinkResources = findProperty('android.enableShrinkResourcesInReleaseBuilds') ?: 'false'
|
||||||
minifyEnabled enableProguardInReleaseBuilds
|
shrinkResources enableShrinkResources.toBoolean()
|
||||||
|
minifyEnabled enableMinifyInReleaseBuilds
|
||||||
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
|
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
|
||||||
crunchPngs (findProperty('android.enablePngCrunchInReleaseBuilds')?.toBoolean() ?: true)
|
def enablePngCrunchInRelease = findProperty('android.enablePngCrunchInReleaseBuilds') ?: 'true'
|
||||||
|
crunchPngs enablePngCrunchInRelease.toBoolean()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
packagingOptions {
|
packagingOptions {
|
||||||
jniLibs {
|
jniLibs {
|
||||||
useLegacyPackaging (findProperty('expo.useLegacyPackaging')?.toBoolean() ?: false)
|
def enableLegacyPackaging = findProperty('expo.useLegacyPackaging') ?: 'false'
|
||||||
|
useLegacyPackaging enableLegacyPackaging.toBoolean()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
androidResources {
|
androidResources {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<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.INTERNET"/>
|
||||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
<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.SYSTEM_ALERT_WINDOW"/>
|
||||||
<uses-permission android:name="android.permission.VIBRATE"/>
|
<uses-permission android:name="android.permission.VIBRATE"/>
|
||||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
|
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
|
||||||
@@ -11,7 +13,11 @@
|
|||||||
<data android:scheme="https"/>
|
<data android:scheme="https"/>
|
||||||
</intent>
|
</intent>
|
||||||
</queries>
|
</queries>
|
||||||
<application android:name=".MainApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="true" android:theme="@style/AppTheme" android:supportsRtl="true">
|
<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.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_CHECK_ON_LAUNCH" android:value="ALWAYS"/>
|
||||||
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_LAUNCH_WAIT_MS" android:value="0"/>
|
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_LAUNCH_WAIT_MS" android:value="0"/>
|
||||||
|
|||||||
@@ -5,13 +5,13 @@ import android.content.res.Configuration
|
|||||||
|
|
||||||
import com.facebook.react.PackageList
|
import com.facebook.react.PackageList
|
||||||
import com.facebook.react.ReactApplication
|
import com.facebook.react.ReactApplication
|
||||||
|
import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative
|
||||||
import com.facebook.react.ReactNativeHost
|
import com.facebook.react.ReactNativeHost
|
||||||
import com.facebook.react.ReactPackage
|
import com.facebook.react.ReactPackage
|
||||||
import com.facebook.react.ReactHost
|
import com.facebook.react.ReactHost
|
||||||
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.load
|
import com.facebook.react.common.ReleaseLevel
|
||||||
|
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint
|
||||||
import com.facebook.react.defaults.DefaultReactNativeHost
|
import com.facebook.react.defaults.DefaultReactNativeHost
|
||||||
import com.facebook.react.soloader.OpenSourceMergedSoMapping
|
|
||||||
import com.facebook.soloader.SoLoader
|
|
||||||
|
|
||||||
import expo.modules.ApplicationLifecycleDispatcher
|
import expo.modules.ApplicationLifecycleDispatcher
|
||||||
import expo.modules.ReactNativeHostWrapper
|
import expo.modules.ReactNativeHostWrapper
|
||||||
@@ -19,21 +19,19 @@ import expo.modules.ReactNativeHostWrapper
|
|||||||
class MainApplication : Application(), ReactApplication {
|
class MainApplication : Application(), ReactApplication {
|
||||||
|
|
||||||
override val reactNativeHost: ReactNativeHost = ReactNativeHostWrapper(
|
override val reactNativeHost: ReactNativeHost = ReactNativeHostWrapper(
|
||||||
this,
|
this,
|
||||||
object : DefaultReactNativeHost(this) {
|
object : DefaultReactNativeHost(this) {
|
||||||
override fun getPackages(): List<ReactPackage> {
|
override fun getPackages(): List<ReactPackage> =
|
||||||
val packages = PackageList(this).packages
|
PackageList(this).packages.apply {
|
||||||
// Packages that cannot be autolinked yet can be added manually here, for example:
|
// Packages that cannot be autolinked yet can be added manually here, for example:
|
||||||
// packages.add(MyReactNativePackage())
|
// add(MyReactNativePackage())
|
||||||
return packages
|
}
|
||||||
}
|
|
||||||
|
|
||||||
override fun getJSMainModuleName(): String = ".expo/.virtual-metro-entry"
|
override fun getJSMainModuleName(): String = ".expo/.virtual-metro-entry"
|
||||||
|
|
||||||
override fun getUseDeveloperSupport(): Boolean = BuildConfig.DEBUG
|
override fun getUseDeveloperSupport(): Boolean = BuildConfig.DEBUG
|
||||||
|
|
||||||
override val isNewArchEnabled: Boolean = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED
|
override val isNewArchEnabled: Boolean = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED
|
||||||
override val isHermesEnabled: Boolean = BuildConfig.IS_HERMES_ENABLED
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -42,11 +40,12 @@ class MainApplication : Application(), ReactApplication {
|
|||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
SoLoader.init(this, OpenSourceMergedSoMapping)
|
DefaultNewArchitectureEntryPoint.releaseLevel = try {
|
||||||
if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
|
ReleaseLevel.valueOf(BuildConfig.REACT_NATIVE_RELEASE_LEVEL.uppercase())
|
||||||
// If you opted-in for the New Architecture, we load the native entry point for this app.
|
} catch (e: IllegalArgumentException) {
|
||||||
load()
|
ReleaseLevel.STABLE
|
||||||
}
|
}
|
||||||
|
loadReactNative(this)
|
||||||
ApplicationLifecycleDispatcher.onApplicationCreate(this)
|
ApplicationLifecycleDispatcher.onApplicationCreate(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
BIN
android/app/src/main/res/drawable-hdpi/notification_icon.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 20 KiB 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 |
|
Before Width: | Height: | Size: 13 KiB 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 |
|
Before Width: | Height: | Size: 28 KiB 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 |
|
Before Width: | Height: | Size: 46 KiB 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 |
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 20 KiB |
@@ -1,5 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
<background android:drawable="@color/iconBackground"/>
|
|
||||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
|
||||||
</adaptive-icon>
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
<background android:drawable="@color/iconBackground"/>
|
|
||||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
|
||||||
</adaptive-icon>
|
|
||||||
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 |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 7.8 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 4.0 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 |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 2.6 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 |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 5.5 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 |
|
Before Width: | Height: | Size: 7.2 KiB After Width: | Height: | Size: 9.6 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 8.9 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 |
|
Before Width: | Height: | Size: 9.9 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 12 KiB |
@@ -3,4 +3,5 @@
|
|||||||
<color name="iconBackground">#ffffff</color>
|
<color name="iconBackground">#ffffff</color>
|
||||||
<color name="colorPrimary">#023c69</color>
|
<color name="colorPrimary">#023c69</color>
|
||||||
<color name="colorPrimaryDark">#ffffff</color>
|
<color name="colorPrimaryDark">#ffffff</color>
|
||||||
|
<color name="notification_icon_color">#ffffff</color>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">digital-pilates</string>
|
<string name="app_name">Out Live</string>
|
||||||
<string name="expo_system_ui_user_interface_style" translatable="false">automatic</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_resize_mode" translatable="false">contain</string>
|
||||||
<string name="expo_splash_screen_status_bar_translucent" translatable="false">false</string>
|
<string name="expo_splash_screen_status_bar_translucent" translatable="false">false</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||||
<style name="AppTheme" parent="Theme.EdgeToEdge">
|
<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="android:editTextBackground">@drawable/rn_edit_text_material</item>
|
||||||
<item name="colorPrimary">@color/colorPrimary</item>
|
<item name="colorPrimary">@color/colorPrimary</item>
|
||||||
<item name="android:statusBarColor">#ffffff</item>
|
<item name="android:statusBarColor">#ffffff</item>
|
||||||
@@ -8,5 +9,6 @@
|
|||||||
<item name="windowSplashScreenBackground">@color/splashscreen_background</item>
|
<item name="windowSplashScreenBackground">@color/splashscreen_background</item>
|
||||||
<item name="windowSplashScreenAnimatedIcon">@drawable/splashscreen_logo</item>
|
<item name="windowSplashScreenAnimatedIcon">@drawable/splashscreen_logo</item>
|
||||||
<item name="postSplashScreenTheme">@style/AppTheme</item>
|
<item name="postSplashScreenTheme">@style/AppTheme</item>
|
||||||
|
<item name="android:windowSplashScreenBehavior">icon_preferred</item>
|
||||||
</style>
|
</style>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -12,21 +12,8 @@ buildscript {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
def reactNativeAndroidDir = new File(
|
|
||||||
providers.exec {
|
|
||||||
workingDir(rootDir)
|
|
||||||
commandLine("node", "--print", "require.resolve('react-native/package.json')")
|
|
||||||
}.standardOutput.asText.get().trim(),
|
|
||||||
"../android"
|
|
||||||
)
|
|
||||||
|
|
||||||
allprojects {
|
allprojects {
|
||||||
repositories {
|
repositories {
|
||||||
maven {
|
|
||||||
// All of React Native (JS, Obj-C sources, Android binaries) is installed from npm
|
|
||||||
url(reactNativeAndroidDir)
|
|
||||||
}
|
|
||||||
|
|
||||||
google()
|
google()
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
maven { url 'https://www.jitpack.io' }
|
maven { url 'https://www.jitpack.io' }
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m
|
|||||||
# When configured, Gradle will run in incubating parallel mode.
|
# When configured, Gradle will run in incubating parallel mode.
|
||||||
# This option should only be used with decoupled projects. More details, visit
|
# 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
|
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
|
||||||
# org.gradle.parallel=true
|
org.gradle.parallel=true
|
||||||
|
|
||||||
# AndroidX package structure to make it clearer which packages are bundled with the
|
# 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
|
# Android operating system, and which are packaged with your app's APK
|
||||||
@@ -39,7 +39,12 @@ newArchEnabled=true
|
|||||||
|
|
||||||
# Use this property to enable or disable the Hermes JS engine.
|
# Use this property to enable or disable the Hermes JS engine.
|
||||||
# If set to false, you will be using JSC instead.
|
# If set to false, you will be using JSC instead.
|
||||||
hermesEnabled=true
|
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)
|
# Enable GIF support in React Native images (~200 B increase)
|
||||||
expo.gif.enabled=true
|
expo.gif.enabled=true
|
||||||
@@ -55,5 +60,6 @@ EX_DEV_CLIENT_NETWORK_INSPECTOR=true
|
|||||||
# Use legacy packaging to compress native libraries in the resulting APK.
|
# Use legacy packaging to compress native libraries in the resulting APK.
|
||||||
expo.useLegacyPackaging=false
|
expo.useLegacyPackaging=false
|
||||||
|
|
||||||
# Whether the app is configured to use edge-to-edge via the app config or `react-native-edge-to-edge` plugin
|
# Specifies whether the app is configured to use edge-to-edge via the app config or plugin
|
||||||
expo.edgeToEdgeEnabled=true
|
# 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
@@ -1,6 +1,6 @@
|
|||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
|
||||||
networkTimeout=10000
|
networkTimeout=10000
|
||||||
validateDistributionUrl=true
|
validateDistributionUrl=true
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
|||||||
4
android/gradlew
vendored
@@ -114,7 +114,7 @@ case "$( uname )" in #(
|
|||||||
NONSTOP* ) nonstop=true ;;
|
NONSTOP* ) nonstop=true ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
CLASSPATH="\\\"\\\""
|
||||||
|
|
||||||
|
|
||||||
# Determine the Java command to use to start the JVM.
|
# Determine the Java command to use to start the JVM.
|
||||||
@@ -213,7 +213,7 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
|||||||
set -- \
|
set -- \
|
||||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||||
-classpath "$CLASSPATH" \
|
-classpath "$CLASSPATH" \
|
||||||
org.gradle.wrapper.GradleWrapperMain \
|
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||||
"$@"
|
"$@"
|
||||||
|
|
||||||
# Stop when "xargs" is not available.
|
# Stop when "xargs" is not available.
|
||||||
|
|||||||
4
android/gradlew.bat
vendored
@@ -70,11 +70,11 @@ goto fail
|
|||||||
:execute
|
:execute
|
||||||
@rem Setup the command line
|
@rem Setup the command line
|
||||||
|
|
||||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
set CLASSPATH=
|
||||||
|
|
||||||
|
|
||||||
@rem Execute Gradle
|
@rem Execute Gradle
|
||||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
"%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
|
:end
|
||||||
@rem End local scope for the variables with windows NT shell
|
@rem End local scope for the variables with windows NT shell
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ extensions.configure(com.facebook.react.ReactSettingsExtension) { ex ->
|
|||||||
}
|
}
|
||||||
expoAutolinking.useExpoModules()
|
expoAutolinking.useExpoModules()
|
||||||
|
|
||||||
rootProject.name = 'digital-pilates'
|
rootProject.name = 'Out Live'
|
||||||
|
|
||||||
expoAutolinking.useExpoVersionCatalog()
|
expoAutolinking.useExpoVersionCatalog()
|
||||||
|
|
||||||
|
|||||||
63
app.json
@@ -1,57 +1,72 @@
|
|||||||
{
|
{
|
||||||
"expo": {
|
"expo": {
|
||||||
"name": "digital-pilates",
|
"name": "Out Live",
|
||||||
"slug": "digital-pilates",
|
"slug": "digital-pilates",
|
||||||
"version": "1.0.0",
|
"version": "1.0.15",
|
||||||
"orientation": "portrait",
|
"orientation": "portrait",
|
||||||
"icon": "./assets/images/icon.png",
|
|
||||||
"scheme": "digitalpilates",
|
"scheme": "digitalpilates",
|
||||||
"userInterfaceStyle": "automatic",
|
"userInterfaceStyle": "light",
|
||||||
|
"icon": "./assets/icon.icon/Assets/icon-1756312748268.jpg",
|
||||||
"newArchEnabled": true,
|
"newArchEnabled": true,
|
||||||
|
"jsEngine": "jsc",
|
||||||
"ios": {
|
"ios": {
|
||||||
"supportsTablet": true,
|
"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": "应用需要使用语音识别功能来转换您的语音为文字,帮助您快速记录饮食信息。",
|
||||||
"android": {
|
"NSUserNotificationsUsageDescription": "应用需要发送通知以提醒您喝水和站立活动。",
|
||||||
"adaptiveIcon": {
|
"UIBackgroundModes": [
|
||||||
"foregroundImage": "./assets/images/adaptive-icon.png",
|
"processing",
|
||||||
"backgroundColor": "#ffffff"
|
"fetch",
|
||||||
|
"remote-notification"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"edgeToEdgeEnabled": true,
|
"appleTeamId": "756WVXJ6MT"
|
||||||
"package": "com.anonymous.digitalpilates"
|
|
||||||
},
|
|
||||||
"web": {
|
|
||||||
"bundler": "metro",
|
|
||||||
"output": "static",
|
|
||||||
"favicon": "./assets/images/favicon.png"
|
|
||||||
},
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
"expo-router",
|
"expo-router",
|
||||||
[
|
[
|
||||||
"expo-splash-screen",
|
"expo-splash-screen",
|
||||||
{
|
{
|
||||||
"image": "./assets/images/splash-icon.png",
|
"image": "./assets/icon.icon/Assets/icon-1756312748268.jpg",
|
||||||
"imageWidth": 200,
|
"imageWidth": 40,
|
||||||
"resizeMode": "contain",
|
"resizeMode": "contain",
|
||||||
"backgroundColor": "#ffffff"
|
"backgroundColor": "#ffffff"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"react-native-health",
|
"expo-notifications",
|
||||||
{
|
{
|
||||||
"enableHealthAPI": true,
|
"icon": "./assets/icon.icon/Assets/icon-1756312748268.jpg",
|
||||||
"healthSharePermission": "应用需要访问您的健康数据(步数与能量消耗)以展示运动统计。"
|
"color": "#ffffff"
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"expo-quick-actions",
|
||||||
|
{
|
||||||
|
"androidIcons": {
|
||||||
|
"drink_water": "./assets/images/icons/IconGlass.png"
|
||||||
|
},
|
||||||
|
"iosIcons": {
|
||||||
|
"drink_water": "./assets/images/icons/IconGlass.png"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"expo-background-task"
|
||||||
]
|
]
|
||||||
],
|
],
|
||||||
"experiments": {
|
"experiments": {
|
||||||
"typedRoutes": true
|
"typedRoutes": true
|
||||||
|
},
|
||||||
|
"android": {
|
||||||
|
"package": "com.anonymous.digitalpilates"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,212 +1,206 @@
|
|||||||
|
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 } from 'react-native';
|
import { Text, TouchableOpacity, View, ViewStyle } from 'react-native';
|
||||||
|
|
||||||
import { IconSymbol } from '@/components/ui/IconSymbol';
|
import { IconSymbol } from '@/components/ui/IconSymbol';
|
||||||
import { Colors } from '@/constants/Colors';
|
import { Colors } from '@/constants/Colors';
|
||||||
|
import { ROUTES } from '@/constants/Routes';
|
||||||
import { TAB_BAR_BOTTOM_OFFSET, TAB_BAR_HEIGHT } from '@/constants/TabBar';
|
import { TAB_BAR_BOTTOM_OFFSET, TAB_BAR_HEIGHT } from '@/constants/TabBar';
|
||||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
|
|
||||||
|
// Tab configuration
|
||||||
|
type TabConfig = {
|
||||||
|
icon: string;
|
||||||
|
title: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const TAB_CONFIGS: Record<string, TabConfig> = {
|
||||||
|
statistics: { icon: 'chart.pie.fill', title: '健康' },
|
||||||
|
goals: { icon: 'flag.fill', title: '习惯' },
|
||||||
|
challenges: { icon: 'trophy.fill', title: '挑战' },
|
||||||
|
personal: { icon: 'person.fill', title: '个人' },
|
||||||
|
};
|
||||||
|
|
||||||
export default function TabLayout() {
|
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
|
||||||
|
const isTabSelected = (routeName: string): boolean => {
|
||||||
|
const routeMap: Record<string, string> = {
|
||||||
|
statistics: ROUTES.TAB_STATISTICS,
|
||||||
|
goals: ROUTES.TAB_GOALS,
|
||||||
|
challenges: ROUTES.TAB_CHALLENGES,
|
||||||
|
personal: ROUTES.TAB_PERSONAL,
|
||||||
|
};
|
||||||
|
|
||||||
|
return routeMap[routeName] === pathname || pathname.includes(routeName);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Custom tab button component
|
||||||
|
const createTabButton = (routeName: string) => (props: any) => {
|
||||||
|
const { onPress } = props;
|
||||||
|
const tabConfig = TAB_CONFIGS[routeName];
|
||||||
|
|
||||||
|
if (!tabConfig) return null;
|
||||||
|
|
||||||
|
const isSelected = isTabSelected(routeName);
|
||||||
|
|
||||||
|
const handlePress = (event: any) => {
|
||||||
|
if (process.env.EXPO_OS === 'ios') {
|
||||||
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
|
}
|
||||||
|
onPress?.(event);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handlePress}
|
||||||
|
accessibilityRole="button"
|
||||||
|
activeOpacity={1}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
flexDirection: 'row',
|
||||||
|
marginHorizontal: 2,
|
||||||
|
marginVertical: 10,
|
||||||
|
borderRadius: 25,
|
||||||
|
backgroundColor: isSelected ? colorTokens.tabBarActiveBackground : 'transparent',
|
||||||
|
paddingHorizontal: isSelected ? 8 : 4,
|
||||||
|
paddingVertical: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
||||||
|
<IconSymbol
|
||||||
|
size={22}
|
||||||
|
name={tabConfig.icon as any}
|
||||||
|
color={isSelected ? colorTokens.tabIconSelected : colorTokens.tabIconDefault}
|
||||||
|
/>
|
||||||
|
{isSelected && (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: colorTokens.tabIconSelected,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '600',
|
||||||
|
marginLeft: 6,
|
||||||
|
}}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{tabConfig.title}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Custom tab bar background component
|
||||||
|
const TabBarBackground = () => {
|
||||||
|
if (glassEffectAvailable) {
|
||||||
|
return (
|
||||||
|
<GlassContainer
|
||||||
|
spacing={8}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
borderRadius: 34,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<GlassView
|
||||||
|
isInteractive
|
||||||
|
glassEffectStyle="regular"
|
||||||
|
tintColor={theme === 'dark' ? 'rgba(0,0,0,0.3)' : 'rgba(255,255,255,0.3)'}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
borderRadius: 34,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</GlassContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Common screen options
|
||||||
|
const getScreenOptions = (routeName: string): BottomTabNavigationOptions => ({
|
||||||
|
headerShown: false,
|
||||||
|
tabBarActiveTintColor: colorTokens.tabIconSelected,
|
||||||
|
tabBarButton: createTabButton(routeName),
|
||||||
|
tabBarBackground: TabBarBackground,
|
||||||
|
tabBarStyle: {
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: TAB_BAR_BOTTOM_OFFSET,
|
||||||
|
height: TAB_BAR_HEIGHT,
|
||||||
|
borderRadius: 34,
|
||||||
|
backgroundColor: glassEffectAvailable ? 'transparent' : colorTokens.tabBarBackground,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: glassEffectAvailable ? 0.1 : 0.2,
|
||||||
|
shadowRadius: 10,
|
||||||
|
elevation: 5,
|
||||||
|
paddingHorizontal: 6,
|
||||||
|
paddingTop: 0,
|
||||||
|
paddingBottom: 0,
|
||||||
|
marginHorizontal: 16,
|
||||||
|
left: 16,
|
||||||
|
right: 16,
|
||||||
|
alignSelf: 'center',
|
||||||
|
borderWidth: glassEffectAvailable ? 1 : 0,
|
||||||
|
borderColor: glassEffectAvailable ? (theme === 'dark' ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)') : 'transparent',
|
||||||
|
} as ViewStyle,
|
||||||
|
tabBarItemStyle: {
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
height: TAB_BAR_HEIGHT,
|
||||||
|
marginTop: 0,
|
||||||
|
marginBottom: 0,
|
||||||
|
paddingTop: 0,
|
||||||
|
paddingBottom: 0,
|
||||||
|
},
|
||||||
|
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
|
||||||
screenOptions={({ route }) => {
|
initialRouteName="statistics"
|
||||||
const routeName = route.name;
|
screenOptions={({ route }) => getScreenOptions(route.name)}
|
||||||
const isSelected = (routeName === 'index' && pathname === '/') ||
|
>
|
||||||
(routeName === 'explore' && pathname === '/explore') ||
|
|
||||||
pathname.includes(routeName);
|
|
||||||
|
|
||||||
return {
|
<Tabs.Screen name="statistics" options={{ title: '健康' }} />
|
||||||
headerShown: false,
|
<Tabs.Screen name="goals" options={{ title: '习惯' }} />
|
||||||
tabBarActiveTintColor: colorTokens.tabIconSelected,
|
<Tabs.Screen name="challenges" options={{ title: '挑战' }} />
|
||||||
tabBarButton: (props) => {
|
<Tabs.Screen name="personal" options={{ title: '个人' }} />
|
||||||
const { onPress } = props;
|
|
||||||
|
|
||||||
const handlePress = (event: any) => {
|
|
||||||
if (process.env.EXPO_OS === 'ios') {
|
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
||||||
}
|
|
||||||
onPress && onPress(event);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 基于 routeName 设置图标与标题,避免 tabBarIcon 的包装导致文字裁剪
|
|
||||||
const getIconAndTitle = () => {
|
|
||||||
switch (routeName) {
|
|
||||||
case 'index':
|
|
||||||
return { icon: 'house.fill', title: '首页' } as const;
|
|
||||||
case 'explore':
|
|
||||||
return { icon: 'paperplane.fill', title: '探索' } as const;
|
|
||||||
case 'personal':
|
|
||||||
return { icon: 'person.fill', title: '个人' } as const;
|
|
||||||
default:
|
|
||||||
return { icon: 'circle', title: '' } as const;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const { icon, title } = getIconAndTitle();
|
|
||||||
const activeContentColor = colorTokens.onPrimary;
|
|
||||||
const inactiveContentColor = colorTokens.tabIconDefault;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={handlePress}
|
|
||||||
accessibilityRole="button"
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
flexDirection: 'row',
|
|
||||||
marginHorizontal: 6,
|
|
||||||
marginVertical: 10,
|
|
||||||
borderRadius: 25,
|
|
||||||
backgroundColor: isSelected ? colorTokens.tabBarActiveBackground : 'transparent',
|
|
||||||
paddingHorizontal: isSelected ? 16 : 10,
|
|
||||||
paddingVertical: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
|
||||||
<IconSymbol
|
|
||||||
size={22}
|
|
||||||
name={icon as any}
|
|
||||||
color={isSelected ? activeContentColor : inactiveContentColor}
|
|
||||||
/>
|
|
||||||
{isSelected && !!title && (
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
color: activeContentColor,
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: '600',
|
|
||||||
marginLeft: 6,
|
|
||||||
}}
|
|
||||||
// 选中态下不限制行数,避免大屏布局下被裁剪成省略号
|
|
||||||
numberOfLines={0 as any}
|
|
||||||
>
|
|
||||||
{title}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
tabBarStyle: {
|
|
||||||
position: 'absolute',
|
|
||||||
bottom: TAB_BAR_BOTTOM_OFFSET,
|
|
||||||
height: TAB_BAR_HEIGHT,
|
|
||||||
borderRadius: 34,
|
|
||||||
backgroundColor: colorTokens.tabBarBackground,
|
|
||||||
shadowColor: '#000',
|
|
||||||
shadowOffset: { width: 0, height: 2 },
|
|
||||||
shadowOpacity: 0.2,
|
|
||||||
shadowRadius: 10,
|
|
||||||
elevation: 5,
|
|
||||||
paddingHorizontal: 10,
|
|
||||||
paddingTop: 0,
|
|
||||||
paddingBottom: 0,
|
|
||||||
marginHorizontal: 20,
|
|
||||||
width: '90%',
|
|
||||||
alignSelf: 'center',
|
|
||||||
},
|
|
||||||
tabBarItemStyle: {
|
|
||||||
backgroundColor: 'transparent',
|
|
||||||
height: TAB_BAR_HEIGHT,
|
|
||||||
marginTop: 0,
|
|
||||||
marginBottom: 0,
|
|
||||||
paddingTop: 0,
|
|
||||||
paddingBottom: 0,
|
|
||||||
},
|
|
||||||
tabBarShowLabel: false,
|
|
||||||
};
|
|
||||||
}}>
|
|
||||||
<Tabs.Screen
|
|
||||||
name="index"
|
|
||||||
options={{
|
|
||||||
title: '首页',
|
|
||||||
tabBarIcon: ({ color }) => {
|
|
||||||
const isHomeSelected = pathname === '/' || pathname === '/index';
|
|
||||||
return (
|
|
||||||
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
|
||||||
<IconSymbol size={22} name="house.fill" color={color} />
|
|
||||||
{isHomeSelected && (
|
|
||||||
<Text
|
|
||||||
numberOfLines={1}
|
|
||||||
style={{
|
|
||||||
color: color,
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: '600',
|
|
||||||
marginLeft: 6,
|
|
||||||
textAlign: 'center',
|
|
||||||
flexShrink: 0,
|
|
||||||
}}>
|
|
||||||
首页
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Tabs.Screen
|
|
||||||
name="explore"
|
|
||||||
options={{
|
|
||||||
title: '探索',
|
|
||||||
tabBarIcon: ({ color }) => {
|
|
||||||
const isExploreSelected = pathname === '/explore';
|
|
||||||
return (
|
|
||||||
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
|
||||||
<IconSymbol size={22} name="paperplane.fill" color={color} />
|
|
||||||
{isExploreSelected && (
|
|
||||||
<Text
|
|
||||||
numberOfLines={1}
|
|
||||||
style={{
|
|
||||||
color: color,
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: '600',
|
|
||||||
marginLeft: 6,
|
|
||||||
textAlign: 'center',
|
|
||||||
flexShrink: 0,
|
|
||||||
}}>
|
|
||||||
探索
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Tabs.Screen
|
|
||||||
name="personal"
|
|
||||||
options={{
|
|
||||||
title: '个人',
|
|
||||||
tabBarIcon: ({ color }) => {
|
|
||||||
const isPersonalSelected = pathname === '/personal';
|
|
||||||
return (
|
|
||||||
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
|
||||||
<IconSymbol size={22} name="person.fill" color={color} />
|
|
||||||
{isPersonalSelected && (
|
|
||||||
<Text
|
|
||||||
numberOfLines={1}
|
|
||||||
style={{
|
|
||||||
color: color,
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: '600',
|
|
||||||
marginLeft: 6,
|
|
||||||
textAlign: 'center',
|
|
||||||
flexShrink: 0,
|
|
||||||
}}>
|
|
||||||
个人
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,442 +0,0 @@
|
|||||||
import { ProgressBar } from '@/components/ProgressBar';
|
|
||||||
import { Colors } from '@/constants/Colors';
|
|
||||||
import { getTabBarBottomPadding } from '@/constants/TabBar';
|
|
||||||
import { getMonthDaysZh, getMonthTitleZh, getTodayIndexInMonth } from '@/utils/date';
|
|
||||||
import { ensureHealthPermissions, fetchTodayHealthData } from '@/utils/health';
|
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
|
||||||
import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
|
|
||||||
import { useFocusEffect } from '@react-navigation/native';
|
|
||||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
|
||||||
import {
|
|
||||||
SafeAreaView,
|
|
||||||
ScrollView,
|
|
||||||
StyleSheet,
|
|
||||||
Text,
|
|
||||||
TouchableOpacity,
|
|
||||||
View,
|
|
||||||
} from 'react-native';
|
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
||||||
|
|
||||||
export default function ExploreScreen() {
|
|
||||||
// 使用 dayjs:当月日期与默认选中“今天”
|
|
||||||
const days = getMonthDaysZh();
|
|
||||||
const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth());
|
|
||||||
const tabBarHeight = useBottomTabBarHeight();
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
const bottomPadding = useMemo(() => {
|
|
||||||
return getTabBarBottomPadding(tabBarHeight) + (insets?.bottom ?? 0);
|
|
||||||
}, [tabBarHeight, insets?.bottom]);
|
|
||||||
|
|
||||||
const monthTitle = getMonthTitleZh();
|
|
||||||
|
|
||||||
// 日期条自动滚动到选中项
|
|
||||||
const daysScrollRef = useRef<import('react-native').ScrollView | null>(null);
|
|
||||||
const [scrollWidth, setScrollWidth] = useState(0);
|
|
||||||
const DAY_PILL_WIDTH = 68;
|
|
||||||
const DAY_PILL_SPACING = 12;
|
|
||||||
|
|
||||||
const scrollToIndex = (index: number, animated = true) => {
|
|
||||||
const baseOffset = index * (DAY_PILL_WIDTH + DAY_PILL_SPACING);
|
|
||||||
const centerOffset = Math.max(0, baseOffset - (scrollWidth / 2 - DAY_PILL_WIDTH / 2));
|
|
||||||
daysScrollRef.current?.scrollTo({ x: centerOffset, animated });
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (scrollWidth > 0) {
|
|
||||||
scrollToIndex(selectedIndex, false);
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [scrollWidth]);
|
|
||||||
|
|
||||||
// HealthKit: 每次页面聚焦都拉取今日数据
|
|
||||||
const [stepCount, setStepCount] = useState<number | null>(null);
|
|
||||||
const [activeCalories, setActiveCalories] = useState<number | null>(null);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
|
|
||||||
const loadHealthData = async () => {
|
|
||||||
try {
|
|
||||||
console.log('=== 开始HealthKit初始化流程 ===');
|
|
||||||
setIsLoading(true);
|
|
||||||
|
|
||||||
const ok = await ensureHealthPermissions();
|
|
||||||
if (!ok) {
|
|
||||||
const errorMsg = '无法获取健康权限,请确保在真实iOS设备上运行并授权应用访问健康数据';
|
|
||||||
console.warn(errorMsg);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('权限获取成功,开始获取健康数据...');
|
|
||||||
const data = await fetchTodayHealthData();
|
|
||||||
|
|
||||||
console.log('设置UI状态:', data);
|
|
||||||
setStepCount(data.steps);
|
|
||||||
setActiveCalories(Math.round(data.activeEnergyBurned));
|
|
||||||
console.log('=== HealthKit数据获取完成 ===');
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('HealthKit流程出现异常:', error);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useFocusEffect(
|
|
||||||
React.useCallback(() => {
|
|
||||||
loadHealthData();
|
|
||||||
}, [])
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={styles.container}>
|
|
||||||
<SafeAreaView style={styles.safeArea}>
|
|
||||||
<ScrollView
|
|
||||||
style={styles.scrollView}
|
|
||||||
contentContainerStyle={{ paddingBottom: bottomPadding }}
|
|
||||||
showsVerticalScrollIndicator={false}
|
|
||||||
>
|
|
||||||
{/* 标题与日期选择 */}
|
|
||||||
<Text style={styles.monthTitle}>{monthTitle}</Text>
|
|
||||||
<ScrollView
|
|
||||||
horizontal
|
|
||||||
showsHorizontalScrollIndicator={false}
|
|
||||||
contentContainerStyle={styles.daysContainer}
|
|
||||||
ref={daysScrollRef}
|
|
||||||
onLayout={(e) => setScrollWidth(e.nativeEvent.layout.width)}
|
|
||||||
>
|
|
||||||
{days.map((d, i) => {
|
|
||||||
const selected = i === selectedIndex;
|
|
||||||
return (
|
|
||||||
<View key={`${d.dayOfMonth}`} style={styles.dayItemWrapper}>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={[styles.dayPill, selected ? styles.dayPillSelected : styles.dayPillNormal]}
|
|
||||||
onPress={() => {
|
|
||||||
setSelectedIndex(i);
|
|
||||||
scrollToIndex(i);
|
|
||||||
}}
|
|
||||||
activeOpacity={0.8}
|
|
||||||
>
|
|
||||||
<Text style={[styles.dayLabel, selected && styles.dayLabelSelected]}> {d.weekdayZh} </Text>
|
|
||||||
<Text style={[styles.dayDate, selected && styles.dayDateSelected]}>{d.dayOfMonth}</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
{selected && <View style={styles.selectedDot} />}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ScrollView>
|
|
||||||
|
|
||||||
{/* 今日报告 标题 */}
|
|
||||||
<Text style={styles.sectionTitle}>今日报告</Text>
|
|
||||||
|
|
||||||
{/* 健康数据错误提示 */}
|
|
||||||
{isLoading && (
|
|
||||||
<View style={styles.errorContainer}>
|
|
||||||
<Ionicons name="warning-outline" size={20} color="#E54D4D" />
|
|
||||||
<Text style={styles.errorText}>加载中...</Text>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.retryButton}
|
|
||||||
onPress={loadHealthData} disabled={isLoading}
|
|
||||||
>
|
|
||||||
<Ionicons
|
|
||||||
name="refresh-outline"
|
|
||||||
size={16}
|
|
||||||
color={isLoading ? '#9AA3AE' : '#E54D4D'}
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 指标行:左大卡(训练时间),右两小卡(消耗卡路里、步数) */}
|
|
||||||
<View style={styles.metricsRow}>
|
|
||||||
<View style={[styles.trainingCard, styles.metricsLeft]}>
|
|
||||||
<Text style={styles.cardTitleSecondary}>训练时间</Text>
|
|
||||||
<View style={styles.trainingContent}>
|
|
||||||
<View style={styles.trainingRingTrack} />
|
|
||||||
<View style={styles.trainingRingProgress} />
|
|
||||||
<Text style={styles.trainingPercent}>80%</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<View style={styles.metricsRight}>
|
|
||||||
<View style={[styles.metricsRightCard, styles.caloriesCard, { minHeight: 88 }]}>
|
|
||||||
<Text style={styles.cardTitleSecondary}>消耗卡路里</Text>
|
|
||||||
<Text style={styles.caloriesValue}>
|
|
||||||
{isLoading ? '加载中...' : activeCalories != null ? `${activeCalories} 千卡` : '——'}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View style={[styles.metricsRightCard, styles.stepsCard, { minHeight: 88 }]}>
|
|
||||||
<View style={styles.cardHeaderRow}>
|
|
||||||
<View style={styles.iconSquare}><Ionicons name="footsteps-outline" size={18} color="#192126" /></View>
|
|
||||||
<Text style={styles.cardTitle}>步数</Text>
|
|
||||||
</View>
|
|
||||||
<Text style={styles.stepsValue}>{isLoading ? '加载中.../2000' : stepCount != null ? `${stepCount}/2000` : '——/2000'}</Text>
|
|
||||||
<ProgressBar progress={Math.min(1, Math.max(0, (stepCount ?? 0) / 2000))} height={12} trackColor="#FFEBCB" fillColor="#FFC365" />
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
</SafeAreaView>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const primary = Colors.light.primary;
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
flex: 1,
|
|
||||||
backgroundColor: '#F6F7F8',
|
|
||||||
},
|
|
||||||
safeArea: {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
scrollView: {
|
|
||||||
flex: 1,
|
|
||||||
paddingHorizontal: 20,
|
|
||||||
},
|
|
||||||
monthTitle: {
|
|
||||||
fontSize: 24,
|
|
||||||
fontWeight: '800',
|
|
||||||
color: '#192126',
|
|
||||||
marginTop: 8,
|
|
||||||
marginBottom: 14,
|
|
||||||
},
|
|
||||||
daysContainer: {
|
|
||||||
paddingBottom: 8,
|
|
||||||
},
|
|
||||||
dayItemWrapper: {
|
|
||||||
alignItems: 'center',
|
|
||||||
width: 68,
|
|
||||||
marginRight: 12,
|
|
||||||
},
|
|
||||||
dayPill: {
|
|
||||||
width: 68,
|
|
||||||
height: 68,
|
|
||||||
borderRadius: 18,
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
},
|
|
||||||
dayPillNormal: {
|
|
||||||
backgroundColor: '#C8F852',
|
|
||||||
},
|
|
||||||
dayPillSelected: {
|
|
||||||
backgroundColor: '#192126',
|
|
||||||
},
|
|
||||||
dayLabel: {
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: '700',
|
|
||||||
color: '#192126',
|
|
||||||
marginBottom: 2,
|
|
||||||
},
|
|
||||||
dayLabelSelected: {
|
|
||||||
color: '#FFFFFF',
|
|
||||||
},
|
|
||||||
dayDate: {
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: '800',
|
|
||||||
color: '#192126',
|
|
||||||
},
|
|
||||||
dayDateSelected: {
|
|
||||||
color: '#FFFFFF',
|
|
||||||
},
|
|
||||||
selectedDot: {
|
|
||||||
width: 8,
|
|
||||||
height: 8,
|
|
||||||
borderRadius: 4,
|
|
||||||
backgroundColor: '#192126',
|
|
||||||
marginTop: 10,
|
|
||||||
marginBottom: 4,
|
|
||||||
alignSelf: 'center',
|
|
||||||
},
|
|
||||||
sectionTitle: {
|
|
||||||
fontSize: 24,
|
|
||||||
fontWeight: '800',
|
|
||||||
color: '#192126',
|
|
||||||
marginTop: 24,
|
|
||||||
marginBottom: 14,
|
|
||||||
},
|
|
||||||
metricsRow: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
marginBottom: 16,
|
|
||||||
},
|
|
||||||
card: {
|
|
||||||
backgroundColor: '#0F1418',
|
|
||||||
borderRadius: 22,
|
|
||||||
padding: 18,
|
|
||||||
marginBottom: 16,
|
|
||||||
},
|
|
||||||
metricsLeft: {
|
|
||||||
flex: 1,
|
|
||||||
backgroundColor: '#EEE9FF',
|
|
||||||
borderRadius: 22,
|
|
||||||
padding: 18,
|
|
||||||
marginRight: 12,
|
|
||||||
},
|
|
||||||
metricsRight: {
|
|
||||||
width: 160,
|
|
||||||
gap: 12,
|
|
||||||
},
|
|
||||||
metricsRightCard: {
|
|
||||||
backgroundColor: '#FFFFFF',
|
|
||||||
borderRadius: 22,
|
|
||||||
padding: 16,
|
|
||||||
},
|
|
||||||
caloriesCard: {
|
|
||||||
backgroundColor: '#FFFFFF',
|
|
||||||
},
|
|
||||||
trainingCard: {
|
|
||||||
backgroundColor: '#EEE9FF',
|
|
||||||
},
|
|
||||||
|
|
||||||
cardTitleSecondary: {
|
|
||||||
color: '#9AA3AE',
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: '600',
|
|
||||||
marginBottom: 10,
|
|
||||||
},
|
|
||||||
caloriesValue: {
|
|
||||||
color: '#192126',
|
|
||||||
fontSize: 22,
|
|
||||||
fontWeight: '800',
|
|
||||||
},
|
|
||||||
trainingContent: {
|
|
||||||
marginTop: 8,
|
|
||||||
width: 120,
|
|
||||||
height: 120,
|
|
||||||
borderRadius: 60,
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignSelf: 'center',
|
|
||||||
},
|
|
||||||
trainingRingTrack: {
|
|
||||||
position: 'absolute',
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
borderRadius: 60,
|
|
||||||
borderWidth: 12,
|
|
||||||
borderColor: '#E2D9FD',
|
|
||||||
},
|
|
||||||
trainingRingProgress: {
|
|
||||||
position: 'absolute',
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
borderRadius: 60,
|
|
||||||
borderWidth: 12,
|
|
||||||
borderColor: 'transparent',
|
|
||||||
borderTopColor: '#8B74F3',
|
|
||||||
borderRightColor: '#8B74F3',
|
|
||||||
transform: [{ rotateZ: '45deg' }],
|
|
||||||
},
|
|
||||||
trainingPercent: {
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: '800',
|
|
||||||
color: '#8B74F3',
|
|
||||||
},
|
|
||||||
cyclingHeader: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
marginBottom: 12,
|
|
||||||
},
|
|
||||||
cyclingIconBadge: {
|
|
||||||
width: 30,
|
|
||||||
height: 30,
|
|
||||||
borderRadius: 6,
|
|
||||||
backgroundColor: primary,
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
marginRight: 8,
|
|
||||||
},
|
|
||||||
cyclingTitle: {
|
|
||||||
color: '#FFFFFF',
|
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: '800',
|
|
||||||
},
|
|
||||||
mapArea: {
|
|
||||||
backgroundColor: 'rgba(255,255,255,0.08)',
|
|
||||||
borderRadius: 14,
|
|
||||||
height: 180,
|
|
||||||
padding: 8,
|
|
||||||
flexDirection: 'row',
|
|
||||||
flexWrap: 'wrap',
|
|
||||||
overflow: 'hidden',
|
|
||||||
},
|
|
||||||
mapTile: {
|
|
||||||
width: '25%',
|
|
||||||
height: '25%',
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: 'rgba(255,255,255,0.12)',
|
|
||||||
},
|
|
||||||
routeLine: {
|
|
||||||
position: 'absolute',
|
|
||||||
height: 6,
|
|
||||||
backgroundColor: primary,
|
|
||||||
borderRadius: 3,
|
|
||||||
},
|
|
||||||
cardHeaderRow: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
marginBottom: 12,
|
|
||||||
},
|
|
||||||
iconSquare: {
|
|
||||||
width: 30,
|
|
||||||
height: 30,
|
|
||||||
borderRadius: 8,
|
|
||||||
backgroundColor: '#FFFFFF',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
marginRight: 10,
|
|
||||||
},
|
|
||||||
cardTitle: {
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: '800',
|
|
||||||
color: '#192126',
|
|
||||||
},
|
|
||||||
heartCard: {
|
|
||||||
backgroundColor: '#FFE5E5',
|
|
||||||
},
|
|
||||||
waveContainer: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'flex-end',
|
|
||||||
height: 70,
|
|
||||||
gap: 6,
|
|
||||||
marginBottom: 8,
|
|
||||||
},
|
|
||||||
waveBar: {
|
|
||||||
width: 6,
|
|
||||||
borderRadius: 3,
|
|
||||||
backgroundColor: '#E54D4D',
|
|
||||||
},
|
|
||||||
heartValue: {
|
|
||||||
alignSelf: 'flex-end',
|
|
||||||
color: '#5B5B5B',
|
|
||||||
fontWeight: '600',
|
|
||||||
},
|
|
||||||
stepsCard: {
|
|
||||||
backgroundColor: '#FFE4B8',
|
|
||||||
},
|
|
||||||
stepsValue: {
|
|
||||||
fontSize: 16,
|
|
||||||
color: '#7A6A42',
|
|
||||||
fontWeight: '700',
|
|
||||||
marginBottom: 8,
|
|
||||||
},
|
|
||||||
errorContainer: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
backgroundColor: '#FFE5E5',
|
|
||||||
borderRadius: 12,
|
|
||||||
padding: 12,
|
|
||||||
marginBottom: 16,
|
|
||||||
},
|
|
||||||
errorText: {
|
|
||||||
fontSize: 14,
|
|
||||||
color: '#E54D4D',
|
|
||||||
fontWeight: '600',
|
|
||||||
marginLeft: 8,
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
retryButton: {
|
|
||||||
padding: 4,
|
|
||||||
marginLeft: 8,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
920
app/(tabs)/goals.tsx
Normal file
@@ -0,0 +1,920 @@
|
|||||||
|
import CelebrationAnimation, { CelebrationAnimationRef } from '@/components/CelebrationAnimation';
|
||||||
|
import GoalTemplateModal from '@/components/GoalTemplateModal';
|
||||||
|
import { GoalsPageGuide } from '@/components/GoalsPageGuide';
|
||||||
|
import { GuideTestButton } from '@/components/GuideTestButton';
|
||||||
|
import { TaskCard } from '@/components/TaskCard';
|
||||||
|
import { TaskFilterTabs, TaskFilterType } from '@/components/TaskFilterTabs';
|
||||||
|
import { TaskProgressCard } from '@/components/TaskProgressCard';
|
||||||
|
import { CreateGoalModal } from '@/components/model/CreateGoalModal';
|
||||||
|
import { useGlobalDialog } from '@/components/ui/DialogProvider';
|
||||||
|
import { Colors } from '@/constants/Colors';
|
||||||
|
import { TAB_BAR_BOTTOM_OFFSET, TAB_BAR_HEIGHT } from '@/constants/TabBar';
|
||||||
|
import { GoalTemplate } from '@/constants/goalTemplates';
|
||||||
|
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||||
|
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||||
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
|
import { clearErrors, createGoal } from '@/store/goalsSlice';
|
||||||
|
import { clearErrors as clearTaskErrors, fetchTasks, loadMoreTasks } from '@/store/tasksSlice';
|
||||||
|
import { CreateGoalRequest, TaskListItem } from '@/types/goals';
|
||||||
|
import { checkGuideCompleted, markGuideCompleted } from '@/utils/guideHelpers';
|
||||||
|
import { GoalNotificationHelpers } from '@/utils/notificationHelpers';
|
||||||
|
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||||
|
import { useFocusEffect } from '@react-navigation/native';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import * as Haptics from 'expo-haptics';
|
||||||
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
|
import Lottie from 'lottie-react-native';
|
||||||
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { Alert, FlatList, Image, RefreshControl, SafeAreaView, StatusBar, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||||
|
|
||||||
|
export default function GoalsScreen() {
|
||||||
|
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||||
|
const colorTokens = Colors[theme];
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const { pushIfAuthedElseLogin, isLoggedIn } = useAuthGuard();
|
||||||
|
|
||||||
|
const { showConfirm } = useGlobalDialog();
|
||||||
|
|
||||||
|
// Redux状态
|
||||||
|
const {
|
||||||
|
tasks,
|
||||||
|
tasksLoading,
|
||||||
|
tasksError,
|
||||||
|
tasksPagination,
|
||||||
|
completeError,
|
||||||
|
skipError,
|
||||||
|
} = useAppSelector((state) => state.tasks);
|
||||||
|
|
||||||
|
|
||||||
|
const {
|
||||||
|
createLoading,
|
||||||
|
createError
|
||||||
|
} = useAppSelector((state) => state.goals);
|
||||||
|
|
||||||
|
const userProfile = useAppSelector((state) => state.user.profile);
|
||||||
|
|
||||||
|
const [showTemplateModal, setShowTemplateModal] = useState(false);
|
||||||
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const [selectedFilter, setSelectedFilter] = useState<TaskFilterType>('all');
|
||||||
|
const [modalKey, setModalKey] = useState(0); // 用于强制重新渲染弹窗
|
||||||
|
const [showGuide, setShowGuide] = useState(false); // 控制引导显示
|
||||||
|
const [selectedTemplateData, setSelectedTemplateData] = useState<Partial<CreateGoalRequest> | undefined>();
|
||||||
|
|
||||||
|
// 庆祝动画引用
|
||||||
|
const celebrationAnimationRef = useRef<CelebrationAnimationRef>(null);
|
||||||
|
|
||||||
|
// 页面聚焦时重新加载数据
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
console.log('useFocusEffect - loading tasks isLoggedIn', isLoggedIn);
|
||||||
|
|
||||||
|
if (isLoggedIn) {
|
||||||
|
loadTasks();
|
||||||
|
checkAndShowGuide();
|
||||||
|
}
|
||||||
|
}, [dispatch, isLoggedIn])
|
||||||
|
);
|
||||||
|
|
||||||
|
// 检查并显示用户引导
|
||||||
|
const checkAndShowGuide = async () => {
|
||||||
|
try {
|
||||||
|
const hasCompletedGuide = await checkGuideCompleted('GOALS_PAGE');
|
||||||
|
if (!hasCompletedGuide) {
|
||||||
|
// 延迟显示引导,确保页面完全加载
|
||||||
|
setTimeout(() => {
|
||||||
|
setShowGuide(true);
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('检查引导状态失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 加载任务列表
|
||||||
|
const loadTasks = async () => {
|
||||||
|
try {
|
||||||
|
|
||||||
|
await dispatch(fetchTasks({
|
||||||
|
startDate: dayjs().startOf('day').toISOString(),
|
||||||
|
endDate: dayjs().endOf('day').toISOString(),
|
||||||
|
})).unwrap();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load tasks:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 下拉刷新
|
||||||
|
const onRefresh = async () => {
|
||||||
|
setRefreshing(true);
|
||||||
|
try {
|
||||||
|
if (!isLoggedIn) return
|
||||||
|
|
||||||
|
await loadTasks();
|
||||||
|
} finally {
|
||||||
|
setRefreshing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 加载更多任务
|
||||||
|
const handleLoadMoreTasks = async () => {
|
||||||
|
if (!isLoggedIn) return
|
||||||
|
|
||||||
|
if (tasksPagination.hasMore && !tasksLoading) {
|
||||||
|
try {
|
||||||
|
await dispatch(loadMoreTasks()).unwrap();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load more tasks:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理错误提示
|
||||||
|
useEffect(() => {
|
||||||
|
|
||||||
|
if (tasksError) {
|
||||||
|
Alert.alert('错误', tasksError);
|
||||||
|
dispatch(clearTaskErrors());
|
||||||
|
}
|
||||||
|
if (createError) {
|
||||||
|
Alert.alert('创建失败', createError);
|
||||||
|
dispatch(clearErrors());
|
||||||
|
}
|
||||||
|
if (completeError) {
|
||||||
|
Alert.alert('完成失败', completeError);
|
||||||
|
dispatch(clearTaskErrors());
|
||||||
|
}
|
||||||
|
if (skipError) {
|
||||||
|
Alert.alert('跳过失败', skipError);
|
||||||
|
dispatch(clearTaskErrors());
|
||||||
|
}
|
||||||
|
}, [tasksError, createError, completeError, skipError, dispatch]);
|
||||||
|
|
||||||
|
// 重置弹窗表单数据
|
||||||
|
const handleModalSuccess = () => {
|
||||||
|
// 不需要在这里改变 modalKey,因为弹窗已经关闭了
|
||||||
|
// 下次打开时会自动使用新的 modalKey
|
||||||
|
setSelectedTemplateData(undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理模板选择
|
||||||
|
const handleSelectTemplate = (template: GoalTemplate) => {
|
||||||
|
setSelectedTemplateData(template.data);
|
||||||
|
setShowTemplateModal(false);
|
||||||
|
setModalKey(prev => prev + 1);
|
||||||
|
setShowCreateModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理创建自定义目标
|
||||||
|
const handleCreateCustomGoal = () => {
|
||||||
|
setSelectedTemplateData(undefined);
|
||||||
|
setShowTemplateModal(false);
|
||||||
|
setModalKey(prev => prev + 1);
|
||||||
|
setShowCreateModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 打开模板选择弹窗
|
||||||
|
const handleOpenTemplateModal = () => {
|
||||||
|
setSelectedTemplateData(undefined);
|
||||||
|
setShowTemplateModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 创建目标处理函数
|
||||||
|
const handleCreateGoal = async (goalData: CreateGoalRequest) => {
|
||||||
|
try {
|
||||||
|
await dispatch(createGoal(goalData)).unwrap();
|
||||||
|
setShowCreateModal(false);
|
||||||
|
|
||||||
|
// 获取用户名
|
||||||
|
const userName = userProfile?.name || '主人';
|
||||||
|
|
||||||
|
// 创建目标成功后,设置定时推送
|
||||||
|
try {
|
||||||
|
if (goalData.hasReminder) {
|
||||||
|
const notificationIds = await GoalNotificationHelpers.scheduleGoalNotifications(
|
||||||
|
{
|
||||||
|
title: goalData.title,
|
||||||
|
repeatType: goalData.repeatType,
|
||||||
|
frequency: goalData.frequency,
|
||||||
|
hasReminder: goalData.hasReminder,
|
||||||
|
reminderTime: goalData.reminderTime,
|
||||||
|
customRepeatRule: goalData.customRepeatRule,
|
||||||
|
startTime: goalData.startTime,
|
||||||
|
},
|
||||||
|
userName
|
||||||
|
);
|
||||||
|
console.log(`目标"${goalData.title}"的定时推送已创建,通知ID:`, notificationIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (notificationError) {
|
||||||
|
console.error('创建目标定时推送失败:', notificationError);
|
||||||
|
// 通知创建失败不影响目标创建的成功
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用确认弹窗显示成功消息
|
||||||
|
showConfirm(
|
||||||
|
{
|
||||||
|
title: '目标创建成功',
|
||||||
|
message: '恭喜!您的目标已成功创建。系统将自动生成相应的任务,帮助您实现目标。',
|
||||||
|
confirmText: '确定',
|
||||||
|
cancelText: '',
|
||||||
|
icon: 'checkmark-circle',
|
||||||
|
iconColor: '#10B981',
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
// 用户点击确定后的回调
|
||||||
|
console.log('用户确认了目标创建成功');
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 创建目标后重新加载任务列表
|
||||||
|
loadTasks();
|
||||||
|
} catch (error) {
|
||||||
|
// 错误已在useEffect中处理
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// 导航到任务列表页面
|
||||||
|
const handleNavigateToTasks = () => {
|
||||||
|
pushIfAuthedElseLogin('/task-list');
|
||||||
|
};
|
||||||
|
|
||||||
|
// 计算各状态的任务数量
|
||||||
|
const taskCounts = {
|
||||||
|
all: tasks.length,
|
||||||
|
pending: tasks.filter(task => task.status === 'pending').length,
|
||||||
|
completed: tasks.filter(task => task.status === 'completed').length,
|
||||||
|
skipped: tasks.filter(task => task.status === 'skipped').length,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 根据筛选条件过滤任务,并将已完成的任务放到最后
|
||||||
|
const filteredTasks = React.useMemo(() => {
|
||||||
|
let filtered: TaskListItem[] = [];
|
||||||
|
|
||||||
|
switch (selectedFilter) {
|
||||||
|
case 'pending':
|
||||||
|
filtered = tasks.filter(task => task.status === 'pending');
|
||||||
|
break;
|
||||||
|
case 'completed':
|
||||||
|
filtered = tasks.filter(task => task.status === 'completed');
|
||||||
|
break;
|
||||||
|
case 'skipped':
|
||||||
|
filtered = tasks.filter(task => task.status === 'skipped');
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
filtered = tasks;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 对所有筛选结果进行排序:已完成的任务放到最后
|
||||||
|
return [...filtered].sort((a, b) => {
|
||||||
|
// 如果a已完成而b未完成,a排在后面
|
||||||
|
if (a.status === 'completed' && b.status !== 'completed') {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
// 如果b已完成而a未完成,b排在后面
|
||||||
|
if (b.status === 'completed' && a.status !== 'completed') {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
// 如果都已完成或都未完成,保持原有顺序
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
}, [tasks, selectedFilter]);
|
||||||
|
|
||||||
|
// 处理筛选变化
|
||||||
|
const handleFilterChange = (filter: TaskFilterType) => {
|
||||||
|
setSelectedFilter(filter);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理引导完成
|
||||||
|
const handleGuideComplete = async () => {
|
||||||
|
try {
|
||||||
|
await markGuideCompleted('GOALS_PAGE');
|
||||||
|
setShowGuide(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('保存引导状态失败:', error);
|
||||||
|
setShowGuide(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理任务完成
|
||||||
|
const handleTaskCompleted = (completedTask: TaskListItem) => {
|
||||||
|
// 触发震动反馈
|
||||||
|
if (process.env.EXPO_OS === 'ios') {
|
||||||
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 播放庆祝动画
|
||||||
|
celebrationAnimationRef.current?.play();
|
||||||
|
|
||||||
|
console.log(`任务 "${completedTask.title}" 已完成,播放庆祝动画`);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 渲染任务项
|
||||||
|
const renderTaskItem = ({ item }: { item: TaskListItem }) => (
|
||||||
|
<TaskCard
|
||||||
|
task={item}
|
||||||
|
onTaskCompleted={handleTaskCompleted}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// 渲染空状态
|
||||||
|
const renderEmptyState = () => {
|
||||||
|
// 未登录状态下的引导
|
||||||
|
if (!isLoggedIn) {
|
||||||
|
return (
|
||||||
|
<View style={styles.emptyStateLogin}>
|
||||||
|
<LinearGradient
|
||||||
|
colors={['#F0F9FF', '#FEFEFE', '#F0F9FF']}
|
||||||
|
style={styles.emptyStateLoginBackground}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 1 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View style={styles.emptyStateLoginContent}>
|
||||||
|
{/* 清新的图标设计 */}
|
||||||
|
<View style={styles.emptyStateLoginIconContainer}>
|
||||||
|
<LinearGradient
|
||||||
|
colors={[colorTokens.primary, '#9B8AFB']}
|
||||||
|
style={styles.emptyStateLoginIconGradient}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 1 }}
|
||||||
|
>
|
||||||
|
<MaterialIcons name="person-outline" size={32} color="#FFFFFF" />
|
||||||
|
</LinearGradient>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 主标题 */}
|
||||||
|
<Text style={[styles.emptyStateLoginTitle, { color: colorTokens.text }]}>
|
||||||
|
开启您的健康之旅
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* 副标题 */}
|
||||||
|
<Text style={[styles.emptyStateLoginSubtitle, { color: colorTokens.textSecondary }]}>
|
||||||
|
登录后即可创建个人目标,让我们一起建立健康的生活习惯
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* 登录按钮 */}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.emptyStateLoginButton, { backgroundColor: colorTokens.primary }]}
|
||||||
|
onPress={() => pushIfAuthedElseLogin('/goals')}
|
||||||
|
>
|
||||||
|
<LinearGradient
|
||||||
|
colors={[colorTokens.primary, '#9B8AFB']}
|
||||||
|
style={styles.emptyStateLoginButtonGradient}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 1 }}
|
||||||
|
>
|
||||||
|
<Text style={styles.emptyStateLoginButtonText}>立即登录</Text>
|
||||||
|
<MaterialIcons name="arrow-forward" size={18} color="#FFFFFF" />
|
||||||
|
</LinearGradient>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 已登录但无任务的状态
|
||||||
|
let title = '暂无任务';
|
||||||
|
let subtitle = '创建目标后,系统会自动生成相应的任务';
|
||||||
|
|
||||||
|
if (selectedFilter === 'pending') {
|
||||||
|
title = '暂无待完成的任务';
|
||||||
|
subtitle = '当前没有待完成的任务';
|
||||||
|
} else if (selectedFilter === 'completed') {
|
||||||
|
title = '暂无已完成的任务';
|
||||||
|
subtitle = '完成一些任务后,它们会显示在这里';
|
||||||
|
} else if (selectedFilter === 'skipped') {
|
||||||
|
title = '暂无已跳过的任务';
|
||||||
|
subtitle = '跳过一些任务后,它们会显示在这里';
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.emptyState}>
|
||||||
|
<Image
|
||||||
|
source={require('@/assets/images/task/ImageEmpty.png')}
|
||||||
|
style={styles.emptyStateImage}
|
||||||
|
resizeMode="contain"
|
||||||
|
/>
|
||||||
|
<Text style={[styles.emptyStateTitle, { color: colorTokens.text }]}>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.emptyStateSubtitle, { color: colorTokens.textSecondary }]}>
|
||||||
|
{subtitle}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 渲染加载更多
|
||||||
|
const renderLoadMore = () => {
|
||||||
|
if (!tasksPagination.hasMore) return null;
|
||||||
|
return (
|
||||||
|
<View style={styles.loadMoreContainer}>
|
||||||
|
<Text style={[styles.loadMoreText, { color: colorTokens.textSecondary }]}>
|
||||||
|
{tasksLoading ? '加载中...' : '上拉加载更多'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.container}>
|
||||||
|
<StatusBar
|
||||||
|
backgroundColor="transparent"
|
||||||
|
translucent
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 背景渐变 */}
|
||||||
|
<LinearGradient
|
||||||
|
colors={['#F0F9FF', '#E0F2FE']}
|
||||||
|
style={styles.gradientBackground}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 1 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
backgroundColor: '#7A5AF8',
|
||||||
|
height: 233,
|
||||||
|
borderBottomLeftRadius: 24,
|
||||||
|
borderBottomRightRadius: 24,
|
||||||
|
}}>
|
||||||
|
{/* 右下角Lottie动画 */}
|
||||||
|
<Lottie
|
||||||
|
source={require('@/assets/lottie/Goal.json')}
|
||||||
|
style={styles.bottomRightImage}
|
||||||
|
autoPlay
|
||||||
|
loop
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.content}>
|
||||||
|
{/* 标题区域 */}
|
||||||
|
<View style={styles.header}>
|
||||||
|
<View>
|
||||||
|
<Text style={[styles.pageTitle, { color: '#FFFFFF' }]}>
|
||||||
|
习惯养成
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.pageTitle2, { color: '#FFFFFF' }]}>
|
||||||
|
自律让我更健康
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 任务进度卡片 */}
|
||||||
|
<View >
|
||||||
|
<TaskProgressCard
|
||||||
|
tasks={tasks}
|
||||||
|
headerButtons={
|
||||||
|
<View style={styles.cardHeaderButtons}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.cardGoalsButton, { borderColor: colorTokens.primary }]}
|
||||||
|
onPress={handleNavigateToTasks}
|
||||||
|
>
|
||||||
|
<Text style={[styles.cardGoalsButtonText, { color: colorTokens.primary }]}>
|
||||||
|
历史
|
||||||
|
</Text>
|
||||||
|
<MaterialIcons name="list" size={16} color={colorTokens.primary} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.cardAddButton, { backgroundColor: colorTokens.primary }]}
|
||||||
|
onPress={handleOpenTemplateModal}
|
||||||
|
>
|
||||||
|
<Text style={styles.cardAddButtonText}>+</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 任务筛选标签 */}
|
||||||
|
<TaskFilterTabs
|
||||||
|
selectedFilter={selectedFilter}
|
||||||
|
onFilterChange={handleFilterChange}
|
||||||
|
taskCounts={taskCounts}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 任务列表 */}
|
||||||
|
<View style={styles.taskListContainer}>
|
||||||
|
<FlatList
|
||||||
|
data={filteredTasks}
|
||||||
|
renderItem={renderTaskItem}
|
||||||
|
keyExtractor={(item) => item.id}
|
||||||
|
contentContainerStyle={styles.taskList}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={refreshing}
|
||||||
|
onRefresh={onRefresh}
|
||||||
|
colors={['#0EA5E9']}
|
||||||
|
tintColor="#0EA5E9"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
onEndReached={handleLoadMoreTasks}
|
||||||
|
onEndReachedThreshold={0.1}
|
||||||
|
ListEmptyComponent={renderEmptyState}
|
||||||
|
ListFooterComponent={renderLoadMore}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 目标模板选择弹窗 */}
|
||||||
|
<GoalTemplateModal
|
||||||
|
visible={showTemplateModal}
|
||||||
|
onClose={() => setShowTemplateModal(false)}
|
||||||
|
onSelectTemplate={handleSelectTemplate}
|
||||||
|
onCreateCustom={handleCreateCustomGoal}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 创建目标弹窗 */}
|
||||||
|
<CreateGoalModal
|
||||||
|
key={modalKey}
|
||||||
|
visible={showCreateModal}
|
||||||
|
onClose={() => {
|
||||||
|
setShowCreateModal(false);
|
||||||
|
setSelectedTemplateData(undefined);
|
||||||
|
}}
|
||||||
|
onSubmit={handleCreateGoal}
|
||||||
|
onSuccess={handleModalSuccess}
|
||||||
|
loading={createLoading}
|
||||||
|
initialData={selectedTemplateData}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 目标页面引导 */}
|
||||||
|
<GoalsPageGuide
|
||||||
|
visible={showGuide}
|
||||||
|
onComplete={handleGuideComplete}
|
||||||
|
tasks={tasks}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 开发测试按钮 */}
|
||||||
|
<GuideTestButton visible={__DEV__} />
|
||||||
|
|
||||||
|
{/* 目标通知测试按钮 */}
|
||||||
|
{__DEV__ && (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.testButton}
|
||||||
|
onPress={() => {
|
||||||
|
// 这里可以导航到测试页面或显示测试弹窗
|
||||||
|
Alert.alert(
|
||||||
|
'目标通知测试',
|
||||||
|
'选择要测试的通知类型',
|
||||||
|
[
|
||||||
|
{ text: '取消', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: '每日目标通知',
|
||||||
|
onPress: async () => {
|
||||||
|
try {
|
||||||
|
const userName = userProfile?.name || '';
|
||||||
|
const notificationIds = await GoalNotificationHelpers.scheduleGoalNotifications(
|
||||||
|
{
|
||||||
|
title: '每日运动目标',
|
||||||
|
repeatType: 'daily',
|
||||||
|
frequency: 1,
|
||||||
|
hasReminder: true,
|
||||||
|
reminderTime: '09:00',
|
||||||
|
},
|
||||||
|
userName
|
||||||
|
);
|
||||||
|
Alert.alert('成功', `每日目标通知已创建,ID: ${notificationIds.join(', ')}`);
|
||||||
|
} catch (error) {
|
||||||
|
Alert.alert('错误', `创建通知失败: ${error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: '每周目标通知',
|
||||||
|
onPress: async () => {
|
||||||
|
try {
|
||||||
|
const userName = userProfile?.name || '';
|
||||||
|
const notificationIds = await GoalNotificationHelpers.scheduleGoalNotifications(
|
||||||
|
{
|
||||||
|
title: '每周运动目标',
|
||||||
|
repeatType: 'weekly',
|
||||||
|
frequency: 1,
|
||||||
|
hasReminder: true,
|
||||||
|
reminderTime: '10:00',
|
||||||
|
customRepeatRule: {
|
||||||
|
weekdays: [1, 3, 5], // 周一、三、五
|
||||||
|
},
|
||||||
|
},
|
||||||
|
userName
|
||||||
|
);
|
||||||
|
Alert.alert('成功', `每周目标通知已创建,ID: ${notificationIds.join(', ')}`);
|
||||||
|
} catch (error) {
|
||||||
|
Alert.alert('错误', `创建通知失败: ${error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: '目标达成通知',
|
||||||
|
onPress: async () => {
|
||||||
|
try {
|
||||||
|
const userName = userProfile?.name || '';
|
||||||
|
await GoalNotificationHelpers.sendGoalAchievementNotification(userName, '每日运动目标');
|
||||||
|
Alert.alert('成功', '目标达成通知已发送');
|
||||||
|
} catch (error) {
|
||||||
|
Alert.alert('错误', `发送通知失败: ${error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: '测试庆祝动画',
|
||||||
|
onPress: () => {
|
||||||
|
celebrationAnimationRef.current?.play();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={styles.testButtonText}>测试通知</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 庆祝动画组件 */}
|
||||||
|
<CelebrationAnimation ref={celebrationAnimationRef} />
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
gradientBackground: {
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
opacity: 0.6,
|
||||||
|
},
|
||||||
|
decorativeCircle1: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: -20,
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingTop: 20,
|
||||||
|
paddingBottom: 16,
|
||||||
|
},
|
||||||
|
headerButtons: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
goalsButton: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 4,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 8,
|
||||||
|
borderRadius: 20,
|
||||||
|
backgroundColor: 'rgba(255, 255, 255, 0.8)',
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 4,
|
||||||
|
elevation: 3,
|
||||||
|
},
|
||||||
|
pageTitle: {
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: '800',
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
pageTitle2: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '400',
|
||||||
|
color: '#FFFFFF',
|
||||||
|
lineHeight: 24,
|
||||||
|
},
|
||||||
|
addButton: {
|
||||||
|
width: 30,
|
||||||
|
height: 30,
|
||||||
|
borderRadius: 20,
|
||||||
|
backgroundColor: '#0EA5E9',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 4,
|
||||||
|
elevation: 3,
|
||||||
|
},
|
||||||
|
addButtonText: {
|
||||||
|
color: '#FFFFFF',
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: '600',
|
||||||
|
lineHeight: 22,
|
||||||
|
},
|
||||||
|
|
||||||
|
taskListContainer: {
|
||||||
|
flex: 1,
|
||||||
|
borderTopLeftRadius: 24,
|
||||||
|
borderTopRightRadius: 24,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
taskList: {
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingTop: 20,
|
||||||
|
paddingBottom: TAB_BAR_HEIGHT + TAB_BAR_BOTTOM_OFFSET + 20, // 避让底部导航栏 + 额外间距
|
||||||
|
},
|
||||||
|
emptyState: {
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingVertical: 60,
|
||||||
|
},
|
||||||
|
emptyStateImage: {
|
||||||
|
width: 223,
|
||||||
|
height: 59,
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
emptyStateTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '600',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
emptyStateSubtitle: {
|
||||||
|
fontSize: 14,
|
||||||
|
textAlign: 'center',
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
// 未登录空状态样式
|
||||||
|
emptyStateLogin: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
paddingVertical: 80,
|
||||||
|
position: 'relative',
|
||||||
|
},
|
||||||
|
emptyStateLoginBackground: {
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
borderRadius: 24,
|
||||||
|
},
|
||||||
|
emptyStateLoginContent: {
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
zIndex: 1,
|
||||||
|
},
|
||||||
|
emptyStateLoginIconContainer: {
|
||||||
|
marginBottom: 24,
|
||||||
|
shadowColor: '#7A5AF8',
|
||||||
|
shadowOffset: { width: 0, height: 8 },
|
||||||
|
shadowOpacity: 0.15,
|
||||||
|
shadowRadius: 16,
|
||||||
|
elevation: 8,
|
||||||
|
},
|
||||||
|
emptyStateLoginIconGradient: {
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
borderRadius: 40,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
emptyStateLoginTitle: {
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: '700',
|
||||||
|
marginBottom: 12,
|
||||||
|
textAlign: 'center',
|
||||||
|
letterSpacing: -0.5,
|
||||||
|
},
|
||||||
|
emptyStateLoginSubtitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
lineHeight: 24,
|
||||||
|
textAlign: 'center',
|
||||||
|
marginBottom: 32,
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
},
|
||||||
|
emptyStateLoginButton: {
|
||||||
|
borderRadius: 28,
|
||||||
|
shadowColor: '#7A5AF8',
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowOpacity: 0.2,
|
||||||
|
shadowRadius: 12,
|
||||||
|
elevation: 6,
|
||||||
|
},
|
||||||
|
emptyStateLoginButtonGradient: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingHorizontal: 32,
|
||||||
|
paddingVertical: 16,
|
||||||
|
borderRadius: 28,
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
emptyStateLoginButtonText: {
|
||||||
|
color: '#FFFFFF',
|
||||||
|
fontSize: 17,
|
||||||
|
fontWeight: '600',
|
||||||
|
letterSpacing: -0.2,
|
||||||
|
},
|
||||||
|
loadMoreContainer: {
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: 20,
|
||||||
|
},
|
||||||
|
loadMoreText: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
bottomRightImage: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 40,
|
||||||
|
right: 36,
|
||||||
|
width: 120,
|
||||||
|
height: 120,
|
||||||
|
},
|
||||||
|
// 任务进度卡片中的按钮样式
|
||||||
|
cardHeaderButtons: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
cardGoalsButton: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 4,
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 6,
|
||||||
|
borderRadius: 16,
|
||||||
|
backgroundColor: '#F3F4F6',
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
cardGoalsListButton: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 4,
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 6,
|
||||||
|
borderRadius: 16,
|
||||||
|
backgroundColor: '#F3F4F6',
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
cardGoalsButtonText: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#374151',
|
||||||
|
},
|
||||||
|
cardAddButton: {
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
borderRadius: 14,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
cardAddButtonText: {
|
||||||
|
color: '#FFFFFF',
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '600',
|
||||||
|
lineHeight: 18,
|
||||||
|
},
|
||||||
|
testButton: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 100,
|
||||||
|
right: 20,
|
||||||
|
backgroundColor: '#10B981',
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 8,
|
||||||
|
borderRadius: 8,
|
||||||
|
zIndex: 1000,
|
||||||
|
},
|
||||||
|
testButtonText: {
|
||||||
|
color: '#FFFFFF',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
import { PlanCard } from '@/components/PlanCard';
|
|
||||||
import { SearchBox } from '@/components/SearchBox';
|
|
||||||
import { ThemedText } from '@/components/ThemedText';
|
|
||||||
import { ThemedView } from '@/components/ThemedView';
|
|
||||||
import { WorkoutCard } from '@/components/WorkoutCard';
|
|
||||||
import { getChineseGreeting } from '@/utils/date';
|
|
||||||
import { useRouter } from 'expo-router';
|
|
||||||
import React from 'react';
|
|
||||||
import { SafeAreaView, ScrollView, StyleSheet, View } from 'react-native';
|
|
||||||
|
|
||||||
const workoutData = [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
title: 'AI体态评估',
|
|
||||||
duration: 5,
|
|
||||||
imageSource: 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/Imagettpg.png',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
title: '认证教练',
|
|
||||||
imageSource: require('@/assets/images/react-logo.png'),
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function HomeScreen() {
|
|
||||||
const router = useRouter();
|
|
||||||
return (
|
|
||||||
<SafeAreaView style={styles.safeArea}>
|
|
||||||
<ThemedView style={styles.container}>
|
|
||||||
<ScrollView showsVerticalScrollIndicator={false}>
|
|
||||||
{/* Header Section */}
|
|
||||||
<View style={styles.header}>
|
|
||||||
<ThemedText style={styles.greeting}>{getChineseGreeting()} 🔥</ThemedText>
|
|
||||||
<ThemedText style={styles.userName}>新学员,欢迎你</ThemedText>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Search Box */}
|
|
||||||
<SearchBox placeholder="搜索" />
|
|
||||||
|
|
||||||
{/* Popular Workouts Section */}
|
|
||||||
<View style={styles.sectionContainer}>
|
|
||||||
<ThemedText style={styles.sectionTitle}>热门活动</ThemedText>
|
|
||||||
|
|
||||||
<ScrollView
|
|
||||||
horizontal
|
|
||||||
showsHorizontalScrollIndicator={false}
|
|
||||||
contentContainerStyle={styles.workoutScrollContainer}
|
|
||||||
style={styles.workoutScroll}
|
|
||||||
>
|
|
||||||
{workoutData.map((workout) => (
|
|
||||||
<WorkoutCard
|
|
||||||
key={workout.id}
|
|
||||||
title={workout.title}
|
|
||||||
duration={workout.duration}
|
|
||||||
imageSource={workout.imageSource}
|
|
||||||
onPress={() => {
|
|
||||||
if (workout.title === 'AI体态评估') {
|
|
||||||
router.push('/ai-posture-assessment');
|
|
||||||
} else if (workout.title === '认证教练') {
|
|
||||||
router.push('/health-consultation' as any);
|
|
||||||
} else {
|
|
||||||
console.log(`Pressed ${workout.title}`);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</ScrollView>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Today Plan Section */}
|
|
||||||
<View style={styles.sectionContainer}>
|
|
||||||
<ThemedText style={styles.sectionTitle}>为你推荐</ThemedText>
|
|
||||||
|
|
||||||
<View style={styles.planList}>
|
|
||||||
<PlanCard
|
|
||||||
image={'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/Imagettpg.png'}
|
|
||||||
title="体态评估"
|
|
||||||
subtitle="评估你的体态,制定训练计划"
|
|
||||||
level="初学者"
|
|
||||||
progress={0}
|
|
||||||
/>
|
|
||||||
<PlanCard
|
|
||||||
image={'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/Image30play@2x.png'}
|
|
||||||
title="30日训练打卡"
|
|
||||||
subtitle="坚持30天,养成训练习惯"
|
|
||||||
level="初学者"
|
|
||||||
progress={0.75}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{/* Add some spacing at the bottom */}
|
|
||||||
<View style={styles.bottomSpacing} />
|
|
||||||
</ScrollView>
|
|
||||||
</ThemedView>
|
|
||||||
</SafeAreaView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
safeArea: {
|
|
||||||
flex: 1,
|
|
||||||
backgroundColor: '#F7F8FA',
|
|
||||||
},
|
|
||||||
container: {
|
|
||||||
flex: 1,
|
|
||||||
backgroundColor: '#F7F8FA',
|
|
||||||
},
|
|
||||||
header: {
|
|
||||||
paddingHorizontal: 24,
|
|
||||||
paddingTop: 16,
|
|
||||||
paddingBottom: 8,
|
|
||||||
},
|
|
||||||
greeting: {
|
|
||||||
fontSize: 16,
|
|
||||||
color: '#8A8A8E',
|
|
||||||
fontWeight: '400',
|
|
||||||
marginBottom: 6,
|
|
||||||
},
|
|
||||||
userName: {
|
|
||||||
fontSize: 30,
|
|
||||||
fontWeight: 'bold',
|
|
||||||
color: '#1A1A1A',
|
|
||||||
lineHeight: 36,
|
|
||||||
},
|
|
||||||
sectionContainer: {
|
|
||||||
marginTop: 24,
|
|
||||||
},
|
|
||||||
sectionTitle: {
|
|
||||||
fontSize: 24,
|
|
||||||
fontWeight: 'bold',
|
|
||||||
color: '#1A1A1A',
|
|
||||||
paddingHorizontal: 24,
|
|
||||||
marginBottom: 18,
|
|
||||||
},
|
|
||||||
planList: {
|
|
||||||
paddingHorizontal: 24,
|
|
||||||
},
|
|
||||||
workoutScroll: {
|
|
||||||
paddingLeft: 24,
|
|
||||||
},
|
|
||||||
workoutScrollContainer: {
|
|
||||||
paddingRight: 24,
|
|
||||||
},
|
|
||||||
bottomSpacing: {
|
|
||||||
height: 120,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,268 +1,398 @@
|
|||||||
import { Colors } from '@/constants/Colors';
|
import ActivityHeatMap from '@/components/ActivityHeatMap';
|
||||||
|
import { PRIVACY_POLICY_URL, USER_AGREEMENT_URL } from '@/constants/Agree';
|
||||||
|
import { ROUTES } from '@/constants/Routes';
|
||||||
import { getTabBarBottomPadding } from '@/constants/TabBar';
|
import { getTabBarBottomPadding } from '@/constants/TabBar';
|
||||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||||
|
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||||
|
import { useNotifications } from '@/hooks/useNotifications';
|
||||||
|
import { DEFAULT_MEMBER_NAME, fetchActivityHistory, fetchMyProfile } from '@/store/userSlice';
|
||||||
|
import { getItem, setItem } from '@/utils/kvStore';
|
||||||
|
import { log } from '@/utils/logger';
|
||||||
|
import { getNotificationEnabled, setNotificationEnabled as saveNotificationEnabled } from '@/utils/userPreferences';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
import { useFocusEffect } from '@react-navigation/native';
|
||||||
import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
|
import { isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||||
import React, { useMemo, useState } from 'react';
|
import { Image } from 'expo-image';
|
||||||
import {
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
Alert,
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
SafeAreaView,
|
import { Alert, Linking, ScrollView, StatusBar, StyleSheet, Switch, Text, TouchableOpacity, View } from 'react-native';
|
||||||
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';
|
||||||
|
|
||||||
|
const DEFAULT_AVATAR_URL = 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/seal-avatar/2.jpeg';
|
||||||
|
|
||||||
export default function PersonalScreen() {
|
export default function PersonalScreen() {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const { confirmLogout, confirmDeleteAccount, isLoggedIn, pushIfAuthedElseLogin } = useAuthGuard();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const tabBarHeight = useBottomTabBarHeight();
|
|
||||||
|
const isLgAvaliable = isLiquidGlassAvailable()
|
||||||
|
|
||||||
|
// 推送通知相关
|
||||||
|
const {
|
||||||
|
requestPermission,
|
||||||
|
sendNotification,
|
||||||
|
} = useNotifications();
|
||||||
|
|
||||||
|
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(() => {
|
||||||
// 统一的页面底部留白:TabBar 高度 + TabBar 与底部的额外间距 + 安全区底部
|
return getTabBarBottomPadding(60) + (insets?.bottom ?? 0);
|
||||||
return getTabBarBottomPadding(tabBarHeight) + (insets?.bottom ?? 0);
|
}, [insets?.bottom]);
|
||||||
}, [tabBarHeight, insets?.bottom]);
|
|
||||||
const [notificationEnabled, setNotificationEnabled] = useState(true);
|
|
||||||
const colorScheme = useColorScheme();
|
|
||||||
const colors = Colors[colorScheme ?? 'light'];
|
|
||||||
|
|
||||||
const handleResetOnboarding = () => {
|
// 直接使用 Redux 中的用户信息,避免重复状态管理
|
||||||
Alert.alert(
|
const userProfile = useAppSelector((state) => state.user.profile);
|
||||||
'重置引导',
|
|
||||||
'确定要重置引导流程吗?下次启动应用时将重新显示引导页面。',
|
|
||||||
[
|
// 页面聚焦时获取最新用户信息
|
||||||
{
|
useFocusEffect(
|
||||||
text: '取消',
|
React.useCallback(() => {
|
||||||
style: 'cancel',
|
dispatch(fetchMyProfile());
|
||||||
},
|
dispatch(fetchActivityHistory());
|
||||||
{
|
// 加载用户推送偏好设置
|
||||||
text: '确定',
|
loadNotificationPreference();
|
||||||
style: 'destructive',
|
// 加载开发者模式状态
|
||||||
onPress: async () => {
|
loadDeveloperModeState();
|
||||||
try {
|
}, [dispatch])
|
||||||
await AsyncStorage.multiRemove(['@onboarding_completed', '@user_personal_info']);
|
);
|
||||||
Alert.alert('成功', '引导状态已重置,请重启应用查看效果。');
|
|
||||||
} catch (error) {
|
// 加载用户推送偏好设置
|
||||||
console.error('重置引导状态失败:', error);
|
const loadNotificationPreference = async () => {
|
||||||
Alert.alert('错误', '重置失败,请稍后重试。');
|
try {
|
||||||
}
|
const enabled = await getNotificationEnabled();
|
||||||
},
|
setNotificationEnabled(enabled);
|
||||||
},
|
} catch (error) {
|
||||||
]
|
console.error('加载推送偏好设置失败:', error);
|
||||||
);
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 加载开发者模式状态
|
||||||
|
const loadDeveloperModeState = async () => {
|
||||||
|
try {
|
||||||
|
const enabled = await getItem('developer_mode_enabled');
|
||||||
|
if (enabled === 'true') {
|
||||||
|
setShowDeveloperSection(true);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载开发者模式状态失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const UserInfoSection = () => (
|
// 保存开发者模式状态
|
||||||
<View style={styles.userInfoCard}>
|
const saveDeveloperModeState = async (enabled: boolean) => {
|
||||||
<View style={styles.userInfoContainer}>
|
try {
|
||||||
{/* 头像 */}
|
await setItem('developer_mode_enabled', enabled.toString());
|
||||||
|
} catch (error) {
|
||||||
|
console.error('保存开发者模式状态失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 数据格式化函数
|
||||||
|
const formatHeight = () => {
|
||||||
|
if (userProfile.height == null) return '--';
|
||||||
|
return `${parseFloat(userProfile.height).toFixed(1)}cm`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatWeight = () => {
|
||||||
|
if (userProfile.weight == null) return '--';
|
||||||
|
return `${parseFloat(userProfile.weight).toFixed(1)}kg`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatAge = () => {
|
||||||
|
if (!userProfile.birthDate) return '--';
|
||||||
|
const birthDate = new Date(userProfile.birthDate);
|
||||||
|
const today = new Date();
|
||||||
|
const age = today.getFullYear() - birthDate.getFullYear();
|
||||||
|
return `${age}岁`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 显示名称
|
||||||
|
const displayName = (userProfile.name?.trim()) ? userProfile.name : DEFAULT_MEMBER_NAME;
|
||||||
|
|
||||||
|
// 初始化时加载推送偏好设置和开发者模式状态
|
||||||
|
useEffect(() => {
|
||||||
|
loadNotificationPreference();
|
||||||
|
loadDeveloperModeState();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 处理用户名连续点击
|
||||||
|
const handleUserNamePress = () => {
|
||||||
|
const now = Date.now();
|
||||||
|
clickTimestamps.current.push(now);
|
||||||
|
|
||||||
|
// 清除之前的超时
|
||||||
|
if (clickTimeoutRef.current) {
|
||||||
|
clearTimeout(clickTimeoutRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只保留最近1秒内的点击
|
||||||
|
clickTimestamps.current = clickTimestamps.current.filter(timestamp => now - timestamp <= 1000);
|
||||||
|
|
||||||
|
// 检查是否有3次连续点击
|
||||||
|
if (clickTimestamps.current.length >= 3) {
|
||||||
|
setShowDeveloperSection(true);
|
||||||
|
saveDeveloperModeState(true); // 持久化保存开发者模式状态
|
||||||
|
clickTimestamps.current = []; // 清空点击记录
|
||||||
|
log.info('开发者模式已激活');
|
||||||
|
} else {
|
||||||
|
// 1秒后清空点击记录
|
||||||
|
clickTimeoutRef.current = setTimeout(() => {
|
||||||
|
clickTimestamps.current = [];
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理通知开关变化
|
||||||
|
const handleNotificationToggle = async (value: boolean) => {
|
||||||
|
if (value) {
|
||||||
|
try {
|
||||||
|
// 先检查系统权限
|
||||||
|
const status = await requestPermission();
|
||||||
|
if (status === 'granted') {
|
||||||
|
// 系统权限获取成功,保存用户偏好设置
|
||||||
|
await saveNotificationEnabled(true);
|
||||||
|
setNotificationEnabled(true);
|
||||||
|
|
||||||
|
// 发送测试通知
|
||||||
|
await sendNotification({
|
||||||
|
title: '通知已开启',
|
||||||
|
body: '您将收到运动提醒和重要通知',
|
||||||
|
sound: true,
|
||||||
|
priority: 'normal',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 系统权限被拒绝,不更新用户偏好设置
|
||||||
|
Alert.alert(
|
||||||
|
'权限被拒绝',
|
||||||
|
'请在系统设置中开启通知权限,然后再尝试开启推送功能',
|
||||||
|
[
|
||||||
|
{ text: '取消', style: 'cancel' },
|
||||||
|
{ text: '去设置', onPress: () => Linking.openSettings() }
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('开启推送通知失败:', error);
|
||||||
|
Alert.alert('错误', '请求通知权限失败');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
// 关闭推送,保存用户偏好设置
|
||||||
|
await saveNotificationEnabled(false);
|
||||||
|
setNotificationEnabled(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('关闭推送通知失败:', error);
|
||||||
|
Alert.alert('错误', '保存设置失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 用户信息头部
|
||||||
|
const UserHeader = () => (
|
||||||
|
<View style={[styles.sectionContainer, {
|
||||||
|
marginBottom: 0
|
||||||
|
}]}>
|
||||||
|
|
||||||
|
<View style={[styles.userInfoContainer,]}>
|
||||||
<View style={styles.avatarContainer}>
|
<View style={styles.avatarContainer}>
|
||||||
<View style={styles.avatar}>
|
<Image
|
||||||
<View style={styles.avatarContent}>
|
source={userProfile.avatar || DEFAULT_AVATAR_URL}
|
||||||
{/* 简单的头像图标,您可以替换为实际图片 */}
|
style={styles.avatar}
|
||||||
<View style={styles.avatarIcon}>
|
contentFit="cover"
|
||||||
<View style={styles.avatarFace} />
|
transition={200}
|
||||||
<View style={styles.avatarBody} />
|
cachePolicy="memory-disk"
|
||||||
</View>
|
/>
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* 用户信息 */}
|
|
||||||
<View style={styles.userDetails}>
|
<View style={styles.userDetails}>
|
||||||
<Text style={styles.userName}>Masi Ramezanzade</Text>
|
<TouchableOpacity onPress={handleUserNamePress} activeOpacity={0.7}>
|
||||||
<Text style={styles.userProgram}>Lose a Fat Program</Text>
|
<Text style={styles.userName}>{displayName}</Text>
|
||||||
</View>
|
</TouchableOpacity>
|
||||||
|
{userProfile.memberNumber && (
|
||||||
{/* 编辑按钮 */}
|
<Text style={styles.userMemberNumber}>会员编号: {userProfile.memberNumber}</Text>
|
||||||
<TouchableOpacity style={dynamicStyles.editButton}>
|
|
||||||
<Text style={dynamicStyles.editButtonText}>Edit</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
const StatsSection = () => (
|
|
||||||
<View style={styles.statsContainer}>
|
|
||||||
<View style={styles.statItem}>
|
|
||||||
<Text style={dynamicStyles.statValue}>180cm</Text>
|
|
||||||
<Text style={styles.statLabel}>Height</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.statItem}>
|
|
||||||
<Text style={dynamicStyles.statValue}>65kg</Text>
|
|
||||||
<Text style={styles.statLabel}>Weight</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.statItem}>
|
|
||||||
<Text style={dynamicStyles.statValue}>22yo</Text>
|
|
||||||
<Text style={styles.statLabel}>Age</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
const MenuSection = ({ title, items }: { title: string; items: any[] }) => (
|
|
||||||
<View style={styles.menuSection}>
|
|
||||||
<Text style={styles.sectionTitle}>{title}</Text>
|
|
||||||
{items.map((item, index) => (
|
|
||||||
<TouchableOpacity
|
|
||||||
key={index}
|
|
||||||
style={styles.menuItem}
|
|
||||||
onPress={item.onPress}
|
|
||||||
>
|
|
||||||
<View style={styles.menuItemLeft}>
|
|
||||||
<View style={[styles.menuIcon]}>
|
|
||||||
<Ionicons name={item.icon} size={20} color={item.iconColor || colors.primary} />
|
|
||||||
</View>
|
|
||||||
<Text style={styles.menuItemText}>{item.title}</Text>
|
|
||||||
</View>
|
|
||||||
{item.type === 'switch' ? (
|
|
||||||
<Switch
|
|
||||||
value={notificationEnabled}
|
|
||||||
onValueChange={setNotificationEnabled}
|
|
||||||
trackColor={{ false: '#E5E5E5', true: colors.primary }}
|
|
||||||
thumbColor="#FFFFFF"
|
|
||||||
style={styles.switch}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Ionicons name="chevron-forward" size={20} color="#C4C4C4" />
|
|
||||||
)}
|
)}
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity style={styles.editButton} onPress={() => pushIfAuthedElseLogin('/profile/edit')}>
|
||||||
|
<Text style={styles.editButtonText}>{isLoggedIn ? '编辑' : '登录'}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
))}
|
|
||||||
|
</View>
|
||||||
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
// 动态创建样式
|
// 数据统计部分
|
||||||
const dynamicStyles = {
|
const StatsSection = () => (
|
||||||
editButton: {
|
<View style={styles.sectionContainer}>
|
||||||
backgroundColor: colors.primary,
|
<View style={[styles.cardContainer, {
|
||||||
paddingHorizontal: 20,
|
backgroundColor: 'unset'
|
||||||
paddingVertical: 10,
|
}]}>
|
||||||
borderRadius: 20,
|
<View style={styles.statsContainer}>
|
||||||
},
|
<View style={styles.statItem}>
|
||||||
editButtonText: {
|
<Text style={styles.statValue}>{formatHeight()}</Text>
|
||||||
color: '#192126',
|
<Text style={styles.statLabel}>身高</Text>
|
||||||
fontSize: 14,
|
</View>
|
||||||
fontWeight: '600' as const,
|
<View style={styles.statItem}>
|
||||||
},
|
<Text style={styles.statValue}>{formatWeight()}</Text>
|
||||||
statValue: {
|
<Text style={styles.statLabel}>体重</Text>
|
||||||
fontSize: 18,
|
</View>
|
||||||
fontWeight: 'bold' as const,
|
<View style={styles.statItem}>
|
||||||
color: colors.primary,
|
<Text style={styles.statValue}>{formatAge()}</Text>
|
||||||
marginBottom: 4,
|
<Text style={styles.statLabel}>年龄</Text>
|
||||||
},
|
</View>
|
||||||
floatingButton: {
|
</View>
|
||||||
width: 56,
|
</View>
|
||||||
height: 56,
|
</View>
|
||||||
borderRadius: 28,
|
);
|
||||||
backgroundColor: colors.primary,
|
|
||||||
alignItems: 'center' as const,
|
|
||||||
justifyContent: 'center' as const,
|
|
||||||
shadowColor: colors.primary,
|
|
||||||
shadowOffset: {
|
|
||||||
width: 0,
|
|
||||||
height: 4,
|
|
||||||
},
|
|
||||||
shadowOpacity: 0.3,
|
|
||||||
shadowRadius: 8,
|
|
||||||
elevation: 8,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const accountItems = [
|
// 菜单项组件
|
||||||
{
|
const MenuSection = ({ title, items }: { title: string; items: any[] }) => (
|
||||||
icon: 'person-outline',
|
<View style={styles.sectionContainer}>
|
||||||
iconBg: '#E8F5E8',
|
<Text style={styles.sectionTitle}>{title}</Text>
|
||||||
iconColor: '#4ADE80',
|
<View style={styles.cardContainer}>
|
||||||
title: 'Personal Data',
|
{items.map((item, index) => (
|
||||||
},
|
<TouchableOpacity
|
||||||
{
|
key={index}
|
||||||
icon: 'trophy-outline',
|
style={[styles.menuItem, index === items.length - 1 && { borderBottomWidth: 0 }]}
|
||||||
iconBg: '#E8F5E8',
|
onPress={item.type === 'switch' ? undefined : item.onPress}
|
||||||
iconColor: '#4ADE80',
|
disabled={item.type === 'switch'}
|
||||||
title: 'Achievement',
|
>
|
||||||
},
|
<View style={styles.menuItemLeft}>
|
||||||
{
|
<View style={[
|
||||||
icon: 'time-outline',
|
styles.iconContainer,
|
||||||
iconBg: '#E8F5E8',
|
{ backgroundColor: item.isDanger ? 'rgba(255,68,68,0.1)' : 'rgba(147, 112, 219, 0.1)' }
|
||||||
iconColor: '#4ADE80',
|
]}>
|
||||||
title: 'Activity History',
|
<Ionicons
|
||||||
},
|
name={item.icon}
|
||||||
{
|
size={20}
|
||||||
icon: 'stats-chart-outline',
|
color={item.isDanger ? '#FF4444' : '#9370DB'}
|
||||||
iconBg: '#E8F5E8',
|
/>
|
||||||
iconColor: '#4ADE80',
|
</View>
|
||||||
title: 'Workout Progress',
|
<Text style={styles.menuItemText}>{item.title}</Text>
|
||||||
},
|
</View>
|
||||||
];
|
{item.type === 'switch' ? (
|
||||||
|
<Switch
|
||||||
|
value={item.switchValue || false}
|
||||||
|
onValueChange={item.onSwitchChange || (() => { })}
|
||||||
|
trackColor={{ false: '#E5E5E5', true: '#9370DB' }}
|
||||||
|
thumbColor="#FFFFFF"
|
||||||
|
style={styles.switch}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Ionicons name="chevron-forward" size={20} color="#CCCCCC" />
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
const notificationItems = [
|
// 菜单项配置
|
||||||
|
const menuSections = [
|
||||||
{
|
{
|
||||||
icon: 'notifications-outline',
|
title: '通知',
|
||||||
iconBg: '#E8F5E8',
|
items: [
|
||||||
iconColor: '#4ADE80',
|
{
|
||||||
title: 'Pop-up Notification',
|
icon: 'notifications-outline' as const,
|
||||||
type: 'switch',
|
title: '消息推送',
|
||||||
|
type: 'switch' as const,
|
||||||
|
switchValue: notificationEnabled,
|
||||||
|
onSwitchChange: handleNotificationToggle,
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
];
|
// 开发者section(需要连续点击三次用户名激活)
|
||||||
|
...(showDeveloperSection ? [{
|
||||||
const otherItems = [
|
title: '开发者',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
icon: 'code-slash-outline' as const,
|
||||||
|
title: '开发者选项',
|
||||||
|
onPress: () => pushIfAuthedElseLogin(ROUTES.DEVELOPER),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}] : []),
|
||||||
{
|
{
|
||||||
icon: 'mail-outline',
|
title: '其他',
|
||||||
iconBg: '#E8F5E8',
|
items: [
|
||||||
iconColor: '#4ADE80',
|
{
|
||||||
title: 'Contact Us',
|
icon: 'shield-checkmark-outline' as const,
|
||||||
},
|
title: '隐私政策',
|
||||||
{
|
onPress: () => Linking.openURL(PRIVACY_POLICY_URL),
|
||||||
icon: 'shield-checkmark-outline',
|
},
|
||||||
iconBg: '#E8F5E8',
|
{
|
||||||
iconColor: '#4ADE80',
|
icon: 'document-text-outline' as const,
|
||||||
title: 'Privacy Policy',
|
title: '用户协议',
|
||||||
},
|
onPress: () => Linking.openURL(USER_AGREEMENT_URL),
|
||||||
{
|
},
|
||||||
icon: 'settings-outline',
|
],
|
||||||
iconBg: '#E8F5E8',
|
|
||||||
iconColor: '#4ADE80',
|
|
||||||
title: 'Settings',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const developerItems = [
|
|
||||||
{
|
|
||||||
icon: 'refresh-outline',
|
|
||||||
iconBg: '#FFE8E8',
|
|
||||||
iconColor: '#FF4444',
|
|
||||||
title: '重置引导流程',
|
|
||||||
onPress: handleResetOnboarding,
|
|
||||||
},
|
},
|
||||||
|
// 只有登录用户才显示账号与安全菜单
|
||||||
|
...(isLoggedIn ? [{
|
||||||
|
title: '账号与安全',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
icon: 'log-out-outline' as const,
|
||||||
|
title: '退出登录',
|
||||||
|
onPress: confirmLogout,
|
||||||
|
isDanger: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'trash-outline' as const,
|
||||||
|
title: '注销帐号',
|
||||||
|
onPress: confirmDeleteAccount,
|
||||||
|
isDanger: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}] : []),
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<StatusBar barStyle="dark-content" backgroundColor="transparent" translucent />
|
<StatusBar barStyle={'dark-content'} backgroundColor="transparent" translucent />
|
||||||
<SafeAreaView style={styles.safeArea}>
|
|
||||||
<ScrollView
|
|
||||||
style={styles.scrollView}
|
|
||||||
contentContainerStyle={{ paddingBottom: bottomPadding }}
|
|
||||||
showsVerticalScrollIndicator={false}
|
|
||||||
>
|
|
||||||
<UserInfoSection />
|
|
||||||
<StatsSection />
|
|
||||||
<MenuSection title="Account" items={accountItems} />
|
|
||||||
<MenuSection title="Notification" items={notificationItems} />
|
|
||||||
<MenuSection title="Other" items={otherItems} />
|
|
||||||
<MenuSection title="Developer" items={developerItems} />
|
|
||||||
|
|
||||||
{/* 底部浮动按钮 */}
|
{/* 背景渐变 */}
|
||||||
<View style={[styles.floatingButtonContainer, { bottom: Math.max(30, tabBarHeight / 2) + (insets?.bottom ?? 0) }]}>
|
<LinearGradient
|
||||||
<TouchableOpacity style={dynamicStyles.floatingButton}>
|
colors={['#f5e5fbff', '#e5fcfeff', '#eefdffff', '#e6f6fcff']}
|
||||||
<Ionicons name="search" size={24} color="#192126" />
|
style={styles.gradientBackground}
|
||||||
</TouchableOpacity>
|
start={{ x: 0, y: 0 }}
|
||||||
</View>
|
end={{ x: 0, y: 1 }}
|
||||||
</ScrollView>
|
/>
|
||||||
</SafeAreaView>
|
|
||||||
|
{/* 装饰性圆圈 */}
|
||||||
|
<View style={styles.decorativeCircle1} />
|
||||||
|
<View style={styles.decorativeCircle2} />
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
style={styles.scrollView}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingTop: insets.top,
|
||||||
|
paddingBottom: bottomPadding,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
}}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
<UserHeader />
|
||||||
|
<StatsSection />
|
||||||
|
<View style={styles.fishRecordContainer}>
|
||||||
|
{/* <Image
|
||||||
|
source={{ uri: 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/icons/icon-profile-fish.png' }}
|
||||||
|
contentFit="cover"
|
||||||
|
style={{ width: 16, height: 16, marginLeft: 6 }}
|
||||||
|
transition={200}
|
||||||
|
cachePolicy="memory-disk"
|
||||||
|
/> */}
|
||||||
|
<Text style={styles.fishRecordText}>能量记录</Text>
|
||||||
|
</View>
|
||||||
|
<ActivityHeatMap />
|
||||||
|
{menuSections.map((section, index) => (
|
||||||
|
<MenuSection key={index} title={section.title} items={section.items} />
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -270,60 +400,74 @@ export default function PersonalScreen() {
|
|||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: '#F5F5F5', // 浅灰色背景
|
|
||||||
},
|
},
|
||||||
safeArea: {
|
gradientBackground: {
|
||||||
flex: 1,
|
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,
|
||||||
},
|
},
|
||||||
scrollView: {
|
scrollView: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
paddingHorizontal: 20,
|
|
||||||
backgroundColor: '#F5F5F5',
|
|
||||||
},
|
},
|
||||||
// 用户信息区域
|
// 部分容器
|
||||||
userInfoCard: {
|
sectionContainer: {
|
||||||
borderRadius: 16,
|
|
||||||
marginBottom: 20,
|
marginBottom: 20,
|
||||||
},
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#2C3E50',
|
||||||
|
marginBottom: 10,
|
||||||
|
paddingHorizontal: 4,
|
||||||
|
},
|
||||||
|
// 卡片容器
|
||||||
|
cardContainer: {
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderRadius: 12,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 1 },
|
||||||
|
shadowOpacity: 0.05,
|
||||||
|
shadowRadius: 4,
|
||||||
|
elevation: 2,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
// 用户信息区域
|
||||||
userInfoContainer: {
|
userInfoContainer: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
padding: 20,
|
padding: 16,
|
||||||
},
|
},
|
||||||
avatarContainer: {
|
avatarContainer: {
|
||||||
marginRight: 15,
|
marginRight: 12,
|
||||||
},
|
},
|
||||||
avatar: {
|
avatar: {
|
||||||
width: 80,
|
width: 60,
|
||||||
height: 80,
|
height: 60,
|
||||||
borderRadius: 40,
|
borderRadius: 30,
|
||||||
backgroundColor: '#E8D4F0',
|
borderWidth: 2,
|
||||||
alignItems: 'center',
|
borderColor: '#9370DB',
|
||||||
justifyContent: 'center',
|
|
||||||
overflow: 'hidden',
|
|
||||||
},
|
|
||||||
avatarContent: {
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
},
|
|
||||||
avatarIcon: {
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
},
|
|
||||||
avatarFace: {
|
|
||||||
width: 25,
|
|
||||||
height: 25,
|
|
||||||
borderRadius: 12.5,
|
|
||||||
backgroundColor: '#D4A574',
|
|
||||||
marginBottom: 5,
|
|
||||||
},
|
|
||||||
avatarBody: {
|
|
||||||
width: 30,
|
|
||||||
height: 20,
|
|
||||||
borderRadius: 15,
|
|
||||||
backgroundColor: '#F4C842',
|
|
||||||
},
|
},
|
||||||
userDetails: {
|
userDetails: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
@@ -331,91 +475,93 @@ const styles = StyleSheet.create({
|
|||||||
userName: {
|
userName: {
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: 'bold',
|
fontWeight: 'bold',
|
||||||
color: '#000',
|
color: '#2C3E50',
|
||||||
marginBottom: 4,
|
marginBottom: 4,
|
||||||
},
|
},
|
||||||
userProgram: {
|
userRole: {
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
color: '#888',
|
color: '#9370DB',
|
||||||
|
fontWeight: '500',
|
||||||
},
|
},
|
||||||
|
userMemberNumber: {
|
||||||
// 统计信息区域
|
fontSize: 10,
|
||||||
|
color: '#6C757D',
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
editButton: {
|
||||||
|
backgroundColor: '#9370DB',
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 8,
|
||||||
|
borderRadius: 16,
|
||||||
|
},
|
||||||
|
editButtonText: {
|
||||||
|
color: '#FFFFFF',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
// 数据统计
|
||||||
statsContainer: {
|
statsContainer: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
backgroundColor: '#FFFFFF',
|
padding: 16,
|
||||||
borderRadius: 16,
|
|
||||||
padding: 20,
|
|
||||||
marginBottom: 20,
|
|
||||||
shadowColor: '#000',
|
|
||||||
shadowOffset: {
|
|
||||||
width: 0,
|
|
||||||
height: 2,
|
|
||||||
},
|
|
||||||
shadowOpacity: 0.1,
|
|
||||||
shadowRadius: 4,
|
|
||||||
elevation: 3,
|
|
||||||
},
|
},
|
||||||
statItem: {
|
statItem: {
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
|
statValue: {
|
||||||
statLabel: {
|
|
||||||
fontSize: 12,
|
|
||||||
color: '#888',
|
|
||||||
},
|
|
||||||
// 菜单区域
|
|
||||||
menuSection: {
|
|
||||||
marginBottom: 20,
|
|
||||||
backgroundColor: '#FFFFFF',
|
|
||||||
padding: 16,
|
|
||||||
},
|
|
||||||
sectionTitle: {
|
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: 'bold',
|
fontWeight: 'bold',
|
||||||
color: '#000',
|
color: '#9370DB',
|
||||||
marginBottom: 12,
|
marginBottom: 4,
|
||||||
paddingHorizontal: 4,
|
|
||||||
},
|
},
|
||||||
|
statLabel: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#6C757D',
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
// 菜单项
|
||||||
menuItem: {
|
menuItem: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
paddingVertical: 16,
|
paddingVertical: 14,
|
||||||
paddingHorizontal: 16,
|
paddingHorizontal: 16,
|
||||||
borderRadius: 12,
|
borderBottomWidth: 1,
|
||||||
marginBottom: 8,
|
borderBottomColor: '#F1F3F4',
|
||||||
},
|
},
|
||||||
menuItemLeft: {
|
menuItemLeft: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
menuIcon: {
|
iconContainer: {
|
||||||
width: 36,
|
width: 32,
|
||||||
height: 36,
|
height: 32,
|
||||||
borderRadius: 8,
|
borderRadius: 6,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
marginRight: 12,
|
marginRight: 12,
|
||||||
},
|
},
|
||||||
menuItemText: {
|
menuItemText: {
|
||||||
fontSize: 16,
|
fontSize: 15,
|
||||||
color: '#000',
|
color: '#2C3E50',
|
||||||
flex: 1,
|
fontWeight: '500',
|
||||||
},
|
},
|
||||||
switch: {
|
switch: {
|
||||||
transform: [{ scaleX: 0.8 }, { scaleY: 0.8 }],
|
transform: [{ scaleX: 0.8 }, { scaleY: 0.8 }],
|
||||||
},
|
},
|
||||||
// 浮动按钮
|
fishRecordContainer: {
|
||||||
floatingButtonContainer: {
|
flexDirection: 'row',
|
||||||
position: 'absolute',
|
|
||||||
bottom: 30,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
pointerEvents: 'box-none',
|
justifyContent: 'flex-start',
|
||||||
|
marginBottom: 10,
|
||||||
|
},
|
||||||
|
fishRecordText: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#2C3E50',
|
||||||
|
marginLeft: 4,
|
||||||
},
|
},
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
855
app/(tabs)/statistics.tsx
Normal file
@@ -0,0 +1,855 @@
|
|||||||
|
import { BasalMetabolismCard } from '@/components/BasalMetabolismCard';
|
||||||
|
import { DateSelector } from '@/components/DateSelector';
|
||||||
|
import { FitnessRingsCard } from '@/components/FitnessRingsCard';
|
||||||
|
import { MoodCard } from '@/components/MoodCard';
|
||||||
|
import { NutritionRadarCard } from '@/components/NutritionRadarCard';
|
||||||
|
import CircumferenceCard from '@/components/statistic/CircumferenceCard';
|
||||||
|
import OxygenSaturationCard from '@/components/statistic/OxygenSaturationCard';
|
||||||
|
import SleepCard from '@/components/statistic/SleepCard';
|
||||||
|
import StepsCard from '@/components/StepsCard';
|
||||||
|
import { StressMeter } from '@/components/StressMeter';
|
||||||
|
import WaterIntakeCard from '@/components/WaterIntakeCard';
|
||||||
|
import { WeightHistoryCard } from '@/components/weight/WeightHistoryCard';
|
||||||
|
import { Colors } from '@/constants/Colors';
|
||||||
|
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||||
|
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||||
|
import { BackgroundTaskManager } from '@/services/backgroundTaskManager';
|
||||||
|
import { setHealthData } from '@/store/healthSlice';
|
||||||
|
import { fetchDailyMoodCheckins, selectLatestMoodRecordByDate } from '@/store/moodSlice';
|
||||||
|
import { fetchTodayWaterStats } from '@/store/waterSlice';
|
||||||
|
import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date';
|
||||||
|
import { fetchHealthDataForDate, testHRVDataFetch } from '@/utils/health';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
|
import { debounce } from 'lodash';
|
||||||
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
AppState,
|
||||||
|
Image,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TouchableOpacity,
|
||||||
|
View
|
||||||
|
} from 'react-native';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
|
// 浮动动画组件
|
||||||
|
const FloatingCard = ({ children, style }: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
style?: any;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
style,
|
||||||
|
{
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ExploreScreen() {
|
||||||
|
const stepGoal = useAppSelector((s) => s.user.profile?.dailyStepsGoal) ?? 2000;
|
||||||
|
|
||||||
|
const { pushIfAuthedElseLogin, isLoggedIn } = useAuthGuard();
|
||||||
|
|
||||||
|
|
||||||
|
// 使用 dayjs:当月日期与默认选中"今天"
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth());
|
||||||
|
// const tabBarHeight = useBottomTabBarHeight();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
// 获取当前选中日期 - 使用 useMemo 缓存避免重复计算
|
||||||
|
const currentSelectedDate = useMemo(() => {
|
||||||
|
const days = getMonthDaysZh();
|
||||||
|
return days[selectedIndex]?.date?.toDate() ?? new Date();
|
||||||
|
}, [selectedIndex]);
|
||||||
|
|
||||||
|
const currentSelectedDateString = useMemo(() => {
|
||||||
|
return dayjs(currentSelectedDate).format('YYYY-MM-DD');
|
||||||
|
}, [currentSelectedDate]);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// 用于触发动画重置的 token(当日期或数据变化时更新)
|
||||||
|
const [animToken, setAnimToken] = useState(0);
|
||||||
|
|
||||||
|
|
||||||
|
// 心情相关状态
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const [isMoodLoading, setIsMoodLoading] = useState(false);
|
||||||
|
|
||||||
|
// 记录最近一次请求的"日期键",避免旧请求覆盖新结果
|
||||||
|
const latestRequestKeyRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
// 请求状态管理,防止重复请求
|
||||||
|
const loadingRef = useRef({
|
||||||
|
health: false,
|
||||||
|
mood: false
|
||||||
|
});
|
||||||
|
|
||||||
|
// 数据缓存时间戳,避免短时间内重复拉取
|
||||||
|
const dataTimestampRef = useRef<{ [key: string]: number }>({});
|
||||||
|
|
||||||
|
const getDateKey = (d: Date) => `${dayjs(d).year()}-${dayjs(d).month() + 1}-${dayjs(d).date()}`;
|
||||||
|
|
||||||
|
// 检查数据是否需要刷新(5分钟内不重复拉取)
|
||||||
|
const shouldRefreshData = (dateKey: string, dataType: string) => {
|
||||||
|
const cacheKey = `${dateKey}-${dataType}`;
|
||||||
|
const lastUpdate = dataTimestampRef.current[cacheKey];
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// 使用5分钟缓存时间
|
||||||
|
const cacheTime = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
return !lastUpdate || (now - lastUpdate) > cacheTime;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 更新数据时间戳
|
||||||
|
const updateDataTimestamp = (dateKey: string, dataType: string) => {
|
||||||
|
const cacheKey = `${dateKey}-${dataType}`;
|
||||||
|
dataTimestampRef.current[cacheKey] = Date.now();
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// 从 Redux 获取当前日期的心情记录
|
||||||
|
const currentMoodCheckin = useAppSelector(selectLatestMoodRecordByDate(
|
||||||
|
currentSelectedDateString
|
||||||
|
));
|
||||||
|
|
||||||
|
// 加载心情数据
|
||||||
|
const loadMoodData = 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.mood) {
|
||||||
|
console.log('心情数据正在加载中,跳过重复请求');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!forceRefresh && !shouldRefreshData(requestKey, 'mood')) {
|
||||||
|
console.log('心情数据缓存未过期,跳过请求');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
loadingRef.current.mood = true;
|
||||||
|
setIsMoodLoading(true);
|
||||||
|
|
||||||
|
const dateString = dayjs(derivedDate).format('YYYY-MM-DD');
|
||||||
|
await dispatch(fetchDailyMoodCheckins(dateString));
|
||||||
|
|
||||||
|
// 更新缓存时间戳
|
||||||
|
updateDataTimestamp(requestKey, 'mood');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载心情数据失败:', error);
|
||||||
|
} finally {
|
||||||
|
loadingRef.current.mood = false;
|
||||||
|
setIsMoodLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const loadHealthData = async (targetDate?: Date, forceRefresh = false) => {
|
||||||
|
// 确定要查询的日期
|
||||||
|
let derivedDate: Date;
|
||||||
|
if (targetDate) {
|
||||||
|
derivedDate = targetDate;
|
||||||
|
} else {
|
||||||
|
derivedDate = currentSelectedDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestKey = getDateKey(derivedDate);
|
||||||
|
|
||||||
|
// 检查是否正在加载或不需要刷新
|
||||||
|
if (loadingRef.current.health) {
|
||||||
|
console.log('健康数据正在加载中,跳过重复请求');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!forceRefresh && !shouldRefreshData(requestKey, 'health')) {
|
||||||
|
console.log('健康数据缓存未过期,跳过请求');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
loadingRef.current.health = true;
|
||||||
|
console.log('=== 开始HealthKit初始化流程 ===');
|
||||||
|
|
||||||
|
latestRequestKeyRef.current = requestKey;
|
||||||
|
|
||||||
|
console.log('权限获取成功,开始获取健康数据...', derivedDate);
|
||||||
|
const data = await fetchHealthDataForDate(derivedDate);
|
||||||
|
|
||||||
|
console.log('设置UI状态:', data);
|
||||||
|
|
||||||
|
// 仅当该请求仍是最新时,才应用结果
|
||||||
|
if (latestRequestKeyRef.current === requestKey) {
|
||||||
|
const dateString = dayjs(derivedDate).format('YYYY-MM-DD');
|
||||||
|
|
||||||
|
// 使用 Redux 存储健康数据
|
||||||
|
dispatch(setHealthData({
|
||||||
|
date: dateString,
|
||||||
|
data: {
|
||||||
|
activeCalories: data.activeEnergyBurned,
|
||||||
|
heartRate: data.heartRate,
|
||||||
|
activeEnergyBurned: data.activeEnergyBurned,
|
||||||
|
activeCaloriesGoal: data.activeCaloriesGoal,
|
||||||
|
exerciseMinutes: data.exerciseMinutes,
|
||||||
|
exerciseMinutesGoal: data.exerciseMinutesGoal,
|
||||||
|
standHours: data.standHours,
|
||||||
|
standHoursGoal: data.standHoursGoal,
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
setAnimToken((t) => t + 1);
|
||||||
|
|
||||||
|
// 更新缓存时间戳
|
||||||
|
updateDataTimestamp(requestKey, 'health');
|
||||||
|
} else {
|
||||||
|
console.log('忽略过期健康数据请求结果,key=', requestKey, '最新key=', latestRequestKeyRef.current);
|
||||||
|
}
|
||||||
|
console.log('=== HealthKit数据获取完成 ===');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('HealthKit流程出现异常:', error);
|
||||||
|
} finally {
|
||||||
|
loadingRef.current.health = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 加载营养数据
|
||||||
|
|
||||||
|
// 实际执行数据加载的方法
|
||||||
|
const executeLoadAllData = React.useCallback((targetDate?: Date, forceRefresh = false) => {
|
||||||
|
const dateToUse = targetDate || currentSelectedDate;
|
||||||
|
if (dateToUse) {
|
||||||
|
console.log('执行数据加载,日期:', dateToUse, '强制刷新:', forceRefresh);
|
||||||
|
loadHealthData(dateToUse, forceRefresh);
|
||||||
|
if (isLoggedIn) {
|
||||||
|
loadMoodData(dateToUse, forceRefresh);
|
||||||
|
// 加载喝水数据(只加载今日数据用于后台检查)
|
||||||
|
const isToday = dayjs(dateToUse).isSame(dayjs(), 'day');
|
||||||
|
if (isToday) {
|
||||||
|
dispatch(fetchTodayWaterStats());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isLoggedIn, dispatch]);
|
||||||
|
|
||||||
|
// 使用 lodash debounce 防抖的加载所有数据方法
|
||||||
|
const debouncedLoadAllData = React.useMemo(
|
||||||
|
() => debounce(executeLoadAllData, 500), // 500ms 防抖延迟
|
||||||
|
[executeLoadAllData]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 对外暴露的 loadAllData 方法
|
||||||
|
const loadAllData = React.useCallback((targetDate?: Date, forceRefresh = false) => {
|
||||||
|
if (forceRefresh) {
|
||||||
|
// 如果是强制刷新,立即执行,不使用防抖
|
||||||
|
executeLoadAllData(targetDate, forceRefresh);
|
||||||
|
} else {
|
||||||
|
// 普通调用使用防抖
|
||||||
|
debouncedLoadAllData(targetDate, forceRefresh);
|
||||||
|
}
|
||||||
|
}, [executeLoadAllData, debouncedLoadAllData]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadAllData(currentSelectedDate);
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
|
||||||
|
// AppState 监听:应用从后台返回前台时的处理
|
||||||
|
useEffect(() => {
|
||||||
|
const handleAppStateChange = (nextAppState: string) => {
|
||||||
|
if (nextAppState === 'active') {
|
||||||
|
// 判断当前选中的日期是否是最新的(今天)
|
||||||
|
const todayIndex = getTodayIndexInMonth();
|
||||||
|
const isTodaySelected = selectedIndex === todayIndex;
|
||||||
|
|
||||||
|
if (!isTodaySelected) {
|
||||||
|
// 如果当前不是选中今天,则切换到今天(这个更新会触发数据加载)
|
||||||
|
console.log('应用回到前台,切换到今天并加载数据');
|
||||||
|
setSelectedIndex(todayIndex);
|
||||||
|
// 注意:这里不直接调用loadAllData,因为setSelectedIndex会触发useEffect重新计算currentSelectedDate
|
||||||
|
// 然后onSelectDate会被调用,从而触发数据加载
|
||||||
|
} else {
|
||||||
|
// 如果已经是今天,则直接调用加载数据的方法
|
||||||
|
console.log('应用回到前台,当前已是今天,直接加载数据');
|
||||||
|
loadAllData(currentSelectedDate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const subscription = AppState.addEventListener('change', handleAppStateChange);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
subscription?.remove();
|
||||||
|
};
|
||||||
|
}, [loadAllData, currentSelectedDate, selectedIndex]);
|
||||||
|
|
||||||
|
|
||||||
|
// 日期点击时,加载对应日期数据
|
||||||
|
const onSelectDate = React.useCallback((index: number, date: Date) => {
|
||||||
|
setSelectedIndex(index);
|
||||||
|
console.log('日期切换,加载数据...', date);
|
||||||
|
// 日期切换时不强制刷新,依赖缓存机制减少不必要的请求
|
||||||
|
// loadAllData 内部已经实现了防抖,无需额外防抖处理
|
||||||
|
loadAllData(date, false);
|
||||||
|
}, [loadAllData]);
|
||||||
|
|
||||||
|
|
||||||
|
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} />
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
style={styles.scrollView}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingTop: insets.top,
|
||||||
|
paddingBottom: 60,
|
||||||
|
paddingHorizontal: 20
|
||||||
|
}}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
{/* 顶部信息栏 */}
|
||||||
|
<View style={styles.headerContainer}>
|
||||||
|
<View style={styles.headerContent}>
|
||||||
|
{/* 左边logo */}
|
||||||
|
<Image
|
||||||
|
source={require('@/assets/icon.icon/Assets/icon-1756312748268.png')}
|
||||||
|
style={styles.logoImage}
|
||||||
|
resizeMode="cover"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 右边文字区域 */}
|
||||||
|
<View style={styles.headerTextContainer}>
|
||||||
|
<Text style={styles.headerTitle}>Out Live</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 开发环境调试按钮 */}
|
||||||
|
{__DEV__ && (
|
||||||
|
<View style={styles.debugButtonsContainer}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.debugButton}
|
||||||
|
onPress={async () => {
|
||||||
|
console.log('🔧 手动触发后台任务测试...');
|
||||||
|
await BackgroundTaskManager.getInstance().triggerTaskForTesting();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={styles.debugButtonText}>🔧</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.debugButton, styles.hrvTestButton]}
|
||||||
|
onPress={async () => {
|
||||||
|
console.log('🫀 测试HRV数据获取...');
|
||||||
|
await testHRVDataFetch();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={styles.debugButtonText}>🫀</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
{/* 日期选择器 */}
|
||||||
|
<DateSelector
|
||||||
|
selectedIndex={selectedIndex}
|
||||||
|
onDateSelect={onSelectDate}
|
||||||
|
showMonthTitle={false}
|
||||||
|
disableFutureDates={true}
|
||||||
|
/>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
{/* 营养摄入雷达图卡片 */}
|
||||||
|
<NutritionRadarCard
|
||||||
|
selectedDate={currentSelectedDate}
|
||||||
|
resetToken={animToken}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<WeightHistoryCard />
|
||||||
|
|
||||||
|
{/* 真正瀑布流布局 */}
|
||||||
|
<View style={styles.masonryContainer}>
|
||||||
|
{/* 左列 */}
|
||||||
|
<View style={styles.masonryColumn}>
|
||||||
|
{/* 心情卡片 */}
|
||||||
|
<FloatingCard style={styles.masonryCard}>
|
||||||
|
<MoodCard
|
||||||
|
moodCheckin={currentMoodCheckin}
|
||||||
|
onPress={() => pushIfAuthedElseLogin('/mood/calendar')}
|
||||||
|
isLoading={isMoodLoading}
|
||||||
|
/>
|
||||||
|
</FloatingCard>
|
||||||
|
|
||||||
|
<FloatingCard style={styles.masonryCard}>
|
||||||
|
<StepsCard
|
||||||
|
curDate={currentSelectedDate}
|
||||||
|
stepGoal={stepGoal}
|
||||||
|
style={styles.stepsCardOverride}
|
||||||
|
/>
|
||||||
|
</FloatingCard>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<FloatingCard style={styles.masonryCard}>
|
||||||
|
<StressMeter
|
||||||
|
curDate={currentSelectedDate}
|
||||||
|
/>
|
||||||
|
</FloatingCard>
|
||||||
|
|
||||||
|
{/* 心率卡片 */}
|
||||||
|
{/* <FloatingCard style={styles.masonryCard} delay={2000}>
|
||||||
|
<HeartRateCard
|
||||||
|
resetToken={animToken}
|
||||||
|
style={styles.basalMetabolismCardOverride}
|
||||||
|
heartRate={heartRate}
|
||||||
|
/>
|
||||||
|
</FloatingCard> */}
|
||||||
|
|
||||||
|
<FloatingCard style={styles.masonryCard}>
|
||||||
|
<SleepCard
|
||||||
|
selectedDate={currentSelectedDate}
|
||||||
|
/>
|
||||||
|
</FloatingCard>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 右列 */}
|
||||||
|
<View style={styles.masonryColumn}>
|
||||||
|
<FloatingCard style={styles.masonryCard}>
|
||||||
|
<FitnessRingsCard
|
||||||
|
selectedDate={currentSelectedDate}
|
||||||
|
resetToken={animToken}
|
||||||
|
/>
|
||||||
|
</FloatingCard>
|
||||||
|
{/* 饮水记录卡片 */}
|
||||||
|
<FloatingCard style={styles.masonryCard}>
|
||||||
|
<WaterIntakeCard
|
||||||
|
selectedDate={currentSelectedDateString}
|
||||||
|
style={styles.waterCardOverride}
|
||||||
|
/>
|
||||||
|
</FloatingCard>
|
||||||
|
|
||||||
|
|
||||||
|
{/* 基础代谢卡片 */}
|
||||||
|
<FloatingCard style={styles.masonryCard}>
|
||||||
|
<BasalMetabolismCard
|
||||||
|
selectedDate={currentSelectedDate}
|
||||||
|
style={styles.basalMetabolismCardOverride}
|
||||||
|
/>
|
||||||
|
</FloatingCard>
|
||||||
|
|
||||||
|
{/* 血氧饱和度卡片 */}
|
||||||
|
<FloatingCard style={styles.masonryCard}>
|
||||||
|
<OxygenSaturationCard
|
||||||
|
style={styles.basalMetabolismCardOverride}
|
||||||
|
/>
|
||||||
|
</FloatingCard>
|
||||||
|
|
||||||
|
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 围度数据卡片 - 占满底部一行 */}
|
||||||
|
<CircumferenceCard style={styles.circumferenceCard} />
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const primary = Colors.light.primary;
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
|
||||||
|
scrollView: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
headerContainer: {
|
||||||
|
marginBottom: 10,
|
||||||
|
},
|
||||||
|
headerContent: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
},
|
||||||
|
logoImage: {
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
borderRadius: 20,
|
||||||
|
},
|
||||||
|
headerTextContainer: {
|
||||||
|
flex: 1,
|
||||||
|
marginLeft: 12,
|
||||||
|
},
|
||||||
|
headerTitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#192126',
|
||||||
|
},
|
||||||
|
debugButtonsContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
debugButton: {
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: 16,
|
||||||
|
backgroundColor: '#FF6B6B',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: {
|
||||||
|
width: 0,
|
||||||
|
height: 2,
|
||||||
|
},
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 4,
|
||||||
|
elevation: 3,
|
||||||
|
},
|
||||||
|
hrvTestButton: {
|
||||||
|
backgroundColor: '#8B5CF6',
|
||||||
|
},
|
||||||
|
debugButtonText: {
|
||||||
|
fontSize: 12,
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
|
sectionTitle: {
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: '800',
|
||||||
|
color: '#192126',
|
||||||
|
marginTop: 24,
|
||||||
|
marginBottom: 14,
|
||||||
|
},
|
||||||
|
metricsRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
backgroundColor: '#0F1418',
|
||||||
|
borderRadius: 22,
|
||||||
|
padding: 18,
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
metricsLeft: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#EEE9FF',
|
||||||
|
borderRadius: 22,
|
||||||
|
padding: 18,
|
||||||
|
marginRight: 12,
|
||||||
|
},
|
||||||
|
metricsRight: {
|
||||||
|
width: 160,
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
metricsRightCard: {
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderRadius: 22,
|
||||||
|
padding: 16,
|
||||||
|
},
|
||||||
|
caloriesValue: {
|
||||||
|
color: '#192126',
|
||||||
|
fontSize: 18,
|
||||||
|
lineHeight: 18,
|
||||||
|
fontWeight: '600',
|
||||||
|
textAlignVertical: 'bottom'
|
||||||
|
},
|
||||||
|
caloriesUnit: {
|
||||||
|
color: '#515558ff',
|
||||||
|
fontSize: 12,
|
||||||
|
marginLeft: 4,
|
||||||
|
lineHeight: 18,
|
||||||
|
},
|
||||||
|
trainingContent: {
|
||||||
|
marginTop: 8,
|
||||||
|
width: 120,
|
||||||
|
height: 120,
|
||||||
|
borderRadius: 60,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignSelf: 'center',
|
||||||
|
},
|
||||||
|
trainingRingTrack: {
|
||||||
|
position: 'absolute',
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
borderRadius: 60,
|
||||||
|
borderWidth: 12,
|
||||||
|
borderColor: '#E2D9FD',
|
||||||
|
},
|
||||||
|
trainingRingProgress: {
|
||||||
|
position: 'absolute',
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
borderRadius: 60,
|
||||||
|
borderWidth: 12,
|
||||||
|
borderColor: 'transparent',
|
||||||
|
borderTopColor: '#8B74F3',
|
||||||
|
borderRightColor: '#8B74F3',
|
||||||
|
transform: [{ rotateZ: '45deg' }],
|
||||||
|
},
|
||||||
|
trainingPercent: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '800',
|
||||||
|
color: '#8B74F3',
|
||||||
|
},
|
||||||
|
cyclingHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
cyclingIconBadge: {
|
||||||
|
width: 30,
|
||||||
|
height: 30,
|
||||||
|
borderRadius: 6,
|
||||||
|
backgroundColor: primary,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginRight: 8,
|
||||||
|
},
|
||||||
|
cyclingTitle: {
|
||||||
|
color: '#FFFFFF',
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: '800',
|
||||||
|
},
|
||||||
|
mapArea: {
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.08)',
|
||||||
|
borderRadius: 14,
|
||||||
|
height: 180,
|
||||||
|
padding: 8,
|
||||||
|
flexDirection: 'row',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
mapTile: {
|
||||||
|
width: '25%',
|
||||||
|
height: '25%',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(255,255,255,0.12)',
|
||||||
|
},
|
||||||
|
routeLine: {
|
||||||
|
position: 'absolute',
|
||||||
|
height: 6,
|
||||||
|
backgroundColor: primary,
|
||||||
|
borderRadius: 3,
|
||||||
|
},
|
||||||
|
cardHeaderRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
iconSquare: {
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
borderRadius: 8,
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginRight: 10,
|
||||||
|
},
|
||||||
|
cardTitle: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#192126',
|
||||||
|
},
|
||||||
|
heartCard: {
|
||||||
|
backgroundColor: '#FFE5E5',
|
||||||
|
},
|
||||||
|
waveContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'flex-end',
|
||||||
|
height: 70,
|
||||||
|
gap: 6,
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
waveBar: {
|
||||||
|
width: 6,
|
||||||
|
borderRadius: 3,
|
||||||
|
backgroundColor: '#E54D4D',
|
||||||
|
},
|
||||||
|
heartValue: {
|
||||||
|
alignSelf: 'flex-end',
|
||||||
|
color: '#5B5B5B',
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
stepsValue: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#7A6A42',
|
||||||
|
fontWeight: '700',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
errorContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: '#FFE5E5',
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 12,
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
errorText: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#E54D4D',
|
||||||
|
fontWeight: '600',
|
||||||
|
marginLeft: 8,
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
retryButton: {
|
||||||
|
padding: 4,
|
||||||
|
marginLeft: 8,
|
||||||
|
},
|
||||||
|
viewMoreContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
viewMoreText: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#192126',
|
||||||
|
},
|
||||||
|
viewMoreIcon: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: '#192126',
|
||||||
|
marginLeft: 4,
|
||||||
|
},
|
||||||
|
stressCardRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
healthCardsRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
masonryContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 16,
|
||||||
|
marginTop: 6,
|
||||||
|
},
|
||||||
|
masonryColumn: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
masonryCard: {
|
||||||
|
width: '100%',
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 16,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: {
|
||||||
|
width: 0,
|
||||||
|
height: 4,
|
||||||
|
},
|
||||||
|
shadowOpacity: 0.12,
|
||||||
|
shadowRadius: 12,
|
||||||
|
elevation: 6,
|
||||||
|
minHeight: 100,
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginTop: 6
|
||||||
|
},
|
||||||
|
basalMetabolismCardOverride: {
|
||||||
|
margin: -16, // 抵消 masonryCard 的 padding
|
||||||
|
borderRadius: 16,
|
||||||
|
},
|
||||||
|
stepsCardOverride: {
|
||||||
|
margin: -16, // 抵消 masonryCard 的 padding
|
||||||
|
borderRadius: 16,
|
||||||
|
height: '100%', // 填充整个masonryCard
|
||||||
|
},
|
||||||
|
waterCardOverride: {
|
||||||
|
margin: -16, // 抵消 masonryCard 的 padding
|
||||||
|
borderRadius: 16,
|
||||||
|
height: '100%', // 填充整个masonryCard
|
||||||
|
},
|
||||||
|
compactStepsCard: {
|
||||||
|
minHeight: 100,
|
||||||
|
},
|
||||||
|
stepsContent: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginTop: 8,
|
||||||
|
},
|
||||||
|
weightCard: {
|
||||||
|
backgroundColor: '#F0F9FF',
|
||||||
|
},
|
||||||
|
weightValue: {
|
||||||
|
fontSize: 22,
|
||||||
|
color: '#0369A1',
|
||||||
|
fontWeight: '800',
|
||||||
|
marginTop: 8,
|
||||||
|
},
|
||||||
|
addWeightButton: {
|
||||||
|
position: 'absolute',
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
padding: 4,
|
||||||
|
},
|
||||||
|
circumferenceCard: {
|
||||||
|
marginBottom: 36,
|
||||||
|
marginTop: 10,
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
|
});
|
||||||
211
app/_layout.tsx
@@ -1,13 +1,180 @@
|
|||||||
import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native';
|
import { DefaultTheme, ThemeProvider } from '@react-navigation/native';
|
||||||
import { useFonts } from 'expo-font';
|
import { useFonts } from 'expo-font';
|
||||||
import { Stack } from 'expo-router';
|
import { Stack } from 'expo-router';
|
||||||
import { StatusBar } from 'expo-status-bar';
|
import { StatusBar } from 'expo-status-bar';
|
||||||
|
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||||
import 'react-native-reanimated';
|
import 'react-native-reanimated';
|
||||||
|
|
||||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
import PrivacyConsentModal from '@/components/PrivacyConsentModal';
|
||||||
|
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||||
|
import { useQuickActions } from '@/hooks/useQuickActions';
|
||||||
|
import { clearAiCoachSessionCache } from '@/services/aiCoachSession';
|
||||||
|
import { notificationService } from '@/services/notifications';
|
||||||
|
import { setupQuickActions } from '@/services/quickActions';
|
||||||
|
import { initializeWaterRecordBridge } from '@/services/waterRecordBridge';
|
||||||
|
import { WaterRecordSource } from '@/services/waterRecords';
|
||||||
|
import { store } from '@/store';
|
||||||
|
import { fetchMyProfile, setPrivacyAgreed } from '@/store/userSlice';
|
||||||
|
import { createWaterRecordAction } from '@/store/waterSlice';
|
||||||
|
import { ensureHealthPermissions, initializeHealthPermissions } from '@/utils/health';
|
||||||
|
import { DailySummaryNotificationHelpers, MoodNotificationHelpers, NutritionNotificationHelpers } from '@/utils/notificationHelpers';
|
||||||
|
import { clearPendingWaterRecords, syncPendingWidgetChanges } from '@/utils/widgetDataSync';
|
||||||
|
import React, { useEffect } from 'react';
|
||||||
|
|
||||||
|
import { DialogProvider } from '@/components/ui/DialogProvider';
|
||||||
|
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';
|
||||||
|
|
||||||
|
|
||||||
|
function Bootstrapper({ children }: { children: React.ReactNode }) {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const { profile } = useAppSelector((state) => state.user);
|
||||||
|
const [showPrivacyModal, setShowPrivacyModal] = React.useState(false);
|
||||||
|
const { isLoggedIn } = useAuthGuard()
|
||||||
|
|
||||||
|
// 初始化快捷动作处理
|
||||||
|
useQuickActions();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isLoggedIn) {
|
||||||
|
dispatch(fetchChallenges());
|
||||||
|
}
|
||||||
|
}, [isLoggedIn]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const loadUserData = async () => {
|
||||||
|
// 数据已经在启动界面预加载,这里只需要快速同步到 Redux 状态
|
||||||
|
await dispatch(fetchMyProfile());
|
||||||
|
};
|
||||||
|
|
||||||
|
const initHealthPermissions = async () => {
|
||||||
|
// 初始化 HealthKit 权限管理系统
|
||||||
|
try {
|
||||||
|
console.log('初始化 HealthKit 权限管理系统...');
|
||||||
|
initializeHealthPermissions();
|
||||||
|
|
||||||
|
// 延迟请求权限,避免应用启动时弹窗
|
||||||
|
setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
await ensureHealthPermissions();
|
||||||
|
console.log('HealthKit 权限请求完成');
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('HealthKit 权限请求失败,可能在模拟器上运行:', error);
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
|
||||||
|
console.log('HealthKit 权限管理初始化完成');
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('HealthKit 权限管理初始化失败:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const initializeNotifications = async () => {
|
||||||
|
try {
|
||||||
|
await BackgroundTaskManager.getInstance().initialize();
|
||||||
|
// 初始化通知服务
|
||||||
|
await notificationService.initialize();
|
||||||
|
console.log('通知服务初始化成功');
|
||||||
|
|
||||||
|
// 注册午餐提醒(12:00)
|
||||||
|
await NutritionNotificationHelpers.scheduleDailyLunchReminder(profile.name || '');
|
||||||
|
console.log('午餐提醒已注册');
|
||||||
|
|
||||||
|
// 注册晚餐提醒(18:00)
|
||||||
|
await NutritionNotificationHelpers.scheduleDailyDinnerReminder(profile.name || '');
|
||||||
|
console.log('晚餐提醒已注册');
|
||||||
|
|
||||||
|
// 注册心情提醒(21:00)
|
||||||
|
await MoodNotificationHelpers.scheduleDailyMoodReminder(profile.name || '');
|
||||||
|
console.log('心情提醒已注册');
|
||||||
|
|
||||||
|
await DailySummaryNotificationHelpers.scheduleDailySummaryNotification(profile.name || '')
|
||||||
|
|
||||||
|
|
||||||
|
// 初始化快捷动作
|
||||||
|
await setupQuickActions();
|
||||||
|
console.log('快捷动作初始化成功');
|
||||||
|
|
||||||
|
// 初始化喝水记录 bridge
|
||||||
|
initializeWaterRecordBridge();
|
||||||
|
console.log('喝水记录 Bridge 初始化成功');
|
||||||
|
|
||||||
|
// 检查并同步Widget数据更改
|
||||||
|
const widgetSync = await syncPendingWidgetChanges();
|
||||||
|
if (widgetSync.hasPendingChanges && widgetSync.pendingRecords) {
|
||||||
|
console.log(`检测到 ${widgetSync.pendingRecords.length} 条待同步的水记录`);
|
||||||
|
|
||||||
|
// 将待同步的记录添加到 Redux store
|
||||||
|
for (const record of widgetSync.pendingRecords) {
|
||||||
|
try {
|
||||||
|
await store.dispatch(createWaterRecordAction({
|
||||||
|
amount: record.amount,
|
||||||
|
recordedAt: record.recordedAt,
|
||||||
|
source: WaterRecordSource.Auto, // 标记为自动添加(来自Widget)
|
||||||
|
})).unwrap();
|
||||||
|
|
||||||
|
console.log(`成功同步水记录: ${record.amount}ml at ${record.recordedAt}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('同步水记录失败:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清除已同步的记录
|
||||||
|
await clearPendingWaterRecords();
|
||||||
|
console.log('所有待同步的水记录已处理完成');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('通知服务、后台任务管理器或快捷动作初始化失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadUserData();
|
||||||
|
initHealthPermissions();
|
||||||
|
initializeNotifications();
|
||||||
|
|
||||||
|
|
||||||
|
// 冷启动时清空 AI 教练会话缓存
|
||||||
|
clearAiCoachSessionCache();
|
||||||
|
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
|
||||||
|
const getPrivacyAgreed = async () => {
|
||||||
|
const str = await AsyncStorage.getItem(STORAGE_KEYS.privacyAgreed)
|
||||||
|
|
||||||
|
setShowPrivacyModal(str !== 'true');
|
||||||
|
}
|
||||||
|
getPrivacyAgreed();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handlePrivacyAgree = () => {
|
||||||
|
dispatch(setPrivacyAgreed());
|
||||||
|
setShowPrivacyModal(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePrivacyDisagree = () => {
|
||||||
|
// RNExitApp.exitApp();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DialogProvider>
|
||||||
|
{children}
|
||||||
|
<PrivacyConsentModal
|
||||||
|
visible={showPrivacyModal}
|
||||||
|
onAgree={handlePrivacyAgree}
|
||||||
|
onDisagree={handlePrivacyDisagree}
|
||||||
|
/>
|
||||||
|
</DialogProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function RootLayout() {
|
export default function RootLayout() {
|
||||||
const colorScheme = useColorScheme();
|
|
||||||
const [loaded] = useFonts({
|
const [loaded] = useFonts({
|
||||||
SpaceMono: require('../assets/fonts/SpaceMono-Regular.ttf'),
|
SpaceMono: require('../assets/fonts/SpaceMono-Regular.ttf'),
|
||||||
});
|
});
|
||||||
@@ -18,14 +185,34 @@ export default function RootLayout() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
|
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||||
<Stack>
|
<Provider store={store}>
|
||||||
<Stack.Screen name="onboarding" options={{ headerShown: false }} />
|
<Bootstrapper>
|
||||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
<ToastProvider>
|
||||||
<Stack.Screen name="ai-posture-assessment" options={{ headerShown: false }} />
|
<ThemeProvider value={DefaultTheme}>
|
||||||
<Stack.Screen name="+not-found" />
|
<Stack screenOptions={{ headerShown: false }}>
|
||||||
</Stack>
|
<Stack.Screen name="(tabs)" />
|
||||||
<StatusBar style="auto" />
|
<Stack.Screen name="challenge" options={{ headerShown: false }} />
|
||||||
</ThemeProvider>
|
<Stack.Screen name="training-plan" options={{ headerShown: false }} />
|
||||||
|
<Stack.Screen name="workout" options={{ headerShown: false }} />
|
||||||
|
<Stack.Screen name="profile/edit" />
|
||||||
|
<Stack.Screen name="profile/goals" options={{ headerShown: false }} />
|
||||||
|
<Stack.Screen name="goals-list" options={{ headerShown: false }} />
|
||||||
|
|
||||||
|
<Stack.Screen name="ai-posture-assessment" />
|
||||||
|
<Stack.Screen name="auth/login" options={{ headerShown: false }} />
|
||||||
|
<Stack.Screen name="legal/user-agreement" options={{ headerShown: true, title: '用户协议' }} />
|
||||||
|
<Stack.Screen name="legal/privacy-policy" options={{ headerShown: true, title: '隐私政策' }} />
|
||||||
|
<Stack.Screen name="article/[id]" options={{ headerShown: false }} />
|
||||||
|
<Stack.Screen name="water-detail" options={{ headerShown: false }} />
|
||||||
|
<Stack.Screen name="water-settings" options={{ headerShown: false }} />
|
||||||
|
<Stack.Screen name="+not-found" />
|
||||||
|
</Stack>
|
||||||
|
<StatusBar style="dark" />
|
||||||
|
</ThemeProvider>
|
||||||
|
</ToastProvider>
|
||||||
|
</Bootstrapper>
|
||||||
|
</Provider>
|
||||||
|
</GestureHandlerRootView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import * as ImagePicker from 'expo-image-picker';
|
|||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
Alert,
|
Alert,
|
||||||
Image,
|
Image,
|
||||||
Linking,
|
Linking,
|
||||||
@@ -12,11 +13,14 @@ import {
|
|||||||
StyleSheet,
|
StyleSheet,
|
||||||
Text,
|
Text,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
View,
|
View
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
|
import ImageViewing from 'react-native-image-viewing';
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
|
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||||
import { Colors } from '@/constants/Colors';
|
import { Colors } from '@/constants/Colors';
|
||||||
|
import { useCosUpload } from '@/hooks/useCosUpload';
|
||||||
|
|
||||||
type PoseView = 'front' | 'side' | 'back';
|
type PoseView = 'front' | 'side' | 'back';
|
||||||
|
|
||||||
@@ -30,17 +34,17 @@ type Sample = { uri: string; correct: boolean };
|
|||||||
|
|
||||||
const SAMPLES: Record<PoseView, Sample[]> = {
|
const SAMPLES: Record<PoseView, Sample[]> = {
|
||||||
front: [
|
front: [
|
||||||
{ uri: 'https://images.unsplash.com/photo-1594737625785-c6683fc87c73?w=400&q=80&auto=format', correct: true },
|
{ uri: 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/imagedemo.jpeg', correct: true },
|
||||||
{ uri: 'https://images.unsplash.com/photo-1544716278-ca5e3f4abd8c?w=400&q=80&auto=format', correct: false },
|
{ uri: 'https://images.unsplash.com/photo-1544716278-ca5e3f4abd8c?w=400&q=80&auto=format', correct: false },
|
||||||
{ uri: 'https://images.unsplash.com/photo-1571019614242-c5c5dee9f50b?w=400&q=80&auto=format', correct: false },
|
{ uri: 'https://images.unsplash.com/photo-1571019614242-c5c5dee9f50b?w=400&q=80&auto=format', correct: false },
|
||||||
],
|
],
|
||||||
side: [
|
side: [
|
||||||
{ uri: 'https://images.unsplash.com/photo-1554463529-e27854014799?w=400&q=80&auto=format', correct: true },
|
{ uri: 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/imagedemo.jpeg', correct: true },
|
||||||
{ uri: 'https://images.unsplash.com/photo-1596357395104-5bcae0b1a5eb?w=400&q=80&auto=format', correct: false },
|
{ uri: 'https://images.unsplash.com/photo-1596357395104-5bcae0b1a5eb?w=400&q=80&auto=format', correct: false },
|
||||||
{ uri: 'https://images.unsplash.com/photo-1526506118085-60ce8714f8c5?w=400&q=80&auto=format', correct: false },
|
{ uri: 'https://images.unsplash.com/photo-1526506118085-60ce8714f8c5?w=400&q=80&auto=format', correct: false },
|
||||||
],
|
],
|
||||||
back: [
|
back: [
|
||||||
{ uri: 'https://images.unsplash.com/photo-1517836357463-d25dfeac3438?w=400&q=80&auto=format', correct: true },
|
{ uri: 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/imagedemo.jpeg', correct: true },
|
||||||
{ uri: 'https://images.unsplash.com/photo-1571721797421-f4c9f2b13107?w=400&q=80&auto=format', correct: false },
|
{ uri: 'https://images.unsplash.com/photo-1571721797421-f4c9f2b13107?w=400&q=80&auto=format', correct: false },
|
||||||
{ uri: 'https://images.unsplash.com/photo-1518611012118-696072aa579a?w=400&q=80&auto=format', correct: false },
|
{ uri: 'https://images.unsplash.com/photo-1518611012118-696072aa579a?w=400&q=80&auto=format', correct: false },
|
||||||
],
|
],
|
||||||
@@ -49,7 +53,7 @@ const SAMPLES: Record<PoseView, Sample[]> = {
|
|||||||
export default function AIPostureAssessmentScreen() {
|
export default function AIPostureAssessmentScreen() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const theme = Colors.dark;
|
const theme = Colors.light;
|
||||||
|
|
||||||
const [uploadState, setUploadState] = useState<UploadState>({});
|
const [uploadState, setUploadState] = useState<UploadState>({});
|
||||||
const canStart = useMemo(
|
const canStart = useMemo(
|
||||||
@@ -57,6 +61,9 @@ export default function AIPostureAssessmentScreen() {
|
|||||||
[uploadState]
|
[uploadState]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const { upload, uploading } = useCosUpload();
|
||||||
|
const [uploadingKey, setUploadingKey] = useState<PoseView | null>(null);
|
||||||
|
|
||||||
const [cameraPerm, setCameraPerm] = useState<ImagePicker.PermissionStatus | null>(null);
|
const [cameraPerm, setCameraPerm] = useState<ImagePicker.PermissionStatus | null>(null);
|
||||||
const [libraryPerm, setLibraryPerm] = useState<ImagePicker.PermissionStatus | null>(null);
|
const [libraryPerm, setLibraryPerm] = useState<ImagePicker.PermissionStatus | null>(null);
|
||||||
const [libraryAccess, setLibraryAccess] = useState<'all' | 'limited' | 'none' | null>(null);
|
const [libraryAccess, setLibraryAccess] = useState<'all' | 'limited' | 'none' | null>(null);
|
||||||
@@ -127,7 +134,25 @@ export default function AIPostureAssessmentScreen() {
|
|||||||
aspect: [3, 4],
|
aspect: [3, 4],
|
||||||
});
|
});
|
||||||
if (!result.canceled) {
|
if (!result.canceled) {
|
||||||
setUploadState((s) => ({ ...s, [key]: result.assets[0]?.uri ?? null }));
|
// 设置正在上传状态
|
||||||
|
setUploadingKey(key);
|
||||||
|
try {
|
||||||
|
// 上传到 COS
|
||||||
|
const { url } = await upload(
|
||||||
|
{ uri: result.assets[0]?.uri ?? '', name: `posture-${key}.jpg`, type: 'image/jpeg' },
|
||||||
|
{ prefix: 'posture-assessment/' }
|
||||||
|
);
|
||||||
|
// 上传成功,更新状态
|
||||||
|
setUploadState((s) => ({ ...s, [key]: url }));
|
||||||
|
} catch (uploadError) {
|
||||||
|
console.warn('上传图片失败', uploadError);
|
||||||
|
Alert.alert('上传失败', '图片上传失败,请重试');
|
||||||
|
// 上传失败,清除状态
|
||||||
|
setUploadState((s) => ({ ...s, [key]: null }));
|
||||||
|
} finally {
|
||||||
|
// 清除上传状态
|
||||||
|
setUploadingKey(null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const resp = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
const resp = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||||
@@ -156,7 +181,25 @@ export default function AIPostureAssessmentScreen() {
|
|||||||
aspect: [3, 4],
|
aspect: [3, 4],
|
||||||
});
|
});
|
||||||
if (!result.canceled) {
|
if (!result.canceled) {
|
||||||
setUploadState((s) => ({ ...s, [key]: result.assets[0]?.uri ?? null }));
|
// 设置正在上传状态
|
||||||
|
setUploadingKey(key);
|
||||||
|
try {
|
||||||
|
// 上传到 COS
|
||||||
|
const { url } = await upload(
|
||||||
|
{ uri: result.assets[0]?.uri ?? '', name: `posture-${key}.jpg`, type: 'image/jpeg' },
|
||||||
|
{ prefix: 'posture-assessment/' }
|
||||||
|
);
|
||||||
|
// 上传成功,更新状态
|
||||||
|
setUploadState((s) => ({ ...s, [key]: url }));
|
||||||
|
} catch (uploadError) {
|
||||||
|
console.warn('上传图片失败', uploadError);
|
||||||
|
Alert.alert('上传失败', '图片上传失败,请重试');
|
||||||
|
// 上传失败,清除状态
|
||||||
|
setUploadState((s) => ({ ...s, [key]: null }));
|
||||||
|
} finally {
|
||||||
|
// 清除上传状态
|
||||||
|
setUploadingKey(null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -166,24 +209,13 @@ export default function AIPostureAssessmentScreen() {
|
|||||||
|
|
||||||
function handleStart() {
|
function handleStart() {
|
||||||
if (!canStart) return;
|
if (!canStart) return;
|
||||||
// TODO: 调用后端或进入分析页面
|
// 进入评估中间页面
|
||||||
Alert.alert('开始测评', '已收集三视角照片,准备开始AI体态分析');
|
router.push('/ai-posture-processing');
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.screen, { backgroundColor: theme.background }]}>
|
<View style={[styles.screen, { backgroundColor: Colors.light.pageBackgroundEmphasis }]}>
|
||||||
{/* Header */}
|
<HeaderBar title="AI体态测评" onBack={() => router.back()} tone="light" transparent />
|
||||||
<View style={[styles.header, { paddingTop: insets.top + 8 }]}>
|
|
||||||
<TouchableOpacity
|
|
||||||
accessibilityRole="button"
|
|
||||||
onPress={() => router.back()}
|
|
||||||
style={styles.backButton}
|
|
||||||
>
|
|
||||||
<Ionicons name="chevron-back" size={24} color="#ECEDEE" />
|
|
||||||
</TouchableOpacity>
|
|
||||||
<Text style={styles.headerTitle}>AI体态测评</Text>
|
|
||||||
<View style={{ width: 32 }} />
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<ScrollView
|
<ScrollView
|
||||||
contentContainerStyle={{ paddingBottom: insets.bottom + 120 }}
|
contentContainerStyle={{ paddingBottom: insets.bottom + 120 }}
|
||||||
@@ -217,10 +249,8 @@ export default function AIPostureAssessmentScreen() {
|
|||||||
|
|
||||||
{/* Intro */}
|
{/* Intro */}
|
||||||
<View style={styles.introBox}>
|
<View style={styles.introBox}>
|
||||||
<Text style={styles.title}>上传标准姿势照片</Text>
|
<Text style={[styles.title, { color: '#192126' }]}>上传标准姿势照片</Text>
|
||||||
<Text style={styles.description}>
|
<Text style={[styles.description, { color: '#5E6468' }]}>请依次上传正面、侧面与背面全身照。保持光线均匀、背景简洁,身体立正自然放松。</Text>
|
||||||
请依次上传正面、侧面与背面全身照。保持光线均匀、背景简洁,身体立正自然放松。
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Upload sections */}
|
{/* Upload sections */}
|
||||||
@@ -230,6 +260,7 @@ export default function AIPostureAssessmentScreen() {
|
|||||||
onPickCamera={() => requestPermissionAndPick('camera', 'front')}
|
onPickCamera={() => requestPermissionAndPick('camera', 'front')}
|
||||||
onPickLibrary={() => requestPermissionAndPick('library', 'front')}
|
onPickLibrary={() => requestPermissionAndPick('library', 'front')}
|
||||||
samples={SAMPLES.front}
|
samples={SAMPLES.front}
|
||||||
|
uploading={uploading && uploadingKey === 'front'}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<UploadTile
|
<UploadTile
|
||||||
@@ -238,6 +269,7 @@ export default function AIPostureAssessmentScreen() {
|
|||||||
onPickCamera={() => requestPermissionAndPick('camera', 'side')}
|
onPickCamera={() => requestPermissionAndPick('camera', 'side')}
|
||||||
onPickLibrary={() => requestPermissionAndPick('library', 'side')}
|
onPickLibrary={() => requestPermissionAndPick('library', 'side')}
|
||||||
samples={SAMPLES.side}
|
samples={SAMPLES.side}
|
||||||
|
uploading={uploading && uploadingKey === 'side'}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<UploadTile
|
<UploadTile
|
||||||
@@ -246,6 +278,7 @@ export default function AIPostureAssessmentScreen() {
|
|||||||
onPickCamera={() => requestPermissionAndPick('camera', 'back')}
|
onPickCamera={() => requestPermissionAndPick('camera', 'back')}
|
||||||
onPickLibrary={() => requestPermissionAndPick('library', 'back')}
|
onPickLibrary={() => requestPermissionAndPick('library', 'back')}
|
||||||
samples={SAMPLES.back}
|
samples={SAMPLES.back}
|
||||||
|
uploading={uploading && uploadingKey === 'back'}
|
||||||
/>
|
/>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
@@ -275,13 +308,19 @@ function UploadTile({
|
|||||||
onPickCamera,
|
onPickCamera,
|
||||||
onPickLibrary,
|
onPickLibrary,
|
||||||
samples,
|
samples,
|
||||||
|
uploading,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
value?: string | null;
|
value?: string | null;
|
||||||
onPickCamera: () => void;
|
onPickCamera: () => void;
|
||||||
onPickLibrary: () => void;
|
onPickLibrary: () => void;
|
||||||
samples: Sample[];
|
samples: Sample[];
|
||||||
|
uploading?: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const [viewerVisible, setViewerVisible] = React.useState(false);
|
||||||
|
const [viewerIndex, setViewerIndex] = React.useState(0);
|
||||||
|
const imagesForViewer = React.useMemo(() => samples.map((s) => ({ uri: s.uri })), [samples]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.section}>
|
<View style={styles.section}>
|
||||||
<View style={styles.sectionHeader}>
|
<View style={styles.sectionHeader}>
|
||||||
@@ -298,13 +337,19 @@ function UploadTile({
|
|||||||
onLongPress={onPickLibrary}
|
onLongPress={onPickLibrary}
|
||||||
onPress={onPickCamera}
|
onPress={onPickCamera}
|
||||||
style={styles.uploader}
|
style={styles.uploader}
|
||||||
|
disabled={uploading}
|
||||||
>
|
>
|
||||||
{value ? (
|
{uploading ? (
|
||||||
|
<View style={[styles.placeholder, { backgroundColor: '#f5f5f5' }]}>
|
||||||
|
<ActivityIndicator size="large" color={Colors.light.accentGreen} />
|
||||||
|
<Text style={styles.placeholderTitle}>上传中...</Text>
|
||||||
|
</View>
|
||||||
|
) : value ? (
|
||||||
<Image source={{ uri: value }} style={styles.preview} />
|
<Image source={{ uri: value }} style={styles.preview} />
|
||||||
) : (
|
) : (
|
||||||
<View style={styles.placeholder}>
|
<View style={styles.placeholder}>
|
||||||
<View style={styles.plusBadge}>
|
<View style={styles.plusBadge}>
|
||||||
<Ionicons name="camera" size={16} color="#192126" />
|
<Ionicons name="camera" size={16} color={Colors.light.accentGreen} />
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.placeholderTitle}>拍摄或选择照片</Text>
|
<Text style={styles.placeholderTitle}>拍摄或选择照片</Text>
|
||||||
<Text style={styles.placeholderDesc}>点击拍摄,长按从相册选择</Text>
|
<Text style={styles.placeholderDesc}>点击拍摄,长按从相册选择</Text>
|
||||||
@@ -312,19 +357,27 @@ function UploadTile({
|
|||||||
)}
|
)}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
<BlurView intensity={18} tint="dark" style={styles.sampleBox}>
|
<BlurView intensity={12} tint="light" style={styles.sampleBox}>
|
||||||
<Text style={styles.sampleTitle}>示例</Text>
|
<Text style={styles.sampleTitle}>示例</Text>
|
||||||
<View style={styles.sampleRow}>
|
<View style={styles.sampleRow}>
|
||||||
{samples.map((s, idx) => (
|
{samples.map((s, idx) => (
|
||||||
<View key={idx} style={styles.sampleItem}>
|
<View key={idx} style={styles.sampleItem}>
|
||||||
<Image source={{ uri: s.uri }} style={styles.sampleImg} />
|
<TouchableOpacity activeOpacity={0.9} onPress={() => { setViewerIndex(idx); setViewerVisible(true); }}>
|
||||||
<View style={[styles.sampleTag, { backgroundColor: s.correct ? '#2BCC7F' : '#E24D4D' }]}>
|
<Image source={{ uri: s.uri }} style={styles.sampleImg} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<View style={[styles.sampleTag, { backgroundColor: s.correct ? '#2BCC7F' : 'rgba(25,33,38,0.08)' }]}>
|
||||||
<Text style={styles.sampleTagText}>{s.correct ? '正确示范' : '错误示范'}</Text>
|
<Text style={styles.sampleTagText}>{s.correct ? '正确示范' : '错误示范'}</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
</BlurView>
|
</BlurView>
|
||||||
|
<ImageViewing
|
||||||
|
images={imagesForViewer}
|
||||||
|
imageIndex={viewerIndex}
|
||||||
|
visible={viewerVisible}
|
||||||
|
onRequestClose={() => setViewerVisible(false)}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -338,15 +391,15 @@ const styles = StyleSheet.create({
|
|||||||
marginHorizontal: 16,
|
marginHorizontal: 16,
|
||||||
padding: 14,
|
padding: 14,
|
||||||
borderRadius: 16,
|
borderRadius: 16,
|
||||||
backgroundColor: 'rgba(255,255,255,0.04)'
|
backgroundColor: 'rgba(25,33,38,0.06)'
|
||||||
},
|
},
|
||||||
permTitle: {
|
permTitle: {
|
||||||
color: '#ECEDEE',
|
color: '#192126',
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
},
|
},
|
||||||
permDesc: {
|
permDesc: {
|
||||||
color: 'rgba(255,255,255,0.75)',
|
color: '#5E6468',
|
||||||
marginTop: 6,
|
marginTop: 6,
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
},
|
},
|
||||||
@@ -362,7 +415,7 @@ const styles = StyleSheet.create({
|
|||||||
paddingHorizontal: 14,
|
paddingHorizontal: 14,
|
||||||
height: 40,
|
height: 40,
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
backgroundColor: '#BBF246',
|
backgroundColor: Colors.light.accentGreen,
|
||||||
},
|
},
|
||||||
permPrimaryText: {
|
permPrimaryText: {
|
||||||
color: '#192126',
|
color: '#192126',
|
||||||
@@ -377,10 +430,10 @@ const styles = StyleSheet.create({
|
|||||||
height: 40,
|
height: 40,
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: 'rgba(255,255,255,0.18)',
|
borderColor: 'rgba(25,33,38,0.14)',
|
||||||
},
|
},
|
||||||
permSecondaryText: {
|
permSecondaryText: {
|
||||||
color: 'rgba(255,255,255,0.85)',
|
color: '#384046',
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
},
|
},
|
||||||
@@ -430,12 +483,12 @@ const styles = StyleSheet.create({
|
|||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
},
|
},
|
||||||
sectionTitle: {
|
sectionTitle: {
|
||||||
color: '#ECEDEE',
|
color: '#192126',
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
},
|
},
|
||||||
retakeHint: {
|
retakeHint: {
|
||||||
color: 'rgba(255,255,255,0.55)',
|
color: '#888F92',
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
},
|
},
|
||||||
uploader: {
|
uploader: {
|
||||||
@@ -443,8 +496,8 @@ const styles = StyleSheet.create({
|
|||||||
borderRadius: 18,
|
borderRadius: 18,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderStyle: 'dashed',
|
borderStyle: 'dashed',
|
||||||
borderColor: 'rgba(255,255,255,0.18)',
|
borderColor: 'rgba(25,33,38,0.14)',
|
||||||
backgroundColor: '#1E262C',
|
backgroundColor: '#FFFFFF',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
},
|
},
|
||||||
preview: {
|
preview: {
|
||||||
@@ -463,25 +516,27 @@ const styles = StyleSheet.create({
|
|||||||
borderRadius: 18,
|
borderRadius: 18,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
backgroundColor: '#BBF246',
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: Colors.light.accentGreen,
|
||||||
},
|
},
|
||||||
placeholderTitle: {
|
placeholderTitle: {
|
||||||
color: '#ECEDEE',
|
color: '#192126',
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
},
|
},
|
||||||
placeholderDesc: {
|
placeholderDesc: {
|
||||||
color: 'rgba(255,255,255,0.65)',
|
color: '#888F92',
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
},
|
},
|
||||||
sampleBox: {
|
sampleBox: {
|
||||||
marginTop: 8,
|
marginTop: 8,
|
||||||
borderRadius: 16,
|
borderRadius: 16,
|
||||||
padding: 12,
|
padding: 12,
|
||||||
backgroundColor: 'rgba(255,255,255,0.04)',
|
backgroundColor: 'rgba(255,255,255,0.72)',
|
||||||
},
|
},
|
||||||
sampleTitle: {
|
sampleTitle: {
|
||||||
color: 'rgba(255,255,255,0.8)',
|
color: '#192126',
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
marginBottom: 8,
|
marginBottom: 8,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
@@ -497,7 +552,7 @@ const styles = StyleSheet.create({
|
|||||||
width: '100%',
|
width: '100%',
|
||||||
height: 90,
|
height: 90,
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
backgroundColor: '#111',
|
backgroundColor: '#F2F4F5',
|
||||||
},
|
},
|
||||||
sampleTag: {
|
sampleTag: {
|
||||||
alignSelf: 'flex-start',
|
alignSelf: 'flex-start',
|
||||||
|
|||||||
279
app/ai-posture-processing.tsx
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { BlurView } from 'expo-blur';
|
||||||
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { Dimensions, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||||
|
import Animated, { Easing, useAnimatedStyle, useSharedValue, withDelay, withRepeat, withSequence, withTiming } from 'react-native-reanimated';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
|
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||||
|
import { Colors } from '@/constants/Colors';
|
||||||
|
|
||||||
|
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window');
|
||||||
|
|
||||||
|
export default function AIPostureProcessingScreen() {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const router = useRouter();
|
||||||
|
const theme = Colors.dark;
|
||||||
|
|
||||||
|
// Core looping animations
|
||||||
|
const spin = useSharedValue(0);
|
||||||
|
const pulse = useSharedValue(0);
|
||||||
|
const scanY = useSharedValue(0);
|
||||||
|
const particle = useSharedValue(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
spin.value = withRepeat(withTiming(1, { duration: 6000, easing: Easing.linear }), -1);
|
||||||
|
pulse.value = withRepeat(withSequence(
|
||||||
|
withTiming(1, { duration: 1600, easing: Easing.inOut(Easing.quad) }),
|
||||||
|
withTiming(0, { duration: 1600, easing: Easing.inOut(Easing.quad) })
|
||||||
|
), -1, true);
|
||||||
|
scanY.value = withRepeat(withTiming(1, { duration: 3800, easing: Easing.inOut(Easing.cubic) }), -1, false);
|
||||||
|
particle.value = withDelay(400, withRepeat(withTiming(1, { duration: 5200, easing: Easing.inOut(Easing.quad) }), -1, true));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const ringStyleOuter = useAnimatedStyle(() => ({
|
||||||
|
transform: [{ rotate: `${spin.value * 360}deg` }],
|
||||||
|
opacity: 0.8,
|
||||||
|
}));
|
||||||
|
const ringStyleInner = useAnimatedStyle(() => ({
|
||||||
|
transform: [{ rotate: `${-spin.value * 360}deg` }, { scale: 0.98 + pulse.value * 0.04 }],
|
||||||
|
}));
|
||||||
|
const scannerStyle = useAnimatedStyle(() => ({
|
||||||
|
transform: [{ translateY: (scanY.value * (SCREEN_HEIGHT * 0.45)) - (SCREEN_HEIGHT * 0.225) }],
|
||||||
|
opacity: 0.6 + Math.sin(scanY.value * Math.PI) * 0.2,
|
||||||
|
}));
|
||||||
|
const particleStyleA = useAnimatedStyle(() => ({
|
||||||
|
transform: [
|
||||||
|
{ translateX: Math.sin(particle.value * Math.PI * 2) * 40 },
|
||||||
|
{ translateY: Math.cos(particle.value * Math.PI * 2) * 24 },
|
||||||
|
{ rotate: `${particle.value * 360}deg` },
|
||||||
|
],
|
||||||
|
opacity: 0.5 + 0.5 * Math.abs(Math.sin(particle.value * Math.PI)),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.screen, { backgroundColor: theme.background }]}>
|
||||||
|
<HeaderBar title="AI评估进行中" onBack={() => router.back()} tone="light" transparent />
|
||||||
|
|
||||||
|
{/* Layered background */}
|
||||||
|
<View style={[StyleSheet.absoluteFill, { zIndex: -1 }]} pointerEvents="none">
|
||||||
|
<LinearGradient
|
||||||
|
colors={["#F7FFE8", "#F0FBFF", "#FFF6E8"]}
|
||||||
|
start={{ x: 0.1, y: 0 }}
|
||||||
|
end={{ x: 0.9, y: 1 }}
|
||||||
|
style={StyleSheet.absoluteFill}
|
||||||
|
/>
|
||||||
|
<BlurView intensity={20} tint="light" style={styles.blurBlobA} />
|
||||||
|
<BlurView intensity={20} tint="light" style={styles.blurBlobB} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Hero visualization */}
|
||||||
|
<View style={styles.hero}>
|
||||||
|
<View style={styles.heroBackdrop} />
|
||||||
|
<Animated.View style={[styles.ringOuter, ringStyleOuter]} />
|
||||||
|
<Animated.View style={[styles.ringInner, ringStyleInner]} />
|
||||||
|
|
||||||
|
<View style={styles.grid}>
|
||||||
|
{Array.from({ length: 9 }).map((_, i) => (
|
||||||
|
<View key={`row-${i}`} style={styles.gridRow}>
|
||||||
|
{Array.from({ length: 9 }).map((__, j) => (
|
||||||
|
<View key={`cell-${i}-${j}`} style={styles.gridCell} />
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
<Animated.View style={[styles.scanner, scannerStyle]} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Animated.View style={[styles.particleA, particleStyleA]} />
|
||||||
|
<Animated.View style={[styles.particleB, particleStyleA, { right: undefined, left: SCREEN_WIDTH * 0.2, top: undefined, bottom: 60 }]} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Copy & actions */}
|
||||||
|
<View style={[styles.panel, { paddingBottom: insets.bottom + 16 }]}>
|
||||||
|
<Text style={styles.title}>正在进行体态特征提取与矢量评估</Text>
|
||||||
|
<Text style={styles.subtitle}>这通常需要 10-30 秒。你可以停留在此页面等待结果,或点击返回稍后在个人中心查看。</Text>
|
||||||
|
|
||||||
|
<View style={styles.actions}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.primaryBtn, { backgroundColor: theme.primary }]}
|
||||||
|
activeOpacity={0.9}
|
||||||
|
// TODO: 评估完成后恢复为停留当前页面等待结果(不要跳转)
|
||||||
|
onPress={() => router.replace('/ai-posture-result')}
|
||||||
|
>
|
||||||
|
<Ionicons name="time-outline" size={16} color="#192126" />
|
||||||
|
<Text style={styles.primaryBtnText}>保持页面等待</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity style={styles.secondaryBtn} activeOpacity={0.9} onPress={() => router.replace('/(tabs)/personal')}>
|
||||||
|
<Text style={styles.secondaryBtnText}>返回个人中心</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const RING_SIZE = Math.min(SCREEN_WIDTH, SCREEN_HEIGHT) * 0.62;
|
||||||
|
const INNER_RING_SIZE = RING_SIZE * 0.72;
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
screen: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
hero: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
blurBlobA: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: -80,
|
||||||
|
right: -60,
|
||||||
|
width: 240,
|
||||||
|
height: 240,
|
||||||
|
borderRadius: 120,
|
||||||
|
backgroundColor: 'rgba(187,242,70,0.20)',
|
||||||
|
},
|
||||||
|
blurBlobB: {
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 120,
|
||||||
|
left: -40,
|
||||||
|
width: 220,
|
||||||
|
height: 220,
|
||||||
|
borderRadius: 110,
|
||||||
|
backgroundColor: 'rgba(89, 198, 255, 0.16)',
|
||||||
|
},
|
||||||
|
heroBackdrop: {
|
||||||
|
position: 'absolute',
|
||||||
|
width: RING_SIZE * 1.08,
|
||||||
|
height: RING_SIZE * 1.08,
|
||||||
|
borderRadius: (RING_SIZE * 1.08) / 2,
|
||||||
|
backgroundColor: 'rgba(25,33,38,0.25)',
|
||||||
|
},
|
||||||
|
ringOuter: {
|
||||||
|
position: 'absolute',
|
||||||
|
width: RING_SIZE,
|
||||||
|
height: RING_SIZE,
|
||||||
|
borderRadius: RING_SIZE / 2,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(25,33,38,0.16)',
|
||||||
|
},
|
||||||
|
ringInner: {
|
||||||
|
position: 'absolute',
|
||||||
|
width: INNER_RING_SIZE,
|
||||||
|
height: INNER_RING_SIZE,
|
||||||
|
borderRadius: INNER_RING_SIZE / 2,
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: 'rgba(187,242,70,0.65)',
|
||||||
|
shadowColor: Colors.light.accentGreen,
|
||||||
|
shadowOffset: { width: 0, height: 0 },
|
||||||
|
shadowOpacity: 0.35,
|
||||||
|
shadowRadius: 24,
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
width: RING_SIZE * 0.9,
|
||||||
|
height: RING_SIZE * 0.9,
|
||||||
|
borderRadius: RING_SIZE * 0.45,
|
||||||
|
overflow: 'hidden',
|
||||||
|
padding: 10,
|
||||||
|
backgroundColor: 'rgba(25,33,38,0.08)',
|
||||||
|
},
|
||||||
|
gridRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
},
|
||||||
|
gridCell: {
|
||||||
|
flex: 1,
|
||||||
|
aspectRatio: 1,
|
||||||
|
margin: 2,
|
||||||
|
borderRadius: 3,
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.16)',
|
||||||
|
},
|
||||||
|
scanner: {
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
top: '50%',
|
||||||
|
height: 60,
|
||||||
|
marginTop: -30,
|
||||||
|
backgroundColor: 'rgba(187,242,70,0.10)',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(187,242,70,0.25)',
|
||||||
|
},
|
||||||
|
particleA: {
|
||||||
|
position: 'absolute',
|
||||||
|
right: SCREEN_WIDTH * 0.18,
|
||||||
|
top: 40,
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
borderRadius: 7,
|
||||||
|
backgroundColor: Colors.light.accentGreen,
|
||||||
|
shadowColor: Colors.light.accentGreen,
|
||||||
|
shadowOffset: { width: 0, height: 0 },
|
||||||
|
shadowOpacity: 0.4,
|
||||||
|
shadowRadius: 16,
|
||||||
|
},
|
||||||
|
particleB: {
|
||||||
|
position: 'absolute',
|
||||||
|
right: SCREEN_WIDTH * 0.08,
|
||||||
|
top: 120,
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: 4,
|
||||||
|
backgroundColor: 'rgba(89, 198, 255, 1)',
|
||||||
|
shadowColor: 'rgba(89, 198, 255, 1)',
|
||||||
|
shadowOffset: { width: 0, height: 0 },
|
||||||
|
shadowOpacity: 0.4,
|
||||||
|
shadowRadius: 12,
|
||||||
|
},
|
||||||
|
panel: {
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingTop: 8,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
color: '#ECEDEE',
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '800',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
color: 'rgba(255,255,255,0.75)',
|
||||||
|
fontSize: 14,
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 10,
|
||||||
|
marginTop: 14,
|
||||||
|
},
|
||||||
|
primaryBtn: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 6,
|
||||||
|
height: 44,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
borderRadius: 12,
|
||||||
|
},
|
||||||
|
primaryBtnText: {
|
||||||
|
color: '#192126',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '800',
|
||||||
|
},
|
||||||
|
secondaryBtn: {
|
||||||
|
flex: 1,
|
||||||
|
height: 44,
|
||||||
|
borderRadius: 12,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(255,255,255,0.18)',
|
||||||
|
},
|
||||||
|
secondaryBtnText: {
|
||||||
|
color: 'rgba(255,255,255,0.85)',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
318
app/ai-posture-result.tsx
Normal file
@@ -0,0 +1,318 @@
|
|||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { BlurView } from 'expo-blur';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||||
|
import Animated, { FadeInDown } from 'react-native-reanimated';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
|
import { RadarChart } from '@/components/RadarChart';
|
||||||
|
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||||
|
import { Colors } from '@/constants/Colors';
|
||||||
|
|
||||||
|
type PoseView = 'front' | 'side' | 'back';
|
||||||
|
|
||||||
|
// 斯多特普拉提体态评估维度(示例)
|
||||||
|
const DIMENSIONS = [
|
||||||
|
{ key: 'head_neck', label: '头颈对齐' },
|
||||||
|
{ key: 'shoulder', label: '肩带稳定' },
|
||||||
|
{ key: 'ribs', label: '胸廓控制' },
|
||||||
|
{ key: 'pelvis', label: '骨盆中立' },
|
||||||
|
{ key: 'spine', label: '脊柱排列' },
|
||||||
|
{ key: 'hip_knee', label: '髋膝对线' },
|
||||||
|
];
|
||||||
|
|
||||||
|
type Issue = {
|
||||||
|
title: string;
|
||||||
|
severity: 'low' | 'medium' | 'high';
|
||||||
|
description: string;
|
||||||
|
suggestions: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type ViewReport = {
|
||||||
|
score: number; // 0-5
|
||||||
|
issues: Issue[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type ResultData = {
|
||||||
|
radar: number[]; // 与 DIMENSIONS 对应,0-5
|
||||||
|
overview: string;
|
||||||
|
byView: Record<PoseView, ViewReport>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// NOTE: 此处示例数据,后续可由 API 注入
|
||||||
|
const MOCK_RESULT: ResultData = {
|
||||||
|
radar: [4.2, 3.6, 3.2, 4.6, 3.8, 3.4],
|
||||||
|
overview: '整体体态较为均衡,骨盆与脊柱控制较好;肩带稳定性与胸廓控制仍有提升空间。',
|
||||||
|
byView: {
|
||||||
|
front: {
|
||||||
|
score: 3.8,
|
||||||
|
issues: [
|
||||||
|
{
|
||||||
|
title: '肩峰略前移,肩胛轻度外旋',
|
||||||
|
severity: 'medium',
|
||||||
|
description: '站立正面观察,右侧肩峰较左侧略有前移,提示肩带稳定性偏弱。',
|
||||||
|
suggestions: ['肩胛稳定训练(如天鹅摆臂分解)', '胸椎伸展与放松', '轻度弹力带外旋激活'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
side: {
|
||||||
|
score: 4.1,
|
||||||
|
issues: [
|
||||||
|
{
|
||||||
|
title: '骨盆接近中立,腰椎轻度前凸',
|
||||||
|
severity: 'low',
|
||||||
|
description: '侧面观察,骨盆位置接近中立位,腰椎存在轻度前凸,需注意腹压与肋骨下沉。',
|
||||||
|
suggestions: ['呼吸配合下的腹横肌激活', '猫牛流动改善胸椎灵活性'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
back: {
|
||||||
|
score: 3.5,
|
||||||
|
issues: [
|
||||||
|
{
|
||||||
|
title: '右侧肩胛轻度上抬',
|
||||||
|
severity: 'medium',
|
||||||
|
description: '背面观察,右肩胛较左侧轻度上抬,肩胛下回旋不足。',
|
||||||
|
suggestions: ['锯前肌激活训练', '低位划船,关注肩胛下沉与后缩'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AIPostureResultScreen() {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const router = useRouter();
|
||||||
|
const theme = Colors.light;
|
||||||
|
|
||||||
|
const categories = useMemo(() => DIMENSIONS.map(d => ({ key: d.key, label: d.label })), []);
|
||||||
|
|
||||||
|
const ScoreBadge = ({ score }: { score: number }) => (
|
||||||
|
<View style={styles.scoreBadge}>
|
||||||
|
<Text style={styles.scoreText}>{score.toFixed(1)}</Text>
|
||||||
|
<Text style={styles.scoreUnit}>/5</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
const IssueItem = ({ issue }: { issue: Issue }) => (
|
||||||
|
<View style={styles.issueItem}>
|
||||||
|
<View style={[styles.issueDot, issue.severity === 'high' ? styles.dotHigh : issue.severity === 'medium' ? styles.dotMedium : styles.dotLow]} />
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text style={styles.issueTitle}>{issue.title}</Text>
|
||||||
|
<Text style={styles.issueDesc}>{issue.description}</Text>
|
||||||
|
{!!issue.suggestions?.length && (
|
||||||
|
<View style={styles.suggestRow}>
|
||||||
|
{issue.suggestions.map((s, idx) => (
|
||||||
|
<View key={idx} style={styles.suggestChip}><Text style={styles.suggestText}>{s}</Text></View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ViewCard = ({ title, report }: { title: string; report: ViewReport }) => (
|
||||||
|
<Animated.View entering={FadeInDown.duration(400)} style={styles.card}>
|
||||||
|
<View style={styles.cardHeader}>
|
||||||
|
<Text style={styles.cardTitle}>{title}</Text>
|
||||||
|
<ScoreBadge score={report.score} />
|
||||||
|
</View>
|
||||||
|
{report.issues.map((iss, idx) => (<IssueItem key={idx} issue={iss} />))}
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.screen, { backgroundColor: theme.background }]}>
|
||||||
|
<HeaderBar title="体态评估结果" onBack={() => router.back()} tone="light" transparent />
|
||||||
|
|
||||||
|
{/* 背景装饰 */}
|
||||||
|
<View style={[StyleSheet.absoluteFill, { zIndex: -1 }]} pointerEvents="none">
|
||||||
|
<BlurView intensity={20} tint="light" style={styles.bgBlobA} />
|
||||||
|
<BlurView intensity={20} tint="light" style={styles.bgBlobB} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView contentContainerStyle={{ paddingBottom: insets.bottom + 40 }} showsVerticalScrollIndicator={false}>
|
||||||
|
{/* 总览与雷达图 */}
|
||||||
|
<Animated.View entering={FadeInDown.duration(400)} style={styles.card}>
|
||||||
|
<Text style={styles.sectionTitle}>总体概览</Text>
|
||||||
|
<Text style={styles.overview}>{MOCK_RESULT.overview}</Text>
|
||||||
|
<View style={styles.radarWrap}>
|
||||||
|
<RadarChart categories={categories} values={MOCK_RESULT.radar} />
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
{/* 视图分析 */}
|
||||||
|
<ViewCard title="正面视图" report={MOCK_RESULT.byView.front} />
|
||||||
|
<ViewCard title="侧面视图" report={MOCK_RESULT.byView.side} />
|
||||||
|
<ViewCard title="背面视图" report={MOCK_RESULT.byView.back} />
|
||||||
|
|
||||||
|
{/* 底部操作 */}
|
||||||
|
<View style={styles.actions}>
|
||||||
|
<TouchableOpacity style={[styles.primaryBtn, { backgroundColor: theme.primary }]} onPress={() => router.replace('/(tabs)/personal')}>
|
||||||
|
<Ionicons name="checkmark-circle" size={18} color={theme.onPrimary} />
|
||||||
|
<Text style={[styles.primaryBtnText, { color: theme.onPrimary }]}>完成并返回</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity style={[styles.secondaryBtn, { borderColor: theme.border }]} onPress={() => router.push('/(tabs)/coach')}>
|
||||||
|
<Text style={[styles.secondaryBtnText, { color: theme.text }]}>生成训练建议</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
screen: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
bgBlobA: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: -60,
|
||||||
|
right: -40,
|
||||||
|
width: 200,
|
||||||
|
height: 200,
|
||||||
|
borderRadius: 100,
|
||||||
|
backgroundColor: 'rgba(187,242,70,0.18)',
|
||||||
|
},
|
||||||
|
bgBlobB: {
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 100,
|
||||||
|
left: -30,
|
||||||
|
width: 180,
|
||||||
|
height: 180,
|
||||||
|
borderRadius: 90,
|
||||||
|
backgroundColor: 'rgba(89, 198, 255, 0.16)',
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
marginTop: 16,
|
||||||
|
marginHorizontal: 16,
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 14,
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.72)',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(25,33,38,0.08)',
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
color: '#192126',
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '800',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
overview: {
|
||||||
|
color: '#384046',
|
||||||
|
fontSize: 14,
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
radarWrap: {
|
||||||
|
marginTop: 10,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
cardHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
cardTitle: {
|
||||||
|
color: '#192126',
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
scoreBadge: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'flex-end',
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 6,
|
||||||
|
borderRadius: 10,
|
||||||
|
backgroundColor: 'rgba(187,242,70,0.16)',
|
||||||
|
},
|
||||||
|
scoreText: {
|
||||||
|
color: '#192126',
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '800',
|
||||||
|
},
|
||||||
|
scoreUnit: {
|
||||||
|
color: '#5E6468',
|
||||||
|
fontSize: 12,
|
||||||
|
marginLeft: 4,
|
||||||
|
},
|
||||||
|
issueItem: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 10,
|
||||||
|
paddingVertical: 10,
|
||||||
|
},
|
||||||
|
issueDot: {
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
borderRadius: 5,
|
||||||
|
marginTop: 6,
|
||||||
|
},
|
||||||
|
dotHigh: { backgroundColor: '#E24D4D' },
|
||||||
|
dotMedium: { backgroundColor: '#F0C23C' },
|
||||||
|
dotLow: { backgroundColor: '#2BCC7F' },
|
||||||
|
issueTitle: {
|
||||||
|
color: '#192126',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
issueDesc: {
|
||||||
|
color: '#5E6468',
|
||||||
|
fontSize: 13,
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
suggestRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: 8,
|
||||||
|
marginTop: 8,
|
||||||
|
},
|
||||||
|
suggestChip: {
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 6,
|
||||||
|
borderRadius: 12,
|
||||||
|
backgroundColor: 'rgba(25,33,38,0.04)',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(25,33,38,0.08)',
|
||||||
|
},
|
||||||
|
suggestText: {
|
||||||
|
color: '#192126',
|
||||||
|
fontSize: 12,
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
marginTop: 16,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 10,
|
||||||
|
},
|
||||||
|
primaryBtn: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 8,
|
||||||
|
height: 48,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
borderRadius: 14,
|
||||||
|
},
|
||||||
|
primaryBtnText: {
|
||||||
|
color: '#192126',
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: '800',
|
||||||
|
},
|
||||||
|
secondaryBtn: {
|
||||||
|
flex: 1,
|
||||||
|
height: 48,
|
||||||
|
borderRadius: 14,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'transparent',
|
||||||
|
},
|
||||||
|
secondaryBtnText: {
|
||||||
|
color: '#384046',
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
126
app/article/[id].tsx
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||||
|
import { Colors } from '@/constants/Colors';
|
||||||
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
|
import { Article, getArticleById } from '@/services/articles';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { ScrollView, StyleSheet, Text, View, useWindowDimensions } from 'react-native';
|
||||||
|
import RenderHTML from 'react-native-render-html';
|
||||||
|
|
||||||
|
export default function ArticleDetailScreen() {
|
||||||
|
const { id } = useLocalSearchParams<{ id: string }>();
|
||||||
|
const router = useRouter();
|
||||||
|
const [article, setArticle] = useState<Article | undefined>(undefined);
|
||||||
|
const { width } = useWindowDimensions();
|
||||||
|
const colorScheme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||||
|
const theme = Colors[colorScheme];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (id) {
|
||||||
|
getArticleById(id).then((article) => {
|
||||||
|
console.log('article', article);
|
||||||
|
setArticle(article);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
if (!article) {
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<HeaderBar title="文章" onBack={() => router.back()} showBottomBorder />
|
||||||
|
<View style={{ padding: 24 }}>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = { html: wrapHtml(article.htmlContent) };
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1, backgroundColor: theme.surface }}>
|
||||||
|
<HeaderBar title="文章" onBack={() => router.back()} showBottomBorder />
|
||||||
|
<ScrollView contentContainerStyle={styles.contentContainer} showsVerticalScrollIndicator={false}>
|
||||||
|
<View style={styles.headerMeta}>
|
||||||
|
<Text style={[styles.title, { color: theme.text }]}>{article.title}</Text>
|
||||||
|
<View style={styles.row}>
|
||||||
|
<Text style={[styles.metaText, { color: theme.textMuted }]}>{dayjs(article.publishedAt).format('YYYY-MM-DD')}</Text>
|
||||||
|
<Text style={[styles.metaText, styles.dot]}>·</Text>
|
||||||
|
<Text style={[styles.metaText, { color: theme.textMuted }]}>{article.readCount} 阅读</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<RenderHTML
|
||||||
|
contentWidth={width - 48}
|
||||||
|
source={source}
|
||||||
|
baseStyle={{ ...htmlBaseStyles, color: theme.text }}
|
||||||
|
tagsStyles={htmlTagStyles}
|
||||||
|
enableExperimentalMarginCollapsing={true}
|
||||||
|
/>
|
||||||
|
<View style={{ height: 36 }} />
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function wrapHtml(inner: string) {
|
||||||
|
// 为了统一排版与图片自适应
|
||||||
|
return `
|
||||||
|
<div class="article">
|
||||||
|
${inner}
|
||||||
|
</div>
|
||||||
|
<style>
|
||||||
|
.article img { max-width: 100%; height: auto; border-radius: 12px; }
|
||||||
|
</style>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
contentContainer: {
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
paddingTop: 12,
|
||||||
|
},
|
||||||
|
headerMeta: {
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: '800',
|
||||||
|
color: '#192126',
|
||||||
|
},
|
||||||
|
row: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginTop: 8,
|
||||||
|
},
|
||||||
|
metaText: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#8A8A8E',
|
||||||
|
},
|
||||||
|
dot: {
|
||||||
|
paddingHorizontal: 6,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const htmlBaseStyles = {
|
||||||
|
color: '#192126',
|
||||||
|
lineHeight: 24,
|
||||||
|
fontSize: 16,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const htmlTagStyles = {
|
||||||
|
h1: { fontSize: 26, fontWeight: '800', marginBottom: 8 },
|
||||||
|
h2: { fontSize: 22, fontWeight: '800', marginTop: 8, marginBottom: 8 },
|
||||||
|
h3: { fontSize: 18, fontWeight: '700', marginTop: 12, marginBottom: 6 },
|
||||||
|
p: { marginBottom: 12 },
|
||||||
|
ol: { marginBottom: 12, paddingLeft: 18 },
|
||||||
|
ul: { marginBottom: 12, paddingLeft: 18 },
|
||||||
|
li: { marginBottom: 6 },
|
||||||
|
img: { marginTop: 8, marginBottom: 8, borderRadius: 12 },
|
||||||
|
em: { fontStyle: 'italic' },
|
||||||
|
strong: { fontWeight: '800' },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
|
||||||
406
app/auth/login.tsx
Normal file
@@ -0,0 +1,406 @@
|
|||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import * as AppleAuthentication from 'expo-apple-authentication';
|
||||||
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
|
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||||
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { Alert, Animated, Linking, Pressable, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
|
import { ThemedText } from '@/components/ThemedText';
|
||||||
|
import { ThemedView } from '@/components/ThemedView';
|
||||||
|
import { PRIVACY_POLICY_URL, USER_AGREEMENT_URL } from '@/constants/Agree';
|
||||||
|
import { Colors } from '@/constants/Colors';
|
||||||
|
import { useAppDispatch } from '@/hooks/redux';
|
||||||
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
|
import { fetchMyProfile, login } from '@/store/userSlice';
|
||||||
|
import Toast from 'react-native-toast-message';
|
||||||
|
|
||||||
|
export default function LoginScreen() {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useLocalSearchParams<{ redirectTo?: string; redirectParams?: string }>();
|
||||||
|
const scheme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||||
|
const color = Colors[scheme];
|
||||||
|
const pageBackground = scheme === 'light' ? color.pageBackgroundEmphasis : color.background;
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const AnimatedLinear = useMemo(() => Animated.createAnimatedComponent(LinearGradient), []);
|
||||||
|
|
||||||
|
// 背景动效:轻微平移/旋转与呼吸动画
|
||||||
|
const translateAnim = useRef(new Animated.Value(0)).current;
|
||||||
|
const rotateAnim = useRef(new Animated.Value(0)).current;
|
||||||
|
const pulseAnimA = useRef(new Animated.Value(0)).current;
|
||||||
|
const pulseAnimB = useRef(new Animated.Value(0)).current;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loopTranslate = Animated.loop(
|
||||||
|
Animated.sequence([
|
||||||
|
Animated.timing(translateAnim, { toValue: 1, duration: 6000, useNativeDriver: true }),
|
||||||
|
Animated.timing(translateAnim, { toValue: 0, duration: 6000, useNativeDriver: true }),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
const loopRotate = Animated.loop(
|
||||||
|
Animated.sequence([
|
||||||
|
Animated.timing(rotateAnim, { toValue: 1, duration: 10000, useNativeDriver: true }),
|
||||||
|
Animated.timing(rotateAnim, { toValue: 0, duration: 10000, useNativeDriver: true }),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
const loopPulseA = Animated.loop(
|
||||||
|
Animated.sequence([
|
||||||
|
Animated.timing(pulseAnimA, { toValue: 1, duration: 3500, useNativeDriver: true }),
|
||||||
|
Animated.timing(pulseAnimA, { toValue: 0, duration: 3500, useNativeDriver: true }),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
const loopPulseB = Animated.loop(
|
||||||
|
Animated.sequence([
|
||||||
|
Animated.timing(pulseAnimB, { toValue: 1, duration: 4200, useNativeDriver: true }),
|
||||||
|
Animated.timing(pulseAnimB, { toValue: 0, duration: 4200, useNativeDriver: true }),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
loopTranslate.start();
|
||||||
|
loopRotate.start();
|
||||||
|
loopPulseA.start();
|
||||||
|
loopPulseB.start();
|
||||||
|
return () => {
|
||||||
|
loopTranslate.stop();
|
||||||
|
loopRotate.stop();
|
||||||
|
loopPulseA.stop();
|
||||||
|
loopPulseB.stop();
|
||||||
|
};
|
||||||
|
}, [pulseAnimA, pulseAnimB, rotateAnim, translateAnim]);
|
||||||
|
|
||||||
|
const [hasAgreed, setHasAgreed] = useState<boolean>(false);
|
||||||
|
const [appleAvailable, setAppleAvailable] = useState<boolean>(false);
|
||||||
|
const [loading, setLoading] = useState<boolean>(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
AppleAuthentication.isAvailableAsync().then(setAppleAvailable).catch(() => setAppleAvailable(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const guardAgreement = useCallback((action: () => void) => {
|
||||||
|
if (!hasAgreed) {
|
||||||
|
Alert.alert(
|
||||||
|
'请先阅读并同意',
|
||||||
|
'继续登录前,请阅读并勾选《隐私政策》和《用户协议》。点击“同意并继续”将默认勾选并继续登录。',
|
||||||
|
[
|
||||||
|
{ text: '取消', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: '同意并继续',
|
||||||
|
onPress: () => {
|
||||||
|
setHasAgreed(true);
|
||||||
|
setTimeout(() => action(), 0);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{ cancelable: true }
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
action();
|
||||||
|
}, [hasAgreed]);
|
||||||
|
|
||||||
|
const onAppleLogin = useCallback(async () => {
|
||||||
|
if (!appleAvailable) return;
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const credential = await AppleAuthentication.signInAsync({
|
||||||
|
requestedScopes: [
|
||||||
|
AppleAuthentication.AppleAuthenticationScope.FULL_NAME,
|
||||||
|
AppleAuthentication.AppleAuthenticationScope.EMAIL,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const identityToken = (credential as any)?.identityToken;
|
||||||
|
if (!identityToken || typeof identityToken !== 'string') {
|
||||||
|
throw new Error('未获取到 Apple 身份令牌');
|
||||||
|
}
|
||||||
|
await dispatch(login({ appleIdentityToken: identityToken })).unwrap();
|
||||||
|
|
||||||
|
// 拉取用户信息
|
||||||
|
await dispatch(fetchMyProfile())
|
||||||
|
|
||||||
|
Toast.show({
|
||||||
|
text1: '登录成功',
|
||||||
|
type: 'success',
|
||||||
|
});
|
||||||
|
// 登录成功后处理重定向
|
||||||
|
const to = searchParams?.redirectTo as string | undefined;
|
||||||
|
const paramsJson = searchParams?.redirectParams as string | undefined;
|
||||||
|
let parsedParams: Record<string, any> | undefined;
|
||||||
|
if (paramsJson) {
|
||||||
|
try { parsedParams = JSON.parse(paramsJson); } catch { }
|
||||||
|
}
|
||||||
|
if (to) {
|
||||||
|
router.replace({ pathname: to, params: parsedParams } as any);
|
||||||
|
} else {
|
||||||
|
router.back();
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.log('err.code', err.code);
|
||||||
|
|
||||||
|
if (err?.code === 'ERR_CANCELED' || err?.code === 'ERR_REQUEST_CANCELED') return;
|
||||||
|
const message = err?.message || '登录失败,请稍后再试';
|
||||||
|
Alert.alert('登录失败', message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [appleAvailable, router, searchParams?.redirectParams, searchParams?.redirectTo]);
|
||||||
|
|
||||||
|
|
||||||
|
// 登录按钮不再因未勾选协议而禁用,仅在加载中禁用
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView edges={['top']} style={[styles.safeArea, { backgroundColor: pageBackground }]}>
|
||||||
|
<ThemedView style={[styles.container, { backgroundColor: pageBackground }]}>
|
||||||
|
{/* 动态背景层(置于内容之下) */}
|
||||||
|
<View pointerEvents="none" style={styles.bgWrap}>
|
||||||
|
{/* 基础全屏渐变:保证覆盖全屏 */}
|
||||||
|
<AnimatedLinear
|
||||||
|
colors={
|
||||||
|
scheme === 'light'
|
||||||
|
? [color.pageBackgroundEmphasis, color.heroSurfaceTint, color.surface]
|
||||||
|
: [color.background, '#0F1112', color.surface]
|
||||||
|
}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 1 }}
|
||||||
|
style={[styles.bgGradientFull]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 次级大面积渐变:对角线方向形成层次 */}
|
||||||
|
<AnimatedLinear
|
||||||
|
colors={
|
||||||
|
scheme === 'light'
|
||||||
|
? ['rgba(164,138,237,0.12)', 'rgba(187,242,70,0.16)', 'transparent']
|
||||||
|
: ['rgba(164,138,237,0.16)', 'rgba(187,242,70,0.12)', 'transparent']
|
||||||
|
}
|
||||||
|
start={{ x: 1, y: 0 }}
|
||||||
|
end={{ x: 0, y: 1 }}
|
||||||
|
style={[
|
||||||
|
styles.bgGradientCover,
|
||||||
|
{
|
||||||
|
transform: [
|
||||||
|
{
|
||||||
|
rotate: rotateAnim.interpolate({ inputRange: [0, 1], outputRange: ['-4deg', '6deg'] }),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
opacity: scheme === 'light' ? 0.9 : 0.65,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 动感色块 A(主色呼吸,置于左下) */}
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.accentBlobLarge,
|
||||||
|
{
|
||||||
|
backgroundColor: color.ornamentPrimary,
|
||||||
|
transform: [
|
||||||
|
{ translateX: -80 },
|
||||||
|
{ translateY: 320 },
|
||||||
|
{ scale: pulseAnimA.interpolate({ inputRange: [0, 1], outputRange: [1, 1.05] }) },
|
||||||
|
],
|
||||||
|
opacity: scheme === 'light' ? 0.55 : 0.4,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 动感色块 B(辅色漂移,置于右上) */}
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.accentBlobMedium,
|
||||||
|
{
|
||||||
|
backgroundColor: color.ornamentAccent,
|
||||||
|
transform: [
|
||||||
|
{ translateX: 240 },
|
||||||
|
{ translateY: -40 },
|
||||||
|
{ scale: pulseAnimB.interpolate({ inputRange: [0, 1], outputRange: [1, 1.07] }) },
|
||||||
|
],
|
||||||
|
opacity: scheme === 'light' ? 0.5 : 0.38,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
{/* 自定义头部,与其它页面风格一致 */}
|
||||||
|
<View style={styles.header}>
|
||||||
|
<TouchableOpacity accessibilityRole="button" onPress={() => router.back()} style={styles.backButton}>
|
||||||
|
<Ionicons name="chevron-back" size={24} color={scheme === 'dark' ? '#ECEDEE' : '#192126'} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text style={[styles.headerTitle, { color: color.text }]}>登录</Text>
|
||||||
|
<View style={{ width: 32 }} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView contentContainerStyle={styles.content} showsVerticalScrollIndicator={false}>
|
||||||
|
<View style={styles.headerWrap}>
|
||||||
|
<ThemedText style={[styles.title, { color: color.text }]}>Out Live</ThemedText>
|
||||||
|
<ThemedText style={[styles.subtitle, { color: color.textMuted }]}>欢迎登录Out Live</ThemedText>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Apple 登录 */}
|
||||||
|
{appleAvailable && (
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
onPress={() => guardAgreement(onAppleLogin)}
|
||||||
|
disabled={loading}
|
||||||
|
style={({ pressed }) => [
|
||||||
|
styles.appleButton,
|
||||||
|
{ backgroundColor: '#000000' },
|
||||||
|
loading && { opacity: 0.7 },
|
||||||
|
pressed && { transform: [{ scale: 0.98 }] },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Ionicons name="logo-apple" size={22} color="#FFFFFF" style={{ marginRight: 10 }} />
|
||||||
|
<Text style={styles.appleText}>使用 Apple 登录</Text>
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 协议勾选 */}
|
||||||
|
<View style={styles.agreementRow}>
|
||||||
|
<Pressable onPress={() => setHasAgreed((v) => !v)} style={styles.checkboxWrap} accessibilityRole="checkbox" accessibilityState={{ checked: hasAgreed }}>
|
||||||
|
<View
|
||||||
|
style={[styles.checkbox, {
|
||||||
|
backgroundColor: hasAgreed ? color.primary : 'transparent',
|
||||||
|
borderColor: hasAgreed ? color.primary : color.border,
|
||||||
|
}]}
|
||||||
|
>
|
||||||
|
{hasAgreed && <Ionicons name="checkmark" size={14} color={color.onPrimary} />}
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
<Text style={[styles.agreementText, { color: color.textMuted }]}>我已阅读并同意</Text>
|
||||||
|
<Pressable onPress={() => Linking.openURL(PRIVACY_POLICY_URL)}>
|
||||||
|
<Text style={[styles.link, { color: color.primary }]}>《隐私政策》</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Text style={[styles.agreementText, { color: color.textMuted }]}>和</Text>
|
||||||
|
<Pressable onPress={() => Linking.openURL(USER_AGREEMENT_URL)}>
|
||||||
|
<Text style={[styles.link, { color: color.primary }]}>《用户协议》</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 占位底部间距 */}
|
||||||
|
<View style={{ height: 40 }} />
|
||||||
|
</ScrollView>
|
||||||
|
</ThemedView>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
safeArea: { flex: 1 },
|
||||||
|
container: { flex: 1 },
|
||||||
|
content: {
|
||||||
|
flexGrow: 1,
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingTop: 4,
|
||||||
|
paddingBottom: 8,
|
||||||
|
},
|
||||||
|
backButton: { width: 32, height: 32, alignItems: 'center', justifyContent: 'center' },
|
||||||
|
headerTitle: { fontSize: 18, fontWeight: '700' },
|
||||||
|
headerWrap: {
|
||||||
|
marginBottom: 36,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 32,
|
||||||
|
fontWeight: '500',
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
lineHeight: 38,
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
marginTop: 8,
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
appleButton: {
|
||||||
|
height: 56,
|
||||||
|
borderRadius: 28,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
flexDirection: 'row',
|
||||||
|
marginBottom: 16,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 8 },
|
||||||
|
shadowOpacity: 0.15,
|
||||||
|
shadowRadius: 12,
|
||||||
|
elevation: 2,
|
||||||
|
},
|
||||||
|
appleText: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: '#FFFFFF',
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
guestButton: {
|
||||||
|
height: 52,
|
||||||
|
borderRadius: 26,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
flexDirection: 'row',
|
||||||
|
borderWidth: 1,
|
||||||
|
marginTop: 6,
|
||||||
|
},
|
||||||
|
guestText: {
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
agreementRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
marginTop: 24,
|
||||||
|
},
|
||||||
|
checkboxWrap: { marginRight: 8 },
|
||||||
|
checkbox: {
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
borderRadius: 5,
|
||||||
|
borderWidth: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
agreementText: { fontSize: 12 },
|
||||||
|
link: { fontSize: 12, fontWeight: '600' },
|
||||||
|
footerHint: { marginTop: 24 },
|
||||||
|
hintText: { fontSize: 12 },
|
||||||
|
// 背景样式
|
||||||
|
bgWrap: {
|
||||||
|
...StyleSheet.absoluteFillObject,
|
||||||
|
zIndex: 0,
|
||||||
|
},
|
||||||
|
bgGradientFull: {
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
},
|
||||||
|
bgGradientCover: {
|
||||||
|
position: 'absolute',
|
||||||
|
left: '-10%',
|
||||||
|
top: '-15%',
|
||||||
|
width: '130%',
|
||||||
|
height: '70%',
|
||||||
|
borderBottomLeftRadius: 36,
|
||||||
|
borderBottomRightRadius: 36,
|
||||||
|
},
|
||||||
|
accentBlob: {
|
||||||
|
position: 'absolute',
|
||||||
|
width: 180,
|
||||||
|
height: 180,
|
||||||
|
borderRadius: 90,
|
||||||
|
},
|
||||||
|
accentBlobLarge: {
|
||||||
|
position: 'absolute',
|
||||||
|
width: 260,
|
||||||
|
height: 260,
|
||||||
|
borderRadius: 130,
|
||||||
|
},
|
||||||
|
accentBlobMedium: {
|
||||||
|
position: 'absolute',
|
||||||
|
width: 180,
|
||||||
|
height: 180,
|
||||||
|
borderRadius: 90,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
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',
|
||||||
|
},
|
||||||
|
});
|
||||||
2350
app/coach.tsx
Normal file
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',
|
||||||
|
},
|
||||||
|
});
|
||||||
524
app/explore.tsx
Normal file
@@ -0,0 +1,524 @@
|
|||||||
|
import { ArticleCard } from '@/components/ArticleCard';
|
||||||
|
import { PlanCard } from '@/components/PlanCard';
|
||||||
|
import { SearchBox } from '@/components/SearchBox';
|
||||||
|
import { ThemedText } from '@/components/ThemedText';
|
||||||
|
import { ThemedView } from '@/components/ThemedView';
|
||||||
|
import { Colors } from '@/constants/Colors';
|
||||||
|
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||||
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
|
import { fetchRecommendations, RecommendationType } from '@/services/recommendations';
|
||||||
|
import { loadPlans } from '@/store/trainingPlanSlice';
|
||||||
|
// Removed WorkoutCard import since we no longer use the horizontal carousel
|
||||||
|
import { QUERY_PARAMS, ROUTE_PARAMS, ROUTES } from '@/constants/Routes';
|
||||||
|
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||||
|
import { TrainingPlan } from '@/services/trainingPlanApi';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import React from 'react';
|
||||||
|
import { Animated, Image, PanResponder, Pressable, ScrollView, StyleSheet, useWindowDimensions, View } from 'react-native';
|
||||||
|
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
|
// 移除旧的“热门活动”滑动数据,改为固定的“热点功能”卡片
|
||||||
|
|
||||||
|
export default function HomeScreen() {
|
||||||
|
const router = useRouter();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const { pushIfAuthedElseLogin, isLoggedIn } = useAuthGuard();
|
||||||
|
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||||
|
const colorTokens = Colors[theme];
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
|
||||||
|
|
||||||
|
// 训练计划状态
|
||||||
|
const { plans } = useAppSelector((s) => s.trainingPlan);
|
||||||
|
const [activePlan, setActivePlan] = React.useState<TrainingPlan | null>(null);
|
||||||
|
|
||||||
|
// Draggable coach badge state
|
||||||
|
const pan = React.useRef(new Animated.ValueXY()).current;
|
||||||
|
const [coachSize, setCoachSize] = React.useState({ width: 0, height: 0 });
|
||||||
|
const hasInitPos = React.useRef(false);
|
||||||
|
const startRef = React.useRef({ x: 0, y: 0 });
|
||||||
|
const dragState = React.useRef({ moved: false });
|
||||||
|
|
||||||
|
const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(max, value));
|
||||||
|
|
||||||
|
const panResponder = React.useMemo(() => PanResponder.create({
|
||||||
|
onStartShouldSetPanResponder: () => true,
|
||||||
|
onMoveShouldSetPanResponder: (_evt, gesture) => Math.abs(gesture.dx) + Math.abs(gesture.dy) > 2,
|
||||||
|
onPanResponderGrant: () => {
|
||||||
|
dragState.current.moved = false;
|
||||||
|
// @ts-ignore access current value
|
||||||
|
const currentX = (pan.x as any)._value ?? 0;
|
||||||
|
// @ts-ignore access current value
|
||||||
|
const currentY = (pan.y as any)._value ?? 0;
|
||||||
|
startRef.current = { x: currentX, y: currentY };
|
||||||
|
},
|
||||||
|
onPanResponderMove: (_evt, gesture) => {
|
||||||
|
if (!dragState.current.moved && (Math.abs(gesture.dx) + Math.abs(gesture.dy) > 4)) {
|
||||||
|
dragState.current.moved = true;
|
||||||
|
}
|
||||||
|
const nextX = startRef.current.x + gesture.dx;
|
||||||
|
const nextY = startRef.current.y + gesture.dy;
|
||||||
|
pan.setValue({ x: nextX, y: nextY });
|
||||||
|
},
|
||||||
|
onPanResponderRelease: (_evt, gesture) => {
|
||||||
|
const minX = 8;
|
||||||
|
const minY = insets.top + 2;
|
||||||
|
const maxX = Math.max(minX, windowWidth - coachSize.width - 8);
|
||||||
|
const maxY = Math.max(minY, windowHeight - coachSize.height - (insets.bottom + 8));
|
||||||
|
const rawX = startRef.current.x + gesture.dx;
|
||||||
|
const rawY = startRef.current.y + gesture.dy;
|
||||||
|
const clampedX = clamp(rawX, minX, maxX);
|
||||||
|
const clampedY = clamp(rawY, minY, maxY);
|
||||||
|
// Snap horizontally to nearest side (left/right only)
|
||||||
|
const distLeft = Math.abs(clampedX - minX);
|
||||||
|
const distRight = Math.abs(maxX - clampedX);
|
||||||
|
const snapX = distLeft <= distRight ? minX : maxX;
|
||||||
|
Animated.spring(pan, { toValue: { x: snapX, y: clampedY }, useNativeDriver: false, bounciness: 6 }).start(() => {
|
||||||
|
if (!dragState.current.moved) {
|
||||||
|
// 切换到教练 tab,并传递name参数
|
||||||
|
router.push(`${ROUTES.TAB_COACH}?${QUERY_PARAMS.COACH_NAME}=Iris` as any);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}), [coachSize.height, coachSize.width, insets.bottom, insets.top, pan, windowHeight, windowWidth, router]);
|
||||||
|
// 推荐项类型(本地 UI 使用)
|
||||||
|
type RecommendItem =
|
||||||
|
| {
|
||||||
|
type: 'plan';
|
||||||
|
key: string;
|
||||||
|
image: string;
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
level?: '初学者' | '中级' | '高级';
|
||||||
|
onPress?: () => void;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'article';
|
||||||
|
key: string;
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
coverImage: string;
|
||||||
|
publishedAt: string;
|
||||||
|
readCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const [items, setItems] = React.useState<RecommendItem[]>();
|
||||||
|
|
||||||
|
// 加载训练计划数据
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (isLoggedIn) {
|
||||||
|
dispatch(loadPlans());
|
||||||
|
}
|
||||||
|
}, [isLoggedIn, dispatch]);
|
||||||
|
|
||||||
|
// 获取激活的训练计划
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (isLoggedIn && plans.length > 0) {
|
||||||
|
const currentPlan = plans.find(p => p.isActive);
|
||||||
|
setActivePlan(currentPlan || null);
|
||||||
|
} else {
|
||||||
|
setActivePlan(null);
|
||||||
|
}
|
||||||
|
}, [isLoggedIn, plans]);
|
||||||
|
|
||||||
|
// 拉取推荐接口(已登录时)
|
||||||
|
React.useEffect(() => {
|
||||||
|
let canceled = false;
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const cards = await fetchRecommendations();
|
||||||
|
|
||||||
|
if (canceled) return;
|
||||||
|
const mapped: RecommendItem[] = [];
|
||||||
|
for (const c of cards || []) {
|
||||||
|
if (c.type === RecommendationType.Article) {
|
||||||
|
const publishedAt = (c.extra && (c.extra.publishedDate || c.extra.published_at)) || new Date().toISOString();
|
||||||
|
const readCount = (c.extra && (c.extra.readCount ?? c.extra.read_count)) || 0;
|
||||||
|
mapped.push({
|
||||||
|
type: 'article',
|
||||||
|
key: c.id,
|
||||||
|
id: c.articleId || c.id,
|
||||||
|
title: c.title || '',
|
||||||
|
coverImage: c.coverUrl,
|
||||||
|
publishedAt,
|
||||||
|
readCount,
|
||||||
|
});
|
||||||
|
} else if (c.type === RecommendationType.Checkin) {
|
||||||
|
mapped.push({
|
||||||
|
type: 'plan',
|
||||||
|
key: c.id || 'checkin',
|
||||||
|
image: 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/imagedemo.jpeg',
|
||||||
|
title: c.title || '今日训练',
|
||||||
|
subtitle: c.subtitle || '完成一次普拉提训练,记录你的坚持',
|
||||||
|
onPress: () => pushIfAuthedElseLogin(ROUTES.WORKOUT_TODAY),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 若接口返回空,也回退到打底
|
||||||
|
setItems(mapped.length > 0 ? mapped : []);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('fetchRecommendations error', e);
|
||||||
|
setItems([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
load();
|
||||||
|
return () => { canceled = true; };
|
||||||
|
}, [isLoggedIn, pushIfAuthedElseLogin]);
|
||||||
|
|
||||||
|
// 处理点击训练计划卡片,跳转到锻炼tab
|
||||||
|
const handlePlanCardPress = () => {
|
||||||
|
if (activePlan) {
|
||||||
|
// 跳转到训练计划页面的锻炼tab,并传递planId参数
|
||||||
|
router.push(`${ROUTES.TRAINING_PLAN}?${ROUTE_PARAMS.TRAINING_PLAN_ID}=${activePlan.id}&${ROUTE_PARAMS.TRAINING_PLAN_TAB}=${QUERY_PARAMS.TRAINING_PLAN_TAB_SCHEDULE}` as any);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={[styles.safeArea, { backgroundColor: theme === 'light' ? colorTokens.pageBackgroundEmphasis : colorTokens.background }]}>
|
||||||
|
<ThemedView style={[styles.container, { backgroundColor: theme === 'light' ? colorTokens.pageBackgroundEmphasis : colorTokens.background }]}>
|
||||||
|
{/* Floating Coach Badge */}
|
||||||
|
<View pointerEvents="box-none" style={styles.coachOverlayWrap}>
|
||||||
|
<Animated.View
|
||||||
|
{...panResponder.panHandlers}
|
||||||
|
onLayout={(e) => {
|
||||||
|
const { width, height } = e.nativeEvent.layout;
|
||||||
|
if (width !== coachSize.width || height !== coachSize.height) {
|
||||||
|
setCoachSize({ width, height });
|
||||||
|
}
|
||||||
|
if (!hasInitPos.current && width > 0 && windowWidth > 0) {
|
||||||
|
const initX = windowWidth - width - 14;
|
||||||
|
const initY = insets.top + 2; // 默认更靠上,避免遮挡搜索框
|
||||||
|
pan.setValue({ x: initX, y: initY });
|
||||||
|
hasInitPos.current = true;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={[
|
||||||
|
styles.coachBadge,
|
||||||
|
{
|
||||||
|
transform: [{ translateX: pan.x }, { translateY: pan.y }],
|
||||||
|
backgroundColor: colorTokens.heroSurfaceTint,
|
||||||
|
borderColor: 'rgba(187,242,70,0.35)',
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOpacity: 0.08,
|
||||||
|
shadowRadius: 10,
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
elevation: 3,
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
source={{ uri: 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/avatar/imageCoach01.jpeg' }}
|
||||||
|
style={styles.coachAvatar}
|
||||||
|
/>
|
||||||
|
<View style={styles.coachMeta}>
|
||||||
|
<ThemedText style={styles.coachName}>Iris</ThemedText>
|
||||||
|
<View style={styles.coachStatusRow}>
|
||||||
|
<View style={styles.statusDot} />
|
||||||
|
<ThemedText style={styles.coachStatusText}>在线</ThemedText>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
</View>
|
||||||
|
<ScrollView showsVerticalScrollIndicator={false}>
|
||||||
|
{/* Header Section */}
|
||||||
|
{/* <View style={styles.header}>
|
||||||
|
<ThemedText style={styles.greeting}>{getChineseGreeting()}</ThemedText>
|
||||||
|
<ThemedText style={styles.userName}></ThemedText>
|
||||||
|
</View> */}
|
||||||
|
|
||||||
|
{/* Search Box */}
|
||||||
|
<SearchBox placeholder="搜索" />
|
||||||
|
|
||||||
|
{/* Hot Features Section */}
|
||||||
|
<View style={styles.sectionContainer}>
|
||||||
|
<ThemedText style={styles.sectionTitle}>热点功能</ThemedText>
|
||||||
|
|
||||||
|
<View style={styles.featureGrid}>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
style={[styles.featureCard, styles.featureCardQuinary]}
|
||||||
|
onPress={() => pushIfAuthedElseLogin(ROUTES.WORKOUT_TODAY)}
|
||||||
|
>
|
||||||
|
<View style={styles.featureIconWrapper}>
|
||||||
|
<View style={styles.featureIconPlaceholder}>
|
||||||
|
<Image
|
||||||
|
source={require('@/assets/images/icons/iconWorkout.png')}
|
||||||
|
style={styles.featureIconImage}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<ThemedText style={styles.featureTitle}>训练</ThemedText>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
style={[styles.featureCard, styles.featureCardPrimary]}
|
||||||
|
onPress={() => pushIfAuthedElseLogin(ROUTES.AI_POSTURE_ASSESSMENT)}
|
||||||
|
>
|
||||||
|
<View style={styles.featureIconWrapper}>
|
||||||
|
<Image
|
||||||
|
source={require('@/assets/images/demo/imageBody.jpeg')}
|
||||||
|
style={styles.featureIconImage}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<ThemedText style={styles.featureTitle}>体态</ThemedText>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
style={[styles.featureCard, styles.featureCardQuaternary]}
|
||||||
|
onPress={() => pushIfAuthedElseLogin(ROUTES.TRAINING_PLAN)}
|
||||||
|
>
|
||||||
|
<View style={styles.featureIconWrapper}>
|
||||||
|
<View style={styles.featureIconPlaceholder}>
|
||||||
|
<Image
|
||||||
|
source={require('@/assets/images/icons/iconPlan.png')}
|
||||||
|
style={styles.featureIconImage}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<ThemedText style={styles.featureTitle}>计划</ThemedText>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* My Plan Section - 显示激活的训练计划 */}
|
||||||
|
{/* {activePlan && (
|
||||||
|
<MyPlanCard
|
||||||
|
plan={activePlan}
|
||||||
|
onPress={handlePlanCardPress}
|
||||||
|
/>
|
||||||
|
)} */}
|
||||||
|
|
||||||
|
{/* Today Plan Section */}
|
||||||
|
<View style={styles.sectionContainer}>
|
||||||
|
<ThemedText style={styles.sectionTitle}>为你推荐</ThemedText>
|
||||||
|
|
||||||
|
<View style={styles.planList}>
|
||||||
|
{items?.map((item) => {
|
||||||
|
if (item.type === 'article') {
|
||||||
|
return (
|
||||||
|
<ArticleCard
|
||||||
|
key={item.key}
|
||||||
|
id={item.id}
|
||||||
|
title={item.title}
|
||||||
|
coverImage={item.coverImage}
|
||||||
|
publishedAt={item.publishedAt}
|
||||||
|
readCount={item.readCount}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const card = (
|
||||||
|
<PlanCard
|
||||||
|
image={item.image}
|
||||||
|
title={item.title}
|
||||||
|
subtitle={item.subtitle}
|
||||||
|
level={item.level}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
return item.onPress ? (
|
||||||
|
<Pressable key={item.key} onPress={item.onPress}>
|
||||||
|
{card}
|
||||||
|
</Pressable>
|
||||||
|
) : (
|
||||||
|
<View key={item.key}>{card}</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
{/* Add some spacing at the bottom */}
|
||||||
|
<View style={styles.bottomSpacing} />
|
||||||
|
</ScrollView>
|
||||||
|
</ThemedView>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
safeArea: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#F7F8FA',
|
||||||
|
},
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#F7F8FA',
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
paddingTop: 16,
|
||||||
|
paddingBottom: 8,
|
||||||
|
},
|
||||||
|
coachOverlayWrap: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
zIndex: 10,
|
||||||
|
},
|
||||||
|
greeting: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: '#8A8A8E',
|
||||||
|
fontWeight: '400',
|
||||||
|
marginBottom: 6,
|
||||||
|
},
|
||||||
|
userName: {
|
||||||
|
fontSize: 30,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#1A1A1A',
|
||||||
|
lineHeight: 36,
|
||||||
|
},
|
||||||
|
coachBadge: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
// RN 不完全支持 gap,这里用 margin 实现
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 8,
|
||||||
|
borderRadius: 20,
|
||||||
|
borderWidth: 1,
|
||||||
|
backgroundColor: '#FFFFFF00',
|
||||||
|
},
|
||||||
|
coachAvatar: {
|
||||||
|
width: 26,
|
||||||
|
height: 26,
|
||||||
|
borderRadius: 13,
|
||||||
|
},
|
||||||
|
coachMeta: {
|
||||||
|
marginLeft: 8,
|
||||||
|
},
|
||||||
|
coachName: {
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#192126',
|
||||||
|
},
|
||||||
|
coachStatusRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
statusDot: {
|
||||||
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
borderRadius: 3,
|
||||||
|
backgroundColor: '#22C55E',
|
||||||
|
marginRight: 4,
|
||||||
|
},
|
||||||
|
coachStatusText: {
|
||||||
|
fontSize: 11,
|
||||||
|
color: '#6B7280',
|
||||||
|
},
|
||||||
|
sectionContainer: {
|
||||||
|
marginTop: 24,
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#1A1A1A',
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
marginBottom: 18,
|
||||||
|
},
|
||||||
|
featureGrid: {
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
featureCard: {
|
||||||
|
flex: 1,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 10,
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
// 精致的阴影效果
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOpacity: 0.06,
|
||||||
|
shadowRadius: 8,
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
elevation: 3,
|
||||||
|
// 渐变边框效果
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(255, 255, 255, 0.8)',
|
||||||
|
// 添加微妙的内阴影效果
|
||||||
|
position: 'relative',
|
||||||
|
minHeight: 48,
|
||||||
|
},
|
||||||
|
featureCardPrimary: {
|
||||||
|
// 由于RN不支持CSS渐变,使用渐变色背景
|
||||||
|
backgroundColor: '#667eea',
|
||||||
|
},
|
||||||
|
featureCardSecondary: {
|
||||||
|
backgroundColor: '#4facfe',
|
||||||
|
},
|
||||||
|
featureCardTertiary: {
|
||||||
|
backgroundColor: '#43e97b',
|
||||||
|
},
|
||||||
|
featureCardQuaternary: {
|
||||||
|
backgroundColor: '#fa709a',
|
||||||
|
},
|
||||||
|
featureCardQuinary: {
|
||||||
|
backgroundColor: '#f59e0b',
|
||||||
|
},
|
||||||
|
featureIconWrapper: {
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: 16,
|
||||||
|
backgroundColor: 'rgba(255, 255, 255, 0.25)',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginRight: 10,
|
||||||
|
// 图标容器的阴影
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOpacity: 0.06,
|
||||||
|
shadowRadius: 4,
|
||||||
|
shadowOffset: { width: 0, height: 1 },
|
||||||
|
elevation: 2,
|
||||||
|
},
|
||||||
|
featureIconImage: {
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
borderRadius: 10,
|
||||||
|
resizeMode: 'cover',
|
||||||
|
},
|
||||||
|
featureIconPlaceholder: {
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
borderRadius: 10,
|
||||||
|
backgroundColor: 'rgba(255, 255, 255, 0.3)',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
featureIconText: {
|
||||||
|
fontSize: 12,
|
||||||
|
},
|
||||||
|
|
||||||
|
featureTitle: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#FFFFFF',
|
||||||
|
textAlign: 'left',
|
||||||
|
letterSpacing: 0.2,
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
featureSubtitle: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: 'rgba(255, 255, 255, 0.85)',
|
||||||
|
lineHeight: 16,
|
||||||
|
textAlign: 'center',
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
planList: {
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
},
|
||||||
|
// 移除旧的滑动样式
|
||||||
|
bottomSpacing: {
|
||||||
|
height: 120,
|
||||||
|
},
|
||||||
|
});
|
||||||
860
app/fitness-rings-detail.tsx
Normal file
@@ -0,0 +1,860 @@
|
|||||||
|
import { CircularRing } from '@/components/CircularRing';
|
||||||
|
import { ThemedView } from '@/components/ThemedView';
|
||||||
|
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||||
|
import { Colors } from '@/constants/Colors';
|
||||||
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
|
import {
|
||||||
|
fetchActivityRingsForDate,
|
||||||
|
fetchHourlyActiveCaloriesForDate,
|
||||||
|
fetchHourlyExerciseMinutesForDate,
|
||||||
|
fetchHourlyStandHoursForDate,
|
||||||
|
type ActivityRingsData,
|
||||||
|
type HourlyActivityData,
|
||||||
|
type HourlyExerciseData,
|
||||||
|
type HourlyStandData
|
||||||
|
} from '@/utils/health';
|
||||||
|
import { getFitnessExerciseMinutesInfoDismissed, setFitnessExerciseMinutesInfoDismissed } from '@/utils/userPreferences';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import DateTimePicker from '@react-native-community/datetimepicker';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import timezone from 'dayjs/plugin/timezone';
|
||||||
|
import utc from 'dayjs/plugin/utc';
|
||||||
|
import weekday from 'dayjs/plugin/weekday';
|
||||||
|
import { router } from 'expo-router';
|
||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Animated,
|
||||||
|
Modal,
|
||||||
|
Platform,
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TouchableOpacity,
|
||||||
|
View
|
||||||
|
} from 'react-native';
|
||||||
|
|
||||||
|
// 配置 dayjs 插件
|
||||||
|
dayjs.extend(utc);
|
||||||
|
dayjs.extend(timezone);
|
||||||
|
dayjs.extend(weekday);
|
||||||
|
|
||||||
|
// 设置默认时区为中国时区
|
||||||
|
dayjs.tz.setDefault('Asia/Shanghai');
|
||||||
|
|
||||||
|
type WeekData = {
|
||||||
|
date: Date;
|
||||||
|
data: ActivityRingsData | null;
|
||||||
|
isToday: boolean;
|
||||||
|
dayName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function FitnessRingsDetailScreen() {
|
||||||
|
const colorScheme = useColorScheme();
|
||||||
|
const [weekData, setWeekData] = useState<WeekData[]>([]);
|
||||||
|
const [selectedDate, setSelectedDate] = useState<Date>(new Date());
|
||||||
|
const [selectedDayData, setSelectedDayData] = useState<ActivityRingsData | null>(null);
|
||||||
|
const [datePickerVisible, setDatePickerVisible] = useState(false);
|
||||||
|
const [pickerDate, setPickerDate] = useState<Date>(new Date());
|
||||||
|
// 每小时数据状态
|
||||||
|
const [hourlyCaloriesData, setHourlyCaloriesData] = useState<HourlyActivityData[]>([]);
|
||||||
|
const [hourlyExerciseData, setHourlyExerciseData] = useState<HourlyExerciseData[]>([]);
|
||||||
|
const [hourlyStandData, setHourlyStandData] = useState<HourlyStandData[]>([]);
|
||||||
|
const [showExerciseInfo, setShowExerciseInfo] = useState(true);
|
||||||
|
const exerciseInfoAnim = useRef(new Animated.Value(1)).current;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 加载周数据和选中日期的详细数据
|
||||||
|
loadWeekData(selectedDate);
|
||||||
|
loadSelectedDayData();
|
||||||
|
loadExerciseInfoPreference();
|
||||||
|
}, [selectedDate]);
|
||||||
|
|
||||||
|
const loadExerciseInfoPreference = async () => {
|
||||||
|
try {
|
||||||
|
const dismissed = await getFitnessExerciseMinutesInfoDismissed();
|
||||||
|
setShowExerciseInfo(!dismissed);
|
||||||
|
if (!dismissed) {
|
||||||
|
exerciseInfoAnim.setValue(1);
|
||||||
|
} else {
|
||||||
|
exerciseInfoAnim.setValue(0);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载锻炼分钟说明偏好失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadWeekData = async (targetDate: Date) => {
|
||||||
|
const target = dayjs(targetDate).tz('Asia/Shanghai');
|
||||||
|
const today = dayjs().tz('Asia/Shanghai');
|
||||||
|
const weekDays = [];
|
||||||
|
|
||||||
|
// 获取目标日期所在周的数据 (周一到周日)
|
||||||
|
// 使用 weekday() 确保周一为一周的开始 (0=Monday, 6=Sunday)
|
||||||
|
const startOfWeek = target.weekday(0); // 周一开始
|
||||||
|
|
||||||
|
for (let i = 0; i < 7; i++) {
|
||||||
|
const currentDay = startOfWeek.add(i, 'day');
|
||||||
|
const isToday = currentDay.isSame(today, 'day');
|
||||||
|
const dayNames = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const activityRingsData = await fetchActivityRingsForDate(currentDay.toDate());
|
||||||
|
weekDays.push({
|
||||||
|
date: currentDay.toDate(),
|
||||||
|
data: activityRingsData,
|
||||||
|
isToday,
|
||||||
|
dayName: dayNames[i]
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch activity rings data for', currentDay.format('YYYY-MM-DD'), error);
|
||||||
|
weekDays.push({
|
||||||
|
date: currentDay.toDate(),
|
||||||
|
data: null,
|
||||||
|
isToday,
|
||||||
|
dayName: dayNames[i]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setWeekData(weekDays);
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadSelectedDayData = async () => {
|
||||||
|
try {
|
||||||
|
// 并行获取活动圆环数据和每小时详细数据
|
||||||
|
const [activityRingsData, hourlyCalories, hourlyExercise, hourlyStand] = await Promise.all([
|
||||||
|
fetchActivityRingsForDate(selectedDate),
|
||||||
|
fetchHourlyActiveCaloriesForDate(selectedDate),
|
||||||
|
fetchHourlyExerciseMinutesForDate(selectedDate),
|
||||||
|
fetchHourlyStandHoursForDate(selectedDate)
|
||||||
|
]);
|
||||||
|
|
||||||
|
setSelectedDayData(activityRingsData);
|
||||||
|
setHourlyCaloriesData(hourlyCalories);
|
||||||
|
setHourlyExerciseData(hourlyExercise);
|
||||||
|
setHourlyStandData(hourlyStand);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch selected day activity rings data', error);
|
||||||
|
setSelectedDayData(null);
|
||||||
|
setHourlyCaloriesData([]);
|
||||||
|
setHourlyExerciseData([]);
|
||||||
|
setHourlyStandData([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 日期选择器相关函数
|
||||||
|
const openDatePicker = () => {
|
||||||
|
setPickerDate(selectedDate);
|
||||||
|
setDatePickerVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeDatePicker = () => setDatePickerVisible(false);
|
||||||
|
|
||||||
|
const onConfirmDate = async (date: Date) => {
|
||||||
|
const today = dayjs().tz('Asia/Shanghai').startOf('day');
|
||||||
|
const picked = dayjs(date).tz('Asia/Shanghai').startOf('day');
|
||||||
|
const finalDate = picked.isAfter(today) ? today.toDate() : picked.toDate();
|
||||||
|
|
||||||
|
setSelectedDate(finalDate);
|
||||||
|
closeDatePicker();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 格式化头部显示的日期
|
||||||
|
const formatHeaderDate = (date: Date) => {
|
||||||
|
const dayJsDate = dayjs(date).tz('Asia/Shanghai');
|
||||||
|
return `${dayJsDate.format('YYYY年MM月DD日')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderWeekRingItem = (item: WeekData, index: number) => {
|
||||||
|
const isSelected = dayjs(item.date).tz('Asia/Shanghai').isSame(dayjs(selectedDate).tz('Asia/Shanghai'), 'day');
|
||||||
|
|
||||||
|
// 使用默认值确保即使没有数据也能显示圆环
|
||||||
|
const data = item.data || {
|
||||||
|
activeEnergyBurned: 0,
|
||||||
|
activeEnergyBurnedGoal: 350,
|
||||||
|
appleExerciseTime: 0,
|
||||||
|
appleExerciseTimeGoal: 30,
|
||||||
|
appleStandHours: 0,
|
||||||
|
appleStandHoursGoal: 12,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { activeEnergyBurned, activeEnergyBurnedGoal, appleExerciseTime, appleExerciseTimeGoal, appleStandHours, appleStandHoursGoal } = data;
|
||||||
|
|
||||||
|
// 计算进度百分比
|
||||||
|
const caloriesProgress = Math.min(1, Math.max(0, activeEnergyBurned / activeEnergyBurnedGoal));
|
||||||
|
const exerciseProgress = Math.min(1, Math.max(0, appleExerciseTime / appleExerciseTimeGoal));
|
||||||
|
const standProgress = Math.min(1, Math.max(0, appleStandHours / appleStandHoursGoal));
|
||||||
|
|
||||||
|
// 检查是否完成了所有目标
|
||||||
|
const isComplete = caloriesProgress >= 1 && exerciseProgress >= 1 && standProgress >= 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={index}
|
||||||
|
style={[styles.weekRingItem, isSelected && styles.weekRingItemSelected]}
|
||||||
|
onPress={() => setSelectedDate(item.date)}
|
||||||
|
>
|
||||||
|
<View style={styles.weekRingContainer}>
|
||||||
|
{/* {isComplete && (
|
||||||
|
<View style={styles.weekStarContainer}>
|
||||||
|
<Text style={styles.weekStarIcon}>✓</Text>
|
||||||
|
</View>
|
||||||
|
)} */}
|
||||||
|
|
||||||
|
<View style={styles.weekRingsWrapper}>
|
||||||
|
{/* 外圈 - 活动卡路里 (红色) */}
|
||||||
|
<View style={styles.ringPosition}>
|
||||||
|
<CircularRing
|
||||||
|
size={50}
|
||||||
|
strokeWidth={3}
|
||||||
|
trackColor="rgba(255, 59, 48, 0.15)"
|
||||||
|
progressColor="#FF3B30"
|
||||||
|
progress={caloriesProgress}
|
||||||
|
showCenterText={false}
|
||||||
|
startAngleDeg={-90}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 中圈 - 锻炼分钟 (橙色) */}
|
||||||
|
<View style={styles.ringPosition}>
|
||||||
|
<CircularRing
|
||||||
|
size={36}
|
||||||
|
strokeWidth={2.5}
|
||||||
|
trackColor="rgba(255, 149, 0, 0.15)"
|
||||||
|
progressColor="#FF9500"
|
||||||
|
progress={exerciseProgress}
|
||||||
|
showCenterText={false}
|
||||||
|
startAngleDeg={-90}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 内圈 - 站立小时 (蓝色) */}
|
||||||
|
<View style={styles.ringPosition}>
|
||||||
|
<CircularRing
|
||||||
|
size={22}
|
||||||
|
strokeWidth={2}
|
||||||
|
trackColor="rgba(0, 122, 255, 0.15)"
|
||||||
|
progressColor="#007AFF"
|
||||||
|
progress={standProgress}
|
||||||
|
showCenterText={false}
|
||||||
|
startAngleDeg={-90}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={[
|
||||||
|
styles.weekDayNumber,
|
||||||
|
item.isToday && styles.weekTodayNumber,
|
||||||
|
isSelected && styles.weekSelectedNumber,
|
||||||
|
{ color: isSelected ? '#007AFF' : (item.isToday ? '#007AFF' : Colors[colorScheme ?? 'light'].text) }
|
||||||
|
]}>
|
||||||
|
{dayjs(item.date).tz('Asia/Shanghai').date()}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={[
|
||||||
|
styles.weekDayLabel,
|
||||||
|
item.isToday && styles.weekTodayLabel,
|
||||||
|
isSelected && styles.weekSelectedLabel,
|
||||||
|
{ color: isSelected ? '#007AFF' : (item.isToday ? '#007AFF' : Colors[colorScheme ?? 'light'].tabIconDefault) }
|
||||||
|
]}>
|
||||||
|
{item.dayName}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getClosedRingCount = () => {
|
||||||
|
let count = 0;
|
||||||
|
weekData.forEach(item => {
|
||||||
|
// 使用默认值处理空数据情况
|
||||||
|
const data = item.data || {
|
||||||
|
activeEnergyBurned: 0,
|
||||||
|
activeEnergyBurnedGoal: 350,
|
||||||
|
appleExerciseTime: 0,
|
||||||
|
appleExerciseTimeGoal: 30,
|
||||||
|
appleStandHours: 0,
|
||||||
|
appleStandHoursGoal: 12,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { activeEnergyBurned, activeEnergyBurnedGoal, appleExerciseTime, appleExerciseTimeGoal, appleStandHours, appleStandHoursGoal } = data;
|
||||||
|
const caloriesComplete = activeEnergyBurned >= activeEnergyBurnedGoal;
|
||||||
|
const exerciseComplete = appleExerciseTime >= appleExerciseTimeGoal;
|
||||||
|
const standComplete = appleStandHours >= appleStandHoursGoal;
|
||||||
|
|
||||||
|
if (caloriesComplete && exerciseComplete && standComplete) {
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return count;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKnowButtonPress = async () => {
|
||||||
|
try {
|
||||||
|
await setFitnessExerciseMinutesInfoDismissed(true);
|
||||||
|
Animated.timing(exerciseInfoAnim, {
|
||||||
|
toValue: 0,
|
||||||
|
duration: 300,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}).start(() => {
|
||||||
|
setShowExerciseInfo(false);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('保存锻炼分钟说明偏好失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 渲染简单的柱状图
|
||||||
|
const renderBarChart = (data: number[], maxValue: number, color: string, unit: string) => {
|
||||||
|
// 确保始终有24小时的数据,没有数据时用0填充
|
||||||
|
const chartData = Array.from({ length: 24 }, (_, index) => {
|
||||||
|
if (data && data.length > index) {
|
||||||
|
return data[index] || 0;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 计算最大值,如果所有数据都是0,使用传入的maxValue作为参考
|
||||||
|
const maxChartValue = Math.max(...chartData, 1); // 确保最小值为1,避免除零
|
||||||
|
const effectiveMaxValue = Math.max(maxChartValue, maxValue);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.chartContainer}>
|
||||||
|
<View style={styles.chartBars}>
|
||||||
|
{chartData.map((value, index) => {
|
||||||
|
const height = Math.max(2, (value / effectiveMaxValue) * 40); // 最小高度2,最大40
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
key={index}
|
||||||
|
style={[
|
||||||
|
styles.chartBar,
|
||||||
|
{
|
||||||
|
height: value > 0 ? height : 2, // 没有数据时显示最小高度的灰色条
|
||||||
|
backgroundColor: value > 0 ? color : '#E5E5EA',
|
||||||
|
opacity: value > 0 ? 1 : 0.5
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
<View style={styles.chartLabels}>
|
||||||
|
<Text style={styles.chartLabel}>00:00</Text>
|
||||||
|
<Text style={styles.chartLabel}>06:00</Text>
|
||||||
|
<Text style={styles.chartLabel}>12:00</Text>
|
||||||
|
<Text style={styles.chartLabel}>18:00</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderSelectedDayDetail = () => {
|
||||||
|
// 使用默认值确保即使没有数据也能显示图表
|
||||||
|
const data = selectedDayData || {
|
||||||
|
activeEnergyBurned: 0,
|
||||||
|
activeEnergyBurnedGoal: 350,
|
||||||
|
appleExerciseTime: 0,
|
||||||
|
appleExerciseTimeGoal: 30,
|
||||||
|
appleStandHours: 0,
|
||||||
|
appleStandHoursGoal: 12,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { activeEnergyBurned, activeEnergyBurnedGoal, appleExerciseTime, appleExerciseTimeGoal, appleStandHours, appleStandHoursGoal } = data;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.detailContainer}>
|
||||||
|
{/* 活动热量卡片 */}
|
||||||
|
<View style={styles.metricCard}>
|
||||||
|
<View style={styles.cardHeader}>
|
||||||
|
<Text style={styles.cardTitle}>活动热量</Text>
|
||||||
|
<TouchableOpacity style={styles.helpButton}>
|
||||||
|
<Text style={styles.helpIcon}>?</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.cardValue}>
|
||||||
|
<Text style={[styles.valueText, { color: '#FF3B30' }]}>
|
||||||
|
{Math.round(activeEnergyBurned)}/{activeEnergyBurnedGoal}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.unitText}>千卡</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={styles.cardSubtext}>
|
||||||
|
{Math.round(activeEnergyBurned)}千卡
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{renderBarChart(
|
||||||
|
hourlyCaloriesData.map(h => h.calories),
|
||||||
|
Math.max(activeEnergyBurnedGoal / 24, 1),
|
||||||
|
'#FF3B30',
|
||||||
|
'千卡'
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 锻炼分钟卡片 */}
|
||||||
|
<View style={styles.metricCard}>
|
||||||
|
<View style={styles.cardHeader}>
|
||||||
|
<Text style={styles.cardTitle}>锻炼分钟数</Text>
|
||||||
|
<TouchableOpacity style={styles.helpButton}>
|
||||||
|
<Text style={styles.helpIcon}>?</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.cardValue}>
|
||||||
|
<Text style={[styles.valueText, { color: '#FF9500' }]}>
|
||||||
|
{Math.round(appleExerciseTime)}/{appleExerciseTimeGoal}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.unitText}>分钟</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={styles.cardSubtext}>
|
||||||
|
{Math.round(appleExerciseTime)}分钟
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{renderBarChart(
|
||||||
|
hourlyExerciseData.map(h => h.minutes),
|
||||||
|
Math.max(appleExerciseTimeGoal / 8, 1),
|
||||||
|
'#FF9500',
|
||||||
|
'分钟'
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 锻炼分钟说明 */}
|
||||||
|
{showExerciseInfo && (
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.exerciseInfo,
|
||||||
|
{
|
||||||
|
opacity: exerciseInfoAnim,
|
||||||
|
transform: [
|
||||||
|
{
|
||||||
|
scale: exerciseInfoAnim.interpolate({
|
||||||
|
inputRange: [0, 1],
|
||||||
|
outputRange: [0.95, 1],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text style={styles.exerciseTitle}>锻炼分钟数:</Text>
|
||||||
|
<Text style={styles.exerciseDesc}>
|
||||||
|
进行强度不低于"快走"的运动锻炼,就会积累对应时长的锻炼分钟数。
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.exerciseRecommendation}>
|
||||||
|
世卫组织推荐的成年人每天至少保持30分钟以上的中高强度运动。
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity style={styles.knowButton} onPress={handleKnowButtonPress}>
|
||||||
|
<Text style={styles.knowButtonText}>知道了</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</Animated.View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 活动小时数卡片 */}
|
||||||
|
<View style={styles.metricCard}>
|
||||||
|
<View style={styles.cardHeader}>
|
||||||
|
<Text style={styles.cardTitle}>活动小时数</Text>
|
||||||
|
<TouchableOpacity style={styles.helpButton}>
|
||||||
|
<Text style={styles.helpIcon}>?</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.cardValue}>
|
||||||
|
<Text style={[styles.valueText, { color: '#007AFF' }]}>
|
||||||
|
{Math.round(appleStandHours)}/{appleStandHoursGoal}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.unitText}>小时</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={styles.cardSubtext}>
|
||||||
|
{Math.round(appleStandHours)}小时
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{renderBarChart(
|
||||||
|
hourlyStandData.map(h => h.hasStood),
|
||||||
|
1,
|
||||||
|
'#007AFF',
|
||||||
|
'小时'
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemedView style={styles.container}>
|
||||||
|
{/* 头部 */}
|
||||||
|
<HeaderBar
|
||||||
|
title={formatHeaderDate(selectedDate)}
|
||||||
|
onBack={() => router.back()}
|
||||||
|
right={
|
||||||
|
<TouchableOpacity style={styles.calendarButton} onPress={openDatePicker}>
|
||||||
|
<Ionicons name="calendar-outline" size={20} color="#666666" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
}
|
||||||
|
withSafeTop={true}
|
||||||
|
transparent={true}
|
||||||
|
variant="default"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
style={styles.scrollView}
|
||||||
|
contentContainerStyle={styles.scrollContent}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
{/* 本周圆环横向滚动 */}
|
||||||
|
<View style={styles.weekSection}>
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
contentContainerStyle={styles.weekScrollContent}
|
||||||
|
style={styles.weekScrollView}
|
||||||
|
>
|
||||||
|
{weekData.map((item, index) => renderWeekRingItem(item, index))}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 选中日期的详细数据 */}
|
||||||
|
{renderSelectedDayDetail()}
|
||||||
|
|
||||||
|
{/* 周闭环天数统计 */}
|
||||||
|
<View style={styles.statsContainer}>
|
||||||
|
<View style={styles.statRow}>
|
||||||
|
<Text style={[styles.statLabel, { color: Colors[colorScheme ?? 'light'].text }]}>周闭环天数</Text>
|
||||||
|
<View style={styles.statValue}>
|
||||||
|
<Text style={[styles.statNumber, { color: Colors[colorScheme ?? 'light'].text }]}>{getClosedRingCount()}天</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
{/* 日期选择器弹窗 */}
|
||||||
|
<Modal
|
||||||
|
visible={datePickerVisible}
|
||||||
|
transparent
|
||||||
|
animationType="fade"
|
||||||
|
onRequestClose={closeDatePicker}
|
||||||
|
>
|
||||||
|
<Pressable style={styles.modalBackdrop} onPress={closeDatePicker} />
|
||||||
|
<View style={styles.modalSheet}>
|
||||||
|
<DateTimePicker
|
||||||
|
value={pickerDate}
|
||||||
|
mode="date"
|
||||||
|
display={Platform.OS === 'ios' ? 'inline' : 'calendar'}
|
||||||
|
minimumDate={new Date(2020, 0, 1)}
|
||||||
|
maximumDate={new Date()}
|
||||||
|
{...(Platform.OS === 'ios' ? { locale: 'zh-CN' } : {})}
|
||||||
|
onChange={(event, date) => {
|
||||||
|
if (Platform.OS === 'ios') {
|
||||||
|
if (date) setPickerDate(date);
|
||||||
|
} else {
|
||||||
|
if (event.type === 'set' && date) {
|
||||||
|
onConfirmDate(date);
|
||||||
|
} else {
|
||||||
|
closeDatePicker();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{Platform.OS === 'ios' && (
|
||||||
|
<View style={styles.modalActions}>
|
||||||
|
<Pressable onPress={closeDatePicker} style={[styles.modalBtn]}>
|
||||||
|
<Text style={styles.modalBtnText}>取消</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable onPress={() => {
|
||||||
|
onConfirmDate(pickerDate);
|
||||||
|
}} style={[styles.modalBtn, styles.modalBtnPrimary]}>
|
||||||
|
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary]}>确定</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
</ThemedView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
calendarButton: {
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
scrollView: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
scrollContent: {
|
||||||
|
paddingBottom: 32,
|
||||||
|
},
|
||||||
|
weekSection: {
|
||||||
|
paddingVertical: 20,
|
||||||
|
},
|
||||||
|
weekScrollView: {
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
},
|
||||||
|
weekScrollContent: {
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
},
|
||||||
|
weekRingItem: {
|
||||||
|
alignItems: 'center',
|
||||||
|
marginHorizontal: 8,
|
||||||
|
padding: 8,
|
||||||
|
borderRadius: 12,
|
||||||
|
},
|
||||||
|
weekRingItemSelected: {
|
||||||
|
backgroundColor: 'rgba(0, 122, 255, 0.1)',
|
||||||
|
},
|
||||||
|
weekRingContainer: {
|
||||||
|
position: 'relative',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
weekStarContainer: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: -8,
|
||||||
|
right: -8,
|
||||||
|
zIndex: 10,
|
||||||
|
},
|
||||||
|
weekStarIcon: {
|
||||||
|
fontSize: 12,
|
||||||
|
},
|
||||||
|
weekRingsWrapper: {
|
||||||
|
position: 'relative',
|
||||||
|
width: 50,
|
||||||
|
height: 50,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
ringPosition: {
|
||||||
|
position: 'absolute',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
weekDayNumber: {
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: '600',
|
||||||
|
marginTop: 6,
|
||||||
|
},
|
||||||
|
weekTodayNumber: {
|
||||||
|
color: '#007AFF',
|
||||||
|
},
|
||||||
|
weekSelectedNumber: {
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
weekDayLabel: {
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: '500',
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
weekTodayLabel: {
|
||||||
|
color: '#007AFF',
|
||||||
|
},
|
||||||
|
weekSelectedLabel: {
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
detailContainer: {
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 20,
|
||||||
|
},
|
||||||
|
// 卡片样式
|
||||||
|
metricCard: {
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 20,
|
||||||
|
marginBottom: 16,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: {
|
||||||
|
width: 0,
|
||||||
|
height: 2,
|
||||||
|
},
|
||||||
|
shadowOpacity: 0.06,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 3,
|
||||||
|
},
|
||||||
|
cardHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
cardTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#1C1C1E',
|
||||||
|
},
|
||||||
|
helpButton: {
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
borderRadius: 12,
|
||||||
|
backgroundColor: '#F2F2F7',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
helpIcon: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#8E8E93',
|
||||||
|
},
|
||||||
|
cardValue: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'baseline',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
valueText: {
|
||||||
|
fontSize: 32,
|
||||||
|
fontWeight: '700',
|
||||||
|
letterSpacing: -1,
|
||||||
|
},
|
||||||
|
unitText: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '500',
|
||||||
|
color: '#8E8E93',
|
||||||
|
marginLeft: 4,
|
||||||
|
},
|
||||||
|
cardSubtext: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#8E8E93',
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
// 图表样式
|
||||||
|
chartContainer: {
|
||||||
|
marginTop: 16,
|
||||||
|
},
|
||||||
|
chartBars: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'flex-end',
|
||||||
|
height: 60,
|
||||||
|
marginBottom: 8,
|
||||||
|
paddingHorizontal: 4,
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
},
|
||||||
|
chartBar: {
|
||||||
|
width: 3,
|
||||||
|
borderRadius: 1.5,
|
||||||
|
marginHorizontal: 0.5,
|
||||||
|
},
|
||||||
|
chartLabels: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingHorizontal: 4,
|
||||||
|
},
|
||||||
|
chartLabel: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#8E8E93',
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
// 锻炼信息样式
|
||||||
|
exerciseInfo: {
|
||||||
|
marginTop: 20,
|
||||||
|
padding: 16,
|
||||||
|
backgroundColor: '#F2F2F7',
|
||||||
|
borderRadius: 12,
|
||||||
|
},
|
||||||
|
exerciseTitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#1C1C1E',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
exerciseDesc: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#3C3C43',
|
||||||
|
lineHeight: 20,
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
exerciseRecommendation: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#3C3C43',
|
||||||
|
lineHeight: 20,
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
knowButton: {
|
||||||
|
alignSelf: 'flex-end',
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 8,
|
||||||
|
backgroundColor: '#007AFF',
|
||||||
|
borderRadius: 20,
|
||||||
|
},
|
||||||
|
knowButtonText: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#FFFFFF',
|
||||||
|
},
|
||||||
|
noDataText: {
|
||||||
|
fontSize: 16,
|
||||||
|
textAlign: 'center',
|
||||||
|
marginTop: 40,
|
||||||
|
},
|
||||||
|
statsContainer: {
|
||||||
|
marginHorizontal: 16,
|
||||||
|
marginTop: 32,
|
||||||
|
padding: 16,
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.05)',
|
||||||
|
borderRadius: 12,
|
||||||
|
},
|
||||||
|
statRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
},
|
||||||
|
statLabel: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
statValue: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
statNumber: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
marginLeft: 4,
|
||||||
|
},
|
||||||
|
starIcon: {
|
||||||
|
fontSize: 16,
|
||||||
|
},
|
||||||
|
// 日期选择器样式
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
modalActions: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
marginTop: 8,
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
modalBtn: {
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 10,
|
||||||
|
borderRadius: 10,
|
||||||
|
backgroundColor: '#F1F5F9',
|
||||||
|
},
|
||||||
|
modalBtnPrimary: {
|
||||||
|
backgroundColor: '#7a5af8',
|
||||||
|
},
|
||||||
|
modalBtnText: {
|
||||||
|
color: '#334155',
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
modalBtnTextPrimary: {
|
||||||
|
color: '#FFFFFF',
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
});
|
||||||
908
app/food-library.tsx
Normal file
@@ -0,0 +1,908 @@
|
|||||||
|
import { CreateCustomFoodModal, type CustomFoodData } from '@/components/model/food/CreateCustomFoodModal';
|
||||||
|
import { FoodDetailModal } from '@/components/model/food/FoodDetailModal';
|
||||||
|
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||||
|
import { Colors } from '@/constants/Colors';
|
||||||
|
import { DEFAULT_IMAGE_FOOD } from '@/constants/Image';
|
||||||
|
import { useAppDispatch } from '@/hooks/redux';
|
||||||
|
import { useFoodLibrary, useFoodSearch } from '@/hooks/useFoodLibrary';
|
||||||
|
import { addDietRecord, type CreateDietRecordDto } from '@/services/dietRecords';
|
||||||
|
import { foodLibraryApi, type CreateCustomFoodDto } from '@/services/foodLibraryApi';
|
||||||
|
import { fetchDailyNutritionData } from '@/store/nutritionSlice';
|
||||||
|
import type { FoodItem, MealType, SelectedFoodItem } from '@/types/food';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { Image } from 'expo-image';
|
||||||
|
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||||
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Alert,
|
||||||
|
Modal,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
TouchableOpacity,
|
||||||
|
View
|
||||||
|
} from 'react-native';
|
||||||
|
|
||||||
|
// 餐次映射保持不变
|
||||||
|
|
||||||
|
// 餐次映射
|
||||||
|
const MEAL_TYPE_MAP = {
|
||||||
|
breakfast: '早餐',
|
||||||
|
lunch: '午餐',
|
||||||
|
dinner: '晚餐',
|
||||||
|
snack: '加餐'
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function FoodLibraryScreen() {
|
||||||
|
const router = useRouter();
|
||||||
|
const params = useLocalSearchParams<{ mealType?: string }>();
|
||||||
|
const mealType = (params.mealType as MealType) || 'breakfast';
|
||||||
|
|
||||||
|
// Redux hooks
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const { categories, loading, error, clearErrors, loadFoodLibrary } = useFoodLibrary();
|
||||||
|
const { searchResults, searchLoading, search, clearResults } = useFoodSearch();
|
||||||
|
|
||||||
|
// 本地状态
|
||||||
|
const [selectedCategoryId, setSelectedCategoryId] = useState('common');
|
||||||
|
const [searchText, setSearchText] = useState('');
|
||||||
|
const [selectedFood, setSelectedFood] = useState<FoodItem | null>(null);
|
||||||
|
const [showFoodDetail, setShowFoodDetail] = useState(false);
|
||||||
|
const [selectedFoodItems, setSelectedFoodItems] = useState<SelectedFoodItem[]>([]);
|
||||||
|
const [showMealSelector, setShowMealSelector] = useState(false);
|
||||||
|
const [currentMealType, setCurrentMealType] = useState<MealType>(mealType);
|
||||||
|
const [isRecording, setIsRecording] = useState(false);
|
||||||
|
const [showCreateCustomFood, setShowCreateCustomFood] = useState(false);
|
||||||
|
|
||||||
|
// 获取当前选中的分类
|
||||||
|
const selectedCategory = categories.find(cat => cat.id === selectedCategoryId);
|
||||||
|
|
||||||
|
|
||||||
|
// 过滤食物列表 - 优先显示搜索结果
|
||||||
|
const filteredFoods = useMemo(() => {
|
||||||
|
if (searchText.trim() && searchResults.length > 0) {
|
||||||
|
return searchResults;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedCategory) {
|
||||||
|
return selectedCategory.foods
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}, [searchText, searchResults, selectedCategory]);
|
||||||
|
|
||||||
|
|
||||||
|
// 处理搜索
|
||||||
|
useEffect(() => {
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
if (searchText.trim()) {
|
||||||
|
search(searchText);
|
||||||
|
} else {
|
||||||
|
clearResults();
|
||||||
|
}
|
||||||
|
}, 300); // 防抖
|
||||||
|
|
||||||
|
return () => clearTimeout(timeoutId);
|
||||||
|
}, [searchText, search, clearResults]);
|
||||||
|
|
||||||
|
// 处理食物选择 - 显示详情弹窗
|
||||||
|
const handleSelectFood = (food: FoodItem) => {
|
||||||
|
console.log('选择食物:', food);
|
||||||
|
setSelectedFood(food);
|
||||||
|
setShowFoodDetail(true);
|
||||||
|
console.log('设置弹窗状态:', {
|
||||||
|
showFoodDetail: true,
|
||||||
|
selectedFood: food,
|
||||||
|
foodName: food.name,
|
||||||
|
foodId: food.id
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理食物保存
|
||||||
|
const handleSaveFood = (food: FoodItem, amount: number, unit: string) => {
|
||||||
|
// 计算实际热量
|
||||||
|
const actualCalories = Math.round((food.calories * amount) / 100);
|
||||||
|
|
||||||
|
// 创建新的选择项目
|
||||||
|
const newSelectedItem: SelectedFoodItem = {
|
||||||
|
id: `${food.id}_${Date.now()}`, // 使用时间戳确保唯一性
|
||||||
|
food,
|
||||||
|
amount,
|
||||||
|
unit,
|
||||||
|
calories: actualCalories
|
||||||
|
};
|
||||||
|
|
||||||
|
// 添加到已选择列表
|
||||||
|
setSelectedFoodItems(prev => [...prev, newSelectedItem]);
|
||||||
|
|
||||||
|
console.log('保存食物:', food, amount, unit, '热量:', actualCalories);
|
||||||
|
setShowFoodDetail(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 移除已选择的食物
|
||||||
|
const handleRemoveSelectedFood = (itemId: string) => {
|
||||||
|
setSelectedFoodItems(prev => prev.filter(item => item.id !== itemId));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 计算总热量
|
||||||
|
const totalCalories = selectedFoodItems.reduce((sum, item) => sum + item.calories, 0);
|
||||||
|
|
||||||
|
// 关闭详情弹窗
|
||||||
|
const handleCloseFoodDetail = () => {
|
||||||
|
setShowFoodDetail(false);
|
||||||
|
setSelectedFood(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理删除自定义食物
|
||||||
|
const handleDeleteFood = async (foodId: string) => {
|
||||||
|
try {
|
||||||
|
await foodLibraryApi.deleteCustomFood(Number(foodId));
|
||||||
|
// 删除成功后重新加载食物库数据
|
||||||
|
await loadFoodLibrary();
|
||||||
|
// 关闭弹窗
|
||||||
|
handleCloseFoodDetail();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('删除食物失败:', error);
|
||||||
|
Alert.alert('删除失败', '删除食物时发生错误,请稍后重试');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理饮食记录
|
||||||
|
const handleRecordDiet = async () => {
|
||||||
|
if (selectedFoodItems.length === 0) return;
|
||||||
|
|
||||||
|
setIsRecording(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 逐个记录选中的食物
|
||||||
|
for (const item of selectedFoodItems) {
|
||||||
|
const dietRecordData: CreateDietRecordDto = {
|
||||||
|
mealType: currentMealType,
|
||||||
|
foodName: item.food.name,
|
||||||
|
foodDescription: item.food.description,
|
||||||
|
portionDescription: `${item.amount}${item.unit}`,
|
||||||
|
estimatedCalories: item.calories,
|
||||||
|
proteinGrams: item.food.protein ? Number(((item.food.protein * item.amount) / 100).toFixed(2)) : undefined,
|
||||||
|
carbohydrateGrams: item.food.carbohydrate ? Number(((item.food.carbohydrate * item.amount) / 100).toFixed(2)) : undefined,
|
||||||
|
fatGrams: item.food.fat ? Number(((item.food.fat * item.amount) / 100).toFixed(2)) : undefined,
|
||||||
|
fiberGrams: item.food.fiber ? Number(((item.food.fiber * item.amount) / 100).toFixed(2)) : undefined,
|
||||||
|
sugarGrams: item.food.sugar ? Number(((item.food.sugar * item.amount) / 100).toFixed(2)) : undefined,
|
||||||
|
sodiumMg: item.food.sodium ? Number(((item.food.sodium * item.amount) / 100).toFixed(2)) : undefined,
|
||||||
|
additionalNutrition: item.food.additionalNutrition,
|
||||||
|
source: 'manual',
|
||||||
|
mealTime: new Date().toISOString(),
|
||||||
|
imageUrl: item.food.imageUrl,
|
||||||
|
};
|
||||||
|
|
||||||
|
await addDietRecord(dietRecordData);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 记录成功后,刷新当天的营养数据
|
||||||
|
const today = new Date();
|
||||||
|
await dispatch(fetchDailyNutritionData(today));
|
||||||
|
|
||||||
|
// 清空选择列表并返回
|
||||||
|
setSelectedFoodItems([]);
|
||||||
|
router.back();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('记录饮食失败:', error);
|
||||||
|
// 这里可以显示错误提示
|
||||||
|
} finally {
|
||||||
|
setIsRecording(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理餐次选择
|
||||||
|
const handleMealTypeSelect = (selectedMealType: MealType) => {
|
||||||
|
setCurrentMealType(selectedMealType);
|
||||||
|
setShowMealSelector(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理创建自定义食物
|
||||||
|
const handleCreateCustomFood = () => {
|
||||||
|
setShowCreateCustomFood(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理保存自定义食物
|
||||||
|
const handleSaveCustomFood = async (customFoodData: CustomFoodData) => {
|
||||||
|
try {
|
||||||
|
// 转换数据格式以匹配API要求
|
||||||
|
const createData: CreateCustomFoodDto = {
|
||||||
|
name: customFoodData.name,
|
||||||
|
caloriesPer100g: customFoodData.calories,
|
||||||
|
proteinPer100g: customFoodData.protein,
|
||||||
|
carbohydratePer100g: customFoodData.carbohydrate,
|
||||||
|
fatPer100g: customFoodData.fat,
|
||||||
|
imageUrl: customFoodData.imageUrl,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 调用API创建自定义食物
|
||||||
|
const createdFood = await foodLibraryApi.createCustomFood(createData);
|
||||||
|
|
||||||
|
// 需要拉取一遍最新的食物列表
|
||||||
|
await loadFoodLibrary();
|
||||||
|
|
||||||
|
// 创建FoodItem对象
|
||||||
|
const customFoodItem: FoodItem = {
|
||||||
|
id: createdFood.id.toString(),
|
||||||
|
name: createdFood.name,
|
||||||
|
calories: createdFood.caloriesPer100g || 0,
|
||||||
|
unit: 'g',
|
||||||
|
description: createdFood.description || `自定义食物 - ${createdFood.name}`,
|
||||||
|
imageUrl: createdFood.imageUrl,
|
||||||
|
protein: createdFood.proteinPer100g,
|
||||||
|
fat: createdFood.fatPer100g,
|
||||||
|
carbohydrate: createdFood.carbohydratePer100g,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 添加到选择列表中
|
||||||
|
const newSelectedItem: SelectedFoodItem = {
|
||||||
|
id: createdFood.id.toString(),
|
||||||
|
food: customFoodItem,
|
||||||
|
amount: customFoodData.defaultAmount,
|
||||||
|
unit: 'g',
|
||||||
|
calories: Math.round((customFoodItem.calories * customFoodData.defaultAmount) / 100)
|
||||||
|
};
|
||||||
|
|
||||||
|
setSelectedFoodItems(prev => [...prev, newSelectedItem]);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('创建自定义食物失败:', error);
|
||||||
|
Alert.alert('创建失败', '创建自定义食物时发生错误,请稍后重试');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 关闭自定义食物弹窗
|
||||||
|
const handleCloseCreateCustomFood = () => {
|
||||||
|
setShowCreateCustomFood(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 餐次选择选项
|
||||||
|
const mealOptions = [
|
||||||
|
{ key: 'breakfast' as const, label: '早餐', color: '#FF6B35' },
|
||||||
|
{ key: 'lunch' as const, label: '午餐', color: '#4CAF50' },
|
||||||
|
{ key: 'dinner' as const, label: '晚餐', color: '#2196F3' },
|
||||||
|
{ key: 'snack' as const, label: '加餐', color: '#FF9800' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
{/* 头部 */}
|
||||||
|
<HeaderBar
|
||||||
|
title="食物库"
|
||||||
|
onBack={() => router.back()}
|
||||||
|
transparent={false}
|
||||||
|
variant="elevated"
|
||||||
|
right={
|
||||||
|
<TouchableOpacity style={styles.customButton} onPress={handleCreateCustomFood}>
|
||||||
|
<Text style={styles.customButtonText}>自定义</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 搜索框 */}
|
||||||
|
<View style={styles.searchContainer}>
|
||||||
|
<Ionicons name="search" size={20} color="#999" style={styles.searchIcon} />
|
||||||
|
<TextInput
|
||||||
|
style={styles.searchInput}
|
||||||
|
placeholder="搜索食物"
|
||||||
|
value={searchText}
|
||||||
|
onChangeText={setSearchText}
|
||||||
|
placeholderTextColor="#999"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 主要内容区域 - 卡片样式 */}
|
||||||
|
<View style={styles.mainContentCard}>
|
||||||
|
{loading && categories.length === 0 ? (
|
||||||
|
<View style={styles.loadingContainer}>
|
||||||
|
<ActivityIndicator size="large" color="#4CAF50" />
|
||||||
|
<Text style={styles.loadingText}>加载食物库中...</Text>
|
||||||
|
</View>
|
||||||
|
) : error ? (
|
||||||
|
<View style={styles.errorContainer}>
|
||||||
|
<Text style={styles.errorText}>{error}</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.retryButton}
|
||||||
|
onPress={() => {
|
||||||
|
clearErrors();
|
||||||
|
// 这里可以重新加载数据
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={styles.retryButtonText}>重试</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View style={styles.mainContent}>
|
||||||
|
{/* 左侧分类导航 */}
|
||||||
|
<View style={styles.categoryContainer}>
|
||||||
|
<ScrollView showsVerticalScrollIndicator={false}>
|
||||||
|
{categories.map((category) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={category.id}
|
||||||
|
style={[
|
||||||
|
styles.categoryItem,
|
||||||
|
selectedCategoryId === category.id && styles.categoryItemActive
|
||||||
|
]}
|
||||||
|
onPress={() => {
|
||||||
|
setSelectedCategoryId(category.id);
|
||||||
|
// 切换分类时清除搜索
|
||||||
|
if (searchText) {
|
||||||
|
setSearchText('');
|
||||||
|
clearResults();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={[
|
||||||
|
styles.categoryText,
|
||||||
|
selectedCategoryId === category.id && styles.categoryTextActive
|
||||||
|
]}>
|
||||||
|
{category.name}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 右侧食物列表 */}
|
||||||
|
<View style={styles.foodContainer}>
|
||||||
|
{searchLoading ? (
|
||||||
|
<View style={styles.searchLoadingContainer}>
|
||||||
|
<ActivityIndicator size="small" color="#4CAF50" />
|
||||||
|
<Text style={styles.searchLoadingText}>搜索中...</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<ScrollView showsVerticalScrollIndicator={false}>
|
||||||
|
{filteredFoods.map((food) => (
|
||||||
|
<View key={food.id} style={styles.foodItem}>
|
||||||
|
<View style={styles.foodInfo}>
|
||||||
|
<Image
|
||||||
|
style={styles.foodImage}
|
||||||
|
source={{ uri: food.imageUrl || DEFAULT_IMAGE_FOOD }}
|
||||||
|
cachePolicy={'memory-disk'}
|
||||||
|
/>
|
||||||
|
<View style={styles.foodDetails}>
|
||||||
|
<Text style={styles.foodName}>{food.name}</Text>
|
||||||
|
<Text style={styles.foodCalories}>
|
||||||
|
{food.calories}千卡/{food.unit}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.addButton}
|
||||||
|
onPress={() => handleSelectFood(food)}
|
||||||
|
>
|
||||||
|
<Ionicons name="add" size={20} color="#666" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{filteredFoods.length === 0 && !searchLoading && (
|
||||||
|
<View style={styles.emptyContainer}>
|
||||||
|
<Text style={styles.emptyText}>
|
||||||
|
{searchText ? '未找到相关食物' : '暂无食物数据'}
|
||||||
|
</Text>
|
||||||
|
{searchText && (
|
||||||
|
<Text style={styles.emptySubText}>
|
||||||
|
尝试使用其他关键词搜索
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 已选择食物列表 */}
|
||||||
|
{selectedFoodItems.length > 0 && (
|
||||||
|
<View style={styles.selectedFoodsContainer}>
|
||||||
|
<View style={styles.selectedFoodsHeader}>
|
||||||
|
<Text style={styles.selectedFoodsTitle}>已选择食物 ({selectedFoodItems.length})</Text>
|
||||||
|
<Text style={styles.totalCalories}>总热量: {totalCalories}千卡</Text>
|
||||||
|
</View>
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
style={styles.selectedFoodsList}
|
||||||
|
contentContainerStyle={styles.selectedFoodsContent}
|
||||||
|
>
|
||||||
|
{selectedFoodItems.map((item) => (
|
||||||
|
<View key={item.id} style={styles.selectedFoodItem}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.removeButton}
|
||||||
|
onPress={() => handleRemoveSelectedFood(item.id)}
|
||||||
|
>
|
||||||
|
<Ionicons name="close-circle" size={16} color="#FF4444" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{item.food.imageUrl ? <Image
|
||||||
|
style={styles.selectedFoodImage}
|
||||||
|
source={{ uri: item.food.imageUrl }}
|
||||||
|
/> : <Text style={styles.selectedFoodEmoji}>{item.food.emoji}</Text>}
|
||||||
|
<Text style={styles.selectedFoodName} numberOfLines={1}>{item.food.name}</Text>
|
||||||
|
<Text style={styles.selectedFoodAmount}>{item.amount}{item.unit}</Text>
|
||||||
|
<Text style={styles.selectedFoodCalories}>{item.calories}千卡</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 底部餐次选择和记录按钮 */}
|
||||||
|
<View style={styles.bottomContainer}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.mealSelector}
|
||||||
|
onPress={() => setShowMealSelector(true)}
|
||||||
|
>
|
||||||
|
<View style={[
|
||||||
|
styles.mealIndicator,
|
||||||
|
{ backgroundColor: mealOptions.find(option => option.key === currentMealType)?.color || '#FF6B35' }
|
||||||
|
]} />
|
||||||
|
<Text style={styles.mealText}>{MEAL_TYPE_MAP[currentMealType]}</Text>
|
||||||
|
<Ionicons name="chevron-down" size={16} color="#333" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.recordButton,
|
||||||
|
(selectedFoodItems.length === 0 || isRecording) && styles.recordButtonDisabled
|
||||||
|
]}
|
||||||
|
disabled={selectedFoodItems.length === 0 || isRecording}
|
||||||
|
onPress={handleRecordDiet}
|
||||||
|
>
|
||||||
|
{isRecording ? (
|
||||||
|
<ActivityIndicator size="small" color="#FFF" />
|
||||||
|
) : (
|
||||||
|
<Text style={[
|
||||||
|
styles.recordButtonText,
|
||||||
|
selectedFoodItems.length === 0 && styles.recordButtonTextDisabled
|
||||||
|
]}>记录</Text>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 餐次选择弹窗 */}
|
||||||
|
<Modal
|
||||||
|
visible={showMealSelector}
|
||||||
|
transparent={true}
|
||||||
|
animationType="fade"
|
||||||
|
onRequestClose={() => setShowMealSelector(false)}
|
||||||
|
>
|
||||||
|
<View style={styles.mealSelectorOverlay}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.mealSelectorBackdrop}
|
||||||
|
onPress={() => setShowMealSelector(false)}
|
||||||
|
/>
|
||||||
|
<View style={styles.mealSelectorModal}>
|
||||||
|
<View style={styles.mealSelectorHeader}>
|
||||||
|
<Text style={styles.mealSelectorTitle}>选择餐次</Text>
|
||||||
|
<TouchableOpacity onPress={() => setShowMealSelector(false)}>
|
||||||
|
<Ionicons name="close" size={24} color="#666" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
<View style={styles.mealOptionsContainer}>
|
||||||
|
{mealOptions.map((option) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={option.key}
|
||||||
|
style={[
|
||||||
|
styles.mealOption,
|
||||||
|
currentMealType === option.key && styles.mealOptionActive
|
||||||
|
]}
|
||||||
|
onPress={() => handleMealTypeSelect(option.key)}
|
||||||
|
>
|
||||||
|
<View style={[styles.mealOptionIndicator, { backgroundColor: option.color }]} />
|
||||||
|
<Text style={[
|
||||||
|
styles.mealOptionText,
|
||||||
|
currentMealType === option.key && styles.mealOptionTextActive
|
||||||
|
]}>
|
||||||
|
{option.label}
|
||||||
|
</Text>
|
||||||
|
{currentMealType === option.key && (
|
||||||
|
<Ionicons name="checkmark" size={20} color="#4CAF50" />
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* 食物详情弹窗 */}
|
||||||
|
<FoodDetailModal
|
||||||
|
visible={showFoodDetail}
|
||||||
|
food={selectedFood}
|
||||||
|
category={selectedCategory}
|
||||||
|
onClose={handleCloseFoodDetail}
|
||||||
|
onSave={handleSaveFood}
|
||||||
|
onDelete={handleDeleteFood}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 创建自定义食物弹窗 */}
|
||||||
|
<CreateCustomFoodModal
|
||||||
|
visible={showCreateCustomFood}
|
||||||
|
onClose={handleCloseCreateCustomFood}
|
||||||
|
onSave={handleSaveCustomFood}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: Colors.light.pageBackgroundEmphasis,
|
||||||
|
},
|
||||||
|
customButton: {
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 4,
|
||||||
|
},
|
||||||
|
customButtonText: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: Colors.light.textSecondary,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
searchContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: '#FFF',
|
||||||
|
marginHorizontal: 16,
|
||||||
|
marginVertical: 12,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 10,
|
||||||
|
borderRadius: 12,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 1 },
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 2,
|
||||||
|
elevation: 2,
|
||||||
|
},
|
||||||
|
searchIcon: {
|
||||||
|
marginRight: 8,
|
||||||
|
},
|
||||||
|
searchInput: {
|
||||||
|
flex: 1,
|
||||||
|
fontSize: 16,
|
||||||
|
color: '#333',
|
||||||
|
},
|
||||||
|
mainContentCard: {
|
||||||
|
flex: 1,
|
||||||
|
marginHorizontal: 16,
|
||||||
|
marginBottom: 16,
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderRadius: 16,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: {
|
||||||
|
width: 0,
|
||||||
|
height: 2,
|
||||||
|
},
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 4,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
mainContent: {
|
||||||
|
flex: 1,
|
||||||
|
flexDirection: 'row',
|
||||||
|
},
|
||||||
|
categoryContainer: {
|
||||||
|
width: 100,
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
},
|
||||||
|
categoryItem: {
|
||||||
|
paddingVertical: 16,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
categoryItemActive: {
|
||||||
|
backgroundColor: '#F0F9FF'
|
||||||
|
},
|
||||||
|
categoryText: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#666',
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
categoryTextActive: {
|
||||||
|
color: Colors.light.text,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
foodContainer: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
},
|
||||||
|
foodItem: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 12,
|
||||||
|
},
|
||||||
|
foodInfo: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
foodImage: {
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
},
|
||||||
|
foodEmoji: {
|
||||||
|
fontSize: 32,
|
||||||
|
marginRight: 12,
|
||||||
|
},
|
||||||
|
foodDetails: {
|
||||||
|
flex: 1,
|
||||||
|
marginLeft: 12
|
||||||
|
},
|
||||||
|
foodName: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: '#333',
|
||||||
|
fontWeight: '500',
|
||||||
|
marginBottom: 2,
|
||||||
|
|
||||||
|
},
|
||||||
|
foodCalories: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#999',
|
||||||
|
},
|
||||||
|
addButton: {
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: 16,
|
||||||
|
backgroundColor: '#F5F5F5',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
emptyContainer: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingVertical: 40,
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: '#999',
|
||||||
|
},
|
||||||
|
emptySubText: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#CCC',
|
||||||
|
marginTop: 8,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
// 加载状态样式
|
||||||
|
loadingContainer: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingVertical: 60,
|
||||||
|
},
|
||||||
|
loadingText: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: '#666',
|
||||||
|
marginTop: 12,
|
||||||
|
},
|
||||||
|
// 错误状态样式
|
||||||
|
errorContainer: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingVertical: 60,
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
},
|
||||||
|
errorText: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: '#FF4444',
|
||||||
|
textAlign: 'center',
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
retryButton: {
|
||||||
|
backgroundColor: '#4CAF50',
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingVertical: 10,
|
||||||
|
borderRadius: 20,
|
||||||
|
},
|
||||||
|
retryButtonText: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#FFF',
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
// 搜索加载状态样式
|
||||||
|
searchLoadingContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingVertical: 20,
|
||||||
|
},
|
||||||
|
searchLoadingText: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#666',
|
||||||
|
marginLeft: 8,
|
||||||
|
},
|
||||||
|
bottomContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 12,
|
||||||
|
backgroundColor: '#FFF',
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: '#E5E5E5',
|
||||||
|
},
|
||||||
|
mealSelector: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 8,
|
||||||
|
backgroundColor: '#F8F9FA',
|
||||||
|
borderRadius: 20,
|
||||||
|
},
|
||||||
|
mealIndicator: {
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: 4,
|
||||||
|
backgroundColor: '#FF6B35',
|
||||||
|
marginRight: 8,
|
||||||
|
},
|
||||||
|
mealText: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#333',
|
||||||
|
marginRight: 4,
|
||||||
|
},
|
||||||
|
recordButton: {
|
||||||
|
backgroundColor: Colors.light.primary,
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
paddingVertical: 10,
|
||||||
|
borderRadius: 20,
|
||||||
|
},
|
||||||
|
recordButtonText: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: '#FFF',
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
recordButtonDisabled: {
|
||||||
|
backgroundColor: '#CCC',
|
||||||
|
},
|
||||||
|
recordButtonTextDisabled: {
|
||||||
|
color: '#999',
|
||||||
|
},
|
||||||
|
// 已选择食物列表样式
|
||||||
|
selectedFoodsContainer: {
|
||||||
|
backgroundColor: '#FFF',
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: '#E5E5E5',
|
||||||
|
paddingVertical: 12,
|
||||||
|
maxHeight: 140, // 限制最大高度
|
||||||
|
},
|
||||||
|
selectedFoodsHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
selectedFoodsTitle: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#333',
|
||||||
|
},
|
||||||
|
totalCalories: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: Colors.light.text,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
selectedFoodsList: {
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
},
|
||||||
|
selectedFoodsContent: {
|
||||||
|
paddingRight: 16,
|
||||||
|
},
|
||||||
|
selectedFoodItem: {
|
||||||
|
backgroundColor: '#F8F9FA',
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 8,
|
||||||
|
marginRight: 8,
|
||||||
|
minWidth: 80,
|
||||||
|
alignItems: 'center',
|
||||||
|
position: 'relative',
|
||||||
|
},
|
||||||
|
removeButton: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 4,
|
||||||
|
right: 4,
|
||||||
|
backgroundColor: '#FFF',
|
||||||
|
borderRadius: 8,
|
||||||
|
zIndex: 1,
|
||||||
|
},
|
||||||
|
selectedFoodImage: {
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
selectedFoodEmoji: {
|
||||||
|
fontSize: 20,
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
selectedFoodName: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#333',
|
||||||
|
fontWeight: '500',
|
||||||
|
textAlign: 'center',
|
||||||
|
marginBottom: 2,
|
||||||
|
maxWidth: 64,
|
||||||
|
},
|
||||||
|
selectedFoodAmount: {
|
||||||
|
fontSize: 11,
|
||||||
|
color: '#666',
|
||||||
|
marginBottom: 2,
|
||||||
|
},
|
||||||
|
selectedFoodCalories: {
|
||||||
|
fontSize: 11,
|
||||||
|
color: '#4CAF50',
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
// 餐次选择弹窗样式
|
||||||
|
mealSelectorOverlay: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
},
|
||||||
|
mealSelectorBackdrop: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
mealSelectorModal: {
|
||||||
|
backgroundColor: '#FFF',
|
||||||
|
borderTopLeftRadius: 20,
|
||||||
|
borderTopRightRadius: 20,
|
||||||
|
paddingBottom: 34, // 为底部安全区域留出空间
|
||||||
|
},
|
||||||
|
mealSelectorHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingVertical: 16,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: '#E5E5E5',
|
||||||
|
},
|
||||||
|
mealSelectorTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#333',
|
||||||
|
},
|
||||||
|
mealOptionsContainer: {
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingVertical: 16,
|
||||||
|
},
|
||||||
|
mealOption: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: 16,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
borderRadius: 12,
|
||||||
|
marginBottom: 8,
|
||||||
|
backgroundColor: '#F8F9FA',
|
||||||
|
},
|
||||||
|
mealOptionActive: {
|
||||||
|
backgroundColor: '#E8F5E8',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#4CAF50',
|
||||||
|
},
|
||||||
|
mealOptionIndicator: {
|
||||||
|
width: 12,
|
||||||
|
height: 12,
|
||||||
|
borderRadius: 6,
|
||||||
|
marginRight: 12,
|
||||||
|
},
|
||||||
|
mealOptionText: {
|
||||||
|
flex: 1,
|
||||||
|
fontSize: 16,
|
||||||
|
color: '#333',
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
mealOptionTextActive: {
|
||||||
|
color: '#4CAF50',
|
||||||
|
},
|
||||||
|
});
|
||||||
1262
app/food/analysis-result.tsx
Normal file
612
app/food/camera.tsx
Normal file
@@ -0,0 +1,612 @@
|
|||||||
|
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||||
|
import { Colors } from '@/constants/Colors';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { CameraType, CameraView, useCameraPermissions } from 'expo-camera';
|
||||||
|
import { Image } from 'expo-image';
|
||||||
|
import * as ImagePicker from 'expo-image-picker';
|
||||||
|
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||||
|
import React, { useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Dimensions,
|
||||||
|
Modal,
|
||||||
|
StatusBar,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
|
type MealType = 'breakfast' | 'lunch' | 'dinner' | 'snack';
|
||||||
|
|
||||||
|
const { width: screenWidth, height: screenHeight } = Dimensions.get('window');
|
||||||
|
|
||||||
|
export default function FoodCameraScreen() {
|
||||||
|
const router = useRouter();
|
||||||
|
const params = useLocalSearchParams<{ mealType?: string }>();
|
||||||
|
const cameraRef = useRef<CameraView>(null);
|
||||||
|
|
||||||
|
const [currentMealType, setCurrentMealType] = useState<MealType>(
|
||||||
|
(params.mealType as MealType) || 'dinner'
|
||||||
|
);
|
||||||
|
const [facing, setFacing] = useState<CameraType>('back');
|
||||||
|
const [permission, requestPermission] = useCameraPermissions();
|
||||||
|
const [showInstructionModal, setShowInstructionModal] = useState(false);
|
||||||
|
|
||||||
|
// 餐次选择选项
|
||||||
|
const mealOptions = [
|
||||||
|
{ key: 'breakfast' as const, label: '早餐', icon: '☀️' },
|
||||||
|
{ key: 'lunch' as const, label: '午餐', icon: '🌤️' },
|
||||||
|
{ key: 'dinner' as const, label: '晚餐', icon: '🌙' },
|
||||||
|
{ key: 'snack' as const, label: '加餐', icon: '🍎' },
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!permission) {
|
||||||
|
// 权限仍在加载中
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.container}>
|
||||||
|
<HeaderBar
|
||||||
|
title="食物拍摄"
|
||||||
|
onBack={() => router.back()}
|
||||||
|
transparent={true}
|
||||||
|
/>
|
||||||
|
<View style={styles.loadingContainer}>
|
||||||
|
<Text style={styles.loadingText}>正在加载相机...</Text>
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!permission.granted) {
|
||||||
|
// 没有相机权限
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.container}>
|
||||||
|
<HeaderBar
|
||||||
|
title="食物拍摄"
|
||||||
|
onBack={() => router.back()}
|
||||||
|
backColor='#ffffff'
|
||||||
|
/>
|
||||||
|
<View style={styles.permissionContainer}>
|
||||||
|
<Ionicons name="camera-outline" size={64} color="#999" />
|
||||||
|
<Text style={styles.permissionTitle}>需要相机权限</Text>
|
||||||
|
<Text style={styles.permissionText}>
|
||||||
|
为了拍摄食物,需要访问您的相机
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.permissionButton}
|
||||||
|
onPress={requestPermission}
|
||||||
|
>
|
||||||
|
<Text style={styles.permissionButtonText}>授权访问</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换相机前后摄像头
|
||||||
|
function toggleCameraFacing() {
|
||||||
|
setFacing(current => (current === 'back' ? 'front' : 'back'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 拍摄照片
|
||||||
|
const takePicture = async () => {
|
||||||
|
if (cameraRef.current) {
|
||||||
|
try {
|
||||||
|
const photo = await cameraRef.current.takePictureAsync({
|
||||||
|
quality: 0.8,
|
||||||
|
base64: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (photo) {
|
||||||
|
// 跳转到食物识别页面
|
||||||
|
console.log('照片拍摄成功:', photo.uri);
|
||||||
|
router.replace(`/food/food-recognition?imageUri=${encodeURIComponent(photo.uri)}&mealType=${currentMealType}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('拍照失败:', error);
|
||||||
|
Alert.alert('拍照失败', '请重试');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 从相册选择照片
|
||||||
|
const pickImageFromGallery = async () => {
|
||||||
|
try {
|
||||||
|
const result = await ImagePicker.launchImageLibraryAsync({
|
||||||
|
mediaTypes: ImagePicker.MediaTypeOptions.Images,
|
||||||
|
allowsEditing: true,
|
||||||
|
aspect: [4, 3],
|
||||||
|
quality: 0.8,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.canceled && result.assets[0]) {
|
||||||
|
const imageUri = result.assets[0].uri;
|
||||||
|
console.log('从相册选择的照片:', imageUri);
|
||||||
|
router.push(`/food/food-recognition?imageUri=${encodeURIComponent(imageUri)}&mealType=${currentMealType}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('选择照片失败:', error);
|
||||||
|
Alert.alert('选择失败', '请重试');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// AR功能(暂时显示提示)
|
||||||
|
const handleARPress = () => {
|
||||||
|
Alert.alert('AR功能', 'AR食物识别功能即将推出');
|
||||||
|
};
|
||||||
|
|
||||||
|
// 餐次选择
|
||||||
|
const handleMealTypeChange = (mealType: MealType) => {
|
||||||
|
setCurrentMealType(mealType);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<StatusBar barStyle="light-content" backgroundColor="transparent" translucent />
|
||||||
|
|
||||||
|
{/* 头部导航 */}
|
||||||
|
<HeaderBar
|
||||||
|
title=""
|
||||||
|
onBack={() => router.back()}
|
||||||
|
transparent={true}
|
||||||
|
backColor={'#fff'}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 主要内容区域 */}
|
||||||
|
<View style={styles.contentContainer}>
|
||||||
|
{/* 取景框容器 */}
|
||||||
|
<View style={styles.cameraFrameContainer}>
|
||||||
|
<Text style={styles.hintText}>确保食物在取景框内</Text>
|
||||||
|
|
||||||
|
{/* 相机取景框包装器 */}
|
||||||
|
<View style={styles.cameraWrapper}>
|
||||||
|
{/* 相机取景框 */}
|
||||||
|
<View style={styles.cameraFrame}>
|
||||||
|
<CameraView
|
||||||
|
ref={cameraRef}
|
||||||
|
style={styles.cameraView}
|
||||||
|
facing={facing}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
{/* 取景框装饰 - 放在外层避免被截断 */}
|
||||||
|
<View style={styles.viewfinderOverlay}>
|
||||||
|
<View style={[styles.corner, styles.topLeft]} />
|
||||||
|
<View style={[styles.corner, styles.topRight]} />
|
||||||
|
<View style={[styles.corner, styles.bottomLeft]} />
|
||||||
|
<View style={[styles.corner, styles.bottomRight]} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 餐次选择器 */}
|
||||||
|
<View style={styles.mealTypeContainer}>
|
||||||
|
{mealOptions.map((option) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={option.key}
|
||||||
|
style={[
|
||||||
|
styles.mealTypeButton,
|
||||||
|
currentMealType === option.key && styles.mealTypeButtonActive
|
||||||
|
]}
|
||||||
|
onPress={() => handleMealTypeChange(option.key)}
|
||||||
|
>
|
||||||
|
<Text style={styles.mealTypeIcon}>{option.icon}</Text>
|
||||||
|
<Text style={[
|
||||||
|
styles.mealTypeText,
|
||||||
|
currentMealType === option.key && styles.mealTypeTextActive
|
||||||
|
]}>
|
||||||
|
{option.label}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 底部控制栏 */}
|
||||||
|
<View style={styles.bottomContainer}>
|
||||||
|
<View style={styles.controlsContainer}>
|
||||||
|
{/* 相册选择按钮 */}
|
||||||
|
<TouchableOpacity style={styles.galleryButton} onPress={pickImageFromGallery}>
|
||||||
|
<Ionicons name="images-outline" size={24} color="#FFF" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{/* 拍照按钮 */}
|
||||||
|
<TouchableOpacity style={styles.captureButton} onPress={takePicture}>
|
||||||
|
<View style={styles.captureButtonInner} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{/* 帮助按钮 */}
|
||||||
|
<TouchableOpacity style={styles.helpButton} onPress={() => setShowInstructionModal(true)}>
|
||||||
|
<Ionicons name="help-outline" size={24} color="#FFF" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 拍摄说明弹窗 */}
|
||||||
|
<Modal
|
||||||
|
visible={showInstructionModal}
|
||||||
|
animationType="fade"
|
||||||
|
transparent={true}
|
||||||
|
onRequestClose={() => setShowInstructionModal(false)}
|
||||||
|
>
|
||||||
|
<View style={styles.modalOverlay}>
|
||||||
|
<View style={styles.instructionModal}>
|
||||||
|
<Text style={styles.instructionTitle}>拍摄示例</Text>
|
||||||
|
|
||||||
|
<View style={styles.exampleContainer}>
|
||||||
|
{/* 好的示例 */}
|
||||||
|
<View style={styles.exampleItem}>
|
||||||
|
<View style={styles.exampleImagePlaceholder}>
|
||||||
|
<View style={styles.checkmarkContainer}>
|
||||||
|
<Ionicons name="checkmark" size={32} color="#FFF" />
|
||||||
|
</View>
|
||||||
|
{/* 这里可以放置好的示例图片 */}
|
||||||
|
<Image
|
||||||
|
style={styles.exampleImage}
|
||||||
|
source={{ uri: 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/function/food-right.jpeg' }}
|
||||||
|
cachePolicy={'memory-disk'}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 不好的示例 */}
|
||||||
|
<View style={styles.exampleItem}>
|
||||||
|
<View style={styles.exampleImagePlaceholder}>
|
||||||
|
<View style={styles.crossContainer}>
|
||||||
|
<Ionicons name="close" size={32} color="#FFF" />
|
||||||
|
</View>
|
||||||
|
<Image
|
||||||
|
style={styles.exampleImage}
|
||||||
|
source={{ uri: 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/function/food-wrong.jpeg' }}
|
||||||
|
cachePolicy={'memory-disk'}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={styles.instructionDescription}>
|
||||||
|
请上传或拍摄如左图所示的食物照片
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.knowButton}
|
||||||
|
onPress={() => setShowInstructionModal(false)}
|
||||||
|
>
|
||||||
|
<Text style={styles.knowButtonText}>知道了</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#000',
|
||||||
|
},
|
||||||
|
contentContainer: {
|
||||||
|
flex: 1,
|
||||||
|
paddingTop: 100,
|
||||||
|
},
|
||||||
|
cameraFrameContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
},
|
||||||
|
cameraWrapper: {
|
||||||
|
width: 300,
|
||||||
|
height: 300,
|
||||||
|
position: 'relative',
|
||||||
|
},
|
||||||
|
cameraFrame: {
|
||||||
|
width: 300,
|
||||||
|
height: 300,
|
||||||
|
borderRadius: 20,
|
||||||
|
overflow: 'hidden',
|
||||||
|
backgroundColor: '#000',
|
||||||
|
},
|
||||||
|
cameraView: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
viewfinderOverlay: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
zIndex: 1,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
},
|
||||||
|
camera: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
loadingContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: '#000',
|
||||||
|
},
|
||||||
|
loadingText: {
|
||||||
|
color: '#FFF',
|
||||||
|
fontSize: 16,
|
||||||
|
},
|
||||||
|
permissionContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: '#000',
|
||||||
|
paddingHorizontal: 40,
|
||||||
|
},
|
||||||
|
permissionTitle: {
|
||||||
|
color: '#FFF',
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: '600',
|
||||||
|
marginTop: 20,
|
||||||
|
marginBottom: 10,
|
||||||
|
},
|
||||||
|
permissionText: {
|
||||||
|
color: '#CCC',
|
||||||
|
fontSize: 16,
|
||||||
|
textAlign: 'center',
|
||||||
|
marginBottom: 30,
|
||||||
|
lineHeight: 22,
|
||||||
|
},
|
||||||
|
permissionButton: {
|
||||||
|
backgroundColor: Colors.light.primary,
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
paddingVertical: 12,
|
||||||
|
borderRadius: 24,
|
||||||
|
},
|
||||||
|
permissionButtonText: {
|
||||||
|
color: '#FFF',
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
zIndex: 10,
|
||||||
|
},
|
||||||
|
hintText: {
|
||||||
|
color: '#FFF',
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '500',
|
||||||
|
marginBottom: 20,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
corner: {
|
||||||
|
position: 'absolute',
|
||||||
|
width: 30,
|
||||||
|
height: 30,
|
||||||
|
borderColor: '#FFF',
|
||||||
|
borderWidth: 3,
|
||||||
|
},
|
||||||
|
topLeft: {
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
borderRightWidth: 0,
|
||||||
|
borderBottomWidth: 0,
|
||||||
|
},
|
||||||
|
topRight: {
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
borderLeftWidth: 0,
|
||||||
|
borderBottomWidth: 0,
|
||||||
|
},
|
||||||
|
bottomLeft: {
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
borderRightWidth: 0,
|
||||||
|
borderTopWidth: 0,
|
||||||
|
},
|
||||||
|
bottomRight: {
|
||||||
|
bottom: 0,
|
||||||
|
right: 0,
|
||||||
|
borderLeftWidth: 0,
|
||||||
|
borderTopWidth: 0,
|
||||||
|
},
|
||||||
|
mealTypeContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
marginVertical: 20,
|
||||||
|
},
|
||||||
|
mealTypeButton: {
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 8,
|
||||||
|
marginHorizontal: 8,
|
||||||
|
borderRadius: 20,
|
||||||
|
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||||
|
minWidth: 70,
|
||||||
|
},
|
||||||
|
mealTypeButtonActive: {
|
||||||
|
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
||||||
|
},
|
||||||
|
mealTypeIcon: {
|
||||||
|
fontSize: 20,
|
||||||
|
marginBottom: 2,
|
||||||
|
},
|
||||||
|
mealTypeText: {
|
||||||
|
color: '#FFF',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
mealTypeTextActive: {
|
||||||
|
color: '#333',
|
||||||
|
},
|
||||||
|
bottomContainer: {
|
||||||
|
paddingBottom: 40,
|
||||||
|
},
|
||||||
|
controlsContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: 20,
|
||||||
|
paddingHorizontal: 40,
|
||||||
|
},
|
||||||
|
controlButton: {
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
controlButtonText: {
|
||||||
|
color: '#FFF',
|
||||||
|
fontSize: 12,
|
||||||
|
marginTop: 8,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
albumButton: {
|
||||||
|
width: 50,
|
||||||
|
height: 50,
|
||||||
|
borderRadius: 10,
|
||||||
|
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: '#FFF',
|
||||||
|
},
|
||||||
|
captureButton: {
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
borderRadius: 40,
|
||||||
|
backgroundColor: '#FFF',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
borderWidth: 4,
|
||||||
|
borderColor: 'rgba(255, 255, 255, 0.3)',
|
||||||
|
},
|
||||||
|
captureButtonInner: {
|
||||||
|
width: 60,
|
||||||
|
height: 60,
|
||||||
|
borderRadius: 30,
|
||||||
|
backgroundColor: '#FFF',
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: '#333',
|
||||||
|
},
|
||||||
|
arButton: {
|
||||||
|
width: 50,
|
||||||
|
height: 50,
|
||||||
|
borderRadius: 25,
|
||||||
|
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: '#FFF',
|
||||||
|
},
|
||||||
|
arButtonText: {
|
||||||
|
color: '#FFF',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
},
|
||||||
|
galleryButton: {
|
||||||
|
width: 50,
|
||||||
|
height: 50,
|
||||||
|
borderRadius: 25,
|
||||||
|
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: '#FFF',
|
||||||
|
},
|
||||||
|
helpButton: {
|
||||||
|
width: 50,
|
||||||
|
height: 50,
|
||||||
|
borderRadius: 25,
|
||||||
|
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: '#FFF',
|
||||||
|
},
|
||||||
|
modalOverlay: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.7)',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
},
|
||||||
|
instructionModal: {
|
||||||
|
backgroundColor: '#FFF',
|
||||||
|
borderTopLeftRadius: 20,
|
||||||
|
borderTopRightRadius: 20,
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
paddingVertical: 32,
|
||||||
|
minHeight: 400,
|
||||||
|
},
|
||||||
|
instructionTitle: {
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
textAlign: 'center',
|
||||||
|
marginBottom: 32,
|
||||||
|
color: '#333',
|
||||||
|
},
|
||||||
|
exampleContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginBottom: 24,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
},
|
||||||
|
exampleItem: {
|
||||||
|
flex: 1,
|
||||||
|
marginHorizontal: 8,
|
||||||
|
},
|
||||||
|
exampleImagePlaceholder: {
|
||||||
|
width: '100%',
|
||||||
|
aspectRatio: 3 / 4,
|
||||||
|
backgroundColor: '#F0F0F0',
|
||||||
|
borderRadius: 16,
|
||||||
|
position: 'relative',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
checkmarkContainer: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 12,
|
||||||
|
right: 12,
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: 16,
|
||||||
|
backgroundColor: '#4CAF50',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
zIndex: 10,
|
||||||
|
},
|
||||||
|
crossContainer: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 12,
|
||||||
|
right: 12,
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: 16,
|
||||||
|
backgroundColor: '#F44336',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
zIndex: 10,
|
||||||
|
},
|
||||||
|
exampleImage: {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
},
|
||||||
|
instructionDescription: {
|
||||||
|
fontSize: 16,
|
||||||
|
textAlign: 'center',
|
||||||
|
color: '#666',
|
||||||
|
marginBottom: 32,
|
||||||
|
lineHeight: 24,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
},
|
||||||
|
knowButton: {
|
||||||
|
backgroundColor: '#000',
|
||||||
|
borderRadius: 25,
|
||||||
|
paddingVertical: 16,
|
||||||
|
marginHorizontal: 16,
|
||||||
|
},
|
||||||
|
knowButtonText: {
|
||||||
|
color: '#FFF',
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
});
|
||||||
747
app/food/food-recognition.tsx
Normal file
@@ -0,0 +1,747 @@
|
|||||||
|
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||||
|
import { Colors } from '@/constants/Colors';
|
||||||
|
import { useAppDispatch } from '@/hooks/redux';
|
||||||
|
import { useCosUpload } from '@/hooks/useCosUpload';
|
||||||
|
import { recognizeFood } from '@/services/foodRecognition';
|
||||||
|
import { saveRecognitionResult, setError, setLoading } from '@/store/foodRecognitionSlice';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Alert,
|
||||||
|
Animated,
|
||||||
|
Easing,
|
||||||
|
Image,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TouchableOpacity,
|
||||||
|
View
|
||||||
|
} from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
|
export default function FoodRecognitionScreen() {
|
||||||
|
const router = useRouter();
|
||||||
|
const params = useLocalSearchParams<{
|
||||||
|
imageUri?: string;
|
||||||
|
mealType?: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const { imageUri, mealType } = params;
|
||||||
|
const { upload } = useCosUpload();
|
||||||
|
const [showRecognitionProcess, setShowRecognitionProcess] = useState(false);
|
||||||
|
const [recognitionLogs, setRecognitionLogs] = useState<string[]>([]);
|
||||||
|
const [currentStep, setCurrentStep] = useState<'idle' | 'uploading' | 'recognizing' | 'completed' | 'failed'>('idle');
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
// 动画引用
|
||||||
|
const scaleAnim = useRef(new Animated.Value(1)).current;
|
||||||
|
const fadeAnim = useRef(new Animated.Value(0)).current;
|
||||||
|
const slideAnim = useRef(new Animated.Value(50)).current;
|
||||||
|
const progressAnim = useRef(new Animated.Value(0)).current;
|
||||||
|
const pulseAnim = useRef(new Animated.Value(1)).current;
|
||||||
|
|
||||||
|
// 启动动画效果
|
||||||
|
useEffect(() => {
|
||||||
|
if (showRecognitionProcess) {
|
||||||
|
// 进入动画
|
||||||
|
Animated.parallel([
|
||||||
|
Animated.timing(fadeAnim, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 600,
|
||||||
|
easing: Easing.out(Easing.cubic),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.timing(slideAnim, {
|
||||||
|
toValue: 0,
|
||||||
|
duration: 600,
|
||||||
|
easing: Easing.out(Easing.cubic),
|
||||||
|
useNativeDriver: true,
|
||||||
|
})
|
||||||
|
]).start();
|
||||||
|
|
||||||
|
// 启动进度条动画
|
||||||
|
if (currentStep === 'uploading' || currentStep === 'recognizing') {
|
||||||
|
Animated.timing(progressAnim, {
|
||||||
|
toValue: currentStep === 'uploading' ? 0.5 : 1,
|
||||||
|
duration: 2000,
|
||||||
|
easing: Easing.inOut(Easing.ease),
|
||||||
|
useNativeDriver: false,
|
||||||
|
}).start();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 脉冲动画
|
||||||
|
const pulseAnimation = Animated.loop(
|
||||||
|
Animated.sequence([
|
||||||
|
Animated.timing(pulseAnim, {
|
||||||
|
toValue: 1.1,
|
||||||
|
duration: 800,
|
||||||
|
easing: Easing.inOut(Easing.sin),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.timing(pulseAnim, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 800,
|
||||||
|
easing: Easing.inOut(Easing.sin),
|
||||||
|
useNativeDriver: true,
|
||||||
|
})
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
if (currentStep === 'uploading' || currentStep === 'recognizing') {
|
||||||
|
pulseAnimation.start();
|
||||||
|
} else {
|
||||||
|
pulseAnimation.stop();
|
||||||
|
pulseAnim.setValue(1);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fadeAnim.setValue(0);
|
||||||
|
slideAnim.setValue(50);
|
||||||
|
progressAnim.setValue(0);
|
||||||
|
}
|
||||||
|
}, [showRecognitionProcess, currentStep]);
|
||||||
|
|
||||||
|
const addLog = (message: string) => {
|
||||||
|
setRecognitionLogs(prev => [...prev, message]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirm = async () => {
|
||||||
|
if (!imageUri) return;
|
||||||
|
|
||||||
|
// 按钮动画效果
|
||||||
|
Animated.sequence([
|
||||||
|
Animated.timing(scaleAnim, {
|
||||||
|
toValue: 0.95,
|
||||||
|
duration: 100,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.timing(scaleAnim, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 100,
|
||||||
|
useNativeDriver: true,
|
||||||
|
})
|
||||||
|
]).start();
|
||||||
|
|
||||||
|
try {
|
||||||
|
setShowRecognitionProcess(true);
|
||||||
|
setRecognitionLogs([]);
|
||||||
|
setCurrentStep('uploading');
|
||||||
|
dispatch(setLoading(true));
|
||||||
|
|
||||||
|
addLog('📤 正在上传图片到云端...');
|
||||||
|
|
||||||
|
// 上传图片到 COS
|
||||||
|
const { url } = await upload(
|
||||||
|
{ uri: imageUri, name: 'food-image.jpg', type: 'image/jpeg' },
|
||||||
|
{ prefix: 'food-images/' }
|
||||||
|
);
|
||||||
|
|
||||||
|
addLog('✅ 图片上传完成');
|
||||||
|
addLog('🤖 AI大模型分析中...');
|
||||||
|
setCurrentStep('recognizing');
|
||||||
|
|
||||||
|
// 调用食物识别 API
|
||||||
|
const recognitionResult = await recognizeFood({
|
||||||
|
imageUrls: [url]
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('食物识别结果:', recognitionResult);
|
||||||
|
|
||||||
|
if (!recognitionResult.isFoodDetected) {
|
||||||
|
addLog('❌ 识别失败:未检测到食物');
|
||||||
|
addLog(`💭 ${recognitionResult.nonFoodMessage || recognitionResult.analysisText}`);
|
||||||
|
setCurrentStep('failed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
addLog('✅ AI分析完成');
|
||||||
|
addLog(`🎯 识别置信度: ${recognitionResult.confidence}%`);
|
||||||
|
addLog(`🍽️ 识别到 ${recognitionResult.items.length} 种食物`);
|
||||||
|
|
||||||
|
setCurrentStep('completed');
|
||||||
|
|
||||||
|
// 生成唯一的识别ID
|
||||||
|
const recognitionId = `recognition_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
|
||||||
|
// 保存识别结果到 Redux
|
||||||
|
dispatch(saveRecognitionResult({
|
||||||
|
id: recognitionId,
|
||||||
|
result: recognitionResult
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 延迟跳转,让用户看到完成状态
|
||||||
|
setTimeout(() => {
|
||||||
|
router.replace(`/food/analysis-result?imageUri=${encodeURIComponent(url)}&mealType=${mealType}&recognitionId=${recognitionId}`);
|
||||||
|
}, 1500);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('食物识别失败', error);
|
||||||
|
addLog('❌ 识别过程出错');
|
||||||
|
addLog(`💥 ${error instanceof Error ? error.message : '未知错误'}`);
|
||||||
|
setCurrentStep('failed');
|
||||||
|
dispatch(setError('食物识别失败,请重试'));
|
||||||
|
} finally {
|
||||||
|
dispatch(setLoading(false));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRetry = () => {
|
||||||
|
setShowRecognitionProcess(false);
|
||||||
|
setCurrentStep('idle');
|
||||||
|
setRecognitionLogs([]);
|
||||||
|
dispatch(setError(null));
|
||||||
|
|
||||||
|
router.back()
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGoBack = () => {
|
||||||
|
if (showRecognitionProcess && currentStep !== 'failed') {
|
||||||
|
Alert.alert(
|
||||||
|
'正在识别中',
|
||||||
|
'识别过程尚未完成,确定要返回吗?',
|
||||||
|
[
|
||||||
|
{ text: '继续识别', style: 'cancel' },
|
||||||
|
{ text: '返回', style: 'destructive', onPress: () => router.back() }
|
||||||
|
]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
router.back();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!imageUri) {
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.container}>
|
||||||
|
<HeaderBar
|
||||||
|
title="食物识别"
|
||||||
|
onBack={() => router.back()}
|
||||||
|
/>
|
||||||
|
<View style={styles.errorContainer}>
|
||||||
|
<Text style={styles.errorText}>未找到图片</Text>
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<HeaderBar
|
||||||
|
title={showRecognitionProcess ? "食物识别" : "确认食物"}
|
||||||
|
onBack={handleGoBack}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 主要内容区域 */}
|
||||||
|
<ScrollView style={styles.contentContainer} showsVerticalScrollIndicator={false}>
|
||||||
|
{!showRecognitionProcess ? (
|
||||||
|
// 确认界面
|
||||||
|
<>
|
||||||
|
{/* 照片卡片 */}
|
||||||
|
<View style={styles.photoCard}>
|
||||||
|
<View style={styles.photoFrame}>
|
||||||
|
<Image
|
||||||
|
source={{ uri: imageUri }}
|
||||||
|
style={styles.photoImage}
|
||||||
|
resizeMode="cover"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 餐次标签叠加 */}
|
||||||
|
{mealType && (
|
||||||
|
<View style={styles.mealTypeBadge}>
|
||||||
|
<Text style={styles.mealTypeBadgeText}>
|
||||||
|
{getMealTypeLabel(mealType)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* AI 识别说明卡片 */}
|
||||||
|
<View style={styles.infoCard}>
|
||||||
|
<View style={styles.infoHeader}>
|
||||||
|
<View style={styles.aiIconContainer}>
|
||||||
|
<Ionicons name="sparkles" size={20} color={Colors.light.primary} />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.infoTitle}>智能食物识别</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.infoDescription}>
|
||||||
|
AI 将分析您的照片,识别食物种类、估算营养成分,为您生成详细的营养分析报告
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 底部按钮区域 */}
|
||||||
|
<View style={styles.bottomContainer}>
|
||||||
|
<Animated.View style={[styles.confirmButtonContainer, { transform: [{ scale: scaleAnim }] }]}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.confirmButton}
|
||||||
|
onPress={handleConfirm}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
<View style={styles.confirmButtonContent}>
|
||||||
|
<Ionicons name="scan" size={20} color={Colors.light.onPrimary} style={{ marginRight: 8 }} />
|
||||||
|
<Text style={styles.confirmButtonText}>开始智能识别</Text>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</Animated.View>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
// 识别过程界面
|
||||||
|
<Animated.View style={[styles.recognitionContainer, {
|
||||||
|
opacity: fadeAnim,
|
||||||
|
transform: [{ translateY: slideAnim }]
|
||||||
|
}]}>
|
||||||
|
{/* 照片缩略图卡片 */}
|
||||||
|
<View style={styles.thumbnailCard}>
|
||||||
|
<Image
|
||||||
|
source={{ uri: imageUri }}
|
||||||
|
style={styles.thumbnailImage}
|
||||||
|
resizeMode="cover"
|
||||||
|
/>
|
||||||
|
<View style={styles.thumbnailInfo}>
|
||||||
|
{mealType && (
|
||||||
|
<View style={styles.thumbnailMealType}>
|
||||||
|
<Text style={styles.thumbnailMealTypeText}>
|
||||||
|
{getMealTypeLabel(mealType)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<Text style={styles.thumbnailTitle}>AI 识别中...</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 进度指示卡片 */}
|
||||||
|
<View style={styles.progressCard}>
|
||||||
|
<View style={styles.progressHeader}>
|
||||||
|
<Animated.View style={[styles.statusIconAnimated, { transform: [{ scale: pulseAnim }] }]}>
|
||||||
|
<View style={[styles.statusIcon, {
|
||||||
|
backgroundColor: currentStep === 'uploading' || currentStep === 'recognizing' ? Colors.light.primary :
|
||||||
|
currentStep === 'completed' ? Colors.light.success :
|
||||||
|
currentStep === 'failed' ? Colors.light.danger : Colors.light.neutral200
|
||||||
|
}]}>
|
||||||
|
{currentStep === 'uploading' || currentStep === 'recognizing' ? (
|
||||||
|
<ActivityIndicator size="small" color={Colors.light.onPrimary} />
|
||||||
|
) : currentStep === 'completed' ? (
|
||||||
|
<Ionicons name="checkmark" size={20} color={Colors.light.onPrimary} />
|
||||||
|
) : currentStep === 'failed' ? (
|
||||||
|
<Ionicons name="close" size={20} color={Colors.light.onPrimary} />
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
<View style={styles.progressInfo}>
|
||||||
|
<Text style={styles.statusText}>{
|
||||||
|
currentStep === 'idle' ? '准备中' :
|
||||||
|
currentStep === 'uploading' ? '上传图片中' :
|
||||||
|
currentStep === 'recognizing' ? 'AI 分析中' :
|
||||||
|
currentStep === 'completed' ? '识别完成' :
|
||||||
|
currentStep === 'failed' ? '识别失败' : ''
|
||||||
|
}</Text>
|
||||||
|
<Text style={styles.statusSubtext}>{
|
||||||
|
currentStep === 'uploading' ? '正在将图片上传到云端处理...' :
|
||||||
|
currentStep === 'recognizing' ? '智能模型正在分析食物成分...' :
|
||||||
|
currentStep === 'completed' ? '即将跳转到分析结果页面' :
|
||||||
|
currentStep === 'failed' ? '请检查网络连接或重新拍照' : ''
|
||||||
|
}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 进度条 */}
|
||||||
|
{(currentStep === 'uploading' || currentStep === 'recognizing') && (
|
||||||
|
<View style={styles.progressBarContainer}>
|
||||||
|
<View style={styles.progressBarBackground}>
|
||||||
|
<Animated.View
|
||||||
|
style={[styles.progressBarFill, {
|
||||||
|
width: progressAnim.interpolate({
|
||||||
|
inputRange: [0, 1],
|
||||||
|
outputRange: ['0%', '100%'],
|
||||||
|
})
|
||||||
|
}]}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 识别日志卡片 */}
|
||||||
|
<View style={styles.logCard}>
|
||||||
|
<View style={styles.logHeader}>
|
||||||
|
<Ionicons name="document-text-outline" size={18} color={Colors.light.primary} />
|
||||||
|
<Text style={styles.logTitle}>进度</Text>
|
||||||
|
</View>
|
||||||
|
<ScrollView
|
||||||
|
style={styles.logScrollView}
|
||||||
|
contentContainerStyle={styles.logContent}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
{recognitionLogs.map((log, index) => (
|
||||||
|
<Animated.View
|
||||||
|
key={index}
|
||||||
|
style={[styles.logItem, {
|
||||||
|
opacity: fadeAnim,
|
||||||
|
transform: [{ translateX: slideAnim }]
|
||||||
|
}]}
|
||||||
|
>
|
||||||
|
<Text style={styles.logText}>{log}</Text>
|
||||||
|
</Animated.View>
|
||||||
|
))}
|
||||||
|
{recognitionLogs.length === 0 && (
|
||||||
|
<Text style={styles.logPlaceholder}>等待处理开始...</Text>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 重试按钮 */}
|
||||||
|
{currentStep === 'failed' && (
|
||||||
|
<View style={styles.bottomContainer}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.retryButton}
|
||||||
|
onPress={handleRetry}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
<Ionicons name="refresh" size={18} color={Colors.light.onPrimary} style={{ marginRight: 8 }} />
|
||||||
|
<Text style={styles.retryButtonText}>返回重新拍照</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Animated.View>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取餐次标签
|
||||||
|
function getMealTypeLabel(mealType: string): string {
|
||||||
|
const mealTypeMap: Record<string, string> = {
|
||||||
|
breakfast: '早餐',
|
||||||
|
lunch: '午餐',
|
||||||
|
dinner: '晚餐',
|
||||||
|
snack: '加餐',
|
||||||
|
};
|
||||||
|
return mealTypeMap[mealType] || '未知餐次';
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: Colors.light.pageBackgroundEmphasis,
|
||||||
|
},
|
||||||
|
contentContainer: {
|
||||||
|
flex: 1,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingTop: 16,
|
||||||
|
},
|
||||||
|
|
||||||
|
// 照片卡片样式
|
||||||
|
photoCard: {
|
||||||
|
backgroundColor: Colors.light.card,
|
||||||
|
borderRadius: 24,
|
||||||
|
padding: 20,
|
||||||
|
marginBottom: 16,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 8 },
|
||||||
|
shadowOpacity: 0.08,
|
||||||
|
shadowRadius: 20,
|
||||||
|
elevation: 8,
|
||||||
|
},
|
||||||
|
photoFrame: {
|
||||||
|
width: '100%',
|
||||||
|
aspectRatio: 1,
|
||||||
|
borderRadius: 20,
|
||||||
|
overflow: 'hidden',
|
||||||
|
backgroundColor: Colors.light.neutral100,
|
||||||
|
position: 'relative',
|
||||||
|
},
|
||||||
|
photoImage: {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
},
|
||||||
|
mealTypeBadge: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 16,
|
||||||
|
right: 16,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 8,
|
||||||
|
backgroundColor: 'rgba(122, 90, 248, 0.95)',
|
||||||
|
borderRadius: 20,
|
||||||
|
backdropFilter: 'blur(10px)',
|
||||||
|
},
|
||||||
|
mealTypeBadgeText: {
|
||||||
|
color: Colors.light.onPrimary,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: '700',
|
||||||
|
letterSpacing: 0.3,
|
||||||
|
},
|
||||||
|
|
||||||
|
// 信息卡片样式
|
||||||
|
infoCard: {
|
||||||
|
backgroundColor: Colors.light.card,
|
||||||
|
borderRadius: 20,
|
||||||
|
padding: 20,
|
||||||
|
marginBottom: 24,
|
||||||
|
shadowColor: Colors.light.primary,
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowOpacity: 0.06,
|
||||||
|
shadowRadius: 16,
|
||||||
|
elevation: 4,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: Colors.light.heroSurfaceTint,
|
||||||
|
},
|
||||||
|
infoHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
aiIconContainer: {
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: 16,
|
||||||
|
backgroundColor: Colors.light.heroSurfaceTint,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginRight: 12,
|
||||||
|
},
|
||||||
|
infoTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: Colors.light.text,
|
||||||
|
letterSpacing: 0.2,
|
||||||
|
},
|
||||||
|
infoDescription: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: Colors.light.textSecondary,
|
||||||
|
lineHeight: 22,
|
||||||
|
letterSpacing: 0.1,
|
||||||
|
},
|
||||||
|
|
||||||
|
// 按钮样式
|
||||||
|
bottomContainer: {
|
||||||
|
paddingBottom: 40,
|
||||||
|
paddingTop: 8,
|
||||||
|
},
|
||||||
|
confirmButtonContainer: {},
|
||||||
|
confirmButton: {
|
||||||
|
backgroundColor: Colors.light.primary,
|
||||||
|
paddingVertical: 18,
|
||||||
|
paddingHorizontal: 32,
|
||||||
|
borderRadius: 28,
|
||||||
|
alignItems: 'center',
|
||||||
|
shadowColor: Colors.light.primary,
|
||||||
|
shadowOffset: { width: 0, height: 8 },
|
||||||
|
shadowOpacity: 0.35,
|
||||||
|
shadowRadius: 20,
|
||||||
|
elevation: 8,
|
||||||
|
},
|
||||||
|
confirmButtonContent: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
confirmButtonText: {
|
||||||
|
color: Colors.light.onPrimary,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '700',
|
||||||
|
letterSpacing: 0.4,
|
||||||
|
},
|
||||||
|
|
||||||
|
// 识别过程容器
|
||||||
|
recognitionContainer: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
|
||||||
|
// 缩略图卡片
|
||||||
|
thumbnailCard: {
|
||||||
|
backgroundColor: Colors.light.card,
|
||||||
|
borderRadius: 20,
|
||||||
|
padding: 16,
|
||||||
|
marginBottom: 16,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowOpacity: 0.06,
|
||||||
|
shadowRadius: 12,
|
||||||
|
elevation: 4,
|
||||||
|
},
|
||||||
|
thumbnailImage: {
|
||||||
|
width: 70,
|
||||||
|
height: 70,
|
||||||
|
borderRadius: 16,
|
||||||
|
},
|
||||||
|
thumbnailInfo: {
|
||||||
|
flex: 1,
|
||||||
|
marginLeft: 16,
|
||||||
|
},
|
||||||
|
thumbnailMealType: {
|
||||||
|
alignSelf: 'flex-start',
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 6,
|
||||||
|
backgroundColor: Colors.light.primary,
|
||||||
|
borderRadius: 16,
|
||||||
|
marginBottom: 6,
|
||||||
|
},
|
||||||
|
thumbnailMealTypeText: {
|
||||||
|
color: Colors.light.onPrimary,
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: '700',
|
||||||
|
letterSpacing: 0.2,
|
||||||
|
},
|
||||||
|
thumbnailTitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: Colors.light.text,
|
||||||
|
letterSpacing: 0.1,
|
||||||
|
},
|
||||||
|
|
||||||
|
// 进度卡片
|
||||||
|
progressCard: {
|
||||||
|
backgroundColor: Colors.light.card,
|
||||||
|
borderRadius: 20,
|
||||||
|
padding: 20,
|
||||||
|
marginBottom: 16,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 6 },
|
||||||
|
shadowOpacity: 0.08,
|
||||||
|
shadowRadius: 16,
|
||||||
|
elevation: 6,
|
||||||
|
},
|
||||||
|
progressHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
},
|
||||||
|
statusIconAnimated: {},
|
||||||
|
statusIcon: {
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
borderRadius: 24,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginRight: 16,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 3,
|
||||||
|
},
|
||||||
|
progressInfo: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
statusText: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: Colors.light.text,
|
||||||
|
marginBottom: 4,
|
||||||
|
letterSpacing: 0.2,
|
||||||
|
},
|
||||||
|
statusSubtext: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: Colors.light.textSecondary,
|
||||||
|
lineHeight: 20,
|
||||||
|
letterSpacing: 0.1,
|
||||||
|
},
|
||||||
|
|
||||||
|
// 进度条
|
||||||
|
progressBarContainer: {
|
||||||
|
marginTop: 20,
|
||||||
|
},
|
||||||
|
progressBarBackground: {
|
||||||
|
width: '100%',
|
||||||
|
height: 8,
|
||||||
|
backgroundColor: Colors.light.neutral100,
|
||||||
|
borderRadius: 4,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
progressBarFill: {
|
||||||
|
height: '100%',
|
||||||
|
backgroundColor: Colors.light.primary,
|
||||||
|
borderRadius: 4,
|
||||||
|
},
|
||||||
|
|
||||||
|
// 日志卡片
|
||||||
|
logCard: {
|
||||||
|
backgroundColor: Colors.light.card,
|
||||||
|
borderRadius: 20,
|
||||||
|
padding: 20,
|
||||||
|
marginBottom: 16,
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 200,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowOpacity: 0.06,
|
||||||
|
shadowRadius: 12,
|
||||||
|
elevation: 4,
|
||||||
|
},
|
||||||
|
logHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
logTitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: Colors.light.text,
|
||||||
|
marginLeft: 8,
|
||||||
|
letterSpacing: 0.2,
|
||||||
|
},
|
||||||
|
logScrollView: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: Colors.light.heroSurfaceTint,
|
||||||
|
borderRadius: 16,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 12,
|
||||||
|
},
|
||||||
|
logContent: {
|
||||||
|
flexGrow: 1,
|
||||||
|
},
|
||||||
|
logItem: {
|
||||||
|
paddingVertical: 6,
|
||||||
|
paddingHorizontal: 4,
|
||||||
|
},
|
||||||
|
logText: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: Colors.light.text,
|
||||||
|
lineHeight: 22,
|
||||||
|
letterSpacing: 0.1,
|
||||||
|
},
|
||||||
|
logPlaceholder: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: Colors.light.textMuted,
|
||||||
|
fontStyle: 'italic',
|
||||||
|
textAlign: 'center',
|
||||||
|
marginTop: 40,
|
||||||
|
},
|
||||||
|
|
||||||
|
// 重试按钮
|
||||||
|
retryButton: {
|
||||||
|
backgroundColor: Colors.light.primary,
|
||||||
|
paddingVertical: 18,
|
||||||
|
paddingHorizontal: 32,
|
||||||
|
borderRadius: 28,
|
||||||
|
alignItems: 'center',
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'center',
|
||||||
|
shadowColor: Colors.light.primary,
|
||||||
|
shadowOffset: { width: 0, height: 8 },
|
||||||
|
shadowOpacity: 0.35,
|
||||||
|
shadowRadius: 20,
|
||||||
|
elevation: 8,
|
||||||
|
},
|
||||||
|
retryButtonText: {
|
||||||
|
color: Colors.light.onPrimary,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '700',
|
||||||
|
letterSpacing: 0.4,
|
||||||
|
},
|
||||||
|
|
||||||
|
// 通用样式
|
||||||
|
errorContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
errorText: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: Colors.light.textMuted,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
});
|
||||||
471
app/goals-list.tsx
Normal file
@@ -0,0 +1,471 @@
|
|||||||
|
import { GoalCard } from '@/components/GoalCard';
|
||||||
|
import { CreateGoalModal } from '@/components/model/CreateGoalModal';
|
||||||
|
import { useGlobalDialog } from '@/components/ui/DialogProvider';
|
||||||
|
import { Colors } from '@/constants/Colors';
|
||||||
|
import { TAB_BAR_BOTTOM_OFFSET, TAB_BAR_HEIGHT } from '@/constants/TabBar';
|
||||||
|
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||||
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
|
import { deleteGoal, fetchGoals, loadMoreGoals, updateGoal } from '@/store/goalsSlice';
|
||||||
|
import { CreateGoalRequest, GoalListItem, UpdateGoalRequest } from '@/types/goals';
|
||||||
|
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||||
|
import { useFocusEffect } from '@react-navigation/native';
|
||||||
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Alert, FlatList, RefreshControl, StatusBar, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
|
export default function GoalsListScreen() {
|
||||||
|
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||||
|
const colorTokens = Colors[theme];
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const { showConfirm } = useGlobalDialog();
|
||||||
|
|
||||||
|
// Redux状态
|
||||||
|
const {
|
||||||
|
goals,
|
||||||
|
goalsLoading,
|
||||||
|
goalsError,
|
||||||
|
goalsPagination,
|
||||||
|
updateLoading,
|
||||||
|
updateError,
|
||||||
|
} = useAppSelector((state) => state.goals);
|
||||||
|
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
|
||||||
|
// 编辑目标相关状态
|
||||||
|
const [showEditModal, setShowEditModal] = useState(false);
|
||||||
|
const [editingGoal, setEditingGoal] = useState<GoalListItem | null>(null);
|
||||||
|
|
||||||
|
// 页面聚焦时重新加载数据
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
console.log('useFocusEffect - loading goals');
|
||||||
|
loadGoals();
|
||||||
|
}, [dispatch])
|
||||||
|
);
|
||||||
|
|
||||||
|
// 加载目标列表
|
||||||
|
const loadGoals = async () => {
|
||||||
|
try {
|
||||||
|
await dispatch(fetchGoals({
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
sortBy: 'createdAt',
|
||||||
|
sortOrder: 'desc',
|
||||||
|
})).unwrap();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load goals:', error);
|
||||||
|
// 在开发模式下,如果API调用失败,使用模拟数据
|
||||||
|
if (__DEV__) {
|
||||||
|
console.log('Using mock data for development');
|
||||||
|
// 添加模拟数据用于测试左滑删除功能
|
||||||
|
const mockGoals: GoalListItem[] = [
|
||||||
|
{
|
||||||
|
id: 'mock-1',
|
||||||
|
userId: 'test-user-1',
|
||||||
|
title: '每日运动30分钟',
|
||||||
|
repeatType: 'daily',
|
||||||
|
frequency: 1,
|
||||||
|
status: 'active',
|
||||||
|
completedCount: 5,
|
||||||
|
targetCount: 30,
|
||||||
|
hasReminder: true,
|
||||||
|
reminderTime: '09:00',
|
||||||
|
category: '运动',
|
||||||
|
priority: 5,
|
||||||
|
startDate: '2024-01-01',
|
||||||
|
startTime: 900,
|
||||||
|
endTime: 1800,
|
||||||
|
progressPercentage: 17,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mock-2',
|
||||||
|
userId: 'test-user-1',
|
||||||
|
title: '每天喝8杯水',
|
||||||
|
repeatType: 'daily',
|
||||||
|
frequency: 8,
|
||||||
|
status: 'active',
|
||||||
|
completedCount: 6,
|
||||||
|
targetCount: 8,
|
||||||
|
hasReminder: true,
|
||||||
|
reminderTime: '10:00',
|
||||||
|
category: '健康',
|
||||||
|
priority: 8,
|
||||||
|
startDate: '2024-01-01',
|
||||||
|
startTime: 600,
|
||||||
|
endTime: 2200,
|
||||||
|
progressPercentage: 75,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mock-3',
|
||||||
|
userId: 'test-user-1',
|
||||||
|
title: '每周读书2小时',
|
||||||
|
repeatType: 'weekly',
|
||||||
|
frequency: 2,
|
||||||
|
status: 'paused',
|
||||||
|
completedCount: 1,
|
||||||
|
targetCount: 2,
|
||||||
|
hasReminder: false,
|
||||||
|
category: '学习',
|
||||||
|
priority: 3,
|
||||||
|
startDate: '2024-01-01',
|
||||||
|
startTime: 800,
|
||||||
|
endTime: 2000,
|
||||||
|
progressPercentage: 50,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// 直接更新 Redux 状态(仅用于开发测试)
|
||||||
|
dispatch({
|
||||||
|
type: 'goals/fetchGoals/fulfilled',
|
||||||
|
payload: {
|
||||||
|
query: { page: 1, pageSize: 20, sortBy: 'createdAt', sortOrder: 'desc' },
|
||||||
|
response: {
|
||||||
|
list: mockGoals,
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
total: mockGoals.length,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 下拉刷新
|
||||||
|
const onRefresh = async () => {
|
||||||
|
setRefreshing(true);
|
||||||
|
try {
|
||||||
|
await loadGoals();
|
||||||
|
} finally {
|
||||||
|
setRefreshing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 加载更多目标
|
||||||
|
const handleLoadMoreGoals = async () => {
|
||||||
|
if (goalsPagination.hasMore && !goalsLoading) {
|
||||||
|
try {
|
||||||
|
await dispatch(loadMoreGoals()).unwrap();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load more goals:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理删除目标
|
||||||
|
const handleDeleteGoal = async (goalId: string) => {
|
||||||
|
try {
|
||||||
|
await dispatch(deleteGoal(goalId)).unwrap();
|
||||||
|
// 删除成功,Redux 会自动更新状态
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete goal:', error);
|
||||||
|
Alert.alert('错误', '删除目标失败,请重试');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理错误提示
|
||||||
|
useEffect(() => {
|
||||||
|
if (goalsError) {
|
||||||
|
Alert.alert('错误', goalsError);
|
||||||
|
}
|
||||||
|
if (updateError) {
|
||||||
|
Alert.alert('更新失败', updateError);
|
||||||
|
}
|
||||||
|
}, [goalsError, updateError]);
|
||||||
|
|
||||||
|
|
||||||
|
// 根据筛选条件过滤目标
|
||||||
|
const filteredGoals = useMemo(() => {
|
||||||
|
return goals;
|
||||||
|
}, [goals]);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// 处理目标点击
|
||||||
|
const handleGoalPress = (goal: GoalListItem) => {
|
||||||
|
setEditingGoal(goal);
|
||||||
|
setShowEditModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 将 GoalListItem 转换为 CreateGoalRequest 格式
|
||||||
|
const convertGoalToModalData = (goal: GoalListItem): Partial<CreateGoalRequest> => {
|
||||||
|
return {
|
||||||
|
title: goal.title,
|
||||||
|
description: goal.description,
|
||||||
|
repeatType: goal.repeatType,
|
||||||
|
frequency: goal.frequency,
|
||||||
|
category: goal.category,
|
||||||
|
priority: goal.priority,
|
||||||
|
hasReminder: goal.hasReminder,
|
||||||
|
reminderTime: goal.reminderTime,
|
||||||
|
customRepeatRule: goal.customRepeatRule,
|
||||||
|
endDate: goal.endDate,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理更新目标
|
||||||
|
const handleUpdateGoal = async (goalId: string, goalData: UpdateGoalRequest) => {
|
||||||
|
try {
|
||||||
|
await dispatch(updateGoal({ goalId, goalData })).unwrap();
|
||||||
|
setShowEditModal(false);
|
||||||
|
setEditingGoal(null);
|
||||||
|
|
||||||
|
// 使用全局弹窗显示成功消息
|
||||||
|
showConfirm(
|
||||||
|
{
|
||||||
|
title: '目标更新成功',
|
||||||
|
message: '恭喜!您的目标已成功更新。',
|
||||||
|
confirmText: '确定',
|
||||||
|
cancelText: '',
|
||||||
|
icon: 'checkmark-circle',
|
||||||
|
iconColor: '#10B981',
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
console.log('用户确认了目标更新成功');
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update goal:', error);
|
||||||
|
Alert.alert('错误', '更新目标失败,请重试');
|
||||||
|
// 更新失败时不关闭弹窗,保持编辑状态
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理编辑弹窗关闭
|
||||||
|
const handleCloseEditModal = () => {
|
||||||
|
setShowEditModal(false);
|
||||||
|
setEditingGoal(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 渲染目标项
|
||||||
|
const renderGoalItem = ({ item }: { item: GoalListItem }) => (
|
||||||
|
<GoalCard
|
||||||
|
goal={item}
|
||||||
|
onPress={handleGoalPress}
|
||||||
|
onDelete={handleDeleteGoal}
|
||||||
|
showStatus={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// 渲染空状态
|
||||||
|
const renderEmptyState = () => {
|
||||||
|
let title = '暂无目标';
|
||||||
|
let subtitle = '创建您的第一个目标,开始您的健康之旅';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.emptyState}>
|
||||||
|
<MaterialIcons name="flag" size={64} color="#D1D5DB" />
|
||||||
|
<Text style={[styles.emptyStateTitle, { color: colorTokens.text }]}>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.emptyStateSubtitle, { color: colorTokens.textSecondary }]}>
|
||||||
|
{subtitle}
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.createButton, { backgroundColor: colorTokens.primary }]}
|
||||||
|
onPress={() => router.push('/(tabs)/goals')}
|
||||||
|
>
|
||||||
|
<Text style={styles.createButtonText}>创建目标</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// 渲染加载更多
|
||||||
|
const renderLoadMore = () => {
|
||||||
|
if (!goalsPagination.hasMore) return null;
|
||||||
|
return (
|
||||||
|
<View style={styles.loadMoreContainer}>
|
||||||
|
<Text style={[styles.loadMoreText, { color: colorTokens.textSecondary }]}>
|
||||||
|
{goalsLoading ? '加载中...' : '上拉加载更多'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.container}>
|
||||||
|
<StatusBar
|
||||||
|
backgroundColor="transparent"
|
||||||
|
translucent
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 背景渐变 */}
|
||||||
|
<LinearGradient
|
||||||
|
colors={['#F8FAFC', '#F1F5F9']}
|
||||||
|
style={styles.gradientBackground}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 1 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View style={styles.content}>
|
||||||
|
{/* 标题区域 */}
|
||||||
|
<View style={styles.header}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.backButton}
|
||||||
|
onPress={() => router.back()}
|
||||||
|
>
|
||||||
|
<MaterialIcons name="arrow-back" size={24} color={colorTokens.text} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text style={[styles.pageTitle, { color: colorTokens.text }]}>
|
||||||
|
目标列表
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.addButton}
|
||||||
|
onPress={() => router.push('/(tabs)/goals')}
|
||||||
|
>
|
||||||
|
<MaterialIcons name="add" size={24} color="#FFFFFF" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
|
||||||
|
{/* 目标列表 */}
|
||||||
|
<View style={styles.goalsListContainer}>
|
||||||
|
<FlatList
|
||||||
|
data={filteredGoals}
|
||||||
|
renderItem={renderGoalItem}
|
||||||
|
keyExtractor={(item) => item.id}
|
||||||
|
contentContainerStyle={styles.goalsList}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={refreshing}
|
||||||
|
onRefresh={onRefresh}
|
||||||
|
colors={['#7A5AF8']}
|
||||||
|
tintColor="#7A5AF8"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
onEndReached={handleLoadMoreGoals}
|
||||||
|
onEndReachedThreshold={0.1}
|
||||||
|
ListEmptyComponent={renderEmptyState}
|
||||||
|
ListFooterComponent={renderLoadMore}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 编辑目标弹窗 */}
|
||||||
|
{editingGoal && (
|
||||||
|
<CreateGoalModal
|
||||||
|
visible={showEditModal}
|
||||||
|
onClose={handleCloseEditModal}
|
||||||
|
onSubmit={() => {}} // 编辑模式下不使用这个回调
|
||||||
|
onUpdate={handleUpdateGoal}
|
||||||
|
loading={updateLoading}
|
||||||
|
initialData={convertGoalToModalData(editingGoal)}
|
||||||
|
editGoalId={editingGoal.id}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
gradientBackground: {
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingTop: 20,
|
||||||
|
paddingBottom: 16,
|
||||||
|
},
|
||||||
|
backButton: {
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 20,
|
||||||
|
backgroundColor: 'rgba(255, 255, 255, 0.8)',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 4,
|
||||||
|
elevation: 3,
|
||||||
|
},
|
||||||
|
pageTitle: {
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: '700',
|
||||||
|
flex: 1,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
addButton: {
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 20,
|
||||||
|
backgroundColor: '#7A5AF8',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 4,
|
||||||
|
elevation: 3,
|
||||||
|
},
|
||||||
|
goalsListContainer: {
|
||||||
|
flex: 1,
|
||||||
|
borderTopLeftRadius: 24,
|
||||||
|
borderTopRightRadius: 24,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
goalsList: {
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingTop: 20,
|
||||||
|
paddingBottom: TAB_BAR_HEIGHT + TAB_BAR_BOTTOM_OFFSET + 20,
|
||||||
|
},
|
||||||
|
emptyState: {
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingVertical: 80,
|
||||||
|
},
|
||||||
|
emptyStateTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '600',
|
||||||
|
marginTop: 16,
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
emptyStateSubtitle: {
|
||||||
|
fontSize: 14,
|
||||||
|
textAlign: 'center',
|
||||||
|
lineHeight: 20,
|
||||||
|
marginBottom: 24,
|
||||||
|
paddingHorizontal: 40,
|
||||||
|
},
|
||||||
|
createButton: {
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
paddingVertical: 12,
|
||||||
|
borderRadius: 20,
|
||||||
|
},
|
||||||
|
createButtonText: {
|
||||||
|
color: '#FFFFFF',
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
loadMoreContainer: {
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: 20,
|
||||||
|
},
|
||||||
|
loadMoreText: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,481 +0,0 @@
|
|||||||
import { ThemedText } from '@/components/ThemedText';
|
|
||||||
import { ThemedView } from '@/components/ThemedView';
|
|
||||||
import { useThemeColor } from '@/hooks/useThemeColor';
|
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
|
||||||
import { useRouter } from 'expo-router';
|
|
||||||
import React, { useEffect, useState } from 'react';
|
|
||||||
import {
|
|
||||||
Dimensions,
|
|
||||||
Pressable,
|
|
||||||
SafeAreaView,
|
|
||||||
ScrollView,
|
|
||||||
StyleSheet,
|
|
||||||
Text,
|
|
||||||
View
|
|
||||||
} from 'react-native';
|
|
||||||
|
|
||||||
const { width: screenWidth } = Dimensions.get('window');
|
|
||||||
|
|
||||||
// 健康数据项类型
|
|
||||||
interface HealthItem {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
subtitle: string;
|
|
||||||
status: 'warning' | 'good' | 'info';
|
|
||||||
icon: string;
|
|
||||||
recommendation: string;
|
|
||||||
value?: string;
|
|
||||||
color: string;
|
|
||||||
bgColor: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 健康数据
|
|
||||||
const healthData: HealthItem[] = [
|
|
||||||
{
|
|
||||||
id: '1',
|
|
||||||
title: '运动状态',
|
|
||||||
subtitle: '本周运动不足',
|
|
||||||
status: 'warning',
|
|
||||||
icon: '🏃♀️',
|
|
||||||
recommendation: '建议每天进行30分钟普拉提训练',
|
|
||||||
value: '2天/周',
|
|
||||||
color: '#FF6B6B',
|
|
||||||
bgColor: '#FFE5E5',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '2',
|
|
||||||
title: '体态评估',
|
|
||||||
subtitle: '需要进行评估',
|
|
||||||
status: 'info',
|
|
||||||
icon: '🧘♀️',
|
|
||||||
recommendation: '进行AI体态评估,了解身体状况',
|
|
||||||
color: '#4ECDC4',
|
|
||||||
bgColor: '#E5F9F7',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '3',
|
|
||||||
title: '核心力量',
|
|
||||||
subtitle: '待加强',
|
|
||||||
status: 'warning',
|
|
||||||
icon: '💪',
|
|
||||||
recommendation: '推荐核心训练课程',
|
|
||||||
value: '初级',
|
|
||||||
color: '#FFB84D',
|
|
||||||
bgColor: '#FFF4E5',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '4',
|
|
||||||
title: '柔韧性',
|
|
||||||
subtitle: '良好',
|
|
||||||
status: 'good',
|
|
||||||
icon: '🤸♀️',
|
|
||||||
recommendation: '保持每日拉伸习惯',
|
|
||||||
value: '良好',
|
|
||||||
color: '#95E1D3',
|
|
||||||
bgColor: '#E5F9F5',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '5',
|
|
||||||
title: '平衡能力',
|
|
||||||
subtitle: '需要提升',
|
|
||||||
status: 'info',
|
|
||||||
icon: '⚖️',
|
|
||||||
recommendation: '尝试单腿站立训练',
|
|
||||||
color: '#A8E6CF',
|
|
||||||
bgColor: '#E8F8F0',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '6',
|
|
||||||
title: '呼吸质量',
|
|
||||||
subtitle: '待改善',
|
|
||||||
status: 'warning',
|
|
||||||
icon: '🌬️',
|
|
||||||
recommendation: '学习普拉提呼吸法',
|
|
||||||
color: '#C7CEEA',
|
|
||||||
bgColor: '#F0F1F8',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function HealthConsultationScreen() {
|
|
||||||
const router = useRouter();
|
|
||||||
const primaryColor = useThemeColor({}, 'primary');
|
|
||||||
const backgroundColor = useThemeColor({}, 'background');
|
|
||||||
const textColor = useThemeColor({}, 'text');
|
|
||||||
const [greeting, setGreeting] = useState('');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const hour = new Date().getHours();
|
|
||||||
if (hour < 12) {
|
|
||||||
setGreeting('早上好');
|
|
||||||
} else if (hour < 18) {
|
|
||||||
setGreeting('下午好');
|
|
||||||
} else {
|
|
||||||
setGreeting('晚上好');
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleHealthItemPress = (item: HealthItem) => {
|
|
||||||
// 根据不同的健康项导航到相应页面
|
|
||||||
if (item.title === '体态评估') {
|
|
||||||
router.push('/ai-posture-assessment');
|
|
||||||
} else {
|
|
||||||
console.log(`点击了 ${item.title}`);
|
|
||||||
// 可以添加更多导航逻辑
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SafeAreaView style={[styles.safeArea, { backgroundColor }]}>
|
|
||||||
<ThemedView style={styles.container}>
|
|
||||||
<ScrollView showsVerticalScrollIndicator={false}>
|
|
||||||
{/* 顶部导航栏 */}
|
|
||||||
<View style={styles.header}>
|
|
||||||
<Pressable onPress={() => router.back()} style={styles.backButton}>
|
|
||||||
<Ionicons name="chevron-back" size={24} color={textColor} />
|
|
||||||
</Pressable>
|
|
||||||
<ThemedText style={styles.headerTitle}>健康咨询</ThemedText>
|
|
||||||
<Pressable style={styles.notificationButton}>
|
|
||||||
<Ionicons name="notifications-outline" size={24} color={textColor} />
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 教练问候卡片 */}
|
|
||||||
<LinearGradient
|
|
||||||
colors={[primaryColor, '#A8E063']}
|
|
||||||
start={{ x: 0, y: 0 }}
|
|
||||||
end={{ x: 1, y: 1 }}
|
|
||||||
style={styles.coachCard}
|
|
||||||
>
|
|
||||||
<View style={styles.coachContent}>
|
|
||||||
<View style={styles.coachInfo}>
|
|
||||||
<View style={styles.coachAvatar}>
|
|
||||||
<Text style={styles.coachAvatarEmoji}>👩⚕️</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.coachTextContainer}>
|
|
||||||
<Text style={styles.coachGreeting}>{greeting},</Text>
|
|
||||||
<Text style={styles.coachName}>我是您的普拉提教练 Sarah</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<Text style={styles.coachQuestion}>今天感觉怎么样?</Text>
|
|
||||||
<Text style={styles.coachSubtext}>让我们一起了解您的身体状况</Text>
|
|
||||||
</View>
|
|
||||||
</LinearGradient>
|
|
||||||
|
|
||||||
{/* 快速操作按钮 */}
|
|
||||||
<View style={styles.quickActions}>
|
|
||||||
<Pressable style={[styles.actionButton, { backgroundColor: primaryColor }]}>
|
|
||||||
<Ionicons name="body-outline" size={20} color="#192126" />
|
|
||||||
<Text style={styles.actionButtonText}>体态检测</Text>
|
|
||||||
</Pressable>
|
|
||||||
<Pressable style={[styles.actionButton, styles.actionButtonOutline]}>
|
|
||||||
<Ionicons name="chatbubble-outline" size={20} color={textColor} />
|
|
||||||
<Text style={[styles.actionButtonText, { color: textColor }]}>咨询教练</Text>
|
|
||||||
</Pressable>
|
|
||||||
<Pressable style={[styles.actionButton, styles.actionButtonOutline]}>
|
|
||||||
<Ionicons name="calendar-outline" size={20} color={textColor} />
|
|
||||||
<Text style={[styles.actionButtonText, { color: textColor }]}>预约课程</Text>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 健康状况标题 */}
|
|
||||||
<View style={styles.sectionHeader}>
|
|
||||||
<ThemedText style={styles.sectionTitle}>您的健康状况</ThemedText>
|
|
||||||
<View style={[styles.healthBadge, { backgroundColor: primaryColor }]}>
|
|
||||||
<Text style={styles.healthBadgeText}>需要关注</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 健康数据网格 */}
|
|
||||||
<View style={styles.healthGrid}>
|
|
||||||
{healthData.map((item) => (
|
|
||||||
<Pressable
|
|
||||||
key={item.id}
|
|
||||||
style={[styles.healthCard, { backgroundColor: item.bgColor }]}
|
|
||||||
onPress={() => handleHealthItemPress(item)}
|
|
||||||
>
|
|
||||||
<View style={styles.healthCardHeader}>
|
|
||||||
<Text style={styles.healthIcon}>{item.icon}</Text>
|
|
||||||
{item.value && (
|
|
||||||
<Text style={[styles.healthValue, { color: item.color }]}>
|
|
||||||
{item.value}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
<Text style={styles.healthTitle}>{item.title}</Text>
|
|
||||||
<Text style={styles.healthSubtitle}>{item.subtitle}</Text>
|
|
||||||
<View style={styles.healthRecommendation}>
|
|
||||||
<Ionicons name="bulb-outline" size={12} color={item.color} />
|
|
||||||
<Text style={[styles.recommendationText, { color: item.color }]} numberOfLines={2}>
|
|
||||||
{item.recommendation}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<Ionicons
|
|
||||||
name="arrow-forward-circle"
|
|
||||||
size={20}
|
|
||||||
color={item.color}
|
|
||||||
style={styles.cardArrow}
|
|
||||||
/>
|
|
||||||
</Pressable>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 今日建议 */}
|
|
||||||
<View style={styles.suggestionSection}>
|
|
||||||
<ThemedText style={styles.suggestionTitle}>今日建议</ThemedText>
|
|
||||||
<View style={[styles.suggestionCard, { backgroundColor: '#F0F8FF' }]}>
|
|
||||||
<View style={styles.suggestionIcon}>
|
|
||||||
<Text style={{ fontSize: 24 }}>💡</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.suggestionContent}>
|
|
||||||
<Text style={styles.suggestionMainText}>
|
|
||||||
根据您的身体状况,建议今天进行轻度核心训练
|
|
||||||
</Text>
|
|
||||||
<Text style={styles.suggestionSubText}>
|
|
||||||
配合呼吸练习,效果更佳
|
|
||||||
</Text>
|
|
||||||
<Pressable style={[styles.startButton, { backgroundColor: primaryColor }]}>
|
|
||||||
<Text style={styles.startButtonText}>开始训练</Text>
|
|
||||||
<Ionicons name="arrow-forward" size={16} color="#192126" />
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 底部间距 */}
|
|
||||||
<View style={styles.bottomSpacing} />
|
|
||||||
</ScrollView>
|
|
||||||
</ThemedView>
|
|
||||||
</SafeAreaView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
safeArea: {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
container: {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
header: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
paddingHorizontal: 20,
|
|
||||||
paddingVertical: 12,
|
|
||||||
},
|
|
||||||
backButton: {
|
|
||||||
padding: 8,
|
|
||||||
},
|
|
||||||
headerTitle: {
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: '600',
|
|
||||||
},
|
|
||||||
notificationButton: {
|
|
||||||
padding: 8,
|
|
||||||
},
|
|
||||||
coachCard: {
|
|
||||||
marginHorizontal: 20,
|
|
||||||
marginTop: 12,
|
|
||||||
borderRadius: 20,
|
|
||||||
padding: 24,
|
|
||||||
elevation: 5,
|
|
||||||
shadowColor: '#000',
|
|
||||||
shadowOffset: { width: 0, height: 2 },
|
|
||||||
shadowOpacity: 0.1,
|
|
||||||
shadowRadius: 8,
|
|
||||||
},
|
|
||||||
coachContent: {
|
|
||||||
gap: 16,
|
|
||||||
},
|
|
||||||
coachInfo: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 12,
|
|
||||||
},
|
|
||||||
coachAvatar: {
|
|
||||||
width: 50,
|
|
||||||
height: 50,
|
|
||||||
borderRadius: 25,
|
|
||||||
backgroundColor: '#fff',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
},
|
|
||||||
coachAvatarEmoji: {
|
|
||||||
fontSize: 30,
|
|
||||||
},
|
|
||||||
coachTextContainer: {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
coachGreeting: {
|
|
||||||
fontSize: 14,
|
|
||||||
color: '#192126',
|
|
||||||
opacity: 0.8,
|
|
||||||
},
|
|
||||||
coachName: {
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: '600',
|
|
||||||
color: '#192126',
|
|
||||||
},
|
|
||||||
coachQuestion: {
|
|
||||||
fontSize: 28,
|
|
||||||
fontWeight: 'bold',
|
|
||||||
color: '#192126',
|
|
||||||
marginTop: 8,
|
|
||||||
},
|
|
||||||
coachSubtext: {
|
|
||||||
fontSize: 14,
|
|
||||||
color: '#192126',
|
|
||||||
opacity: 0.7,
|
|
||||||
},
|
|
||||||
quickActions: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
paddingHorizontal: 20,
|
|
||||||
marginTop: 20,
|
|
||||||
gap: 12,
|
|
||||||
},
|
|
||||||
actionButton: {
|
|
||||||
flex: 1,
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
paddingVertical: 12,
|
|
||||||
borderRadius: 12,
|
|
||||||
gap: 6,
|
|
||||||
},
|
|
||||||
actionButtonOutline: {
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: '#E0E0E0',
|
|
||||||
},
|
|
||||||
actionButtonText: {
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: '500',
|
|
||||||
},
|
|
||||||
sectionHeader: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
paddingHorizontal: 20,
|
|
||||||
marginTop: 32,
|
|
||||||
marginBottom: 16,
|
|
||||||
},
|
|
||||||
sectionTitle: {
|
|
||||||
fontSize: 22,
|
|
||||||
fontWeight: 'bold',
|
|
||||||
},
|
|
||||||
healthBadge: {
|
|
||||||
paddingHorizontal: 12,
|
|
||||||
paddingVertical: 6,
|
|
||||||
borderRadius: 12,
|
|
||||||
},
|
|
||||||
healthBadgeText: {
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: '600',
|
|
||||||
color: '#192126',
|
|
||||||
},
|
|
||||||
healthGrid: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
flexWrap: 'wrap',
|
|
||||||
paddingHorizontal: 16,
|
|
||||||
gap: 12,
|
|
||||||
},
|
|
||||||
healthCard: {
|
|
||||||
width: (screenWidth - 44) / 2,
|
|
||||||
padding: 16,
|
|
||||||
borderRadius: 16,
|
|
||||||
position: 'relative',
|
|
||||||
},
|
|
||||||
healthCardHeader: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
marginBottom: 8,
|
|
||||||
},
|
|
||||||
healthIcon: {
|
|
||||||
fontSize: 28,
|
|
||||||
},
|
|
||||||
healthValue: {
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: '600',
|
|
||||||
},
|
|
||||||
healthTitle: {
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: '600',
|
|
||||||
color: '#192126',
|
|
||||||
marginBottom: 4,
|
|
||||||
},
|
|
||||||
healthSubtitle: {
|
|
||||||
fontSize: 13,
|
|
||||||
color: '#666',
|
|
||||||
marginBottom: 12,
|
|
||||||
},
|
|
||||||
healthRecommendation: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'flex-start',
|
|
||||||
gap: 6,
|
|
||||||
paddingRight: 20,
|
|
||||||
},
|
|
||||||
recommendationText: {
|
|
||||||
fontSize: 11,
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
cardArrow: {
|
|
||||||
position: 'absolute',
|
|
||||||
bottom: 12,
|
|
||||||
right: 12,
|
|
||||||
},
|
|
||||||
suggestionSection: {
|
|
||||||
paddingHorizontal: 20,
|
|
||||||
marginTop: 32,
|
|
||||||
},
|
|
||||||
suggestionTitle: {
|
|
||||||
fontSize: 22,
|
|
||||||
fontWeight: 'bold',
|
|
||||||
marginBottom: 16,
|
|
||||||
},
|
|
||||||
suggestionCard: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
padding: 20,
|
|
||||||
borderRadius: 16,
|
|
||||||
gap: 16,
|
|
||||||
},
|
|
||||||
suggestionIcon: {
|
|
||||||
width: 40,
|
|
||||||
height: 40,
|
|
||||||
borderRadius: 20,
|
|
||||||
backgroundColor: '#fff',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
},
|
|
||||||
suggestionContent: {
|
|
||||||
flex: 1,
|
|
||||||
gap: 8,
|
|
||||||
},
|
|
||||||
suggestionMainText: {
|
|
||||||
fontSize: 15,
|
|
||||||
fontWeight: '500',
|
|
||||||
color: '#192126',
|
|
||||||
},
|
|
||||||
suggestionSubText: {
|
|
||||||
fontSize: 13,
|
|
||||||
color: '#666',
|
|
||||||
},
|
|
||||||
startButton: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
alignSelf: 'flex-start',
|
|
||||||
paddingHorizontal: 16,
|
|
||||||
paddingVertical: 8,
|
|
||||||
borderRadius: 20,
|
|
||||||
marginTop: 8,
|
|
||||||
gap: 6,
|
|
||||||
},
|
|
||||||
startButtonText: {
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: '600',
|
|
||||||
color: '#192126',
|
|
||||||
},
|
|
||||||
bottomSpacing: {
|
|
||||||
height: 100,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { ThemedView } from '@/components/ThemedView';
|
import { ThemedView } from '@/components/ThemedView';
|
||||||
|
import { ROUTES } from '@/constants/Routes';
|
||||||
import { useThemeColor } from '@/hooks/useThemeColor';
|
import { useThemeColor } from '@/hooks/useThemeColor';
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
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, Text, View } from 'react-native';
|
import { ActivityIndicator, View } from 'react-native';
|
||||||
|
|
||||||
const ONBOARDING_COMPLETED_KEY = '@onboarding_completed';
|
const ONBOARDING_COMPLETED_KEY = '@onboarding_completed';
|
||||||
|
|
||||||
@@ -18,25 +19,26 @@ export default function SplashScreen() {
|
|||||||
|
|
||||||
const checkOnboardingStatus = async () => {
|
const checkOnboardingStatus = async () => {
|
||||||
try {
|
try {
|
||||||
const onboardingCompleted = await AsyncStorage.getItem(ONBOARDING_COMPLETED_KEY);
|
// 先预加载用户数据,这样进入应用时就有正确的 token 状态
|
||||||
|
console.log('开始预加载用户数据...');
|
||||||
|
await preloadUserData();
|
||||||
|
console.log('用户数据预加载完成');
|
||||||
|
|
||||||
// 添加一个短暂的延迟以显示启动画面
|
// const onboardingCompleted = await AsyncStorage.getItem(ONBOARDING_COMPLETED_KEY);
|
||||||
setTimeout(() => {
|
|
||||||
if (onboardingCompleted === 'true') {
|
// if (onboardingCompleted === 'true') {
|
||||||
router.replace('/(tabs)');
|
// router.replace('/(tabs)');
|
||||||
} else {
|
// } else {
|
||||||
router.replace('/onboarding');
|
// router.replace('/onboarding');
|
||||||
}
|
// }
|
||||||
setIsLoading(false);
|
// setIsLoading(false);
|
||||||
}, 1000);
|
router.replace(ROUTES.TAB_STATISTICS);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('检查引导状态失败:', error);
|
console.error('检查引导状态或预加载用户数据失败:', error);
|
||||||
// 如果出现错误,默认显示引导页面
|
// 如果出现错误,仍然进入应用,但可能会有状态更新
|
||||||
setTimeout(() => {
|
router.replace(ROUTES.TAB_STATISTICS);
|
||||||
router.replace('/onboarding');
|
|
||||||
setIsLoading(false);
|
|
||||||
}, 1000);
|
|
||||||
}
|
}
|
||||||
|
setIsLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!isLoading) {
|
if (!isLoading) {
|
||||||
@@ -54,16 +56,12 @@ export default function SplashScreen() {
|
|||||||
width: 80,
|
width: 80,
|
||||||
height: 80,
|
height: 80,
|
||||||
borderRadius: 40,
|
borderRadius: 40,
|
||||||
backgroundColor: primaryColor,
|
// backgroundColor: primaryColor,
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
marginBottom: 20,
|
marginBottom: 20,
|
||||||
}}>
|
}}>
|
||||||
<Text style={{
|
|
||||||
fontSize: 32,
|
|
||||||
}}>
|
|
||||||
🧘♀️
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
<ActivityIndicator size="large" color={primaryColor} />
|
<ActivityIndicator size="large" color={primaryColor} />
|
||||||
</ThemedView>
|
</ThemedView>
|
||||||
|
|||||||
15
app/legal/privacy-policy.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { ScrollView, Text } from 'react-native';
|
||||||
|
|
||||||
|
export default function PrivacyPolicy() {
|
||||||
|
return (
|
||||||
|
<ScrollView style={{ flex: 1, padding: 16 }}>
|
||||||
|
<Text style={{ fontSize: 20, fontWeight: '700', marginBottom: 12 }}>隐私政策(示例)</Text>
|
||||||
|
<Text style={{ lineHeight: 22, color: '#4A4A4A' }}>
|
||||||
|
这是占位文案,用于展示隐私政策内容。请替换为正式的隐私政策文本。
|
||||||
|
</Text>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
15
app/legal/user-agreement.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { ScrollView, Text } from 'react-native';
|
||||||
|
|
||||||
|
export default function UserAgreement() {
|
||||||
|
return (
|
||||||
|
<ScrollView style={{ flex: 1, padding: 16 }}>
|
||||||
|
<Text style={{ fontSize: 20, fontWeight: '700', marginBottom: 12 }}>用户协议(示例)</Text>
|
||||||
|
<Text style={{ lineHeight: 22, color: '#4A4A4A' }}>
|
||||||
|
这是占位文案,用于说明用户协议。请在此替换为你们的正式协议内容。
|
||||||
|
</Text>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
289
app/mood-statistics.tsx
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
import { MoodHistoryCard } from '@/components/MoodHistoryCard';
|
||||||
|
import { Colors } from '@/constants/Colors';
|
||||||
|
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||||
|
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||||
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
|
import {
|
||||||
|
fetchMoodHistory,
|
||||||
|
fetchMoodStatistics,
|
||||||
|
selectMoodLoading,
|
||||||
|
selectMoodRecords,
|
||||||
|
selectMoodStatistics
|
||||||
|
} from '@/store/moodSlice';
|
||||||
|
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
|
import { router } from 'expo-router';
|
||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
SafeAreaView,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
|
||||||
|
export default function MoodStatisticsScreen() {
|
||||||
|
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||||
|
const colorTokens = Colors[theme];
|
||||||
|
const { isLoggedIn } = useAuthGuard();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
// 从 Redux 获取数据
|
||||||
|
const moodRecords = useAppSelector(selectMoodRecords);
|
||||||
|
const statistics = useAppSelector(selectMoodStatistics);
|
||||||
|
const loading = useAppSelector(selectMoodLoading);
|
||||||
|
|
||||||
|
// 获取最近30天的心情数据
|
||||||
|
const loadMoodData = async () => {
|
||||||
|
if (!isLoggedIn) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const endDate = dayjs().format('YYYY-MM-DD');
|
||||||
|
const startDate = dayjs().subtract(30, 'days').format('YYYY-MM-DD');
|
||||||
|
|
||||||
|
// 并行加载历史记录和统计数据
|
||||||
|
await Promise.all([
|
||||||
|
dispatch(fetchMoodHistory({ startDate, endDate })),
|
||||||
|
dispatch(fetchMoodStatistics({ startDate, endDate }))
|
||||||
|
]);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载心情数据失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadMoodData();
|
||||||
|
}, [isLoggedIn, dispatch]);
|
||||||
|
|
||||||
|
// 将 moodRecords 转换为数组格式
|
||||||
|
const moodCheckins = Object.values(moodRecords).flat();
|
||||||
|
|
||||||
|
// 使用统一的渐变背景色
|
||||||
|
const backgroundGradientColors = [colorTokens.backgroundGradientStart, colorTokens.backgroundGradientEnd] as const;
|
||||||
|
|
||||||
|
if (!isLoggedIn) {
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<LinearGradient
|
||||||
|
colors={backgroundGradientColors}
|
||||||
|
style={styles.gradientBackground}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 0, y: 1 }}
|
||||||
|
/>
|
||||||
|
<SafeAreaView style={styles.safeArea}>
|
||||||
|
<View style={styles.centerContainer}>
|
||||||
|
<Text style={styles.loginPrompt}>请先登录查看心情统计</Text>
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<LinearGradient
|
||||||
|
colors={backgroundGradientColors}
|
||||||
|
style={styles.gradientBackground}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 0, y: 1 }}
|
||||||
|
/>
|
||||||
|
<SafeAreaView style={styles.safeArea}>
|
||||||
|
<HeaderBar
|
||||||
|
title="心情统计"
|
||||||
|
onBack={() => router.back()}
|
||||||
|
withSafeTop={false}
|
||||||
|
transparent={true}
|
||||||
|
tone="light"
|
||||||
|
/>
|
||||||
|
<ScrollView style={styles.scrollView} showsVerticalScrollIndicator={false}>
|
||||||
|
|
||||||
|
{loading.history || loading.statistics ? (
|
||||||
|
<View style={styles.loadingContainer}>
|
||||||
|
<ActivityIndicator size="large" color={colorTokens.primary} />
|
||||||
|
<Text style={styles.loadingText}>加载中...</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* 统计概览 */}
|
||||||
|
{statistics && (
|
||||||
|
<View style={styles.statsOverview}>
|
||||||
|
<Text style={styles.sectionTitle}>统计概览</Text>
|
||||||
|
<View style={styles.statsGrid}>
|
||||||
|
<View style={styles.statCard}>
|
||||||
|
<Text style={styles.statNumber}>{statistics.totalCheckins}</Text>
|
||||||
|
<Text style={styles.statLabel}>总打卡次数</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.statCard}>
|
||||||
|
<Text style={styles.statNumber}>{statistics.averageIntensity.toFixed(1)}</Text>
|
||||||
|
<Text style={styles.statLabel}>平均强度</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.statCard}>
|
||||||
|
<Text style={styles.statNumber}>
|
||||||
|
{statistics.mostFrequentMood ? statistics.moodDistribution[statistics.mostFrequentMood] || 0 : 0}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.statLabel}>最常见心情</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 心情历史记录 */}
|
||||||
|
<MoodHistoryCard
|
||||||
|
moodCheckins={moodCheckins}
|
||||||
|
title="最近30天心情记录"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 心情分布 */}
|
||||||
|
{statistics && (
|
||||||
|
<View style={styles.distributionContainer}>
|
||||||
|
<Text style={styles.sectionTitle}>心情分布</Text>
|
||||||
|
<View style={styles.distributionList}>
|
||||||
|
{Object.entries(statistics.moodDistribution)
|
||||||
|
.sort(([, a], [, b]) => b - a)
|
||||||
|
.map(([moodType, count]) => (
|
||||||
|
<View key={moodType} style={styles.distributionItem}>
|
||||||
|
<Text style={styles.moodType}>{moodType}</Text>
|
||||||
|
<View style={styles.countContainer}>
|
||||||
|
<Text style={styles.count}>{count}</Text>
|
||||||
|
<Text style={styles.percentage}>
|
||||||
|
({((count / statistics.totalCheckins) * 100).toFixed(1)}%)
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
</SafeAreaView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
gradientBackground: {
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
},
|
||||||
|
safeArea: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
scrollView: {
|
||||||
|
flex: 1,
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
},
|
||||||
|
centerContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
loginPrompt: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: '#666',
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
|
||||||
|
loadingContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: 60,
|
||||||
|
},
|
||||||
|
loadingText: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: '#666',
|
||||||
|
marginTop: 16,
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#192126',
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
statsOverview: {
|
||||||
|
marginBottom: 24,
|
||||||
|
},
|
||||||
|
statsGrid: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
statCard: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 20,
|
||||||
|
alignItems: 'center',
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: {
|
||||||
|
width: 0,
|
||||||
|
height: 2,
|
||||||
|
},
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 3.84,
|
||||||
|
elevation: 5,
|
||||||
|
},
|
||||||
|
statNumber: {
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: '800',
|
||||||
|
color: '#192126',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
statLabel: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#6B7280',
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
distributionContainer: {
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 16,
|
||||||
|
marginBottom: 24,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: {
|
||||||
|
width: 0,
|
||||||
|
height: 2,
|
||||||
|
},
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 3.84,
|
||||||
|
elevation: 5,
|
||||||
|
},
|
||||||
|
distributionList: {
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
distributionItem: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: 8,
|
||||||
|
},
|
||||||
|
moodType: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '500',
|
||||||
|
color: '#192126',
|
||||||
|
},
|
||||||
|
countContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
count: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#192126',
|
||||||
|
},
|
||||||
|
percentage: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#6B7280',
|
||||||
|
},
|
||||||
|
});
|
||||||
718
app/mood/calendar.tsx
Normal file
@@ -0,0 +1,718 @@
|
|||||||
|
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||||
|
import { useAppSelector } from '@/hooks/redux';
|
||||||
|
import { useMoodData } from '@/hooks/useMoodData';
|
||||||
|
import { getMoodOptions } from '@/services/moodCheckins';
|
||||||
|
import { selectLatestMoodRecordByDate } from '@/store/moodSlice';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
|
import { router, useFocusEffect, useLocalSearchParams } from 'expo-router';
|
||||||
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Dimensions, Image, SafeAreaView,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TouchableOpacity,
|
||||||
|
View
|
||||||
|
} from 'react-native';
|
||||||
|
|
||||||
|
const { width } = Dimensions.get('window');
|
||||||
|
|
||||||
|
// 心情日历数据生成函数
|
||||||
|
const generateCalendarData = (targetDate: Date) => {
|
||||||
|
// 使用 dayjs 确保时区一致性
|
||||||
|
const targetDayjs = dayjs(targetDate);
|
||||||
|
const year = targetDayjs.year();
|
||||||
|
const month = targetDayjs.month(); // dayjs month is 0-based
|
||||||
|
const daysInMonth = targetDayjs.daysInMonth();
|
||||||
|
|
||||||
|
// 使用 dayjs 获取月初第一天是周几(0=周日,1=周一...6=周六)
|
||||||
|
const firstDayOfWeek = targetDayjs.startOf('month').day();
|
||||||
|
// 转换为中国习惯(周一为一周开始):周日(0)转为6,其他减1
|
||||||
|
const firstDayAdjusted = firstDayOfWeek === 0 ? 6 : firstDayOfWeek - 1;
|
||||||
|
|
||||||
|
const calendar = [];
|
||||||
|
const weeks = [];
|
||||||
|
|
||||||
|
// 添加空白日期(基于周一开始)
|
||||||
|
for (let i = 0; i < firstDayAdjusted; i++) {
|
||||||
|
weeks.push(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加实际日期
|
||||||
|
for (let day = 1; day <= daysInMonth; day++) {
|
||||||
|
weeks.push(day);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按周分组
|
||||||
|
for (let i = 0; i < weeks.length; i += 7) {
|
||||||
|
calendar.push(weeks.slice(i, i + 7));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用 dayjs 获取今天的日期,确保时区一致
|
||||||
|
const today = dayjs();
|
||||||
|
return {
|
||||||
|
calendar,
|
||||||
|
today: today.date(),
|
||||||
|
month: month + 1, // 转回1-based用于显示
|
||||||
|
year
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function MoodCalendarScreen() {
|
||||||
|
const params = useLocalSearchParams();
|
||||||
|
const { fetchMoodRecords, fetchMoodHistoryRecords } = useMoodData();
|
||||||
|
|
||||||
|
// 使用 useRef 来存储函数引用,避免依赖循环
|
||||||
|
const fetchMoodRecordsRef = useRef(fetchMoodRecords);
|
||||||
|
const fetchMoodHistoryRecordsRef = useRef(fetchMoodHistoryRecords);
|
||||||
|
|
||||||
|
// 更新 ref 值
|
||||||
|
fetchMoodRecordsRef.current = fetchMoodRecords;
|
||||||
|
fetchMoodHistoryRecordsRef.current = fetchMoodHistoryRecords;
|
||||||
|
|
||||||
|
const { selectedDate } = params;
|
||||||
|
const initialDate = selectedDate ? dayjs(selectedDate as string).toDate() : new Date();
|
||||||
|
|
||||||
|
const [currentMonth, setCurrentMonth] = useState(initialDate);
|
||||||
|
const [selectedDay, setSelectedDay] = useState<number | null>(null);
|
||||||
|
|
||||||
|
// 使用 Redux store 中的数据
|
||||||
|
const moodRecords = useAppSelector(state => state.mood.moodRecords);
|
||||||
|
|
||||||
|
// 获取选中日期的数据
|
||||||
|
const selectedDateMood = useAppSelector(state => {
|
||||||
|
if (!selectedDay) return null;
|
||||||
|
const selectedDateString = dayjs(currentMonth).date(selectedDay).format('YYYY-MM-DD');
|
||||||
|
return selectLatestMoodRecordByDate(selectedDateString)(state);
|
||||||
|
});
|
||||||
|
|
||||||
|
const moodOptions = getMoodOptions();
|
||||||
|
const weekDays = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
|
||||||
|
const monthNames = ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'];
|
||||||
|
|
||||||
|
// 生成当前月份的日历数据
|
||||||
|
const { calendar, today, month, year } = generateCalendarData(currentMonth);
|
||||||
|
|
||||||
|
// 加载整个月份的心情数据
|
||||||
|
const loadMonthMoodData = useCallback(async (targetMonth: Date) => {
|
||||||
|
try {
|
||||||
|
const startDate = dayjs(targetMonth).startOf('month').format('YYYY-MM-DD');
|
||||||
|
const endDate = dayjs(targetMonth).endOf('month').format('YYYY-MM-DD');
|
||||||
|
await fetchMoodHistoryRecordsRef.current({ startDate, endDate });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载月份心情数据失败:', error);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 加载选中日期的心情记录
|
||||||
|
const loadDailyMoodCheckins = useCallback(async (dateString: string) => {
|
||||||
|
try {
|
||||||
|
await fetchMoodRecordsRef.current(dateString);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载心情记录失败:', error);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 初始化选中日期
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedDate) {
|
||||||
|
const date = dayjs(selectedDate as string);
|
||||||
|
setCurrentMonth(date.toDate());
|
||||||
|
setSelectedDay(date.date());
|
||||||
|
const dateString = date.format('YYYY-MM-DD');
|
||||||
|
loadDailyMoodCheckins(dateString);
|
||||||
|
loadMonthMoodData(date.toDate());
|
||||||
|
} else {
|
||||||
|
const today = dayjs().toDate();
|
||||||
|
setCurrentMonth(today);
|
||||||
|
setSelectedDay(dayjs().date());
|
||||||
|
const dateString = dayjs().format('YYYY-MM-DD');
|
||||||
|
loadDailyMoodCheckins(dateString);
|
||||||
|
loadMonthMoodData(today);
|
||||||
|
}
|
||||||
|
}, [selectedDate, loadDailyMoodCheckins, loadMonthMoodData]);
|
||||||
|
|
||||||
|
// 监听页面焦点变化,当从编辑页面返回时刷新数据
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
// 当页面获得焦点时,刷新当前月份的数据和选中日期的数据
|
||||||
|
const refreshData = async () => {
|
||||||
|
if (selectedDay) {
|
||||||
|
const selectedDateString = dayjs(currentMonth).date(selectedDay).format('YYYY-MM-DD');
|
||||||
|
await fetchMoodRecordsRef.current(selectedDateString);
|
||||||
|
}
|
||||||
|
const startDate = dayjs(currentMonth).startOf('month').format('YYYY-MM-DD');
|
||||||
|
const endDate = dayjs(currentMonth).endOf('month').format('YYYY-MM-DD');
|
||||||
|
await fetchMoodHistoryRecordsRef.current({ startDate, endDate });
|
||||||
|
};
|
||||||
|
refreshData();
|
||||||
|
}, [currentMonth, selectedDay])
|
||||||
|
);
|
||||||
|
|
||||||
|
// 月份切换函数
|
||||||
|
const goToPreviousMonth = () => {
|
||||||
|
const newMonth = dayjs(currentMonth).subtract(1, 'month').toDate();
|
||||||
|
setCurrentMonth(newMonth);
|
||||||
|
setSelectedDay(null);
|
||||||
|
loadMonthMoodData(newMonth);
|
||||||
|
};
|
||||||
|
|
||||||
|
const goToNextMonth = () => {
|
||||||
|
const newMonth = dayjs(currentMonth).add(1, 'month').toDate();
|
||||||
|
setCurrentMonth(newMonth);
|
||||||
|
setSelectedDay(null);
|
||||||
|
loadMonthMoodData(newMonth);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 日期选择函数
|
||||||
|
const onSelectDate = (day: number) => {
|
||||||
|
setSelectedDay(day);
|
||||||
|
const selectedDateString = dayjs(currentMonth).date(day).format('YYYY-MM-DD');
|
||||||
|
loadDailyMoodCheckins(selectedDateString);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 跳转到心情编辑页面
|
||||||
|
const openMoodEdit = () => {
|
||||||
|
const selectedDateString = selectedDay ? dayjs(currentMonth).date(selectedDay).format('YYYY-MM-DD') : dayjs().format('YYYY-MM-DD');
|
||||||
|
const moodId = selectedDateMood?.id;
|
||||||
|
|
||||||
|
router.push({
|
||||||
|
pathname: '/mood/edit',
|
||||||
|
params: {
|
||||||
|
date: selectedDateString,
|
||||||
|
...(moodId && { moodId })
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderMoodRing = (day: number | null, isSelected: boolean) => {
|
||||||
|
if (!day) return null;
|
||||||
|
|
||||||
|
// 检查该日期是否有心情记录 - 现在从 Redux store 中获取
|
||||||
|
const dayDateString = dayjs(currentMonth).date(day).format('YYYY-MM-DD');
|
||||||
|
const dayRecords = moodRecords[dayDateString] || [];
|
||||||
|
const moodRecord = dayRecords.length > 0 ? dayRecords[0] : null;
|
||||||
|
|
||||||
|
const isToday = day === dayjs().date() &&
|
||||||
|
month === dayjs().month() + 1 &&
|
||||||
|
year === dayjs().year();
|
||||||
|
|
||||||
|
if (moodRecord) {
|
||||||
|
const mood = moodOptions.find(m => m.type === moodRecord.moodType);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={isToday ? styles.todayMoodIconContainer : styles.moodIconContainer}>
|
||||||
|
<View style={styles.moodIcon}>
|
||||||
|
<Image
|
||||||
|
source={mood?.image}
|
||||||
|
style={styles.moodIconImage}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={isToday ? styles.todayDefaultMoodIcon : styles.defaultMoodIcon}>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<LinearGradient
|
||||||
|
colors={['#fafaff', '#f4f3ff']} // 使用紫色主题的浅色渐变
|
||||||
|
style={styles.gradientBackground}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 0, y: 1 }}
|
||||||
|
/>
|
||||||
|
{/* 装饰性圆圈 */}
|
||||||
|
<View style={styles.decorativeCircle1} />
|
||||||
|
<View style={styles.decorativeCircle2} />
|
||||||
|
|
||||||
|
<SafeAreaView style={styles.safeArea}>
|
||||||
|
<HeaderBar
|
||||||
|
title="心情日历"
|
||||||
|
onBack={() => router.back()}
|
||||||
|
withSafeTop={false}
|
||||||
|
transparent={true}
|
||||||
|
tone="light"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ScrollView style={styles.content}>
|
||||||
|
{/* 日历视图 */}
|
||||||
|
<View style={styles.calendar}>
|
||||||
|
{/* 月份导航 */}
|
||||||
|
<View style={styles.monthNavigation}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.navButton}
|
||||||
|
onPress={goToPreviousMonth}
|
||||||
|
>
|
||||||
|
<Text style={styles.navButtonText}>‹</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text style={styles.monthTitle}>{year}年{monthNames[month - 1]}</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.navButton}
|
||||||
|
onPress={goToNextMonth}
|
||||||
|
>
|
||||||
|
<Text style={styles.navButtonText}>›</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.weekHeader}>
|
||||||
|
{weekDays.map((day, index) => (
|
||||||
|
<Text key={index} style={styles.weekDay}>{day}</Text>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View >
|
||||||
|
{calendar.map((week, weekIndex) => (
|
||||||
|
<View key={weekIndex} style={styles.weekRow}>
|
||||||
|
{week.map((day, dayIndex) => {
|
||||||
|
const isSelected = day === selectedDay;
|
||||||
|
const isToday = day === today && month === dayjs().month() + 1 && year === dayjs().year();
|
||||||
|
const isFutureDate = Boolean(day && dayjs(currentMonth).date(day).isAfter(dayjs(), 'day'));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View key={dayIndex} style={styles.dayContainer}>
|
||||||
|
{day && (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.dayButton,
|
||||||
|
isSelected && styles.dayButtonSelected,
|
||||||
|
isToday && styles.dayButtonToday
|
||||||
|
]}
|
||||||
|
onPress={() => !isFutureDate && day && onSelectDate(day)}
|
||||||
|
disabled={isFutureDate}
|
||||||
|
>
|
||||||
|
<View style={styles.dayContent}>
|
||||||
|
<Text style={[
|
||||||
|
styles.dayNumber,
|
||||||
|
isSelected && styles.dayNumberSelected,
|
||||||
|
isToday && styles.dayNumberToday,
|
||||||
|
isFutureDate && styles.dayNumberDisabled
|
||||||
|
]}>
|
||||||
|
{day.toString().padStart(2, '0')}
|
||||||
|
</Text>
|
||||||
|
{renderMoodRing(day, isSelected)}
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 选中日期的记录 */}
|
||||||
|
<View style={styles.selectedDateSection}>
|
||||||
|
<View style={styles.selectedDateHeader}>
|
||||||
|
<Text style={styles.selectedDateTitle}>
|
||||||
|
{selectedDay ? dayjs(currentMonth).date(selectedDay).format('YYYY年M月D日') : '请选择日期'}
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.addMoodButton}
|
||||||
|
onPress={openMoodEdit}
|
||||||
|
>
|
||||||
|
<Text style={styles.addMoodButtonText}>记录</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{selectedDay ? (
|
||||||
|
selectedDateMood ? (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.moodRecord}
|
||||||
|
onPress={openMoodEdit}
|
||||||
|
>
|
||||||
|
<View style={styles.recordIcon}>
|
||||||
|
<View style={styles.moodIcon}>
|
||||||
|
<Image
|
||||||
|
source={moodOptions.find(m => m.type === selectedDateMood.moodType)?.image}
|
||||||
|
style={styles.moodIconImage}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View style={styles.recordContent}>
|
||||||
|
<Text style={styles.recordMood}>
|
||||||
|
{moodOptions.find(m => m.type === selectedDateMood.moodType)?.label}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.recordIntensity}>强度: {selectedDateMood.intensity}</Text>
|
||||||
|
{selectedDateMood.description && (
|
||||||
|
<Text style={styles.recordDescription}>{selectedDateMood.description}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<View style={styles.spacer} />
|
||||||
|
<Text style={styles.recordTime}>
|
||||||
|
{dayjs(selectedDateMood.createdAt).format('HH:mm')}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
) : (
|
||||||
|
<View style={styles.emptyRecord}>
|
||||||
|
<Text style={styles.emptyRecordText}>暂无心情记录</Text>
|
||||||
|
<Text style={styles.emptyRecordSubtext}>点击右上角"记录"按钮添加心情</Text>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<View style={styles.emptyRecord}>
|
||||||
|
<Text style={styles.emptyRecordText}>请先选择一个日期</Text>
|
||||||
|
<Text style={styles.emptyRecordSubtext}>点击日历中的日期,然后点击"记录"按钮添加心情</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</SafeAreaView>
|
||||||
|
</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: '#7a5af8',
|
||||||
|
opacity: 0.08,
|
||||||
|
},
|
||||||
|
decorativeCircle2: {
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: -15,
|
||||||
|
left: -15,
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 20,
|
||||||
|
backgroundColor: '#7a5af8',
|
||||||
|
opacity: 0.04,
|
||||||
|
},
|
||||||
|
safeArea: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
|
||||||
|
content: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
calendar: {
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.95)',
|
||||||
|
margin: 16,
|
||||||
|
borderRadius: 20,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: 20,
|
||||||
|
shadowColor: '#7a5af8',
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 12,
|
||||||
|
elevation: 6,
|
||||||
|
},
|
||||||
|
monthNavigation: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
width: '100%',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 24,
|
||||||
|
},
|
||||||
|
navButton: {
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
borderRadius: 22,
|
||||||
|
backgroundColor: 'rgba(122,90,248,0.1)',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
shadowColor: '#7a5af8',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 4,
|
||||||
|
elevation: 2,
|
||||||
|
},
|
||||||
|
navButtonText: {
|
||||||
|
fontSize: 24,
|
||||||
|
color: '#7a5af8',
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
monthTitle: {
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: '800',
|
||||||
|
color: '#192126',
|
||||||
|
},
|
||||||
|
weekHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
weekDay: {
|
||||||
|
fontSize: 13,
|
||||||
|
color: '#5d6676',
|
||||||
|
textAlign: 'center',
|
||||||
|
width: (width - 96) / 7,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
weekRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
dayContainer: {
|
||||||
|
width: (width - 96) / 7,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
dayButton: {
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
borderRadius: 22,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 8,
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
},
|
||||||
|
dayButtonSelected: {
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
shadowColor: '#7a5af8',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.15,
|
||||||
|
shadowRadius: 6,
|
||||||
|
elevation: 4,
|
||||||
|
},
|
||||||
|
dayButtonToday: {
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: '#7a5af8',
|
||||||
|
},
|
||||||
|
dayContent: {
|
||||||
|
position: 'relative',
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
dayNumber: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#777f8c',
|
||||||
|
fontWeight: '600',
|
||||||
|
position: 'absolute',
|
||||||
|
top: 2,
|
||||||
|
zIndex: 1,
|
||||||
|
},
|
||||||
|
dayNumberSelected: {
|
||||||
|
color: '#192126',
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
dayNumberToday: {
|
||||||
|
color: '#7a5af8',
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
dayNumberDisabled: {
|
||||||
|
color: '#c0c4ca',
|
||||||
|
},
|
||||||
|
moodIconContainer: {
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 2,
|
||||||
|
width: 22,
|
||||||
|
height: 22,
|
||||||
|
borderRadius: 11,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 1 },
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 2,
|
||||||
|
elevation: 1,
|
||||||
|
},
|
||||||
|
todayMoodIconContainer: {
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 1,
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
borderRadius: 10,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
shadowColor: '#7a5af8',
|
||||||
|
shadowOffset: { width: 0, height: 1 },
|
||||||
|
shadowOpacity: 0.2,
|
||||||
|
shadowRadius: 2,
|
||||||
|
elevation: 2,
|
||||||
|
},
|
||||||
|
moodIcon: {
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
borderRadius: 9,
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.95)',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
moodIconImage: {
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
borderRadius: 9,
|
||||||
|
},
|
||||||
|
defaultMoodIcon: {
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 2,
|
||||||
|
width: 22,
|
||||||
|
height: 22,
|
||||||
|
borderRadius: 11,
|
||||||
|
borderWidth: 1.5,
|
||||||
|
borderColor: 'rgba(122,90,248,0.3)',
|
||||||
|
borderStyle: 'dashed',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: 'rgba(122,90,248,0.05)',
|
||||||
|
},
|
||||||
|
todayDefaultMoodIcon: {
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 1,
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
borderRadius: 10,
|
||||||
|
borderWidth: 1.5,
|
||||||
|
borderColor: 'rgba(122,90,248,0.4)',
|
||||||
|
borderStyle: 'dashed',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: 'rgba(122,90,248,0.08)',
|
||||||
|
},
|
||||||
|
moodRingContainer: {
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 2,
|
||||||
|
width: 22,
|
||||||
|
height: 22,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
|
||||||
|
moodIntensityText: {
|
||||||
|
fontSize: 8,
|
||||||
|
fontWeight: '800',
|
||||||
|
textAlign: 'center',
|
||||||
|
position: 'absolute',
|
||||||
|
zIndex: 1,
|
||||||
|
textShadowColor: 'rgba(0,0,0,0.3)',
|
||||||
|
textShadowOffset: { width: 0, height: 0.5 },
|
||||||
|
textShadowRadius: 1,
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
|
selectedDateSection: {
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.95)',
|
||||||
|
margin: 16,
|
||||||
|
marginTop: 0,
|
||||||
|
borderRadius: 20,
|
||||||
|
padding: 20,
|
||||||
|
shadowColor: '#7a5af8',
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 12,
|
||||||
|
elevation: 6,
|
||||||
|
},
|
||||||
|
selectedDateHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
selectedDateTitle: {
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: '800',
|
||||||
|
color: '#192126',
|
||||||
|
},
|
||||||
|
addMoodButton: {
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
height: 36,
|
||||||
|
borderRadius: 18,
|
||||||
|
backgroundColor: '#7a5af8',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
shadowColor: '#7a5af8',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.2,
|
||||||
|
shadowRadius: 4,
|
||||||
|
elevation: 3,
|
||||||
|
},
|
||||||
|
addMoodButtonText: {
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
moodRecord: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
paddingVertical: 16,
|
||||||
|
backgroundColor: 'rgba(122,90,248,0.05)',
|
||||||
|
borderRadius: 16,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
},
|
||||||
|
recordIcon: {
|
||||||
|
width: 52,
|
||||||
|
height: 52,
|
||||||
|
borderRadius: 26,
|
||||||
|
backgroundColor: '#e9e7f1ff',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginRight: 16,
|
||||||
|
shadowColor: '#7a5af8',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.15,
|
||||||
|
shadowRadius: 4,
|
||||||
|
elevation: 2,
|
||||||
|
},
|
||||||
|
recordContent: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
recordMood: {
|
||||||
|
fontSize: 18,
|
||||||
|
color: '#192126',
|
||||||
|
fontWeight: '700',
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
recordIntensity: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#5d6676',
|
||||||
|
marginTop: 2,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
recordDescription: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#5d6676',
|
||||||
|
marginTop: 6,
|
||||||
|
fontStyle: 'italic',
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
spacer: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
recordTime: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#777f8c',
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
emptyRecord: {
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: 32,
|
||||||
|
},
|
||||||
|
emptyRecordText: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: '#5d6676',
|
||||||
|
marginBottom: 8,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
emptyRecordSubtext: {
|
||||||
|
fontSize: 13,
|
||||||
|
color: '#777f8c',
|
||||||
|
textAlign: 'center',
|
||||||
|
lineHeight: 18,
|
||||||
|
},
|
||||||
|
|
||||||
|
});
|
||||||
530
app/mood/edit.tsx
Normal file
@@ -0,0 +1,530 @@
|
|||||||
|
import MoodIntensitySlider from '@/components/MoodIntensitySlider';
|
||||||
|
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||||
|
import { Colors } from '@/constants/Colors';
|
||||||
|
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||||
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
|
import { getMoodOptions, MoodType } from '@/services/moodCheckins';
|
||||||
|
import {
|
||||||
|
createMoodRecord,
|
||||||
|
deleteMoodRecord,
|
||||||
|
fetchDailyMoodCheckins,
|
||||||
|
selectMoodRecordsByDate,
|
||||||
|
updateMoodRecord
|
||||||
|
} from '@/store/moodSlice';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
|
import { router, useLocalSearchParams } from 'expo-router';
|
||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Alert, Image,
|
||||||
|
Keyboard,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Platform,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
TouchableOpacity,
|
||||||
|
View
|
||||||
|
} from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
|
export default function MoodEditScreen() {
|
||||||
|
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||||
|
const colorTokens = Colors[theme];
|
||||||
|
const params = useLocalSearchParams();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const { date, moodId } = params;
|
||||||
|
const selectedDate = date as string || dayjs().format('YYYY-MM-DD');
|
||||||
|
|
||||||
|
const [selectedMood, setSelectedMood] = useState<MoodType | ''>('');
|
||||||
|
const [intensity, setIntensity] = useState(5);
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
const [existingMood, setExistingMood] = useState<any>(null);
|
||||||
|
|
||||||
|
const scrollViewRef = useRef<ScrollView>(null);
|
||||||
|
const textInputRef = useRef<TextInput>(null);
|
||||||
|
|
||||||
|
const moodOptions = getMoodOptions();
|
||||||
|
|
||||||
|
// 从 Redux 获取数据
|
||||||
|
const moodRecords = useAppSelector(selectMoodRecordsByDate(selectedDate));
|
||||||
|
const loading = useAppSelector(state => state.mood.loading);
|
||||||
|
|
||||||
|
// 初始化数据
|
||||||
|
useEffect(() => {
|
||||||
|
// 加载当前日期的心情记录
|
||||||
|
dispatch(fetchDailyMoodCheckins(selectedDate));
|
||||||
|
}, [selectedDate, dispatch]);
|
||||||
|
|
||||||
|
// 当 moodRecords 更新时,查找现有记录
|
||||||
|
useEffect(() => {
|
||||||
|
if (moodId && moodRecords.length > 0) {
|
||||||
|
const mood = moodRecords.find((c: any) => c.id === moodId) || moodRecords[0];
|
||||||
|
setExistingMood(mood);
|
||||||
|
setSelectedMood(mood.moodType);
|
||||||
|
setIntensity(mood.intensity);
|
||||||
|
setDescription(mood.description || '');
|
||||||
|
}
|
||||||
|
}, [moodId, moodRecords]);
|
||||||
|
|
||||||
|
// 键盘事件监听器
|
||||||
|
useEffect(() => {
|
||||||
|
const keyboardDidShowListener = Keyboard.addListener('keyboardDidShow', () => {
|
||||||
|
// 键盘出现时,延迟滚动到文本输入框
|
||||||
|
setTimeout(() => {
|
||||||
|
scrollViewRef.current?.scrollToEnd({ animated: true });
|
||||||
|
}, 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
const keyboardDidHideListener = Keyboard.addListener('keyboardDidHide', () => {
|
||||||
|
// 键盘隐藏时,可以进行必要的调整
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
keyboardDidShowListener?.remove();
|
||||||
|
keyboardDidHideListener?.remove();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!selectedMood) {
|
||||||
|
Alert.alert('提示', '请选择心情');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
if (existingMood) {
|
||||||
|
// 更新现有记录
|
||||||
|
await dispatch(updateMoodRecord({
|
||||||
|
id: existingMood.id,
|
||||||
|
moodType: selectedMood,
|
||||||
|
intensity,
|
||||||
|
description: description.trim() || undefined,
|
||||||
|
})).unwrap();
|
||||||
|
} else {
|
||||||
|
// 创建新记录
|
||||||
|
await dispatch(createMoodRecord({
|
||||||
|
moodType: selectedMood,
|
||||||
|
intensity,
|
||||||
|
description: description.trim() || undefined,
|
||||||
|
checkinDate: selectedDate,
|
||||||
|
})).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
Alert.alert('成功', existingMood ? '心情记录已更新' : '心情记录已保存', [
|
||||||
|
{ text: '确定', onPress: () => router.back() }
|
||||||
|
]);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('保存心情失败:', error);
|
||||||
|
Alert.alert('错误', '保存心情失败,请重试');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!existingMood) return;
|
||||||
|
|
||||||
|
Alert.alert(
|
||||||
|
'确认删除',
|
||||||
|
'确定要删除这条心情记录吗?',
|
||||||
|
[
|
||||||
|
{ text: '取消', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: '删除',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: async () => {
|
||||||
|
try {
|
||||||
|
setIsDeleting(true);
|
||||||
|
await dispatch(deleteMoodRecord({ id: existingMood.id })).unwrap();
|
||||||
|
|
||||||
|
Alert.alert('成功', '心情记录已删除', [
|
||||||
|
{ text: '确定', onPress: () => router.back() }
|
||||||
|
]);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('删除心情失败:', error);
|
||||||
|
Alert.alert('错误', '删除心情失败,请重试');
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleIntensityChange = (value: number) => {
|
||||||
|
setIntensity(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 使用统一的渐变背景色
|
||||||
|
const backgroundGradientColors = [colorTokens.backgroundGradientStart, colorTokens.backgroundGradientEnd] as const;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<LinearGradient
|
||||||
|
colors={['#fafaff', '#f4f3ff']} // 使用紫色主题的浅色渐变
|
||||||
|
style={styles.gradientBackground}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 0, y: 1 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 装饰性圆圈 */}
|
||||||
|
<View style={styles.decorativeCircle1} />
|
||||||
|
<View style={styles.decorativeCircle2} />
|
||||||
|
<SafeAreaView style={styles.safeArea} edges={['top']}>
|
||||||
|
<HeaderBar
|
||||||
|
title={existingMood ? '编辑心情' : '记录心情'}
|
||||||
|
onBack={() => router.back()}
|
||||||
|
withSafeTop={false}
|
||||||
|
transparent={true}
|
||||||
|
tone="light"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||||
|
style={styles.keyboardAvoidingView}
|
||||||
|
keyboardVerticalOffset={Platform.OS === 'ios' ? 0 : 0}
|
||||||
|
>
|
||||||
|
<ScrollView
|
||||||
|
ref={scrollViewRef}
|
||||||
|
style={styles.content}
|
||||||
|
contentContainerStyle={styles.scrollContent}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
{/* 日期显示 */}
|
||||||
|
<View style={styles.dateSection}>
|
||||||
|
<Text style={styles.dateTitle}>
|
||||||
|
{dayjs(selectedDate).format('YYYY年M月D日')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 心情选择 */}
|
||||||
|
<View style={styles.moodSection}>
|
||||||
|
<Text style={styles.sectionTitle}>选择心情</Text>
|
||||||
|
<View style={styles.moodOptions}>
|
||||||
|
{moodOptions.map((mood, index) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={index}
|
||||||
|
style={[
|
||||||
|
styles.moodOption,
|
||||||
|
selectedMood === mood.type && styles.selectedMoodOption
|
||||||
|
]}
|
||||||
|
onPress={() => setSelectedMood(mood.type)}
|
||||||
|
>
|
||||||
|
<Image source={mood.image} style={styles.moodImage} />
|
||||||
|
<Text style={styles.moodLabel}>{mood.label}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 心情强度选择 */}
|
||||||
|
<View style={styles.intensitySection}>
|
||||||
|
<Text style={styles.sectionTitle}>心情强度</Text>
|
||||||
|
<MoodIntensitySlider
|
||||||
|
value={intensity}
|
||||||
|
onValueChange={handleIntensityChange}
|
||||||
|
min={1}
|
||||||
|
max={10}
|
||||||
|
width={280}
|
||||||
|
height={12}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
|
||||||
|
{/* 心情描述 */}
|
||||||
|
|
||||||
|
<View style={styles.descriptionSection}>
|
||||||
|
<Text style={styles.sectionTitle}>心情日记</Text>
|
||||||
|
<Text style={styles.diarySubtitle}>记录你的心情,珍藏美好回忆</Text>
|
||||||
|
<TextInput
|
||||||
|
ref={textInputRef}
|
||||||
|
style={styles.descriptionInput}
|
||||||
|
placeholder={`今天的心情如何?
|
||||||
|
|
||||||
|
你经历过什么特别的事情吗?
|
||||||
|
有什么让你开心的事?
|
||||||
|
或者,有什么让你感到困扰?
|
||||||
|
|
||||||
|
写下你的感受,让这些时刻成为你珍贵的记忆...`}
|
||||||
|
placeholderTextColor="#a8a8a8"
|
||||||
|
value={description}
|
||||||
|
onChangeText={setDescription}
|
||||||
|
multiline
|
||||||
|
maxLength={1000}
|
||||||
|
textAlignVertical="top"
|
||||||
|
onFocus={() => {
|
||||||
|
// 当文本输入框获得焦点时,滚动到输入框
|
||||||
|
setTimeout(() => {
|
||||||
|
scrollViewRef.current?.scrollToEnd({ animated: true });
|
||||||
|
}, 300);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Text style={styles.characterCount}>{description.length}/1000</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
</ScrollView>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
|
||||||
|
{/* 底部按钮 */}
|
||||||
|
<View style={styles.footer}>
|
||||||
|
<View style={styles.buttonRow}>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.saveButton, (!selectedMood || isLoading) && styles.disabledButton]}
|
||||||
|
onPress={handleSave}
|
||||||
|
disabled={!selectedMood || isLoading}
|
||||||
|
>
|
||||||
|
<Text style={styles.saveButtonText}>
|
||||||
|
{isLoading ? '保存中...' : existingMood ? '更新心情' : '保存心情'}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
{existingMood && (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.deleteIconButton, isDeleting && styles.disabledButton]}
|
||||||
|
onPress={handleDelete}
|
||||||
|
disabled={isDeleting}
|
||||||
|
>
|
||||||
|
<Ionicons name="trash-outline" size={20} color="#f95555" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
gradientBackground: {
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
},
|
||||||
|
decorativeCircle1: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 30,
|
||||||
|
right: 15,
|
||||||
|
width: 45,
|
||||||
|
height: 45,
|
||||||
|
borderRadius: 22.5,
|
||||||
|
backgroundColor: '#7a5af8',
|
||||||
|
opacity: 0.06,
|
||||||
|
},
|
||||||
|
decorativeCircle2: {
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: -10,
|
||||||
|
left: -10,
|
||||||
|
width: 30,
|
||||||
|
height: 30,
|
||||||
|
borderRadius: 15,
|
||||||
|
backgroundColor: '#7a5af8',
|
||||||
|
opacity: 0.04,
|
||||||
|
},
|
||||||
|
safeArea: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
keyboardAvoidingView: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
scrollContent: {
|
||||||
|
paddingBottom: 100, // 为底部按钮留出空间
|
||||||
|
},
|
||||||
|
dateSection: {
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.95)',
|
||||||
|
margin: 12,
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 16,
|
||||||
|
alignItems: 'center',
|
||||||
|
shadowColor: '#7a5af8',
|
||||||
|
shadowOffset: { width: 0, height: 3 },
|
||||||
|
shadowOpacity: 0.08,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 4,
|
||||||
|
},
|
||||||
|
dateTitle: {
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#192126',
|
||||||
|
},
|
||||||
|
moodSection: {
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.95)',
|
||||||
|
margin: 12,
|
||||||
|
marginTop: 0,
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 16,
|
||||||
|
shadowColor: '#7a5af8',
|
||||||
|
shadowOffset: { width: 0, height: 3 },
|
||||||
|
shadowOpacity: 0.08,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 4,
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#192126',
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
moodOptions: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
},
|
||||||
|
moodOption: {
|
||||||
|
width: '18%',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: 12,
|
||||||
|
marginBottom: 8,
|
||||||
|
borderRadius: 12,
|
||||||
|
backgroundColor: 'rgba(122,90,248,0.05)',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(122,90,248,0.1)',
|
||||||
|
},
|
||||||
|
selectedMoodOption: {
|
||||||
|
backgroundColor: 'rgba(122,90,248,0.15)',
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: '#7a5af8',
|
||||||
|
shadowColor: '#7a5af8',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.12,
|
||||||
|
shadowRadius: 3,
|
||||||
|
elevation: 2,
|
||||||
|
},
|
||||||
|
moodImage: {
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
marginBottom: 6,
|
||||||
|
},
|
||||||
|
moodLabel: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#192126',
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
intensitySection: {
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.95)',
|
||||||
|
margin: 12,
|
||||||
|
marginTop: 0,
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 16,
|
||||||
|
shadowColor: '#7a5af8',
|
||||||
|
shadowOffset: { width: 0, height: 3 },
|
||||||
|
shadowOpacity: 0.08,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 4,
|
||||||
|
},
|
||||||
|
|
||||||
|
descriptionSection: {
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.95)',
|
||||||
|
margin: 12,
|
||||||
|
marginTop: 0,
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 16,
|
||||||
|
shadowColor: '#7a5af8',
|
||||||
|
shadowOffset: { width: 0, height: 3 },
|
||||||
|
shadowOpacity: 0.08,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 4,
|
||||||
|
},
|
||||||
|
diarySubtitle: {
|
||||||
|
fontSize: 13,
|
||||||
|
color: '#666',
|
||||||
|
fontWeight: '500',
|
||||||
|
marginBottom: 12,
|
||||||
|
lineHeight: 18,
|
||||||
|
},
|
||||||
|
descriptionInput: {
|
||||||
|
borderWidth: 1.5,
|
||||||
|
borderColor: 'rgba(122,90,248,0.2)',
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: 12,
|
||||||
|
fontSize: 14,
|
||||||
|
minHeight: 120,
|
||||||
|
textAlignVertical: 'top',
|
||||||
|
backgroundColor: 'rgba(122,90,248,0.02)',
|
||||||
|
color: '#192126',
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
characterCount: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#777f8c',
|
||||||
|
textAlign: 'right',
|
||||||
|
marginTop: 8,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
padding: 12,
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 20,
|
||||||
|
right: 8,
|
||||||
|
},
|
||||||
|
buttonRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
saveButton: {
|
||||||
|
backgroundColor: '#7a5af8',
|
||||||
|
borderRadius: 10,
|
||||||
|
paddingVertical: 10,
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
alignItems: 'center',
|
||||||
|
marginLeft: 8,
|
||||||
|
shadowColor: '#7a5af8',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.15,
|
||||||
|
shadowRadius: 3,
|
||||||
|
elevation: 2,
|
||||||
|
},
|
||||||
|
deleteIconButton: {
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: 16,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginLeft: 8,
|
||||||
|
},
|
||||||
|
deleteButton: {
|
||||||
|
backgroundColor: '#f95555',
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingVertical: 12,
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
alignItems: 'center',
|
||||||
|
shadowColor: '#f95555',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.2,
|
||||||
|
shadowRadius: 4,
|
||||||
|
elevation: 3,
|
||||||
|
},
|
||||||
|
disabledButton: {
|
||||||
|
backgroundColor: '#c0c4ca',
|
||||||
|
shadowOpacity: 0,
|
||||||
|
elevation: 0,
|
||||||
|
},
|
||||||
|
saveButtonText: {
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
deleteButtonText: {
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
});
|
||||||
9
app/nutrition/_layout.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Stack } from 'expo-router';
|
||||||
|
|
||||||
|
export default function NutritionLayout() {
|
||||||
|
return (
|
||||||
|
<Stack screenOptions={{ headerShown: false }}>
|
||||||
|
<Stack.Screen name="records" />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
609
app/nutrition/records.tsx
Normal file
@@ -0,0 +1,609 @@
|
|||||||
|
import { CalorieRingChart } from '@/components/CalorieRingChart';
|
||||||
|
import { DateSelector } from '@/components/DateSelector';
|
||||||
|
import { FloatingFoodOverlay } from '@/components/FloatingFoodOverlay';
|
||||||
|
import { NutritionRecordCard } from '@/components/NutritionRecordCard';
|
||||||
|
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||||
|
import { Colors } from '@/constants/Colors';
|
||||||
|
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||||
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
|
import { DietRecord } from '@/services/dietRecords';
|
||||||
|
import { type FoodRecognitionResponse } from '@/services/foodRecognition';
|
||||||
|
import { saveRecognitionResult } from '@/store/foodRecognitionSlice';
|
||||||
|
import { selectHealthDataByDate } from '@/store/healthSlice';
|
||||||
|
import {
|
||||||
|
deleteNutritionRecord,
|
||||||
|
fetchDailyNutritionData,
|
||||||
|
fetchNutritionRecords,
|
||||||
|
selectNutritionLoading,
|
||||||
|
selectNutritionRecordsByDate,
|
||||||
|
selectNutritionSummaryByDate
|
||||||
|
} from '@/store/nutritionSlice';
|
||||||
|
import { getMonthDaysZh, getMonthTitleZh, getTodayIndexInMonth } from '@/utils/date';
|
||||||
|
import { fetchBasalEnergyBurned } from '@/utils/health';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { useFocusEffect } from '@react-navigation/native';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { router } from 'expo-router';
|
||||||
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
FlatList,
|
||||||
|
RefreshControl,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TouchableOpacity,
|
||||||
|
View
|
||||||
|
} from 'react-native';
|
||||||
|
|
||||||
|
type ViewMode = 'daily' | 'all';
|
||||||
|
|
||||||
|
export default function NutritionRecordsScreen() {
|
||||||
|
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||||
|
const colorTokens = Colors[theme];
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
// 日期相关状态 - 使用与统计页面相同的日期逻辑
|
||||||
|
const days = getMonthDaysZh();
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth());
|
||||||
|
const monthTitle = getMonthTitleZh();
|
||||||
|
|
||||||
|
// 获取当前选中日期 - 使用 useMemo 避免每次渲染都创建新对象
|
||||||
|
const currentSelectedDate = useMemo(() => {
|
||||||
|
return days[selectedIndex]?.date?.toDate() ?? new Date();
|
||||||
|
}, [selectedIndex, days]);
|
||||||
|
|
||||||
|
const currentSelectedDateString = useMemo(() => {
|
||||||
|
return dayjs(currentSelectedDate).format('YYYY-MM-DD');
|
||||||
|
}, [currentSelectedDate]);
|
||||||
|
|
||||||
|
// 从 Redux 获取数据
|
||||||
|
const healthData = useAppSelector(selectHealthDataByDate(currentSelectedDateString));
|
||||||
|
const nutritionSummary = useAppSelector(selectNutritionSummaryByDate(currentSelectedDateString));
|
||||||
|
const userProfile = useAppSelector((state) => state.user.profile);
|
||||||
|
|
||||||
|
// 从 Redux 获取营养记录数据
|
||||||
|
const nutritionRecords = useAppSelector(selectNutritionRecordsByDate(currentSelectedDateString));
|
||||||
|
const nutritionLoading = useAppSelector(selectNutritionLoading);
|
||||||
|
|
||||||
|
// 视图模式:按天查看 vs 全部查看
|
||||||
|
const [viewMode, setViewMode] = useState<ViewMode>('daily');
|
||||||
|
|
||||||
|
// 全部记录模式的本地状态
|
||||||
|
const [allRecords, setAllRecords] = useState<DietRecord[]>([]);
|
||||||
|
const [allRecordsLoading, setAllRecordsLoading] = useState(false);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const [hasMoreData, setHasMoreData] = useState(true);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
|
||||||
|
// 基础代谢数据状态
|
||||||
|
const [basalMetabolism, setBasalMetabolism] = useState<number>(1482);
|
||||||
|
|
||||||
|
// 食物添加弹窗状态
|
||||||
|
const [showFoodOverlay, setShowFoodOverlay] = useState(false);
|
||||||
|
|
||||||
|
// 根据视图模式选择使用的数据
|
||||||
|
const displayRecords = viewMode === 'daily' ? nutritionRecords : allRecords;
|
||||||
|
const loading = viewMode === 'daily' ? nutritionLoading.records : allRecordsLoading;
|
||||||
|
|
||||||
|
|
||||||
|
// 页面聚焦时自动刷新数据
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
console.log('营养记录页面聚焦,刷新数据...');
|
||||||
|
if (viewMode === 'daily') {
|
||||||
|
dispatch(fetchDailyNutritionData(currentSelectedDate));
|
||||||
|
} else {
|
||||||
|
// 全部记录模式:重新加载数据
|
||||||
|
const loadAllRecords = async () => {
|
||||||
|
try {
|
||||||
|
setAllRecordsLoading(true);
|
||||||
|
const response = await dispatch(fetchNutritionRecords({
|
||||||
|
page: 1,
|
||||||
|
limit: 10,
|
||||||
|
append: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (fetchNutritionRecords.fulfilled.match(response)) {
|
||||||
|
const { records } = response.payload;
|
||||||
|
setAllRecords(records);
|
||||||
|
setHasMoreData(records.length === 10);
|
||||||
|
setPage(1);
|
||||||
|
}
|
||||||
|
setAllRecordsLoading(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载全部记录失败:', error);
|
||||||
|
setAllRecordsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadAllRecords();
|
||||||
|
}
|
||||||
|
}, [viewMode, currentSelectedDateString, dispatch])
|
||||||
|
);
|
||||||
|
|
||||||
|
// 当选中日期或视图模式变化时重新加载数据
|
||||||
|
useEffect(() => {
|
||||||
|
fetchBasalMetabolismData();
|
||||||
|
if (viewMode === 'daily') {
|
||||||
|
dispatch(fetchDailyNutritionData(currentSelectedDate));
|
||||||
|
} else {
|
||||||
|
setPage(1); // 重置分页
|
||||||
|
setAllRecords([]); // 清空记录
|
||||||
|
|
||||||
|
// 全部记录模式:加载数据
|
||||||
|
const loadAllRecords = async () => {
|
||||||
|
try {
|
||||||
|
setAllRecordsLoading(true);
|
||||||
|
const response = await dispatch(fetchNutritionRecords({
|
||||||
|
page: 1,
|
||||||
|
limit: 10,
|
||||||
|
append: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (fetchNutritionRecords.fulfilled.match(response)) {
|
||||||
|
const { records } = response.payload;
|
||||||
|
setAllRecords(records);
|
||||||
|
setHasMoreData(records.length === 10);
|
||||||
|
}
|
||||||
|
setAllRecordsLoading(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载全部记录失败:', error);
|
||||||
|
setAllRecordsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadAllRecords();
|
||||||
|
}
|
||||||
|
}, [viewMode, currentSelectedDateString, dispatch]);
|
||||||
|
|
||||||
|
// 获取基础代谢数据
|
||||||
|
const fetchBasalMetabolismData = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const options = {
|
||||||
|
startDate: dayjs(currentSelectedDate).startOf('day').toDate().toISOString(),
|
||||||
|
endDate: dayjs(currentSelectedDate).endOf('day').toDate().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
const basalEnergy = await fetchBasalEnergyBurned(options);
|
||||||
|
setBasalMetabolism(basalEnergy || 1482);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取基础代谢数据失败:', error);
|
||||||
|
setBasalMetabolism(1482); // 失败时使用默认值
|
||||||
|
}
|
||||||
|
}, [currentSelectedDate]);
|
||||||
|
|
||||||
|
const onRefresh = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setRefreshing(true);
|
||||||
|
|
||||||
|
if (viewMode === 'daily') {
|
||||||
|
await dispatch(fetchDailyNutritionData(currentSelectedDate));
|
||||||
|
} else {
|
||||||
|
// 全部记录模式:刷新数据
|
||||||
|
setPage(1);
|
||||||
|
const response = await dispatch(fetchNutritionRecords({
|
||||||
|
page: 1,
|
||||||
|
limit: 10,
|
||||||
|
append: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (fetchNutritionRecords.fulfilled.match(response)) {
|
||||||
|
const { records } = response.payload;
|
||||||
|
setAllRecords(records);
|
||||||
|
setHasMoreData(records.length === 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('刷新数据失败:', error);
|
||||||
|
} finally {
|
||||||
|
setRefreshing(false);
|
||||||
|
}
|
||||||
|
}, [viewMode, currentSelectedDateString, dispatch]);
|
||||||
|
|
||||||
|
// 计算营养目标
|
||||||
|
const calculateNutritionGoals = () => {
|
||||||
|
const weight = parseFloat(userProfile?.weight || '70'); // 默认70kg
|
||||||
|
const height = parseFloat(userProfile?.height || '170'); // 默认170cm
|
||||||
|
const age = userProfile?.birthDate ?
|
||||||
|
dayjs().diff(dayjs(userProfile.birthDate), 'year') : 25; // 默认25岁
|
||||||
|
const isWoman = userProfile?.gender === 'female';
|
||||||
|
|
||||||
|
// 基础代谢率计算(Mifflin-St Jeor Equation)
|
||||||
|
let bmr;
|
||||||
|
if (isWoman) {
|
||||||
|
bmr = 10 * weight + 6.25 * height - 5 * age - 161;
|
||||||
|
} else {
|
||||||
|
bmr = 10 * weight + 6.25 * height - 5 * age + 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 总热量需求(假设轻度活动)
|
||||||
|
const totalCalories = bmr * 1.375;
|
||||||
|
|
||||||
|
// 计算营养素目标
|
||||||
|
const proteinGoal = weight * 1.6; // 1.6g/kg
|
||||||
|
const fatGoal = totalCalories * 0.25 / 9; // 25%来自脂肪,9卡/克
|
||||||
|
const carbsGoal = (totalCalories - proteinGoal * 4 - fatGoal * 9) / 4; // 剩余来自碳水
|
||||||
|
|
||||||
|
return {
|
||||||
|
proteinGoal: Math.round(proteinGoal * 10) / 10,
|
||||||
|
fatGoal: Math.round(fatGoal * 10) / 10,
|
||||||
|
carbsGoal: Math.round(carbsGoal * 10) / 10,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const nutritionGoals = calculateNutritionGoals();
|
||||||
|
|
||||||
|
const loadMoreRecords = useCallback(async () => {
|
||||||
|
if (hasMoreData && !loading && !refreshing && viewMode === 'all') {
|
||||||
|
try {
|
||||||
|
const nextPage = page + 1;
|
||||||
|
const response = await dispatch(fetchNutritionRecords({
|
||||||
|
page: nextPage,
|
||||||
|
limit: 10,
|
||||||
|
append: true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (fetchNutritionRecords.fulfilled.match(response)) {
|
||||||
|
const { records } = response.payload;
|
||||||
|
setAllRecords(prev => [...prev, ...records]);
|
||||||
|
setHasMoreData(records.length === 10);
|
||||||
|
setPage(nextPage);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载更多记录失败:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [hasMoreData, loading, refreshing, viewMode, page, dispatch]);
|
||||||
|
|
||||||
|
// 删除记录
|
||||||
|
const handleDeleteRecord = async (recordId: number) => {
|
||||||
|
try {
|
||||||
|
if (viewMode === 'daily') {
|
||||||
|
// 按天查看模式,使用 Redux 删除
|
||||||
|
await dispatch(deleteNutritionRecord({
|
||||||
|
recordId,
|
||||||
|
dateKey: currentSelectedDateString
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
// 全部记录模式,从本地状态中移除
|
||||||
|
await dispatch(deleteNutritionRecord({
|
||||||
|
recordId,
|
||||||
|
dateKey: currentSelectedDateString
|
||||||
|
}));
|
||||||
|
setAllRecords(prev => prev.filter(record => record.id !== recordId));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('删除营养记录失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理营养记录卡片点击
|
||||||
|
const handleRecordPress = (record: DietRecord) => {
|
||||||
|
// 将 DietRecord 转换为 FoodRecognitionResponse 格式
|
||||||
|
const recognitionResult: FoodRecognitionResponse = {
|
||||||
|
items: [{
|
||||||
|
id: record.id.toString(),
|
||||||
|
label: record.foodName,
|
||||||
|
foodName: record.foodName,
|
||||||
|
portion: record.portionDescription || `${record.estimatedCalories || 0}g`,
|
||||||
|
calories: record.estimatedCalories || 0,
|
||||||
|
mealType: record.mealType,
|
||||||
|
nutritionData: {
|
||||||
|
proteinGrams: record.proteinGrams || 0,
|
||||||
|
carbohydrateGrams: record.carbohydrateGrams || 0,
|
||||||
|
fatGrams: record.fatGrams || 0,
|
||||||
|
fiberGrams: 0, // DietRecord 中没有纤维数据,设为0
|
||||||
|
}
|
||||||
|
}],
|
||||||
|
analysisText: record.foodDescription || `${record.foodName} - ${record.portionDescription}`,
|
||||||
|
confidence: 95, // 设置一个默认置信度
|
||||||
|
isFoodDetected: true,
|
||||||
|
nonFoodMessage: undefined
|
||||||
|
};
|
||||||
|
|
||||||
|
// 生成唯一的识别ID
|
||||||
|
const recognitionId = `record-${record.id}-${Date.now()}`;
|
||||||
|
|
||||||
|
// 保存到 Redux
|
||||||
|
dispatch(saveRecognitionResult({
|
||||||
|
id: recognitionId,
|
||||||
|
result: recognitionResult
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 跳转到分析结果页面
|
||||||
|
router.push({
|
||||||
|
pathname: '/food/analysis-result',
|
||||||
|
params: {
|
||||||
|
imageUri: record.imageUrl || '',
|
||||||
|
mealType: record.mealType,
|
||||||
|
recognitionId: recognitionId,
|
||||||
|
hideRecordBar: 'true'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// 渲染日期选择器(仅在按天查看模式下显示)
|
||||||
|
const renderDateSelector = () => {
|
||||||
|
if (viewMode !== 'daily') return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DateSelector
|
||||||
|
selectedIndex={selectedIndex}
|
||||||
|
onDateSelect={(index, date) => setSelectedIndex(index)}
|
||||||
|
showMonthTitle={true}
|
||||||
|
disableFutureDates={true}
|
||||||
|
showCalendarIcon={true}
|
||||||
|
containerStyle={{
|
||||||
|
paddingHorizontal: 16
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderEmptyState = () => (
|
||||||
|
<View style={styles.emptyContainer}>
|
||||||
|
<View style={styles.emptyContent}>
|
||||||
|
<Ionicons name="restaurant-outline" size={48} color={colorTokens.textSecondary} />
|
||||||
|
<Text style={[styles.emptyTitle, { color: colorTokens.text }]}>
|
||||||
|
{viewMode === 'daily' ? '今天还没有记录' : '暂无营养记录'}
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.emptySubtitle, { color: colorTokens.textSecondary }]}>
|
||||||
|
{viewMode === 'daily' ? '开始记录今日营养摄入' : '开始记录你的营养摄入吧'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderRecord = ({ item, index }: { item: DietRecord; index: number }) => (
|
||||||
|
<NutritionRecordCard
|
||||||
|
record={item}
|
||||||
|
onPress={() => handleRecordPress(item)}
|
||||||
|
onDelete={() => handleDeleteRecord(item.id)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderFooter = () => {
|
||||||
|
if (!hasMoreData) {
|
||||||
|
return (
|
||||||
|
<View style={styles.footerContainer}>
|
||||||
|
<Text style={[styles.footerText, { color: colorTokens.textSecondary }]}>
|
||||||
|
没有更多数据了
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (viewMode === 'all' && displayRecords.length > 0) {
|
||||||
|
return (
|
||||||
|
<TouchableOpacity style={styles.loadMoreButton} onPress={loadMoreRecords}>
|
||||||
|
<Text style={[styles.loadMoreText, { color: colorTokens.primary }]}>
|
||||||
|
加载更多
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 根据当前时间智能判断餐次类型
|
||||||
|
const getCurrentMealType = (): 'breakfast' | 'lunch' | 'dinner' | 'snack' => {
|
||||||
|
const hour = new Date().getHours();
|
||||||
|
|
||||||
|
if (hour >= 5 && hour < 11) {
|
||||||
|
return 'breakfast'; // 5:00-10:59 早餐
|
||||||
|
} else if (hour >= 11 && hour < 14) {
|
||||||
|
return 'lunch'; // 11:00-13:59 午餐
|
||||||
|
} else if (hour >= 17 && hour < 21) {
|
||||||
|
return 'dinner'; // 17:00-20:59 晚餐
|
||||||
|
} else {
|
||||||
|
return 'snack'; // 其他时间默认为零食
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 添加食物的处理函数
|
||||||
|
const handleAddFood = () => {
|
||||||
|
setShowFoodOverlay(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 渲染右侧添加按钮
|
||||||
|
const renderRightButton = () => (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.addButton, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}
|
||||||
|
onPress={handleAddFood}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Ionicons name="add" size={20} color={colorTokens.primary} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.container, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}>
|
||||||
|
<HeaderBar
|
||||||
|
title="营养记录"
|
||||||
|
onBack={() => router.back()}
|
||||||
|
right={renderRightButton()}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* {renderViewModeToggle()} */}
|
||||||
|
{renderDateSelector()}
|
||||||
|
|
||||||
|
{/* Calorie Ring Chart */}
|
||||||
|
<CalorieRingChart
|
||||||
|
metabolism={basalMetabolism}
|
||||||
|
exercise={healthData?.activeEnergyBurned || 0}
|
||||||
|
consumed={nutritionSummary?.totalCalories || 0}
|
||||||
|
protein={nutritionSummary?.totalProtein || 0}
|
||||||
|
fat={nutritionSummary?.totalFat || 0}
|
||||||
|
carbs={nutritionSummary?.totalCarbohydrate || 0}
|
||||||
|
proteinGoal={nutritionGoals.proteinGoal}
|
||||||
|
fatGoal={nutritionGoals.fatGoal}
|
||||||
|
carbsGoal={nutritionGoals.carbsGoal}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{(
|
||||||
|
<FlatList
|
||||||
|
data={displayRecords}
|
||||||
|
renderItem={({ item, index }) => renderRecord({ item, index })}
|
||||||
|
keyExtractor={(item) => item.id.toString()}
|
||||||
|
contentContainerStyle={[
|
||||||
|
styles.listContainer,
|
||||||
|
{ paddingBottom: 40, paddingTop: 16 }
|
||||||
|
]}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={refreshing}
|
||||||
|
onRefresh={onRefresh}
|
||||||
|
tintColor={colorTokens.primary}
|
||||||
|
colors={[colorTokens.primary]}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
ListEmptyComponent={renderEmptyState}
|
||||||
|
ListFooterComponent={renderFooter}
|
||||||
|
onEndReached={viewMode === 'all' ? loadMoreRecords : undefined}
|
||||||
|
onEndReachedThreshold={0.1}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 食物添加悬浮窗 */}
|
||||||
|
<FloatingFoodOverlay
|
||||||
|
visible={showFoodOverlay}
|
||||||
|
onClose={() => setShowFoodOverlay(false)}
|
||||||
|
mealType={getCurrentMealType()}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
viewModeContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 12,
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
monthTitle: {
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: '800',
|
||||||
|
},
|
||||||
|
toggleContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
borderRadius: 20,
|
||||||
|
padding: 2,
|
||||||
|
},
|
||||||
|
toggleButton: {
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 8,
|
||||||
|
borderRadius: 18,
|
||||||
|
minWidth: 80,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
toggleText: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
daysContainer: {
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
daysScrollContainer: {
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 8,
|
||||||
|
},
|
||||||
|
dayPill: {
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
borderRadius: 34,
|
||||||
|
marginRight: 12,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.08,
|
||||||
|
shadowRadius: 4,
|
||||||
|
elevation: 3,
|
||||||
|
},
|
||||||
|
dayNumber: {
|
||||||
|
fontSize: 18,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
dayLabel: {
|
||||||
|
fontSize: 12,
|
||||||
|
marginTop: 2,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
addButton: {
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: 16,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: {
|
||||||
|
width: 0,
|
||||||
|
height: 2,
|
||||||
|
},
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 4,
|
||||||
|
elevation: 2,
|
||||||
|
},
|
||||||
|
loadingContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
loadingText: {
|
||||||
|
marginTop: 12,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
listContainer: {
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingTop: 8,
|
||||||
|
},
|
||||||
|
emptyContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: 60,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
},
|
||||||
|
emptyContent: {
|
||||||
|
alignItems: 'center',
|
||||||
|
maxWidth: 320,
|
||||||
|
},
|
||||||
|
emptyTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '700',
|
||||||
|
marginTop: 16,
|
||||||
|
marginBottom: 8,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
emptySubtitle: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '500',
|
||||||
|
textAlign: 'center',
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
footerContainer: {
|
||||||
|
paddingVertical: 20,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
footerText: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
loadMoreButton: {
|
||||||
|
paddingVertical: 16,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
loadMoreText: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import { Stack } from 'expo-router';
|
|
||||||
|
|
||||||
export default function OnboardingLayout() {
|
|
||||||
return (
|
|
||||||
<Stack>
|
|
||||||
<Stack.Screen name="index" options={{ headerShown: false }} />
|
|
||||||
<Stack.Screen name="personal-info" options={{ headerShown: false }} />
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,233 +0,0 @@
|
|||||||
import { ThemedText } from '@/components/ThemedText';
|
|
||||||
import { ThemedView } from '@/components/ThemedView';
|
|
||||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
|
||||||
import { useThemeColor } from '@/hooks/useThemeColor';
|
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
||||||
import { router } from 'expo-router';
|
|
||||||
import React from 'react';
|
|
||||||
import {
|
|
||||||
Dimensions,
|
|
||||||
StatusBar,
|
|
||||||
StyleSheet,
|
|
||||||
Text,
|
|
||||||
TouchableOpacity,
|
|
||||||
View
|
|
||||||
} from 'react-native';
|
|
||||||
|
|
||||||
const { width, height } = Dimensions.get('window');
|
|
||||||
|
|
||||||
export default function WelcomeScreen() {
|
|
||||||
const colorScheme = useColorScheme();
|
|
||||||
const backgroundColor = useThemeColor({}, 'background');
|
|
||||||
const primaryColor = useThemeColor({}, 'primary');
|
|
||||||
const textColor = useThemeColor({}, 'text');
|
|
||||||
|
|
||||||
const handleGetStarted = () => {
|
|
||||||
router.push('/onboarding/personal-info');
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSkip = async () => {
|
|
||||||
try {
|
|
||||||
await AsyncStorage.setItem('@onboarding_completed', 'true');
|
|
||||||
router.replace('/(tabs)');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('保存引导状态失败:', error);
|
|
||||||
router.replace('/(tabs)');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ThemedView style={[styles.container, { backgroundColor }]}>
|
|
||||||
<StatusBar
|
|
||||||
barStyle={colorScheme === 'dark' ? 'light-content' : 'dark-content'}
|
|
||||||
backgroundColor={backgroundColor}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 跳过按钮 */}
|
|
||||||
<TouchableOpacity style={styles.skipButton} onPress={handleSkip}>
|
|
||||||
<ThemedText style={[styles.skipText, { color: textColor }]}>
|
|
||||||
跳过
|
|
||||||
</ThemedText>
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
{/* 主要内容区域 */}
|
|
||||||
<View style={styles.contentContainer}>
|
|
||||||
{/* Logo 或插图区域 */}
|
|
||||||
<View style={styles.imageContainer}>
|
|
||||||
<View style={[styles.logoPlaceholder, { backgroundColor: primaryColor }]}>
|
|
||||||
<Text style={styles.logoText}>🧘♀️</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 标题和描述 */}
|
|
||||||
<View style={styles.textContainer}>
|
|
||||||
<ThemedText type="title" style={styles.title}>
|
|
||||||
欢迎来到数字普拉提
|
|
||||||
</ThemedText>
|
|
||||||
<ThemedText style={[styles.subtitle, { color: textColor + '90' }]}>
|
|
||||||
让我们一起开始您的健康之旅{'\n'}
|
|
||||||
个性化的普拉提体验正等着您
|
|
||||||
</ThemedText>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 特色功能点 */}
|
|
||||||
<View style={styles.featuresContainer}>
|
|
||||||
{[
|
|
||||||
{ icon: '📊', title: '个性化训练', desc: '根据您的身体状况定制训练计划' },
|
|
||||||
{ icon: '🤖', title: 'AI 姿态分析', desc: '实时纠正您的动作姿态' },
|
|
||||||
{ icon: '📈', title: '进度追踪', desc: '记录您的每一次进步' },
|
|
||||||
].map((feature, index) => (
|
|
||||||
<View key={index} style={styles.featureItem}>
|
|
||||||
<Text style={styles.featureIcon}>{feature.icon}</Text>
|
|
||||||
<View style={styles.featureTextContainer}>
|
|
||||||
<ThemedText style={[styles.featureTitle, { color: textColor }]}>
|
|
||||||
{feature.title}
|
|
||||||
</ThemedText>
|
|
||||||
<ThemedText style={[styles.featureDesc, { color: textColor + '70' }]}>
|
|
||||||
{feature.desc}
|
|
||||||
</ThemedText>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 底部按钮 */}
|
|
||||||
<View style={styles.buttonContainer}>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={[styles.getStartedButton, { backgroundColor: primaryColor }]}
|
|
||||||
onPress={handleGetStarted}
|
|
||||||
activeOpacity={0.8}
|
|
||||||
>
|
|
||||||
<Text style={styles.getStartedButtonText}>开始体验</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
<TouchableOpacity style={styles.laterButton} onPress={handleSkip}>
|
|
||||||
<ThemedText style={[styles.laterButtonText, { color: textColor + '70' }]}>
|
|
||||||
稍后再说
|
|
||||||
</ThemedText>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</ThemedView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
flex: 1,
|
|
||||||
paddingTop: StatusBar.currentHeight || 44,
|
|
||||||
},
|
|
||||||
skipButton: {
|
|
||||||
position: 'absolute',
|
|
||||||
top: StatusBar.currentHeight ? StatusBar.currentHeight + 16 : 60,
|
|
||||||
right: 20,
|
|
||||||
zIndex: 10,
|
|
||||||
padding: 8,
|
|
||||||
},
|
|
||||||
skipText: {
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: '500',
|
|
||||||
},
|
|
||||||
contentContainer: {
|
|
||||||
flex: 1,
|
|
||||||
paddingHorizontal: 24,
|
|
||||||
justifyContent: 'center',
|
|
||||||
},
|
|
||||||
imageContainer: {
|
|
||||||
alignItems: 'center',
|
|
||||||
marginBottom: 40,
|
|
||||||
},
|
|
||||||
logoPlaceholder: {
|
|
||||||
width: 120,
|
|
||||||
height: 120,
|
|
||||||
borderRadius: 60,
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
shadowColor: '#000',
|
|
||||||
shadowOffset: {
|
|
||||||
width: 0,
|
|
||||||
height: 4,
|
|
||||||
},
|
|
||||||
shadowOpacity: 0.1,
|
|
||||||
shadowRadius: 8,
|
|
||||||
elevation: 8,
|
|
||||||
},
|
|
||||||
logoText: {
|
|
||||||
fontSize: 48,
|
|
||||||
},
|
|
||||||
textContainer: {
|
|
||||||
alignItems: 'center',
|
|
||||||
marginBottom: 48,
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
textAlign: 'center',
|
|
||||||
marginBottom: 16,
|
|
||||||
fontWeight: '700',
|
|
||||||
},
|
|
||||||
subtitle: {
|
|
||||||
fontSize: 16,
|
|
||||||
textAlign: 'center',
|
|
||||||
lineHeight: 24,
|
|
||||||
paddingHorizontal: 12,
|
|
||||||
},
|
|
||||||
featuresContainer: {
|
|
||||||
marginBottom: 40,
|
|
||||||
},
|
|
||||||
featureItem: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
marginBottom: 24,
|
|
||||||
paddingHorizontal: 8,
|
|
||||||
},
|
|
||||||
featureIcon: {
|
|
||||||
fontSize: 32,
|
|
||||||
marginRight: 16,
|
|
||||||
width: 40,
|
|
||||||
textAlign: 'center',
|
|
||||||
},
|
|
||||||
featureTextContainer: {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
featureTitle: {
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: '600',
|
|
||||||
marginBottom: 4,
|
|
||||||
},
|
|
||||||
featureDesc: {
|
|
||||||
fontSize: 14,
|
|
||||||
lineHeight: 20,
|
|
||||||
},
|
|
||||||
buttonContainer: {
|
|
||||||
paddingHorizontal: 24,
|
|
||||||
paddingBottom: 48,
|
|
||||||
},
|
|
||||||
getStartedButton: {
|
|
||||||
height: 56,
|
|
||||||
borderRadius: 16,
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
marginBottom: 16,
|
|
||||||
shadowColor: '#000',
|
|
||||||
shadowOffset: {
|
|
||||||
width: 0,
|
|
||||||
height: 2,
|
|
||||||
},
|
|
||||||
shadowOpacity: 0.1,
|
|
||||||
shadowRadius: 4,
|
|
||||||
elevation: 4,
|
|
||||||
},
|
|
||||||
getStartedButtonText: {
|
|
||||||
color: '#192126',
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: '600',
|
|
||||||
},
|
|
||||||
laterButton: {
|
|
||||||
height: 48,
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
laterButtonText: {
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: '500',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,426 +0,0 @@
|
|||||||
import { ThemedText } from '@/components/ThemedText';
|
|
||||||
import { ThemedView } from '@/components/ThemedView';
|
|
||||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
|
||||||
import { useThemeColor } from '@/hooks/useThemeColor';
|
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
||||||
import { router } from 'expo-router';
|
|
||||||
import React, { useState } from 'react';
|
|
||||||
import {
|
|
||||||
Alert,
|
|
||||||
Dimensions,
|
|
||||||
ScrollView,
|
|
||||||
StatusBar,
|
|
||||||
StyleSheet,
|
|
||||||
Text,
|
|
||||||
TextInput,
|
|
||||||
TouchableOpacity,
|
|
||||||
View,
|
|
||||||
} from 'react-native';
|
|
||||||
|
|
||||||
const { width } = Dimensions.get('window');
|
|
||||||
|
|
||||||
interface PersonalInfo {
|
|
||||||
gender: 'male' | 'female' | '';
|
|
||||||
age: string;
|
|
||||||
height: string;
|
|
||||||
weight: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function PersonalInfoScreen() {
|
|
||||||
const colorScheme = useColorScheme();
|
|
||||||
const backgroundColor = useThemeColor({}, 'background');
|
|
||||||
const primaryColor = useThemeColor({}, 'primary');
|
|
||||||
const textColor = useThemeColor({}, 'text');
|
|
||||||
const iconColor = useThemeColor({}, 'icon');
|
|
||||||
|
|
||||||
const [personalInfo, setPersonalInfo] = useState<PersonalInfo>({
|
|
||||||
gender: '',
|
|
||||||
age: '',
|
|
||||||
height: '',
|
|
||||||
weight: '',
|
|
||||||
});
|
|
||||||
|
|
||||||
const [currentStep, setCurrentStep] = useState(0);
|
|
||||||
|
|
||||||
const steps = [
|
|
||||||
{
|
|
||||||
title: '请选择您的性别',
|
|
||||||
subtitle: '这将帮助我们为您制定更合适的训练计划',
|
|
||||||
type: 'gender' as const,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '请输入您的年龄',
|
|
||||||
subtitle: '年龄信息有助于调整训练强度',
|
|
||||||
type: 'age' as const,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '请输入您的身高',
|
|
||||||
subtitle: '身高信息用于计算身体比例',
|
|
||||||
type: 'height' as const,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '请输入您的体重',
|
|
||||||
subtitle: '体重信息用于个性化训练方案',
|
|
||||||
type: 'weight' as const,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const handleGenderSelect = (gender: 'male' | 'female') => {
|
|
||||||
setPersonalInfo(prev => ({ ...prev, gender }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleInputChange = (field: keyof PersonalInfo, value: string) => {
|
|
||||||
setPersonalInfo(prev => ({ ...prev, [field]: value }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleNext = () => {
|
|
||||||
const currentStepType = steps[currentStep].type;
|
|
||||||
|
|
||||||
// 验证当前步骤是否已填写
|
|
||||||
if (currentStepType === 'gender' && !personalInfo.gender) {
|
|
||||||
Alert.alert('提示', '请选择您的性别');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (currentStepType === 'age' && !personalInfo.age) {
|
|
||||||
Alert.alert('提示', '请输入您的年龄');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (currentStepType === 'height' && !personalInfo.height) {
|
|
||||||
Alert.alert('提示', '请输入您的身高');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (currentStepType === 'weight' && !personalInfo.weight) {
|
|
||||||
Alert.alert('提示', '请输入您的体重');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentStep < steps.length - 1) {
|
|
||||||
setCurrentStep(currentStep + 1);
|
|
||||||
} else {
|
|
||||||
handleComplete();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePrevious = () => {
|
|
||||||
if (currentStep > 0) {
|
|
||||||
setCurrentStep(currentStep - 1);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSkip = async () => {
|
|
||||||
try {
|
|
||||||
await AsyncStorage.setItem('@onboarding_completed', 'true');
|
|
||||||
router.replace('/(tabs)');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('保存引导状态失败:', error);
|
|
||||||
router.replace('/(tabs)');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleComplete = async () => {
|
|
||||||
try {
|
|
||||||
// 保存用户信息和引导完成状态
|
|
||||||
await AsyncStorage.multiSet([
|
|
||||||
['@onboarding_completed', 'true'],
|
|
||||||
['@user_personal_info', JSON.stringify(personalInfo)],
|
|
||||||
]);
|
|
||||||
console.log('用户信息:', personalInfo);
|
|
||||||
router.replace('/(tabs)');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('保存用户信息失败:', error);
|
|
||||||
router.replace('/(tabs)');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderGenderSelection = () => (
|
|
||||||
<View style={styles.optionsContainer}>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={[
|
|
||||||
styles.genderOption,
|
|
||||||
{ borderColor: primaryColor },
|
|
||||||
personalInfo.gender === 'female' && { backgroundColor: primaryColor + '20', borderWidth: 2 }
|
|
||||||
]}
|
|
||||||
onPress={() => handleGenderSelect('female')}
|
|
||||||
>
|
|
||||||
<Text style={styles.genderIcon}>👩</Text>
|
|
||||||
<ThemedText style={[styles.genderText, { color: textColor }]}>女性</ThemedText>
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
<TouchableOpacity
|
|
||||||
style={[
|
|
||||||
styles.genderOption,
|
|
||||||
{ borderColor: primaryColor },
|
|
||||||
personalInfo.gender === 'male' && { backgroundColor: primaryColor + '20', borderWidth: 2 }
|
|
||||||
]}
|
|
||||||
onPress={() => handleGenderSelect('male')}
|
|
||||||
>
|
|
||||||
<Text style={styles.genderIcon}>👨</Text>
|
|
||||||
<ThemedText style={[styles.genderText, { color: textColor }]}>男性</ThemedText>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderNumberInput = (
|
|
||||||
field: 'age' | 'height' | 'weight',
|
|
||||||
placeholder: string,
|
|
||||||
unit: string
|
|
||||||
) => (
|
|
||||||
<View style={styles.inputContainer}>
|
|
||||||
<View style={[styles.inputWrapper, { borderColor: iconColor + '30' }]}>
|
|
||||||
<TextInput
|
|
||||||
style={[styles.numberInput, { color: textColor }]}
|
|
||||||
value={personalInfo[field]}
|
|
||||||
onChangeText={(value) => handleInputChange(field, value)}
|
|
||||||
placeholder={placeholder}
|
|
||||||
placeholderTextColor={iconColor}
|
|
||||||
keyboardType="numeric"
|
|
||||||
maxLength={field === 'age' ? 3 : 4}
|
|
||||||
/>
|
|
||||||
<ThemedText style={[styles.unitText, { color: iconColor }]}>{unit}</ThemedText>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderStepContent = () => {
|
|
||||||
const step = steps[currentStep];
|
|
||||||
switch (step.type) {
|
|
||||||
case 'gender':
|
|
||||||
return renderGenderSelection();
|
|
||||||
case 'age':
|
|
||||||
return renderNumberInput('age', '请输入年龄', '岁');
|
|
||||||
case 'height':
|
|
||||||
return renderNumberInput('height', '请输入身高', 'cm');
|
|
||||||
case 'weight':
|
|
||||||
return renderNumberInput('weight', '请输入体重', 'kg');
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const isStepCompleted = () => {
|
|
||||||
const currentStepType = steps[currentStep].type;
|
|
||||||
switch (currentStepType) {
|
|
||||||
case 'gender':
|
|
||||||
return !!personalInfo.gender;
|
|
||||||
case 'age':
|
|
||||||
return !!personalInfo.age;
|
|
||||||
case 'height':
|
|
||||||
return !!personalInfo.height;
|
|
||||||
case 'weight':
|
|
||||||
return !!personalInfo.weight;
|
|
||||||
default:
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ThemedView style={[styles.container, { backgroundColor }]}>
|
|
||||||
<StatusBar
|
|
||||||
barStyle={colorScheme === 'dark' ? 'light-content' : 'dark-content'}
|
|
||||||
backgroundColor={backgroundColor}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 顶部导航 */}
|
|
||||||
<View style={styles.header}>
|
|
||||||
{currentStep > 0 && (
|
|
||||||
<TouchableOpacity style={styles.backButton} onPress={handlePrevious}>
|
|
||||||
<ThemedText style={[styles.backText, { color: textColor }]}>‹ 返回</ThemedText>
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<TouchableOpacity style={styles.skipButton} onPress={handleSkip}>
|
|
||||||
<ThemedText style={[styles.skipText, { color: iconColor }]}>跳过</ThemedText>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 进度条 */}
|
|
||||||
<View style={styles.progressContainer}>
|
|
||||||
<View style={[styles.progressBackground, { backgroundColor: iconColor + '20' }]}>
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
styles.progressBar,
|
|
||||||
{
|
|
||||||
backgroundColor: primaryColor,
|
|
||||||
width: `${((currentStep + 1) / steps.length) * 100}%`
|
|
||||||
}
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<ThemedText style={[styles.progressText, { color: iconColor }]}>
|
|
||||||
{currentStep + 1} / {steps.length}
|
|
||||||
</ThemedText>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<ScrollView
|
|
||||||
style={styles.content}
|
|
||||||
contentContainerStyle={styles.contentContainer}
|
|
||||||
showsVerticalScrollIndicator={false}
|
|
||||||
>
|
|
||||||
{/* 标题区域 */}
|
|
||||||
<View style={styles.titleContainer}>
|
|
||||||
<ThemedText type="title" style={styles.title}>
|
|
||||||
{steps[currentStep].title}
|
|
||||||
</ThemedText>
|
|
||||||
<ThemedText style={[styles.subtitle, { color: textColor + '80' }]}>
|
|
||||||
{steps[currentStep].subtitle}
|
|
||||||
</ThemedText>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 内容区域 */}
|
|
||||||
{renderStepContent()}
|
|
||||||
</ScrollView>
|
|
||||||
|
|
||||||
{/* 底部按钮 */}
|
|
||||||
<View style={styles.buttonContainer}>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={[
|
|
||||||
styles.nextButton,
|
|
||||||
{ backgroundColor: isStepCompleted() ? primaryColor : iconColor + '30' }
|
|
||||||
]}
|
|
||||||
onPress={handleNext}
|
|
||||||
disabled={!isStepCompleted()}
|
|
||||||
activeOpacity={0.8}
|
|
||||||
>
|
|
||||||
<Text style={[
|
|
||||||
styles.nextButtonText,
|
|
||||||
{ color: isStepCompleted() ? '#192126' : iconColor }
|
|
||||||
]}>
|
|
||||||
{currentStep === steps.length - 1 ? '完成' : '下一步'}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</ThemedView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
flex: 1,
|
|
||||||
paddingTop: StatusBar.currentHeight || 44,
|
|
||||||
},
|
|
||||||
header: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
paddingHorizontal: 20,
|
|
||||||
paddingVertical: 16,
|
|
||||||
},
|
|
||||||
backButton: {
|
|
||||||
padding: 8,
|
|
||||||
},
|
|
||||||
backText: {
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: '500',
|
|
||||||
},
|
|
||||||
skipButton: {
|
|
||||||
padding: 8,
|
|
||||||
},
|
|
||||||
skipText: {
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: '500',
|
|
||||||
},
|
|
||||||
progressContainer: {
|
|
||||||
paddingHorizontal: 20,
|
|
||||||
marginBottom: 32,
|
|
||||||
},
|
|
||||||
progressBackground: {
|
|
||||||
height: 4,
|
|
||||||
borderRadius: 2,
|
|
||||||
marginBottom: 8,
|
|
||||||
},
|
|
||||||
progressBar: {
|
|
||||||
height: '100%',
|
|
||||||
borderRadius: 2,
|
|
||||||
},
|
|
||||||
progressText: {
|
|
||||||
fontSize: 12,
|
|
||||||
textAlign: 'right',
|
|
||||||
},
|
|
||||||
content: {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
contentContainer: {
|
|
||||||
paddingHorizontal: 24,
|
|
||||||
paddingBottom: 24,
|
|
||||||
},
|
|
||||||
titleContainer: {
|
|
||||||
marginBottom: 48,
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
textAlign: 'center',
|
|
||||||
marginBottom: 16,
|
|
||||||
fontWeight: '700',
|
|
||||||
},
|
|
||||||
subtitle: {
|
|
||||||
fontSize: 16,
|
|
||||||
textAlign: 'center',
|
|
||||||
lineHeight: 24,
|
|
||||||
},
|
|
||||||
optionsContainer: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
justifyContent: 'space-around',
|
|
||||||
paddingHorizontal: 20,
|
|
||||||
},
|
|
||||||
genderOption: {
|
|
||||||
width: width * 0.35,
|
|
||||||
height: 120,
|
|
||||||
borderRadius: 16,
|
|
||||||
borderWidth: 1,
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
padding: 16,
|
|
||||||
},
|
|
||||||
genderIcon: {
|
|
||||||
fontSize: 48,
|
|
||||||
marginBottom: 8,
|
|
||||||
},
|
|
||||||
genderText: {
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: '600',
|
|
||||||
},
|
|
||||||
inputContainer: {
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
inputWrapper: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
borderWidth: 1,
|
|
||||||
borderRadius: 12,
|
|
||||||
paddingHorizontal: 16,
|
|
||||||
width: width * 0.6,
|
|
||||||
height: 56,
|
|
||||||
},
|
|
||||||
numberInput: {
|
|
||||||
flex: 1,
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: '600',
|
|
||||||
textAlign: 'center',
|
|
||||||
},
|
|
||||||
unitText: {
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: '500',
|
|
||||||
marginLeft: 8,
|
|
||||||
},
|
|
||||||
buttonContainer: {
|
|
||||||
paddingHorizontal: 24,
|
|
||||||
paddingBottom: 48,
|
|
||||||
},
|
|
||||||
nextButton: {
|
|
||||||
height: 56,
|
|
||||||
borderRadius: 16,
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
shadowColor: '#000',
|
|
||||||
shadowOffset: {
|
|
||||||
width: 0,
|
|
||||||
height: 2,
|
|
||||||
},
|
|
||||||
shadowOpacity: 0.1,
|
|
||||||
shadowRadius: 4,
|
|
||||||
elevation: 4,
|
|
||||||
},
|
|
||||||
nextButtonText: {
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: '600',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
939
app/profile/edit.tsx
Normal file
@@ -0,0 +1,939 @@
|
|||||||
|
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||||
|
import { Colors } from '@/constants/Colors';
|
||||||
|
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||||
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
|
import { useCosUpload } from '@/hooks/useCosUpload';
|
||||||
|
import { fetchMyProfile, updateUserProfile } from '@/store/userSlice';
|
||||||
|
import { fetchMaximumHeartRate } from '@/utils/health';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import AsyncStorage from '@/utils/kvStore';
|
||||||
|
import DateTimePicker from '@react-native-community/datetimepicker';
|
||||||
|
import { Picker } from '@react-native-picker/picker';
|
||||||
|
import { useFocusEffect } from '@react-navigation/native';
|
||||||
|
import { Image } from 'expo-image';
|
||||||
|
import * as ImagePicker from 'expo-image-picker';
|
||||||
|
import { router } from 'expo-router';
|
||||||
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Alert,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Modal,
|
||||||
|
Platform,
|
||||||
|
Pressable,
|
||||||
|
SafeAreaView,
|
||||||
|
ScrollView,
|
||||||
|
StatusBar,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
|
||||||
|
|
||||||
|
interface UserProfile {
|
||||||
|
name?: string;
|
||||||
|
gender?: 'male' | 'female' | '';
|
||||||
|
birthDate?: string; // 出生日期
|
||||||
|
// 以公制为基准存储
|
||||||
|
weight?: number; // kg
|
||||||
|
height?: number; // cm
|
||||||
|
avatarUri?: string | null;
|
||||||
|
activityLevel?: number; // 活动水平 1-4
|
||||||
|
maxHeartRate?: number; // 最大心率
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORAGE_KEY = '@user_profile';
|
||||||
|
|
||||||
|
export default function EditProfileScreen() {
|
||||||
|
const colorScheme = useColorScheme();
|
||||||
|
const colors = Colors[colorScheme ?? 'light'];
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const accountProfile = useAppSelector((s) => (s as any)?.user?.profile as any);
|
||||||
|
const userId: string | undefined = useMemo(() => {
|
||||||
|
return (
|
||||||
|
accountProfile?.userId ||
|
||||||
|
accountProfile?.id ||
|
||||||
|
accountProfile?._id ||
|
||||||
|
accountProfile?.uid ||
|
||||||
|
undefined
|
||||||
|
) as string | undefined;
|
||||||
|
}, [accountProfile]);
|
||||||
|
|
||||||
|
const [profile, setProfile] = useState<UserProfile>({
|
||||||
|
name: '',
|
||||||
|
gender: '',
|
||||||
|
birthDate: '',
|
||||||
|
weight: undefined,
|
||||||
|
height: undefined,
|
||||||
|
avatarUri: null,
|
||||||
|
activityLevel: undefined,
|
||||||
|
maxHeartRate: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 出生日期选择器
|
||||||
|
const [datePickerVisible, setDatePickerVisible] = useState(false);
|
||||||
|
const [pickerDate, setPickerDate] = useState<Date>(new Date());
|
||||||
|
const [editingField, setEditingField] = useState<string | null>(null);
|
||||||
|
const [tempValue, setTempValue] = useState<string>('');
|
||||||
|
|
||||||
|
// 输入框字符串
|
||||||
|
|
||||||
|
// 从本地存储加载(身高/体重等本地字段)
|
||||||
|
const loadLocalProfile = async () => {
|
||||||
|
try {
|
||||||
|
const [p, fromOnboarding] = await Promise.all([
|
||||||
|
AsyncStorage.getItem(STORAGE_KEY),
|
||||||
|
AsyncStorage.getItem('@user_personal_info'),
|
||||||
|
]);
|
||||||
|
let next: UserProfile = {
|
||||||
|
name: '',
|
||||||
|
gender: '',
|
||||||
|
birthDate: '',
|
||||||
|
weight: undefined,
|
||||||
|
height: undefined,
|
||||||
|
avatarUri: null,
|
||||||
|
activityLevel: undefined,
|
||||||
|
maxHeartRate: undefined,
|
||||||
|
};
|
||||||
|
if (fromOnboarding) {
|
||||||
|
try {
|
||||||
|
const o = JSON.parse(fromOnboarding);
|
||||||
|
|
||||||
|
if (o?.weight) next.weight = parseFloat(o.weight) || undefined;
|
||||||
|
if (o?.height) next.height = parseFloat(o.height) || undefined;
|
||||||
|
if (o?.birthDate) next.birthDate = o.birthDate;
|
||||||
|
if (o?.gender) next.gender = o.gender;
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
if (p) {
|
||||||
|
try {
|
||||||
|
const parsed: UserProfile = JSON.parse(p);
|
||||||
|
next = { ...next, ...parsed };
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
console.log('loadLocalProfile', next);
|
||||||
|
setProfile((prev) => ({ ...next, avatarUri: prev.avatarUri ?? next.avatarUri ?? null }));
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('读取资料失败', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadLocalProfile();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 获取最大心率数据
|
||||||
|
useEffect(() => {
|
||||||
|
const loadMaximumHeartRate = async () => {
|
||||||
|
try {
|
||||||
|
const today = new Date();
|
||||||
|
const startDate = new Date(today.getTime() - 7 * 24 * 60 * 60 * 1000); // 过去7天
|
||||||
|
|
||||||
|
const maxHeartRate = await fetchMaximumHeartRate({
|
||||||
|
startDate: startDate.toISOString(),
|
||||||
|
endDate: today.toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (maxHeartRate !== null) {
|
||||||
|
setProfile(prev => ({ ...prev, maxHeartRate }));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('获取最大心率失败', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadMaximumHeartRate();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 页面聚焦时拉取最新用户信息,并刷新本地 UI
|
||||||
|
useFocusEffect(
|
||||||
|
React.useCallback(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
await dispatch(fetchMyProfile() as any);
|
||||||
|
if (!cancelled) {
|
||||||
|
// 拉取完成后,再次从本地存储同步身高/体重等字段
|
||||||
|
await loadLocalProfile();
|
||||||
|
}
|
||||||
|
} catch { }
|
||||||
|
})();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [dispatch])
|
||||||
|
);
|
||||||
|
|
||||||
|
// 当全局 profile 更新时,用后端字段覆盖页面 UI 的对应字段(不影响本地身高/体重)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!accountProfile) return;
|
||||||
|
setProfile((prev) => ({
|
||||||
|
...prev,
|
||||||
|
name: accountProfile?.name ?? prev.name ?? '',
|
||||||
|
gender: (accountProfile?.gender === 'male' || accountProfile?.gender === 'female') ? accountProfile.gender : (prev.gender ?? ''),
|
||||||
|
avatarUri: accountProfile?.avatar && typeof accountProfile.avatar === 'string'
|
||||||
|
? (accountProfile.avatar.startsWith('http') || accountProfile.avatar.startsWith('data:') ? accountProfile.avatar : prev.avatarUri)
|
||||||
|
: prev.avatarUri,
|
||||||
|
weight: accountProfile?.weight ?? prev.weight ?? undefined,
|
||||||
|
height: accountProfile?.height ?? prev.height ?? undefined,
|
||||||
|
activityLevel: accountProfile?.activityLevel ?? prev.activityLevel ?? undefined,
|
||||||
|
// maxHeartRate 不从后端获取,保持本地状态
|
||||||
|
maxHeartRate: prev.maxHeartRate,
|
||||||
|
}));
|
||||||
|
}, [accountProfile]);
|
||||||
|
|
||||||
|
const textColor = colors.text;
|
||||||
|
const placeholderColor = colors.icon;
|
||||||
|
|
||||||
|
const handleSaveWithProfile = async (profileData: UserProfile) => {
|
||||||
|
try {
|
||||||
|
if (!userId) {
|
||||||
|
Alert.alert('未登录', '请先登录后再尝试保存');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const next: UserProfile = { ...profileData };
|
||||||
|
|
||||||
|
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
||||||
|
|
||||||
|
// 同步到后端(仅更新后端需要的字段)
|
||||||
|
try {
|
||||||
|
await dispatch(updateUserProfile({
|
||||||
|
name: next.name || undefined,
|
||||||
|
gender: (next.gender === 'male' || next.gender === 'female') ? next.gender : undefined,
|
||||||
|
// 头像采用已上传的 URL(若有)
|
||||||
|
avatar: next.avatarUri || undefined,
|
||||||
|
weight: next.weight || undefined,
|
||||||
|
height: next.height || undefined,
|
||||||
|
birthDate: next.birthDate ? new Date(next.birthDate).getTime() / 1000 : undefined,
|
||||||
|
activityLevel: next.activityLevel || undefined,
|
||||||
|
}));
|
||||||
|
// 拉取最新用户信息,刷新全局状态
|
||||||
|
await dispatch(fetchMyProfile() as any);
|
||||||
|
} catch (e: any) {
|
||||||
|
// 接口失败不阻断本地保存
|
||||||
|
console.warn('更新用户信息失败', e?.message || e);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
Alert.alert('保存失败', '请稍后重试');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const { upload, uploading } = useCosUpload();
|
||||||
|
|
||||||
|
// 出生日期选择器交互
|
||||||
|
const openDatePicker = () => {
|
||||||
|
const base = profile.birthDate ? new Date(profile.birthDate) : new Date();
|
||||||
|
base.setHours(0, 0, 0, 0);
|
||||||
|
setPickerDate(base);
|
||||||
|
setDatePickerVisible(true);
|
||||||
|
};
|
||||||
|
const closeDatePicker = () => setDatePickerVisible(false);
|
||||||
|
const onConfirmDate = async (date: Date) => {
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
const picked = new Date(date);
|
||||||
|
picked.setHours(0, 0, 0, 0);
|
||||||
|
const finalDate = picked > today ? today : picked;
|
||||||
|
|
||||||
|
const updatedProfile = { ...profile, birthDate: finalDate.toISOString() };
|
||||||
|
setProfile((p) => ({ ...p, birthDate: finalDate.toISOString() }));
|
||||||
|
closeDatePicker();
|
||||||
|
|
||||||
|
// 保存到后端
|
||||||
|
await handleSaveWithProfile(updatedProfile);
|
||||||
|
};
|
||||||
|
|
||||||
|
const pickAvatarFromLibrary = async () => {
|
||||||
|
try {
|
||||||
|
const resp = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||||
|
const libGranted = resp.status === 'granted' || (resp as any).accessPrivileges === 'limited';
|
||||||
|
if (!libGranted) {
|
||||||
|
Alert.alert('权限不足', '需要相册权限以选择头像');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await ImagePicker.launchImageLibraryAsync({
|
||||||
|
allowsEditing: true,
|
||||||
|
quality: 0.9,
|
||||||
|
aspect: [1, 1],
|
||||||
|
mediaTypes: ['images'],
|
||||||
|
base64: false,
|
||||||
|
});
|
||||||
|
if (!result.canceled) {
|
||||||
|
const asset = result.assets?.[0];
|
||||||
|
if (!asset?.uri) return;
|
||||||
|
// 直接上传到 COS,成功后写入 URL
|
||||||
|
try {
|
||||||
|
const { url } = await upload(
|
||||||
|
{ uri: asset.uri, name: asset.fileName || 'avatar.jpg', type: asset.mimeType || 'image/jpeg' },
|
||||||
|
{ prefix: 'avatars/', userId }
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('url', url);
|
||||||
|
|
||||||
|
|
||||||
|
setProfile((p) => ({ ...p, avatarUri: url }));
|
||||||
|
// 保存更新后的 profile
|
||||||
|
await handleSaveWithProfile({ ...profile, avatarUri: url });
|
||||||
|
Alert.alert('成功', '头像更新成功');
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('上传头像失败', e);
|
||||||
|
Alert.alert('上传失败', '头像上传失败,请重试');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
Alert.alert('发生错误', '选择头像失败,请重试');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={[styles.container, { backgroundColor: '#F5F5F5' }]}>
|
||||||
|
<StatusBar barStyle={'dark-content'} />
|
||||||
|
|
||||||
|
{/* HeaderBar 放在 ScrollView 外部,确保全宽显示 */}
|
||||||
|
<HeaderBar
|
||||||
|
title="编辑资料"
|
||||||
|
onBack={() => router.back()}
|
||||||
|
withSafeTop={false}
|
||||||
|
transparent={true}
|
||||||
|
variant="elevated"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} style={{ flex: 1 }}>
|
||||||
|
<ScrollView contentContainerStyle={{ paddingBottom: 40 }} style={{ paddingHorizontal: 20 }} showsVerticalScrollIndicator={false}>
|
||||||
|
|
||||||
|
{/* 头像(带相机蒙层,点击从相册选择) */}
|
||||||
|
<View style={{ alignItems: 'center', marginTop: 4, marginBottom: 32 }}>
|
||||||
|
<TouchableOpacity activeOpacity={0.85} onPress={pickAvatarFromLibrary} disabled={uploading}>
|
||||||
|
<View style={styles.avatarCircle}>
|
||||||
|
<Image source={{ uri: profile.avatarUri || 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/seal-avatar/2.jpeg' }} style={styles.avatarImage} />
|
||||||
|
<View style={styles.avatarOverlay}>
|
||||||
|
<Ionicons name="camera" size={22} color="#192126" />
|
||||||
|
</View>
|
||||||
|
{uploading && (
|
||||||
|
<View style={styles.avatarLoadingOverlay}>
|
||||||
|
<ActivityIndicator size="large" color="#FFFFFF" />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 用户信息卡片列表 */}
|
||||||
|
<View style={styles.cardContainer}>
|
||||||
|
{/* 姓名 */}
|
||||||
|
<ProfileCard
|
||||||
|
iconUri='https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/icons/icon-edit-name.png'
|
||||||
|
title="昵称"
|
||||||
|
value={profile.name || '今晚要吃肉'}
|
||||||
|
onPress={() => {
|
||||||
|
setTempValue(profile.name || '');
|
||||||
|
setEditingField('name');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 性别 */}
|
||||||
|
<ProfileCard
|
||||||
|
icon="body"
|
||||||
|
iconUri="https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/icons/icon-edit-sex.png"
|
||||||
|
iconColor="#FF6B9D"
|
||||||
|
title="性别"
|
||||||
|
value={profile.gender === 'male' ? '男' : profile.gender === 'female' ? '女' : '未设置'}
|
||||||
|
onPress={() => {
|
||||||
|
setEditingField('gender');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 身高 */}
|
||||||
|
<ProfileCard
|
||||||
|
iconUri="https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/icons/icon-edit-height.png"
|
||||||
|
title="身高"
|
||||||
|
value={profile.height ? `${Math.round(profile.height)}厘米` : '170厘米'}
|
||||||
|
onPress={() => {
|
||||||
|
setTempValue(profile.height ? String(Math.round(profile.height)) : '170');
|
||||||
|
setEditingField('height');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 体重 */}
|
||||||
|
<ProfileCard
|
||||||
|
iconUri="https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/icons/icon-edit-weight.png"
|
||||||
|
title="体重"
|
||||||
|
value={profile.weight ? `${round(profile.weight, 1)}公斤` : '55公斤'}
|
||||||
|
onPress={() => {
|
||||||
|
setTempValue(profile.weight ? String(round(profile.weight, 1)) : '55');
|
||||||
|
setEditingField('weight');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 活动水平 */}
|
||||||
|
<ProfileCard
|
||||||
|
iconUri='https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/icons/icon-edit-activity.png'
|
||||||
|
title="活动水平"
|
||||||
|
value={(() => {
|
||||||
|
switch (profile.activityLevel) {
|
||||||
|
case 1: return '久坐';
|
||||||
|
case 2: return '轻度活跃';
|
||||||
|
case 3: return '中度活跃';
|
||||||
|
case 4: return '非常活跃';
|
||||||
|
default: return '久坐';
|
||||||
|
}
|
||||||
|
})()}
|
||||||
|
onPress={() => {
|
||||||
|
setEditingField('activity');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 出生日期 */}
|
||||||
|
<ProfileCard
|
||||||
|
iconUri='https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/icons/icon-edit-birth.png'
|
||||||
|
title="出生日期"
|
||||||
|
value={profile.birthDate ? (() => {
|
||||||
|
try {
|
||||||
|
const d = new Date(profile.birthDate);
|
||||||
|
return `${d.getFullYear()}年${d.getMonth() + 1}月${d.getDate()}日`;
|
||||||
|
} catch {
|
||||||
|
return '1995年1月1日';
|
||||||
|
}
|
||||||
|
})() : '1995年1月1日'}
|
||||||
|
onPress={() => {
|
||||||
|
openDatePicker();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 最大心率 */}
|
||||||
|
<ProfileCard
|
||||||
|
icon="heart"
|
||||||
|
iconColor="#FF6B9D"
|
||||||
|
title="最大心率"
|
||||||
|
value={profile.maxHeartRate ? `${Math.round(profile.maxHeartRate)}次/分钟` : '未获取'}
|
||||||
|
onPress={() => {
|
||||||
|
// 最大心率不可编辑,只显示
|
||||||
|
Alert.alert('提示', '最大心率数据从健康应用自动获取');
|
||||||
|
}}
|
||||||
|
disabled={true}
|
||||||
|
hideArrow={true}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 编辑弹窗 */}
|
||||||
|
<EditModal
|
||||||
|
visible={!!editingField}
|
||||||
|
field={editingField}
|
||||||
|
value={tempValue}
|
||||||
|
profile={profile}
|
||||||
|
onClose={() => {
|
||||||
|
setEditingField(null);
|
||||||
|
setTempValue('');
|
||||||
|
}}
|
||||||
|
onSave={async (field, value) => {
|
||||||
|
// 先更新本地状态
|
||||||
|
let updatedProfile = { ...profile };
|
||||||
|
if (field === 'name') {
|
||||||
|
updatedProfile.name = value;
|
||||||
|
setProfile(p => ({ ...p, name: value }));
|
||||||
|
} else if (field === 'gender') {
|
||||||
|
updatedProfile.gender = value as 'male' | 'female';
|
||||||
|
setProfile(p => ({ ...p, gender: value as 'male' | 'female' }));
|
||||||
|
} else if (field === 'height') {
|
||||||
|
updatedProfile.height = parseFloat(value) || undefined;
|
||||||
|
setProfile(p => ({ ...p, height: parseFloat(value) || undefined }));
|
||||||
|
|
||||||
|
} else if (field === 'weight') {
|
||||||
|
updatedProfile.weight = parseFloat(value) || undefined;
|
||||||
|
setProfile(p => ({ ...p, weight: parseFloat(value) || undefined }));
|
||||||
|
|
||||||
|
} else if (field === 'activity') {
|
||||||
|
const activityLevel = parseInt(value) as number;
|
||||||
|
updatedProfile.activityLevel = activityLevel;
|
||||||
|
setProfile(p => ({ ...p, activityLevel: activityLevel }));
|
||||||
|
}
|
||||||
|
|
||||||
|
setEditingField(null);
|
||||||
|
setTempValue('');
|
||||||
|
|
||||||
|
// 使用更新后的数据保存
|
||||||
|
await handleSaveWithProfile(updatedProfile);
|
||||||
|
}}
|
||||||
|
colors={colors}
|
||||||
|
textColor={textColor}
|
||||||
|
placeholderColor={placeholderColor}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 出生日期选择器弹窗 */}
|
||||||
|
<Modal
|
||||||
|
visible={datePickerVisible}
|
||||||
|
transparent
|
||||||
|
animationType="fade"
|
||||||
|
onRequestClose={closeDatePicker}
|
||||||
|
>
|
||||||
|
<Pressable style={styles.modalBackdrop} onPress={closeDatePicker} />
|
||||||
|
<View style={styles.modalSheet}>
|
||||||
|
<DateTimePicker
|
||||||
|
value={pickerDate}
|
||||||
|
mode="date"
|
||||||
|
display={Platform.OS === 'ios' ? 'inline' : 'calendar'}
|
||||||
|
minimumDate={new Date(1900, 0, 1)}
|
||||||
|
maximumDate={new Date()}
|
||||||
|
{...(Platform.OS === 'ios' ? { locale: 'zh-CN' } : {})}
|
||||||
|
onChange={(event, date) => {
|
||||||
|
if (Platform.OS === 'ios') {
|
||||||
|
if (date) setPickerDate(date);
|
||||||
|
} else {
|
||||||
|
if (event.type === 'set' && date) {
|
||||||
|
onConfirmDate(date);
|
||||||
|
} else {
|
||||||
|
closeDatePicker();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{Platform.OS === 'ios' && (
|
||||||
|
<View style={styles.modalActions}>
|
||||||
|
<Pressable onPress={closeDatePicker} style={[styles.modalBtn]}>
|
||||||
|
<Text style={styles.modalBtnText}>取消</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable onPress={() => {
|
||||||
|
onConfirmDate(pickerDate);
|
||||||
|
}} style={[styles.modalBtn, styles.modalBtnPrimary]}>
|
||||||
|
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary]}>确定</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
</ScrollView>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProfileCard({ icon, iconUri, iconColor, title, value, onPress, disabled, hideArrow }: {
|
||||||
|
icon?: keyof typeof Ionicons.glyphMap;
|
||||||
|
iconUri?: string;
|
||||||
|
iconColor?: string;
|
||||||
|
title: string;
|
||||||
|
value: string;
|
||||||
|
onPress: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
hideArrow?: boolean;
|
||||||
|
}) {
|
||||||
|
const Container = disabled ? View : TouchableOpacity;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container onPress={disabled ? undefined : onPress} style={styles.profileCard} {...(disabled ? {} : { activeOpacity: 0.8 })}>
|
||||||
|
<View style={styles.profileCardLeft}>
|
||||||
|
<View style={[styles.iconContainer]}>
|
||||||
|
{iconUri ? <Image
|
||||||
|
source={{ uri: iconUri }}
|
||||||
|
style={{ width: 20, height: 20 }}
|
||||||
|
cachePolicy="memory-disk" /> : <Ionicons name={icon} size={20} color={iconColor} />}
|
||||||
|
</View>
|
||||||
|
<Text style={styles.profileCardTitle}>{title}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.profileCardRight}>
|
||||||
|
<Text style={styles.profileCardValue}>{value}</Text>
|
||||||
|
{!hideArrow && <Ionicons name="chevron-forward" size={16} color="#C7C7CC" />}
|
||||||
|
</View>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditModal({ visible, field, value, profile, onClose, onSave, colors, textColor, placeholderColor }: {
|
||||||
|
visible: boolean;
|
||||||
|
field: string | null;
|
||||||
|
value: string;
|
||||||
|
profile: UserProfile;
|
||||||
|
onClose: () => void;
|
||||||
|
onSave: (field: string, value: string) => void;
|
||||||
|
colors: any;
|
||||||
|
textColor: string;
|
||||||
|
placeholderColor: string;
|
||||||
|
}) {
|
||||||
|
const [inputValue, setInputValue] = useState(value);
|
||||||
|
const [selectedGender, setSelectedGender] = useState(profile.gender || 'female');
|
||||||
|
const [selectedActivity, setSelectedActivity] = useState(profile.activityLevel || 1);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setInputValue(value);
|
||||||
|
if (field === 'activity') {
|
||||||
|
setSelectedActivity(profile.activityLevel || 1);
|
||||||
|
}
|
||||||
|
}, [value, field, profile.activityLevel]);
|
||||||
|
|
||||||
|
const renderContent = () => {
|
||||||
|
switch (field) {
|
||||||
|
case 'name':
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<Text style={styles.modalTitle}>昵称</Text>
|
||||||
|
<TextInput
|
||||||
|
style={[styles.modalInput, { color: textColor, borderColor: '#E0E0E0' }]}
|
||||||
|
placeholder="输入昵称"
|
||||||
|
placeholderTextColor={placeholderColor}
|
||||||
|
value={inputValue}
|
||||||
|
onChangeText={setInputValue}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
case 'gender':
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<Text style={styles.modalTitle}>性别</Text>
|
||||||
|
<View style={styles.genderSelector}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.genderOption, selectedGender === 'female' && { backgroundColor: colors.primary + '20' }]}
|
||||||
|
onPress={() => setSelectedGender('female')}
|
||||||
|
>
|
||||||
|
<Text style={[styles.genderEmoji, selectedGender === 'female' && { color: colors.primary }]}>♀</Text>
|
||||||
|
<Text style={[styles.genderText, selectedGender === 'female' && { color: colors.primary }]}>女性</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.genderOption, selectedGender === 'male' && { backgroundColor: colors.primary + '20' }]}
|
||||||
|
onPress={() => setSelectedGender('male')}
|
||||||
|
>
|
||||||
|
<Text style={[styles.genderEmoji, selectedGender === 'male' && { color: colors.primary }]}>♂</Text>
|
||||||
|
<Text style={[styles.genderText, selectedGender === 'male' && { color: colors.primary }]}>男性</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
case 'height':
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<Text style={styles.modalTitle}>身高</Text>
|
||||||
|
<View style={styles.pickerContainer}>
|
||||||
|
<Picker
|
||||||
|
selectedValue={inputValue}
|
||||||
|
onValueChange={setInputValue}
|
||||||
|
style={styles.picker}
|
||||||
|
>
|
||||||
|
{Array.from({ length: 101 }, (_, i) => 120 + i).map(height => (
|
||||||
|
<Picker.Item key={height} label={`${height}厘米`} value={String(height)} />
|
||||||
|
))}
|
||||||
|
</Picker>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
case 'weight':
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<Text style={styles.modalTitle}>体重</Text>
|
||||||
|
<TextInput
|
||||||
|
style={[styles.modalInput, { color: textColor, borderColor: '#E0E0E0' }]}
|
||||||
|
placeholder="输入体重"
|
||||||
|
placeholderTextColor={placeholderColor}
|
||||||
|
value={inputValue}
|
||||||
|
onChangeText={setInputValue}
|
||||||
|
keyboardType="numeric"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<Text style={styles.unitText}>公斤 (kg)</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
case 'activity':
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<Text style={styles.modalTitle}>活动水平</Text>
|
||||||
|
<View style={styles.activitySelector}>
|
||||||
|
{[
|
||||||
|
{ key: 1, label: '久坐', desc: '很少运动' },
|
||||||
|
{ key: 2, label: '轻度活跃', desc: '每周1-3次运动' },
|
||||||
|
{ key: 3, label: '中度活跃', desc: '每周3-5次运动' },
|
||||||
|
{ key: 4, label: '非常活跃', desc: '每周6-7次运动' },
|
||||||
|
].map(item => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={item.key}
|
||||||
|
style={[styles.activityOption, selectedActivity === item.key && { backgroundColor: colors.primary + '20' }]}
|
||||||
|
onPress={() => setSelectedActivity(item.key)}
|
||||||
|
>
|
||||||
|
<View style={styles.activityContent}>
|
||||||
|
<Text style={[styles.activityLabel, selectedActivity === item.key && { color: colors.primary }]}>{item.label}</Text>
|
||||||
|
<Text style={styles.activityDesc}>{item.desc}</Text>
|
||||||
|
</View>
|
||||||
|
{selectedActivity === item.key && <Ionicons name="checkmark" size={20} color={colors.primary} />}
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
|
||||||
|
<Pressable style={styles.modalBackdrop} onPress={onClose} />
|
||||||
|
<View style={styles.editModalSheet}>
|
||||||
|
<View style={styles.modalHandle} />
|
||||||
|
{renderContent()}
|
||||||
|
<View style={styles.modalButtons}>
|
||||||
|
<TouchableOpacity onPress={onClose} style={styles.modalCancelBtn}>
|
||||||
|
<Text style={styles.modalCancelText}>取消</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
if (field === 'gender') {
|
||||||
|
onSave(field, selectedGender);
|
||||||
|
} else if (field === 'activity') {
|
||||||
|
onSave(field, String(selectedActivity));
|
||||||
|
} else {
|
||||||
|
onSave(field!, inputValue);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={[styles.modalSaveBtn, { backgroundColor: colors.primary }]}
|
||||||
|
>
|
||||||
|
<Text style={[styles.modalSaveText, { color: colors.onPrimary }]}>保存</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function round(n: number, d = 0) { const p = Math.pow(10, d); return Math.round(n * p) / p; }
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: { flex: 1 },
|
||||||
|
avatarCircle: {
|
||||||
|
width: 120,
|
||||||
|
height: 120,
|
||||||
|
borderRadius: 60,
|
||||||
|
backgroundColor: '#E8D4F0',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
avatarImage: {
|
||||||
|
width: 120,
|
||||||
|
height: 120,
|
||||||
|
borderRadius: 60,
|
||||||
|
},
|
||||||
|
avatarOverlay: {
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.25)',
|
||||||
|
},
|
||||||
|
avatarLoadingOverlay: {
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||||
|
borderRadius: 60,
|
||||||
|
},
|
||||||
|
cardContainer: {
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderRadius: 16,
|
||||||
|
overflow: 'hidden',
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
profileCard: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingVertical: 16,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: '#F0F0F0',
|
||||||
|
},
|
||||||
|
profileCardLeft: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
iconContainer: {
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: 16,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginRight: 12,
|
||||||
|
},
|
||||||
|
profileCardTitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: '#333333',
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
profileCardRight: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
profileCardValue: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: '#666666',
|
||||||
|
marginRight: 8,
|
||||||
|
},
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
modalActions: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
marginTop: 8,
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
modalBtn: {
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 10,
|
||||||
|
borderRadius: 10,
|
||||||
|
backgroundColor: '#F1F5F9',
|
||||||
|
},
|
||||||
|
modalBtnPrimary: {
|
||||||
|
backgroundColor: '#7a5af8',
|
||||||
|
},
|
||||||
|
modalBtnText: {
|
||||||
|
color: '#334155',
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
modalBtnTextPrimary: {
|
||||||
|
color: '#0F172A',
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
editModalSheet: {
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderTopLeftRadius: 20,
|
||||||
|
borderTopRightRadius: 20,
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingBottom: 40,
|
||||||
|
paddingTop: 20,
|
||||||
|
},
|
||||||
|
modalHandle: {
|
||||||
|
width: 36,
|
||||||
|
height: 4,
|
||||||
|
backgroundColor: '#E0E0E0',
|
||||||
|
borderRadius: 2,
|
||||||
|
alignSelf: 'center',
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
modalTitle: {
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#333333',
|
||||||
|
marginBottom: 20,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
modalInput: {
|
||||||
|
height: 50,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
fontSize: 16,
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
unitText: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#666666',
|
||||||
|
textAlign: 'center',
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
genderSelector: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 16,
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
genderOption: {
|
||||||
|
flex: 1,
|
||||||
|
height: 80,
|
||||||
|
borderRadius: 12,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: '#F8F8F8',
|
||||||
|
},
|
||||||
|
genderEmoji: {
|
||||||
|
fontSize: 24,
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
genderText: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
pickerContainer: {
|
||||||
|
height: 200,
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
picker: {
|
||||||
|
height: 200,
|
||||||
|
},
|
||||||
|
activitySelector: {
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
activityOption: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingVertical: 16,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
borderRadius: 12,
|
||||||
|
marginBottom: 8,
|
||||||
|
backgroundColor: '#F8F8F8',
|
||||||
|
},
|
||||||
|
activityContent: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
activityLabel: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '500',
|
||||||
|
color: '#333333',
|
||||||
|
},
|
||||||
|
activityDesc: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#666666',
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
modalButtons: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
modalCancelBtn: {
|
||||||
|
flex: 1,
|
||||||
|
height: 50,
|
||||||
|
backgroundColor: '#F0F0F0',
|
||||||
|
borderRadius: 12,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
modalCancelText: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#666666',
|
||||||
|
},
|
||||||
|
modalSaveBtn: {
|
||||||
|
flex: 1,
|
||||||
|
height: 50,
|
||||||
|
borderRadius: 12,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
modalSaveText: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
444
app/profile/goals.tsx
Normal file
@@ -0,0 +1,444 @@
|
|||||||
|
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||||
|
import { Colors } from '@/constants/Colors';
|
||||||
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
|
import AsyncStorage from '@/utils/kvStore';
|
||||||
|
import * as Haptics from 'expo-haptics';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
SafeAreaView,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
|
import { ProgressBar } from '@/components/ProgressBar';
|
||||||
|
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||||
|
import { updateUser as updateUserApi } from '@/services/users';
|
||||||
|
import { fetchMyProfile } from '@/store/userSlice';
|
||||||
|
import { useFocusEffect } from '@react-navigation/native';
|
||||||
|
|
||||||
|
const STORAGE_KEYS = {
|
||||||
|
calories: '@goal_calories_burn',
|
||||||
|
steps: '@goal_daily_steps',
|
||||||
|
purposes: '@goal_pilates_purposes',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const CALORIES_RANGE = { min: 100, max: 1500, step: 50 };
|
||||||
|
const STEPS_RANGE = { min: 2000, max: 20000, step: 500 };
|
||||||
|
|
||||||
|
function arraysEqualUnordered(a?: string[], b?: string[]): boolean {
|
||||||
|
if (!Array.isArray(a) && !Array.isArray(b)) return true;
|
||||||
|
if (!Array.isArray(a) || !Array.isArray(b)) return false;
|
||||||
|
if (a.length !== b.length) return false;
|
||||||
|
const sa = [...a].sort();
|
||||||
|
const sb = [...b].sort();
|
||||||
|
for (let i = 0; i < sa.length; i += 1) {
|
||||||
|
if (sa[i] !== sb[i]) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GoalsScreen() {
|
||||||
|
const router = useRouter();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||||
|
const colors = Colors[theme];
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const accountProfile = useAppSelector((s) => (s as any)?.user?.profile as any);
|
||||||
|
const userId: string | undefined = useMemo(() => {
|
||||||
|
return (
|
||||||
|
accountProfile?.userId ||
|
||||||
|
accountProfile?.id ||
|
||||||
|
accountProfile?._id ||
|
||||||
|
accountProfile?.uid ||
|
||||||
|
undefined
|
||||||
|
) as string | undefined;
|
||||||
|
}, [accountProfile]);
|
||||||
|
|
||||||
|
const [calories, setCalories] = useState<number>(400);
|
||||||
|
const [steps, setSteps] = useState<number>(8000);
|
||||||
|
const [purposes, setPurposes] = useState<string[]>([]);
|
||||||
|
const lastSentRef = React.useRef<{ calories?: number; steps?: number; purposes?: string[] }>({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
const [c, s, p] = await Promise.all([
|
||||||
|
AsyncStorage.getItem(STORAGE_KEYS.calories),
|
||||||
|
AsyncStorage.getItem(STORAGE_KEYS.steps),
|
||||||
|
AsyncStorage.getItem(STORAGE_KEYS.purposes),
|
||||||
|
]);
|
||||||
|
if (c) {
|
||||||
|
const v = parseInt(c, 10);
|
||||||
|
if (!Number.isNaN(v)) setCalories(v);
|
||||||
|
}
|
||||||
|
if (s) {
|
||||||
|
const v = parseInt(s, 10);
|
||||||
|
if (!Number.isNaN(v)) setSteps(v);
|
||||||
|
}
|
||||||
|
if (p) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(p);
|
||||||
|
if (Array.isArray(parsed)) setPurposes(parsed.filter((x) => typeof x === 'string'));
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
} catch { }
|
||||||
|
};
|
||||||
|
load();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 页面聚焦时,从后端拉取并用全局 profile 的值覆盖 UI,保证是最新
|
||||||
|
useFocusEffect(
|
||||||
|
React.useCallback(() => {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
await dispatch(fetchMyProfile() as any);
|
||||||
|
const latest = (accountProfile ?? {}) as any;
|
||||||
|
if (typeof latest?.dailyCaloriesGoal === 'number') setCalories(latest.dailyCaloriesGoal);
|
||||||
|
if (typeof latest?.dailyStepsGoal === 'number') setSteps(latest.dailyStepsGoal);
|
||||||
|
if (Array.isArray(latest?.pilatesPurposes)) setPurposes(latest.pilatesPurposes.filter((x: any) => typeof x === 'string'));
|
||||||
|
} catch { }
|
||||||
|
})();
|
||||||
|
}, [dispatch])
|
||||||
|
);
|
||||||
|
|
||||||
|
// 当全局 profile 有变化时,同步覆盖 UI
|
||||||
|
useEffect(() => {
|
||||||
|
const latest = (accountProfile ?? {}) as any;
|
||||||
|
if (typeof latest?.dailyCaloriesGoal === 'number') setCalories(latest.dailyCaloriesGoal);
|
||||||
|
if (typeof latest?.dailyStepsGoal === 'number') setSteps(latest.dailyStepsGoal);
|
||||||
|
if (Array.isArray(latest?.pilatesPurposes)) setPurposes(latest.pilatesPurposes.filter((x: any) => typeof x === 'string'));
|
||||||
|
}, [accountProfile]);
|
||||||
|
|
||||||
|
// 当全局 profile 变化(例如刚拉完或保存后刷新)时,将“已发送基线”对齐为后端值,避免重复上报
|
||||||
|
useEffect(() => {
|
||||||
|
const latest = (accountProfile ?? {}) as any;
|
||||||
|
if (typeof latest?.dailyCaloriesGoal === 'number') {
|
||||||
|
lastSentRef.current.calories = latest.dailyCaloriesGoal;
|
||||||
|
}
|
||||||
|
if (typeof latest?.dailyStepsGoal === 'number') {
|
||||||
|
lastSentRef.current.steps = latest.dailyStepsGoal;
|
||||||
|
}
|
||||||
|
if (Array.isArray(latest?.pilatesPurposes)) {
|
||||||
|
lastSentRef.current.purposes = [...latest.pilatesPurposes];
|
||||||
|
}
|
||||||
|
}, [accountProfile]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
AsyncStorage.setItem(STORAGE_KEYS.calories, String(calories)).catch(() => { });
|
||||||
|
if (!userId) return;
|
||||||
|
if (lastSentRef.current.calories === calories) return;
|
||||||
|
lastSentRef.current.calories = calories;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
await updateUserApi({ userId, dailyCaloriesGoal: calories });
|
||||||
|
await dispatch(fetchMyProfile() as any);
|
||||||
|
} catch { }
|
||||||
|
})();
|
||||||
|
}, [calories, userId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
AsyncStorage.setItem(STORAGE_KEYS.steps, String(steps)).catch(() => { });
|
||||||
|
if (!userId) return;
|
||||||
|
if (lastSentRef.current.steps === steps) return;
|
||||||
|
lastSentRef.current.steps = steps;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
await updateUserApi({ userId, dailyStepsGoal: steps });
|
||||||
|
await dispatch(fetchMyProfile() as any);
|
||||||
|
} catch { }
|
||||||
|
})();
|
||||||
|
}, [steps, userId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
AsyncStorage.setItem(STORAGE_KEYS.purposes, JSON.stringify(purposes)).catch(() => { });
|
||||||
|
if (!userId) return;
|
||||||
|
if (arraysEqualUnordered(lastSentRef.current.purposes, purposes)) return;
|
||||||
|
lastSentRef.current.purposes = [...purposes];
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
await updateUserApi({ userId, pilatesPurposes: purposes });
|
||||||
|
await dispatch(fetchMyProfile() as any);
|
||||||
|
} catch { }
|
||||||
|
})();
|
||||||
|
}, [purposes, userId]);
|
||||||
|
|
||||||
|
const caloriesPercent = useMemo(() =>
|
||||||
|
(Math.min(CALORIES_RANGE.max, Math.max(CALORIES_RANGE.min, calories)) - CALORIES_RANGE.min) /
|
||||||
|
(CALORIES_RANGE.max - CALORIES_RANGE.min),
|
||||||
|
[calories]);
|
||||||
|
|
||||||
|
const stepsPercent = useMemo(() =>
|
||||||
|
(Math.min(STEPS_RANGE.max, Math.max(STEPS_RANGE.min, steps)) - STEPS_RANGE.min) /
|
||||||
|
(STEPS_RANGE.max - STEPS_RANGE.min),
|
||||||
|
[steps]);
|
||||||
|
|
||||||
|
const changeWithHaptics = (next: number, setter: (v: number) => void) => {
|
||||||
|
if (process.env.EXPO_OS === 'ios') {
|
||||||
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
|
}
|
||||||
|
setter(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const inc = (value: number, range: { step: number; max: number }) => {
|
||||||
|
return Math.min(range.max, value + range.step);
|
||||||
|
};
|
||||||
|
const dec = (value: number, range: { step: number; min: number }) => {
|
||||||
|
return Math.max(range.min, value - range.step);
|
||||||
|
};
|
||||||
|
|
||||||
|
const SectionCard: React.FC<{ title: string; subtitle?: string; children: React.ReactNode }>
|
||||||
|
= ({ title, subtitle, children }) => (
|
||||||
|
<View style={[styles.card, { backgroundColor: colors.surface }]}>
|
||||||
|
<Text style={[styles.cardTitle, { color: colors.text }]}>{title}</Text>
|
||||||
|
{subtitle ? <Text style={[styles.cardSubtitle, { color: colors.textSecondary }]}>{subtitle}</Text> : null}
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
const PresetChip: React.FC<{ label: string; active?: boolean; onPress: () => void }>
|
||||||
|
= ({ label, active, onPress }) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={onPress}
|
||||||
|
style={[styles.chip, { backgroundColor: active ? colors.primary : colors.card, borderColor: colors.border }]}
|
||||||
|
>
|
||||||
|
<Text style={[styles.chipText, { color: active ? colors.onPrimary : colors.text }]}>{label}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
|
||||||
|
const Stepper: React.FC<{ onDec: () => void; onInc: () => void }>
|
||||||
|
= ({ onDec, onInc }) => (
|
||||||
|
<View style={styles.stepperRow}>
|
||||||
|
<TouchableOpacity onPress={onDec} style={[styles.stepperBtn, { backgroundColor: colors.card, borderColor: colors.border }]}>
|
||||||
|
<Text style={[styles.stepperText, { color: colors.text }]}>-</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity onPress={onInc} style={[styles.stepperBtn, { backgroundColor: colors.card, borderColor: colors.border }]}>
|
||||||
|
<Text style={[styles.stepperText, { color: colors.text }]}>+</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
const PURPOSE_OPTIONS: { id: string; label: string; icon: any }[] = [
|
||||||
|
{ id: 'core', label: '增强核心力量', icon: 'barbell-outline' },
|
||||||
|
{ id: 'posture', label: '改善姿势体态', icon: 'body-outline' },
|
||||||
|
{ id: 'flexibility', label: '提高柔韧灵活', icon: 'walk-outline' },
|
||||||
|
{ id: 'balance', label: '强化平衡稳定', icon: 'accessibility-outline' },
|
||||||
|
{ id: 'shape', label: '塑形与线条', icon: 'heart-outline' },
|
||||||
|
{ id: 'stress', label: '减压与身心放松', icon: 'leaf-outline' },
|
||||||
|
{ id: 'backpain', label: '预防/改善腰背痛', icon: 'shield-checkmark-outline' },
|
||||||
|
{ id: 'rehab', label: '术后/伤后康复', icon: 'medkit-outline' },
|
||||||
|
{ id: 'performance', label: '提升运动表现', icon: 'fitness-outline' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const togglePurpose = (id: string) => {
|
||||||
|
if (process.env.EXPO_OS === 'ios') {
|
||||||
|
Haptics.selectionAsync();
|
||||||
|
}
|
||||||
|
setPurposes((prev) =>
|
||||||
|
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.container, { backgroundColor: theme === 'light' ? '#F5F5F5' : colors.background }]}>
|
||||||
|
<SafeAreaView style={styles.safeArea}>
|
||||||
|
<HeaderBar title="目标管理" onBack={() => router.back()} withSafeTop={false} tone={theme} transparent />
|
||||||
|
<ScrollView contentContainerStyle={[styles.content, { paddingBottom: Math.max(20, insets.bottom + 16) }]}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
<SectionCard title="每日卡路里消耗目标" subtitle="设置你计划每天通过活动消耗的热量">
|
||||||
|
<View style={styles.rowBetween}>
|
||||||
|
<Text style={[styles.valueText, { color: colors.text }]}>{calories} kcal</Text>
|
||||||
|
<Stepper
|
||||||
|
onDec={() => changeWithHaptics(dec(calories, CALORIES_RANGE), setCalories)}
|
||||||
|
onInc={() => changeWithHaptics(inc(calories, CALORIES_RANGE), setCalories)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<ProgressBar
|
||||||
|
progress={caloriesPercent}
|
||||||
|
height={18}
|
||||||
|
style={styles.progressMargin}
|
||||||
|
trackColor={theme === 'light' ? '#EDEDED' : colors.separator}
|
||||||
|
fillColor={colors.primary}
|
||||||
|
/>
|
||||||
|
<View style={styles.chipsRow}>
|
||||||
|
{[200, 300, 400, 500, 600].map((v) => (
|
||||||
|
<PresetChip key={v}
|
||||||
|
label={`${v}`}
|
||||||
|
active={v === calories}
|
||||||
|
onPress={() => changeWithHaptics(v, setCalories)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
<Text style={[styles.rangeHint, { color: colors.textMuted }]}>建议范围 {CALORIES_RANGE.min}-{CALORIES_RANGE.max} kcal,步进 {CALORIES_RANGE.step}</Text>
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
<SectionCard title="每日步数目标" subtitle="快速设置你的目标步数">
|
||||||
|
<View style={styles.rowBetween}>
|
||||||
|
<Text style={[styles.valueText, { color: colors.text }]}>{steps.toLocaleString()} 步</Text>
|
||||||
|
<Stepper
|
||||||
|
onDec={() => changeWithHaptics(dec(steps, STEPS_RANGE), setSteps)}
|
||||||
|
onInc={() => changeWithHaptics(inc(steps, STEPS_RANGE), setSteps)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<ProgressBar
|
||||||
|
progress={stepsPercent}
|
||||||
|
height={18}
|
||||||
|
style={styles.progressMargin}
|
||||||
|
trackColor={theme === 'light' ? '#EDEDED' : colors.separator}
|
||||||
|
fillColor={colors.primary}
|
||||||
|
/>
|
||||||
|
<View style={styles.chipsRow}>
|
||||||
|
{[6000, 8000, 10000, 12000, 15000].map((v) => (
|
||||||
|
<PresetChip key={v}
|
||||||
|
label={`${v / 1000}k`}
|
||||||
|
active={v === steps}
|
||||||
|
onPress={() => changeWithHaptics(v, setSteps)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
<Text style={[styles.rangeHint, { color: colors.textMuted }]}>建议范围 {STEPS_RANGE.min.toLocaleString()}-{STEPS_RANGE.max.toLocaleString()},步进 {STEPS_RANGE.step}</Text>
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
</ScrollView>
|
||||||
|
</SafeAreaView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
},
|
||||||
|
backButton: {
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: 16,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
headerTitle: {
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
safeArea: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingTop: 8,
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 16,
|
||||||
|
marginTop: 16,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.08,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 2,
|
||||||
|
},
|
||||||
|
cardTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
cardSubtitle: {
|
||||||
|
fontSize: 13,
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
rowBetween: {
|
||||||
|
marginTop: 12,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
},
|
||||||
|
valueText: {
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: '800',
|
||||||
|
},
|
||||||
|
chipsRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: 8,
|
||||||
|
marginTop: 14,
|
||||||
|
},
|
||||||
|
chip: {
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 8,
|
||||||
|
borderRadius: 20,
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
chipText: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
stepperRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 10,
|
||||||
|
},
|
||||||
|
stepperBtn: {
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
borderRadius: 18,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
stepperText: {
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
rangeHint: {
|
||||||
|
fontSize: 12,
|
||||||
|
marginTop: 10,
|
||||||
|
},
|
||||||
|
progressMargin: {
|
||||||
|
marginTop: 12,
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
marginTop: 12,
|
||||||
|
flexDirection: 'row',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
rowGap: 12,
|
||||||
|
},
|
||||||
|
optionCard: {
|
||||||
|
width: '48%',
|
||||||
|
borderRadius: 14,
|
||||||
|
paddingVertical: 14,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 10,
|
||||||
|
},
|
||||||
|
optionIconWrap: {
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
borderRadius: 14,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.04)',
|
||||||
|
},
|
||||||
|
optionLabel: {
|
||||||
|
flex: 1,
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
selectedHint: {
|
||||||
|
marginTop: 10,
|
||||||
|
fontSize: 12,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
951
app/sleep-detail.tsx
Normal file
@@ -0,0 +1,951 @@
|
|||||||
|
import { ThemedView } from '@/components/ThemedView';
|
||||||
|
import {
|
||||||
|
fetchCompleteSleepData,
|
||||||
|
formatSleepTime,
|
||||||
|
formatTime,
|
||||||
|
getSleepStageColor,
|
||||||
|
SleepStage,
|
||||||
|
type CompleteSleepData
|
||||||
|
} from '@/utils/sleepHealthKit';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { router, useLocalSearchParams } from 'expo-router';
|
||||||
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TouchableOpacity,
|
||||||
|
View
|
||||||
|
} from 'react-native';
|
||||||
|
|
||||||
|
import { InfoModal, type SleepDetailData } from '@/components/sleep/InfoModal';
|
||||||
|
import { SleepStagesInfoModal } from '@/components/sleep/SleepStagesInfoModal';
|
||||||
|
import { SleepStageTimeline } from '@/components/sleep/SleepStageTimeline';
|
||||||
|
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||||
|
import { Colors } from '@/constants/Colors';
|
||||||
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
|
|
||||||
|
// SleepGradeCard 组件现在在 InfoModal 组件内部
|
||||||
|
|
||||||
|
// SleepStagesInfoModal 组件现在从独立文件导入
|
||||||
|
|
||||||
|
// InfoModal 组件现在从独立文件导入
|
||||||
|
|
||||||
|
export default function SleepDetailScreen() {
|
||||||
|
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||||
|
const colorTokens = Colors[theme];
|
||||||
|
const [sleepData, setSleepData] = useState<CompleteSleepData | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
// 从导航参数获取日期,如果没有则使用今天
|
||||||
|
const { date: dateParam } = useLocalSearchParams<{ date?: string }>();
|
||||||
|
const [selectedDate] = useState(() => {
|
||||||
|
if (dateParam) {
|
||||||
|
return dayjs(dateParam).toDate();
|
||||||
|
}
|
||||||
|
return dayjs().toDate();
|
||||||
|
});
|
||||||
|
|
||||||
|
const [infoModal, setInfoModal] = useState<{ visible: boolean; title: string; type: 'sleep-time' | 'sleep-quality' | null }>({
|
||||||
|
visible: false,
|
||||||
|
title: '',
|
||||||
|
type: null
|
||||||
|
});
|
||||||
|
|
||||||
|
const [sleepStagesModal, setSleepStagesModal] = useState({
|
||||||
|
visible: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadSleepData = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
console.log('开始加载睡眠数据...');
|
||||||
|
|
||||||
|
const data = await fetchCompleteSleepData(selectedDate);
|
||||||
|
setSleepData(data);
|
||||||
|
|
||||||
|
if (data) {
|
||||||
|
console.log('睡眠数据加载成功,得分:', data.sleepScore);
|
||||||
|
} else {
|
||||||
|
console.log('未找到睡眠数据');
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载睡眠数据失败:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [selectedDate]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadSleepData();
|
||||||
|
}, [loadSleepData]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<View style={[styles.container, styles.loadingContainer]}>
|
||||||
|
<ActivityIndicator size="large" color={Colors.light.primary} />
|
||||||
|
<Text style={styles.loadingText}>加载睡眠数据中...</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果没有数据,使用默认数据结构
|
||||||
|
const displayData: CompleteSleepData = sleepData || {
|
||||||
|
sleepScore: 0,
|
||||||
|
totalSleepTime: 0,
|
||||||
|
sleepQualityPercentage: 0,
|
||||||
|
bedtime: '',
|
||||||
|
wakeupTime: '',
|
||||||
|
timeInBed: 0,
|
||||||
|
sleepStages: [],
|
||||||
|
rawSleepSamples: [],
|
||||||
|
averageHeartRate: null,
|
||||||
|
sleepHeartRateData: [],
|
||||||
|
sleepEfficiency: 0,
|
||||||
|
qualityDescription: '暂无睡眠数据',
|
||||||
|
recommendation: '请确保在真实iOS设备上运行并授权访问健康数据,或等待有睡眠数据后再查看。'
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemedView style={styles.container}>
|
||||||
|
{/* 顶部导航 */}
|
||||||
|
<HeaderBar
|
||||||
|
title={`${dayjs(selectedDate).isSame(dayjs(), 'day') ? '今天' : dayjs(selectedDate).format('M月DD日')}`}
|
||||||
|
onBack={() => router.back()}
|
||||||
|
transparent={true}
|
||||||
|
variant="default"
|
||||||
|
/>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
style={styles.scrollView}
|
||||||
|
contentContainerStyle={styles.scrollContent}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
{/* 睡眠得分圆形显示 */}
|
||||||
|
<View style={styles.scoreContainer}>
|
||||||
|
<View style={styles.scoreTextContainer}>
|
||||||
|
<Text style={styles.scoreNumber}>{displayData.sleepScore}</Text>
|
||||||
|
<Text style={styles.scoreLabel}>睡眠得分</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 睡眠质量描述 */}
|
||||||
|
<Text style={styles.qualityDescription}>{displayData.qualityDescription}</Text>
|
||||||
|
|
||||||
|
{/* 建议文本 */}
|
||||||
|
<Text style={styles.recommendationText}>{displayData.recommendation}</Text>
|
||||||
|
|
||||||
|
{/* 睡眠统计卡片 */}
|
||||||
|
<View style={styles.statsContainer}>
|
||||||
|
<View style={[styles.newStatCard, { backgroundColor: colorTokens.background }]}>
|
||||||
|
<View style={styles.statCardHeader}>
|
||||||
|
<View style={styles.statCardLeftGroup}>
|
||||||
|
<View style={styles.statCardIcon}>
|
||||||
|
<Ionicons name="moon-outline" size={18} color="#6B7280" />
|
||||||
|
</View>
|
||||||
|
<Text style={[styles.statLabel, { color: colorTokens.textSecondary }]}>睡眠时间</Text>
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.infoButton}
|
||||||
|
onPress={() => setInfoModal({
|
||||||
|
visible: true,
|
||||||
|
title: '睡眠时间',
|
||||||
|
type: 'sleep-time'
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Ionicons name="information-circle-outline" size={18} color={colorTokens.textMuted} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
<Text style={[styles.newStatValue, { color: colorTokens.text }]}>
|
||||||
|
{displayData.totalSleepTime > 0 ? formatSleepTime(displayData.totalSleepTime) : '--'}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={[styles.newStatCard, { backgroundColor: colorTokens.background }]}>
|
||||||
|
<View style={styles.statCardHeader}>
|
||||||
|
<View style={styles.statCardLeftGroup}>
|
||||||
|
<View style={styles.statCardIcon}>
|
||||||
|
<Ionicons name="star-outline" size={18} color="#6B7280" />
|
||||||
|
</View>
|
||||||
|
<Text style={[styles.statLabel, { color: colorTokens.textSecondary }]}>睡眠质量</Text>
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.infoButton}
|
||||||
|
onPress={() => setInfoModal({
|
||||||
|
visible: true,
|
||||||
|
title: '睡眠质量',
|
||||||
|
type: 'sleep-quality'
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Ionicons name="information-circle-outline" size={16} color={colorTokens.textMuted} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
<Text style={[styles.newStatValue, { color: colorTokens.text }]}>
|
||||||
|
{displayData.sleepQualityPercentage > 0 ? `${displayData.sleepQualityPercentage}%` : '--%'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 睡眠阶段图表 */}
|
||||||
|
{/* <SleepStageChart
|
||||||
|
sleepData={displayData}
|
||||||
|
onInfoPress={() => setSleepStagesModal({ visible: true })}
|
||||||
|
/> */}
|
||||||
|
|
||||||
|
{/* 苹果健康风格的睡眠阶段时间轴图表 */}
|
||||||
|
<SleepStageTimeline
|
||||||
|
sleepSamples={displayData.rawSleepSamples}
|
||||||
|
bedtime={displayData.bedtime}
|
||||||
|
wakeupTime={displayData.wakeupTime}
|
||||||
|
onInfoPress={() => setSleepStagesModal({ visible: true })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 睡眠阶段统计 - 2x2网格布局 */}
|
||||||
|
<View style={styles.stagesGridContainer}>
|
||||||
|
{/* 使用真实数据或默认数据,确保包含所有4个阶段 */}
|
||||||
|
{(() => {
|
||||||
|
let stagesToDisplay;
|
||||||
|
if (displayData.sleepStages.length > 0) {
|
||||||
|
// 使用真实数据,确保所有阶段都存在
|
||||||
|
const existingStages = new Map(displayData.sleepStages.map(s => [s.stage, s]));
|
||||||
|
stagesToDisplay = [
|
||||||
|
existingStages.get(SleepStage.Awake) || { stage: SleepStage.Awake, duration: 0, percentage: 0, quality: 'good' as any },
|
||||||
|
existingStages.get(SleepStage.REM) || { stage: SleepStage.REM, duration: 0, percentage: 0, quality: 'good' as any },
|
||||||
|
existingStages.get(SleepStage.Core) || { stage: SleepStage.Core, duration: 0, percentage: 0, quality: 'good' as any },
|
||||||
|
existingStages.get(SleepStage.Deep) || { stage: SleepStage.Deep, duration: 0, percentage: 0, quality: 'good' as any }
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
// 使用默认数据
|
||||||
|
stagesToDisplay = [
|
||||||
|
{ stage: SleepStage.Awake, duration: 0, percentage: 0, quality: 'good' as any },
|
||||||
|
{ stage: SleepStage.REM, duration: 0, percentage: 0, quality: 'good' as any },
|
||||||
|
{ stage: SleepStage.Core, duration: 0, percentage: 0, quality: 'good' as any },
|
||||||
|
{ stage: SleepStage.Deep, duration: 0, percentage: 0, quality: 'poor' as any }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return stagesToDisplay;
|
||||||
|
})().map((stageData, index) => {
|
||||||
|
const getStageName = (stage: SleepStage) => {
|
||||||
|
switch (stage) {
|
||||||
|
case SleepStage.Awake: return '清醒时间';
|
||||||
|
case SleepStage.REM: return '快速眼动';
|
||||||
|
case SleepStage.Core: return '核心睡眠';
|
||||||
|
case SleepStage.Deep: return '深度睡眠';
|
||||||
|
default: return '未知';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getQualityDisplay = (quality: any) => {
|
||||||
|
switch (quality) {
|
||||||
|
case 'excellent': return { text: '★ 优秀', color: '#10B981', bgColor: '#D1FAE5', progressColor: '#10B981', progressWidth: '100%' };
|
||||||
|
case 'good': return { text: '✓ 良好', color: '#065F46', bgColor: '#D1FAE5', progressColor: '#10B981', progressWidth: '85%' };
|
||||||
|
case 'fair': return { text: '○ 一般', color: '#92400E', bgColor: '#FEF3C7', progressColor: '#F59E0B', progressWidth: '65%' };
|
||||||
|
case 'poor': return { text: '⚠ 低', color: '#DC2626', bgColor: '#FECACA', progressColor: '#F59E0B', progressWidth: '45%' };
|
||||||
|
default: return { text: '✓ 正常', color: '#065F46', bgColor: '#D1FAE5', progressColor: '#10B981', progressWidth: '75%' };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const qualityInfo = getQualityDisplay(stageData.quality);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View key={index} style={[styles.stageCard, { backgroundColor: colorTokens.background }]}>
|
||||||
|
<Text style={[styles.stageCardTitle, { color: getSleepStageColor(stageData.stage) }]}>
|
||||||
|
{getStageName(stageData.stage)}
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.stageCardValue, { color: colorTokens.text }]}>
|
||||||
|
{formatSleepTime(stageData.duration)}
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.stageCardPercentage, { color: colorTokens.textSecondary }]}>
|
||||||
|
占总体睡眠的 {stageData.percentage}%
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Raw Sleep Samples List - 显示所有原始睡眠数据 */}
|
||||||
|
{sleepData && sleepData.rawSleepSamples && sleepData.rawSleepSamples.length > 100 && (
|
||||||
|
<View style={[styles.rawSamplesContainer, { backgroundColor: colorTokens.background }]}>
|
||||||
|
<View style={styles.rawSamplesHeader}>
|
||||||
|
<Text style={[styles.rawSamplesTitle, { color: colorTokens.text }]}>
|
||||||
|
原始睡眠数据 ({sleepData.rawSleepSamples.length} 条记录)
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.rawSamplesSubtitle, { color: colorTokens.textSecondary }]}>
|
||||||
|
查看数据间隔和可能的gap
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView style={styles.rawSamplesScrollView} nestedScrollEnabled={true}>
|
||||||
|
{sleepData.rawSleepSamples.map((sample, index) => {
|
||||||
|
// 计算与前一个样本的时间间隔
|
||||||
|
const prevSample = index > 0 ? sleepData.rawSleepSamples[index - 1] : null;
|
||||||
|
let gapMinutes = 0;
|
||||||
|
let hasGap = false;
|
||||||
|
|
||||||
|
if (prevSample) {
|
||||||
|
const prevEndTime = new Date(prevSample.endDate).getTime();
|
||||||
|
const currentStartTime = new Date(sample.startDate).getTime();
|
||||||
|
gapMinutes = (currentStartTime - prevEndTime) / (1000 * 60);
|
||||||
|
hasGap = gapMinutes > 1; // 大于1分钟视为有间隔
|
||||||
|
}
|
||||||
|
|
||||||
|
const startTime = formatTime(sample.startDate);
|
||||||
|
const endTime = formatTime(sample.endDate);
|
||||||
|
const duration = Math.round((new Date(sample.endDate).getTime() - new Date(sample.startDate).getTime()) / (1000 * 60));
|
||||||
|
|
||||||
|
// 获取睡眠阶段中文名称
|
||||||
|
const getStageName = (value: SleepStage) => {
|
||||||
|
switch (value) {
|
||||||
|
case SleepStage.InBed: return '在床上';
|
||||||
|
case SleepStage.Awake: return '清醒';
|
||||||
|
case SleepStage.Core: return '核心睡眠';
|
||||||
|
case SleepStage.Deep: return '深度睡眠';
|
||||||
|
case SleepStage.REM: return 'REM睡眠';
|
||||||
|
case SleepStage.Asleep: return '未指定睡眠';
|
||||||
|
default: return value;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View key={index}>
|
||||||
|
{/* 显示数据间隔 */}
|
||||||
|
{hasGap && (
|
||||||
|
<View style={[styles.gapIndicator, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}>
|
||||||
|
<Ionicons name="alert-circle-outline" size={14} color="#F59E0B" />
|
||||||
|
<Text style={[styles.gapText, { color: '#F59E0B' }]}>
|
||||||
|
数据间隔: {Math.round(gapMinutes)}分钟
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 睡眠样本条目 */}
|
||||||
|
<View style={[styles.rawSampleItem, { borderColor: colorTokens.border }]}>
|
||||||
|
<View style={styles.sampleHeader}>
|
||||||
|
<View style={styles.sampleLeft}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.stageDot,
|
||||||
|
{ backgroundColor: getSleepStageColor(sample.value) }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Text style={[styles.sampleStage, { color: colorTokens.text }]}>
|
||||||
|
{getStageName(sample.value)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={[styles.sampleDuration, { color: colorTokens.textSecondary }]}>
|
||||||
|
{duration}分钟
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.sampleTimeRange}>
|
||||||
|
<Text style={[styles.sampleTime, { color: colorTokens.textSecondary }]}>
|
||||||
|
{startTime} - {endTime}
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.sampleIndex, { color: colorTokens.textMuted }]}>
|
||||||
|
#{index + 1}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
{infoModal.type && (
|
||||||
|
<InfoModal
|
||||||
|
visible={infoModal.visible}
|
||||||
|
onClose={() => setInfoModal({ ...infoModal, visible: false })}
|
||||||
|
title={infoModal.title}
|
||||||
|
type={infoModal.type}
|
||||||
|
sleepData={displayData as SleepDetailData}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SleepStagesInfoModal
|
||||||
|
visible={sleepStagesModal.visible}
|
||||||
|
onClose={() => setSleepStagesModal({ visible: false })}
|
||||||
|
/>
|
||||||
|
</ThemedView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
scrollView: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
scrollContent: {
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingBottom: 40,
|
||||||
|
},
|
||||||
|
scoreContainer: {
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
circularProgressContainer: {
|
||||||
|
position: 'relative',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
scoreTextContainer: {
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
scoreNumber: {
|
||||||
|
fontSize: 48,
|
||||||
|
fontWeight: '800',
|
||||||
|
color: '#1F2937',
|
||||||
|
lineHeight: 48,
|
||||||
|
},
|
||||||
|
scoreLabel: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#6B7280',
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
qualityDescription: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#1F2937',
|
||||||
|
textAlign: 'center',
|
||||||
|
marginBottom: 16,
|
||||||
|
lineHeight: 24,
|
||||||
|
},
|
||||||
|
recommendationText: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#6B7280',
|
||||||
|
textAlign: 'center',
|
||||||
|
lineHeight: 20,
|
||||||
|
marginBottom: 32,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
},
|
||||||
|
statsContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 12,
|
||||||
|
marginBottom: 32,
|
||||||
|
paddingHorizontal: 4,
|
||||||
|
},
|
||||||
|
newStatCard: {
|
||||||
|
flex: 1,
|
||||||
|
borderRadius: 20,
|
||||||
|
padding: 16,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowOpacity: 0.08,
|
||||||
|
shadowRadius: 12,
|
||||||
|
elevation: 4,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(0, 0, 0, 0.06)',
|
||||||
|
},
|
||||||
|
statCardHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
statCardLeftGroup: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
statCardIcon: {
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignSelf: 'center',
|
||||||
|
},
|
||||||
|
infoButton: {
|
||||||
|
padding: 4,
|
||||||
|
alignSelf: 'center',
|
||||||
|
},
|
||||||
|
statCard: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 16,
|
||||||
|
alignItems: 'center',
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 3,
|
||||||
|
},
|
||||||
|
statIcon: {
|
||||||
|
fontSize: 18,
|
||||||
|
},
|
||||||
|
statLabel: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '500',
|
||||||
|
letterSpacing: 0.2,
|
||||||
|
alignSelf: 'center',
|
||||||
|
},
|
||||||
|
newStatValue: {
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: '600',
|
||||||
|
marginBottom: 12,
|
||||||
|
letterSpacing: -0.5,
|
||||||
|
},
|
||||||
|
qualityBadge: {
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 4,
|
||||||
|
borderRadius: 8,
|
||||||
|
alignSelf: 'flex-start',
|
||||||
|
},
|
||||||
|
goodQualityBadge: {
|
||||||
|
backgroundColor: '#D1FAE5',
|
||||||
|
},
|
||||||
|
excellentQualityBadge: {
|
||||||
|
backgroundColor: '#FEF3C7',
|
||||||
|
},
|
||||||
|
qualityBadgeText: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '600',
|
||||||
|
letterSpacing: 0.1,
|
||||||
|
},
|
||||||
|
goodQualityText: {
|
||||||
|
color: '#065F46',
|
||||||
|
},
|
||||||
|
excellentQualityText: {
|
||||||
|
color: '#92400E',
|
||||||
|
},
|
||||||
|
statValue: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#1F2937',
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
statQuality: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#10B981',
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
chartContainer: {
|
||||||
|
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 16,
|
||||||
|
marginBottom: 24,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 3,
|
||||||
|
},
|
||||||
|
chartHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
chartTimeLabel: {
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
chartTimeText: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#6B7280',
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
chartHeartRate: {
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
chartHeartRateText: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#EF4444',
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
chartBars: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'flex-end',
|
||||||
|
height: 120,
|
||||||
|
gap: 2,
|
||||||
|
},
|
||||||
|
chartBar: {
|
||||||
|
borderRadius: 2,
|
||||||
|
minHeight: 8,
|
||||||
|
},
|
||||||
|
chartTimeScale: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingHorizontal: 4,
|
||||||
|
marginTop: 8,
|
||||||
|
},
|
||||||
|
chartTimeScaleText: {
|
||||||
|
fontSize: 10,
|
||||||
|
color: '#9CA3AF',
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
layeredChartContainer: {
|
||||||
|
position: 'relative',
|
||||||
|
marginVertical: 16,
|
||||||
|
},
|
||||||
|
sleepBlock: {
|
||||||
|
borderRadius: 2,
|
||||||
|
borderWidth: 0.5,
|
||||||
|
borderColor: 'rgba(255, 255, 255, 0.2)',
|
||||||
|
},
|
||||||
|
baselineLine: {
|
||||||
|
height: 1,
|
||||||
|
backgroundColor: '#E5E7EB',
|
||||||
|
position: 'absolute',
|
||||||
|
},
|
||||||
|
stagesContainer: {
|
||||||
|
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 16,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 3,
|
||||||
|
},
|
||||||
|
stageRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingVertical: 12,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: '#F3F4F6',
|
||||||
|
},
|
||||||
|
stageInfo: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
stageColorDot: {
|
||||||
|
width: 12,
|
||||||
|
height: 12,
|
||||||
|
borderRadius: 6,
|
||||||
|
marginRight: 12,
|
||||||
|
},
|
||||||
|
stageName: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#374151',
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
stageStats: {
|
||||||
|
alignItems: 'flex-end',
|
||||||
|
},
|
||||||
|
stagePercentage: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#1F2937',
|
||||||
|
},
|
||||||
|
stageDuration: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#6B7280',
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
stageQuality: {
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: '600',
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
loadingContainer: {
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
loadingText: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: '#6B7280',
|
||||||
|
marginTop: 16,
|
||||||
|
},
|
||||||
|
errorText: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: '#6B7280',
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
retryButton: {
|
||||||
|
backgroundColor: Colors.light.primary,
|
||||||
|
borderRadius: 8,
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
paddingVertical: 12,
|
||||||
|
},
|
||||||
|
retryButtonText: {
|
||||||
|
color: '#FFFFFF',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
noDataContainer: {
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: 24,
|
||||||
|
},
|
||||||
|
noDataText: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#9CA3AF',
|
||||||
|
fontStyle: 'italic',
|
||||||
|
},
|
||||||
|
// Info Modal 和 Grade Cards 样式已移动到独立组件中
|
||||||
|
mockDataToggle: {
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 6,
|
||||||
|
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||||
|
borderRadius: 16,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(255, 255, 255, 0.3)',
|
||||||
|
},
|
||||||
|
mockDataToggleText: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
// 简化睡眠阶段图表样式
|
||||||
|
simplifiedChartContainer: {
|
||||||
|
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 16,
|
||||||
|
marginBottom: 24,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 3,
|
||||||
|
},
|
||||||
|
chartTitleContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
chartTitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#1F2937',
|
||||||
|
},
|
||||||
|
chartInfoButton: {
|
||||||
|
padding: 4,
|
||||||
|
},
|
||||||
|
simplifiedChartBar: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
height: 24,
|
||||||
|
borderRadius: 12,
|
||||||
|
overflow: 'hidden',
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
stageSegment: {
|
||||||
|
height: '100%',
|
||||||
|
},
|
||||||
|
chartLegend: {
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
legendRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
legendItem: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
legendDot: {
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: 4,
|
||||||
|
marginRight: 6,
|
||||||
|
},
|
||||||
|
legendText: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#6B7280',
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
// 睡眠阶段卡片网格样式
|
||||||
|
stagesGridContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: 12,
|
||||||
|
paddingHorizontal: 4,
|
||||||
|
},
|
||||||
|
stageCard: {
|
||||||
|
width: '48%',
|
||||||
|
borderRadius: 20,
|
||||||
|
padding: 16,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowOpacity: 0.08,
|
||||||
|
shadowRadius: 12,
|
||||||
|
elevation: 4,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(0, 0, 0, 0.06)',
|
||||||
|
},
|
||||||
|
stageCardTitle: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '500',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
stageCardValue: {
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: '700',
|
||||||
|
lineHeight: 28,
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
stageCardPercentage: {
|
||||||
|
fontSize: 12,
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
stageCardQuality: {
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 4,
|
||||||
|
borderRadius: 8,
|
||||||
|
alignSelf: 'flex-start',
|
||||||
|
},
|
||||||
|
normalQuality: {
|
||||||
|
backgroundColor: '#D1FAE5',
|
||||||
|
},
|
||||||
|
lowQuality: {
|
||||||
|
backgroundColor: '#FECACA',
|
||||||
|
},
|
||||||
|
stageCardQualityText: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
normalQualityText: {
|
||||||
|
color: '#065F46',
|
||||||
|
},
|
||||||
|
lowQualityText: {
|
||||||
|
color: '#DC2626',
|
||||||
|
},
|
||||||
|
stageCardProgress: {
|
||||||
|
height: 6,
|
||||||
|
backgroundColor: '#E5E7EB',
|
||||||
|
borderRadius: 3,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
stageCardProgressBar: {
|
||||||
|
height: '100%',
|
||||||
|
borderRadius: 3,
|
||||||
|
},
|
||||||
|
// Sleep Stages Modal 样式已移动到独立组件中
|
||||||
|
// 睡眠时间标签样式
|
||||||
|
sleepTimeLabels: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
sleepTimeLabel: {
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
sleepTimeText: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '500',
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
sleepTimeValue: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '700',
|
||||||
|
letterSpacing: -0.2,
|
||||||
|
},
|
||||||
|
// 调试信息样式
|
||||||
|
debugContainer: {
|
||||||
|
marginHorizontal: 20,
|
||||||
|
marginBottom: 20,
|
||||||
|
padding: 16,
|
||||||
|
borderRadius: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(0, 0, 0, 0.1)',
|
||||||
|
},
|
||||||
|
debugTitle: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '600',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
debugText: {
|
||||||
|
fontSize: 12,
|
||||||
|
lineHeight: 16,
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
// Raw Sleep Samples List 样式
|
||||||
|
rawSamplesContainer: {
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 16,
|
||||||
|
marginBottom: 24,
|
||||||
|
marginHorizontal: 4,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 3,
|
||||||
|
},
|
||||||
|
rawSamplesHeader: {
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
rawSamplesTitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
rawSamplesSubtitle: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
rawSamplesScrollView: {
|
||||||
|
maxHeight: 400, // 限制高度,避免列表过长
|
||||||
|
},
|
||||||
|
rawSampleItem: {
|
||||||
|
paddingVertical: 12,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
borderLeftWidth: 3,
|
||||||
|
borderLeftColor: 'transparent',
|
||||||
|
marginBottom: 8,
|
||||||
|
borderRadius: 8,
|
||||||
|
backgroundColor: 'rgba(248, 250, 252, 0.5)',
|
||||||
|
},
|
||||||
|
sampleHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
sampleLeft: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
stageDot: {
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: 4,
|
||||||
|
marginRight: 8,
|
||||||
|
},
|
||||||
|
sampleStage: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '500',
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
sampleDuration: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
sampleTimeRange: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
sampleTime: {
|
||||||
|
fontSize: 12,
|
||||||
|
},
|
||||||
|
sampleIndex: {
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
gapIndicator: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingVertical: 8,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
marginVertical: 4,
|
||||||
|
borderRadius: 8,
|
||||||
|
gap: 6,
|
||||||
|
},
|
||||||
|
gapText: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
});
|
||||||
732
app/steps/detail.tsx
Normal file
@@ -0,0 +1,732 @@
|
|||||||
|
import { DateSelector } from '@/components/DateSelector';
|
||||||
|
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||||
|
import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date';
|
||||||
|
import { fetchHourlyStepSamples, fetchStepCount, HourlyStepData } from '@/utils/health';
|
||||||
|
import { logger } from '@/utils/logger';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
|
import { useLocalSearchParams } from 'expo-router';
|
||||||
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Animated,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
View
|
||||||
|
} from 'react-native';
|
||||||
|
|
||||||
|
export default function StepsDetailScreen() {
|
||||||
|
// 获取路由参数
|
||||||
|
const { date } = useLocalSearchParams<{ date?: string }>();
|
||||||
|
|
||||||
|
// 根据传入的日期参数计算初始选中索引
|
||||||
|
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(getInitialSelectedIndex());
|
||||||
|
|
||||||
|
// 步数数据状态
|
||||||
|
const [stepCount, setStepCount] = useState(0);
|
||||||
|
const [hourlySteps, setHourSteps] = useState<HourlyStepData[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
// 获取当前选中日期
|
||||||
|
const currentSelectedDate = useMemo(() => {
|
||||||
|
const days = getMonthDaysZh();
|
||||||
|
return days[selectedIndex]?.date?.toDate() ?? new Date();
|
||||||
|
}, [selectedIndex]);
|
||||||
|
|
||||||
|
// 获取步数数据的函数,参考 StepsCard 的实现
|
||||||
|
const getStepData = async (date: Date) => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
logger.info('获取步数详情数据...');
|
||||||
|
const [steps, hourly] = await Promise.all([
|
||||||
|
fetchStepCount(date),
|
||||||
|
fetchHourlyStepSamples(date)
|
||||||
|
]);
|
||||||
|
|
||||||
|
setStepCount(steps);
|
||||||
|
setHourSteps(hourly);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('获取步数详情数据失败:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// 为每个柱体创建独立的动画值
|
||||||
|
const animatedValues = useRef(
|
||||||
|
Array.from({ length: 24 }, () => new Animated.Value(0))
|
||||||
|
).current;
|
||||||
|
|
||||||
|
// 计算柱状图数据
|
||||||
|
const chartData = useMemo(() => {
|
||||||
|
if (!hourlySteps || hourlySteps.length === 0) {
|
||||||
|
return Array.from({ length: 24 }, (_, i) => ({ hour: i, steps: 0, height: 0 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 找到最大步数用于计算高度比例
|
||||||
|
const maxSteps = Math.max(...hourlySteps.map(data => data.steps), 1);
|
||||||
|
const maxHeight = 120; // 详情页面使用更大的高度
|
||||||
|
|
||||||
|
return hourlySteps.map(data => ({
|
||||||
|
...data,
|
||||||
|
height: maxSteps > 0 ? (data.steps / maxSteps) * maxHeight : 0
|
||||||
|
}));
|
||||||
|
}, [hourlySteps]);
|
||||||
|
|
||||||
|
// 计算平均值刻度线位置
|
||||||
|
const averageLinePosition = useMemo(() => {
|
||||||
|
if (!hourlySteps || hourlySteps.length === 0 || !chartData || chartData.length === 0) return 0;
|
||||||
|
|
||||||
|
const activeHours = hourlySteps.filter(h => h.steps > 0);
|
||||||
|
if (activeHours.length === 0) return 0;
|
||||||
|
|
||||||
|
const avgSteps = activeHours.reduce((sum, h) => sum + h.steps, 0) / activeHours.length;
|
||||||
|
const maxSteps = Math.max(...hourlySteps.map(data => data.steps), 1);
|
||||||
|
const maxHeight = 120;
|
||||||
|
|
||||||
|
return maxSteps > 0 ? (avgSteps / maxSteps) * maxHeight : 0;
|
||||||
|
}, [hourlySteps, chartData]);
|
||||||
|
|
||||||
|
// 获取当前小时
|
||||||
|
const currentHour = new Date().getHours();
|
||||||
|
|
||||||
|
// 触发柱体动画
|
||||||
|
useEffect(() => {
|
||||||
|
if (chartData && chartData.length > 0) {
|
||||||
|
// 重置所有动画值
|
||||||
|
animatedValues.forEach(animValue => animValue.setValue(0));
|
||||||
|
|
||||||
|
// 延迟启动动画,创建波浪效果
|
||||||
|
chartData.forEach((data, index) => {
|
||||||
|
if (data.steps > 0) {
|
||||||
|
setTimeout(() => {
|
||||||
|
Animated.spring(animatedValues[index], {
|
||||||
|
toValue: 1,
|
||||||
|
tension: 120,
|
||||||
|
friction: 8,
|
||||||
|
useNativeDriver: false,
|
||||||
|
}).start();
|
||||||
|
}, index * 50); // 每个柱体延迟50ms
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [chartData, animatedValues]);
|
||||||
|
|
||||||
|
// 日期选择处理
|
||||||
|
const onSelectDate = (index: number, date: Date) => {
|
||||||
|
setSelectedIndex(index);
|
||||||
|
getStepData(date);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 当路由参数变化时更新选中索引
|
||||||
|
useEffect(() => {
|
||||||
|
const newIndex = getInitialSelectedIndex();
|
||||||
|
setSelectedIndex(newIndex);
|
||||||
|
}, [date]);
|
||||||
|
|
||||||
|
// 当选中日期变化时获取数据
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentSelectedDate) {
|
||||||
|
getStepData(currentSelectedDate);
|
||||||
|
}
|
||||||
|
}, [currentSelectedDate]);
|
||||||
|
|
||||||
|
// 计算总步数和平均步数
|
||||||
|
const totalSteps = stepCount || 0;
|
||||||
|
const averageHourlySteps = useMemo(() => {
|
||||||
|
if (!hourlySteps || hourlySteps.length === 0) return 0;
|
||||||
|
const activeHours = hourlySteps.filter(h => h.steps > 0);
|
||||||
|
if (activeHours.length === 0) return 0;
|
||||||
|
return Math.round(activeHours.reduce((sum, h) => sum + h.steps, 0) / activeHours.length);
|
||||||
|
}, [hourlySteps]);
|
||||||
|
|
||||||
|
// 找出最活跃的时间段
|
||||||
|
const mostActiveHour = useMemo(() => {
|
||||||
|
if (!hourlySteps || hourlySteps.length === 0) return null;
|
||||||
|
const maxStepsData = hourlySteps.reduce((max, current) =>
|
||||||
|
current.steps > max.steps ? current : max
|
||||||
|
);
|
||||||
|
return maxStepsData.steps > 0 ? maxStepsData : null;
|
||||||
|
}, [hourlySteps]);
|
||||||
|
|
||||||
|
// 活动等级配置
|
||||||
|
const activityLevels = useMemo(() => [
|
||||||
|
{ key: 'inactive', label: '不怎么动', minSteps: 0, maxSteps: 3000, color: '#B8C8D6' },
|
||||||
|
{ key: 'light', label: '轻度活跃', minSteps: 3000, maxSteps: 7500, color: '#93C5FD' },
|
||||||
|
{ key: 'moderate', label: '中等活跃', minSteps: 7500, maxSteps: 10000, color: '#FCD34D' },
|
||||||
|
{ key: 'very_active', label: '非常活跃', minSteps: 10000, maxSteps: Infinity, color: '#FB923C' }
|
||||||
|
], []);
|
||||||
|
|
||||||
|
// 计算当前活动等级
|
||||||
|
const currentActivityLevel = useMemo(() => {
|
||||||
|
return activityLevels.find(level =>
|
||||||
|
totalSteps >= level.minSteps && totalSteps < level.maxSteps
|
||||||
|
) || activityLevels[0];
|
||||||
|
}, [totalSteps, activityLevels]);
|
||||||
|
|
||||||
|
// 计算下一等级
|
||||||
|
const nextActivityLevel = useMemo(() => {
|
||||||
|
const currentIndex = activityLevels.indexOf(currentActivityLevel);
|
||||||
|
return currentIndex < activityLevels.length - 1 ? activityLevels[currentIndex + 1] : null;
|
||||||
|
}, [currentActivityLevel, activityLevels]);
|
||||||
|
|
||||||
|
// 计算进度百分比
|
||||||
|
const progressPercentage = useMemo(() => {
|
||||||
|
if (!nextActivityLevel) return 100; // 已达到最高级
|
||||||
|
|
||||||
|
const rangeSize = nextActivityLevel.minSteps - currentActivityLevel.minSteps;
|
||||||
|
const currentProgress = totalSteps - currentActivityLevel.minSteps;
|
||||||
|
return Math.min(Math.max((currentProgress / rangeSize) * 100, 0), 100);
|
||||||
|
}, [totalSteps, currentActivityLevel, nextActivityLevel]);
|
||||||
|
|
||||||
|
// 倒序显示的活动等级(用于图例)
|
||||||
|
const reversedActivityLevels = useMemo(() => [...activityLevels].reverse(), [activityLevels]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
{/* 背景渐变 */}
|
||||||
|
<LinearGradient
|
||||||
|
colors={['#F0F9FF', '#E0F2FE']}
|
||||||
|
style={styles.gradientBackground}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 1 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<HeaderBar
|
||||||
|
title="步数详情"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
style={styles.scrollView}
|
||||||
|
contentContainerStyle={{}}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
{/* 日期选择器 */}
|
||||||
|
<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 style={styles.chartCard}>
|
||||||
|
<View style={styles.chartHeader}>
|
||||||
|
<Text style={styles.chartTitle}>每小时步数分布</Text>
|
||||||
|
<Text style={styles.chartSubtitle}>
|
||||||
|
{dayjs(currentSelectedDate).format('YYYY年MM月DD日')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 柱状图容器 */}
|
||||||
|
<View style={styles.chartContainer}>
|
||||||
|
{/* 平均值刻度线 - 放在chartArea外面,相对于chartContainer定位 */}
|
||||||
|
{averageLinePosition > 0 && (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.averageLine,
|
||||||
|
{ bottom: averageLinePosition }
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View style={styles.averageLineDashContainer}>
|
||||||
|
{/* 创建更多的虚线段来确保完整覆盖 */}
|
||||||
|
{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 style={styles.chartArea}>
|
||||||
|
{chartData.map((data, index) => {
|
||||||
|
const isActive = data.steps > 0;
|
||||||
|
const isCurrent = index <= currentHour;
|
||||||
|
const isKeyTime = index === 0 || index === 12 || index === 23;
|
||||||
|
|
||||||
|
// 动画变换
|
||||||
|
const animatedHeight = animatedValues[index].interpolate({
|
||||||
|
inputRange: [0, 1],
|
||||||
|
outputRange: [0, data.height],
|
||||||
|
});
|
||||||
|
|
||||||
|
const animatedOpacity = animatedValues[index].interpolate({
|
||||||
|
inputRange: [0, 1],
|
||||||
|
outputRange: [0, 1],
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View key={`bar-${index}`} style={styles.barContainer}>
|
||||||
|
{/* 背景柱体 */}
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.backgroundBar,
|
||||||
|
{
|
||||||
|
backgroundColor: isKeyTime ? '#FFF4E6' : '#F8FAFC',
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 数据柱体 */}
|
||||||
|
{isActive && (
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.dataBar,
|
||||||
|
{
|
||||||
|
height: animatedHeight,
|
||||||
|
backgroundColor: isCurrent ? '#FFC365' : '#FFEBCB',
|
||||||
|
opacity: animatedOpacity,
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 步数标签(仅在有数据且是关键时间点时显示) */}
|
||||||
|
{/* {isActive && isKeyTime && (
|
||||||
|
<Animated.View
|
||||||
|
style={[styles.stepLabel, { opacity: animatedOpacity }]}
|
||||||
|
>
|
||||||
|
<Text style={styles.stepLabelText}>{data.steps}</Text>
|
||||||
|
</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>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
gradientBackground: {
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingVertical: 16,
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
},
|
||||||
|
backButton: {
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 20,
|
||||||
|
backgroundColor: 'rgba(255, 255, 255, 0.8)',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
headerTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#192126',
|
||||||
|
},
|
||||||
|
headerRight: {
|
||||||
|
width: 40,
|
||||||
|
},
|
||||||
|
scrollView: {
|
||||||
|
flex: 1,
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
},
|
||||||
|
statsCard: {
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 20,
|
||||||
|
marginVertical: 16,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: {
|
||||||
|
width: 0,
|
||||||
|
height: 4,
|
||||||
|
},
|
||||||
|
shadowOpacity: 0.08,
|
||||||
|
shadowRadius: 12,
|
||||||
|
elevation: 6,
|
||||||
|
},
|
||||||
|
statsRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-around',
|
||||||
|
},
|
||||||
|
statItem: {
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
statValue: {
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#192126',
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
statLabel: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#64748B',
|
||||||
|
},
|
||||||
|
loadingContainer: {
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingVertical: 20,
|
||||||
|
},
|
||||||
|
loadingText: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: '#64748B',
|
||||||
|
},
|
||||||
|
chartCard: {
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 20,
|
||||||
|
marginBottom: 16,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: {
|
||||||
|
width: 0,
|
||||||
|
height: 4,
|
||||||
|
},
|
||||||
|
shadowOpacity: 0.08,
|
||||||
|
shadowRadius: 12,
|
||||||
|
elevation: 6,
|
||||||
|
},
|
||||||
|
chartHeader: {
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
chartTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#192126',
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
chartSubtitle: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#64748B',
|
||||||
|
},
|
||||||
|
chartContainer: {
|
||||||
|
position: 'relative',
|
||||||
|
},
|
||||||
|
timeLabels: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginTop: 8,
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
},
|
||||||
|
timeLabel: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#64748B',
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
chartArea: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'flex-end',
|
||||||
|
height: 120,
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingHorizontal: 4,
|
||||||
|
},
|
||||||
|
barContainer: {
|
||||||
|
width: 8,
|
||||||
|
height: 120,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
position: 'relative',
|
||||||
|
},
|
||||||
|
backgroundBar: {
|
||||||
|
width: 8,
|
||||||
|
height: 120,
|
||||||
|
borderRadius: 2,
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 0,
|
||||||
|
},
|
||||||
|
dataBar: {
|
||||||
|
width: 8,
|
||||||
|
borderRadius: 2,
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 0,
|
||||||
|
},
|
||||||
|
stepLabel: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: -20,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
stepLabelText: {
|
||||||
|
fontSize: 10,
|
||||||
|
color: '#64748B',
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
averageLine: {
|
||||||
|
position: 'absolute',
|
||||||
|
left: 4, // 匹配 chartArea 的 paddingHorizontal
|
||||||
|
right: 4, // 匹配 chartArea 的 paddingHorizontal
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
zIndex: 1,
|
||||||
|
},
|
||||||
|
averageLineDashContainer: {
|
||||||
|
flex: 1,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginRight: 8,
|
||||||
|
overflow: 'hidden', // 防止虚线段溢出容器
|
||||||
|
},
|
||||||
|
dashSegment: {
|
||||||
|
width: 3,
|
||||||
|
height: 1.5,
|
||||||
|
backgroundColor: '#FFA726',
|
||||||
|
opacity: 0.8,
|
||||||
|
},
|
||||||
|
averageLineLabel: {
|
||||||
|
fontSize: 10,
|
||||||
|
color: '#FFA726',
|
||||||
|
fontWeight: '600',
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
paddingHorizontal: 6,
|
||||||
|
paddingVertical: 2,
|
||||||
|
borderRadius: 4,
|
||||||
|
borderWidth: 0.5,
|
||||||
|
borderColor: '#FFA726',
|
||||||
|
},
|
||||||
|
activityLevelCard: {
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 24,
|
||||||
|
marginVertical: 16,
|
||||||
|
alignItems: 'center',
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: {
|
||||||
|
width: 0,
|
||||||
|
height: 4,
|
||||||
|
},
|
||||||
|
shadowOpacity: 0.08,
|
||||||
|
shadowRadius: 12,
|
||||||
|
elevation: 6,
|
||||||
|
},
|
||||||
|
activityIconContainer: {
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
activityIcon: {
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
borderRadius: 40,
|
||||||
|
backgroundColor: '#E0F2FE',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: '#93C5FD',
|
||||||
|
borderStyle: 'dashed',
|
||||||
|
},
|
||||||
|
meditationIcon: {
|
||||||
|
width: 50,
|
||||||
|
height: 50,
|
||||||
|
borderRadius: 25,
|
||||||
|
backgroundColor: '#93C5FD',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
meditationEmoji: {
|
||||||
|
fontSize: 24,
|
||||||
|
},
|
||||||
|
activityMainText: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: '#64748B',
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
activityLevelText: {
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#192126',
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
progressBarContainer: {
|
||||||
|
width: '100%',
|
||||||
|
marginBottom: 24,
|
||||||
|
},
|
||||||
|
progressBarBackground: {
|
||||||
|
width: '100%',
|
||||||
|
height: 8,
|
||||||
|
backgroundColor: '#F0F9FF',
|
||||||
|
borderRadius: 4,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
progressBarFill: {
|
||||||
|
height: '100%',
|
||||||
|
borderRadius: 4,
|
||||||
|
},
|
||||||
|
stepsInfoContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
width: '100%',
|
||||||
|
marginBottom: 32,
|
||||||
|
},
|
||||||
|
currentStepsInfo: {
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
},
|
||||||
|
nextStepsInfo: {
|
||||||
|
alignItems: 'flex-end',
|
||||||
|
},
|
||||||
|
stepsValue: {
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#192126',
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
stepsLabel: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#64748B',
|
||||||
|
},
|
||||||
|
activityLegendContainer: {
|
||||||
|
width: '100%',
|
||||||
|
},
|
||||||
|
legendItem: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingVertical: 12,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
backgroundColor: '#F8FAFC',
|
||||||
|
marginBottom: 8,
|
||||||
|
borderRadius: 12,
|
||||||
|
},
|
||||||
|
legendIcon: {
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: 8,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginRight: 12,
|
||||||
|
},
|
||||||
|
legendIconText: {
|
||||||
|
fontSize: 16,
|
||||||
|
},
|
||||||
|
legendLabel: {
|
||||||
|
flex: 1,
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#192126',
|
||||||
|
},
|
||||||
|
legendRange: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#64748B',
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
});
|
||||||
649
app/task-detail.tsx
Normal file
@@ -0,0 +1,649 @@
|
|||||||
|
import { useGlobalDialog } from '@/components/ui/DialogProvider';
|
||||||
|
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||||
|
import { Colors } from '@/constants/Colors';
|
||||||
|
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||||
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
|
import { completeTask, skipTask } from '@/store/tasksSlice';
|
||||||
|
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||||
|
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Image,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
TouchableOpacity,
|
||||||
|
View
|
||||||
|
} from 'react-native';
|
||||||
|
|
||||||
|
export default function TaskDetailScreen() {
|
||||||
|
const { taskId } = useLocalSearchParams<{ taskId: string }>();
|
||||||
|
const router = useRouter();
|
||||||
|
const theme = useColorScheme() ?? 'light';
|
||||||
|
const colorTokens = Colors[theme];
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const { showConfirm } = useGlobalDialog();
|
||||||
|
|
||||||
|
// 从Redux中获取任务数据
|
||||||
|
const { tasks, tasksLoading } = useAppSelector(state => state.tasks);
|
||||||
|
const task = tasks.find(t => t.id === taskId) || null;
|
||||||
|
|
||||||
|
const [comment, setComment] = useState('');
|
||||||
|
|
||||||
|
const getStatusText = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'completed':
|
||||||
|
return '已完成';
|
||||||
|
case 'in_progress':
|
||||||
|
return '进行中';
|
||||||
|
case 'overdue':
|
||||||
|
return '已过期';
|
||||||
|
case 'skipped':
|
||||||
|
return '已跳过';
|
||||||
|
default:
|
||||||
|
return '待开始';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusColor = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'completed':
|
||||||
|
return '#10B981';
|
||||||
|
case 'in_progress':
|
||||||
|
return '#7A5AF8';
|
||||||
|
case 'overdue':
|
||||||
|
return '#EF4444';
|
||||||
|
case 'skipped':
|
||||||
|
return '#6B7280';
|
||||||
|
default:
|
||||||
|
return '#6B7280';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDifficultyText = (difficulty: string) => {
|
||||||
|
switch (difficulty) {
|
||||||
|
case 'very_easy':
|
||||||
|
return '非常简单 (少于一天)';
|
||||||
|
case 'easy':
|
||||||
|
return '简单 (1-2天)';
|
||||||
|
case 'medium':
|
||||||
|
return '中等 (3-5天)';
|
||||||
|
case 'hard':
|
||||||
|
return '困难 (1-2周)';
|
||||||
|
case 'very_hard':
|
||||||
|
return '非常困难 (2周以上)';
|
||||||
|
default:
|
||||||
|
return '非常简单 (少于一天)';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDifficultyColor = (difficulty: string) => {
|
||||||
|
switch (difficulty) {
|
||||||
|
case 'very_easy':
|
||||||
|
return '#10B981';
|
||||||
|
case 'easy':
|
||||||
|
return '#34D399';
|
||||||
|
case 'medium':
|
||||||
|
return '#F59E0B';
|
||||||
|
case 'hard':
|
||||||
|
return '#F97316';
|
||||||
|
case 'very_hard':
|
||||||
|
return '#EF4444';
|
||||||
|
default:
|
||||||
|
return '#10B981';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
const options: Intl.DateTimeFormatOptions = {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric'
|
||||||
|
};
|
||||||
|
return `创建于 ${date.toLocaleDateString('zh-CN', options)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCompleteTask = async () => {
|
||||||
|
if (!task || task.status === 'completed') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await dispatch(completeTask({
|
||||||
|
taskId: task.id,
|
||||||
|
completionData: {
|
||||||
|
count: 1,
|
||||||
|
notes: '通过任务详情页面完成'
|
||||||
|
}
|
||||||
|
})).unwrap();
|
||||||
|
|
||||||
|
// 检查任务是否真正完成(当前完成次数是否达到目标次数)
|
||||||
|
const updatedTask = tasks.find(t => t.id === task.id);
|
||||||
|
if (updatedTask && updatedTask.currentCount >= updatedTask.targetCount) {
|
||||||
|
Alert.alert('成功', '任务已完成!');
|
||||||
|
router.back();
|
||||||
|
} else {
|
||||||
|
Alert.alert('成功', '任务进度已更新!');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
Alert.alert('错误', '完成任务失败,请重试');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSkipTask = async () => {
|
||||||
|
if (!task || task.status === 'completed' || task.status === 'skipped') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
showConfirm(
|
||||||
|
{
|
||||||
|
title: '确认跳过任务',
|
||||||
|
message: `确定要跳过任务"${task.title}"吗?\n\n跳过后的任务将不会显示在任务列表中,且无法恢复。`,
|
||||||
|
confirmText: '跳过',
|
||||||
|
cancelText: '取消',
|
||||||
|
destructive: true,
|
||||||
|
icon: 'warning',
|
||||||
|
iconColor: '#F59E0B',
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
try {
|
||||||
|
await dispatch(skipTask({
|
||||||
|
taskId: task.id,
|
||||||
|
skipData: {
|
||||||
|
reason: '用户主动跳过'
|
||||||
|
}
|
||||||
|
})).unwrap();
|
||||||
|
|
||||||
|
Alert.alert('成功', '任务已跳过!');
|
||||||
|
router.back();
|
||||||
|
} catch (error) {
|
||||||
|
Alert.alert('错误', '跳过任务失败,请重试');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSendComment = () => {
|
||||||
|
if (comment.trim()) {
|
||||||
|
// 这里应该调用API发送评论
|
||||||
|
console.log('发送评论:', comment);
|
||||||
|
setComment('');
|
||||||
|
Alert.alert('成功', '评论已发送!');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (tasksLoading) {
|
||||||
|
return (
|
||||||
|
<View style={[styles.container, { backgroundColor: colorTokens.background }]}>
|
||||||
|
<HeaderBar
|
||||||
|
title="任务详情"
|
||||||
|
onBack={() => router.back()}
|
||||||
|
/>
|
||||||
|
<View style={styles.loadingContainer}>
|
||||||
|
<Text style={[styles.loadingText, { color: colorTokens.text }]}>加载中...</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!task) {
|
||||||
|
return (
|
||||||
|
<View style={[styles.container, { backgroundColor: colorTokens.background }]}>
|
||||||
|
<HeaderBar
|
||||||
|
title="任务详情"
|
||||||
|
onBack={() => router.back()}
|
||||||
|
/>
|
||||||
|
<View style={styles.errorContainer}>
|
||||||
|
<Text style={[styles.errorText, { color: colorTokens.text }]}>任务不存在</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.container, { backgroundColor: colorTokens.background }]}>
|
||||||
|
{/* 使用HeaderBar组件 */}
|
||||||
|
<HeaderBar
|
||||||
|
title="任务详情"
|
||||||
|
onBack={() => router.back()}
|
||||||
|
right={
|
||||||
|
task.status !== 'completed' && task.status !== 'skipped' && task.currentCount < task.targetCount ? (
|
||||||
|
<TouchableOpacity onPress={handleCompleteTask} style={styles.completeButton}>
|
||||||
|
<Image
|
||||||
|
source={require('@/assets/images/task/iconTaskHeader.png')}
|
||||||
|
style={styles.taskIcon}
|
||||||
|
resizeMode="contain"
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ScrollView style={styles.scrollView} showsVerticalScrollIndicator={false}>
|
||||||
|
{/* 任务标题和创建时间 */}
|
||||||
|
<View style={styles.titleSection}>
|
||||||
|
<Text style={[styles.taskTitle, { color: colorTokens.text }]}>{task.title}</Text>
|
||||||
|
<Text style={[styles.createdDate, { color: colorTokens.textSecondary }]}>
|
||||||
|
{formatDate(task.startDate)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 状态标签 */}
|
||||||
|
<View style={styles.statusContainer}>
|
||||||
|
<View style={[styles.statusTag, { backgroundColor: getStatusColor(task.status) }]}>
|
||||||
|
<Text style={styles.statusTagText}>{getStatusText(task.status)}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 描述区域 */}
|
||||||
|
<View style={styles.descriptionSection}>
|
||||||
|
<Text style={[styles.sectionTitle, { color: colorTokens.text }]}>描述</Text>
|
||||||
|
<Text style={[styles.descriptionText, { color: colorTokens.textSecondary }]}>
|
||||||
|
{task.description || '暂无描述'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 优先级和难度 */}
|
||||||
|
<View style={styles.infoSection}>
|
||||||
|
<View style={styles.infoItem}>
|
||||||
|
<Text style={[styles.infoLabel, { color: colorTokens.text }]}>优先级</Text>
|
||||||
|
<View style={styles.priorityTag}>
|
||||||
|
<MaterialIcons name="flag" size={16} color="#FFFFFF" />
|
||||||
|
<Text style={styles.priorityTagText}>高</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.infoItem}>
|
||||||
|
<Text style={[styles.infoLabel, { color: colorTokens.text }]}>难度</Text>
|
||||||
|
<View style={[styles.difficultyTag, { backgroundColor: getDifficultyColor('very_easy') }]}>
|
||||||
|
<MaterialIcons name="sentiment-satisfied" size={16} color="#FFFFFF" />
|
||||||
|
<Text style={styles.difficultyTagText}>非常简单 (少于一天)</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 任务进度信息 */}
|
||||||
|
<View style={styles.progressSection}>
|
||||||
|
<Text style={[styles.sectionTitle, { color: colorTokens.text }]}>进度</Text>
|
||||||
|
|
||||||
|
{/* 进度条 */}
|
||||||
|
<View style={styles.progressBar}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.progressFill,
|
||||||
|
{
|
||||||
|
width: task.progressPercentage > 0 ? `${Math.min(task.progressPercentage, 100)}%` : '2%',
|
||||||
|
backgroundColor: task.progressPercentage >= 100
|
||||||
|
? '#10B981'
|
||||||
|
: task.progressPercentage >= 50
|
||||||
|
? '#F59E0B'
|
||||||
|
: task.progressPercentage > 0
|
||||||
|
? colorTokens.primary
|
||||||
|
: '#E5E7EB',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
{task.progressPercentage > 0 && task.progressPercentage < 100 && (
|
||||||
|
<View style={styles.progressGlow} />
|
||||||
|
)}
|
||||||
|
{/* 进度文本 */}
|
||||||
|
<View style={styles.progressTextContainer}>
|
||||||
|
<Text style={styles.progressText}>{task.currentCount}/{task.targetCount}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 进度详细信息 */}
|
||||||
|
<View style={styles.progressInfo}>
|
||||||
|
<View style={styles.progressItem}>
|
||||||
|
<Text style={[styles.progressLabel, { color: colorTokens.textSecondary }]}>目标次数</Text>
|
||||||
|
<Text style={[styles.progressValue, { color: colorTokens.text }]}>{task.targetCount}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.progressItem}>
|
||||||
|
<Text style={[styles.progressLabel, { color: colorTokens.textSecondary }]}>已完成</Text>
|
||||||
|
<Text style={[styles.progressValue, { color: colorTokens.text }]}>{task.currentCount}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.progressItem}>
|
||||||
|
<Text style={[styles.progressLabel, { color: colorTokens.textSecondary }]}>剩余天数</Text>
|
||||||
|
<Text style={[styles.progressValue, { color: colorTokens.text }]}>{task.daysRemaining}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 底部操作按钮 */}
|
||||||
|
{task.status !== 'completed' && task.status !== 'skipped' && task.currentCount < task.targetCount && (
|
||||||
|
<View style={styles.actionButtons}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.actionButton, styles.skipButton]}
|
||||||
|
onPress={handleSkipTask}
|
||||||
|
>
|
||||||
|
<MaterialIcons name="skip-next" size={20} color="#6B7280" />
|
||||||
|
<Text style={styles.skipButtonText}>跳过任务</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 评论区域 */}
|
||||||
|
<View style={styles.commentSection}>
|
||||||
|
<Text style={[styles.sectionTitle, { color: colorTokens.text }]}>评论区域</Text>
|
||||||
|
<View style={styles.commentInputContainer}>
|
||||||
|
<View style={styles.commentAvatar}>
|
||||||
|
<Image
|
||||||
|
source={require('@/assets/images/Sealife.jpeg')}
|
||||||
|
style={styles.commentAvatarImage}
|
||||||
|
resizeMode="cover"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View style={styles.commentInputWrapper}>
|
||||||
|
<TextInput
|
||||||
|
style={[styles.commentInput, {
|
||||||
|
color: colorTokens.text,
|
||||||
|
backgroundColor: '#F3F4F6'
|
||||||
|
}]}
|
||||||
|
placeholder="写评论..."
|
||||||
|
placeholderTextColor="#9CA3AF"
|
||||||
|
value={comment}
|
||||||
|
onChangeText={setComment}
|
||||||
|
multiline
|
||||||
|
maxLength={500}
|
||||||
|
/>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.sendButton, {
|
||||||
|
backgroundColor: comment.trim() ? '#6B7280' : '#D1D5DB'
|
||||||
|
}]}
|
||||||
|
onPress={handleSendComment}
|
||||||
|
disabled={!comment.trim()}
|
||||||
|
>
|
||||||
|
<MaterialIcons name="send" size={16} color="#FFFFFF" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
loadingContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
loadingText: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
errorContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
errorText: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
completeButton: {
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: 16,
|
||||||
|
backgroundColor: '#7A5AF8',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
taskIcon: {
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
},
|
||||||
|
scrollView: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
titleSection: {
|
||||||
|
padding: 16,
|
||||||
|
paddingBottom: 8,
|
||||||
|
},
|
||||||
|
taskTitle: {
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: '600',
|
||||||
|
lineHeight: 28,
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
createdDate: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '400',
|
||||||
|
opacity: 0.7,
|
||||||
|
},
|
||||||
|
statusContainer: {
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingBottom: 16,
|
||||||
|
alignItems: 'flex-end',
|
||||||
|
},
|
||||||
|
statusTag: {
|
||||||
|
alignSelf: 'flex-end',
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 6,
|
||||||
|
borderRadius: 16,
|
||||||
|
},
|
||||||
|
statusTagText: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#FFFFFF',
|
||||||
|
},
|
||||||
|
imagePlaceholder: {
|
||||||
|
height: 240,
|
||||||
|
backgroundColor: '#F9FAFB',
|
||||||
|
marginHorizontal: 16,
|
||||||
|
marginBottom: 20,
|
||||||
|
borderRadius: 12,
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: '#E5E7EB',
|
||||||
|
borderStyle: 'dashed',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
imagePlaceholderText: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '500',
|
||||||
|
color: '#9CA3AF',
|
||||||
|
marginTop: 8,
|
||||||
|
},
|
||||||
|
descriptionSection: {
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
descriptionText: {
|
||||||
|
fontSize: 15,
|
||||||
|
lineHeight: 22,
|
||||||
|
fontWeight: '400',
|
||||||
|
},
|
||||||
|
infoSection: {
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
infoItem: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
infoLabel: {
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
priorityTag: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 4,
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 4,
|
||||||
|
borderRadius: 12,
|
||||||
|
backgroundColor: '#EF4444',
|
||||||
|
},
|
||||||
|
priorityTagText: {
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#FFFFFF',
|
||||||
|
},
|
||||||
|
difficultyTag: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 4,
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 4,
|
||||||
|
borderRadius: 12,
|
||||||
|
},
|
||||||
|
difficultyTagText: {
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#FFFFFF',
|
||||||
|
},
|
||||||
|
progressSection: {
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
progressBar: {
|
||||||
|
height: 6,
|
||||||
|
backgroundColor: '#F3F4F6',
|
||||||
|
borderRadius: 3,
|
||||||
|
marginBottom: 16,
|
||||||
|
overflow: 'visible',
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 1 },
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 2,
|
||||||
|
elevation: 2,
|
||||||
|
position: 'relative',
|
||||||
|
},
|
||||||
|
progressFill: {
|
||||||
|
height: '100%',
|
||||||
|
borderRadius: 3,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 1 },
|
||||||
|
shadowOpacity: 0.2,
|
||||||
|
shadowRadius: 2,
|
||||||
|
elevation: 3,
|
||||||
|
},
|
||||||
|
progressGlow: {
|
||||||
|
position: 'absolute',
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
width: 8,
|
||||||
|
height: '100%',
|
||||||
|
backgroundColor: 'rgba(255, 255, 255, 0.6)',
|
||||||
|
borderRadius: 3,
|
||||||
|
},
|
||||||
|
progressTextContainer: {
|
||||||
|
position: 'absolute',
|
||||||
|
right: 0,
|
||||||
|
top: -6,
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
paddingHorizontal: 6,
|
||||||
|
paddingVertical: 2,
|
||||||
|
borderRadius: 8,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 1 },
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 2,
|
||||||
|
elevation: 2,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#E5E7EB',
|
||||||
|
zIndex: 1,
|
||||||
|
},
|
||||||
|
progressText: {
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#374151',
|
||||||
|
},
|
||||||
|
progressInfo: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
},
|
||||||
|
progressItem: {
|
||||||
|
alignItems: 'center',
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
progressLabel: {
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: '400',
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
progressValue: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
commentSection: {
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
commentInputContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
commentAvatar: {
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
borderRadius: 14,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
commentAvatarImage: {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
},
|
||||||
|
commentInputWrapper: {
|
||||||
|
flex: 1,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
commentInput: {
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 36,
|
||||||
|
maxHeight: 120,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 8,
|
||||||
|
borderRadius: 18,
|
||||||
|
fontSize: 15,
|
||||||
|
textAlignVertical: 'top',
|
||||||
|
},
|
||||||
|
sendButton: {
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
borderRadius: 14,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
actionButtons: {
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingBottom: 20,
|
||||||
|
},
|
||||||
|
actionButton: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 8,
|
||||||
|
paddingVertical: 12,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
borderRadius: 8,
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
skipButton: {
|
||||||
|
backgroundColor: '#F9FAFB',
|
||||||
|
borderColor: '#E5E7EB',
|
||||||
|
},
|
||||||
|
skipButtonText: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '500',
|
||||||
|
color: '#6B7280',
|
||||||
|
},
|
||||||
|
});
|
||||||
286
app/task-list.tsx
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
import { DateSelector } from '@/components/DateSelector';
|
||||||
|
import { TaskCard } from '@/components/TaskCard';
|
||||||
|
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||||
|
import { Colors } from '@/constants/Colors';
|
||||||
|
import { TAB_BAR_BOTTOM_OFFSET, TAB_BAR_HEIGHT } from '@/constants/TabBar';
|
||||||
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
|
import { tasksApi } from '@/services/tasksApi';
|
||||||
|
import { TaskListItem } from '@/types/goals';
|
||||||
|
import { getTodayIndexInMonth } from '@/utils/date';
|
||||||
|
import { useFocusEffect } from '@react-navigation/native';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Alert, FlatList, RefreshControl, StatusBar, StyleSheet, Text, View } from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
|
export default function GoalsDetailScreen() {
|
||||||
|
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||||
|
const colorTokens = Colors[theme];
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
// 本地状态管理
|
||||||
|
const [tasks, setTasks] = useState<TaskListItem[]>([]);
|
||||||
|
const [tasksLoading, setTasksLoading] = useState(false);
|
||||||
|
const [tasksError, setTasksError] = useState<string | null>(null);
|
||||||
|
const [selectedDate, setSelectedDate] = useState<Date>(new Date());
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
|
||||||
|
// 日期选择器相关状态
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth());
|
||||||
|
|
||||||
|
// 加载任务列表
|
||||||
|
const loadTasks = async (targetDate?: Date) => {
|
||||||
|
try {
|
||||||
|
setTasksLoading(true);
|
||||||
|
setTasksError(null);
|
||||||
|
|
||||||
|
const dateToUse = targetDate || selectedDate;
|
||||||
|
console.log('Loading tasks for date:', dayjs(dateToUse).format('YYYY-MM-DD'));
|
||||||
|
|
||||||
|
const response = await tasksApi.getTasks({
|
||||||
|
startDate: dayjs(dateToUse).startOf('day').toISOString(),
|
||||||
|
endDate: dayjs(dateToUse).endOf('day').toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Tasks API response:', response);
|
||||||
|
setTasks(response.list || []);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Failed to load tasks:', error);
|
||||||
|
setTasksError(error.message || '获取任务列表失败');
|
||||||
|
setTasks([]);
|
||||||
|
} finally {
|
||||||
|
setTasksLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 页面聚焦时重新加载数据
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
console.log('useFocusEffect - loading tasks');
|
||||||
|
loadTasks();
|
||||||
|
}, [])
|
||||||
|
);
|
||||||
|
|
||||||
|
// 下拉刷新
|
||||||
|
const onRefresh = async () => {
|
||||||
|
setRefreshing(true);
|
||||||
|
try {
|
||||||
|
await loadTasks();
|
||||||
|
} finally {
|
||||||
|
setRefreshing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理错误提示
|
||||||
|
useEffect(() => {
|
||||||
|
if (tasksError) {
|
||||||
|
Alert.alert('错误', tasksError);
|
||||||
|
setTasksError(null);
|
||||||
|
}
|
||||||
|
}, [tasksError]);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// 日期选择处理
|
||||||
|
const onSelectDate = async (index: number, date: Date) => {
|
||||||
|
console.log('Date selected:', dayjs(date).format('YYYY-MM-DD'));
|
||||||
|
setSelectedIndex(index);
|
||||||
|
setSelectedDate(date);
|
||||||
|
// 重新加载对应日期的任务数据
|
||||||
|
await loadTasks(date);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 根据选中日期筛选任务,并将已完成的任务放到最后
|
||||||
|
const filteredTasks = useMemo(() => {
|
||||||
|
const selected = dayjs(selectedDate);
|
||||||
|
const filtered = tasks.filter(task => {
|
||||||
|
if (task.status === 'skipped') return false;
|
||||||
|
const taskDate = dayjs(task.startDate);
|
||||||
|
return taskDate.isSame(selected, 'day');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 对筛选结果进行排序:已完成的任务放到最后
|
||||||
|
return [...filtered].sort((a, b) => {
|
||||||
|
const aCompleted = a.status === 'completed';
|
||||||
|
const bCompleted = b.status === 'completed';
|
||||||
|
|
||||||
|
// 如果a已完成而b未完成,a排在后面
|
||||||
|
if (aCompleted && !bCompleted) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
// 如果b已完成而a未完成,b排在后面
|
||||||
|
if (bCompleted && !aCompleted) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
// 如果都已完成或都未完成,保持原有顺序
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
}, [selectedDate, tasks]);
|
||||||
|
|
||||||
|
const handleBackPress = () => {
|
||||||
|
router.back();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 渲染任务项
|
||||||
|
const renderTaskItem = ({ item }: { item: TaskListItem }) => (
|
||||||
|
<TaskCard
|
||||||
|
task={item}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// 渲染空状态
|
||||||
|
const renderEmptyState = () => {
|
||||||
|
const selectedDateStr = dayjs(selectedDate).format('YYYY年M月D日');
|
||||||
|
|
||||||
|
if (tasksLoading) {
|
||||||
|
return (
|
||||||
|
<View style={styles.emptyState}>
|
||||||
|
<Text style={[styles.emptyStateTitle, { color: colorTokens.text }]}>
|
||||||
|
加载中...
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.emptyState}>
|
||||||
|
<Text style={[styles.emptyStateTitle, { color: colorTokens.text }]}>
|
||||||
|
暂无任务
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.emptyStateSubtitle, { color: colorTokens.textSecondary }]}>
|
||||||
|
{selectedDateStr} 没有任务安排
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.container}>
|
||||||
|
<StatusBar
|
||||||
|
backgroundColor="transparent"
|
||||||
|
translucent
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 背景渐变 */}
|
||||||
|
<LinearGradient
|
||||||
|
colors={['#F0F9FF', '#E0F2FE']}
|
||||||
|
style={styles.gradientBackground}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 1 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 装饰性圆圈 */}
|
||||||
|
<View style={styles.decorativeCircle1} />
|
||||||
|
<View style={styles.decorativeCircle2} />
|
||||||
|
|
||||||
|
<View style={styles.content}>
|
||||||
|
{/* 标题区域 */}
|
||||||
|
<HeaderBar
|
||||||
|
title="任务列表"
|
||||||
|
onBack={handleBackPress}
|
||||||
|
transparent={true}
|
||||||
|
withSafeTop={false}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 日期选择器 */}
|
||||||
|
<View style={styles.dateSelector}>
|
||||||
|
<DateSelector
|
||||||
|
selectedIndex={selectedIndex}
|
||||||
|
onDateSelect={onSelectDate}
|
||||||
|
showMonthTitle={true}
|
||||||
|
disableFutureDates={true}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 任务列表 */}
|
||||||
|
<View style={styles.taskListContainer}>
|
||||||
|
<FlatList
|
||||||
|
data={filteredTasks}
|
||||||
|
renderItem={renderTaskItem}
|
||||||
|
keyExtractor={(item) => item.id}
|
||||||
|
contentContainerStyle={styles.taskList}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={refreshing}
|
||||||
|
onRefresh={onRefresh}
|
||||||
|
colors={['#0EA5E9']}
|
||||||
|
tintColor="#0EA5E9"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
ListEmptyComponent={renderEmptyState}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
gradientBackground: {
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
opacity: 0.6,
|
||||||
|
},
|
||||||
|
decorativeCircle1: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: -20,
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
|
||||||
|
// 日期选择器样式
|
||||||
|
dateSelector: {
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
},
|
||||||
|
taskListContainer: {
|
||||||
|
flex: 1,
|
||||||
|
borderTopLeftRadius: 24,
|
||||||
|
borderTopRightRadius: 24,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
taskList: {
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingBottom: TAB_BAR_HEIGHT + TAB_BAR_BOTTOM_OFFSET + 20,
|
||||||
|
},
|
||||||
|
emptyState: {
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingVertical: 60,
|
||||||
|
},
|
||||||
|
emptyStateTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '600',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
emptyStateSubtitle: {
|
||||||
|
fontSize: 14,
|
||||||
|
textAlign: 'center',
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
});
|
||||||