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.organizeImports": "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.
|
||||
|
||||
## Commands
|
||||
- **Start development server**: `npm start`
|
||||
- **Run on Android**: `npm run android`
|
||||
- **Run on iOS**: `npm run ios`
|
||||
- **Run on Web**: `npm run web`
|
||||
- **Lint**: `npm run lint`
|
||||
- **Reset project**: `npm run reset-project`
|
||||
|
||||
## Architecture
|
||||
- **Framework**: React Native (Expo) with TypeScript.
|
||||
- **Navigation**: Expo Router for file-based routing (`app/` directory).
|
||||
- **UI**: Themed components (`ThemedText`, `ThemedView`) and reusable UI elements (`Collapsible`, `ParallaxScrollView`).
|
||||
- **Platform-Specific**: Android (`android/`) and iOS (`ios/`) configurations with native modules.
|
||||
- **Hooks**: Custom hooks for color scheme (`useColorScheme`) and theme management (`useThemeColor`).
|
||||
- **Dependencies**: React Navigation for tab-based navigation, Expo modules for native features (haptics, blur, etc.).
|
||||
- **Framework**: React Native (Expo Prebuild/Ejected) with TypeScript using Expo Router for file-based navigation
|
||||
- **State Management**: Redux Toolkit with domain-specific slices (`store/`) and typed hooks (`hooks/redux.ts`)
|
||||
- **Authentication**: Custom auth guard system with `useAuthGuard` hook for protected navigation
|
||||
- **Navigation**:
|
||||
- File-based routing in `app/` directory with nested layouts
|
||||
- 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)
|
||||
@@ -93,7 +93,9 @@ android {
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 1
|
||||
versionName "1.0.0"
|
||||
versionName "1.0.12"
|
||||
|
||||
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
|
||||
}
|
||||
signingConfigs {
|
||||
debug {
|
||||
@@ -111,15 +113,18 @@ android {
|
||||
// Caution! In production, you need to generate your own keystore file.
|
||||
// see https://reactnative.dev/docs/signed-apk-android.
|
||||
signingConfig signingConfigs.debug
|
||||
shrinkResources (findProperty('android.enableShrinkResourcesInReleaseBuilds')?.toBoolean() ?: false)
|
||||
minifyEnabled enableProguardInReleaseBuilds
|
||||
def enableShrinkResources = findProperty('android.enableShrinkResourcesInReleaseBuilds') ?: 'false'
|
||||
shrinkResources enableShrinkResources.toBoolean()
|
||||
minifyEnabled enableMinifyInReleaseBuilds
|
||||
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
|
||||
crunchPngs (findProperty('android.enablePngCrunchInReleaseBuilds')?.toBoolean() ?: true)
|
||||
def enablePngCrunchInRelease = findProperty('android.enablePngCrunchInReleaseBuilds') ?: 'true'
|
||||
crunchPngs enablePngCrunchInRelease.toBoolean()
|
||||
}
|
||||
}
|
||||
packagingOptions {
|
||||
jniLibs {
|
||||
useLegacyPackaging (findProperty('expo.useLegacyPackaging')?.toBoolean() ?: false)
|
||||
def enableLegacyPackaging = findProperty('expo.useLegacyPackaging') ?: 'false'
|
||||
useLegacyPackaging enableLegacyPackaging.toBoolean()
|
||||
}
|
||||
}
|
||||
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.READ_EXTERNAL_STORAGE"/>
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
|
||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
|
||||
<uses-permission android:name="android.permission.VIBRATE"/>
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
|
||||
@@ -11,7 +13,11 @@
|
||||
<data android:scheme="https"/>
|
||||
</intent>
|
||||
</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.EXPO_UPDATES_CHECK_ON_LAUNCH" android:value="ALWAYS"/>
|
||||
<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.ReactApplication
|
||||
import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative
|
||||
import com.facebook.react.ReactNativeHost
|
||||
import com.facebook.react.ReactPackage
|
||||
import com.facebook.react.ReactHost
|
||||
import com.facebook.react.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.soloader.OpenSourceMergedSoMapping
|
||||
import com.facebook.soloader.SoLoader
|
||||
|
||||
import expo.modules.ApplicationLifecycleDispatcher
|
||||
import expo.modules.ReactNativeHostWrapper
|
||||
@@ -21,11 +21,10 @@ class MainApplication : Application(), ReactApplication {
|
||||
override val reactNativeHost: ReactNativeHost = ReactNativeHostWrapper(
|
||||
this,
|
||||
object : DefaultReactNativeHost(this) {
|
||||
override fun getPackages(): List<ReactPackage> {
|
||||
val packages = PackageList(this).packages
|
||||
override fun getPackages(): List<ReactPackage> =
|
||||
PackageList(this).packages.apply {
|
||||
// Packages that cannot be autolinked yet can be added manually here, for example:
|
||||
// packages.add(MyReactNativePackage())
|
||||
return packages
|
||||
// add(MyReactNativePackage())
|
||||
}
|
||||
|
||||
override fun getJSMainModuleName(): String = ".expo/.virtual-metro-entry"
|
||||
@@ -33,7 +32,6 @@ class MainApplication : Application(), ReactApplication {
|
||||
override fun getUseDeveloperSupport(): Boolean = BuildConfig.DEBUG
|
||||
|
||||
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() {
|
||||
super.onCreate()
|
||||
SoLoader.init(this, OpenSourceMergedSoMapping)
|
||||
if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
|
||||
// If you opted-in for the New Architecture, we load the native entry point for this app.
|
||||
load()
|
||||
DefaultNewArchitectureEntryPoint.releaseLevel = try {
|
||||
ReleaseLevel.valueOf(BuildConfig.REACT_NATIVE_RELEASE_LEVEL.uppercase())
|
||||
} catch (e: IllegalArgumentException) {
|
||||
ReleaseLevel.STABLE
|
||||
}
|
||||
loadReactNative(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="colorPrimary">#023c69</color>
|
||||
<color name="colorPrimaryDark">#ffffff</color>
|
||||
<color name="notification_icon_color">#ffffff</color>
|
||||
</resources>
|
||||
@@ -1,6 +1,6 @@
|
||||
<resources>
|
||||
<string name="app_name">digital-pilates</string>
|
||||
<string name="expo_system_ui_user_interface_style" translatable="false">automatic</string>
|
||||
<string name="app_name">Out Live</string>
|
||||
<string name="expo_system_ui_user_interface_style" translatable="false">light</string>
|
||||
<string name="expo_splash_screen_resize_mode" translatable="false">contain</string>
|
||||
<string name="expo_splash_screen_status_bar_translucent" translatable="false">false</string>
|
||||
</resources>
|
||||
@@ -1,5 +1,6 @@
|
||||
<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="colorPrimary">@color/colorPrimary</item>
|
||||
<item name="android:statusBarColor">#ffffff</item>
|
||||
@@ -8,5 +9,6 @@
|
||||
<item name="windowSplashScreenBackground">@color/splashscreen_background</item>
|
||||
<item name="windowSplashScreenAnimatedIcon">@drawable/splashscreen_logo</item>
|
||||
<item name="postSplashScreenTheme">@style/AppTheme</item>
|
||||
<item name="android:windowSplashScreenBehavior">icon_preferred</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -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 {
|
||||
repositories {
|
||||
maven {
|
||||
// All of React Native (JS, Obj-C sources, Android binaries) is installed from npm
|
||||
url(reactNativeAndroidDir)
|
||||
}
|
||||
|
||||
google()
|
||||
mavenCentral()
|
||||
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.
|
||||
# This option should only be used with decoupled projects. More details, visit
|
||||
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
|
||||
# org.gradle.parallel=true
|
||||
org.gradle.parallel=true
|
||||
|
||||
# AndroidX package structure to make it clearer which packages are bundled with the
|
||||
# Android operating system, and which are packaged with your app's APK
|
||||
@@ -39,7 +39,12 @@ newArchEnabled=true
|
||||
|
||||
# Use this property to enable or disable the Hermes JS engine.
|
||||
# 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)
|
||||
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.
|
||||
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
|
||||
# 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
|
||||
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
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
|
||||
4
android/gradlew
vendored
@@ -114,7 +114,7 @@ case "$( uname )" in #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
CLASSPATH="\\\"\\\""
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
@@ -213,7 +213,7 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-classpath "$CLASSPATH" \
|
||||
org.gradle.wrapper.GradleWrapperMain \
|
||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
|
||||
4
android/gradlew.bat
vendored
@@ -70,11 +70,11 @@ goto fail
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
set CLASSPATH=
|
||||
|
||||
|
||||
@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
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
|
||||
@@ -31,7 +31,7 @@ extensions.configure(com.facebook.react.ReactSettingsExtension) { ex ->
|
||||
}
|
||||
expoAutolinking.useExpoModules()
|
||||
|
||||
rootProject.name = 'digital-pilates'
|
||||
rootProject.name = 'Out Live'
|
||||
|
||||
expoAutolinking.useExpoVersionCatalog()
|
||||
|
||||
|
||||
63
app.json
@@ -1,57 +1,72 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "digital-pilates",
|
||||
"name": "Out Live",
|
||||
"slug": "digital-pilates",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.15",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/images/icon.png",
|
||||
"scheme": "digitalpilates",
|
||||
"userInterfaceStyle": "automatic",
|
||||
"userInterfaceStyle": "light",
|
||||
"icon": "./assets/icon.icon/Assets/icon-1756312748268.jpg",
|
||||
"newArchEnabled": true,
|
||||
"jsEngine": "jsc",
|
||||
"ios": {
|
||||
"supportsTablet": true,
|
||||
"supportsTablet": false,
|
||||
"deploymentTarget": "16.0",
|
||||
"bundleIdentifier": "com.anonymous.digitalpilates",
|
||||
"infoPlist": {
|
||||
"ITSAppUsesNonExemptEncryption": false,
|
||||
"NSCameraUsageDescription": "应用需要使用相机以拍摄您的体态照片用于AI测评。",
|
||||
"NSPhotoLibraryUsageDescription": "应用需要访问相册以选择您的体态照片用于AI测评。",
|
||||
"NSPhotoLibraryAddUsageDescription": "应用需要写入相册以保存拍摄的体态照片(可选)。"
|
||||
}
|
||||
"NSPhotoLibraryAddUsageDescription": "应用需要写入相册以保存拍摄的体态照片(可选)。",
|
||||
"NSMicrophoneUsageDescription": "应用需要使用麦克风进行语音识别,将您的语音转换为文字记录饮食信息。",
|
||||
"NSSpeechRecognitionUsageDescription": "应用需要使用语音识别功能来转换您的语音为文字,帮助您快速记录饮食信息。",
|
||||
"NSUserNotificationsUsageDescription": "应用需要发送通知以提醒您喝水和站立活动。",
|
||||
"UIBackgroundModes": [
|
||||
"processing",
|
||||
"fetch",
|
||||
"remote-notification"
|
||||
]
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/images/adaptive-icon.png",
|
||||
"backgroundColor": "#ffffff"
|
||||
},
|
||||
"edgeToEdgeEnabled": true,
|
||||
"package": "com.anonymous.digitalpilates"
|
||||
},
|
||||
"web": {
|
||||
"bundler": "metro",
|
||||
"output": "static",
|
||||
"favicon": "./assets/images/favicon.png"
|
||||
"appleTeamId": "756WVXJ6MT"
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
[
|
||||
"expo-splash-screen",
|
||||
{
|
||||
"image": "./assets/images/splash-icon.png",
|
||||
"imageWidth": 200,
|
||||
"image": "./assets/icon.icon/Assets/icon-1756312748268.jpg",
|
||||
"imageWidth": 40,
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#ffffff"
|
||||
}
|
||||
],
|
||||
[
|
||||
"react-native-health",
|
||||
"expo-notifications",
|
||||
{
|
||||
"enableHealthAPI": true,
|
||||
"healthSharePermission": "应用需要访问您的健康数据(步数与能量消耗)以展示运动统计。"
|
||||
"icon": "./assets/icon.icon/Assets/icon-1756312748268.jpg",
|
||||
"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": {
|
||||
"typedRoutes": true
|
||||
},
|
||||
"android": {
|
||||
"package": "com.anonymous.digitalpilates"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,116 +1,164 @@
|
||||
import type { BottomTabNavigationOptions } from '@react-navigation/bottom-tabs';
|
||||
import { GlassContainer, GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { Tabs, usePathname } from 'expo-router';
|
||||
import { Icon, Label, NativeTabs } from 'expo-router/unstable-native-tabs';
|
||||
|
||||
import React from 'react';
|
||||
import { Text, TouchableOpacity, View } from 'react-native';
|
||||
import { Text, TouchableOpacity, View, ViewStyle } from 'react-native';
|
||||
|
||||
import { IconSymbol } from '@/components/ui/IconSymbol';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { ROUTES } from '@/constants/Routes';
|
||||
import { TAB_BAR_BOTTOM_OFFSET, TAB_BAR_HEIGHT } from '@/constants/TabBar';
|
||||
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() {
|
||||
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||
const colorTokens = Colors[theme];
|
||||
const pathname = usePathname();
|
||||
const glassEffectAvailable = isLiquidGlassAvailable();
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
screenOptions={({ route }) => {
|
||||
const routeName = route.name;
|
||||
const isSelected = (routeName === 'index' && pathname === '/') ||
|
||||
(routeName === 'explore' && pathname === '/explore') ||
|
||||
pathname.includes(routeName);
|
||||
// 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 {
|
||||
headerShown: false,
|
||||
tabBarActiveTintColor: colorTokens.tabIconSelected,
|
||||
tabBarButton: (props) => {
|
||||
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 && onPress(event);
|
||||
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"
|
||||
activeOpacity={1}
|
||||
style={{
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexDirection: 'row',
|
||||
marginHorizontal: 6,
|
||||
marginHorizontal: 2,
|
||||
marginVertical: 10,
|
||||
borderRadius: 25,
|
||||
backgroundColor: isSelected ? colorTokens.tabBarActiveBackground : 'transparent',
|
||||
paddingHorizontal: isSelected ? 16 : 10,
|
||||
paddingHorizontal: isSelected ? 8 : 4,
|
||||
paddingVertical: 8,
|
||||
}}
|
||||
>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
||||
<IconSymbol
|
||||
size={22}
|
||||
name={icon as any}
|
||||
color={isSelected ? activeContentColor : inactiveContentColor}
|
||||
name={tabConfig.icon as any}
|
||||
color={isSelected ? colorTokens.tabIconSelected : colorTokens.tabIconDefault}
|
||||
/>
|
||||
{isSelected && !!title && (
|
||||
{isSelected && (
|
||||
<Text
|
||||
style={{
|
||||
color: activeContentColor,
|
||||
color: colorTokens.tabIconSelected,
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
marginLeft: 6,
|
||||
}}
|
||||
// 选中态下不限制行数,避免大屏布局下被裁剪成省略号
|
||||
numberOfLines={0 as any}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{title}
|
||||
{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: colorTokens.tabBarBackground,
|
||||
backgroundColor: glassEffectAvailable ? 'transparent' : colorTokens.tabBarBackground,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowOpacity: glassEffectAvailable ? 0.1 : 0.2,
|
||||
shadowRadius: 10,
|
||||
elevation: 5,
|
||||
paddingHorizontal: 10,
|
||||
paddingHorizontal: 6,
|
||||
paddingTop: 0,
|
||||
paddingBottom: 0,
|
||||
marginHorizontal: 20,
|
||||
width: '90%',
|
||||
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,
|
||||
@@ -120,93 +168,39 @@ export default function TabLayout() {
|
||||
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>
|
||||
);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
});
|
||||
|
||||
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>
|
||||
}
|
||||
|
||||
<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
|
||||
initialRouteName="statistics"
|
||||
screenOptions={({ route }) => getScreenOptions(route.name)}
|
||||
>
|
||||
|
||||
<Tabs.Screen name="statistics" options={{ title: '健康' }} />
|
||||
<Tabs.Screen name="goals" options={{ title: '习惯' }} />
|
||||
<Tabs.Screen name="challenges" options={{ title: '挑战' }} />
|
||||
<Tabs.Screen name="personal" options={{ title: '个人' }} />
|
||||
</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 { 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 AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
SafeAreaView,
|
||||
ScrollView,
|
||||
StatusBar,
|
||||
StyleSheet,
|
||||
Switch,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View
|
||||
} from 'react-native';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
import { isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
import { Image } from 'expo-image';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Alert, Linking, ScrollView, StatusBar, StyleSheet, Switch, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
export default function PersonalScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const tabBarHeight = useBottomTabBarHeight();
|
||||
const bottomPadding = useMemo(() => {
|
||||
// 统一的页面底部留白:TabBar 高度 + TabBar 与底部的额外间距 + 安全区底部
|
||||
return getTabBarBottomPadding(tabBarHeight) + (insets?.bottom ?? 0);
|
||||
}, [tabBarHeight, insets?.bottom]);
|
||||
const [notificationEnabled, setNotificationEnabled] = useState(true);
|
||||
const colorScheme = useColorScheme();
|
||||
const colors = Colors[colorScheme ?? 'light'];
|
||||
const DEFAULT_AVATAR_URL = 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/seal-avatar/2.jpeg';
|
||||
|
||||
const handleResetOnboarding = () => {
|
||||
Alert.alert(
|
||||
'重置引导',
|
||||
'确定要重置引导流程吗?下次启动应用时将重新显示引导页面。',
|
||||
[
|
||||
{
|
||||
text: '取消',
|
||||
style: 'cancel',
|
||||
},
|
||||
{
|
||||
text: '确定',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
try {
|
||||
await AsyncStorage.multiRemove(['@onboarding_completed', '@user_personal_info']);
|
||||
Alert.alert('成功', '引导状态已重置,请重启应用查看效果。');
|
||||
} catch (error) {
|
||||
console.error('重置引导状态失败:', error);
|
||||
Alert.alert('错误', '重置失败,请稍后重试。');
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
export default function PersonalScreen() {
|
||||
const dispatch = useAppDispatch();
|
||||
const { confirmLogout, confirmDeleteAccount, isLoggedIn, pushIfAuthedElseLogin } = useAuthGuard();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
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(() => {
|
||||
return getTabBarBottomPadding(60) + (insets?.bottom ?? 0);
|
||||
}, [insets?.bottom]);
|
||||
|
||||
// 直接使用 Redux 中的用户信息,避免重复状态管理
|
||||
const userProfile = useAppSelector((state) => state.user.profile);
|
||||
|
||||
|
||||
// 页面聚焦时获取最新用户信息
|
||||
useFocusEffect(
|
||||
React.useCallback(() => {
|
||||
dispatch(fetchMyProfile());
|
||||
dispatch(fetchActivityHistory());
|
||||
// 加载用户推送偏好设置
|
||||
loadNotificationPreference();
|
||||
// 加载开发者模式状态
|
||||
loadDeveloperModeState();
|
||||
}, [dispatch])
|
||||
);
|
||||
|
||||
// 加载用户推送偏好设置
|
||||
const loadNotificationPreference = async () => {
|
||||
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}>
|
||||
<View style={styles.userInfoContainer}>
|
||||
{/* 头像 */}
|
||||
// 保存开发者模式状态
|
||||
const saveDeveloperModeState = async (enabled: boolean) => {
|
||||
try {
|
||||
await setItem('developer_mode_enabled', enabled.toString());
|
||||
} catch (error) {
|
||||
console.error('保存开发者模式状态失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 数据格式化函数
|
||||
const formatHeight = () => {
|
||||
if (userProfile.height == null) return '--';
|
||||
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.avatar}>
|
||||
<View style={styles.avatarContent}>
|
||||
{/* 简单的头像图标,您可以替换为实际图片 */}
|
||||
<View style={styles.avatarIcon}>
|
||||
<View style={styles.avatarFace} />
|
||||
<View style={styles.avatarBody} />
|
||||
<Image
|
||||
source={userProfile.avatar || DEFAULT_AVATAR_URL}
|
||||
style={styles.avatar}
|
||||
contentFit="cover"
|
||||
transition={200}
|
||||
cachePolicy="memory-disk"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 用户信息 */}
|
||||
<View style={styles.userDetails}>
|
||||
<Text style={styles.userName}>Masi Ramezanzade</Text>
|
||||
<Text style={styles.userProgram}>Lose a Fat Program</Text>
|
||||
<TouchableOpacity onPress={handleUserNamePress} activeOpacity={0.7}>
|
||||
<Text style={styles.userName}>{displayName}</Text>
|
||||
</TouchableOpacity>
|
||||
{userProfile.memberNumber && (
|
||||
<Text style={styles.userMemberNumber}>会员编号: {userProfile.memberNumber}</Text>
|
||||
)}
|
||||
</View>
|
||||
<TouchableOpacity style={styles.editButton} onPress={() => pushIfAuthedElseLogin('/profile/edit')}>
|
||||
<Text style={styles.editButtonText}>{isLoggedIn ? '编辑' : '登录'}</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
</View>
|
||||
|
||||
{/* 编辑按钮 */}
|
||||
<TouchableOpacity style={dynamicStyles.editButton}>
|
||||
<Text style={dynamicStyles.editButtonText}>Edit</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
// 数据统计部分
|
||||
const StatsSection = () => (
|
||||
<View style={styles.sectionContainer}>
|
||||
<View style={[styles.cardContainer, {
|
||||
backgroundColor: 'unset'
|
||||
}]}>
|
||||
<View style={styles.statsContainer}>
|
||||
<View style={styles.statItem}>
|
||||
<Text style={dynamicStyles.statValue}>180cm</Text>
|
||||
<Text style={styles.statLabel}>Height</Text>
|
||||
<Text style={styles.statValue}>{formatHeight()}</Text>
|
||||
<Text style={styles.statLabel}>身高</Text>
|
||||
</View>
|
||||
<View style={styles.statItem}>
|
||||
<Text style={dynamicStyles.statValue}>65kg</Text>
|
||||
<Text style={styles.statLabel}>Weight</Text>
|
||||
<Text style={styles.statValue}>{formatWeight()}</Text>
|
||||
<Text style={styles.statLabel}>体重</Text>
|
||||
</View>
|
||||
<View style={styles.statItem}>
|
||||
<Text style={dynamicStyles.statValue}>22yo</Text>
|
||||
<Text style={styles.statLabel}>Age</Text>
|
||||
<Text style={styles.statValue}>{formatAge()}</Text>
|
||||
<Text style={styles.statLabel}>年龄</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
// 菜单项组件
|
||||
const MenuSection = ({ title, items }: { title: string; items: any[] }) => (
|
||||
<View style={styles.menuSection}>
|
||||
<View style={styles.sectionContainer}>
|
||||
<Text style={styles.sectionTitle}>{title}</Text>
|
||||
<View style={styles.cardContainer}>
|
||||
{items.map((item, index) => (
|
||||
<TouchableOpacity
|
||||
key={index}
|
||||
style={styles.menuItem}
|
||||
onPress={item.onPress}
|
||||
style={[styles.menuItem, index === items.length - 1 && { borderBottomWidth: 0 }]}
|
||||
onPress={item.type === 'switch' ? undefined : item.onPress}
|
||||
disabled={item.type === 'switch'}
|
||||
>
|
||||
<View style={styles.menuItemLeft}>
|
||||
<View style={[styles.menuIcon]}>
|
||||
<Ionicons name={item.icon} size={20} color={item.iconColor || colors.primary} />
|
||||
<View style={[
|
||||
styles.iconContainer,
|
||||
{ backgroundColor: item.isDanger ? 'rgba(255,68,68,0.1)' : 'rgba(147, 112, 219, 0.1)' }
|
||||
]}>
|
||||
<Ionicons
|
||||
name={item.icon}
|
||||
size={20}
|
||||
color={item.isDanger ? '#FF4444' : '#9370DB'}
|
||||
/>
|
||||
</View>
|
||||
<Text style={styles.menuItemText}>{item.title}</Text>
|
||||
</View>
|
||||
{item.type === 'switch' ? (
|
||||
<Switch
|
||||
value={notificationEnabled}
|
||||
onValueChange={setNotificationEnabled}
|
||||
trackColor={{ false: '#E5E5E5', true: colors.primary }}
|
||||
value={item.switchValue || false}
|
||||
onValueChange={item.onSwitchChange || (() => { })}
|
||||
trackColor={{ false: '#E5E5E5', true: '#9370DB' }}
|
||||
thumbColor="#FFFFFF"
|
||||
style={styles.switch}
|
||||
/>
|
||||
) : (
|
||||
<Ionicons name="chevron-forward" size={20} color="#C4C4C4" />
|
||||
<Ionicons name="chevron-forward" size={20} color="#CCCCCC" />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
// 动态创建样式
|
||||
const dynamicStyles = {
|
||||
editButton: {
|
||||
backgroundColor: colors.primary,
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 20,
|
||||
},
|
||||
editButtonText: {
|
||||
color: '#192126',
|
||||
fontSize: 14,
|
||||
fontWeight: '600' as const,
|
||||
},
|
||||
statValue: {
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold' as const,
|
||||
color: colors.primary,
|
||||
marginBottom: 4,
|
||||
},
|
||||
floatingButton: {
|
||||
width: 56,
|
||||
height: 56,
|
||||
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 menuSections = [
|
||||
{
|
||||
icon: 'person-outline',
|
||||
iconBg: '#E8F5E8',
|
||||
iconColor: '#4ADE80',
|
||||
title: 'Personal Data',
|
||||
title: '通知',
|
||||
items: [
|
||||
{
|
||||
icon: 'notifications-outline' as const,
|
||||
title: '消息推送',
|
||||
type: 'switch' as const,
|
||||
switchValue: notificationEnabled,
|
||||
onSwitchChange: handleNotificationToggle,
|
||||
},
|
||||
],
|
||||
},
|
||||
// 开发者section(需要连续点击三次用户名激活)
|
||||
...(showDeveloperSection ? [{
|
||||
title: '开发者',
|
||||
items: [
|
||||
{
|
||||
icon: 'code-slash-outline' as const,
|
||||
title: '开发者选项',
|
||||
onPress: () => pushIfAuthedElseLogin(ROUTES.DEVELOPER),
|
||||
},
|
||||
],
|
||||
}] : []),
|
||||
{
|
||||
title: '其他',
|
||||
items: [
|
||||
{
|
||||
icon: 'shield-checkmark-outline' as const,
|
||||
title: '隐私政策',
|
||||
onPress: () => Linking.openURL(PRIVACY_POLICY_URL),
|
||||
},
|
||||
{
|
||||
icon: 'trophy-outline',
|
||||
iconBg: '#E8F5E8',
|
||||
iconColor: '#4ADE80',
|
||||
title: 'Achievement',
|
||||
icon: 'document-text-outline' as const,
|
||||
title: '用户协议',
|
||||
onPress: () => Linking.openURL(USER_AGREEMENT_URL),
|
||||
},
|
||||
],
|
||||
},
|
||||
// 只有登录用户才显示账号与安全菜单
|
||||
...(isLoggedIn ? [{
|
||||
title: '账号与安全',
|
||||
items: [
|
||||
{
|
||||
icon: 'log-out-outline' as const,
|
||||
title: '退出登录',
|
||||
onPress: confirmLogout,
|
||||
isDanger: false,
|
||||
},
|
||||
{
|
||||
icon: 'time-outline',
|
||||
iconBg: '#E8F5E8',
|
||||
iconColor: '#4ADE80',
|
||||
title: 'Activity History',
|
||||
},
|
||||
{
|
||||
icon: 'stats-chart-outline',
|
||||
iconBg: '#E8F5E8',
|
||||
iconColor: '#4ADE80',
|
||||
title: 'Workout Progress',
|
||||
},
|
||||
];
|
||||
|
||||
const notificationItems = [
|
||||
{
|
||||
icon: 'notifications-outline',
|
||||
iconBg: '#E8F5E8',
|
||||
iconColor: '#4ADE80',
|
||||
title: 'Pop-up Notification',
|
||||
type: 'switch',
|
||||
},
|
||||
];
|
||||
|
||||
const otherItems = [
|
||||
{
|
||||
icon: 'mail-outline',
|
||||
iconBg: '#E8F5E8',
|
||||
iconColor: '#4ADE80',
|
||||
title: 'Contact Us',
|
||||
},
|
||||
{
|
||||
icon: 'shield-checkmark-outline',
|
||||
iconBg: '#E8F5E8',
|
||||
iconColor: '#4ADE80',
|
||||
title: 'Privacy Policy',
|
||||
},
|
||||
{
|
||||
icon: 'settings-outline',
|
||||
iconBg: '#E8F5E8',
|
||||
iconColor: '#4ADE80',
|
||||
title: 'Settings',
|
||||
},
|
||||
];
|
||||
|
||||
const developerItems = [
|
||||
{
|
||||
icon: 'refresh-outline',
|
||||
iconBg: '#FFE8E8',
|
||||
iconColor: '#FF4444',
|
||||
title: '重置引导流程',
|
||||
onPress: handleResetOnboarding,
|
||||
icon: 'trash-outline' as const,
|
||||
title: '注销帐号',
|
||||
onPress: confirmDeleteAccount,
|
||||
isDanger: true,
|
||||
},
|
||||
],
|
||||
}] : []),
|
||||
];
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<StatusBar barStyle="dark-content" backgroundColor="transparent" translucent />
|
||||
<SafeAreaView style={styles.safeArea}>
|
||||
<StatusBar barStyle={'dark-content'} backgroundColor="transparent" translucent />
|
||||
|
||||
{/* 背景渐变 */}
|
||||
<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={{ paddingBottom: bottomPadding }}
|
||||
contentContainerStyle={{
|
||||
paddingTop: insets.top,
|
||||
paddingBottom: bottomPadding,
|
||||
paddingHorizontal: 16,
|
||||
}}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<UserInfoSection />
|
||||
<UserHeader />
|
||||
<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) }]}>
|
||||
<TouchableOpacity style={dynamicStyles.floatingButton}>
|
||||
<Ionicons name="search" size={24} color="#192126" />
|
||||
</TouchableOpacity>
|
||||
<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>
|
||||
</SafeAreaView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -270,60 +400,74 @@ export default function PersonalScreen() {
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#F5F5F5', // 浅灰色背景
|
||||
},
|
||||
safeArea: {
|
||||
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,
|
||||
paddingHorizontal: 20,
|
||||
backgroundColor: '#F5F5F5',
|
||||
},
|
||||
// 用户信息区域
|
||||
userInfoCard: {
|
||||
borderRadius: 16,
|
||||
// 部分容器
|
||||
sectionContainer: {
|
||||
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: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: 20,
|
||||
padding: 16,
|
||||
},
|
||||
avatarContainer: {
|
||||
marginRight: 15,
|
||||
marginRight: 12,
|
||||
},
|
||||
avatar: {
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: 40,
|
||||
backgroundColor: '#E8D4F0',
|
||||
alignItems: 'center',
|
||||
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',
|
||||
width: 60,
|
||||
height: 60,
|
||||
borderRadius: 30,
|
||||
borderWidth: 2,
|
||||
borderColor: '#9370DB',
|
||||
},
|
||||
userDetails: {
|
||||
flex: 1,
|
||||
@@ -331,91 +475,93 @@ const styles = StyleSheet.create({
|
||||
userName: {
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
color: '#000',
|
||||
color: '#2C3E50',
|
||||
marginBottom: 4,
|
||||
},
|
||||
userProgram: {
|
||||
userRole: {
|
||||
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: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: 16,
|
||||
padding: 20,
|
||||
marginBottom: 20,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 2,
|
||||
},
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 3,
|
||||
padding: 16,
|
||||
},
|
||||
statItem: {
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
},
|
||||
|
||||
statLabel: {
|
||||
fontSize: 12,
|
||||
color: '#888',
|
||||
},
|
||||
// 菜单区域
|
||||
menuSection: {
|
||||
marginBottom: 20,
|
||||
backgroundColor: '#FFFFFF',
|
||||
padding: 16,
|
||||
},
|
||||
sectionTitle: {
|
||||
statValue: {
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
color: '#000',
|
||||
marginBottom: 12,
|
||||
paddingHorizontal: 4,
|
||||
color: '#9370DB',
|
||||
marginBottom: 4,
|
||||
},
|
||||
statLabel: {
|
||||
fontSize: 12,
|
||||
color: '#6C757D',
|
||||
fontWeight: '500',
|
||||
},
|
||||
// 菜单项
|
||||
menuItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingVertical: 16,
|
||||
paddingVertical: 14,
|
||||
paddingHorizontal: 16,
|
||||
borderRadius: 12,
|
||||
marginBottom: 8,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#F1F3F4',
|
||||
},
|
||||
menuItemLeft: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
},
|
||||
menuIcon: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 8,
|
||||
iconContainer: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 6,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: 12,
|
||||
},
|
||||
menuItemText: {
|
||||
fontSize: 16,
|
||||
color: '#000',
|
||||
flex: 1,
|
||||
fontSize: 15,
|
||||
color: '#2C3E50',
|
||||
fontWeight: '500',
|
||||
},
|
||||
switch: {
|
||||
transform: [{ scaleX: 0.8 }, { scaleY: 0.8 }],
|
||||
},
|
||||
// 浮动按钮
|
||||
floatingButtonContainer: {
|
||||
position: 'absolute',
|
||||
bottom: 30,
|
||||
left: 0,
|
||||
right: 0,
|
||||
fishRecordContainer: {
|
||||
flexDirection: 'row',
|
||||
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,
|
||||
},
|
||||
|
||||
|
||||
});
|
||||
205
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 { Stack } from 'expo-router';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||
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() {
|
||||
const colorScheme = useColorScheme();
|
||||
const [loaded] = useFonts({
|
||||
SpaceMono: require('../assets/fonts/SpaceMono-Regular.ttf'),
|
||||
});
|
||||
@@ -18,14 +185,34 @@ export default function RootLayout() {
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
|
||||
<Stack>
|
||||
<Stack.Screen name="onboarding" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="ai-posture-assessment" options={{ headerShown: false }} />
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<Provider store={store}>
|
||||
<Bootstrapper>
|
||||
<ToastProvider>
|
||||
<ThemeProvider value={DefaultTheme}>
|
||||
<Stack screenOptions={{ headerShown: false }}>
|
||||
<Stack.Screen name="(tabs)" />
|
||||
<Stack.Screen name="challenge" options={{ headerShown: false }} />
|
||||
<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="auto" />
|
||||
<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 React, { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
Image,
|
||||
Linking,
|
||||
@@ -12,11 +13,14 @@ import {
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
View
|
||||
} from 'react-native';
|
||||
import ImageViewing from 'react-native-image-viewing';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useCosUpload } from '@/hooks/useCosUpload';
|
||||
|
||||
type PoseView = 'front' | 'side' | 'back';
|
||||
|
||||
@@ -30,17 +34,17 @@ type Sample = { uri: string; correct: boolean };
|
||||
|
||||
const SAMPLES: Record<PoseView, Sample[]> = {
|
||||
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-1571019614242-c5c5dee9f50b?w=400&q=80&auto=format', correct: false },
|
||||
],
|
||||
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-1526506118085-60ce8714f8c5?w=400&q=80&auto=format', correct: false },
|
||||
],
|
||||
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-1518611012118-696072aa579a?w=400&q=80&auto=format', correct: false },
|
||||
],
|
||||
@@ -49,7 +53,7 @@ const SAMPLES: Record<PoseView, Sample[]> = {
|
||||
export default function AIPostureAssessmentScreen() {
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const theme = Colors.dark;
|
||||
const theme = Colors.light;
|
||||
|
||||
const [uploadState, setUploadState] = useState<UploadState>({});
|
||||
const canStart = useMemo(
|
||||
@@ -57,6 +61,9 @@ export default function AIPostureAssessmentScreen() {
|
||||
[uploadState]
|
||||
);
|
||||
|
||||
const { upload, uploading } = useCosUpload();
|
||||
const [uploadingKey, setUploadingKey] = useState<PoseView | null>(null);
|
||||
|
||||
const [cameraPerm, setCameraPerm] = useState<ImagePicker.PermissionStatus | null>(null);
|
||||
const [libraryPerm, setLibraryPerm] = useState<ImagePicker.PermissionStatus | null>(null);
|
||||
const [libraryAccess, setLibraryAccess] = useState<'all' | 'limited' | 'none' | null>(null);
|
||||
@@ -127,7 +134,25 @@ export default function AIPostureAssessmentScreen() {
|
||||
aspect: [3, 4],
|
||||
});
|
||||
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 {
|
||||
const resp = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||
@@ -156,7 +181,25 @@ export default function AIPostureAssessmentScreen() {
|
||||
aspect: [3, 4],
|
||||
});
|
||||
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) {
|
||||
@@ -166,24 +209,13 @@ export default function AIPostureAssessmentScreen() {
|
||||
|
||||
function handleStart() {
|
||||
if (!canStart) return;
|
||||
// TODO: 调用后端或进入分析页面
|
||||
Alert.alert('开始测评', '已收集三视角照片,准备开始AI体态分析');
|
||||
// 进入评估中间页面
|
||||
router.push('/ai-posture-processing');
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[styles.screen, { backgroundColor: theme.background }]}>
|
||||
{/* Header */}
|
||||
<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>
|
||||
<View style={[styles.screen, { backgroundColor: Colors.light.pageBackgroundEmphasis }]}>
|
||||
<HeaderBar title="AI体态测评" onBack={() => router.back()} tone="light" transparent />
|
||||
|
||||
<ScrollView
|
||||
contentContainerStyle={{ paddingBottom: insets.bottom + 120 }}
|
||||
@@ -217,10 +249,8 @@ export default function AIPostureAssessmentScreen() {
|
||||
|
||||
{/* Intro */}
|
||||
<View style={styles.introBox}>
|
||||
<Text style={styles.title}>上传标准姿势照片</Text>
|
||||
<Text style={styles.description}>
|
||||
请依次上传正面、侧面与背面全身照。保持光线均匀、背景简洁,身体立正自然放松。
|
||||
</Text>
|
||||
<Text style={[styles.title, { color: '#192126' }]}>上传标准姿势照片</Text>
|
||||
<Text style={[styles.description, { color: '#5E6468' }]}>请依次上传正面、侧面与背面全身照。保持光线均匀、背景简洁,身体立正自然放松。</Text>
|
||||
</View>
|
||||
|
||||
{/* Upload sections */}
|
||||
@@ -230,6 +260,7 @@ export default function AIPostureAssessmentScreen() {
|
||||
onPickCamera={() => requestPermissionAndPick('camera', 'front')}
|
||||
onPickLibrary={() => requestPermissionAndPick('library', 'front')}
|
||||
samples={SAMPLES.front}
|
||||
uploading={uploading && uploadingKey === 'front'}
|
||||
/>
|
||||
|
||||
<UploadTile
|
||||
@@ -238,6 +269,7 @@ export default function AIPostureAssessmentScreen() {
|
||||
onPickCamera={() => requestPermissionAndPick('camera', 'side')}
|
||||
onPickLibrary={() => requestPermissionAndPick('library', 'side')}
|
||||
samples={SAMPLES.side}
|
||||
uploading={uploading && uploadingKey === 'side'}
|
||||
/>
|
||||
|
||||
<UploadTile
|
||||
@@ -246,6 +278,7 @@ export default function AIPostureAssessmentScreen() {
|
||||
onPickCamera={() => requestPermissionAndPick('camera', 'back')}
|
||||
onPickLibrary={() => requestPermissionAndPick('library', 'back')}
|
||||
samples={SAMPLES.back}
|
||||
uploading={uploading && uploadingKey === 'back'}
|
||||
/>
|
||||
</ScrollView>
|
||||
|
||||
@@ -275,13 +308,19 @@ function UploadTile({
|
||||
onPickCamera,
|
||||
onPickLibrary,
|
||||
samples,
|
||||
uploading,
|
||||
}: {
|
||||
label: string;
|
||||
value?: string | null;
|
||||
onPickCamera: () => void;
|
||||
onPickLibrary: () => void;
|
||||
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 (
|
||||
<View style={styles.section}>
|
||||
<View style={styles.sectionHeader}>
|
||||
@@ -298,13 +337,19 @@ function UploadTile({
|
||||
onLongPress={onPickLibrary}
|
||||
onPress={onPickCamera}
|
||||
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} />
|
||||
) : (
|
||||
<View style={styles.placeholder}>
|
||||
<View style={styles.plusBadge}>
|
||||
<Ionicons name="camera" size={16} color="#192126" />
|
||||
<Ionicons name="camera" size={16} color={Colors.light.accentGreen} />
|
||||
</View>
|
||||
<Text style={styles.placeholderTitle}>拍摄或选择照片</Text>
|
||||
<Text style={styles.placeholderDesc}>点击拍摄,长按从相册选择</Text>
|
||||
@@ -312,19 +357,27 @@ function UploadTile({
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
<BlurView intensity={18} tint="dark" style={styles.sampleBox}>
|
||||
<BlurView intensity={12} tint="light" style={styles.sampleBox}>
|
||||
<Text style={styles.sampleTitle}>示例</Text>
|
||||
<View style={styles.sampleRow}>
|
||||
{samples.map((s, idx) => (
|
||||
<View key={idx} style={styles.sampleItem}>
|
||||
<TouchableOpacity activeOpacity={0.9} onPress={() => { setViewerIndex(idx); setViewerVisible(true); }}>
|
||||
<Image source={{ uri: s.uri }} style={styles.sampleImg} />
|
||||
<View style={[styles.sampleTag, { backgroundColor: s.correct ? '#2BCC7F' : '#E24D4D' }]}>
|
||||
</TouchableOpacity>
|
||||
<View style={[styles.sampleTag, { backgroundColor: s.correct ? '#2BCC7F' : 'rgba(25,33,38,0.08)' }]}>
|
||||
<Text style={styles.sampleTagText}>{s.correct ? '正确示范' : '错误示范'}</Text>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</BlurView>
|
||||
<ImageViewing
|
||||
images={imagesForViewer}
|
||||
imageIndex={viewerIndex}
|
||||
visible={viewerVisible}
|
||||
onRequestClose={() => setViewerVisible(false)}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -338,15 +391,15 @@ const styles = StyleSheet.create({
|
||||
marginHorizontal: 16,
|
||||
padding: 14,
|
||||
borderRadius: 16,
|
||||
backgroundColor: 'rgba(255,255,255,0.04)'
|
||||
backgroundColor: 'rgba(25,33,38,0.06)'
|
||||
},
|
||||
permTitle: {
|
||||
color: '#ECEDEE',
|
||||
color: '#192126',
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
},
|
||||
permDesc: {
|
||||
color: 'rgba(255,255,255,0.75)',
|
||||
color: '#5E6468',
|
||||
marginTop: 6,
|
||||
fontSize: 13,
|
||||
},
|
||||
@@ -362,7 +415,7 @@ const styles = StyleSheet.create({
|
||||
paddingHorizontal: 14,
|
||||
height: 40,
|
||||
borderRadius: 12,
|
||||
backgroundColor: '#BBF246',
|
||||
backgroundColor: Colors.light.accentGreen,
|
||||
},
|
||||
permPrimaryText: {
|
||||
color: '#192126',
|
||||
@@ -377,10 +430,10 @@ const styles = StyleSheet.create({
|
||||
height: 40,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255,255,255,0.18)',
|
||||
borderColor: 'rgba(25,33,38,0.14)',
|
||||
},
|
||||
permSecondaryText: {
|
||||
color: 'rgba(255,255,255,0.85)',
|
||||
color: '#384046',
|
||||
fontSize: 14,
|
||||
fontWeight: '700',
|
||||
},
|
||||
@@ -430,12 +483,12 @@ const styles = StyleSheet.create({
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
sectionTitle: {
|
||||
color: '#ECEDEE',
|
||||
color: '#192126',
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
},
|
||||
retakeHint: {
|
||||
color: 'rgba(255,255,255,0.55)',
|
||||
color: '#888F92',
|
||||
fontSize: 13,
|
||||
},
|
||||
uploader: {
|
||||
@@ -443,8 +496,8 @@ const styles = StyleSheet.create({
|
||||
borderRadius: 18,
|
||||
borderWidth: 1,
|
||||
borderStyle: 'dashed',
|
||||
borderColor: 'rgba(255,255,255,0.18)',
|
||||
backgroundColor: '#1E262C',
|
||||
borderColor: 'rgba(25,33,38,0.14)',
|
||||
backgroundColor: '#FFFFFF',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
preview: {
|
||||
@@ -463,25 +516,27 @@ const styles = StyleSheet.create({
|
||||
borderRadius: 18,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: '#BBF246',
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderWidth: 2,
|
||||
borderColor: Colors.light.accentGreen,
|
||||
},
|
||||
placeholderTitle: {
|
||||
color: '#ECEDEE',
|
||||
color: '#192126',
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
},
|
||||
placeholderDesc: {
|
||||
color: 'rgba(255,255,255,0.65)',
|
||||
color: '#888F92',
|
||||
fontSize: 12,
|
||||
},
|
||||
sampleBox: {
|
||||
marginTop: 8,
|
||||
borderRadius: 16,
|
||||
padding: 12,
|
||||
backgroundColor: 'rgba(255,255,255,0.04)',
|
||||
backgroundColor: 'rgba(255,255,255,0.72)',
|
||||
},
|
||||
sampleTitle: {
|
||||
color: 'rgba(255,255,255,0.8)',
|
||||
color: '#192126',
|
||||
fontSize: 14,
|
||||
marginBottom: 8,
|
||||
fontWeight: '600',
|
||||
@@ -497,7 +552,7 @@ const styles = StyleSheet.create({
|
||||
width: '100%',
|
||||
height: 90,
|
||||
borderRadius: 12,
|
||||
backgroundColor: '#111',
|
||||
backgroundColor: '#F2F4F5',
|
||||
},
|
||||
sampleTag: {
|
||||
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 { ROUTES } from '@/constants/Routes';
|
||||
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 React, { useEffect, useState } from 'react';
|
||||
import { ActivityIndicator, Text, View } from 'react-native';
|
||||
import { ActivityIndicator, View } from 'react-native';
|
||||
|
||||
const ONBOARDING_COMPLETED_KEY = '@onboarding_completed';
|
||||
|
||||
@@ -18,25 +19,26 @@ export default function SplashScreen() {
|
||||
|
||||
const checkOnboardingStatus = async () => {
|
||||
try {
|
||||
const onboardingCompleted = await AsyncStorage.getItem(ONBOARDING_COMPLETED_KEY);
|
||||
// 先预加载用户数据,这样进入应用时就有正确的 token 状态
|
||||
console.log('开始预加载用户数据...');
|
||||
await preloadUserData();
|
||||
console.log('用户数据预加载完成');
|
||||
|
||||
// 添加一个短暂的延迟以显示启动画面
|
||||
setTimeout(() => {
|
||||
if (onboardingCompleted === 'true') {
|
||||
router.replace('/(tabs)');
|
||||
} else {
|
||||
router.replace('/onboarding');
|
||||
}
|
||||
setIsLoading(false);
|
||||
}, 1000);
|
||||
// const onboardingCompleted = await AsyncStorage.getItem(ONBOARDING_COMPLETED_KEY);
|
||||
|
||||
// if (onboardingCompleted === 'true') {
|
||||
// router.replace('/(tabs)');
|
||||
// } else {
|
||||
// router.replace('/onboarding');
|
||||
// }
|
||||
// setIsLoading(false);
|
||||
router.replace(ROUTES.TAB_STATISTICS);
|
||||
} catch (error) {
|
||||
console.error('检查引导状态失败:', error);
|
||||
// 如果出现错误,默认显示引导页面
|
||||
setTimeout(() => {
|
||||
router.replace('/onboarding');
|
||||
setIsLoading(false);
|
||||
}, 1000);
|
||||
console.error('检查引导状态或预加载用户数据失败:', error);
|
||||
// 如果出现错误,仍然进入应用,但可能会有状态更新
|
||||
router.replace(ROUTES.TAB_STATISTICS);
|
||||
}
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
if (!isLoading) {
|
||||
@@ -54,16 +56,12 @@ export default function SplashScreen() {
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: 40,
|
||||
backgroundColor: primaryColor,
|
||||
// backgroundColor: primaryColor,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: 20,
|
||||
}}>
|
||||
<Text style={{
|
||||
fontSize: 32,
|
||||
}}>
|
||||
🧘♀️
|
||||
</Text>
|
||||
|
||||
</View>
|
||||
<ActivityIndicator size="large" color={primaryColor} />
|
||||
</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,
|
||||
},
|
||||
});
|
||||